Compare commits

..

No commits in common. "main" and "dk/search-improvements" have entirely different histories.

45 changed files with 2473 additions and 3234 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/*

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,5 +1,5 @@
{ {
"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",
@ -11,20 +11,16 @@
"elasticsearch" "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",
"email": "info@sbp.de"
},
{ {
"name": "Team Blomstra", "name": "Team Blomstra",
"email": "development@blomstra.net", "email": "development@blomstra.net",
"role": "Original Developer" "role": "Developer"
} }
], ],
"require": { "require": {
@ -47,7 +43,7 @@
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Sbp\\Search\\": "src/" "Blomstra\\Search\\": "src/"
} }
} }
} }

View File

@ -1,41 +1,29 @@
<?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), ->put('/blomstra/search/index', 'blomstra.search.index', Api\Controllers\IndexController::class),
(new Flarum\Console())
(new Flarum\Console)
->command(Commands\BuildCommand::class), ->command(Commands\BuildCommand::class),
(new Flarum\Settings()) (new Flarum\Settings)
->default('blomstra-search.search-discussion-subjects', true) ->default('blomstra-search.search-discussion-subjects', true)
->default('blomstra-search.search-post-bodies', true) ->default('blomstra-search.search-post-bodies', true)
->default('blomstra-search.min-search-length', Commands\BuildCommand::DEFAULT_MIN_SEARCH_LENGTH) ->default('blomstra-search.match-sentences', true)
->default('blomstra-search.stem-exclusion', '') ->default('blomstra-search.match-words', true)
->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={}})(); (()=>{var a={n:t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return a.d(e,{a:e}),e},d:(t,e)=>{for(var s in e)a.o(e,s)&&!a.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:e[s]})},o:(a,t)=>Object.prototype.hasOwnProperty.call(a,t),r:a=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(a,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(a,"__esModule",{value:!0})}},t={};(()=>{"use strict";a.r(t);const e=flarum.core.compat["admin/app"];var s=a.n(e);s().initializers.add("blomstra-search",(function(){var a=new Map;["arabic","armenian","basque","bengali","brazilian","bulgarian","catalan","cjk","czech","danish","dutch","english","estonian","finnish","french","galician","german","greek","hindi","hungarian","indonesian","irish","italian","latvian","lithuanian","norwegian","persian","portuguese","romanian","russian","sorani","spanish","swedish","turkish","thai"].forEach((function(t){a.set(t,t)})),s().extensionData.for("blomstra-search").registerSetting({setting:"blomstra-search.elastic-endpoint",label:s().translator.trans("blomstra-search.admin.elastic-endpoint"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-username",label:s().translator.trans("blomstra-search.admin.elastic-username"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-password",label:s().translator.trans("blomstra-search.admin.elastic-password"),type:"password"}).registerSetting({setting:"blomstra-search.elastic-index",label:s().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.analyzer-language",label:s().translator.trans("blomstra-search.admin.analyzer.label"),help:s().translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:Object.fromEntries(a.entries()),default:"english"}).registerSetting({setting:"blomstra-search.elastic-index",label:s().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.search-discussion-subjects",label:s().translator.trans("blomstra-search.admin.search-discussion-subjects"),type:"switch"}).registerSetting({setting:"blomstra-search.search-post-bodies",label:s().translator.trans("blomstra-search.admin.search-post-bodies"),type:"switch"}).registerSetting({setting:"blomstra-search.match-sentences",label:s().translator.trans("blomstra-search.admin.match-sentences"),type:"switch"}).registerSetting({setting:"blomstra-search.match-words",label:s().translator.trans("blomstra-search.admin.match-words"),type:"switch"})}))})(),module.exports=t})();
//# 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

3032
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": "^2.0.0",
"webpack": "^5.75.0", "pusher-js": "^7.0.3",
"webpack-cli": "^5.0.0" "webpack": "^5.72.0",
"webpack-cli": "^4.9.2"
}, },
"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.6.2"
} }
} }

View File

@ -1,98 +1,49 @@
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 { [
className() { 'arabic',
return 'ReindexWarningWidget'; 'armenian',
} 'basque',
'bengali',
content() { 'brazilian',
return m( 'bulgarian',
Alert, 'catalan',
{ 'cjk',
type: 'warning', 'czech',
dismissible: false, 'danish',
icon: 'fas fa-exclamation-triangle', 'dutch',
title: app.translator.trans('blomstra-search.admin.reindex-required.title'), 'english',
}, 'estonian',
app.translator.trans('blomstra-search.admin.reindex-required.detail') 'finnish',
); 'french',
} 'galician',
} 'german',
'greek',
app.initializers.add('sbp-search', () => { 'hindi',
const activeIndex = app.data.settings['blomstra-search.active-index']; 'hungarian',
const compatVersion = app.data.settings['blomstra-search.index-compatible']; 'indonesian',
'irish',
if (activeIndex && compatVersion !== REQUIRED_INDEX_COMPAT) { 'italian',
extend(DashboardPage.prototype, 'availableWidgets', function (items) { 'latvian',
items.add('blomstra-search-reindex', m(ReindexWarningWidget), 110); 'lithuanian',
'norwegian',
'persian',
'portuguese',
'romanian',
'russian',
'sorani',
'spanish',
'swedish',
'turkish',
'thai',
].forEach((language) => {
languages.set(language, language);
}); });
}
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'),
@ -114,6 +65,20 @@ app.initializers.add('sbp-search', () => {
default: 'flarum', default: 'flarum',
type: 'input', type: 'input',
}) })
.registerSetting({
setting: 'blomstra-search.analyzer-language',
label: app.translator.trans('blomstra-search.admin.analyzer.label'),
help: app.translator.trans('blomstra-search.admin.analyzer.help'),
type: 'select',
options: Object.fromEntries(languages.entries()),
default: 'english',
})
.registerSetting({
setting: 'blomstra-search.elastic-index',
label: app.translator.trans('blomstra-search.admin.elastic-index'),
default: 'flarum',
type: 'input',
})
.registerSetting({ .registerSetting({
setting: 'blomstra-search.search-discussion-subjects', setting: 'blomstra-search.search-discussion-subjects',
label: app.translator.trans('blomstra-search.admin.search-discussion-subjects'), label: app.translator.trans('blomstra-search.admin.search-discussion-subjects'),
@ -125,33 +90,13 @@ app.initializers.add('sbp-search', () => {
type: 'switch', type: 'switch',
}) })
.registerSetting({ .registerSetting({
setting: 'blomstra-search.analyzer-language', setting: 'blomstra-search.match-sentences',
label: app.translator.trans('blomstra-search.admin.analyzer.label'), label: app.translator.trans('blomstra-search.admin.match-sentences'),
help: app.translator.trans('blomstra-search.admin.analyzer.help'), type: 'switch',
type: 'select',
options: languages,
default: 'english',
}) })
.registerSetting(function (this: any) { .registerSetting({
const isCjk = (this.setting('blomstra-search.analyzer-language')() || 'english') === 'cjk'; setting: 'blomstra-search.match-words',
return this.buildSettingComponent({ label: app.translator.trans('blomstra-search.admin.match-words'),
setting: 'blomstra-search.stem-exclusion', type: 'switch',
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,9 +6,10 @@ import DiscussionListState from 'flarum/forum/states/DiscussionListState';
export default function extendDiscussionState() { export default function extendDiscussionState() {
override(DiscussionListState.prototype, 'loadPage', async function (this: DiscussionListState, original, page: number = 1) { override(DiscussionListState.prototype, 'loadPage', async function (this: DiscussionListState, original, page: number = 1) {
const preloaded = app.data.apiDocument || null;
// If existing payload is given or no search is made, fallback on native page. // If existing payload is given or no search is made, fallback on native page.
if (!this.requestParams()?.filter?.q) return original.call(this, page); if (preloaded || !this.requestParams()?.filter?.q) return original.call(this, page);
const params = this.requestParams(); const params = this.requestParams();
params.page = { params.page = {
@ -16,13 +17,12 @@ 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' });

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,20 @@
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()); items.add('discussions', new DiscussionsSearchSource());
});
}); });
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,8 @@ 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-discussion-subjects: Search inside discussion titles
search-post-bodies: Search inside comments search-post-bodies: Search inside comments
match-sentences: Match search term against full sentence match-sentences: Match search term against full sentence
match-words: Match search term against full words match-words: Match search term against full words
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,16 +1,6 @@
<?php <?php
/* namespace Blomstra\Search\Api;
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Sbp\Search\Api;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
@ -19,9 +9,7 @@ class Client extends \Flarum\Api\Client
{ {
public function get(string $path): ResponseInterface public function get(string $path): ResponseInterface
{ {
if ($path === '/discussions' && Arr::has($this->queryParams, 'filter.q')) { if ($path === '/discussions' && Arr::has($this->queryParams, 'filter.q')) return parent::get("/blomstra/search/discussions");
return parent::get('/blomstra/search/discussions');
}
return parent::get($path); return parent::get($path);
} }

View File

@ -1,18 +1,8 @@
<?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\Commands\BuildCommand;
use Sbp\Search\Commands\BuildCommand;
use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
@ -33,8 +23,7 @@ class IndexController implements RequestHandlerInterface
$command->run( $command->run(
new ArrayInput([]), new ArrayInput([]),
new ConsoleOutput() new ConsoleOutput);
);
return new EmptyResponse(); return new EmptyResponse();
} }

View File

@ -1,24 +1,12 @@
<?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\Elasticsearch\MatchPhraseQuery;
use Blomstra\Search\Elasticsearch\MatchQuery;
use Sbp\Search\Elasticsearch\HasChildQuery; use Blomstra\Search\Save\Document as ElasticDocument;
use Sbp\Search\Elasticsearch\MatchPhraseQuery; use Blomstra\Search\Elasticsearch\TermsQuery;
use Sbp\Search\Elasticsearch\MatchQuery; use Blomstra\Search\Searchers\Searcher;
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;
@ -28,16 +16,15 @@ use Flarum\Group\Group;
use Flarum\Http\RequestUtil; use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator; use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface; use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\Tags\Tag;
use Flarum\User\User; use Flarum\User\User;
use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Spatie\ElasticsearchQueryBuilder\Builder; use Spatie\ElasticsearchQueryBuilder\Builder;
use Sbp\Search\Elasticsearch\BoolQuery; use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery; use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery;
use Spatie\ElasticsearchQueryBuilder\Sorts\Sort; use Spatie\ElasticsearchQueryBuilder\Sorts\Sort;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@ -49,134 +36,105 @@ class SearchController extends ListDiscussionsController
protected array $translateSort = [ protected array $translateSort = [
'lastPostedAt' => 'updated_at', 'lastPostedAt' => 'updated_at',
'createdAt' => 'created_at', 'createdAt' => 'created_at',
'commentCount' => 'comment_count', 'commentCount' => 'comment_count'
'view_count' => 'view_count',
]; ];
protected Collection $searchers;
protected bool $matchSentences; protected bool $matchSentences;
protected bool $matchWords; protected bool $matchWords;
protected ?Searcher $discussionSearcher;
protected ?Searcher $postSearcher;
public function __construct(protected Client $elastic, protected UrlGenerator $uri, Container $container, SettingsRepositoryInterface $settings) public function __construct(protected Client $elastic, protected UrlGenerator $uri, Container $container, SettingsRepositoryInterface $settings)
{ {
$this->matchSentences = true; $this->searchers = $this->gatherSearchers($container->tagged('blomstra.search.searchers'), $container);
$this->matchWords = true;
$searchers = collect($container->tagged('blomstra.search.searchers')); $this->matchSentences = (bool) $settings->get('blomstra-search.match-sentences', true);
$this->matchWords = (bool) $settings->get('blomstra-search.match-words', true);
}
protected function gatherSearchers(iterable $searchers, Container $container)
{
return collect($searchers)
->map(fn ($searcher) => new $searcher)
->filter(fn (Searcher $searcher) => $searcher->enabled());
$this->discussionSearcher = $searchers->first(fn ($s) => $s instanceof DiscussionSearcher);
$this->postSearcher = $searchers->first(fn ($s) => $s instanceof CommentPostSearcher);
} }
protected function data(ServerRequestInterface $request, Document $document) protected function data(ServerRequestInterface $request, Document $document)
{ {
// Not used for now.
$type = Arr::get($request->getQueryParams(), 'type');
$actor = RequestUtil::getActor($request); $actor = RequestUtil::getActor($request);
$filters = $this->extractFilter($request); $filters = $this->extractFilter($request);
$search = $this->getSearch($filters); $search = $this->getSearch($filters);
$limit = $this->extractLimit($request); $limit = $this->extractLimit($request);
$offset = $this->extractOffset($request); $offset = $this->extractOffset($request);
$include = array_merge($this->extractInclude($request), ['state']); $include = array_merge($this->extractInclude($request), ['state']);
// Autocomplete path: fast prefix match on title.autocomplete only. $filterQuery = BoolQuery::create();
if (!empty($filters['autocomplete']) && !empty($search)) {
return $this->handleAutocomplete($search, $actor, $limit, $offset, $include, $document, $request);
}
$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)) { if (! empty($search)) {
$query->add($this->buildTextQuery($search, $actor, $needsScoring)); if ($this->matchSentences) $filterQuery->add($this->sentenceMatch($search));
if ($this->matchWords) $filterQuery->add($this->wordMatch($search, 'and'));
if ($this->matchWords) $filterQuery->add($this->wordMatch($search, 'or'));
} }
$this->addFilters($query, $actor, $filters);
$builder = (new Builder($this->elastic)) $builder = (new Builder($this->elastic))
->index(resolve('blomstra.search.elastic_index')) ->index(resolve('blomstra.search.elastic_index'))
->addQuery($query); ->size($limit + 1)
->from($this->extractOffset($request))
->addQuery(
$this->addFilters($filterQuery, $actor, $filters)
);
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. $type = $hit['_source']['type'];
$discussionId = Str::after($hit['_id'], 'discussions:'); $id = Str::after($hit['_source']['id'], "$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')
'weight' => Arr::get($hit, 'sort.0', Arr::get($hit, '_score', 0)),
]; ];
} else {
return [
'discussion_id' => $id,
'weight' => Arr::get($hit, 'sort.0')
];
}
}); });
$document->addPaginationLinks( $document->addPaginationLinks(
$this->uri->to('api')->route('sbp.search', ['type' => 'discussions']), $this->uri->to('api')->route('blomstra.search', [
'type' => 'discussions'
]),
$request->getQueryParams(), $request->getQueryParams(),
$offset, $offset,
$limit, $limit,
@ -186,25 +144,28 @@ class SearchController extends ListDiscussionsController
$results = $results->take($limit); $results = $results->take($limit);
$discussions = Discussion::query() $discussions = Discussion::query()
->when( ->select('discussions.*')
$actor->isGuest() || !$actor->hasPermission('discussion.hide'), ->join('posts', 'posts.discussion_id', 'discussions.id')
fn ($q) => $q->whereNull('hidden_at') // Extra safety to prevent leaking hidden discussion (titles) towards search results.
) ->when($actor->isGuest() || ! $actor->hasPermission('discussion.hide'), fn($query) => $query->whereNull('discussions.hidden_at'))
->whereIn('id', $results->pluck('discussion_id')->filter()) ->where(function ($query) use ($results) {
$query
->whereIn('discussions.id', $results->pluck('discussion_id')->filter())
->orWhereIn('posts.id', $results->pluck('most_relevant_post_id')->filter());
})
->get() ->get()
->each(function (Discussion $discussion) use ($results) { ->each(function (Discussion $discussion) use ($results) {
$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'] $discussion->weight = $results->firstWhere('discussion_id', $discussion->id)['weight'] ?? 0;
?? $discussion->first_post_id; } else {
$discussion->weight = $result['weight'] ?? 0; $post = $discussion->posts()->whereIn('id', $results->pluck('most_relevant_post_id'))->first();
$discussion->most_relevant_post_id = $post?->id ?? $discussion->first_post_id;
$discussion->weight = $results->firstWhere('most_relevant_post_id', $post?->id)['weight'] ?? 0;
}
}) })
->keyBy('id') ->keyBy('id')
->when( ->sortByDesc('weight')
$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,208 +183,33 @@ 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, array $filters = []): BoolQuery
{ {
$groups = $this->getGroups($actor); $groups = $this->getGroups($actor);
$onlyPrivate = Str::contains($filters['q'] ?? '', 'is:private'); $onlyPrivate = Str::contains($filters['q'] ?? '', 'is:private');
$subQuery = BoolQuery::create() $subQuery = BoolQuery::create()
->add(TermQuery::create('is_private', 'false')) ->add(TermQuery::create('is_private', 'false'))
->add(TermsQuery::create('groups', $groups->toArray())); ->add(TermsQuery::create('groups', $groups->toArray()));
if ($this->extensionEnabled('flarum-tags') && !empty($filters['tag'])) {
$slugs = is_array($filters['tag']) ? $filters['tag'] : explode(',', $filters['tag']);
$tagIds = Tag::query()->whereIn('slug', $slugs)->pluck('id')->toArray();
if (!empty($tagIds)) {
$query->add(TermsQuery::create('tags', $tagIds), 'filter');
}
}
if ($this->extensionEnabled('fof-byobu') && $actor->exists) { if ($this->extensionEnabled('fof-byobu') && $actor->exists) {
$byobuQuery = BoolQuery::create() $byobuQuery = BoolQuery::create()
->add(TermQuery::create('is_private', 'true')) ->add(TermQuery::create('is_private', 'true'))
@ -442,12 +228,56 @@ class SearchController extends ListDiscussionsController
} }
} }
$query->add($subQuery, 'filter'); $query->add(
$subQuery,
'filter'
);
return $query;
} }
protected function boolQuery(Query $parent, float $boost = 1): Query
{
$bool = new BoolQuery;
/** @var Searcher $searcher */
foreach ($this->searchers as $searcher) {
$searcher = new $searcher;
$bool->add(
BoolQuery::create()
->add(TermQuery::create('type', $searcher->type()), 'filter')
->add(clone $parent->boost($boost * $searcher->boost())),
'should'
);
}
return $bool;
}
protected function sentenceMatch(string $q): Query
{
$query = (new MatchPhraseQuery('content', $q));
return $this->boolQuery($query, 2);
}
protected function wordMatch(string $q, string $operator = 'or'): Query
{
$query = (new MatchQuery('content', $q))
->operator($operator);
$boost = $operator === 'and' ? 1.8 : .8;
return $this->boolQuery($query, $boost);
}
protected function getGroups(User $actor): Collection protected function getGroups(User $actor): Collection
{ {
/** @var Collection $groups */
$groups = $actor->groups->pluck('id'); $groups = $actor->groups->pluck('id');
$groups->add(Group::GUEST_ID); $groups->add(Group::GUEST_ID);
if ($actor->is_email_confirmed) { if ($actor->is_email_confirmed) {
@ -462,12 +292,10 @@ class SearchController extends ListDiscussionsController
$search = Arr::get($filters, 'q'); $search = Arr::get($filters, 'q');
if ($search) { if ($search) {
// Strip Flarum gambit operators (tag:foo, author:bar, is:private, etc.)
// before passing to ES. These are structural filters handled separately;
// leaving them in causes operator:and to require the gambit tokens to
// appear literally in post content, producing zero results.
$q = collect(explode(' ', $search)) $q = collect(explode(' ', $search))
->filter(fn (string $part) => !preg_match('/^\w+:/', $part)) ->filter(function (string $part) {
return $part !== 'is:private';
})
->filter() ->filter()
->join(' '); ->join(' ');

View File

@ -1,398 +1,143 @@
<?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\Job;
use Blomstra\Search\Jobs\SavingJob;
use Sbp\Search\Jobs\Job; use Blomstra\Search\Seeders\Seeder;
use Sbp\Search\Jobs\UpdateSearchJob; use Carbon\Carbon;
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} {--chunk-size= : Size of the chunks to dispatch into jobs}
{--resume} {--throttle= : Number of seconds to wait between pushing to the queue}
{--staging} {--only= : type to run seeder for, eg discussions or posts}
{--keep-backup} {--recreate : create or recreate the index}
{--pending} {--mapping : recreate the mapping}
{--backup} {--continue : continue each object type where you left off}';
{--only=} protected $description = 'Rebuilds the complete search server with its documents.';
{--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'); 'properties' => [
'content' => ['type' => 'text', 'analyzer' => 'flarum_analyzer_partial', 'search_analyzer' => 'flarum_analyzer'],
'created_at' => ['type' => 'date'],
'updated_at' => ['type' => 'date'],
'is_private' => ['type' => 'boolean'],
'is_sticky' => ['type' => 'boolean'],
'groups' => ['type' => 'integer'],
'recipient_groups' => ['type' => 'integer'],
'recipient_users' => ['type' => 'integer'],
'comment_count' => ['type' => 'integer'],
]
];
if (!$this->argument('action')) { if ($this->option('recreate')) {
$this->call('help', ['command_name' => $this->getName()]); // Flush the index.
return; $client->indices()->delete([
'index' => $index,
'ignore_unavailable' => true
]);
// Create a new index.
$client->indices()->create([
'index' => $index,
'body' => [
'settings' => [
'index.max_ngram_diff' => 10,
'analysis' => [
'analyzer' => [
'flarum_analyzer' => [
'type' => $settings->get('blomstra-search.analyzer-language') ?: 'english'
],
'flarum_analyzer_partial' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => [
'lowercase',
'partial_search_filter'
]
]
],
'filter' => [
'partial_search_filter' => [
'type' => 'ngram',
'min_gram' => 1,
'max_gram' => 10,
'token_chars' => ['letter', 'digit', 'symbol']
]
]
]
]
]
]);
} }
switch ($this->argument('action')) { if ($this->option('recreate') || $this->option('mapping')) {
case 'build':
$this->runBuild($client, $alias, $settings, $container);
break;
case 'promote':
$this->runPromote($client, $alias, $settings);
break;
case 'rollback':
$this->runRollback($client, $alias, $settings);
break;
case 'discard':
$this->runDiscard($client, $settings);
break;
case 'mapping':
$client->indices()->putMapping([ $client->indices()->putMapping([
'index' => $alias, 'index' => $index,
'body' => $this->mappingProperties(), 'body' => $properties
]); ]);
$this->info('Mapping updated on live index.');
break;
case 'fill':
$this->runSeeders(
collect($container->tagged('blomstra.search.seeders')),
$container->make(Queue::class),
$client,
$settings,
$alias,
seedMissing: true
);
break;
default:
$this->error("Unknown action '{$this->argument('action')}'. Valid actions: build, promote, rollback, discard, mapping, fill.");
}
} }
protected function runBuild(Client $client, string $alias, SettingsRepositoryInterface $settings, Container $container): void
{
/** @var Seeder[] $seeders */
$seeders = collect($container->tagged('blomstra.search.seeders'));
// Guard: staging build exists without explicit intent given.
$staging = $settings->get('blomstra-search.staging-index');
if ($staging && $client->indices()->exists(['index' => $staging])
&& !$this->option('resume') && !$this->option('fresh')
) {
$this->warn("An in-progress build already exists: $staging");
$this->line('');
$this->line('Choose one of:');
$this->line(' blomstra:search:index build --resume Continue from where it left off');
$this->line(' blomstra:search:index build --resume --staging Continue and keep in staging when done');
$this->line(' blomstra:search:index build --fresh Drop this build and start completely fresh');
$this->line(' blomstra:search:index build --fresh --staging Start fresh and keep in staging when done');
return;
}
$aliasExists = (bool) $client->indices()->existsAlias(['name' => $alias]);
$indexExists = !$aliasExists && (bool) $client->indices()->exists(['index' => $alias]);
if (!$aliasExists && !$indexExists) {
// First install: create a timestamped index, alias it immediately, seed live.
$targetIndex = $this->prepareFirstInstall($client, $alias, $settings, $seeders);
$stagingBuild = false;
} else {
// Live system: build into a staging index without touching the live alias.
$targetIndex = $this->prepareStagingIndex($client, $alias, $settings, $seeders);
$stagingBuild = true;
}
$client->indices()->putMapping([
'index' => $targetIndex,
'body' => $this->mappingProperties(),
]);
$this->runSeeders($seeders, $container->make(Queue::class), $client, $settings, $targetIndex);
if ($stagingBuild) {
if ($this->option('staging')) {
$staging = $settings->get('blomstra-search.staging-index');
$this->info("Staging build complete. Drain the queue, then promote '$staging' to live:");
$this->line(' php flarum queue:work --stop-when-empty');
$this->line(' php flarum blomstra:search:index promote');
} else {
$this->info('Jobs queued. Promoting now (search improves as the queue drains)...');
$this->performPromote($client, $alias, $settings);
}
}
}
protected function runPromote(Client $client, string $alias, SettingsRepositoryInterface $settings): void
{
if (!$this->option('i-am-sure')) {
$this->warn('promote switches the live index. Ensure all queued jobs have finished first:');
$this->line(' php flarum queue:work --stop-when-empty');
$this->line('');
if (!$this->confirm('Queue drained? Proceed?')) {
return;
}
}
$this->performPromote($client, $alias, $settings);
}
protected function performPromote(Client $client, string $alias, SettingsRepositoryInterface $settings): void
{
$staging = $settings->get('blomstra-search.staging-index');
if (!$staging || !$client->indices()->exists(['index' => $staging])) {
$this->error('No staging index ready to promote. Run: blomstra:search:index build');
return;
}
$aliasExists = (bool) $client->indices()->existsAlias(['name' => $alias]);
$indexExists = !$aliasExists && (bool) $client->indices()->exists(['index' => $alias]);
if ($aliasExists) {
$result = $client->indices()->getAlias(['name' => $alias]);
$activeIndex = array_key_first($result);
$client->indices()->updateAliases([
'body' => ['actions' => [
['remove' => ['index' => $activeIndex, 'alias' => $alias]],
['add' => ['index' => $staging, 'alias' => $alias]],
]],
]);
$this->info("'$alias' now points to '$staging'. Promote complete.");
if ($this->option('keep-backup')) {
$settings->set('blomstra-search.backup-index', $activeIndex);
$this->info("Kept '$activeIndex' as backup — run rollback to revert, discard --backup to drop it.");
} else {
$client->indices()->delete(['index' => $activeIndex, 'ignore_unavailable' => true]);
$this->info("Deleted old index: $activeIndex");
}
} else {
// One-time migration: concrete index → alias (legacy installs only).
if ($indexExists) {
$client->indices()->delete(['index' => $alias]);
$this->info("Deleted legacy concrete index: $alias");
}
$client->indices()->putAlias(['index' => $staging, 'name' => $alias]);
$this->info("'$alias' now points to '$staging'. Promote complete.");
}
$settings->set('blomstra-search.active-index', $staging);
$settings->set('blomstra-search.staging-index', null);
$this->saveIndexedConfig($client, $settings, $staging);
}
protected function runRollback(Client $client, string $alias, SettingsRepositoryInterface $settings): void
{
$backup = $settings->get('blomstra-search.backup-index');
if (!$backup || !$client->indices()->exists(['index' => $backup])) {
$this->error('No backup index to roll back to. Was promote run with --keep-backup?');
return;
}
$result = $client->indices()->getAlias(['name' => $alias]);
$activeIndex = array_key_first($result);
$client->indices()->updateAliases([
'body' => ['actions' => [
['remove' => ['index' => $activeIndex, 'alias' => $alias]],
['add' => ['index' => $backup, 'alias' => $alias]],
]],
]);
$client->indices()->delete(['index' => $activeIndex, 'ignore_unavailable' => true]);
$this->info("Rolled back: '$alias' now points to '$backup'. Deleted '$activeIndex'.");
$settings->set('blomstra-search.active-index', $backup);
$settings->set('blomstra-search.backup-index', null);
$this->saveIndexedConfig($client, $settings, $backup);
}
protected function runDiscard(Client $client, SettingsRepositoryInterface $settings): void
{
$pending = $this->option('pending');
$backup = $this->option('backup');
if (!$pending && !$backup) {
$this->error('Specify what to discard: --pending (staging build) or --backup (rollback index).');
return;
}
if ($pending) {
$staging = $settings->get('blomstra-search.staging-index');
if (!$staging) {
$this->warn('No staging index to discard.');
} else {
if ($client->indices()->exists(['index' => $staging])) {
$client->indices()->delete(['index' => $staging]);
}
$settings->set('blomstra-search.staging-index', null);
$this->info("Discarded staging index: $staging");
}
}
if ($backup) {
$backupIndex = $settings->get('blomstra-search.backup-index');
if (!$backupIndex) {
$this->warn('No backup index to discard.');
} else {
if ($client->indices()->exists(['index' => $backupIndex])) {
$client->indices()->delete(['index' => $backupIndex]);
}
$settings->set('blomstra-search.backup-index', null);
$this->info("Discarded backup index: $backupIndex");
}
}
}
protected function runSeeders(
iterable $seeders,
Queue $queue,
Client $client,
SettingsRepositoryInterface $settings,
string $targetIndex,
bool $seedMissing = false
): void {
$only = $this->option('only'); $only = $this->option('only');
/** @var Seeder $seeder */ /** @var Seeder $seeder */
foreach ($seeders as $seeder) { foreach ($seeders as $seeder) {
if ($only && $seeder->type() !== $only) { if ($only && $seeder->type() !== $only) continue;
continue;
}
$total = 0; $total = 0;
if ($this->option('resume')) { $continueAt = $this->option('continue')
$saved = $this->getContinueAt($settings, $seeder->type()); ? ($this->continueAt($seeder->type()) ?? $seeder->query()->max('id'))
: $seeder->query()->max('id');
if ($saved === 0) {
$this->info("Seeder '{$seeder->type()}' already completed in a previous run, skipping.");
continue;
}
$continueAt = $saved ?? $seeder->query()->max('id');
} else {
$continueAt = $seeder->query()->max('id');
}
$seeded = null;
while($continueAt !== null) { while($continueAt !== null) {
$rangeFrom = max(1, $continueAt - 2500);
$rangeTo = $continueAt;
if ($seedMissing) {
$seeded = $this->queryIndexedIds($client, $targetIndex, $seeder->joinRelation(), $rangeFrom, $rangeTo);
}
/** @var Collection $collection */ /** @var Collection $collection */
$collection = $seeder->query() $collection = $seeder->query()
->latest('id') ->latest('id')
->whereBetween('id', [$rangeFrom, $rangeTo]) ->whereBetween('id', [$continueAt - 1000, $continueAt])
->when($this->option('max-id'), fn ($q, $id) => $q->where('id', '<=', $id)) ->when($this->option('max-id'), function ($query, $id) {
->when($seeded, fn ($q, $seeded) => $q->whereNotIn('id', $seeded)) $query->where('id', '<=', $id);
})
->get(); ->get();
$min = $collection->min('id'); $min = $collection->min('id');
if ($seedMissing && $collection->isEmpty()) {
$continueAt = $rangeFrom > 2 ? $rangeFrom - 1 : null;
} else {
$continueAt = $min && $min > 2 ? $min - 1 : null; $continueAt = $min && $min > 2 ? $min - 1 : null;
}
if ($collection->isNotEmpty()) { $queue->pushOn(Job::$onQueue, new SavingJob($collection, $seeder));
$queue->pushOn(Job::$onQueue, new UpdateSearchJob($collection, $seeder, $targetIndex));
}
$this->info("IDs {$rangeFrom}{$rangeTo} | type: {$seeder->type()} | queued: {$collection->count()}."); $this->info("Pushed into the index, type: {$seeder->type()}, amount: {$collection->count()}.");
$total += $collection->count(); $total += $collection->count();
$this->setContinueAt($settings, $seeder->type(), $continueAt);
$this->continueAt(
$seeder->type(),
$continueAt
);
if ($throttle = $this->option('throttle')) { if ($throttle = $this->option('throttle')) {
$this->info("Throttling for $throttle seconds"); $this->info("Throttling for $throttle seconds");
@ -400,274 +145,21 @@ HELP;
} }
} }
$this->setContinueAt($settings, $seeder->type(), 0); $this->info("Pushed a total of $total into the index.");
$this->info("Queued a total of $total {$seeder->type()} for indexing.");
} }
} }
/** protected function continueAt(string $type, int $at = null)
* First install: create a timestamped concrete index, alias the configured name to it
* immediately, and return the alias so seeding goes through it and is live from the start.
*/
protected function prepareFirstInstall(
Client $client,
string $alias,
SettingsRepositoryInterface $settings,
iterable $seeders
): string {
$concrete = $alias . '_' . date('YmdHis');
$client->indices()->create([
'index' => $concrete,
'body' => ['settings' => $this->buildIndexSettings($settings)],
]);
$client->indices()->putAlias(['index' => $concrete, 'name' => $alias]);
$settings->set('blomstra-search.active-index', $concrete);
$this->saveIndexedConfig($client, $settings, $concrete);
foreach ($seeders as $seeder) {
$this->setContinueAt($settings, $seeder->type(), null);
}
$this->info("Created '$concrete', aliased '$alias' → '$concrete'.");
$this->info("Index is live — documents become searchable as the queue processes.");
return $alias;
}
/**
* Prepare the staging index for a blue-green build.
*
* - If a staging build exists and --fresh is not set, resume it.
* - Otherwise create a fresh timestamped index and save it as staging.
*/
protected function prepareStagingIndex(
Client $client,
string $alias,
SettingsRepositoryInterface $settings,
iterable $seeders
): string {
$staging = $settings->get('blomstra-search.staging-index');
if ($this->option('fresh') && $staging) {
if ($client->indices()->exists(['index' => $staging])) {
$client->indices()->delete(['index' => $staging]);
$this->info("Dropped staging index: $staging");
}
$staging = null;
$settings->set('blomstra-search.staging-index', null);
}
if ($staging && $client->indices()->exists(['index' => $staging])) {
$this->info("Resuming staging index build: $staging");
return $staging;
}
$staging = $alias . '_' . date('YmdHis');
$client->indices()->create([
'index' => $staging,
'body' => ['settings' => $this->buildIndexSettings($settings)],
]);
$settings->set('blomstra-search.staging-index', $staging);
foreach ($seeders as $seeder) {
$this->setContinueAt($settings, $seeder->type(), null);
}
$this->info("Created staging index: $staging");
return $staging;
}
/**
* Persist the analysis config and compat version that are actually live in ES for the
* given index. Reading from ES (rather than from Flarum settings) means rollbacks are
* also covered: the stored values always reflect the index that is currently aliased,
* not the settings at the time the command ran. Indexes built before _meta tracking
* existed will yield a null compat version, which correctly triggers the reindex warning.
*/
protected function saveIndexedConfig(Client $client, SettingsRepositoryInterface $settings, string $indexName): void
{ {
$settingsResponse = $client->indices()->getSettings(['index' => $indexName]); /** @var SettingsRepositoryInterface $settings */
$analysis = Arr::get($settingsResponse, "$indexName.settings.index.analysis", []); $settings = resolve(SettingsRepositoryInterface::class);
$analyzer = Arr::get($analysis, 'analyzer.flarum_analyzer.type', 'english'); $key = "blomstra-search.continued-at.$type";
$stemExclusion = Arr::get($analysis, 'analyzer.flarum_analyzer.stem_exclusion', []);
$mappingResponse = $client->indices()->getMapping(['index' => $indexName]); if ($at) {
$compatVersion = Arr::get($mappingResponse, "$indexName.mappings._meta.index_compat_version"); $settings->set($key, $at);
} else {
$settings->set('blomstra-search.indexed-analyzer', $analyzer); return $settings->get($key);
$settings->set('blomstra-search.indexed-stem-exclusion', implode("\n", $stemExclusion));
$settings->set('blomstra-search.index-compatible', $compatVersion);
}
protected function buildIndexSettings(SettingsRepositoryInterface $settings): array
{
$language = $settings->get('blomstra-search.analyzer-language') ?: 'english';
$raw = $settings->get('blomstra-search.stem-exclusion', '');
$stemExclusion = array_values(array_filter(array_map('trim', explode("\n", $raw))));
if ($language === 'cjk') {
// CJK uses the built-in bigram analyzer; no autocomplete subfield.
return [
'analysis' => [
'analyzer' => [
'flarum_analyzer' => ['type' => 'cjk'],
],
],
];
}
$analyzerConfig = ['type' => $language];
if (!empty($stemExclusion)) {
$analyzerConfig['stem_exclusion'] = $stemExclusion;
}
return [
'index.max_ngram_diff' => self::TITLE_MAX_GRAM - self::TITLE_MIN_GRAM,
'analysis' => [
'filter' => [
'title_autocomplete_filter' => [
'type' => 'edge_ngram',
'min_gram' => self::TITLE_MIN_GRAM,
'max_gram' => self::TITLE_MAX_GRAM,
'token_chars' => ['letter', 'digit'],
],
],
'analyzer' => [
// Symmetric language analyzer: used for BOTH index and search on content + title.
'flarum_analyzer' => $analyzerConfig,
// Title autocomplete index-time: prefix n-grams, no stemming.
'flarum_title_autocomplete' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase', 'title_autocomplete_filter'],
],
// Title autocomplete search-time: just lowercase — predictable on partial words.
'flarum_title_search' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase'],
],
],
],
];
}
protected function mappingProperties(): array
{
$language = resolve(SettingsRepositoryInterface::class)->get('blomstra-search.analyzer-language') ?: 'english';
$titleMapping = [
'type' => 'text',
'analyzer' => 'flarum_analyzer',
];
// Only add the autocomplete subfield for non-CJK languages.
if ($language !== 'cjk') {
$titleMapping['fields'] = [
'autocomplete' => [
'type' => 'text',
'analyzer' => 'flarum_title_autocomplete',
'search_analyzer' => 'flarum_title_search',
],
];
}
$contentMapping = [
'type' => 'text',
'analyzer' => 'flarum_analyzer',
];
// Only add the autocomplete subfield for non-CJK languages.
if ($language !== 'cjk') {
$contentMapping['fields'] = [
'autocomplete' => [
'type' => 'text',
'analyzer' => 'flarum_title_autocomplete',
'search_analyzer' => 'flarum_title_search',
],
];
}
return [
'_meta' => ['index_compat_version' => self::INDEX_COMPAT_VERSION],
'properties' => [
'join_field' => ['type' => 'join', 'relations' => ['discussion' => 'post']],
'discussion_id' => ['type' => 'integer'],
'content' => $contentMapping,
'title' => $titleMapping,
'rawId' => ['type' => 'integer'],
'created_at' => ['type' => 'date'],
'updated_at' => ['type' => 'date'],
'is_private' => ['type' => 'boolean'],
'user_id' => ['type' => 'integer'],
'groups' => ['type' => 'keyword'],
'comment_count' => ['type' => 'integer'],
'tags' => ['type' => 'keyword'],
'is_sticky' => ['type' => 'boolean'],
'is_hidden' => ['type' => 'boolean'],
],
];
}
/**
* Query ES for rawIds already indexed in $targetIndex for the given $joinRelation and ID range.
* Retries on transient ES failures (NoNodesAvailableException / other ElasticsearchException)
* by sleeping past the StaticNoPingConnectionPool dead-node timeout (default 60 s) before
* each retry, giving the pool a chance to resurface the node.
*/
protected function queryIndexedIds(
Client $client,
string $targetIndex,
string $joinRelation,
int $rangeFrom,
int $rangeTo,
int $maxRetries = 10
): array {
$attempt = 0;
while (true) {
try {
$response = (new Builder($client))
->index($targetIndex)
->size(2500)
->addQuery(
(new BoolQuery())
->add((new RangeQuery('rawId'))->gte($rangeFrom)->lte($rangeTo))
->add(TermQuery::create('join_field', $joinRelation))
)
->search();
return Arr::pluck(Arr::get($response, 'hits.hits'), '_source.rawId');
} catch (ElasticsearchException $e) {
$attempt++;
if ($attempt >= $maxRetries) {
throw $e;
}
$this->warn("ES error on range {$rangeFrom}{$rangeTo} (attempt {$attempt}/{$maxRetries}): {$e->getMessage()}. Waiting 65 s before retry…");
sleep(65); // outlast the StaticNoPingConnectionPool dead-node window (default 60 s)
} }
} }
} }
protected function getContinueAt(SettingsRepositoryInterface $settings, string $type): ?int
{
$raw = $settings->get("blomstra-search.continued-at.$type");
return $raw !== null ? (int) $raw : null;
}
protected function setContinueAt(SettingsRepositoryInterface $settings, string $type, ?int $at): void
{
$settings->set("blomstra-search.continued-at.$type", $at);
}
}

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,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\MatchQuery; use Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery;
@ -32,7 +22,7 @@ class MatchPhraseQuery extends MatchQuery
$query[$this->field]['boost'] = $this->boost; $query[$this->field]['boost'] = $this->boost;
return [ return [
'match_phrase' => $query, 'match_phrase' => $query
]; ];
} }
} }

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;
class MatchQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery class MatchQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery
{ {
@ -62,10 +52,6 @@ class MatchQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery
$query['match'][$this->field]['boost'] = $this->boost; $query['match'][$this->field]['boost'] = $this->boost;
$query['match'][$this->field]['zero_terms_query'] = $this->zeroTerms ? 'all' : 'none'; $query['match'][$this->field]['zero_terms_query'] = $this->zeroTerms ? 'all' : 'none';
if ($this->analyzer !== null) {
$query['match'][$this->field]['analyzer'] = $this->analyzer;
}
return $query; return $query;
} }
} }

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;
@ -52,8 +42,8 @@ class SimpleSearchQuery implements Query
'fields' => $this->fields, 'fields' => $this->fields,
'analyzer' => $this->analyzer, 'analyzer' => $this->analyzer,
'default_operator' => 'AND', 'default_operator' => 'AND',
'boost' => $this->boost, '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,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;
class WildcardQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\WildcardQuery class WildcardQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\WildcardQuery
{ {
@ -48,6 +38,7 @@ class WildcardQuery extends \Spatie\ElasticsearchQueryBuilder\Queries\WildcardQu
if ($this->rewrite) { if ($this->rewrite) {
$query['wildcard'][$this->field]['rewrite'] = $this->rewrite; $query['wildcard'][$this->field]['rewrite'] = $this->rewrite;
} }
return $query; return $query;

View File

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

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);
}
} }
} }

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

@ -0,0 +1,44 @@
<?php
namespace Blomstra\Search\Jobs;
use Blomstra\Search\Exceptions\SeedingException;
use Elasticsearch\Client;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
class SavingJob extends Job
{
public function handle(Client $client)
{
if ($this->models->isEmpty()) return;
// Preparing body for storing.
$body = $this->models->map(function (Model $model) {
$document = $this->seeder->toDocument($model);
return [
['index' => ['_index' => $this->index, '_id' => $document->id]],
$document->toArray()
];
})
->flatten(1);
$response = $client->bulk([
'index' => $this->index,
'body' => $body->toArray(),
'refresh' => true
]);
if (Arr::get($response, 'errors') !== true) return true;
$items = Arr::get($response, 'items');
$error = Arr::get(Arr::first($items), 'index.error.reason');
throw new SeedingException(
"Failed to seed: $error",
$items
);
}
}

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,21 +1,12 @@
<?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\Job;
use Sbp\Search\Jobs\DeletingJob; use Blomstra\Search\Jobs\SavingJob;
use Sbp\Search\Jobs\Job; use Blomstra\Search\Searchers;
use Sbp\Search\Jobs\UpdateSearchJob; use Blomstra\Search\Seeders;
use Sbp\Search\Jobs\ViewsSearchJob;
use Elasticsearch\Client as Elastic; use Elasticsearch\Client as Elastic;
use Elasticsearch\ClientBuilder; use Elasticsearch\ClientBuilder;
use Flarum\Api\Client; use Flarum\Api\Client;
@ -26,7 +17,7 @@ use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Queue\Queue; use Illuminate\Contracts\Queue\Queue;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection;
use Laminas\Stratigility\MiddlewarePipe; use Laminas\Stratigility\MiddlewarePipe;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -47,13 +38,7 @@ class Provider extends AbstractServiceProvider
$this->container->singleton(Elastic::class, function (Container $container) use ($settings, $config) { $this->container->singleton(Elastic::class, function (Container $container) use ($settings, $config) {
$builder = ClientBuilder::create() $builder = ClientBuilder::create()
->setHosts([$settings->get('blomstra-search.elastic-endpoint')]) ->setHosts([$settings->get('blomstra-search.elastic-endpoint')]);
->setConnectionParams([
'client' => [
'connect_timeout' => 2, // fail fast if ES is unreachable
'timeout' => 10, // allow time for complex queries
],
]);
if ($config->inDebugMode()) { if ($config->inDebugMode()) {
$builder->setLogger($container->make(LoggerInterface::class)); $builder->setLogger($container->make(LoggerInterface::class));
@ -69,6 +54,7 @@ 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')
@ -77,7 +63,7 @@ class Provider extends AbstractServiceProvider
$this->container->extend( $this->container->extend(
Client::class, Client::class,
function () { function () {
$pipe = new MiddlewarePipe(); $pipe = new MiddlewarePipe;
$exclude = resolve('flarum.api_client.exclude_middleware'); $exclude = resolve('flarum.api_client.exclude_middleware');
@ -89,7 +75,7 @@ class Provider extends AbstractServiceProvider
$pipe->pipe(resolve($middleware)); $pipe->pipe(resolve($middleware));
} }
$pipe->pipe(new ExecuteRoute()); $pipe->pipe(new ExecuteRoute);
return new Api\Client($pipe); return new Api\Client($pipe);
} }
@ -115,16 +101,12 @@ 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->pushOn(Job::$onQueue, 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->pushOn(Job::$onQueue, 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,18 +1,8 @@
<?php <?php
/* namespace Blomstra\Search\Searchers;
* 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 Blomstra\Search\Seeders\CommentSeeder;
use Sbp\Search\Seeders\CommentSeeder;
class CommentPostSearcher extends Searcher class CommentPostSearcher extends Searcher
{ {

View File

@ -1,18 +1,8 @@
<?php <?php
/* namespace Blomstra\Search\Searchers;
* 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 Blomstra\Search\Seeders\DiscussionSeeder;
use Sbp\Search\Seeders\DiscussionSeeder;
class DiscussionSearcher extends Searcher class DiscussionSearcher extends Searcher
{ {

View File

@ -1,18 +1,8 @@
<?php <?php
/* namespace Blomstra\Search\Searchers;
* 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 Blomstra\Search\Seeders\Seeder;
use Sbp\Search\Seeders\Seeder;
use Flarum\Settings\SettingsRepositoryInterface; use Flarum\Settings\SettingsRepositoryInterface;
abstract class Searcher abstract class Searcher
@ -24,11 +14,9 @@ abstract class Searcher
/** @var Seeder $seeder */ /** @var Seeder $seeder */
$seeder = $this->seeder; $seeder = $this->seeder;
if (empty($seeder)) { if (empty($seeder)) throw new \InvalidArgumentException("Implement type or add \$seeder");
throw new \InvalidArgumentException('Implement type or add $seeder');
}
return (new $seeder())->type(); return (new $seeder)->type();
} }
public function enabled(): bool public function enabled(): bool

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,66 @@ class CommentSeeder extends Seeder
{ {
public function type(): string public function type(): string
{ {
return resolve(PostSerializer::class)->getType(new CommentPost()); return resolve(PostSerializer::class)->getType(new CommentPost);
}
public function joinRelation(): string
{
return 'post';
}
public function routing(Model $model): string
{
return (string) $model->discussion_id;
} }
public function query(): Builder public function query(): Builder
{ {
$includes = ['discussion'];
if ($this->extensionEnabled('flarum-tags')) {
$includes[] = 'discussion.tags';
}
if ($this->extensionEnabled('fof-byobu')) {
$includes[] = 'discussion.recipientUsers';
$includes[] = 'discussion.recipientGroups';
}
return CommentPost::query() return CommentPost::query()
->where('type', CommentPost::$type); ->whereNull('hidden_at')
->where('type', CommentPost::$type)
->with($includes);
} }
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, 'content_partial' => $model->content,
'created_at' => $model->created_at?->toAtomString(),
'updated_at' => $model->edited_at?->toAtomString(),
'is_private' => $model->is_private,
'user_id' => $model->user_id,
'groups' => $this->groupsForDiscussion($model->discussion),
'comment_count' => $model->discussion->comment_count,
]); ]);
if ($this->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,56 +1,26 @@
<?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()
->whereNull('hidden_at');
}
public function relationships(): array
{ {
$includes = []; $includes = [];
@ -62,66 +32,36 @@ class DiscussionSeeder extends Seeder
$includes[] = 'recipientUsers'; $includes[] = 'recipientUsers';
$includes[] = 'recipientGroups'; $includes[] = 'recipientGroups';
} }
return Discussion::query()
return $includes; ->whereNull('hidden_at')
->with($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, 'content_partial' => $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,
@ -130,81 +70,15 @@ class DiscussionSeeder extends Seeder
'comment_count' => $model->comment_count, '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'] = (bool) $model->is_sticky;
} }
if ($this->extensionEnabled('fof-discussion-views')) {
$document['view_count'] = (int) ($model->view_count ?? 0);
}
return $document; return $document;
} }
/**
* All viewForum permissions keyed by permission string, loaded once per seeder instance.
* Avoids N×2 Permission queries inside the per-document map loop.
*/
private ?Collection $cachedPermissions = null;
private ?Collection $cachedGlobalPermission = null;
private function allPermissions(): Collection
{
if ($this->cachedPermissions === null) {
$this->cachedPermissions = Permission::query()
->where(function ($q) {
$q->where('permission', 'viewForum')
->orWhere('permission', 'like', 'tag%.viewForum');
})
->get();
$this->cachedGlobalPermission = $this->cachedPermissions
->where('permission', 'viewForum')
->pluck('group_id');
}
return $this->cachedPermissions;
}
protected function groupsForDiscussion(Discussion $discussion): array
{
$allPerms = $this->allPermissions();
$permissions = collect();
if ($this->extensionEnabled('flarum-tags')) {
/** @var Collection $tags */
$tags = $discussion->tags;
$permissions = $tags->map(function (Tag $tag) use ($allPerms) {
$tagPerms = $allPerms->where('permission', "tag$tag->id.viewForum");
if ($tag->is_restricted) {
$tagPerms = $tagPerms->add(['group_id' => Group::ADMINISTRATOR_ID]);
}
return $tagPerms->pluck('group_id');
})->flatten();
}
if (!$discussion->is_private && $permissions->isEmpty()) {
$permissions = $this->cachedGlobalPermission;
}
return $permissions->toArray();
}
} }

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