diff --git a/README.md b/README.md
index 1f9548e..a04bae3 100644
--- a/README.md
+++ b/README.md
@@ -39,34 +39,48 @@ php flarum queue:work
When you need to rebuild the full index (e.g. after a mapping change):
```bash
-# 1. Build into a staging index — live index is untouched
+# Simple rebuild — promotes automatically once all jobs are queued
php flarum blomstra:search:index build
-# 2. Drain the queue
-php flarum queue:work --stop-when-empty
-
-# 3a. Promote the new index to live
-php flarum blomstra:search:index promote
-# 3b: promote but keep the old index as a backup for rollback
-php flarum blomstra:search:index promote --keep-backup
-
-# 4. Add content added between 'build' and 'promote'
-php flarum blomstra:search:index fill
-
+# Or keep a backup of the old index in case you need to roll back
+php flarum blomstra:search:index build --keep-backup
```
-If you kept the backup and want to roll back:
+After the queue drains, fill any gaps from content posted during the build:
+
+```bash
+php flarum blomstra:search:index fill
+```
+
+If you kept a backup and want to roll back:
```bash
php flarum blomstra:search:index rollback
```
-Once you are satisfied with the new index, drop the backup:
+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:
@@ -102,14 +116,16 @@ php flarum blomstra:search:index mapping
| Command | Description |
|---|---|
-| `build` | Build into a new timestamped staging index. On first install, aliases it immediately so search is live during seeding. On subsequent runs, use `promote` when the queue is drained. |
+| `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 from the live index to the completed staging index. Prompts for confirmation. |
+| `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 (after `promote --keep-backup`). Deletes the index that was live. |
+| `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 `promote --keep-backup`). |
+| `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`). |
diff --git a/extend.php b/extend.php
index 0ff9a15..457aa34 100644
--- a/extend.php
+++ b/extend.php
@@ -20,7 +20,8 @@ return [
(new Flarum\Frontend('forum'))
->js(__DIR__.'/js/dist/forum.js'),
(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'),
diff --git a/js/dist/admin.js b/js/dist/admin.js
index 81a5c50..4ec3e4a 100644
--- a/js/dist/admin.js
+++ b/js/dist/admin.js
@@ -1,2 +1,2 @@
-(()=>{var a={n:e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},d:(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o:(a,e)=>Object.prototype.hasOwnProperty.call(a,e),r:a=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(a,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(a,"__esModule",{value:!0})}},e={};(()=>{"use strict";a.r(e);const t=flarum.core.compat["admin/app"];var r=a.n(t);r().initializers.add("blomstra-search",(function(){var a=new Map;["arabic","armenian","basque","bengali","brazilian","bulgarian","catalan","cjk","czech","danish","dutch","english","estonian","finnish","french","galician","german","greek","hindi","hungarian","indonesian","irish","italian","latvian","lithuanian","norwegian","persian","portuguese","romanian","russian","sorani","spanish","swedish","turkish","thai"].forEach((function(e){a.set(e,e)})),r().extensionData.for("blomstra-search").registerSetting({setting:"blomstra-search.elastic-endpoint",label:r().translator.trans("blomstra-search.admin.elastic-endpoint"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-username",label:r().translator.trans("blomstra-search.admin.elastic-username"),type:"input"}).registerSetting({setting:"blomstra-search.elastic-password",label:r().translator.trans("blomstra-search.admin.elastic-password"),type:"password"}).registerSetting({setting:"blomstra-search.elastic-index",label:r().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.analyzer-language",label:r().translator.trans("blomstra-search.admin.analyzer.label"),help:r().translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:Object.fromEntries(a.entries()),default:"english"}).registerSetting({setting:"blomstra-search.elastic-index",label:r().translator.trans("blomstra-search.admin.elastic-index"),default:"flarum",type:"input"}).registerSetting({setting:"blomstra-search.search-discussion-subjects",label:r().translator.trans("blomstra-search.admin.search-discussion-subjects"),type:"switch"}).registerSetting({setting:"blomstra-search.search-post-bodies",label:r().translator.trans("blomstra-search.admin.search-post-bodies"),type:"switch"})}))})(),module.exports=e})();
+(()=>{var t={n:a=>{var e=a&&a.__esModule?()=>a.default:()=>a;return t.d(e,{a:e}),e},d:(a,e)=>{for(var r in e)t.o(e,r)&&!t.o(a,r)&&Object.defineProperty(a,r,{enumerable:!0,get:e[r]})},o:(t,a)=>Object.prototype.hasOwnProperty.call(t,a),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},a={};(()=>{"use strict";function e(t,a){return e=Object.setPrototypeOf||function(t,a){return t.__proto__=a,t},e(t,a)}t.r(a);const r=flarum.core.compat["admin/app"];var n=t.n(r);const s=flarum.core.compat["common/extend"],i=flarum.core.compat["admin/components/DashboardPage"];var o=t.n(i);const l=flarum.core.compat["admin/components/DashboardWidget"];var c=t.n(l);const d=flarum.core.compat["common/components/Alert"];var p=t.n(d),u=function(t){var a,r;function s(){return t.apply(this,arguments)||this}r=t,(a=s).prototype=Object.create(r.prototype),a.prototype.constructor=a,e(a,r);var i=s.prototype;return i.className=function(){return"ReindexWarningWidget"},i.content=function(){return m(p(),{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}(c());n().initializers.add("blomstra-search",(function(){var t=n().data.settings["blomstra-search.active-index"],a=n().data.settings["blomstra-search.index-compatible"];t&&"v2"!==a&&(0,s.extend)(o().prototype,"availableWidgets",(function(t){t.add("blomstra-search-reindex",m(u),110)}));var e=new Map;["arabic","armenian","basque","bengali","brazilian","bulgarian","catalan","cjk","czech","danish","dutch","english","estonian","finnish","french","galician","german","greek","hindi","hungarian","indonesian","irish","italian","latvian","lithuanian","norwegian","persian","portuguese","romanian","russian","sorani","spanish","swedish","turkish","thai"].forEach((function(t){e.set(t,t)})),n().extensionData.for("blomstra-search").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.analyzer-language",label:n().translator.trans("blomstra-search.admin.analyzer.label"),help:n().translator.trans("blomstra-search.admin.analyzer.help"),type:"select",options:Object.fromEntries(e.entries()),default:"english"}).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"})}))})(),module.exports=a})();
//# sourceMappingURL=admin.js.map
\ No newline at end of file
diff --git a/js/dist/admin.js.map b/js/dist/admin.js.map
index 9a2fe66..9b7408c 100644
--- a/js/dist/admin.js.map
+++ b/js/dist/admin.js.map
@@ -1 +1 @@
-{"version":3,"file":"admin.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,+BCL9D,MAAM,EAA+BC,OAAOC,KAAKC,OAAO,a,aCExDC,IAAAA,aAAAA,IAAqB,mBAAmB,WACtC,IAAMC,EAAY,IAAIC,IACtB,CACE,SACA,WACA,SACA,UACA,YACA,YACA,UACA,MACA,QACA,SACA,QACA,UACA,WACA,UACA,SACA,WACA,SACA,QACA,QACA,YACA,aACA,QACA,UACA,UACA,aACA,YACA,UACA,aACA,WACA,UACA,SACA,UACA,UACA,UACA,QACAC,SAAQ,SAACC,GACTH,EAAUI,IAAID,EAAUA,EACzB,IAEDJ,IAAAA,cAAAA,IACO,mBACJM,gBAAgB,CACfC,QAAS,mCACTC,MAAOR,IAAAA,WAAAA,MAAqB,0CAC5BS,KAAM,UAEPH,gBAAgB,CACfC,QAAS,mCACTC,MAAOR,IAAAA,WAAAA,MAAqB,0CAC5BS,KAAM,UAEPH,gBAAgB,CACfC,QAAS,mCACTC,MAAOR,IAAAA,WAAAA,MAAqB,0CAC5BS,KAAM,aAEPH,gBAAgB,CACfC,QAAS,gCACTC,MAAOR,IAAAA,WAAAA,MAAqB,uCAC5BU,QAAS,SACTD,KAAM,UAEPH,gBAAgB,CACfC,QAAS,oCACTC,MAAOR,IAAAA,WAAAA,MAAqB,wCAC5BW,KAAMX,IAAAA,WAAAA,MAAqB,uCAC3BS,KAAM,SACNG,QAAS3B,OAAO4B,YAAYZ,EAAUa,WACtCJ,QAAS,YAEVJ,gBAAgB,CACfC,QAAS,gCACTC,MAAOR,IAAAA,WAAAA,MAAqB,uCAC5BU,QAAS,SACTD,KAAM,UAEPH,gBAAgB,CACfC,QAAS,6CACTC,MAAOR,IAAAA,WAAAA,MAAqB,oDAC5BS,KAAM,WAEPH,gBAAgB,CACfC,QAAS,qCACTC,MAAOR,IAAAA,WAAAA,MAAqB,4CAC5BS,KAAM,UAEX,G","sources":["webpack://@blomstra/search/webpack/bootstrap","webpack://@blomstra/search/webpack/runtime/compat get default export","webpack://@blomstra/search/webpack/runtime/define property getters","webpack://@blomstra/search/webpack/runtime/hasOwnProperty shorthand","webpack://@blomstra/search/webpack/runtime/make namespace object","webpack://@blomstra/search/external root \"flarum.core.compat['admin/app']\"","webpack://@blomstra/search/./src/admin/index.ts"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/app'];","import app from 'flarum/admin/app';\n\napp.initializers.add('blomstra-search', () => {\n const languages = new Map();\n [\n 'arabic',\n 'armenian',\n 'basque',\n 'bengali',\n 'brazilian',\n 'bulgarian',\n 'catalan',\n 'cjk',\n 'czech',\n 'danish',\n 'dutch',\n 'english',\n 'estonian',\n 'finnish',\n 'french',\n 'galician',\n 'german',\n 'greek',\n 'hindi',\n 'hungarian',\n 'indonesian',\n 'irish',\n 'italian',\n 'latvian',\n 'lithuanian',\n 'norwegian',\n 'persian',\n 'portuguese',\n 'romanian',\n 'russian',\n 'sorani',\n 'spanish',\n 'swedish',\n 'turkish',\n 'thai',\n ].forEach((language) => {\n languages.set(language, language);\n });\n\n app.extensionData\n .for('blomstra-search')\n .registerSetting({\n setting: 'blomstra-search.elastic-endpoint',\n label: app.translator.trans('blomstra-search.admin.elastic-endpoint'),\n type: 'input',\n })\n .registerSetting({\n setting: 'blomstra-search.elastic-username',\n label: app.translator.trans('blomstra-search.admin.elastic-username'),\n type: 'input',\n })\n .registerSetting({\n setting: 'blomstra-search.elastic-password',\n label: app.translator.trans('blomstra-search.admin.elastic-password'),\n type: 'password',\n })\n .registerSetting({\n setting: 'blomstra-search.elastic-index',\n label: app.translator.trans('blomstra-search.admin.elastic-index'),\n default: 'flarum',\n type: 'input',\n })\n .registerSetting({\n setting: 'blomstra-search.analyzer-language',\n label: app.translator.trans('blomstra-search.admin.analyzer.label'),\n help: app.translator.trans('blomstra-search.admin.analyzer.help'),\n type: 'select',\n options: Object.fromEntries(languages.entries()),\n default: 'english',\n })\n .registerSetting({\n setting: 'blomstra-search.elastic-index',\n label: app.translator.trans('blomstra-search.admin.elastic-index'),\n default: 'flarum',\n type: 'input',\n })\n .registerSetting({\n setting: 'blomstra-search.search-discussion-subjects',\n label: app.translator.trans('blomstra-search.admin.search-discussion-subjects'),\n type: 'switch',\n })\n .registerSetting({\n setting: 'blomstra-search.search-post-bodies',\n label: app.translator.trans('blomstra-search.admin.search-post-bodies'),\n type: 'switch',\n });\n});\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","app","languages","Map","forEach","language","set","registerSetting","setting","label","type","default","help","options","fromEntries","entries"],"sourceRoot":""}
\ No newline at end of file
+{"version":3,"file":"admin.js","mappings":"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","sources":["webpack://@blomstra/search/webpack/bootstrap","webpack://@blomstra/search/webpack/runtime/compat get default export","webpack://@blomstra/search/webpack/runtime/define property getters","webpack://@blomstra/search/webpack/runtime/hasOwnProperty shorthand","webpack://@blomstra/search/webpack/runtime/make namespace object","webpack://@blomstra/search/./node_modules/@babel/runtime/helpers/esm/setPrototypeOf.js","webpack://@blomstra/search/external root \"flarum.core.compat['admin/app']\"","webpack://@blomstra/search/external root \"flarum.core.compat['common/extend']\"","webpack://@blomstra/search/external root \"flarum.core.compat['admin/components/DashboardPage']\"","webpack://@blomstra/search/external root \"flarum.core.compat['admin/components/DashboardWidget']\"","webpack://@blomstra/search/external root \"flarum.core.compat['common/components/Alert']\"","webpack://@blomstra/search/./src/admin/index.ts","webpack://@blomstra/search/./node_modules/@babel/runtime/helpers/esm/inheritsLoose.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","export default function _setPrototypeOf(o, p) {\n _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {\n o.__proto__ = p;\n return o;\n };\n\n return _setPrototypeOf(o, p);\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/app'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/extend'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/components/DashboardPage'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/components/DashboardWidget'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Alert'];","import app from 'flarum/admin/app';\nimport { extend } from 'flarum/common/extend';\nimport DashboardPage from 'flarum/admin/components/DashboardPage';\nimport DashboardWidget from 'flarum/admin/components/DashboardWidget';\nimport Alert from 'flarum/common/components/Alert';\n\nconst REQUIRED_INDEX_COMPAT = 'v2';\n\nclass ReindexWarningWidget extends DashboardWidget {\n className() {\n return 'ReindexWarningWidget';\n }\n\n content() {\n return m(Alert, {\n type: 'warning',\n dismissible: false,\n icon: 'fas fa-exclamation-triangle',\n title: app.translator.trans('blomstra-search.admin.reindex-required.title'),\n }, app.translator.trans('blomstra-search.admin.reindex-required.detail'));\n }\n}\n\napp.initializers.add('blomstra-search', () => {\n const activeIndex = app.data.settings['blomstra-search.active-index'];\n const compatVersion = app.data.settings['blomstra-search.index-compatible'];\n\n if (activeIndex && compatVersion !== REQUIRED_INDEX_COMPAT) {\n extend(DashboardPage.prototype, 'availableWidgets', function (items) {\n items.add('blomstra-search-reindex', m(ReindexWarningWidget), 110);\n });\n }\n\n const languages = new Map();\n [\n 'arabic',\n 'armenian',\n 'basque',\n 'bengali',\n 'brazilian',\n 'bulgarian',\n 'catalan',\n 'cjk',\n 'czech',\n 'danish',\n 'dutch',\n 'english',\n 'estonian',\n 'finnish',\n 'french',\n 'galician',\n 'german',\n 'greek',\n 'hindi',\n 'hungarian',\n 'indonesian',\n 'irish',\n 'italian',\n 'latvian',\n 'lithuanian',\n 'norwegian',\n 'persian',\n 'portuguese',\n 'romanian',\n 'russian',\n 'sorani',\n 'spanish',\n 'swedish',\n 'turkish',\n 'thai',\n ].forEach((language) => {\n languages.set(language, language);\n });\n\n app.extensionData\n .for('blomstra-search')\n .registerSetting({\n setting: 'blomstra-search.elastic-endpoint',\n label: app.translator.trans('blomstra-search.admin.elastic-endpoint'),\n type: 'input',\n })\n .registerSetting({\n setting: 'blomstra-search.elastic-username',\n label: app.translator.trans('blomstra-search.admin.elastic-username'),\n type: 'input',\n })\n .registerSetting({\n setting: 'blomstra-search.elastic-password',\n label: app.translator.trans('blomstra-search.admin.elastic-password'),\n type: 'password',\n })\n .registerSetting({\n setting: 'blomstra-search.elastic-index',\n label: app.translator.trans('blomstra-search.admin.elastic-index'),\n default: 'flarum',\n type: 'input',\n })\n .registerSetting({\n setting: 'blomstra-search.analyzer-language',\n label: app.translator.trans('blomstra-search.admin.analyzer.label'),\n help: app.translator.trans('blomstra-search.admin.analyzer.help'),\n type: 'select',\n options: Object.fromEntries(languages.entries()),\n default: 'english',\n })\n .registerSetting({\n setting: 'blomstra-search.search-discussion-subjects',\n label: app.translator.trans('blomstra-search.admin.search-discussion-subjects'),\n type: 'switch',\n })\n .registerSetting({\n setting: 'blomstra-search.search-post-bodies',\n label: app.translator.trans('blomstra-search.admin.search-post-bodies'),\n type: 'switch',\n });\n});\n","import setPrototypeOf from \"./setPrototypeOf.js\";\nexport default function _inheritsLoose(subClass, superClass) {\n subClass.prototype = Object.create(superClass.prototype);\n subClass.prototype.constructor = subClass;\n setPrototypeOf(subClass, superClass);\n}"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","_setPrototypeOf","p","setPrototypeOf","__proto__","flarum","core","compat","ReindexWarningWidget","subClass","superClass","create","constructor","className","content","m","Alert","type","dismissible","icon","title","app","DashboardWidget","activeIndex","compatVersion","extend","DashboardPage","items","add","languages","Map","forEach","language","set","registerSetting","setting","label","default","help","options","fromEntries","entries"],"sourceRoot":""}
\ No newline at end of file
diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts
index fd096ee..42da4ee 100644
--- a/js/src/admin/index.ts
+++ b/js/src/admin/index.ts
@@ -1,6 +1,36 @@
import app from 'flarum/admin/app';
+import { extend } from 'flarum/common/extend';
+import DashboardPage from 'flarum/admin/components/DashboardPage';
+import DashboardWidget from 'flarum/admin/components/DashboardWidget';
+import Alert from 'flarum/common/components/Alert';
+
+const REQUIRED_INDEX_COMPAT = 'v2';
+
+class ReindexWarningWidget extends DashboardWidget {
+ className() {
+ return 'ReindexWarningWidget';
+ }
+
+ content() {
+ return m(Alert, {
+ type: 'warning',
+ dismissible: false,
+ icon: 'fas fa-exclamation-triangle',
+ title: app.translator.trans('blomstra-search.admin.reindex-required.title'),
+ }, app.translator.trans('blomstra-search.admin.reindex-required.detail'));
+ }
+}
app.initializers.add('blomstra-search', () => {
+ const activeIndex = app.data.settings['blomstra-search.active-index'];
+ const compatVersion = app.data.settings['blomstra-search.index-compatible'];
+
+ if (activeIndex && compatVersion !== REQUIRED_INDEX_COMPAT) {
+ extend(DashboardPage.prototype, 'availableWidgets', function (items) {
+ items.add('blomstra-search-reindex', m(ReindexWarningWidget), 110);
+ });
+ }
+
const languages = new Map();
[
'arabic',
@@ -73,12 +103,6 @@ app.initializers.add('blomstra-search', () => {
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({
setting: 'blomstra-search.search-discussion-subjects',
label: app.translator.trans('blomstra-search.admin.search-discussion-subjects'),
diff --git a/resources/less/admin.less b/resources/less/admin.less
new file mode 100644
index 0000000..ab4e33e
--- /dev/null
+++ b/resources/less/admin.less
@@ -0,0 +1,3 @@
+.ReindexWarningWidget {
+ padding: 0;
+}
diff --git a/resources/locale/en.yml b/resources/locale/en.yml
index 5d72824..16a6597 100644
--- a/resources/locale/en.yml
+++ b/resources/locale/en.yml
@@ -9,6 +9,9 @@ blomstra-search:
help: |
The analyzer makes search understand stop words and undertakes language
specific improvements for indexing.
+ reindex-required:
+ title: Search index rebuild required
+ detail: "The search index is not compatible with this version of the extension. Run: php flarum blomstra:search:index build"
search-discussion-subjects: Search inside discussion titles
search-post-bodies: Search inside comments
match-sentences: Match search term against full sentence
diff --git a/src/Commands/BuildCommand.php b/src/Commands/BuildCommand.php
index a0a8b7c..e4b1ca9 100644
--- a/src/Commands/BuildCommand.php
+++ b/src/Commands/BuildCommand.php
@@ -29,20 +29,56 @@ use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery;
class BuildCommand extends Command
{
+ /**
+ * Bump this when a mapping change requires a full reindex.
+ * Stored in blomstra-search.index-compatible after a successful build.
+ */
+ public const INDEX_COMPAT_VERSION = 'v2';
+
protected $signature = 'blomstra:search:index
{action? : build | promote | rollback | discard | mapping | fill}
- {--fresh : With build: drop the staging index and start completely fresh}
- {--resume : With build: continue from where an interrupted build left off}
- {--keep-backup : With promote: retain the replaced live index as a backup for rollback}
- {--pending : With discard: drop the staging index}
- {--backup : With discard: drop the backup index}
- {--only= : Seed only this document type (e.g. discussions or posts)}
- {--max-id= : Limit seeding to IDs up to this value}
- {--throttle= : Seconds to wait between batches}
- {--i-am-sure : Skip the promote confirmation prompt (for scripts/CI)}';
+ {--fresh}
+ {--resume}
+ {--staging}
+ {--keep-backup}
+ {--pending}
+ {--backup}
+ {--only=}
+ {--max-id=}
+ {--throttle=}
+ {--i-am-sure}';
protected $description = 'Build and manage the Elasticsearch search index.';
+ protected $help = <<<'HELP'
+Actions and their options:
+
+ build Queue documents and promote automatically.
+ --resume Resume an interrupted build from where it left off
+ --fresh Drop the staging index and start completely fresh
+ --staging Keep in staging — requires explicit promote (blue-green)
+ --keep-backup Retain the replaced index for rollback
+
+ promote Swap alias to staging index (blue-green workflow).
+ --keep-backup Retain the replaced index for rollback
+ --i-am-sure Skip the confirmation prompt
+
+ rollback Restore the backup index to live.
+
+ discard Drop an index without promoting.
+ --pending Drop the staging index (cancel a build)
+ --backup Drop the backup index (cleanup after --keep-backup)
+
+ mapping Push updated mapping to the live index only.
+
+ fill Seed only documents missing from the live index.
+
+Shared seeding options (build / fill):
+ --only=TYPE Seed only this type: discussions or posts
+ --throttle=N Seconds to wait between batches
+ --max-id=N Limit seeding to IDs up to this value
+HELP;
+
public function handle(Container $container): void
{
/** @var Client $client */
@@ -139,11 +175,15 @@ class BuildCommand extends Command
$this->runSeeders($seeders, $container->make(Queue::class), $client, $settings, $targetIndex);
if ($stagingBuild) {
- $staging = $settings->get('blomstra-search.staging-index');
- $this->info("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');
- $this->line(' php flarum blomstra:search:index promote --keep-backup # retain old index for rollback');
+ 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);
+ }
}
}
@@ -159,6 +199,11 @@ class BuildCommand extends Command
}
}
+ $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])) {
@@ -202,6 +247,7 @@ class BuildCommand extends Command
$settings->set('blomstra-search.active-index', $staging);
$settings->set('blomstra-search.staging-index', null);
+ $settings->set('blomstra-search.index-compatible', self::INDEX_COMPAT_VERSION);
}
protected function runRollback(Client $client, string $alias, SettingsRepositoryInterface $settings): void
@@ -376,6 +422,7 @@ class BuildCommand extends Command
$client->indices()->putAlias(['index' => $concrete, 'name' => $alias]);
$settings->set('blomstra-search.active-index', $concrete);
+ $settings->set('blomstra-search.index-compatible', self::INDEX_COMPAT_VERSION);
foreach ($seeders as $seeder) {
$this->setContinueAt($settings, $seeder->type(), null);