Compare commits
100 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
e4590f3ea9 | |
|
|
83c0b0e7ec | |
|
|
3a8559ecca | |
|
|
082892f413 | |
|
|
0b9586aee2 | |
|
|
c54d916361 | |
|
|
09c01b3445 | |
|
|
a089698247 | |
|
|
7344fc33b7 | |
|
|
2696fa4dfd | |
|
|
a6d6230553 | |
|
|
f537506166 | |
|
|
a9bb20dfe7 | |
|
|
6417e8e9f4 | |
|
|
22e2796a65 | |
|
|
52168bb5c3 | |
|
|
7f4befd73b | |
|
|
115c06e9bf | |
|
|
d2b8869eb4 | |
|
|
f72acc60ef | |
|
|
fc830ea921 | |
|
|
4538fe2ba8 | |
|
|
5774879331 | |
|
|
3e2f3f9585 | |
|
|
bf90e3279e | |
|
|
bce3258325 | |
|
|
7f282cdb84 | |
|
|
7cb4071a54 | |
|
|
e3dfc04192 | |
|
|
e7e4495306 | |
|
|
0fa8fb92eb | |
|
|
925ecb0e7c | |
|
|
e92307cf16 | |
|
|
d27d71ff72 | |
|
|
f7d9935fb9 | |
|
|
33d4946947 | |
|
|
0f58abf141 | |
|
|
0adaab5d84 | |
|
|
c55db0abd6 | |
|
|
c5643bed31 | |
|
|
6bcd2becd5 | |
|
|
2706ed6be0 | |
|
|
557604e426 | |
|
|
08353d2473 | |
|
|
80357e6a77 | |
|
|
d8eebb183a | |
|
|
c54f37349f | |
|
|
ca8c248870 | |
|
|
bf37e01eb0 | |
|
|
4709b9d1db | |
|
|
bf19eed20f | |
|
|
2fc66b9bf7 | |
|
|
439c74f0a3 | |
|
|
f486292f15 | |
|
|
4412583cd9 | |
|
|
81e7b6502d | |
|
|
3039b65946 | |
|
|
e2e2d1428f | |
|
|
80d66c98c4 | |
|
|
ff2a252e99 | |
|
|
5929a191f6 | |
|
|
6699c8cb58 | |
|
|
369ee504f3 | |
|
|
eeb091273d | |
|
|
d252a17586 | |
|
|
a9dc8bfb16 | |
|
|
82e6ec786f | |
|
|
f981c27e00 | |
|
|
f6503f5e6a | |
|
|
ad92d8856b | |
|
|
8b519619e5 | |
|
|
83afe95d61 | |
|
|
846cc348ff | |
|
|
508bb42ac9 | |
|
|
db74da3e37 | |
|
|
a02d35394b | |
|
|
b60563f2ec | |
|
|
703cbbdd48 | |
|
|
769b6cd278 | |
|
|
f500d7f956 | |
|
|
7257d5fb34 | |
|
|
09fb4e8b7b | |
|
|
21170fbe1b | |
|
|
1cd3dac177 | |
|
|
5815144d3a | |
|
|
f8aa3dcf13 | |
|
|
a26f88a9b6 | |
|
|
23dd403417 | |
|
|
ed718e1458 | |
|
|
4240269b7f | |
|
|
c186535bbb | |
|
|
fa90def995 | |
|
|
4d2c5cb22f | |
|
|
9401b677a3 | |
|
|
4bc89949ec | |
|
|
29ba864849 | |
|
|
aabba9c2d0 | |
|
|
886e78d6fb | |
|
|
af85834a5a | |
|
|
e26d90d7b6 |
|
|
@ -1,17 +1,18 @@
|
||||||
name: Build JavaScript assets
|
name: Search JS
|
||||||
|
|
||||||
on:
|
on: [workflow_dispatch, push, pull_request]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
run:
|
||||||
runs-on: ubuntu-latest
|
uses: flarum/framework/.github/workflows/REUSABLE_frontend.yml@main
|
||||||
|
with:
|
||||||
steps:
|
enable_bundlewatch: false
|
||||||
- uses: actions/checkout@master
|
enable_prettier: true
|
||||||
- uses: flarum/action-build@master
|
enable_typescript: false
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
|
frontend_directory: ./js
|
||||||
|
backend_directory: .
|
||||||
|
js_package_manager: npm
|
||||||
|
main_git_branch: main
|
||||||
|
secrets:
|
||||||
|
bundlewatch_github_token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ js/node_modules
|
||||||
js/dist
|
js/dist
|
||||||
vendor/
|
vendor/
|
||||||
composer.lock
|
composer.lock
|
||||||
|
analysis/*
|
||||||
|
|
|
||||||
166
README.md
166
README.md
|
|
@ -5,47 +5,177 @@ 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.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 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
|
## Installation
|
||||||
|
|
||||||
Use composer:
|
```bash
|
||||||
|
composer require sbp/flarum-search
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This fork is now maintained as `sbp/flarum-search`.
|
||||||
|
|
||||||
|
Intentional compatibility leftovers:
|
||||||
|
- settings keys remain `blomstra-search.*`
|
||||||
|
- some translation keys remain `blomstra-search.*`
|
||||||
|
- CLI command names may still remain `blomstra:*` until a later migration
|
||||||
|
|
||||||
|
Search behavior:
|
||||||
|
- title autocomplete enabled
|
||||||
|
- fuzzy title search enabled
|
||||||
|
- exact + fuzzy content search on full search page
|
||||||
|
- no content autocomplete in dropdown (performance decision)
|
||||||
|
``
|
||||||
|
|
||||||
|
Repository can be found here: https://gitea.muehlberger.net/sbp-jm/sbp-flarum-search
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Enable the extension in the admin area and configure the Elasticsearch endpoint and index name in the extension settings.
|
||||||
|
|
||||||
|
## Setting up the index
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require blomstra/search:*
|
php flarum blomstra:search:index build
|
||||||
|
php flarum queue:work
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable the extension inside the admin area and configure the settings.
|
### Subsequent rebuilds (zero-downtime)
|
||||||
|
|
||||||
### Set up
|
When you need to rebuild the full index (e.g. after a mapping change):
|
||||||
|
|
||||||
Enable the extension in your admin area. Now to seed your existing discussions use the following command:
|
```bash
|
||||||
|
# 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:build
|
php flarum blomstra:search:index build --keep-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
All mutations to discussions are automatically added and removed from the elasticsearch index.
|
After the queue drains, fill any gaps from content posted during the build:
|
||||||
|
|
||||||
### FAQ
|
```bash
|
||||||
|
php flarum blomstra:search:index fill
|
||||||
|
```
|
||||||
|
|
||||||
*I have another question.*
|
If you kept a backup and want to roll back:
|
||||||
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.
|
|
||||||
|
```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/).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "blomstra/search",
|
"name": "sbp/flarum-search",
|
||||||
"description": "Replaces Flarum search with one powered by an elastic search server.",
|
"description": "Replaces Flarum search with one powered by an elastic search server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"extension",
|
"extension",
|
||||||
|
|
@ -11,21 +11,25 @@
|
||||||
"elasticsearch"
|
"elasticsearch"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"email": "helpdesk@blomstra.net",
|
"email": "mueh+git@sbp.de",
|
||||||
"forum": "https://blomstra.community/t/ext-search"
|
"forum": "https://blomstra.community/t/ext-search"
|
||||||
},
|
},
|
||||||
"type": "flarum-extension",
|
"type": "flarum-extension",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"authors": [
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "sbp",
|
||||||
|
"email": "info@sbp.de"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Team Blomstra",
|
"name": "Team Blomstra",
|
||||||
"email": "development@blomstra.net",
|
"email": "development@blomstra.net",
|
||||||
"role": "Developer"
|
"role": "Original Developer"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">= 8.0",
|
"php": ">= 8.0",
|
||||||
"flarum/core": "^1.0.0",
|
"flarum/core": "^1.2.0",
|
||||||
"elasticsearch/elasticsearch": "7.*",
|
"elasticsearch/elasticsearch": "7.*",
|
||||||
"spatie/elasticsearch-query-builder": "^1.3.0"
|
"spatie/elasticsearch-query-builder": "^1.3.0"
|
||||||
},
|
},
|
||||||
|
|
@ -43,7 +47,7 @@
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Blomstra\\Search\\": "src/"
|
"Sbp\\Search\\": "src/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
extend.php
36
extend.php
|
|
@ -1,23 +1,41 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search;
|
/*
|
||||||
|
* 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 Sbp\Search;
|
||||||
|
|
||||||
use Flarum\Extend as Flarum;
|
use Flarum\Extend as Flarum;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
(new Flarum\ServiceProvider)->register(Provider::class),
|
(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\Routes('api'))
|
(new Flarum\Routes('api'))
|
||||||
->get('/blomstra/search/{type}', 'blomstra.search', Api\Controllers\SearchController::class)
|
->get('/sbp/search/{type}', 'sbp.search', Api\Controllers\SearchController::class)
|
||||||
->put('/blomstra/search/index', 'blomstra.search.index', Api\Controllers\IndexController::class),
|
->put('/sbp/search/index', 'sbp.search.index', Api\Controllers\IndexController::class),
|
||||||
|
(new Flarum\Console())
|
||||||
|
->command(Commands\BuildCommand::class),
|
||||||
|
|
||||||
(new Flarum\Console)
|
(new Flarum\Settings())
|
||||||
->command(Commands\BuildCommand::class)
|
->default('blomstra-search.search-discussion-subjects', true)
|
||||||
|
->default('blomstra-search.search-post-bodies', true)
|
||||||
|
->default('blomstra-search.min-search-length', Commands\BuildCommand::DEFAULT_MIN_SEARCH_LENGTH)
|
||||||
|
->default('blomstra-search.stem-exclusion', '')
|
||||||
|
->serializeToForum('blomstraSearchMinLength', 'blomstra-search.min-search-length', 'intval')
|
||||||
|
->serializeToForum('blomstraSearchPostBodies', 'blomstra-search.search-post-bodies', fn ($v) => (bool) $v, true),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
module.exports=function(e){var t={};function a(r){if(t[r])return t[r].exports;var n=t[r]={i:r,l:!1,exports:{}};return e[r].call(n.exports,n,n.exports,a),n.l=!0,n.exports}return a.m=e,a.c=t,a.d=function(e,t,r){a.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,t){if(1&t&&(e=a(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(a.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var n in e)a.d(r,n,function(t){return e[t]}.bind(null,n));return r},a.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(t,"a",t),t},a.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},a.p="",a(a.s=11)}({1:function(e,t){e.exports=flarum.core.compat["admin/app"]},11:function(e,t,a){"use strict";a.r(t);var r=a(1),n=a.n(r);n.a.initializers.add("blomstra-search",(function(){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)})),n.a.extensionData.for("blomstra-search").registerSetting({setting:"blomstra-search.elastic-endpoint",label:n.a.translator.trans("blomstra-search.admin.elastic-endpoint"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-username",label:n.a.translator.trans("blomstra-search.admin.elastic-username"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-password",label:n.a.translator.trans("blomstra-search.admin.elastic-password"),type:"password"}).registerSetting({setting:"blomstra-search.elastic-index",label:n.a.translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.analyzer-language",label:n.a.translator.trans("blomstra-search.admin.analyzer.label"),help:n.a.translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:Object.fromEntries(e.entries()),default:"english"}).registerSetting({setting:"blomstra-search.elastic-index",label:n.a.translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"})}))}});
|
(()=>{var a={n:t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return a.d(e,{a:e}),e},d:(t,e)=>{for(var n in e)a.o(e,n)&&!a.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},o:(a,t)=>Object.prototype.hasOwnProperty.call(a,t)};(()=>{"use strict";function t(a,e){return t=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(a,t){return a.__proto__=t,a},t(a,e)}const e=flarum.core.compat["admin/app"];var n=a.n(e);const r=flarum.core.compat["common/extend"],s=flarum.core.compat["admin/components/DashboardPage"];var i=a.n(s);const l=flarum.core.compat["admin/components/DashboardWidget"];var o=a.n(l);const c=flarum.core.compat["common/components/Alert"];var h=a.n(c),d=function(a){var e,r;function s(){return a.apply(this,arguments)||this}r=a,(e=s).prototype=Object.create(r.prototype),e.prototype.constructor=e,t(e,r);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:n().translator.trans("blomstra-search.admin.reindex-required.title")},n().translator.trans("blomstra-search.admin.reindex-required.detail"))},s}(o());n().initializers.add("sbp-search",function(){var a=n().data.settings["blomstra-search.active-index"],t=n().data.settings["blomstra-search.index-compatible"];a&&"v3"!==t&&(0,r.extend)(i().prototype,"availableWidgets",function(a){a.add("blomstra-search-reindex",m(d),110)}),n().extensionData.for("sbp-search").registerSetting(function(){var t=n().data.settings["blomstra-search.indexed-analyzer"];if(!a||!t)return null;var e=this.setting("blomstra-search.analyzer-language")()||"english",r=this.setting("blomstra-search.stem-exclusion")()||"",s=n().data.settings["blomstra-search.indexed-stem-exclusion"]||"";return e===t&&r===s?null:m(h(),{type:"warning",dismissible:!1,icon:"fas fa-exclamation-triangle"},n().translator.trans("blomstra-search.admin.index-settings-changed"))}).registerSetting({setting:"blomstra-search.elastic-endpoint",label:n().translator.trans("blomstra-search.admin.elastic-endpoint"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-username",label:n().translator.trans("blomstra-search.admin.elastic-username"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-password",label:n().translator.trans("blomstra-search.admin.elastic-password"),type:"password"}).registerSetting({setting:"blomstra-search.elastic-index",label:n().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.search-discussion-subjects",label:n().translator.trans("blomstra-search.admin.search-discussion-subjects"),type:"switch"}).registerSetting({setting:"blomstra-search.search-post-bodies",label:n().translator.trans("blomstra-search.admin.search-post-bodies"),type:"switch"}).registerSetting({setting:"blomstra-search.analyzer-language",label:n().translator.trans("blomstra-search.admin.analyzer.label"),help:n().translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:{arabic:"Arabic",armenian:"Armenian",basque:"Basque",bengali:"Bengali",brazilian:"Brazilian Portuguese",bulgarian:"Bulgarian",catalan:"Catalan",cjk:"CJK (Chinese, Japanese, Korean)",czech:"Czech",danish:"Danish",dutch:"Dutch",english:"English",estonian:"Estonian",finnish:"Finnish",french:"French",galician:"Galician",german:"German",greek:"Greek",hindi:"Hindi",hungarian:"Hungarian",indonesian:"Indonesian",irish:"Irish",italian:"Italian",latvian:"Latvian",lithuanian:"Lithuanian",norwegian:"Norwegian",persian:"Persian",portuguese:"Portuguese",romanian:"Romanian",russian:"Russian",sorani:"Sorani (Kurdish)",spanish:"Spanish",standard:"Standard (generic)",swedish:"Swedish",turkish:"Turkish",thai:"Thai"},default:"english"}).registerSetting(function(){var a="cjk"===(this.setting("blomstra-search.analyzer-language")()||"english");return this.buildSettingComponent({setting:"blomstra-search.stem-exclusion",type:"textarea",label:n().translator.trans("blomstra-search.admin.settings.stem-exclusion.label"),help:n().translator.trans("blomstra-search.admin.settings.stem-exclusion.help"),disabled:a})}).registerSetting(function(){var a="cjk"===(this.setting("blomstra-search.analyzer-language")()||"english");return this.buildSettingComponent({setting:"blomstra-search.min-search-length",label:n().translator.trans("blomstra-search.admin.min-search-length.label"),help:n().translator.trans("blomstra-search.admin.min-search-length.help"),type:"select",options:{1:"1",2:"2",3:"3",4:"4"},default:n().data.settings["blomstra-search.min-search-length"],disabled:a})})})})(),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
|
|
@ -0,0 +1 @@
|
||||||
|
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
|
@ -1,16 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "@blomstra/realtime",
|
"name": "@blomstra/search",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"prettier": "@flarum/prettier-config",
|
"prettier": "@flarum/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@flarum/prettier-config": "^1.0.0",
|
"@flarum/prettier-config": "^1.0.0",
|
||||||
"build": "^0.1.4",
|
|
||||||
"flarum-tsconfig": "^1.0.2",
|
"flarum-tsconfig": "^1.0.2",
|
||||||
"flarum-webpack-config": "^1.0.0",
|
"flarum-webpack-config": "^2.0.0",
|
||||||
"pusher-js": "^7.0.3",
|
"webpack": "^5.75.0",
|
||||||
"webpack": "^4.46.0",
|
"webpack-cli": "^5.0.0"
|
||||||
"webpack-cli": "^4.9.1"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack --mode development --watch",
|
"dev": "webpack --mode development --watch",
|
||||||
|
|
@ -19,6 +17,6 @@
|
||||||
"format-check": "prettier --check src"
|
"format-check": "prettier --check src"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^2.4.1"
|
"prettier": "^2.7.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,157 @@
|
||||||
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';
|
||||||
|
|
||||||
app.initializers.add('blomstra-search', () => {
|
const REQUIRED_INDEX_COMPAT = 'v3';
|
||||||
const languages = 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(language => {
|
|
||||||
languages.set(language, language);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.extensionData
|
class ReindexWarningWidget extends DashboardWidget {
|
||||||
.for('blomstra-search')
|
className() {
|
||||||
.registerSetting({
|
return 'ReindexWarningWidget';
|
||||||
setting: 'blomstra-search.elastic-endpoint',
|
}
|
||||||
label: app.translator.trans('blomstra-search.admin.elastic-endpoint'),
|
|
||||||
type: 'input'
|
content() {
|
||||||
})
|
return m(
|
||||||
.registerSetting({
|
Alert,
|
||||||
setting: 'blomstra-search.elastic-username',
|
{
|
||||||
label: app.translator.trans('blomstra-search.admin.elastic-username'),
|
type: 'warning',
|
||||||
type: 'input'
|
dismissible: false,
|
||||||
})
|
icon: 'fas fa-exclamation-triangle',
|
||||||
.registerSetting({
|
title: app.translator.trans('blomstra-search.admin.reindex-required.title'),
|
||||||
setting: 'blomstra-search.elastic-password',
|
},
|
||||||
label: app.translator.trans('blomstra-search.admin.elastic-password'),
|
app.translator.trans('blomstra-search.admin.reindex-required.detail')
|
||||||
type: 'password'
|
);
|
||||||
})
|
}
|
||||||
.registerSetting({
|
}
|
||||||
setting: 'blomstra-search.elastic-index',
|
|
||||||
label: app.translator.trans('blomstra-search.admin.elastic-index'),
|
app.initializers.add('sbp-search', () => {
|
||||||
default: 'flarum',
|
const activeIndex = app.data.settings['blomstra-search.active-index'];
|
||||||
type: 'input'
|
const compatVersion = app.data.settings['blomstra-search.index-compatible'];
|
||||||
})
|
|
||||||
.registerSetting({
|
if (activeIndex && compatVersion !== REQUIRED_INDEX_COMPAT) {
|
||||||
setting: 'blomstra-search.analyzer-language',
|
extend(DashboardPage.prototype, 'availableWidgets', function (items) {
|
||||||
label: app.translator.trans('blomstra-search.admin.analyzer.label'),
|
items.add('blomstra-search-reindex', m(ReindexWarningWidget), 110);
|
||||||
help: app.translator.trans('blomstra-search.admin.analyzer.help'),
|
});
|
||||||
type: 'select',
|
}
|
||||||
options: Object.fromEntries(languages.entries()),
|
|
||||||
default: 'english',
|
const languages: Record<string, string> = {
|
||||||
})
|
arabic: 'Arabic',
|
||||||
.registerSetting({
|
armenian: 'Armenian',
|
||||||
setting: 'blomstra-search.elastic-index',
|
basque: 'Basque',
|
||||||
label: app.translator.trans('blomstra-search.admin.elastic-index'),
|
bengali: 'Bengali',
|
||||||
default: 'flarum',
|
brazilian: 'Brazilian Portuguese',
|
||||||
type: 'input'
|
bulgarian: 'Bulgarian',
|
||||||
})
|
catalan: 'Catalan',
|
||||||
|
cjk: 'CJK (Chinese, Japanese, Korean)',
|
||||||
|
czech: 'Czech',
|
||||||
|
danish: 'Danish',
|
||||||
|
dutch: 'Dutch',
|
||||||
|
english: 'English',
|
||||||
|
estonian: 'Estonian',
|
||||||
|
finnish: 'Finnish',
|
||||||
|
french: 'French',
|
||||||
|
galician: 'Galician',
|
||||||
|
german: 'German',
|
||||||
|
greek: 'Greek',
|
||||||
|
hindi: 'Hindi',
|
||||||
|
hungarian: 'Hungarian',
|
||||||
|
indonesian: 'Indonesian',
|
||||||
|
irish: 'Irish',
|
||||||
|
italian: 'Italian',
|
||||||
|
latvian: 'Latvian',
|
||||||
|
lithuanian: 'Lithuanian',
|
||||||
|
norwegian: 'Norwegian',
|
||||||
|
persian: 'Persian',
|
||||||
|
portuguese: 'Portuguese',
|
||||||
|
romanian: 'Romanian',
|
||||||
|
russian: 'Russian',
|
||||||
|
sorani: 'Sorani (Kurdish)',
|
||||||
|
spanish: 'Spanish',
|
||||||
|
standard: 'Standard (generic)',
|
||||||
|
swedish: 'Swedish',
|
||||||
|
turkish: 'Turkish',
|
||||||
|
thai: 'Thai',
|
||||||
|
};
|
||||||
|
|
||||||
|
app.extensionData
|
||||||
|
.for('sbp-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 currentStemExclusion = this.setting('blomstra-search.stem-exclusion')() || '';
|
||||||
|
const indexedStemExclusion = app.data.settings['blomstra-search.indexed-stem-exclusion'] || '';
|
||||||
|
|
||||||
|
if (currentAnalyzer === indexedAnalyzer && currentStemExclusion === indexedStemExclusion) return null;
|
||||||
|
|
||||||
|
return m(
|
||||||
|
Alert,
|
||||||
|
{ type: 'warning', dismissible: false, icon: 'fas fa-exclamation-triangle' },
|
||||||
|
app.translator.trans('blomstra-search.admin.index-settings-changed')
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.registerSetting({
|
||||||
|
setting: 'blomstra-search.elastic-endpoint',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.elastic-endpoint'),
|
||||||
|
type: 'input',
|
||||||
|
})
|
||||||
|
.registerSetting({
|
||||||
|
setting: 'blomstra-search.elastic-username',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.elastic-username'),
|
||||||
|
type: 'input',
|
||||||
|
})
|
||||||
|
.registerSetting({
|
||||||
|
setting: 'blomstra-search.elastic-password',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.elastic-password'),
|
||||||
|
type: 'password',
|
||||||
|
})
|
||||||
|
.registerSetting({
|
||||||
|
setting: 'blomstra-search.elastic-index',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.elastic-index'),
|
||||||
|
default: 'flarum',
|
||||||
|
type: 'input',
|
||||||
|
})
|
||||||
|
.registerSetting({
|
||||||
|
setting: 'blomstra-search.search-discussion-subjects',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.search-discussion-subjects'),
|
||||||
|
type: 'switch',
|
||||||
|
})
|
||||||
|
.registerSetting({
|
||||||
|
setting: 'blomstra-search.search-post-bodies',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.search-post-bodies'),
|
||||||
|
type: 'switch',
|
||||||
|
})
|
||||||
|
.registerSetting({
|
||||||
|
setting: 'blomstra-search.analyzer-language',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.analyzer.label'),
|
||||||
|
help: app.translator.trans('blomstra-search.admin.analyzer.help'),
|
||||||
|
type: 'select',
|
||||||
|
options: languages,
|
||||||
|
default: 'english',
|
||||||
|
})
|
||||||
|
.registerSetting(function (this: any) {
|
||||||
|
const isCjk = (this.setting('blomstra-search.analyzer-language')() || 'english') === 'cjk';
|
||||||
|
return this.buildSettingComponent({
|
||||||
|
setting: 'blomstra-search.stem-exclusion',
|
||||||
|
type: 'textarea',
|
||||||
|
label: app.translator.trans('blomstra-search.admin.settings.stem-exclusion.label'),
|
||||||
|
help: app.translator.trans('blomstra-search.admin.settings.stem-exclusion.help'),
|
||||||
|
disabled: isCjk,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.registerSetting(function (this: any) {
|
||||||
|
const isCjk = (this.setting('blomstra-search.analyzer-language')() || 'english') === 'cjk';
|
||||||
|
return this.buildSettingComponent({
|
||||||
|
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'],
|
||||||
|
disabled: isCjk,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,33 @@
|
||||||
import app from 'flarum/forum/app';
|
import app from 'flarum/forum/app';
|
||||||
|
|
||||||
import {override} from 'flarum/common/extend';
|
import { override } from 'flarum/common/extend';
|
||||||
|
|
||||||
import DiscussionListState from 'flarum/forum/states/DiscussionListState';
|
import DiscussionListState from 'flarum/forum/states/DiscussionListState';
|
||||||
|
|
||||||
export default function extendDiscussionState() {
|
export default function extendDiscussionState() {
|
||||||
override(DiscussionListState.prototype, 'loadPage', async function (this: DiscussionListState, original, page: number = 1) {
|
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 existing payload is given or no search is made, fallback on native page.
|
||||||
if (preloaded || !this.requestParams()?.filter?.q) return original.call(this, page);
|
if (!this.requestParams()?.filter?.q) return original.call(this, page);
|
||||||
|
|
||||||
const params = this.requestParams();
|
const params = this.requestParams();
|
||||||
params.page = {
|
params.page = {
|
||||||
offset: this.pageSize * (page - 1),
|
offset: this.pageSize * (page - 1),
|
||||||
...params.page,
|
...params.page,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(params.include)) {
|
|
||||||
params.include = params.include.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct API search URI
|
if (Array.isArray(params.include)) {
|
||||||
const url = `${app.forum.attribute('apiUrl')}/blomstra/search/${this.type}`;
|
params.include = params.include.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
// Make API GET request
|
// Construct API search URI
|
||||||
const results = await app.request({params, url, method: 'GET'});
|
const url = `${app.forum.attribute('apiUrl')}/sbp/search/${this.type}`;
|
||||||
|
|
||||||
// Parse API response into models and push to store
|
// Make API GET request
|
||||||
return app.store.pushPayload(results);
|
const results = await app.request({ params, url, method: 'GET' });
|
||||||
});
|
|
||||||
|
// Parse API response into models and push to store
|
||||||
|
return app.store.pushPayload(results);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,18 +21,21 @@ export default class DiscussionsSearchSource implements SearchSource {
|
||||||
private type = 'discussions';
|
private type = 'discussions';
|
||||||
|
|
||||||
async search(query: string): Promise<void> {
|
async search(query: string): Promise<void> {
|
||||||
|
// Suppress ES instant results when "search within comments" is ON;
|
||||||
|
// only usernames are shown in that mode.
|
||||||
|
|
||||||
query = query.toLowerCase();
|
query = query.toLowerCase();
|
||||||
|
|
||||||
this.results.set(query, []);
|
this.results.set(query, []);
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
filter: { q: query },
|
filter: { q: query, autocomplete: 1 },
|
||||||
page: { limit: 3 },
|
page: { limit: 3 },
|
||||||
include: 'mostRelevantPost',
|
include: 'mostRelevantPost',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Construct API search URI
|
// Construct API search URI
|
||||||
const url = `${app.forum.attribute('apiUrl')}/blomstra/search/${this.type}`;
|
const url = `${app.forum.attribute('apiUrl')}/sbp/search/${this.type}`;
|
||||||
|
|
||||||
// Make API GET request
|
// Make API GET request
|
||||||
const results = await app.request({ params, url, method: 'GET' });
|
const results = await app.request({ params, url, method: 'GET' });
|
||||||
|
|
@ -44,7 +47,7 @@ export default class DiscussionsSearchSource implements SearchSource {
|
||||||
this.results.set(query, models);
|
this.results.set(query, models);
|
||||||
}
|
}
|
||||||
|
|
||||||
view(query: string): Mithril.Children {
|
view(query: string): Array<Mithril.Vnode> {
|
||||||
query = query.toLowerCase();
|
query = query.toLowerCase();
|
||||||
|
|
||||||
// Get results from map
|
// Get results from map
|
||||||
|
|
@ -54,7 +57,7 @@ export default class DiscussionsSearchSource implements SearchSource {
|
||||||
const mostRelevantPost = discussion.mostRelevantPost();
|
const mostRelevantPost = discussion.mostRelevantPost();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="DiscussionSearchResult" data-index={`${this.type}${discussion.id()}`} key={`${this.type}${discussion.id()}`}>
|
<li className="DiscussionSearchResult" data-index={`${this.type}${discussion.id()}`}>
|
||||||
<Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
|
<Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
|
||||||
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||||
{!!mostRelevantPost && <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div>}
|
{!!mostRelevantPost && <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div>}
|
||||||
|
|
@ -63,16 +66,14 @@ export default class DiscussionsSearchSource implements SearchSource {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return [
|
||||||
<>
|
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
|
||||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>
|
<li>
|
||||||
<li>
|
<LinkButton icon="fas fa-search" href={app.route('index', { q: query })}>
|
||||||
<LinkButton icon="fas fa-search" href={app.route('index', { q: query })}>
|
{app.translator.trans('core.forum.search.all_discussions_button', { query })}
|
||||||
{app.translator.trans('core.forum.search.all_discussions_button', { query })}
|
</LinkButton>
|
||||||
</LinkButton>
|
</li>,
|
||||||
</li>
|
...results,
|
||||||
{results}
|
];
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { extend } from 'flarum/common/extend';
|
||||||
|
import DiscussionListItem from 'flarum/forum/components/DiscussionListItem';
|
||||||
|
import TerminalPost from 'flarum/forum/components/TerminalPost';
|
||||||
|
|
||||||
|
export default function extendDiscussionListItem() {
|
||||||
|
extend(DiscussionListItem.prototype, 'infoItems', function (items) {
|
||||||
|
const params = this.attrs.params;
|
||||||
|
|
||||||
|
if (!params.q) return;
|
||||||
|
|
||||||
|
const hasFieldSort = params.sort && params.sort !== 'relevance';
|
||||||
|
|
||||||
|
if (hasFieldSort) {
|
||||||
|
// Field sort active (latest, oldest, top, …): replace excerpt with TerminalPost.
|
||||||
|
// An excerpt is meaningless when results are ordered by date/count rather than relevance.
|
||||||
|
items.remove('excerpt');
|
||||||
|
if (!items.has('terminalPost')) {
|
||||||
|
items.add('terminalPost', <TerminalPost discussion={this.attrs.discussion} lastPost={!this.showFirstPost()} />);
|
||||||
|
}
|
||||||
|
} else if (!items.has('excerpt')) {
|
||||||
|
// Relevance mode but no excerpt (mostRelevantPost was null or non-comment type).
|
||||||
|
// Fall back to TerminalPost so the info section is never silently empty.
|
||||||
|
items.add('terminalPost', <TerminalPost discussion={this.attrs.discussion} lastPost={true} />);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,32 @@
|
||||||
import app from 'flarum/forum/app';
|
import app from 'flarum/forum/app';
|
||||||
|
|
||||||
import Search from 'flarum/forum/components/Search';
|
import Search, { SearchAttrs, SearchSource } from 'flarum/forum/components/Search';
|
||||||
|
|
||||||
import { extend } from 'flarum/common/extend';
|
import { extend } from 'flarum/common/extend';
|
||||||
import ItemList from 'flarum/common/utils/ItemList';
|
import ItemList from 'flarum/common/utils/ItemList';
|
||||||
|
|
||||||
import DiscussionsSearchSource from './SearchSources/DiscussionsSearchSource';
|
import DiscussionsSearchSource from './SearchSources/DiscussionsSearchSource';
|
||||||
import extendDiscussionState from './PaginatedListStates/extendDiscussionState';
|
import extendDiscussionState from './PaginatedListStates/extendDiscussionState';
|
||||||
|
import extendDiscussionListItem from './extendDiscussionListItem';
|
||||||
|
|
||||||
app.initializers.add('blomstra-search', () => {
|
app.initializers.add('blomstra-search', () => {
|
||||||
extend(Search.prototype, 'sourceItems', function (this: Search, items: ItemList) {
|
extend(Search.prototype, 'sourceItems', function (this: Search<SearchAttrs>, items: ItemList<SearchSource>) {
|
||||||
// items.remove('users');
|
// app.forum is not available during initializers (it is set after they run),
|
||||||
items.remove('discussions');
|
// so read the setting lazily here, at first render time.
|
||||||
|
const minLength = (app.forum.attribute('blomstraSearchMinLength') as number) || Search.MIN_SEARCH_LEN;
|
||||||
|
if (minLength !== Search.MIN_SEARCH_LEN) {
|
||||||
|
(Search as any).MIN_SEARCH_LEN = minLength;
|
||||||
|
}
|
||||||
|
|
||||||
items.add('discussions', new DiscussionsSearchSource());
|
items.replace('discussions', new DiscussionsSearchSource());
|
||||||
});
|
});
|
||||||
|
|
||||||
extendDiscussionState();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.initializers.add(
|
||||||
|
'blomstra-search-early',
|
||||||
|
() => {
|
||||||
|
extendDiscussionState();
|
||||||
|
extendDiscussionListItem();
|
||||||
|
},
|
||||||
|
999999
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
diff --git a/src/Commands/BuildCommand.php b/src/Commands/BuildCommand.php
|
||||||
|
index 9024e1e..9024e1e 100644
|
||||||
|
--- a/src/Commands/BuildCommand.php
|
||||||
|
+++ b/src/Commands/BuildCommand.php
|
||||||
|
@@ -38,6 +38,6 @@ class BuildCommand extends Command
|
||||||
|
/** Matches Flarum's Search::MIN_SEARCH_LEN — the default minimum query length. */
|
||||||
|
public const DEFAULT_MIN_SEARCH_LENGTH = 3;
|
||||||
|
/** N-gram bounds for the title autocomplete subfield (not admin-configurable). */
|
||||||
|
- private const TITLE_MIN_GRAM = 2;
|
||||||
|
- private const TITLE_MAX_GRAM = 15;
|
||||||
|
+ private const TITLE_MIN_GRAM = 3;
|
||||||
|
+ private const TITLE_MAX_GRAM = 20;
|
||||||
|
|
||||||
|
protected $signature = 'blomstra:search:index
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
.ReindexWarningWidget {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
@ -8,4 +8,21 @@ 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-post-bodies: Search inside comments
|
||||||
|
match-sentences: Match search term against full sentence
|
||||||
|
match-words: Match search term against full words
|
||||||
|
settings:
|
||||||
|
stem-exclusion:
|
||||||
|
label: "Protected words (stem exclusion)"
|
||||||
|
help: "One word per line. These words will not be stemmed during search (e.g. brand names, proper nouns). Not applicable for CJK or Thai languages. Requires a full re-index to take effect."
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Api;
|
/*
|
||||||
|
* 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 Sbp\Search\Api;
|
||||||
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
@ -9,7 +19,9 @@ class Client extends \Flarum\Api\Client
|
||||||
{
|
{
|
||||||
public function get(string $path): ResponseInterface
|
public function get(string $path): ResponseInterface
|
||||||
{
|
{
|
||||||
if ($path === '/discussions' && Arr::has($this->queryParams, 'filter.q')) return parent::get("/blomstra/search/discussions");
|
if ($path === '/discussions' && Arr::has($this->queryParams, 'filter.q')) {
|
||||||
|
return parent::get('/blomstra/search/discussions');
|
||||||
|
}
|
||||||
|
|
||||||
return parent::get($path);
|
return parent::get($path);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Api\Controllers;
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
use Blomstra\Search\Commands\BuildCommand;
|
namespace Sbp\Search\Api\Controllers;
|
||||||
|
|
||||||
|
use Sbp\Search\Commands\BuildCommand;
|
||||||
use Laminas\Diactoros\Response\EmptyResponse;
|
use Laminas\Diactoros\Response\EmptyResponse;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
@ -23,7 +33,8 @@ class IndexController implements RequestHandlerInterface
|
||||||
|
|
||||||
$command->run(
|
$command->run(
|
||||||
new ArrayInput([]),
|
new ArrayInput([]),
|
||||||
new ConsoleOutput);
|
new ConsoleOutput()
|
||||||
|
);
|
||||||
|
|
||||||
return new EmptyResponse();
|
return new EmptyResponse();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,24 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Api\Controllers;
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
use Blomstra\Search\Elasticsearch\MatchPhraseQuery;
|
namespace Sbp\Search\Api\Controllers;
|
||||||
use Blomstra\Search\Elasticsearch\MatchQuery;
|
|
||||||
use Blomstra\Search\Save\Document as ElasticDocument;
|
use Sbp\Search\Elasticsearch\HasChildQuery;
|
||||||
use Blomstra\Search\Elasticsearch\TermsQuery;
|
use Sbp\Search\Elasticsearch\MatchPhraseQuery;
|
||||||
|
use Sbp\Search\Elasticsearch\MatchQuery;
|
||||||
|
use Sbp\Search\Elasticsearch\TermsQuery;
|
||||||
|
use Sbp\Search\Searchers\CommentPostSearcher;
|
||||||
|
use Sbp\Search\Searchers\DiscussionSearcher;
|
||||||
|
use Sbp\Search\Searchers\Searcher;
|
||||||
use Elasticsearch\Client;
|
use Elasticsearch\Client;
|
||||||
use Flarum\Api\Controller\ListDiscussionsController;
|
use Flarum\Api\Controller\ListDiscussionsController;
|
||||||
use Flarum\Api\Serializer\DiscussionSerializer;
|
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
|
|
@ -14,15 +27,17 @@ use Flarum\Extension\ExtensionManager;
|
||||||
use Flarum\Group\Group;
|
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\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\Builder;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
use Sbp\Search\Elasticsearch\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;
|
||||||
|
|
@ -33,93 +48,135 @@ class SearchController extends ListDiscussionsController
|
||||||
|
|
||||||
protected array $translateSort = [
|
protected array $translateSort = [
|
||||||
'lastPostedAt' => 'updated_at',
|
'lastPostedAt' => 'updated_at',
|
||||||
'createdAt' => 'created_at',
|
'createdAt' => 'created_at',
|
||||||
'commentCount' => 'comment_count'
|
'commentCount' => 'comment_count',
|
||||||
|
'view_count' => 'view_count',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(protected Client $elastic, protected UrlGenerator $uri)
|
protected bool $matchSentences;
|
||||||
{}
|
protected bool $matchWords;
|
||||||
|
protected ?Searcher $discussionSearcher;
|
||||||
|
protected ?Searcher $postSearcher;
|
||||||
|
|
||||||
|
public function __construct(protected Client $elastic, protected UrlGenerator $uri, Container $container, SettingsRepositoryInterface $settings)
|
||||||
|
{
|
||||||
|
$this->matchSentences = true;
|
||||||
|
$this->matchWords = true;
|
||||||
|
|
||||||
|
$searchers = collect($container->tagged('blomstra.search.searchers'));
|
||||||
|
|
||||||
|
$this->discussionSearcher = $searchers->first(fn ($s) => $s instanceof DiscussionSearcher);
|
||||||
|
$this->postSearcher = $searchers->first(fn ($s) => $s instanceof CommentPostSearcher);
|
||||||
|
}
|
||||||
|
|
||||||
protected function data(ServerRequestInterface $request, Document $document)
|
protected function data(ServerRequestInterface $request, Document $document)
|
||||||
{
|
{
|
||||||
// Not used for now.
|
$actor = RequestUtil::getActor($request);
|
||||||
$type = Arr::get($request->getQueryParams(), 'type');
|
|
||||||
|
|
||||||
$actor = RequestUtil::getActor($request);
|
|
||||||
|
|
||||||
$filters = $this->extractFilter($request);
|
$filters = $this->extractFilter($request);
|
||||||
|
$search = $this->getSearch($filters);
|
||||||
$search = $this->getSearch($filters);
|
$limit = $this->extractLimit($request);
|
||||||
|
$offset = $this->extractOffset($request);
|
||||||
$limit = $this->extractLimit($request);
|
|
||||||
$offset = $this->extractOffset($request);
|
|
||||||
|
|
||||||
$include = array_merge($this->extractInclude($request), ['state']);
|
$include = array_merge($this->extractInclude($request), ['state']);
|
||||||
|
|
||||||
$filterQuery = BoolQuery::create();
|
// Autocomplete path: fast prefix match on title.autocomplete only.
|
||||||
|
if (!empty($filters['autocomplete']) && !empty($search)) {
|
||||||
if (! empty($search)) {
|
return $this->handleAutocomplete($search, $actor, $limit, $offset, $include, $document, $request);
|
||||||
$filterQuery
|
|
||||||
// @todo commented out to use only partial matching for now
|
|
||||||
->add($this->sentenceMatch($search))
|
|
||||||
->add($this->wordMatch($search, 'and'))
|
|
||||||
->add($this->wordMatch($search, 'or'))
|
|
||||||
// ->add($this->partialMatch($search))
|
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$knownSortFields = array_merge(array_values($this->translateSort), ['rawId']);
|
||||||
|
$logger = resolve(LoggerInterface::class);
|
||||||
|
|
||||||
|
$phpSortField = null;
|
||||||
|
$phpSortDir = 'desc';
|
||||||
|
$needsScoring = true;
|
||||||
|
$sorts = [];
|
||||||
|
|
||||||
|
foreach ($this->extractSort($request) as $field => $direction) {
|
||||||
|
$translated = $this->translateSort[$field] ?? $field;
|
||||||
|
|
||||||
|
if (!in_array($translated, $knownSortFields)) {
|
||||||
|
$logger->warning("blomstra/search: unknown sort field \"{$field}\", ignoring.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sorts[] = new Sort($translated, $direction);
|
||||||
|
$needsScoring = false;
|
||||||
|
|
||||||
|
if ($phpSortField === null && $translated !== 'rawId') {
|
||||||
|
$phpSortField = $translated;
|
||||||
|
$phpSortDir = $direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to latest when no explicit sort is requested. This lets has_child use
|
||||||
|
// score_mode:none, which skips child scoring entirely and lets ES short-circuit
|
||||||
|
// early on large corpora. Relevance is still available via sort=relevant if added.
|
||||||
|
if (empty($sorts)) {
|
||||||
|
$needsScoring = false;
|
||||||
|
$phpSortField = 'updated_at';
|
||||||
|
$phpSortDir = 'desc';
|
||||||
|
$sorts[] = new Sort('updated_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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)) {
|
||||||
|
$query->add($this->buildTextQuery($search, $actor, $needsScoring));
|
||||||
|
}
|
||||||
|
|
||||||
|
$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)
|
->addQuery($query);
|
||||||
->from($this->extractOffset($request))
|
|
||||||
->addQuery(
|
|
||||||
$this->addFilters($filterQuery, $actor, $filters)
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($this->extractSort($request) as $field => $direction) {
|
foreach ($sorts as $sort) {
|
||||||
$field = $this->translateSort[$field] ?? $field;
|
$builder->addSort($sort);
|
||||||
$builder->addSort(new Sort($field, $direction));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $builder->search();
|
// track_total_hits: false lets ES stop counting once it has collected
|
||||||
|
// enough results in sort order, avoiding a full-index count on every query.
|
||||||
|
$payload = $builder->getPayload();
|
||||||
|
$payload['track_total_hits'] = false;
|
||||||
|
|
||||||
|
$response = $this->elastic->search([
|
||||||
|
'index' => resolve('blomstra.search.elastic_index'),
|
||||||
|
'size' => $limit + 1,
|
||||||
|
'from' => $offset,
|
||||||
|
'body' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
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,
|
if (!in_array('mostRelevantPost', $include)) {
|
||||||
// add it so the code below can look for it
|
|
||||||
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:');
|
||||||
|
|
||||||
if ($type === 'posts') {
|
// rawId on the inner hit gives us the integer post ID.
|
||||||
return [
|
$bestPostId = Arr::get($hit, 'inner_hits.best_post.hits.hits.0._source.rawId');
|
||||||
'most_relevant_post_id' => $id,
|
|
||||||
'weight' => Arr::get($hit, 'sort.0')
|
return [
|
||||||
];
|
'discussion_id' => $discussionId,
|
||||||
} else {
|
'most_relevant_post_id' => $bestPostId,
|
||||||
return [
|
'weight' => Arr::get($hit, 'sort.0', Arr::get($hit, '_score', 0)),
|
||||||
'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('sbp.search', ['type' => 'discussions']),
|
||||||
'type' => 'discussions'
|
|
||||||
]),
|
|
||||||
$request->getQueryParams(),
|
$request->getQueryParams(),
|
||||||
$offset,
|
$offset,
|
||||||
$limit,
|
$limit,
|
||||||
|
|
@ -129,28 +186,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->hasPermission('discussion.hide'), fn($query) => $query->whereNull('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);
|
||||||
|
|
@ -168,33 +222,208 @@ class SearchController extends ListDiscussionsController
|
||||||
return $discussions;
|
return $discussions;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getDocument(string $type): ?ElasticDocument
|
/**
|
||||||
{
|
* Fast autocomplete path: match prefix n-grams on title.autocomplete only.
|
||||||
$documents = resolve(Container::class)->tagged('blomstra.search.documents');
|
* No has_child, no phrase scoring — a single MatchQuery suffices.
|
||||||
|
*/
|
||||||
|
protected function handleAutocomplete(
|
||||||
|
string $search,
|
||||||
|
User $actor,
|
||||||
|
int $limit,
|
||||||
|
int $offset,
|
||||||
|
array $include,
|
||||||
|
Document $document,
|
||||||
|
ServerRequestInterface $request
|
||||||
|
): mixed {
|
||||||
|
$query = BoolQuery::create()
|
||||||
|
->add(TermQuery::create('join_field', 'discussion'), 'filter');
|
||||||
|
|
||||||
return collect($documents)->first(function (ElasticDocument $document) use ($type) {
|
$query->add($this->buildAutocompleteQuery($search), 'must');
|
||||||
return $document->type() === $type;
|
|
||||||
});
|
$this->addFilters($query, $actor, []);
|
||||||
|
|
||||||
|
$payload = (new Builder($this->elastic))
|
||||||
|
->index(resolve('blomstra.search.elastic_index'))
|
||||||
|
->addQuery($query)
|
||||||
|
->addSort(new Sort('updated_at', 'desc'))
|
||||||
|
->getPayload();
|
||||||
|
|
||||||
|
$response = $this->elastic->search([
|
||||||
|
'index' => resolve('blomstra.search.elastic_index'),
|
||||||
|
'size' => $limit + 1,
|
||||||
|
'from' => $offset,
|
||||||
|
'body' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Discussion::setStateUser($actor);
|
||||||
|
|
||||||
|
$results = Collection::make(Arr::get($response, 'hits.hits'))
|
||||||
|
->map(fn ($hit) => [
|
||||||
|
'discussion_id' => Str::after($hit['_id'], 'discussions:'),
|
||||||
|
'most_relevant_post_id' => null,
|
||||||
|
'weight' => Arr::get($hit, 'sort.0', 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$document->addPaginationLinks(
|
||||||
|
$this->uri->to('api')->route('sbp.search', ['type' => 'discussions']),
|
||||||
|
$request->getQueryParams(),
|
||||||
|
$offset,
|
||||||
|
$limit,
|
||||||
|
$results->count() > $limit ? null : 0
|
||||||
|
);
|
||||||
|
|
||||||
|
$results = $results->take($limit);
|
||||||
|
|
||||||
|
$discussions = Discussion::query()
|
||||||
|
->when(
|
||||||
|
$actor->isGuest() || !$actor->hasPermission('discussion.hide'),
|
||||||
|
fn ($q) => $q->whereNull('hidden_at')
|
||||||
|
)
|
||||||
|
->whereIn('id', $results->pluck('discussion_id')->filter())
|
||||||
|
->get()
|
||||||
|
->each(function (Discussion $discussion) use ($results) {
|
||||||
|
$result = $results->firstWhere('discussion_id', $discussion->id);
|
||||||
|
$discussion->most_relevant_post_id = $result['most_relevant_post_id'] ?? $discussion->first_post_id;
|
||||||
|
$discussion->weight = $result['weight'] ?? 0;
|
||||||
|
})
|
||||||
|
->sortByDesc('updated_at')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
$this->loadRelations($discussions, $include);
|
||||||
|
|
||||||
|
return $discussions;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected function buildAutocompleteQuery(string $search): BoolQuery
|
||||||
|
{
|
||||||
|
$query = BoolQuery::create();
|
||||||
|
|
||||||
|
// Title prefix match (stronger)
|
||||||
|
$query->add(
|
||||||
|
(new MatchQuery('title.autocomplete', $search))
|
||||||
|
->operator('and')
|
||||||
|
->boost(1.0),
|
||||||
|
'should'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Content prefix match (weaker)
|
||||||
|
$query->add(
|
||||||
|
(new MatchQuery('content.autocomplete', $search))
|
||||||
|
->operator('and')
|
||||||
|
->boost(0.4),
|
||||||
|
'should'
|
||||||
|
);
|
||||||
|
|
||||||
|
$query->minimumShouldMatch(1);
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. When $needsScoring is true (no explicit
|
||||||
|
* sort, i.e. relevance ordering), score_mode=sum accumulates child scores onto the
|
||||||
|
* parent so that discussions with many strongly-matching posts rank higher. When
|
||||||
|
* false (any explicit field sort), score_mode=none skips scoring entirely — ES only
|
||||||
|
* checks whether a matching child exists, which is significantly cheaper on large
|
||||||
|
* corpora.
|
||||||
|
* inner_hits returns the best-matching 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, bool $needsScoring = false): BoolQuery
|
||||||
|
{
|
||||||
|
$textQuery = BoolQuery::create();
|
||||||
|
// Strong direct title match for the full search page.
|
||||||
|
$textQuery->add(
|
||||||
|
(new MatchQuery('title', $search))
|
||||||
|
->operator('and')
|
||||||
|
->boost(4.0),
|
||||||
|
'should'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Low-weight prefix match on title.autocomplete for partial title hits.
|
||||||
|
$textQuery->add(
|
||||||
|
(new MatchQuery('title.autocomplete', $search))
|
||||||
|
->operator('and')
|
||||||
|
->boost(0.35),
|
||||||
|
'should'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Low-weight prefix match on title.autocomplete for the full search page.
|
||||||
|
// Exact / normal title matches should still rank higher than prefix-only hits.
|
||||||
|
$textQuery->add(
|
||||||
|
(new MatchQuery('title.autocomplete', $search))
|
||||||
|
->operator('and')
|
||||||
|
->boost(0.35),
|
||||||
|
'should'
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
if ($this->discussionSearcher?->enabled()) {
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without minimum_should_match, ES default MSM is 0 when a filter clause is present,
|
||||||
|
// causing has_child to score every non-hidden post instead of only matching ones.
|
||||||
|
$postQuery->minimumShouldMatch(1);
|
||||||
|
|
||||||
|
$textQuery->add(
|
||||||
|
HasChildQuery::create('post', $postQuery, $needsScoring ? 'sum' : 'none')->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.autocomplete', $search))->operator('and')->boost(0.30 * $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'))
|
||||||
|
|
@ -213,86 +442,12 @@ class SearchController extends ListDiscussionsController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->add(
|
$query->add($subQuery, 'filter');
|
||||||
$subQuery,
|
|
||||||
'filter'
|
|
||||||
);
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function sentenceMatch(string $q): Query
|
|
||||||
{
|
|
||||||
$query = (new MatchPhraseQuery('content', $q));
|
|
||||||
|
|
||||||
return BoolQuery::create()
|
|
||||||
// Discussion titles
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermQuery::create('type', 'discussions'), 'filter')
|
|
||||||
->add($query->boost(2)),
|
|
||||||
'should'
|
|
||||||
)
|
|
||||||
// Post bodies
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermQuery::create('type', 'posts'), 'filter')
|
|
||||||
->add($query->boost(1.9)),
|
|
||||||
'should'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function wordMatch(string $q, string $operator = 'or')
|
|
||||||
{
|
|
||||||
$query = (new MatchQuery('content', $q))
|
|
||||||
->operator($operator);
|
|
||||||
|
|
||||||
$boost = $operator === 'and' ? 1 : .8;
|
|
||||||
|
|
||||||
return BoolQuery::create()
|
|
||||||
// Discussion titles
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermQuery::create('type', 'discussions'), 'filter')
|
|
||||||
->add($query->boost($boost * 1.8)),
|
|
||||||
'should'
|
|
||||||
)
|
|
||||||
// Post bodies
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermQuery::create('type', 'posts'), 'filter')
|
|
||||||
->add($query->boost($boost * 1.8)),
|
|
||||||
'should'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function partialMatch(string $q)
|
|
||||||
{
|
|
||||||
$query = (new MatchQuery('content', $q));
|
|
||||||
|
|
||||||
return BoolQuery::create()
|
|
||||||
// Discussion titles
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermQuery::create('type', 'discussions'), 'filter')
|
|
||||||
->add(clone $query->boost(1.6)),
|
|
||||||
'should'
|
|
||||||
)
|
|
||||||
// Post bodies
|
|
||||||
->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermQuery::create('type', 'posts'), 'filter')
|
|
||||||
->add(clone $query->boost(1.3)),
|
|
||||||
'should'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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,10 +462,12 @@ class SearchController extends ListDiscussionsController
|
||||||
$search = Arr::get($filters, 'q');
|
$search = Arr::get($filters, 'q');
|
||||||
|
|
||||||
if ($search) {
|
if ($search) {
|
||||||
|
// Strip Flarum gambit operators (tag:foo, author:bar, is:private, etc.)
|
||||||
|
// before passing to ES. These are structural filters handled separately;
|
||||||
|
// leaving them in causes operator:and to require the gambit tokens to
|
||||||
|
// appear literally in post content, producing zero results.
|
||||||
$q = collect(explode(' ', $search))
|
$q = collect(explode(' ', $search))
|
||||||
->filter(function (string $part) {
|
->filter(fn (string $part) => !preg_match('/^\w+:/', $part))
|
||||||
return $part !== 'is:private';
|
|
||||||
})
|
|
||||||
->filter()
|
->filter()
|
||||||
->join(' ');
|
->join(' ');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,143 +1,398 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Commands;
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
use Blomstra\Search\Jobs\Job;
|
namespace Sbp\Search\Commands;
|
||||||
use Blomstra\Search\Jobs\SavingJob;
|
|
||||||
use Blomstra\Search\Seeders\Seeder;
|
use Sbp\Search\Jobs\Job;
|
||||||
use Carbon\Carbon;
|
use Sbp\Search\Jobs\UpdateSearchJob;
|
||||||
|
use Sbp\Search\Seeders\Seeder;
|
||||||
use Elasticsearch\Client;
|
use Elasticsearch\Client;
|
||||||
|
use Elasticsearch\Common\Exceptions\ElasticsearchException;
|
||||||
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 Spatie\ElasticsearchQueryBuilder\Builder;
|
||||||
|
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
||||||
|
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 = 'v3';
|
||||||
|
|
||||||
|
/** Matches Flarum's Search::MIN_SEARCH_LEN — the default minimum query length. */
|
||||||
|
public const DEFAULT_MIN_SEARCH_LENGTH = 3;
|
||||||
|
|
||||||
|
/** N-gram bounds for the title autocomplete subfield (not admin-configurable). */
|
||||||
|
private const TITLE_MIN_GRAM = 3;
|
||||||
|
private const TITLE_MAX_GRAM = 20;
|
||||||
|
|
||||||
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}
|
||||||
{--chunk-size= : Size of the chunks to dispatch into jobs}
|
{--fresh}
|
||||||
{--throttle= : Number of seconds to wait between pushing to the queue}
|
{--resume}
|
||||||
{--only= : type to run seeder for, eg discussions or posts}
|
{--staging}
|
||||||
{--recreate : create or recreate the index}
|
{--keep-backup}
|
||||||
{--mapping : recreate the mapping}
|
{--pending}
|
||||||
{--continue : continue each object type where you left off}';
|
{--backup}
|
||||||
protected $description = 'Rebuilds the complete search server with its documents.';
|
{--only=}
|
||||||
|
{--max-id=}
|
||||||
|
{--throttle=}
|
||||||
|
{--i-am-sure}';
|
||||||
|
|
||||||
public function handle(Container $container)
|
protected $description = 'Build and manage the Elasticsearch search index.';
|
||||||
|
|
||||||
|
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
|
||||||
{
|
{
|
||||||
$index = $container->make('blomstra.search.elastic_index');
|
|
||||||
|
|
||||||
/** @var array $seeders */
|
|
||||||
$seeders = $container->tagged('blomstra.search.seeders');
|
|
||||||
|
|
||||||
/** @var Queue $queue */
|
|
||||||
$queue = $container->make(Queue::class);
|
|
||||||
|
|
||||||
/** @var Client $client */
|
/** @var Client $client */
|
||||||
$client = $container->make(Client::class);
|
$client = $container->make(Client::class);
|
||||||
|
|
||||||
/** @var SettingsRepositoryInterface $settings */
|
/** @var SettingsRepositoryInterface $settings */
|
||||||
$settings = $container->make(SettingsRepositoryInterface::class);
|
$settings = $container->make(SettingsRepositoryInterface::class);
|
||||||
|
|
||||||
$properties = [
|
/** @var string $alias */
|
||||||
'properties' => [
|
$alias = $container->make('blomstra.search.elastic_index');
|
||||||
'content' => ['type' => 'text', 'analyzer' => 'flarum_analyzer_partial', 'search_analyzer' => 'flarum_analyzer'],
|
|
||||||
'created_at' => ['type' => 'date'],
|
|
||||||
'updated_at' => ['type' => 'date'],
|
|
||||||
'is_private' => ['type' => 'boolean'],
|
|
||||||
'is_sticky' => ['type' => 'boolean'],
|
|
||||||
'groups' => ['type' => 'integer'],
|
|
||||||
'recipient_groups' => ['type' => 'integer'],
|
|
||||||
'recipient_users' => ['type' => 'integer'],
|
|
||||||
'comment_count' => ['type' => 'integer'],
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->option('recreate')) {
|
if (!$this->argument('action')) {
|
||||||
// Flush the index.
|
$this->call('help', ['command_name' => $this->getName()]);
|
||||||
$client->indices()->delete([
|
return;
|
||||||
'index' => $index,
|
|
||||||
'ignore_unavailable' => true
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create a new index.
|
|
||||||
$client->indices()->create([
|
|
||||||
'index' => $index,
|
|
||||||
'body' => [
|
|
||||||
'settings' => [
|
|
||||||
'index.max_ngram_diff' => 10,
|
|
||||||
'analysis' => [
|
|
||||||
'analyzer' => [
|
|
||||||
'flarum_analyzer' => [
|
|
||||||
'type' => $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']
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->option('recreate') || $this->option('mapping')) {
|
switch ($this->argument('action')) {
|
||||||
$client->indices()->putMapping([
|
case 'build':
|
||||||
'index' => $index,
|
$this->runBuild($client, $alias, $settings, $container);
|
||||||
'body' => $properties
|
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([
|
||||||
|
'index' => $alias,
|
||||||
|
'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 --resume --staging Continue and keep in staging when done');
|
||||||
|
$this->line(' blomstra:search:index build --fresh Drop this build and start completely fresh');
|
||||||
|
$this->line(' blomstra:search:index build --fresh --staging Start fresh and keep in staging when done');
|
||||||
|
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');
|
$only = $this->option('only');
|
||||||
|
|
||||||
/** @var Seeder $seeder */
|
/** @var Seeder $seeder */
|
||||||
foreach ($seeders as $seeder) {
|
foreach ($seeders as $seeder) {
|
||||||
if ($only && $seeder->type() !== $only) continue;
|
if ($only && $seeder->type() !== $only) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$total = 0;
|
$total = 0;
|
||||||
|
|
||||||
$continueAt = $this->option('continue')
|
if ($this->option('resume')) {
|
||||||
? ($this->continueAt($seeder->type()) ?? $seeder->query()->max('id'))
|
$saved = $this->getContinueAt($settings, $seeder->type());
|
||||||
: $seeder->query()->max('id');
|
|
||||||
|
if ($saved === 0) {
|
||||||
|
$this->info("Seeder '{$seeder->type()}' already completed in a previous run, skipping.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$continueAt = $saved ?? $seeder->query()->max('id');
|
||||||
|
} else {
|
||||||
|
$continueAt = $seeder->query()->max('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$seeded = null;
|
||||||
|
|
||||||
|
while ($continueAt !== null) {
|
||||||
|
$rangeFrom = max(1, $continueAt - 2500);
|
||||||
|
$rangeTo = $continueAt;
|
||||||
|
|
||||||
|
if ($seedMissing) {
|
||||||
|
$seeded = $this->queryIndexedIds($client, $targetIndex, $seeder->joinRelation(), $rangeFrom, $rangeTo);
|
||||||
|
}
|
||||||
|
|
||||||
while($continueAt !== null) {
|
|
||||||
/** @var Collection $collection */
|
/** @var Collection $collection */
|
||||||
$collection = $seeder->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))
|
||||||
})
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$min = $collection->min('id');
|
$min = $collection->min('id');
|
||||||
$continueAt = $min && $min > 2 ? $min - 1 : null;
|
|
||||||
|
|
||||||
$queue->pushOn(Job::$onQueue, new SavingJob($collection, $seeder));
|
if ($seedMissing && $collection->isEmpty()) {
|
||||||
|
$continueAt = $rangeFrom > 2 ? $rangeFrom - 1 : null;
|
||||||
|
} else {
|
||||||
|
$continueAt = $min && $min > 2 ? $min - 1 : null;
|
||||||
|
}
|
||||||
|
|
||||||
$this->info("Pushed into the index, type: {$seeder->type()}, amount: {$collection->count()}.");
|
if ($collection->isNotEmpty()) {
|
||||||
|
$queue->pushOn(Job::$onQueue, new UpdateSearchJob($collection, $seeder, $targetIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
$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(
|
|
||||||
$seeder->type(),
|
|
||||||
$continueAt
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($throttle = $this->option('throttle')) {
|
if ($throttle = $this->option('throttle')) {
|
||||||
$this->info("Throttling for $throttle seconds");
|
$this->info("Throttling for $throttle seconds");
|
||||||
|
|
@ -145,21 +400,274 @@ 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
|
||||||
/** @var SettingsRepositoryInterface $settings */
|
* immediately, and return the alias so seeding goes through it and is live from the start.
|
||||||
$settings = resolve(SettingsRepositoryInterface::class);
|
*/
|
||||||
|
protected function prepareFirstInstall(
|
||||||
|
Client $client,
|
||||||
|
string $alias,
|
||||||
|
SettingsRepositoryInterface $settings,
|
||||||
|
iterable $seeders
|
||||||
|
): string {
|
||||||
|
$concrete = $alias . '_' . date('YmdHis');
|
||||||
|
|
||||||
$key = "blomstra-search.continued-at.$type";
|
$client->indices()->create([
|
||||||
|
'index' => $concrete,
|
||||||
|
'body' => ['settings' => $this->buildIndexSettings($settings)],
|
||||||
|
]);
|
||||||
|
|
||||||
if ($at) {
|
$client->indices()->putAlias(['index' => $concrete, 'name' => $alias]);
|
||||||
$settings->set($key, $at);
|
|
||||||
} else {
|
$settings->set('blomstra-search.active-index', $concrete);
|
||||||
return $settings->get($key);
|
$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
|
||||||
|
{
|
||||||
|
$settingsResponse = $client->indices()->getSettings(['index' => $indexName]);
|
||||||
|
$analysis = Arr::get($settingsResponse, "$indexName.settings.index.analysis", []);
|
||||||
|
|
||||||
|
$analyzer = Arr::get($analysis, 'analyzer.flarum_analyzer.type', 'english');
|
||||||
|
$stemExclusion = Arr::get($analysis, 'analyzer.flarum_analyzer.stem_exclusion', []);
|
||||||
|
|
||||||
|
$mappingResponse = $client->indices()->getMapping(['index' => $indexName]);
|
||||||
|
$compatVersion = Arr::get($mappingResponse, "$indexName.mappings._meta.index_compat_version");
|
||||||
|
|
||||||
|
$settings->set('blomstra-search.indexed-analyzer', $analyzer);
|
||||||
|
$settings->set('blomstra-search.indexed-stem-exclusion', implode("\n", $stemExclusion));
|
||||||
|
$settings->set('blomstra-search.index-compatible', $compatVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildIndexSettings(SettingsRepositoryInterface $settings): array
|
||||||
|
{
|
||||||
|
$language = $settings->get('blomstra-search.analyzer-language') ?: 'english';
|
||||||
|
|
||||||
|
$raw = $settings->get('blomstra-search.stem-exclusion', '');
|
||||||
|
$stemExclusion = array_values(array_filter(array_map('trim', explode("\n", $raw))));
|
||||||
|
|
||||||
|
if ($language === 'cjk') {
|
||||||
|
// CJK uses the built-in bigram analyzer; no autocomplete subfield.
|
||||||
|
return [
|
||||||
|
'analysis' => [
|
||||||
|
'analyzer' => [
|
||||||
|
'flarum_analyzer' => ['type' => 'cjk'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$analyzerConfig = ['type' => $language];
|
||||||
|
|
||||||
|
if (!empty($stemExclusion)) {
|
||||||
|
$analyzerConfig['stem_exclusion'] = $stemExclusion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'index.max_ngram_diff' => self::TITLE_MAX_GRAM - self::TITLE_MIN_GRAM,
|
||||||
|
'analysis' => [
|
||||||
|
'filter' => [
|
||||||
|
'title_autocomplete_filter' => [
|
||||||
|
'type' => 'edge_ngram',
|
||||||
|
'min_gram' => self::TITLE_MIN_GRAM,
|
||||||
|
'max_gram' => self::TITLE_MAX_GRAM,
|
||||||
|
'token_chars' => ['letter', 'digit'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'analyzer' => [
|
||||||
|
// Symmetric language analyzer: used for BOTH index and search on content + title.
|
||||||
|
'flarum_analyzer' => $analyzerConfig,
|
||||||
|
// Title autocomplete index-time: prefix n-grams, no stemming.
|
||||||
|
'flarum_title_autocomplete' => [
|
||||||
|
'type' => 'custom',
|
||||||
|
'tokenizer' => 'standard',
|
||||||
|
'filter' => ['lowercase', 'title_autocomplete_filter'],
|
||||||
|
],
|
||||||
|
// Title autocomplete search-time: just lowercase — predictable on partial words.
|
||||||
|
'flarum_title_search' => [
|
||||||
|
'type' => 'custom',
|
||||||
|
'tokenizer' => 'standard',
|
||||||
|
'filter' => ['lowercase'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mappingProperties(): array
|
||||||
|
{
|
||||||
|
$language = resolve(SettingsRepositoryInterface::class)->get('blomstra-search.analyzer-language') ?: 'english';
|
||||||
|
|
||||||
|
$titleMapping = [
|
||||||
|
'type' => 'text',
|
||||||
|
'analyzer' => 'flarum_analyzer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only add the autocomplete subfield for non-CJK languages.
|
||||||
|
if ($language !== 'cjk') {
|
||||||
|
$titleMapping['fields'] = [
|
||||||
|
'autocomplete' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'analyzer' => 'flarum_title_autocomplete',
|
||||||
|
'search_analyzer' => 'flarum_title_search',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentMapping = [
|
||||||
|
'type' => 'text',
|
||||||
|
'analyzer' => 'flarum_analyzer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only add the autocomplete subfield for non-CJK languages.
|
||||||
|
if ($language !== 'cjk') {
|
||||||
|
$contentMapping['fields'] = [
|
||||||
|
'autocomplete' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'analyzer' => 'flarum_title_autocomplete',
|
||||||
|
'search_analyzer' => 'flarum_title_search',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'_meta' => ['index_compat_version' => self::INDEX_COMPAT_VERSION],
|
||||||
|
'properties' => [
|
||||||
|
'join_field' => ['type' => 'join', 'relations' => ['discussion' => 'post']],
|
||||||
|
'discussion_id' => ['type' => 'integer'],
|
||||||
|
'content' => $contentMapping,
|
||||||
|
'title' => $titleMapping,
|
||||||
|
'rawId' => ['type' => 'integer'],
|
||||||
|
'created_at' => ['type' => 'date'],
|
||||||
|
'updated_at' => ['type' => 'date'],
|
||||||
|
'is_private' => ['type' => 'boolean'],
|
||||||
|
'user_id' => ['type' => 'integer'],
|
||||||
|
'groups' => ['type' => 'keyword'],
|
||||||
|
'comment_count' => ['type' => 'integer'],
|
||||||
|
'tags' => ['type' => 'keyword'],
|
||||||
|
'is_sticky' => ['type' => 'boolean'],
|
||||||
|
'is_hidden' => ['type' => 'boolean'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Query ES for rawIds already indexed in $targetIndex for the given $joinRelation and ID range.
|
||||||
|
* Retries on transient ES failures (NoNodesAvailableException / other ElasticsearchException)
|
||||||
|
* by sleeping past the StaticNoPingConnectionPool dead-node timeout (default 60 s) before
|
||||||
|
* each retry, giving the pool a chance to resurface the node.
|
||||||
|
*/
|
||||||
|
protected function queryIndexedIds(
|
||||||
|
Client $client,
|
||||||
|
string $targetIndex,
|
||||||
|
string $joinRelation,
|
||||||
|
int $rangeFrom,
|
||||||
|
int $rangeTo,
|
||||||
|
int $maxRetries = 10
|
||||||
|
): array {
|
||||||
|
$attempt = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
$response = (new Builder($client))
|
||||||
|
->index($targetIndex)
|
||||||
|
->size(2500)
|
||||||
|
->addQuery(
|
||||||
|
(new BoolQuery())
|
||||||
|
->add((new RangeQuery('rawId'))->gte($rangeFrom)->lte($rangeTo))
|
||||||
|
->add(TermQuery::create('join_field', $joinRelation))
|
||||||
|
)
|
||||||
|
->search();
|
||||||
|
|
||||||
|
return Arr::pluck(Arr::get($response, 'hits.hits'), '_source.rawId');
|
||||||
|
} catch (ElasticsearchException $e) {
|
||||||
|
$attempt++;
|
||||||
|
|
||||||
|
if ($attempt >= $maxRetries) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn("ES error on range {$rangeFrom}–{$rangeTo} (attempt {$attempt}/{$maxRetries}): {$e->getMessage()}. Waiting 65 s before retry…");
|
||||||
|
sleep(65); // outlast the StaticNoPingConnectionPool dead-node window (default 60 s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?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 Sbp\Search\Elasticsearch;
|
||||||
|
|
||||||
|
class BoolQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery
|
||||||
|
{
|
||||||
|
protected ?int $minimumShouldMatch = null;
|
||||||
|
|
||||||
|
public static function create(): static
|
||||||
|
{
|
||||||
|
return new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function minimumShouldMatch(int $minimum): static
|
||||||
|
{
|
||||||
|
$this->minimumShouldMatch = $minimum;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$array = parent::toArray();
|
||||||
|
|
||||||
|
if ($this->minimumShouldMatch !== null) {
|
||||||
|
$array['bool']['minimum_should_match'] = $this->minimumShouldMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 Sbp\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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Elasticsearch;
|
/*
|
||||||
|
* 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 Sbp\Search\Elasticsearch;
|
||||||
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery;
|
use Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery;
|
||||||
|
|
||||||
|
|
@ -22,7 +32,7 @@ class MatchPhraseQuery extends MatchQuery
|
||||||
$query[$this->field]['boost'] = $this->boost;
|
$query[$this->field]['boost'] = $this->boost;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'match_phrase' => $query
|
'match_phrase' => $query,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Elasticsearch;
|
/*
|
||||||
|
* 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 Sbp\Search\Elasticsearch;
|
||||||
|
|
||||||
class MatchQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery
|
class MatchQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +62,10 @@ class MatchQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery
|
||||||
$query['match'][$this->field]['boost'] = $this->boost;
|
$query['match'][$this->field]['boost'] = $this->boost;
|
||||||
$query['match'][$this->field]['zero_terms_query'] = $this->zeroTerms ? 'all' : 'none';
|
$query['match'][$this->field]['zero_terms_query'] = $this->zeroTerms ? 'all' : 'none';
|
||||||
|
|
||||||
|
if ($this->analyzer !== null) {
|
||||||
|
$query['match'][$this->field]['analyzer'] = $this->analyzer;
|
||||||
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Elasticsearch;
|
/*
|
||||||
|
* 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 Sbp\Search\Elasticsearch;
|
||||||
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
||||||
|
|
||||||
|
|
@ -38,12 +48,12 @@ class SimpleSearchQuery implements Query
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'simple_query_string' => [
|
'simple_query_string' => [
|
||||||
'query' => $this->value,
|
'query' => $this->value,
|
||||||
'fields' => $this->fields,
|
'fields' => $this->fields,
|
||||||
'analyzer' => $this->analyzer,
|
'analyzer' => $this->analyzer,
|
||||||
'default_operator' => 'AND',
|
'default_operator' => 'AND',
|
||||||
'boost' => $this->boost
|
'boost' => $this->boost,
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Elasticsearch;
|
/*
|
||||||
|
* 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 Sbp\Search\Elasticsearch;
|
||||||
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Elasticsearch;
|
/*
|
||||||
|
* 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 Sbp\Search\Elasticsearch;
|
||||||
|
|
||||||
class WildcardQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\WildcardQuery
|
class WildcardQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\WildcardQuery
|
||||||
{
|
{
|
||||||
|
|
@ -34,11 +44,10 @@ class WildcardQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\WildcardQu
|
||||||
$query = parent::toArray();
|
$query = parent::toArray();
|
||||||
|
|
||||||
$query['wildcard'][$this->field]['boost'] = $this->boost;
|
$query['wildcard'][$this->field]['boost'] = $this->boost;
|
||||||
$query['wildcard'][$this->field]['case_insensitive'] = ! $this->sensitivity;
|
$query['wildcard'][$this->field]['case_insensitive'] = !$this->sensitivity;
|
||||||
|
|
||||||
if ($this->rewrite) {
|
if ($this->rewrite) {
|
||||||
$query['wildcard'][$this->field]['rewrite'] = $this->rewrite;
|
$query['wildcard'][$this->field]['rewrite'] = $this->rewrite;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Exceptions;
|
/*
|
||||||
|
* 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 Sbp\Search\Exceptions;
|
||||||
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class SeedingException extends \Exception
|
class SeedingException extends \Exception
|
||||||
{
|
{
|
||||||
|
public function __construct($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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Jobs;
|
/*
|
||||||
|
* 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 Sbp\Search\Jobs;
|
||||||
|
|
||||||
use Elasticsearch\Client;
|
use Elasticsearch\Client;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -9,21 +19,24 @@ class DeletingJob extends Job
|
||||||
{
|
{
|
||||||
public function handle(Client $client)
|
public function handle(Client $client)
|
||||||
{
|
{
|
||||||
if ($this->models->isEmpty()) return;
|
if ($this->models->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Preparing body for storing.
|
// Preparing body for storing.
|
||||||
$body = $this->models->map(function (Model $model) {
|
$body = $this->models->map(function (Model $model) {
|
||||||
$document = $this->seeder->toDocument($model);
|
$document = $this->seeder->toDocument($model);
|
||||||
|
$routing = $this->seeder->routing($model);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
['delete' => ['_index' => $this->index, '_id' => $document->id]]
|
['delete' => ['_index' => $this->index, '_id' => $document->id, 'routing' => $routing]],
|
||||||
];
|
];
|
||||||
})->flatten(1);
|
})->flatten(1);
|
||||||
|
|
||||||
$response = $client->bulk([
|
$response = $client->bulk([
|
||||||
'index' => $this->index,
|
'index' => $this->index,
|
||||||
'body' => $body->toArray(),
|
'body' => $body->toArray(),
|
||||||
'refresh' => true
|
'refresh' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Jobs;
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
use Blomstra\Search\Seeders\Seeder;
|
namespace Sbp\Search\Jobs;
|
||||||
|
|
||||||
|
use Sbp\Search\Seeders\Seeder;
|
||||||
use Flarum\Queue\AbstractJob;
|
use Flarum\Queue\AbstractJob;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
abstract class Job extends AbstractJob
|
abstract class Job extends AbstractJob
|
||||||
{
|
{
|
||||||
|
|
@ -12,10 +22,16 @@ abstract class Job extends AbstractJob
|
||||||
|
|
||||||
public static ?string $onQueue = null;
|
public static ?string $onQueue = null;
|
||||||
|
|
||||||
public function __construct(protected Collection $models, protected Seeder $seeder)
|
/**
|
||||||
|
* @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 = resolve('blomstra.search.elastic_index');
|
$this->index = $targetIndex ?? resolve('blomstra.search.elastic_index');
|
||||||
|
|
||||||
if (static::$onQueue) $this->onQueue(static::$onQueue);
|
if (static::$onQueue) {
|
||||||
|
$this->onQueue(static::$onQueue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Blomstra\Search\Jobs;
|
|
||||||
|
|
||||||
use Blomstra\Search\Exceptions\SeedingException;
|
|
||||||
use Elasticsearch\Client;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class SavingJob 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);
|
|
||||||
|
|
||||||
return [
|
|
||||||
['index' => ['_index' => $this->index, '_id' => $document->id]],
|
|
||||||
$document->toArray()
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->flatten(1);
|
|
||||||
|
|
||||||
$response = $client->bulk([
|
|
||||||
'index' => $this->index,
|
|
||||||
'body' => $body->toArray(),
|
|
||||||
'refresh' => true
|
|
||||||
]);
|
|
||||||
|
|
||||||
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,62 @@
|
||||||
|
<?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 Sbp\Search\Jobs;
|
||||||
|
|
||||||
|
use Sbp\Search\Exceptions\SeedingException;
|
||||||
|
use Elasticsearch\Client;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
|
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');
|
||||||
|
|
||||||
|
$failed = array_filter($items, fn ($item) => isset($item['index']['error']));
|
||||||
|
$error = Arr::get(Arr::first($failed), 'index.error.reason', 'unknown error');
|
||||||
|
|
||||||
|
throw new SeedingException(
|
||||||
|
"Failed to seed: $error (" . count($failed) . '/' . count($items) . ' items failed)',
|
||||||
|
$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 Sbp\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,11 +1,21 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search;
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
use Blomstra\Search\Jobs\DeletingJob;
|
namespace Sbp\Search;
|
||||||
use Blomstra\Search\Jobs\Job;
|
|
||||||
use Blomstra\Search\Jobs\SavingJob;
|
use Sbp\Search\Jobs\DeletingJob;
|
||||||
use Blomstra\Search\Seeders;
|
use Sbp\Search\Jobs\Job;
|
||||||
|
use Sbp\Search\Jobs\UpdateSearchJob;
|
||||||
|
use Sbp\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\Api\Client;
|
||||||
|
|
@ -16,7 +26,7 @@ use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
use Illuminate\Contracts\Container\Container;
|
use Illuminate\Contracts\Container\Container;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Contracts\Queue\Queue;
|
use Illuminate\Contracts\Queue\Queue;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Laminas\Stratigility\MiddlewarePipe;
|
use Laminas\Stratigility\MiddlewarePipe;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
|
@ -37,7 +47,13 @@ class Provider extends AbstractServiceProvider
|
||||||
|
|
||||||
$this->container->singleton(Elastic::class, function (Container $container) use ($settings, $config) {
|
$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')])
|
||||||
|
->setConnectionParams([
|
||||||
|
'client' => [
|
||||||
|
'connect_timeout' => 2, // fail fast if ES is unreachable
|
||||||
|
'timeout' => 10, // allow time for complex queries
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
if ($config->inDebugMode()) {
|
if ($config->inDebugMode()) {
|
||||||
$builder->setLogger($container->make(LoggerInterface::class));
|
$builder->setLogger($container->make(LoggerInterface::class));
|
||||||
|
|
@ -53,7 +69,6 @@ class Provider extends AbstractServiceProvider
|
||||||
return $builder->build();
|
return $builder->build();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
$this->container->instance(
|
$this->container->instance(
|
||||||
'blomstra.search.elastic_index',
|
'blomstra.search.elastic_index',
|
||||||
$settings->get('blomstra-search.elastic-index', 'flarum')
|
$settings->get('blomstra-search.elastic-index', 'flarum')
|
||||||
|
|
@ -62,23 +77,28 @@ class Provider extends AbstractServiceProvider
|
||||||
$this->container->extend(
|
$this->container->extend(
|
||||||
Client::class,
|
Client::class,
|
||||||
function () {
|
function () {
|
||||||
$pipe = new MiddlewarePipe;
|
$pipe = new MiddlewarePipe();
|
||||||
|
|
||||||
$exclude = resolve('flarum.api_client.exclude_middleware');
|
$exclude = resolve('flarum.api_client.exclude_middleware');
|
||||||
|
|
||||||
$middlewareStack = array_filter(resolve('flarum.api.middleware'), function ($middlewareClass) use ($exclude) {
|
$middlewareStack = array_filter(resolve('flarum.api.middleware'), function ($middlewareClass) use ($exclude) {
|
||||||
return ! in_array($middlewareClass, $exclude);
|
return !in_array($middlewareClass, $exclude);
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach ($middlewareStack as $middleware) {
|
foreach ($middlewareStack as $middleware) {
|
||||||
$pipe->pipe(resolve($middleware));
|
$pipe->pipe(resolve($middleware));
|
||||||
}
|
}
|
||||||
|
|
||||||
$pipe->pipe(new ExecuteRoute);
|
$pipe->pipe(new ExecuteRoute());
|
||||||
|
|
||||||
return new Api\Client($pipe);
|
return new Api\Client($pipe);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->container->tag([
|
||||||
|
Searchers\DiscussionSearcher::class,
|
||||||
|
Searchers\CommentPostSearcher::class,
|
||||||
|
], 'blomstra.search.searchers');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot()
|
public function boot()
|
||||||
|
|
@ -95,12 +115,16 @@ class Provider extends AbstractServiceProvider
|
||||||
/** @var string|Seeders\Seeder $seeder */
|
/** @var string|Seeders\Seeder $seeder */
|
||||||
foreach ($seeders as $seeder) {
|
foreach ($seeders as $seeder) {
|
||||||
$seeder::savingOn($events, function ($model) use ($queue, $seeder) {
|
$seeder::savingOn($events, function ($model) use ($queue, $seeder) {
|
||||||
$queue->pushOn(Job::$onQueue, new SavingJob(Collection::make([$model]), $seeder));
|
$queue->pushOn(Job::$onQueue, new UpdateSearchJob(Collection::make([$model]), $seeder));
|
||||||
});
|
});
|
||||||
|
|
||||||
$seeder::deletingOn($events, function ($model) use ($queue, $seeder) {
|
$seeder::deletingOn($events, function ($model) use ($queue, $seeder) {
|
||||||
$queue->pushOn(Job::$onQueue, new DeletingJob(Collection::make([$model]), $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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,43 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Save;
|
/*
|
||||||
|
* 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 Sbp\Search\Save;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Fluent;
|
use Illuminate\Support\Fluent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $type
|
* @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
|
||||||
* @property int $user_id
|
* @property int $user_id
|
||||||
* @property bool $is_private
|
* @property bool $is_private
|
||||||
* @property bool $is_sticky
|
* @property bool $is_sticky
|
||||||
* @property array|int[] $groups
|
* @property array|int[] $groups
|
||||||
* @property array|int[] $recipient_groups
|
* @property array|int[] $recipient_groups
|
||||||
* @property array|int[] $recipient_users
|
* @property array|int[] $recipient_users
|
||||||
*/
|
*/
|
||||||
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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 Sbp\Search\Searchers;
|
||||||
|
|
||||||
|
use Sbp\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 Sbp\Search\Searchers;
|
||||||
|
|
||||||
|
use Sbp\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 Sbp\Search\Searchers;
|
||||||
|
|
||||||
|
use Sbp\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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Seeders;
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
use Blomstra\Search\Save\Document;
|
namespace Sbp\Search\Seeders;
|
||||||
|
|
||||||
|
use Sbp\Search\Save\Document;
|
||||||
|
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
use Flarum\Api\Serializer\PostSerializer;
|
use Flarum\Api\Serializer\PostSerializer;
|
||||||
|
use Flarum\Discussion\Discussion;
|
||||||
use Flarum\Post\CommentPost;
|
use Flarum\Post\CommentPost;
|
||||||
use Flarum\Post\Event\Deleted;
|
use Flarum\Post\Event as Core;
|
||||||
use Flarum\Post\Event\Posted;
|
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -15,66 +26,71 @@ class CommentSeeder extends Seeder
|
||||||
{
|
{
|
||||||
public function type(): string
|
public function type(): string
|
||||||
{
|
{
|
||||||
return resolve(PostSerializer::class)->getType(new CommentPost);
|
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
|
public function query(): Builder
|
||||||
{
|
{
|
||||||
$includes = ['discussion'];
|
|
||||||
|
|
||||||
if ($this->extensionEnabled('flarum-tags')) {
|
|
||||||
$includes[] = 'discussion.tags';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->extensionEnabled('fof-byobu')) {
|
|
||||||
$includes[] = 'discussion.recipientUsers';
|
|
||||||
$includes[] = 'discussion.recipientGroups';
|
|
||||||
}
|
|
||||||
|
|
||||||
return CommentPost::query()
|
return CommentPost::query()
|
||||||
->whereNull('hidden_at')
|
->where('type', CommentPost::$type);
|
||||||
->where('type', CommentPost::$type)
|
|
||||||
->with($includes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function savingOn(Dispatcher $events, callable $callable)
|
public static function savingOn(Dispatcher $events, callable $callable)
|
||||||
{
|
{
|
||||||
$events->listen(Posted::class, function (Posted $event) use ($callable) {
|
$events->listen([
|
||||||
|
Core\Posted::class,
|
||||||
|
Core\Revised::class,
|
||||||
|
Core\Hidden::class,
|
||||||
|
Core\Restored::class,
|
||||||
|
], function ($event) use ($callable) {
|
||||||
$callable($event->post);
|
$callable($event->post);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function deletingOn(Dispatcher $events, callable $callable)
|
public static function deletingOn(Dispatcher $events, callable $callable)
|
||||||
{
|
{
|
||||||
$events->listen(Deleted::class, function (Deleted $event) use ($callable) {
|
$events->listen([
|
||||||
|
Core\Deleted::class
|
||||||
|
], function ($event) use ($callable) {
|
||||||
$callable($event->post);
|
$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
|
* @param CommentPost $model
|
||||||
* @return Document
|
|
||||||
*/
|
*/
|
||||||
public function toDocument(Model $model): Document
|
public function toDocument(Model $model): Document
|
||||||
{
|
{
|
||||||
$document = new Document([
|
return new Document([
|
||||||
'type' => $this->type(),
|
'join_field' => ['name' => $this->joinRelation(), 'parent' => "{$this->discussionType()}:{$model->discussion_id}"],
|
||||||
'id' => $this->type() . ':' . $model->id,
|
'discussion_id' => $model->discussion_id,
|
||||||
'content' => $model->content,
|
'id' => $this->type().':'.$model->id,
|
||||||
'content_partial' => $model->content,
|
'rawId' => $model->id,
|
||||||
'created_at' => $model->created_at?->toAtomString(),
|
'content' => $model->content,
|
||||||
'updated_at' => $model->edited_at?->toAtomString(),
|
'is_hidden' => $model->hidden_at !== null,
|
||||||
'is_private' => $model->is_private,
|
|
||||||
'user_id' => $model->user_id,
|
|
||||||
'groups' => $this->groupsForDiscussion($model->discussion),
|
|
||||||
'comment_count' => $model->discussion->comment_count,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->extensionEnabled('fof-byobu')) {
|
|
||||||
$document['recipient_users'] = $model->discussion->recipientUsers->pluck('id')->toArray();
|
|
||||||
$document['recipient_groups'] = $model->discussion->recipientGroups->pluck('id')->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $document;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,56 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Seeders;
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
use Blomstra\Search\Save\Document;
|
namespace Sbp\Search\Seeders;
|
||||||
|
|
||||||
|
use Sbp\Search\Save\Document;
|
||||||
use Flarum\Api\Serializer\DiscussionSerializer;
|
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
use Flarum\Discussion\Discussion;
|
use Flarum\Discussion\Discussion;
|
||||||
use Flarum\Discussion\Event\Deleted;
|
use Flarum\Discussion\Event as Core;
|
||||||
use Flarum\Discussion\Event\Hidden;
|
use Flarum\Extension\ExtensionManager;
|
||||||
use Flarum\Discussion\Event\Restored;
|
use Flarum\Group\Group;
|
||||||
use Flarum\Discussion\Event\Started;
|
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\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class DiscussionSeeder extends Seeder
|
class DiscussionSeeder extends Seeder
|
||||||
{
|
{
|
||||||
public function type(): string
|
public function type(): string
|
||||||
{
|
{
|
||||||
return resolve(DiscussionSerializer::class)->getType(new Discussion);
|
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
|
public function query(): Builder
|
||||||
|
{
|
||||||
|
return Discussion::query()
|
||||||
|
->whereNull('hidden_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function relationships(): array
|
||||||
{
|
{
|
||||||
$includes = [];
|
$includes = [];
|
||||||
|
|
||||||
|
|
@ -32,53 +62,149 @@ class DiscussionSeeder extends Seeder
|
||||||
$includes[] = 'recipientUsers';
|
$includes[] = 'recipientUsers';
|
||||||
$includes[] = 'recipientGroups';
|
$includes[] = 'recipientGroups';
|
||||||
}
|
}
|
||||||
return Discussion::query()
|
|
||||||
->whereNull('hidden_at')
|
return $includes;
|
||||||
->with($includes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function savingOn(Dispatcher $events, callable $callable)
|
public static function savingOn(Dispatcher $events, callable $callable)
|
||||||
{
|
{
|
||||||
$events->listen([Started::class, Restored::class], function ($event) use ($callable) {
|
$events->listen([
|
||||||
|
// flarum/core events
|
||||||
|
Core\Started::class, Core\Restored::class, Core\Renamed::class,
|
||||||
|
// fof/byobu discussion recipients events.
|
||||||
|
Byobu\DiscussionMadePublic::class, Byobu\RemovedSelf::class, Byobu\RecipientsChanged::class,
|
||||||
|
], function ($event) use ($callable) {
|
||||||
return $callable($event->discussion);
|
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)
|
public static function deletingOn(Dispatcher $events, callable $callable)
|
||||||
{
|
{
|
||||||
$events->listen([Deleted::class, Hidden::class], function ($event) use ($callable) {
|
$events->listen([
|
||||||
|
// flarum/core events.
|
||||||
|
Core\Deleted::class, Core\Hidden::class
|
||||||
|
], function ($event) use ($callable) {
|
||||||
return $callable($event->discussion);
|
return $callable($event->discussion);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Discussion $model
|
* @param Discussion $model
|
||||||
|
*
|
||||||
* @return Document
|
* @return Document
|
||||||
*/
|
*/
|
||||||
public function toDocument(Model $model): Document
|
public function toDocument(Model $model): Document
|
||||||
{
|
{
|
||||||
$document = new Document([
|
$document = new Document([
|
||||||
'type' => $this->type(),
|
'join_field' => $this->joinRelation(),
|
||||||
'id' => $this->type() . ':' . $model->id,
|
'id' => $this->type().':'.$model->id,
|
||||||
'content' => $model->title,
|
'rawId' => $model->id,
|
||||||
'content_partial' => $model->title,
|
'content' => $model->title,
|
||||||
'created_at' => $model->created_at?->toAtomString(),
|
'title' => $model->title,
|
||||||
'updated_at' => $model->last_posted_at?->toAtomString(),
|
'created_at' => $model->created_at?->toAtomString(),
|
||||||
'is_private' => $model->is_private,
|
'updated_at' => $model->last_posted_at?->toAtomString(),
|
||||||
'user_id' => $model->user_id,
|
'is_private' => $model->is_private,
|
||||||
'groups' => $this->groupsForDiscussion($model),
|
'user_id' => $model->user_id,
|
||||||
'comment_count' => $model->comment_count,
|
'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')) {
|
if ($this->extensionEnabled('fof-byobu')) {
|
||||||
$document['recipient_users'] = $model->recipientUsers->pluck('id')->toArray();
|
$document['recipient_users'] = $model->recipientUsers
|
||||||
$document['recipient_groups'] = $model->recipientGroups->pluck('id')->toArray();
|
->whereNull('removed_at')
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
$document['recipient_groups'] = $model->recipientGroups
|
||||||
|
->whereNull('removed_at')
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->extensionEnabled('flarum-sticky')) {
|
if ($this->extensionEnabled('flarum-sticky')) {
|
||||||
$document['is_sticky'] = (bool) $model->is_sticky;
|
$document['is_sticky'] = (bool) $model->is_sticky;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('fof-discussion-views')) {
|
||||||
|
$document['view_count'] = (int) ($model->view_count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
return $document;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,54 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Blomstra\Search\Seeders;
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
use Blomstra\Search\Save\Document;
|
namespace Sbp\Search\Seeders;
|
||||||
use Flarum\Discussion\Discussion;
|
|
||||||
|
use Sbp\Search\Save\Document;
|
||||||
use Flarum\Extension\ExtensionManager;
|
use Flarum\Extension\ExtensionManager;
|
||||||
use Flarum\Group\Group;
|
|
||||||
use Flarum\Group\Permission;
|
|
||||||
use Flarum\Tags\Tag;
|
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
abstract class Seeder
|
abstract class Seeder
|
||||||
{
|
{
|
||||||
abstract public function type(): string;
|
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 function query(): Builder;
|
||||||
|
|
||||||
abstract public static function savingOn(Dispatcher $events, callable $callable);
|
abstract public static function savingOn(Dispatcher $events, callable $callable);
|
||||||
|
|
||||||
abstract public static function deletingOn(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;
|
abstract public function toDocument(Model $model): Document;
|
||||||
|
|
||||||
protected function groupsForDiscussion(Discussion $discussion): array
|
/** Relationships to eager-load on the collection before calling toDocument(). */
|
||||||
|
public function relationships(): array
|
||||||
{
|
{
|
||||||
$permissions = collect();
|
return [];
|
||||||
|
|
||||||
$globalPermission = Permission::query()
|
|
||||||
->where('permission', 'viewForum')
|
|
||||||
->pluck('group_id');
|
|
||||||
|
|
||||||
if ($this->extensionEnabled('flarum-tags')) {
|
|
||||||
/** @var Collection $tags */
|
|
||||||
$tags = $discussion->tags;
|
|
||||||
|
|
||||||
$filters['tags'] = $tags->pluck('id')->toArray();
|
|
||||||
$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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extensionEnabled(string $extension): bool
|
protected function extensionEnabled(string $extension): bool
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue