Compare commits

..

No commits in common. "main" and "0.0.7" have entirely different histories.
main ... 0.0.7

46 changed files with 8757 additions and 5040 deletions

View File

@ -1,18 +1,17 @@
name: Search JS name: Build JavaScript assets
on: [workflow_dispatch, push, pull_request] on:
push:
branches:
- main
jobs: jobs:
run: build:
uses: flarum/framework/.github/workflows/REUSABLE_frontend.yml@main runs-on: ubuntu-latest
with:
enable_bundlewatch: false steps:
enable_prettier: true - uses: actions/checkout@master
enable_typescript: false - uses: flarum/action-build@master
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 }}

1
.gitignore vendored
View File

@ -2,4 +2,3 @@ js/node_modules
js/dist js/dist
vendor/ vendor/
composer.lock composer.lock
analysis/*

View File

@ -1,9 +0,0 @@
MIT License
Copyright (c) 2021 Blomstra Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

166
README.md
View File

@ -5,177 +5,47 @@ fulltext search with one that is completely relying on the proven elasticsearch
## Features ## Features
- Sync discussions and posts to Elasticsearch using your queue, unobtrusively for the user. - Sync discussions to elastic search using your queue, unobtrusively for the user.
- Reduces search loading times to well below 400ms (local tests with 50,000 discussions: **260ms**) - Reduces search loading times to well below 400ms (local tests with 50.000 discussion **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
```bash Use composer:
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
php flarum blomstra:search:index build composer require blomstra/search:*
php flarum queue:work
``` ```
### Subsequent rebuilds (zero-downtime) Enable the extension inside the admin area and configure the settings.
When you need to rebuild the full index (e.g. after a mapping change): ### Set up
```bash Enable the extension in your admin area. Now to seed your existing discussions use the following command:
# Simple rebuild — promotes automatically once all jobs are queued
php flarum blomstra:search:index build
# Or keep a backup of the old index in case you need to roll back ```
php flarum blomstra:search:index build --keep-backup php flarum blomstra:search:build
``` ```
After the queue drains, fill any gaps from content posted during the build: All mutations to discussions are automatically added and removed from the elasticsearch index.
```bash ### FAQ
php flarum blomstra:search:index fill
```
If you kept a backup and want to roll back: *I have another question.*
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\Jobs\Job::$onQueue = 'search'; \Blomstra\Search\Observe\Job::$onQueue = 'sync';
``` ```
*"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.
--- ---
- Floxum provides managed Flarum hosting. - Blomstra provides managed Flarum hosting.
- https://floxum.com - https://blomstra.net
- https://support.on-floxum.com/t/ext-search - https://blomstra.community/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/).

View File

@ -1,35 +1,27 @@
{ {
"name": "sbp/flarum-search", "name": "blomstra/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",
"flarum", "flarum", "blomstra",
"blomstra", "search", "fulltext", "elastic", "elasticsearch"
"search",
"fulltext",
"elastic",
"elasticsearch"
], ],
"support": { "support": {
"email": "mueh+git@sbp.de", "email": "helpdesk@blomstra.net",
"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", "name": "Daniël Klabbers",
"email": "info@sbp.de" "email": "daniel@klabbers.email",
}, "homepage": "http://luceos.com"
{
"name": "Team Blomstra",
"email": "development@blomstra.net",
"role": "Original Developer"
} }
], ],
"require": { "require": {
"php": ">= 8.0", "php": ">= 8.0",
"flarum/core": "^1.2.0", "flarum/core": "^1.0.0",
"elasticsearch/elasticsearch": "7.*", "elasticsearch/elasticsearch": "7.*",
"spatie/elasticsearch-query-builder": "^1.3.0" "spatie/elasticsearch-query-builder": "^1.3.0"
}, },
@ -47,7 +39,7 @@
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Sbp\\Search\\": "src/" "Blomstra\\Search\\": "src/"
} }
} }
} }

View File

@ -1,41 +1,22 @@
<?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('/sbp/search/{type}', 'sbp.search', Api\Controllers\SearchController::class) ->get('/blomstra/search/{type}', 'blomstra.search', Api\Controllers\SearchController::class),
->put('/sbp/search/index', 'sbp.search.index', Api\Controllers\IndexController::class),
(new Flarum\Console())
->command(Commands\BuildCommand::class),
(new Flarum\Settings()) (new Flarum\Console)
->default('blomstra-search.search-discussion-subjects', true) ->command(Commands\BuildCommand::class)
->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),
]; ];

2
js/dist/admin.js vendored
View File

@ -1,2 +1,2 @@
(()=>{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={}})(); 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"})}))}});
//# sourceMappingURL=admin.js.map //# sourceMappingURL=admin.js.map

File diff suppressed because one or more lines are too long

3
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
/*! 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

10831
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,16 @@
{ {
"name": "@blomstra/search", "name": "@blomstra/realtime",
"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": "^2.0.0", "flarum-webpack-config": "^1.0.0",
"webpack": "^5.75.0", "pusher-js": "^7.0.3",
"webpack-cli": "^5.0.0" "webpack": "^4.46.0",
"webpack-cli": "^4.9.1"
}, },
"scripts": { "scripts": {
"dev": "webpack --mode development --watch", "dev": "webpack --mode development --watch",
@ -17,6 +19,6 @@
"format-check": "prettier --check src" "format-check": "prettier --check src"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^2.7.1" "prettier": "^2.4.1"
} }
} }

View File

@ -1,157 +1,44 @@
import app from 'flarum/admin/app'; import app from 'flarum/admin/app';
import { extend } from 'flarum/common/extend';
import DashboardPage from 'flarum/admin/components/DashboardPage';
import DashboardWidget from 'flarum/admin/components/DashboardWidget';
import Alert from 'flarum/common/components/Alert';
const REQUIRED_INDEX_COMPAT = 'v3'; app.initializers.add('blomstra-search', () => {
const languages = new Map;
class ReindexWarningWidget extends DashboardWidget { ['arabic', 'armenian', 'basque', 'bengali', 'brazilian', 'bulgarian', 'catalan', 'cjk', 'czech',
className() { 'danish', 'dutch', 'english', 'estonian', 'finnish', 'french', 'galician', 'german', 'greek',
return 'ReindexWarningWidget'; 'hindi', 'hungarian', 'indonesian', 'irish', 'italian', 'latvian', 'lithuanian', 'norwegian',
} 'persian', 'portuguese', 'romanian', 'russian', 'sorani', 'spanish', 'swedish', 'turkish', 'thai']
.forEach(language => {
content() { languages.set(language, language);
return m(
Alert,
{
type: 'warning',
dismissible: false,
icon: 'fas fa-exclamation-triangle',
title: app.translator.trans('blomstra-search.admin.reindex-required.title'),
},
app.translator.trans('blomstra-search.admin.reindex-required.detail')
);
}
}
app.initializers.add('sbp-search', () => {
const activeIndex = app.data.settings['blomstra-search.active-index'];
const compatVersion = app.data.settings['blomstra-search.index-compatible'];
if (activeIndex && compatVersion !== REQUIRED_INDEX_COMPAT) {
extend(DashboardPage.prototype, 'availableWidgets', function (items) {
items.add('blomstra-search-reindex', m(ReindexWarningWidget), 110);
}); });
}
const languages: Record<string, string> = {
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',
};
app.extensionData app.extensionData
.for('sbp-search') .for('blomstra-search')
.registerSetting(function (this: any) {
// No index yet, or first build predates this tracking — stay silent.
const indexedAnalyzer = app.data.settings['blomstra-search.indexed-analyzer'];
if (!activeIndex || !indexedAnalyzer) return null;
const currentAnalyzer = this.setting('blomstra-search.analyzer-language')() || 'english';
const 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({ .registerSetting({
setting: 'blomstra-search.elastic-endpoint', setting: 'blomstra-search.elastic-endpoint',
label: app.translator.trans('blomstra-search.admin.elastic-endpoint'), label: app.translator.trans('blomstra-search.admin.elastic-endpoint'),
type: 'input', type: 'input'
}) })
.registerSetting({ .registerSetting({
setting: 'blomstra-search.elastic-username', setting: 'blomstra-search.elastic-username',
label: app.translator.trans('blomstra-search.admin.elastic-username'), label: app.translator.trans('blomstra-search.admin.elastic-username'),
type: 'input', type: 'input'
}) })
.registerSetting({ .registerSetting({
setting: 'blomstra-search.elastic-password', setting: 'blomstra-search.elastic-password',
label: app.translator.trans('blomstra-search.admin.elastic-password'), label: app.translator.trans('blomstra-search.admin.elastic-password'),
type: 'password', type: 'password'
}) })
.registerSetting({ .registerSetting({
setting: 'blomstra-search.elastic-index', setting: 'blomstra-search.elastic-index',
label: app.translator.trans('blomstra-search.admin.elastic-index'), label: app.translator.trans('blomstra-search.admin.elastic-index'),
default: 'flarum', default: 'flarum',
type: 'input', 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({ .registerSetting({
setting: 'blomstra-search.analyzer-language', setting: 'blomstra-search.analyzer-language',
label: app.translator.trans('blomstra-search.admin.analyzer.label'), label: app.translator.trans('blomstra-search.admin.analyzer.label'),
help: app.translator.trans('blomstra-search.admin.analyzer.help'), help: app.translator.trans('blomstra-search.admin.analyzer.help'),
type: 'select', type: 'select',
options: languages, options: Object.fromEntries(languages.entries()),
default: 'english', 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,
});
});
}); });

View File

@ -6,8 +6,8 @@ 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) {
console.log(this.requestParams());
// If existing payload is given or no search is made, fallback on native page.
if (!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();
@ -16,18 +16,19 @@ export default function extendDiscussionState() {
...params.page, ...params.page,
}; };
if (Array.isArray(params.include)) { if (Array.isArray(params.include)) {
params.include = params.include.join(','); params.include = params.include.join(',');
} }
// Construct API search URI // Construct API search URI
const url = `${app.forum.attribute('apiUrl')}/sbp/search/${this.type}`; const url = `${app.forum.attribute('apiUrl')}/blomstra/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' });
// Parse API response into models and push to store // Parse API response into models and push to store
return app.store.pushPayload(results); const models = app.store.pushPayload(results);
return models;
}); });
} }

View File

@ -21,21 +21,18 @@ 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, autocomplete: 1 }, filter: { q: query },
page: { limit: 3 }, page: { limit: 3 },
include: 'mostRelevantPost', include: 'mostRelevantPost',
}; };
// Construct API search URI // Construct API search URI
const url = `${app.forum.attribute('apiUrl')}/sbp/search/${this.type}`; const url = `${app.forum.attribute('apiUrl')}/blomstra/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' });
@ -47,7 +44,7 @@ export default class DiscussionsSearchSource implements SearchSource {
this.results.set(query, models); this.results.set(query, models);
} }
view(query: string): Array<Mithril.Vnode> { view(query: string): Mithril.Children {
query = query.toLowerCase(); query = query.toLowerCase();
// Get results from map // Get results from map
@ -57,7 +54,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()}`}> <li className="DiscussionSearchResult" data-index={`${this.type}${discussion.id()}`} key={`${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>}
@ -66,14 +63,16 @@ 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}
]; </>
);
} }
} }

View File

@ -1,26 +0,0 @@
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} />);
}
});
}

View File

@ -1,32 +1,22 @@
import app from 'flarum/forum/app'; import app from 'flarum/forum/app';
import Search, { SearchAttrs, SearchSource } from 'flarum/forum/components/Search'; import Search 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<SearchAttrs>, items: ItemList<SearchSource>) { extend(Search.prototype, 'sourceItems', function (this: Search, items: ItemList) {
// app.forum is not available during initializers (it is set after they run), items.remove('users');
// so read the setting lazily here, at first render time. items.remove('discussions');
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.replace('discussions', new DiscussionsSearchSource()); /* if (app.forum.attribute('canViewForum')) */ items.add('discussions', new DiscussionsSearchSource());
});
// console.log(items);
}); });
app.initializers.add(
'blomstra-search-early',
() => {
extendDiscussionState(); extendDiscussionState();
extendDiscussionListItem(); });
},
999999
);

View File

@ -1,14 +0,0 @@
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

View File

@ -1,3 +0,0 @@
.ReindexWarningWidget {
padding: 0;
}

View File

@ -8,21 +8,4 @@ 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. Changing this requires a full index rebuild to take effect. specific improvements for indexing.
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.

View File

@ -1,28 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Sbp\Search\Api;
use Illuminate\Support\Arr;
use Psr\Http\Message\ResponseInterface;
class Client extends \Flarum\Api\Client
{
public function get(string $path): ResponseInterface
{
if ($path === '/discussions' && Arr::has($this->queryParams, 'filter.q')) {
return parent::get('/blomstra/search/discussions');
}
return parent::get($path);
}
}

View File

@ -1,41 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Sbp\Search\Api\Controllers;
use Sbp\Search\Commands\BuildCommand;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\ConsoleOutput;
class IndexController implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
/** @var \Flarum\User\User */
$actor = $request->getAttribute('actor');
$actor->assertAdmin();
/** @var BuildCommand $command */
$command = resolve(BuildCommand::class);
$command->run(
new ArrayInput([]),
new ConsoleOutput()
);
return new EmptyResponse();
}
}

View File

@ -1,24 +1,9 @@
<?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.
*
*/
namespace Sbp\Search\Api\Controllers; use Blomstra\Search\Save\Document as ElasticDocument;
use Blomstra\Search\Elasticsearch\TermsQuery;
use Sbp\Search\Elasticsearch\HasChildQuery;
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;
@ -26,18 +11,17 @@ use Flarum\Discussion\Discussion;
use Flarum\Extension\ExtensionManager; 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\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\Database\Eloquent\Collection;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
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\Aggregations\TermsAggregation;
use Spatie\ElasticsearchQueryBuilder\Aggregations\TopHitsAggregation;
use Spatie\ElasticsearchQueryBuilder\Builder; use Spatie\ElasticsearchQueryBuilder\Builder;
use Sbp\Search\Elasticsearch\BoolQuery; use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
use Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery;
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;
@ -48,163 +32,93 @@ 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',
'view_count' => 'view_count',
]; ];
protected bool $matchSentences; public function __construct(protected Client $elastic)
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)
{ {
$type = Arr::get($request->getQueryParams(), 'type');
$actor = RequestUtil::getActor($request); $actor = RequestUtil::getActor($request);
$filters = $this->extractFilter($request); $filters = $this->extractFilter($request);
$search = $this->getSearch($filters);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$include = array_merge($this->extractInclude($request), ['state']); $include = array_merge($this->extractInclude($request), ['state']);
// Autocomplete path: fast prefix match on title.autocomplete only. $filterQuery = (BoolQuery::create())
if (!empty($filters['autocomplete']) && !empty($search)) { ->add(
return $this->handleAutocomplete($search, $actor, $limit, $offset, $include, $document, $request); BoolQuery::create()
} ->add(MatchQuery::create('content', $filters['q']))
);
$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'))
->addQuery($query); ->size($this->extractLimit($request))
->from($this->extractOffset($request))
->addQuery(
$this->addFilters($filterQuery, $actor)
)
// ->addAggregation(
// TermsAggregation::create('discussions', 'discussion_id')
// ->aggregation(TopHitsAggregation::create('hits', 1))
// )
;
foreach ($sorts as $sort) { foreach ($this->extractSort($request) as $field => $direction) {
$builder->addSort($sort); $field = $this->translateSort[$field] ?? $field;
$builder->addSort(new Sort($field, $direction));
} }
// track_total_hits: false lets ES stop counting once it has collected $response = $builder->search();
// 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,
// add it so the code below can look for it
if (! in_array('mostRelevantPost', $include)) { if (! in_array('mostRelevantPost', $include)) {
$include[] = 'mostRelevantPost'; $include[] = 'mostRelevantPost';
} }
} }
// All hits are discussion documents. Extract the best-matching post ID from // we need to retrieve all discussion ids and when the results are posts,
// inner_hits when a has_child clause matched (i.e. the match came from a post). // their ids as most relevant post id
$results = Collection::make(Arr::get($response, 'hits.hits')) $results = Collection::make(Arr::get($response, 'hits.hits'))
->map(function ($hit) { ->map(function ($hit) {
// _id is "discussions:123" — parse the numeric part directly. $id = Str::after($hit['_source']['id'], ':');
$discussionId = Str::after($hit['_id'], 'discussions:'); $type = $hit['_source']['type'];
// rawId on the inner hit gives us the integer post ID.
$bestPostId = Arr::get($hit, 'inner_hits.best_post.hits.hits.0._source.rawId');
if ($type === 'posts') {
return [ return [
'discussion_id' => $discussionId, 'most_relevant_post_id' => $id,
'most_relevant_post_id' => $bestPostId,
'weight' => Arr::get($hit, 'sort.0', Arr::get($hit, '_score', 0)),
]; ];
} else {
return [
'discussion_id' => $id
];
}
}); });
$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() $discussions = Discussion::query()
->when( ->whereIn('id', $results->pluck('discussion_id'))
$actor->isGuest() || !$actor->hasPermission('discussion.hide'), ->orWhereHas('posts', function ($query) use ($results) {
fn ($q) => $q->whereNull('hidden_at') $query->whereIn('id', $results->pluck('most_relevant_post_id'));
) })
->whereIn('id', $results->pluck('discussion_id')->filter())
->get() ->get()
->each(function (Discussion $discussion) use ($results) { ->each(function (Discussion $discussion) use ($results) {
$result = $results->firstWhere('discussion_id', $discussion->id); if (in_array($discussion->id, $results->pluck('discussion_id')->toArray())) {
$discussion->most_relevant_post_id = $discussion->first_post_id;
$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;
}
}) })
->keyBy('id') ->keyBy('id')
->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);
@ -222,258 +136,56 @@ 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.
* 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');
$query->add($this->buildAutocompleteQuery($search), 'must');
$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(); $documents = resolve(Container::class)->tagged('blomstra.search.documents');
// Title prefix match (stronger) return collect($documents)->first(function (ElasticDocument $document) use ($type) {
$query->add( return $document->type() === $type;
(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
{ {
return resolve(ExtensionManager::class)->isEnabled($extension); /** @var ExtensionManager $manager */
$manager = resolve(ExtensionManager::class);
return $manager->isEnabled($extension);
} }
protected function addFilters(BoolQuery $query, User $actor, array $filters = []): void protected function addFilters(BoolQuery $query, User $actor): BoolQuery
{ {
$groups = $this->getGroups($actor); /** @var Collection $groups */
$onlyPrivate = Str::contains($filters['q'] ?? '', 'is:private'); $groups = $actor->groups->pluck('id');
$groups->add(Group::GUEST_ID);
if ($actor->is_email_confirmed) $groups->add(Group::MEMBER_ID);
$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'), 'should')
->add( ->add(
BoolQuery::create() BoolQuery::create()
->add(TermsQuery::create('recipient_groups', $groups->toArray()), 'should') ->add(TermsQuery::create('recipient-groups', $groups->toArray()))
->add(TermsQuery::create('recipient_users', [$actor->id]), 'should'), ->add(TermQuery::create('recipient-users', $actor->id)),
'should'
); );
if ($onlyPrivate) {
$subQuery = $byobuQuery;
} else {
$subQuery = BoolQuery::create() $subQuery = BoolQuery::create()
->add($subQuery, 'should') ->add($subQuery, 'should')
->add($byobuQuery, 'should'); ->add($byobuQuery, 'should');
} }
}
$query->add($subQuery, 'filter'); $query->add(
} $subQuery,
'filter'
);
protected function getGroups(User $actor): Collection return $query;
{
$groups = $actor->groups->pluck('id');
$groups->add(Group::GUEST_ID);
if ($actor->is_email_confirmed) {
$groups->add(Group::MEMBER_ID);
}
return $groups;
}
protected function getSearch(array $filters): ?string
{
$search = Arr::get($filters, 'q');
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))
->filter(fn (string $part) => !preg_match('/^\w+:/', $part))
->filter()
->join(' ');
return empty($q) ? null : $q;
}
return null;
} }
} }

View File

@ -1,673 +1,93 @@
<?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.
*
*/
namespace Sbp\Search\Commands; use Blomstra\Search\Jobs\SavingJob;
use Blomstra\Search\Seeders\Seeder;
use Sbp\Search\Jobs\Job;
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
{action? : build | promote | rollback | discard | mapping | fill} {--max-id= : Limits for each object the number of items to seed}';
{--fresh} protected $description = 'Rebuilds the complete search server with its documents.';
{--resume}
{--staging}
{--keep-backup}
{--pending}
{--backup}
{--only=}
{--max-id=}
{--throttle=}
{--i-am-sure}';
protected $description = 'Build and manage the Elasticsearch search index.'; public function handle(Container $container)
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);
/** @var string $alias */ $properties = [
$alias = $container->make('blomstra.search.elastic_index');
if (!$this->argument('action')) {
$this->call('help', ['command_name' => $this->getName()]);
return;
}
switch ($this->argument('action')) {
case 'build':
$this->runBuild($client, $alias, $settings, $container);
break;
case 'promote':
$this->runPromote($client, $alias, $settings);
break;
case 'rollback':
$this->runRollback($client, $alias, $settings);
break;
case 'discard':
$this->runDiscard($client, $settings);
break;
case 'mapping':
$client->indices()->putMapping([
'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');
/** @var Seeder $seeder */
foreach ($seeders as $seeder) {
if ($only && $seeder->type() !== $only) {
continue;
}
$total = 0;
if ($this->option('resume')) {
$saved = $this->getContinueAt($settings, $seeder->type());
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);
}
/** @var Collection $collection */
$collection = $seeder->query()
->latest('id')
->whereBetween('id', [$rangeFrom, $rangeTo])
->when($this->option('max-id'), fn ($q, $id) => $q->where('id', '<=', $id))
->when($seeded, fn ($q, $seeded) => $q->whereNotIn('id', $seeded))
->get();
$min = $collection->min('id');
if ($seedMissing && $collection->isEmpty()) {
$continueAt = $rangeFrom > 2 ? $rangeFrom - 1 : null;
} else {
$continueAt = $min && $min > 2 ? $min - 1 : null;
}
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();
$this->setContinueAt($settings, $seeder->type(), $continueAt);
if ($throttle = $this->option('throttle')) {
$this->info("Throttling for $throttle seconds");
sleep($throttle);
}
}
$this->setContinueAt($settings, $seeder->type(), 0);
$this->info("Queued a total of $total {$seeder->type()} for indexing.");
}
}
/**
* First install: create a timestamped concrete index, alias the configured name to it
* immediately, and return the alias so seeding goes through it and is live from the start.
*/
protected function prepareFirstInstall(
Client $client,
string $alias,
SettingsRepositoryInterface $settings,
iterable $seeders
): string {
$concrete = $alias . '_' . date('YmdHis');
$client->indices()->create([
'index' => $concrete,
'body' => ['settings' => $this->buildIndexSettings($settings)],
]);
$client->indices()->putAlias(['index' => $concrete, 'name' => $alias]);
$settings->set('blomstra-search.active-index', $concrete);
$this->saveIndexedConfig($client, $settings, $concrete);
foreach ($seeders as $seeder) {
$this->setContinueAt($settings, $seeder->type(), null);
}
$this->info("Created '$concrete', aliased '$alias' → '$concrete'.");
$this->info("Index is live — documents become searchable as the queue processes.");
return $alias;
}
/**
* Prepare the staging index for a blue-green build.
*
* - If a staging build exists and --fresh is not set, resume it.
* - Otherwise create a fresh timestamped index and save it as staging.
*/
protected function prepareStagingIndex(
Client $client,
string $alias,
SettingsRepositoryInterface $settings,
iterable $seeders
): string {
$staging = $settings->get('blomstra-search.staging-index');
if ($this->option('fresh') && $staging) {
if ($client->indices()->exists(['index' => $staging])) {
$client->indices()->delete(['index' => $staging]);
$this->info("Dropped staging index: $staging");
}
$staging = null;
$settings->set('blomstra-search.staging-index', null);
}
if ($staging && $client->indices()->exists(['index' => $staging])) {
$this->info("Resuming staging index build: $staging");
return $staging;
}
$staging = $alias . '_' . date('YmdHis');
$client->indices()->create([
'index' => $staging,
'body' => ['settings' => $this->buildIndexSettings($settings)],
]);
$settings->set('blomstra-search.staging-index', $staging);
foreach ($seeders as $seeder) {
$this->setContinueAt($settings, $seeder->type(), null);
}
$this->info("Created staging index: $staging");
return $staging;
}
/**
* Persist the analysis config and compat version that are actually live in ES for the
* given index. Reading from ES (rather than from Flarum settings) means rollbacks are
* also covered: the stored values always reflect the index that is currently aliased,
* not the settings at the time the command ran. Indexes built before _meta tracking
* existed will yield a null compat version, which correctly triggers the reindex warning.
*/
protected function saveIndexedConfig(Client $client, SettingsRepositoryInterface $settings, string $indexName): void
{
$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' => [ 'properties' => [
'join_field' => ['type' => 'join', 'relations' => ['discussion' => 'post']], 'content' => ['type' => 'text', 'analyzer' => 'flarum_analyzer'],
'discussion_id' => ['type' => 'integer'],
'content' => $contentMapping,
'title' => $titleMapping,
'rawId' => ['type' => 'integer'],
'created_at' => ['type' => 'date'], 'created_at' => ['type' => 'date'],
'updated_at' => ['type' => 'date'], 'updated_at' => ['type' => 'date'],
'is_private' => ['type' => 'boolean'], 'is_private' => ['type' => 'boolean'],
'user_id' => ['type' => 'integer'],
'groups' => ['type' => 'keyword'],
'comment_count' => ['type' => 'integer'],
'tags' => ['type' => 'keyword'],
'is_sticky' => ['type' => 'boolean'], 'is_sticky' => ['type' => 'boolean'],
'is_hidden' => ['type' => 'boolean'], 'groups' => ['type' => 'integer'],
], 'recipient_groups' => ['type' => 'integer'],
'recipient_users' => ['type' => 'integer'],
]
]; ];
}
/**
* 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) { // Flush the index.
try { $client->indices()->delete([
$response = (new Builder($client)) 'index' => $index,
->index($targetIndex) 'ignore_unavailable' => true
->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'); // Create a new index.
} catch (ElasticsearchException $e) { $client->indices()->create([
$attempt++; 'index' => $index,
'body' => [
'settings' => [
'analysis' => [
'analyzer' => [
'flarum_analyzer' => [
'type' => $settings->get('blomstra-search.analyzer-language') ?: 'english'
]
]
]
]
]
]);
if ($attempt >= $maxRetries) { $client->indices()->putMapping([
throw $e; 'index' => $index,
} 'body' => $properties
]);
$this->warn("ES error on range {$rangeFrom}{$rangeTo} (attempt {$attempt}/{$maxRetries}): {$e->getMessage()}. Waiting 65 s before retry…"); /** @var Seeder $seeder */
sleep(65); // outlast the StaticNoPingConnectionPool dead-node window (default 60 s) foreach ($seeders as $seeder) {
} $seeder->query()
} ->when($this->option('max-id'), function ($query, $id) {
} $query->where('id', '<=', $id);
})
->chunk(50, function (Collection $collection) use ($queue, &$total, $seeder) {
$queue->push(new SavingJob($collection, $seeder));
protected function getContinueAt(SettingsRepositoryInterface $settings, string $type): ?int $this->info("Pushed into the index, type: {$seeder->type()}, amount: {$collection->count()}.");
{
$raw = $settings->get("blomstra-search.continued-at.$type");
return $raw !== null ? (int) $raw : null; $total += $collection->count();
} });
protected function setContinueAt(SettingsRepositoryInterface $settings, string $type, ?int $at): void $this->info("Pushed a total of $total into the index.");
{ }
$settings->set("blomstra-search.continued-at.$type", $at);
} }
} }

View File

@ -1,41 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace 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;
}
}

View File

@ -1,57 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace 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];
}
}

View File

@ -1,38 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Sbp\Search\Elasticsearch;
use Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery;
class MatchPhraseQuery extends MatchQuery
{
protected float $boost = 1;
public function boost(float $boost = 1)
{
$this->boost = $boost;
return $this;
}
public function toArray(): array
{
$query = parent::toArray()['match'];
$query[$this->field]['boost'] = $this->boost;
return [
'match_phrase' => $query,
];
}
}

View File

@ -1,71 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Sbp\Search\Elasticsearch;
class MatchQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery
{
protected string $operator = 'or';
protected float $boost = 1;
protected ?string $analyzer = null;
protected bool $zeroTerms = false;
public function and()
{
$this->operator = 'and';
return $this;
}
public function operator(string $operator)
{
$this->operator = $operator;
return $this;
}
public function boost(float $boost = 1)
{
$this->boost = $boost;
return $this;
}
public function analyzer(string $analyzer)
{
$this->analyzer = $analyzer;
return $this;
}
public function zeroTerms(bool $zeroTerms = true)
{
$this->zeroTerms = $zeroTerms;
return $this;
}
public function toArray(): array
{
$query = parent::toArray();
$query['match'][$this->field]['operator'] = $this->operator;
$query['match'][$this->field]['boost'] = $this->boost;
$query['match'][$this->field]['zero_terms_query'] = $this->zeroTerms ? 'all' : 'none';
if ($this->analyzer !== null) {
$query['match'][$this->field]['analyzer'] = $this->analyzer;
}
return $query;
}
}

View File

@ -1,59 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Sbp\Search\Elasticsearch;
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
class SimpleSearchQuery implements Query
{
protected float $boost = 1;
protected ?string $analyzer = null;
public static function create(array $field, string $value)
{
return new self($field, $value);
}
public function boost(float $boost = 1)
{
$this->boost = $boost;
return $this;
}
public function analyzer(string $analyzer)
{
$this->analyzer = $analyzer;
return $this;
}
public function __construct(
protected array $fields,
protected string $value
) {
}
public function toArray(): array
{
return [
'simple_query_string' => [
'query' => $this->value,
'fields' => $this->fields,
'analyzer' => $this->analyzer,
'default_operator' => 'AND',
'boost' => $this->boost,
],
];
}
}

View File

@ -1,16 +1,6 @@
<?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;

View File

@ -1,55 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Sbp\Search\Elasticsearch;
class WildcardQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\WildcardQuery
{
protected float $boost = 1;
protected bool $sensitivity = true;
protected ?string $rewrite = null;
public function boost(float $boost = 1)
{
$this->boost = $boost;
return $this;
}
public function caseSensitivity(bool $sensitivity = true)
{
$this->sensitivity = $sensitivity;
return $this;
}
public function rewrite(string $rewrite = null)
{
$this->rewrite = $rewrite;
return $this;
}
public function toArray(): array
{
$query = parent::toArray();
$query['wildcard'][$this->field]['boost'] = $this->boost;
$query['wildcard'][$this->field]['case_insensitive'] = !$this->sensitivity;
if ($this->rewrite) {
$query['wildcard'][$this->field]['rewrite'] = $this->rewrite;
}
return $query;
}
}

View File

@ -1,23 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Sbp\Search\Exceptions;
use Throwable;
class SeedingException extends \Exception
{
public function __construct($message = '', public array $items, $code = 0, Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@ -1,16 +1,6 @@
<?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;
@ -19,24 +9,21 @@ class DeletingJob extends Job
{ {
public function handle(Client $client) public function handle(Client $client)
{ {
if ($this->models->isEmpty()) { if ($this->models->isEmpty()) return;
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, 'routing' => $routing]], ['delete' => ['_index' => $this->index, '_id' => $document->id]]
]; ];
})->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
]); ]);
} }
} }

View File

@ -1,20 +1,10 @@
<?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 Blomstra\Search\Seeders\Seeder;
use Sbp\Search\Seeders\Seeder;
use Flarum\Queue\AbstractJob; use Flarum\Queue\AbstractJob;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection;
abstract class Job extends AbstractJob abstract class Job extends AbstractJob
{ {
@ -22,16 +12,10 @@ 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 = $targetIndex ?? resolve('blomstra.search.elastic_index'); $this->index = resolve('blomstra.search.elastic_index');
if (static::$onQueue) { if (static::$onQueue) $this->onQueue(static::$onQueue);
$this->onQueue(static::$onQueue);
}
} }
} }

31
src/Jobs/SavingJob.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace Blomstra\Search\Jobs;
use Elasticsearch\Client;
use Illuminate\Database\Eloquent\Model;
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
]);
}
}

View File

@ -1,62 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace 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
);
}
}

View File

@ -1,59 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace 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
}
}
}

View File

@ -1,33 +1,18 @@
<?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 Blomstra\Search\Jobs\DeletingJob;
use Blomstra\Search\Jobs\SavingJob;
use Sbp\Search\Jobs\DeletingJob; use Blomstra\Search\Seeders;
use Sbp\Search\Jobs\Job; use Elasticsearch\Client;
use Sbp\Search\Jobs\UpdateSearchJob;
use Sbp\Search\Jobs\ViewsSearchJob;
use Elasticsearch\Client as Elastic;
use Elasticsearch\ClientBuilder; use Elasticsearch\ClientBuilder;
use Flarum\Api\Client;
use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Config;
use Flarum\Http\Middleware\ExecuteRoute;
use Flarum\Settings\SettingsRepositoryInterface; use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Queue\Queue; use Illuminate\Contracts\Queue\Queue;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection;
use Laminas\Stratigility\MiddlewarePipe;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class Provider extends AbstractServiceProvider class Provider extends AbstractServiceProvider
@ -42,22 +27,10 @@ class Provider extends AbstractServiceProvider
/** @var SettingsRepositoryInterface $settings */ /** @var SettingsRepositoryInterface $settings */
$settings = $this->container->make(SettingsRepositoryInterface::class); $settings = $this->container->make(SettingsRepositoryInterface::class);
/** @var Config $config */ $this->container->singleton(Client::class, function (Container $container) use ($settings) {
$config = $this->container->make(Config::class);
$this->container->singleton(Elastic::class, function (Container $container) use ($settings, $config) {
$builder = ClientBuilder::create() $builder = ClientBuilder::create()
->setHosts([$settings->get('blomstra-search.elastic-endpoint')]) ->setHosts([$settings->get('blomstra-search.elastic-endpoint')])
->setConnectionParams([ ->setLogger($container->make(LoggerInterface::class));
'client' => [
'connect_timeout' => 2, // fail fast if ES is unreachable
'timeout' => 10, // allow time for complex queries
],
]);
if ($config->inDebugMode()) {
$builder->setLogger($container->make(LoggerInterface::class));
}
if ($settings->get('blomstra-search.elastic-username')) { if ($settings->get('blomstra-search.elastic-username')) {
$builder->setBasicAuthentication( $builder->setBasicAuthentication(
@ -69,36 +42,11 @@ 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')
); );
$this->container->extend(
Client::class,
function () {
$pipe = new MiddlewarePipe();
$exclude = resolve('flarum.api_client.exclude_middleware');
$middlewareStack = array_filter(resolve('flarum.api.middleware'), function ($middlewareClass) use ($exclude) {
return !in_array($middlewareClass, $exclude);
});
foreach ($middlewareStack as $middleware) {
$pipe->pipe(resolve($middleware));
}
$pipe->pipe(new ExecuteRoute());
return new Api\Client($pipe);
}
);
$this->container->tag([
Searchers\DiscussionSearcher::class,
Searchers\CommentPostSearcher::class,
], 'blomstra.search.searchers');
} }
public function boot() public function boot()
@ -115,15 +63,11 @@ 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 UpdateSearchJob(Collection::make([$model]), $seeder)); $queue->push(new SavingJob(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->push(new DeletingJob(Collection::make([$model]), $seeder));
});
$seeder::viewingOn($events, function (int $discussionId) use ($queue) {
$queue->pushOn(Job::$onQueue, new ViewsSearchJob($discussionId));
}); });
} }
} }

View File

@ -1,24 +1,13 @@
<?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
@ -31,13 +20,4 @@ use Illuminate\Support\Fluent;
*/ */
class Document extends Fluent class Document extends Fluent
{ {
/**
* Exclude `id` from the body sent to Elasticsearch. The document ID is
* already stored as `_id` by the bulk API action; duplicating it in
* `_source` wastes space and is redundant.
*/
public function toArray(): array
{
return Arr::except($this->attributes, ['id']);
}
} }

View File

@ -1,27 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace 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);
}
}

View File

@ -1,32 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace 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;
}
}

View File

@ -1,48 +0,0 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace 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);
}
}

View File

@ -1,23 +1,12 @@
<?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.
*
*/
namespace Sbp\Search\Seeders; use Blomstra\Search\Save\Document;
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 as Core; use Flarum\Post\Event\Deleted;
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;
@ -26,71 +15,52 @@ 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
{ {
return CommentPost::query() return CommentPost::query()
->where('type', CommentPost::$type); ->where('type', CommentPost::$type)
->with('discussion');
} }
public static function savingOn(Dispatcher $events, callable $callable) public static function savingOn(Dispatcher $events, callable $callable)
{ {
$events->listen([ $events->listen(Posted::class, function (Posted $event) use ($callable) {
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([ $events->listen(Deleted::class, function (Deleted $event) use ($callable) {
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
{ {
return new Document([ $document = new Document([
'join_field' => ['name' => $this->joinRelation(), 'parent' => "{$this->discussionType()}:{$model->discussion_id}"], 'type' => $this->type(),
'discussion_id' => $model->discussion_id,
'id' => $this->type() . ':' . $model->id, 'id' => $this->type() . ':' . $model->id,
'rawId' => $model->id,
'content' => $model->content, 'content' => $model->content,
'is_hidden' => $model->hidden_at !== null, 'created_at' => $model->created_at?->toAtomString(),
'updated_at' => $model->edited_at?->toAtomString(),
'is_private' => $model->is_private,
'user_id' => $model->user_id,
'groups' => $this->groupsForDiscussion($model->discussion)
]); ]);
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;
} }
} }

View File

@ -1,210 +1,70 @@
<?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.
*
*/
namespace Sbp\Search\Seeders; use Blomstra\Search\Save\Document;
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 as Core; use Flarum\Discussion\Event\Deleted;
use Flarum\Extension\ExtensionManager; use Flarum\Discussion\Event\Hidden;
use Flarum\Group\Group; use Flarum\Discussion\Event\Restored;
use Flarum\Group\Permission; use Flarum\Discussion\Event\Started;
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() return Discussion::query();
->whereNull('hidden_at');
}
public function relationships(): array
{
$includes = [];
if ($this->extensionEnabled('flarum-tags')) {
$includes[] = 'tags';
}
if ($this->extensionEnabled('fof-byobu')) {
$includes[] = 'recipientUsers';
$includes[] = 'recipientGroups';
}
return $includes;
} }
public static function savingOn(Dispatcher $events, callable $callable) public static function savingOn(Dispatcher $events, callable $callable)
{ {
$events->listen([ $events->listen([Started::class, Restored::class], function ($event) use ($callable) {
// 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([ $events->listen([Deleted::class, Hidden::class], function ($event) use ($callable) {
// 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([
'join_field' => $this->joinRelation(), 'type' => $this->type(),
'id' => $this->type() . ':' . $model->id, 'id' => $this->type() . ':' . $model->id,
'rawId' => $model->id,
'content' => $model->title, 'content' => $model->title,
'title' => $model->title,
'created_at' => $model->created_at?->toAtomString(), 'created_at' => $model->created_at?->toAtomString(),
'updated_at' => $model->last_posted_at?->toAtomString(), 'updated_at' => $model->last_posted_at?->toAtomString(),
'is_private' => $model->is_private, 'is_private' => $model->is_private,
'user_id' => $model->user_id, 'user_id' => $model->user_id,
'groups' => $this->groupsForDiscussion($model), '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 $document['recipient_users'] = $model->recipientUsers->pluck('id')->toArray();
->whereNull('removed_at') $document['recipient_groups'] = $model->recipientGroups->pluck('id')->toArray();
->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'] = $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();
}
} }

View File

@ -1,54 +1,66 @@
<?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.
*
*/
namespace Sbp\Search\Seeders; use Blomstra\Search\Save\Document;
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;
/** Relationships to eager-load on the collection before calling toDocument(). */ protected function groupsForDiscussion(Discussion $discussion): array
public function relationships(): array
{ {
return []; $permissions = collect();
$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