Repair incompatible search indexes safely

This commit is contained in:
2026-07-18 17:38:38 +02:00
parent ca5eb4e1ac
commit 2890921f3a
12 changed files with 653 additions and 175 deletions

View File

@@ -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<bool> {
conn.with(|c| {
@@ -49,30 +73,64 @@ pub fn tables_exist(conn: &Connection) -> QueryResult<bool> {
/// 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<bool> {
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::<ColumnNameRow>(c)?
.into_iter()
.map(|row| row.name)
.collect::<Vec<_>>();
let media_columns =
diesel::sql_query("SELECT name FROM pragma_table_info('media_fts') ORDER BY cid")
.load::<ColumnNameRow>(c)?
.into_iter()
.map(|row| row.name)
.collect::<Vec<_>>();
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<()> {

View File

@@ -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();

View File

@@ -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<dyn Fn(usize, usize, &str) + Send>;
/// 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<ReindexReport> {
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<bool> {
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<bool> {
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<ItemProgressFn>,
) -> EngineResult<ReindexReport> {
// 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<fts::PostTranslationFts> = 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<ReindexReport> {
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::<Vec<_>>();
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::<Vec<_>>();
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<fts::MediaTranslationFts> = 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);
}
}

View File

@@ -686,6 +686,9 @@ pub struct BdsApp {
task_manager: Arc<TaskManager>,
task_snapshots: Vec<TaskSnapshot>,
output_entries: Vec<OutputEntry>,
search_index_rebuild_required: bool,
search_index_rebuild_running: bool,
search_index_rebuild_task_id: Option<TaskId>,
// Platform
_menu_bar: Option<muda::Menu>,
@@ -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", &current.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<Message> {
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", &current.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<F>(&mut self, label_key: &str, work: F) -> Task<Message>
where
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
@@ -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();

View File

@@ -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,

View File

@@ -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])

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -179,6 +179,13 @@ modal-confirmDelete-delete = Supprimer
modal-confirm-title = Confirmer
modal-confirm-cancel = Annuler
modal-confirm-confirm = Confirmer
searchIndexRepair-title = Reconstruire lindex 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 lindex de recherche
searchIndexRepair-rebuildingMessage = Veuillez patienter. La modification est temporairement indisponible pendant la reconstruction de lindex.
searchIndexRepair-waitForTasks = Attendez la fin des autres tâches en arrière-plan avant de reconstruire lindex 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 lindex de recherche
settings-rebuildMedia = Reconstruire les médias
settings-rebuildScripts = Reconstruire les scripts
settings-rebuildTemplates = Reconstruire les modèles

View File

@@ -179,6 +179,13 @@ modal-confirmDelete-delete = Elimina
modal-confirm-title = Conferma
modal-confirm-cancel = Annulla
modal-confirm-confirm = Conferma
searchIndexRepair-title = Ricostruire lindice 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 dellindice di ricerca
searchIndexRepair-rebuildingMessage = Attendi. La modifica è temporaneamente non disponibile durante la ricostruzione dellindice.
searchIndexRepair-waitForTasks = Attendi il completamento delle altre attività in background prima di ricostruire lindice 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

View File

@@ -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()
}