From 2890921f3a33ada4abe30ddaf53320aff6832bc4 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 18 Jul 2026 17:38:38 +0200 Subject: [PATCH] Repair incompatible search indexes safely --- crates/bds-core/src/db/fts.rs | 98 ++++++-- crates/bds-core/src/engine/post.rs | 105 +++++++- crates/bds-core/src/engine/search.rs | 299 ++++++++++++++++------- crates/bds-ui/src/app.rs | 208 +++++++++++----- crates/bds-ui/src/views/modal.rs | 52 ++++ crates/bds-ui/src/views/settings_view.rs | 5 + locales/ui/de.ftl | 8 + locales/ui/en.ftl | 8 + locales/ui/es.ftl | 8 + locales/ui/fr.ftl | 8 + locales/ui/it.ftl | 8 + specs/search.allium | 21 ++ 12 files changed, 653 insertions(+), 175 deletions(-) diff --git a/crates/bds-core/src/db/fts.rs b/crates/bds-core/src/db/fts.rs index 9fc8033..dc34667 100644 --- a/crates/bds-core/src/db/fts.rs +++ b/crates/bds-core/src/db/fts.rs @@ -32,6 +32,30 @@ struct TableCountRow { count: i64, } +#[derive(QueryableByName)] +#[diesel(check_for_backend(Sqlite))] +struct ColumnNameRow { + #[diesel(sql_type = Text)] + name: String, +} + +const CREATE_FTS_TABLES: &str = "CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( + post_id UNINDEXED, + title, + excerpt, + content, + tags, + categories + ); + CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( + media_id UNINDEXED, + title, + alt, + caption, + original_name, + tags + );"; + /// Whether both application FTS5 virtual tables are present. pub fn tables_exist(conn: &Connection) -> QueryResult { conn.with(|c| { @@ -49,30 +73,64 @@ pub fn tables_exist(conn: &Connection) -> QueryResult { /// Schema follows specs/schema.allium: multi-column FTS5 with separate fields /// for weighted search. Not content-sync — we manually manage stemmed content. pub fn ensure_fts_tables(conn: &Connection) -> QueryResult<()> { + conn.with(|c| c.batch_execute(CREATE_FTS_TABLES)) +} + +/// Whether the runtime-managed FTS tables have the current deployed schema. +pub fn schema_is_current(conn: &Connection) -> QueryResult { conn.with(|c| { - c.batch_execute( - "CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( - post_id UNINDEXED, - title, - excerpt, - content, - tags, - categories - ); - CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( - media_id UNINDEXED, - title, - alt, - caption, - original_name, - tags - );", - ) + let post_columns = + diesel::sql_query("SELECT name FROM pragma_table_info('posts_fts') ORDER BY cid") + .load::(c)? + .into_iter() + .map(|row| row.name) + .collect::>(); + let media_columns = + diesel::sql_query("SELECT name FROM pragma_table_info('media_fts') ORDER BY cid") + .load::(c)? + .into_iter() + .map(|row| row.name) + .collect::>(); + + Ok(post_columns + == [ + "post_id", + "title", + "excerpt", + "content", + "tags", + "categories", + ] + && media_columns + == [ + "media_id", + "title", + "alt", + "caption", + "original_name", + "tags", + ]) }) } -pub fn clear_indexes(conn: &Connection) -> QueryResult<()> { - conn.with(|c| c.batch_execute("DELETE FROM posts_fts; DELETE FROM media_fts;")) +/// Replace the derived FTS tables without touching user-authored data. +pub fn recreate_tables(conn: &Connection) -> QueryResult<()> { + conn.with(|c| { + c.batch_execute("DROP TABLE IF EXISTS posts_fts; DROP TABLE IF EXISTS media_fts;")?; + c.batch_execute(CREATE_FTS_TABLES) + }) +} + +#[cfg(test)] +pub(crate) fn install_deployed_schema_for_test(conn: &Connection) -> QueryResult<()> { + conn.with(|c| { + c.batch_execute( + "DROP TABLE posts_fts; + DROP TABLE media_fts; + CREATE VIRTUAL TABLE posts_fts USING fts5(post_id UNINDEXED, content); + CREATE VIRTUAL TABLE media_fts USING fts5(media_id UNINDEXED, content);", + ) + }) } pub fn drop_post_index(conn: &Connection) -> QueryResult<()> { diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 20ed3aa..caafa7c 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -440,9 +440,21 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> post.status = PostStatus::Published; post.checksum = checksum; post.updated_at = now_unix_ms(); - qp::update_post(conn, &post)?; - fts_index_post(conn, &post)?; - Ok(post) + conn.begin_savepoint()?; + match (|| { + qp::update_post(conn, &post)?; + fts_index_post(conn, &post)?; + Ok(post) + })() { + Ok(post) => { + conn.release_savepoint()?; + Ok(post) + } + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) + } + } } /// Create a new draft post by duplicating an existing post. @@ -1429,6 +1441,93 @@ mod tests { assert!(!discarded.do_not_translate); } + #[test] + fn discard_works_after_deployed_search_index_schema_is_repaired() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Deployed Schema", + Some("published body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + publish_post(db.conn(), dir.path(), &post.id).unwrap(); + update_post( + db.conn(), + dir.path(), + &post.id, + Some("Draft Title"), + None, + None, + Some("draft body"), + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + + crate::db::fts::install_deployed_schema_for_test(db.conn()).unwrap(); + + assert!(crate::engine::search::prepare_search_index(db.conn()).unwrap()); + crate::engine::search::rebuild_search_index(db.conn(), None).unwrap(); + let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap(); + + assert_eq!(discarded.title, "Deployed Schema"); + assert_eq!(discarded.status, PostStatus::Published); + assert!(!crate::engine::search::search_index_rebuild_required(db.conn()).unwrap()); + } + + #[test] + fn discard_rolls_back_when_search_index_update_fails() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Published Title", + Some("published body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + publish_post(db.conn(), dir.path(), &post.id).unwrap(); + update_post( + db.conn(), + dir.path(), + &post.id, + Some("Draft Title"), + None, + None, + Some("draft body"), + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + crate::db::fts::install_deployed_schema_for_test(db.conn()).unwrap(); + + assert!(discard_post_draft(db.conn(), dir.path(), &post.id).is_err()); + let unchanged = qp::get_post_by_id(db.conn(), &post.id).unwrap(); + assert_eq!(unchanged.title, "Draft Title"); + assert_eq!(unchanged.status, PostStatus::Draft); + assert_eq!(unchanged.content.as_deref(), Some("draft body")); + } + #[test] fn duplicate_post_creates_new_draft_copy() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/search.rs b/crates/bds-core/src/engine/search.rs index da54d46..96b2df7 100644 --- a/crates/bds-core/src/engine/search.rs +++ b/crates/bds-core/src/engine/search.rs @@ -1,10 +1,18 @@ //! Full-text search reindexing engine functions. +use std::path::Path; + use crate::db::DbConnection as Connection; use crate::db::fts; -use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation}; +use crate::db::queries::{ + media as media_q, media_translation, post as post_q, post_translation, project as project_q, + setting, +}; use crate::engine::EngineResult; +use crate::util::now_unix_ms; + +const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required"; /// Result of a full reindex operation. pub struct ReindexReport { @@ -15,104 +23,163 @@ pub struct ReindexReport { /// Per-item progress callback: (current_item, total_items, item_description). pub type ItemProgressFn = Box; -/// Drop and rebuild the entire FTS index for all posts and media in a project. -pub fn reindex_all( - conn: &Connection, - project_id: &str, - main_language: &str, -) -> EngineResult { - reindex_all_with_progress(conn, project_id, main_language, None) +/// Repair a missing or previously deployed FTS schema and report whether its +/// derived content still needs to be rebuilt. +pub fn prepare_search_index(conn: &Connection) -> EngineResult { + if fts::schema_is_current(conn)? { + return search_index_rebuild_required(conn); + } + + let projects = project_q::list_projects(conn)?; + let has_content = projects.iter().try_fold(false, |has_content, project| { + Ok::<_, diesel::result::Error>( + has_content + || post_q::count_posts_by_project(conn, &project.id)? > 0 + || media_q::count_media_by_project(conn, &project.id)? > 0, + ) + })?; + + conn.begin_savepoint()?; + let result = (|| { + fts::recreate_tables(conn)?; + setting::set_setting_value( + conn, + REBUILD_REQUIRED_SETTING, + if has_content { "true" } else { "false" }, + now_unix_ms(), + )?; + Ok::<_, diesel::result::Error>(()) + })(); + match result { + Ok(()) => conn.release_savepoint()?, + Err(error) => { + let _ = conn.rollback_savepoint(); + return Err(error.into()); + } + } + + Ok(has_content) } -/// Like `reindex_all` but with optional per-item progress. -pub fn reindex_all_with_progress( +pub fn search_index_rebuild_required(conn: &Connection) -> EngineResult { + match setting::get_setting_by_key(conn, REBUILD_REQUIRED_SETTING) { + Ok(value) => Ok(value.value == "true"), + Err(diesel::result::Error::NotFound) => Ok(false), + Err(error) => Err(error.into()), + } +} + +/// Atomically recreate and rebuild the shared FTS index for every project. +pub fn rebuild_search_index( conn: &Connection, - project_id: &str, - main_language: &str, on_item: Option, ) -> EngineResult { - // Wipe existing FTS content - fts::clear_indexes(conn)?; - - // Reindex all posts - let posts = post_q::list_posts_by_project(conn, project_id)?; - - // Reindex all media - let media_items = media_q::list_media_by_project(conn, project_id)?; - - let total = posts.len() + media_items.len(); - - let mut posts_indexed = 0; - for (i, post) in posts.iter().enumerate() { - if let Some(ref cb) = on_item { - cb(i + 1, total, &post.title); + conn.begin_savepoint()?; + let result = (|| { + fts::recreate_tables(conn)?; + let report = index_all_projects(conn, on_item.as_ref())?; + setting::set_setting_value(conn, REBUILD_REQUIRED_SETTING, "false", now_unix_ms())?; + Ok(report) + })(); + match result { + Ok(report) => { + conn.release_savepoint()?; + Ok(report) } - let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) + } + } +} - let trans_data: Vec = translations - .iter() - .map(|t| fts::PostTranslationFts { - title: t.title.clone(), - excerpt: t.excerpt.clone(), - content: t.content.clone(), - language: t.language.clone(), - }) - .collect(); +fn index_all_projects( + conn: &Connection, + on_item: Option<&ItemProgressFn>, +) -> EngineResult { + let projects = project_q::list_projects(conn)?; + let total = projects.iter().try_fold(0usize, |total, project| { + Ok::<_, diesel::result::Error>( + total + + post_q::count_posts_by_project(conn, &project.id)? as usize + + media_q::count_media_by_project(conn, &project.id)? as usize, + ) + })?; + let mut current = 0; + let mut report = ReindexReport { + posts_indexed: 0, + media_indexed: 0, + }; - let language = post.language.as_deref().unwrap_or(main_language); - fts::index_post( - conn, - &post.id, - &post.title, - post.excerpt.as_deref(), - post.content.as_deref(), - &post.tags, - &post.categories, - &trans_data, - language, - )?; + for project in projects { + let main_language = project + .data_path + .as_deref() + .and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok()) + .and_then(|metadata| metadata.main_language) + .unwrap_or_else(|| "en".to_string()); - posts_indexed += 1; + for post in post_q::list_posts_by_project(conn, &project.id)? { + current += 1; + if let Some(callback) = on_item { + callback(current, total, &post.title); + } + let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; + let translation_data = translations + .iter() + .map(|translation| fts::PostTranslationFts { + title: translation.title.clone(), + excerpt: translation.excerpt.clone(), + content: translation.content.clone(), + language: translation.language.clone(), + }) + .collect::>(); + fts::index_post( + conn, + &post.id, + &post.title, + post.excerpt.as_deref(), + post.content.as_deref(), + &post.tags, + &post.categories, + &translation_data, + post.language.as_deref().unwrap_or(&main_language), + )?; + report.posts_indexed += 1; + } + + for media in media_q::list_media_by_project(conn, &project.id)? { + current += 1; + if let Some(callback) = on_item { + callback(current, total, &media.original_name); + } + let translations = + media_translation::list_media_translations_by_media(conn, &media.id)?; + let translation_data = translations + .iter() + .map(|translation| fts::MediaTranslationFts { + title: translation.title.clone(), + alt: translation.alt.clone(), + caption: translation.caption.clone(), + language: translation.language.clone(), + }) + .collect::>(); + fts::index_media( + conn, + &media.id, + media.title.as_deref(), + media.alt.as_deref(), + media.caption.as_deref(), + &media.original_name, + &media.tags, + &translation_data, + media.language.as_deref().unwrap_or(&main_language), + )?; + report.media_indexed += 1; + } } - let offset = posts.len(); - let mut media_indexed = 0; - for (i, m) in media_items.iter().enumerate() { - if let Some(ref cb) = on_item { - cb(offset + i + 1, total, &m.original_name); - } - let translations = media_translation::list_media_translations_by_media(conn, &m.id)?; - - let trans_data: Vec = translations - .iter() - .map(|t| fts::MediaTranslationFts { - title: t.title.clone(), - alt: t.alt.clone(), - caption: t.caption.clone(), - language: t.language.clone(), - }) - .collect(); - - let language = m.language.as_deref().unwrap_or(main_language); - fts::index_media( - conn, - &m.id, - m.title.as_deref(), - m.alt.as_deref(), - m.caption.as_deref(), - &m.original_name, - &m.tags, - &trans_data, - language, - )?; - - media_indexed += 1; - } - - Ok(ReindexReport { - posts_indexed, - media_indexed, - }) + Ok(report) } #[cfg(test)] @@ -139,15 +206,15 @@ mod tests { } #[test] - fn reindex_empty_project() { - let (db, project_id) = setup(); - let report = reindex_all(db.conn(), &project_id, "en").unwrap(); + fn rebuild_empty_index() { + let (db, _) = setup(); + let report = rebuild_search_index(db.conn(), None).unwrap(); assert_eq!(report.posts_indexed, 0); assert_eq!(report.media_indexed, 0); } #[test] - fn reindex_with_posts() { + fn rebuild_with_posts() { let (db, project_id) = setup(); let tmp = tempfile::tempdir().unwrap(); @@ -165,7 +232,7 @@ mod tests { ) .unwrap(); - let report = reindex_all(db.conn(), &project_id, "en").unwrap(); + let report = rebuild_search_index(db.conn(), None).unwrap(); assert_eq!(report.posts_indexed, 1); assert_eq!(report.media_indexed, 0); @@ -173,4 +240,50 @@ mod tests { let results = crate::db::fts::search_posts(db.conn(), "test", "en").unwrap(); assert_eq!(results.len(), 1); } + + #[test] + fn shared_rebuild_indexes_every_project() { + let (db, first_project_id) = setup(); + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + let second_project = engine::project::create_project( + db.conn(), + "Second Project", + Some(second_dir.path().to_str().unwrap()), + ) + .unwrap(); + + for (project_id, data_dir, title) in [ + ( + &first_project_id, + first_dir.path(), + "First Project Searchable", + ), + ( + &second_project.id, + second_dir.path(), + "Second Project Searchable", + ), + ] { + engine::post::create_post( + db.conn(), + data_dir, + project_id, + title, + Some("body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + } + + let report = rebuild_search_index(db.conn(), None).unwrap(); + let results = crate::db::fts::search_posts(db.conn(), "searchable", "en").unwrap(); + + assert_eq!(report.posts_indexed, 2); + assert_eq!(results.len(), 2); + } } diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 6a6d229..ecd6491 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -686,6 +686,9 @@ pub struct BdsApp { task_manager: Arc, task_snapshots: Vec, output_entries: Vec, + search_index_rebuild_required: bool, + search_index_rebuild_running: bool, + search_index_rebuild_task_id: Option, // Platform _menu_bar: Option, @@ -747,10 +750,13 @@ impl BdsApp { let _ = std::fs::create_dir_all(parent); } - let mut db = Database::open(&db_path).ok(); - if let Some(ref mut db) = db { + let db = Database::open(&db_path).ok(); + if let Some(ref db) = db { let _ = db.migrate(); } + let search_index_rebuild_required = db + .as_ref() + .is_some_and(|db| engine::search::prepare_search_index(db.conn()).unwrap_or(true)); // Load projects let projects = db @@ -831,6 +837,9 @@ impl BdsApp { task_manager: Arc::new(TaskManager::default()), task_snapshots: Vec::new(), output_entries: Vec::new(), + search_index_rebuild_required, + search_index_rebuild_running: false, + search_index_rebuild_task_id: None, _menu_bar: Some(menu_bar), menu_registry: registry, ui_locale: locale, @@ -841,7 +850,8 @@ impl BdsApp { project_dropdown_open: false, theme_badge: String::from("pico"), toasts: Vec::new(), - active_modal: None, + active_modal: search_index_rebuild_required + .then_some(modal::ModalState::SearchIndexRepair), preview_session: None, embedded_preview: None, main_window_id: None, @@ -889,6 +899,9 @@ impl BdsApp { task_manager: Arc::new(TaskManager::default()), task_snapshots: Vec::new(), output_entries: Vec::new(), + search_index_rebuild_required: false, + search_index_rebuild_running: false, + search_index_rebuild_task_id: None, _menu_bar: None, menu_registry: MenuRegistry::empty(), ui_locale: UiLocale::En, @@ -1260,7 +1273,9 @@ impl BdsApp { // ── Tasks ── Message::TaskTick => { self.refresh_task_snapshots(); - self.auto_save_due_post_editors(); + if !self.search_index_rebuild_running { + self.auto_save_due_post_editors(); + } Task::none() } @@ -1334,52 +1349,10 @@ impl BdsApp { }, ), Message::ReindexText => { - let locale = self.ui_locale; - self.spawn_engine_task( - "engine.reindexStarted", - move |db_path, project_id, data_dir, tm, tid| { - let db = Database::open(&db_path).map_err(|e| e.to_string())?; - tm.report_progress( - tid, - Some(0.0), - Some(t(locale, "engine.readingProjectConfig")), - ); - let main_lang = engine::meta::read_project_json(&data_dir) - .ok() - .and_then(|m| m.main_language) - .unwrap_or_else(|| "en".to_string()); - let tm2 = Arc::clone(&tm); - let on_item: engine::search::ItemProgressFn = - Box::new(move |current, total, name| { - let pct = if total > 0 { - current as f32 / total as f32 - } else { - 1.0 - }; - let msg = tw( - locale, - "engine.indexingItem", - &[ - ("current", ¤t.to_string()), - ("total", &total.to_string()), - ("name", name), - ], - ); - tm2.report_progress(tid, Some(pct), Some(msg)); - }); - let report = engine::search::reindex_all_with_progress( - db.conn(), - &project_id, - &main_lang, - Some(on_item), - ) - .map_err(|e| e.to_string())?; - Ok(format!( - "posts={}, media={}", - report.posts_indexed, report.media_indexed - )) - }, - ) + if !self.search_index_rebuild_running { + self.active_modal = Some(modal::ModalState::SearchIndexRepair); + } + Task::none() } Message::RegenerateCalendar => { let locale = self.ui_locale; @@ -1577,6 +1550,7 @@ impl BdsApp { label, result, } => { + let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id); match &result { Ok(detail) => { self.task_manager.complete(task_id); @@ -1592,6 +1566,15 @@ impl BdsApp { self.notify(ToastLevel::Error, &message); } } + if search_rebuild_finished { + self.search_index_rebuild_running = false; + self.search_index_rebuild_task_id = None; + self.active_modal = None; + if result.is_ok() { + self.search_index_rebuild_required = false; + } + self.sync_menu_state(); + } let sidebar_task = self.refresh_counts(); self.refresh_task_snapshots(); sidebar_task @@ -1670,6 +1653,10 @@ impl BdsApp { // ── Sidebar filters ── Message::PostSearchChanged(query) => { + if self.search_index_rebuild_required && !query.is_empty() { + self.active_modal = Some(modal::ModalState::SearchIndexRepair); + return Task::none(); + } let filter = match self.sidebar_view { SidebarView::Pages => &mut self.page_filter, _ => &mut self.post_filter, @@ -1767,6 +1754,10 @@ impl BdsApp { self.refresh_sidebar_posts() } Message::MediaSearchChanged(query) => { + if self.search_index_rebuild_required && !query.is_empty() { + self.active_modal = Some(modal::ModalState::SearchIndexRepair); + return Task::none(); + } self.media_filter.search_query = query; self.refresh_sidebar_media() } @@ -1838,6 +1829,7 @@ impl BdsApp { Message::ConfirmModal(action) => { self.active_modal = None; match action { + modal::ConfirmAction::RebuildSearchIndex => self.start_search_index_rebuild(), modal::ConfirmAction::DeleteProject(id) => { Task::done(Message::DeleteProject(id)) } @@ -3235,14 +3227,80 @@ impl BdsApp { ) } + /// Rebuild the shared search index while the modal blocks editor writes. + fn start_search_index_rebuild(&mut self) -> Task { + if self.db.is_none() || self.search_index_rebuild_running { + return Task::none(); + } + if self.task_manager.running_count() > 0 || self.task_manager.pending_count() > 0 { + self.active_modal = Some(modal::ModalState::SearchIndexRepair); + self.notify( + ToastLevel::Warning, + &t(self.ui_locale, "searchIndexRepair.waitForTasks"), + ); + return Task::none(); + } + + self.flush_active_post_editor(); + let locale = self.ui_locale; + let label = t(locale, "engine.reindexStarted"); + self.add_output(&label); + let task_id = self.task_manager.submit(&label); + self.search_index_rebuild_running = true; + self.search_index_rebuild_task_id = Some(task_id); + self.active_modal = Some(modal::ModalState::SearchIndexRebuilding); + self.refresh_task_snapshots(); + self.sync_menu_state(); + + let db_path = self.db_path.clone(); + let label_for_message = label.clone(); + let task_manager = Arc::clone(&self.task_manager); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let progress_manager = Arc::clone(&task_manager); + let on_item: engine::search::ItemProgressFn = + Box::new(move |current, total, name| { + let progress = if total > 0 { + current as f32 / total as f32 + } else { + 1.0 + }; + let message = tw( + locale, + "engine.indexingItem", + &[ + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ("name", name), + ], + ); + progress_manager.report_progress( + task_id, + Some(progress), + Some(message), + ); + }); + let report = engine::search::rebuild_search_index(db.conn(), Some(on_item)) + .map_err(|error| error.to_string())?; + Ok(format!( + "posts={}, media={}", + report.posts_indexed, report.media_indexed + )) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::EngineTaskDone { + task_id, + label: label_for_message.clone(), + result, + }, + ) + } + /// Spawn a blocking engine operation on a background thread via TaskManager. - /// - /// Returns `Task::none()` if no active project/db/data_dir. - /// Otherwise registers the task, logs the start message, and returns an - /// async `Task` that opens a fresh DB connection on a worker thread. - /// - /// The closure receives `(db_path, project_id, data_dir, task_manager, task_id)`. - /// Use `task_manager.report_progress(task_id, percent, message)` for live updates. fn spawn_engine_task(&mut self, label_key: &str, work: F) -> Task where F: FnOnce(PathBuf, String, PathBuf, Arc, TaskId) -> Result @@ -3854,12 +3912,13 @@ impl BdsApp { /// Called after state-changing operations (project switch, tab open/close, /// offline toggle) so that menu items reflect what's actually possible. fn sync_menu_state(&self) { - let has_project = self.active_project.is_some(); + let interactions_enabled = !self.search_index_rebuild_running; + let has_project = self.active_project.is_some() && interactions_enabled; let active_tab_type = self .active_tab .as_ref() .and_then(|id| self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type)); - let has_tab = active_tab_type.is_some(); + let has_tab = active_tab_type.is_some() && interactions_enabled; let has_post_tab = active_tab_type == Some(&TabType::Post); // File group: need active project for most, need open tab for Save @@ -5831,6 +5890,7 @@ impl BdsApp { }, ); } + SettingsMsg::RebuildSearchIndex => return Task::done(Message::ReindexText), SettingsMsg::RegenerateThumbnails => { let locale = self.ui_locale; return self.spawn_engine_task( @@ -7022,6 +7082,7 @@ mod tests { }; use crate::state::sidebar_filter::PostFilter; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; + use crate::views::modal; use crate::views::post_editor::PostEditorState; use crate::views::script_editor::ScriptEditorState; use crate::views::settings_view::SettingsViewState; @@ -7109,6 +7170,35 @@ mod tests { BdsApp::new_for_tests(db, project, tmp.path().to_path_buf()) } + #[test] + fn search_index_rebuild_requires_confirmation_and_blocks_editing() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + app.search_index_rebuild_required = true; + + let _ = app.update(Message::PostSearchChanged("query".to_string())); + assert!(app.post_filter.search_query.is_empty()); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::SearchIndexRepair) + )); + + let _ = app.update(Message::ReindexText); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::SearchIndexRepair) + )); + + let _ = app.update(Message::ConfirmModal( + modal::ConfirmAction::RebuildSearchIndex, + )); + assert!(app.search_index_rebuild_running); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::SearchIndexRebuilding) + )); + } + #[test] fn post_editor_save_flow_persists_changes() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index d485fbf..17db4d2 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -62,6 +62,8 @@ pub enum ModalState { message: String, on_confirm: ConfirmAction, }, + SearchIndexRepair, + SearchIndexRebuilding, /// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion. PostInsertLink { post_id: String, @@ -107,6 +109,7 @@ pub enum PostInsertLinkTab { /// What action to perform when modal is confirmed. #[derive(Debug, Clone)] pub enum ConfirmAction { + RebuildSearchIndex, DeleteProject(String), DeletePost(String), DeleteMedia(String), @@ -369,6 +372,55 @@ pub fn view( .into() } + ModalState::SearchIndexRepair => { + let buttons = row![ + button(text(t(locale, "searchIndexRepair.later")).size(13)) + .on_press(Message::DismissModal) + .padding([6, 16]) + .style(cancel_button_style), + Space::with_width(Length::Fill), + button(text(t(locale, "searchIndexRepair.rebuildNow")).size(13)) + .on_press(Message::ConfirmModal(ConfirmAction::RebuildSearchIndex)) + .padding([6, 16]) + .style(confirm_button_style), + ]; + let content = column![ + text(t(locale, "searchIndexRepair.title")) + .size(16) + .shaping(Shaping::Advanced) + .color(Color::WHITE), + Space::with_height(12.0), + text(t(locale, "searchIndexRepair.message")) + .size(13) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.80, 0.80, 0.85)), + Space::with_height(16.0), + buttons, + ]; + container(content.padding(20)) + .width(Length::Fixed(420.0)) + .style(modal_box_style) + .into() + } + + ModalState::SearchIndexRebuilding => container( + column![ + text(t(locale, "searchIndexRepair.rebuildingTitle")) + .size(16) + .shaping(Shaping::Advanced) + .color(Color::WHITE), + Space::with_height(12.0), + text(t(locale, "searchIndexRepair.rebuildingMessage")) + .size(13) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.80, 0.80, 0.85)), + ] + .padding(20), + ) + .width(Length::Fixed(420.0)) + .style(modal_box_style) + .into(), + ModalState::PostInsertLink { post_id: _post_id, title: _title, diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 17304c5..54f9c47 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -336,6 +336,7 @@ pub enum SettingsMsg { RebuildScripts, RebuildTemplates, RebuildLinks, + RebuildSearchIndex, RegenerateThumbnails, OpenDataFolder, /// Navigate to a specific section from sidebar; expand it, collapse all others. @@ -947,6 +948,10 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> { .on_press(Message::Settings(SettingsMsg::RebuildLinks)) .padding([6, 16]) .width(Length::Fill), + button(text(t(locale, "settings.rebuildSearchIndex")).size(13)) + .on_press(Message::Settings(SettingsMsg::RebuildSearchIndex)) + .padding([6, 16]) + .width(Length::Fill), button(text(t(locale, "settings.regenerateThumbnails")).size(13)) .on_press(Message::Settings(SettingsMsg::RegenerateThumbnails)) .padding([6, 16]) diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index b1c9391..3cfc49b 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -179,6 +179,13 @@ modal-confirmDelete-delete = Löschen modal-confirm-title = Bestätigen modal-confirm-cancel = Abbrechen modal-confirm-confirm = Bestätigen +searchIndexRepair-title = Suchindex neu aufbauen? +searchIndexRepair-message = Der Neuaufbau kann einige Zeit dauern. Die Suche ist bis zum Abschluss nicht verfügbar. Sie können ihn jetzt oder später in den Einstellungen starten. +searchIndexRepair-later = Später +searchIndexRepair-rebuildNow = Jetzt neu aufbauen +searchIndexRepair-rebuildingTitle = Suchindex wird neu aufgebaut +searchIndexRepair-rebuildingMessage = Bitte warten. Die Bearbeitung ist vorübergehend nicht verfügbar, während der Suchindex neu aufgebaut wird. +searchIndexRepair-waitForTasks = Warten Sie, bis die anderen Hintergrundaufgaben abgeschlossen sind, bevor Sie den Suchindex neu aufbauen. modal-postInsertLink-title = Link einfügen modal-postInsertLink-description = Wählen Sie einen Beitrag, den Sie verlinken möchten. modal-postInsertLink-cancel = Abbrechen @@ -353,6 +360,7 @@ settings-sshUsername = SSH-Benutzername settings-sshRemotePath = Remote-Pfad settings-clear = Leeren settings-rebuildPosts = Beiträge neu aufbauen +settings-rebuildSearchIndex = Suchindex neu aufbauen settings-rebuildMedia = Medien neu aufbauen settings-rebuildScripts = Skripte neu aufbauen settings-rebuildTemplates = Vorlagen neu aufbauen diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index f04d7cc..f69a578 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -179,6 +179,13 @@ modal-confirmDelete-delete = Delete modal-confirm-title = Confirm modal-confirm-cancel = Cancel modal-confirm-confirm = Confirm +searchIndexRepair-title = Rebuild Search Index? +searchIndexRepair-message = Rebuilding may take some time. Search is unavailable until it finishes. You can rebuild now or do it later from Preferences. +searchIndexRepair-later = Later +searchIndexRepair-rebuildNow = Rebuild Now +searchIndexRepair-rebuildingTitle = Rebuilding Search Index +searchIndexRepair-rebuildingMessage = Please wait. Editing is temporarily unavailable while the search index is rebuilt. +searchIndexRepair-waitForTasks = Wait for the other background tasks to finish before rebuilding the search index. modal-postInsertLink-title = Insert Link modal-postInsertLink-description = Select a post to link to. modal-postInsertLink-cancel = Cancel @@ -353,6 +360,7 @@ settings-sshUsername = SSH Username settings-sshRemotePath = Remote Path settings-clear = Clear settings-rebuildPosts = Rebuild Posts +settings-rebuildSearchIndex = Rebuild Search Index settings-rebuildMedia = Rebuild Media settings-rebuildScripts = Rebuild Scripts settings-rebuildTemplates = Rebuild Templates diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index a254b35..b0a9886 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -179,6 +179,13 @@ modal-confirmDelete-delete = Eliminar modal-confirm-title = Confirmar modal-confirm-cancel = Cancelar modal-confirm-confirm = Confirmar +searchIndexRepair-title = ¿Reconstruir el índice de búsqueda? +searchIndexRepair-message = La reconstrucción puede tardar. La búsqueda no estará disponible hasta que termine. Puedes iniciarla ahora o más tarde desde Preferencias. +searchIndexRepair-later = Más tarde +searchIndexRepair-rebuildNow = Reconstruir ahora +searchIndexRepair-rebuildingTitle = Reconstruyendo el índice de búsqueda +searchIndexRepair-rebuildingMessage = Espera. La edición no está disponible temporalmente mientras se reconstruye el índice. +searchIndexRepair-waitForTasks = Espera a que terminen las demás tareas en segundo plano antes de reconstruir el índice de búsqueda. modal-postInsertLink-title = Insertar enlace modal-postInsertLink-description = Selecciona una entrada a la que vincular. modal-postInsertLink-cancel = Cancelar @@ -353,6 +360,7 @@ settings-sshUsername = Nombre de usuario SSH settings-sshRemotePath = Ruta remota settings-clear = Limpiar settings-rebuildPosts = Reconstruir artículos +settings-rebuildSearchIndex = Reconstruir índice de búsqueda settings-rebuildMedia = Reconstruir medios settings-rebuildScripts = Reconstruir scripts settings-rebuildTemplates = Reconstruir plantillas diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 4529648..ea6bc05 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -179,6 +179,13 @@ modal-confirmDelete-delete = Supprimer modal-confirm-title = Confirmer modal-confirm-cancel = Annuler modal-confirm-confirm = Confirmer +searchIndexRepair-title = Reconstruire l’index de recherche ? +searchIndexRepair-message = La reconstruction peut prendre du temps. La recherche sera indisponible jusqu’à la fin. Vous pouvez la lancer maintenant ou plus tard dans les préférences. +searchIndexRepair-later = Plus tard +searchIndexRepair-rebuildNow = Reconstruire maintenant +searchIndexRepair-rebuildingTitle = Reconstruction de l’index de recherche +searchIndexRepair-rebuildingMessage = Veuillez patienter. La modification est temporairement indisponible pendant la reconstruction de l’index. +searchIndexRepair-waitForTasks = Attendez la fin des autres tâches en arrière-plan avant de reconstruire l’index de recherche. modal-postInsertLink-title = Insérer un lien modal-postInsertLink-description = Sélectionnez un article à lier. modal-postInsertLink-cancel = Annuler @@ -353,6 +360,7 @@ settings-sshUsername = Nom d'utilisateur SSH settings-sshRemotePath = Chemin distant settings-clear = Effacer settings-rebuildPosts = Reconstruire les articles +settings-rebuildSearchIndex = Reconstruire l’index de recherche settings-rebuildMedia = Reconstruire les médias settings-rebuildScripts = Reconstruire les scripts settings-rebuildTemplates = Reconstruire les modèles diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 7fc8e8a..f303022 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -179,6 +179,13 @@ modal-confirmDelete-delete = Elimina modal-confirm-title = Conferma modal-confirm-cancel = Annulla modal-confirm-confirm = Conferma +searchIndexRepair-title = Ricostruire l’indice di ricerca? +searchIndexRepair-message = La ricostruzione può richiedere tempo. La ricerca non sarà disponibile fino al completamento. Puoi avviarla ora o più tardi nelle preferenze. +searchIndexRepair-later = Più tardi +searchIndexRepair-rebuildNow = Ricostruisci ora +searchIndexRepair-rebuildingTitle = Ricostruzione dell’indice di ricerca +searchIndexRepair-rebuildingMessage = Attendi. La modifica è temporaneamente non disponibile durante la ricostruzione dell’indice. +searchIndexRepair-waitForTasks = Attendi il completamento delle altre attività in background prima di ricostruire l’indice di ricerca. modal-postInsertLink-title = Inserisci link modal-postInsertLink-description = Seleziona un post a cui collegarti. modal-postInsertLink-cancel = Annulla @@ -353,6 +360,7 @@ settings-sshUsername = Nome utente SSH settings-sshRemotePath = Percorso remoto settings-clear = Cancella settings-rebuildPosts = Ricostruisci articoli +settings-rebuildSearchIndex = Ricostruisci indice di ricerca settings-rebuildMedia = Ricostruisci media settings-rebuildScripts = Ricostruisci script settings-rebuildTemplates = Ricostruisci modelli diff --git a/specs/search.allium b/specs/search.allium index 81da2dd..be33d55 100644 --- a/specs/search.allium +++ b/specs/search.allium @@ -13,6 +13,11 @@ surface SearchControlSurface { provides: SearchPostsRequested(query, filters) SearchMediaRequested(query, filters) + RebuildSearchIndexRequested() + + @guarantee DeferredRepairChoice + -- When the search index requires repair, the operator is informed and + -- may rebuild immediately or defer the rebuild until later. } surface SearchIndexRuntimeSurface { @@ -21,6 +26,12 @@ surface SearchIndexRuntimeSurface { provides: SearchIndexUpdated(post) SearchIndexUpdated(media) + SearchIndexSchemaMismatchDetected() + + @guarantee SafeSearchIndexRepair + -- An incompatible derived index is made safe without changing authored + -- content. Search remains unavailable until an atomic rebuild covers + -- posts and media from every project. } value StemmerLanguage { @@ -165,3 +176,13 @@ rule IndexMedia { tags: tags ) } + +rule PrepareIncompatibleSearchIndex { + when: SearchIndexSchemaMismatchDetected() + ensures: SearchIndexRepairRequired() +} + +rule RebuildSearchIndex { + when: RebuildSearchIndexRequested() + ensures: SearchIndexesRebuilt() +}