Repair incompatible search indexes safely
This commit is contained in:
@@ -32,6 +32,30 @@ struct TableCountRow {
|
|||||||
count: i64,
|
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.
|
/// Whether both application FTS5 virtual tables are present.
|
||||||
pub fn tables_exist(conn: &Connection) -> QueryResult<bool> {
|
pub fn tables_exist(conn: &Connection) -> QueryResult<bool> {
|
||||||
conn.with(|c| {
|
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
|
/// Schema follows specs/schema.allium: multi-column FTS5 with separate fields
|
||||||
/// for weighted search. Not content-sync — we manually manage stemmed content.
|
/// for weighted search. Not content-sync — we manually manage stemmed content.
|
||||||
pub fn ensure_fts_tables(conn: &Connection) -> QueryResult<()> {
|
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| {
|
conn.with(|c| {
|
||||||
c.batch_execute(
|
let post_columns =
|
||||||
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
diesel::sql_query("SELECT name FROM pragma_table_info('posts_fts') ORDER BY cid")
|
||||||
post_id UNINDEXED,
|
.load::<ColumnNameRow>(c)?
|
||||||
title,
|
.into_iter()
|
||||||
excerpt,
|
.map(|row| row.name)
|
||||||
content,
|
.collect::<Vec<_>>();
|
||||||
tags,
|
let media_columns =
|
||||||
categories
|
diesel::sql_query("SELECT name FROM pragma_table_info('media_fts') ORDER BY cid")
|
||||||
);
|
.load::<ColumnNameRow>(c)?
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
|
.into_iter()
|
||||||
media_id UNINDEXED,
|
.map(|row| row.name)
|
||||||
title,
|
.collect::<Vec<_>>();
|
||||||
alt,
|
|
||||||
caption,
|
Ok(post_columns
|
||||||
original_name,
|
== [
|
||||||
tags
|
"post_id",
|
||||||
);",
|
"title",
|
||||||
)
|
"excerpt",
|
||||||
|
"content",
|
||||||
|
"tags",
|
||||||
|
"categories",
|
||||||
|
]
|
||||||
|
&& media_columns
|
||||||
|
== [
|
||||||
|
"media_id",
|
||||||
|
"title",
|
||||||
|
"alt",
|
||||||
|
"caption",
|
||||||
|
"original_name",
|
||||||
|
"tags",
|
||||||
|
])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_indexes(conn: &Connection) -> QueryResult<()> {
|
/// Replace the derived FTS tables without touching user-authored data.
|
||||||
conn.with(|c| c.batch_execute("DELETE FROM posts_fts; DELETE FROM media_fts;"))
|
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<()> {
|
pub fn drop_post_index(conn: &Connection) -> QueryResult<()> {
|
||||||
|
|||||||
@@ -440,9 +440,21 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) ->
|
|||||||
post.status = PostStatus::Published;
|
post.status = PostStatus::Published;
|
||||||
post.checksum = checksum;
|
post.checksum = checksum;
|
||||||
post.updated_at = now_unix_ms();
|
post.updated_at = now_unix_ms();
|
||||||
qp::update_post(conn, &post)?;
|
conn.begin_savepoint()?;
|
||||||
fts_index_post(conn, &post)?;
|
match (|| {
|
||||||
Ok(post)
|
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.
|
/// Create a new draft post by duplicating an existing post.
|
||||||
@@ -1429,6 +1441,93 @@ mod tests {
|
|||||||
assert!(!discarded.do_not_translate);
|
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]
|
#[test]
|
||||||
fn duplicate_post_creates_new_draft_copy() {
|
fn duplicate_post_creates_new_draft_copy() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
//! Full-text search reindexing engine functions.
|
//! Full-text search reindexing engine functions.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
|
|
||||||
use crate::db::fts;
|
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::engine::EngineResult;
|
||||||
|
use crate::util::now_unix_ms;
|
||||||
|
|
||||||
|
const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required";
|
||||||
|
|
||||||
/// Result of a full reindex operation.
|
/// Result of a full reindex operation.
|
||||||
pub struct ReindexReport {
|
pub struct ReindexReport {
|
||||||
@@ -15,104 +23,163 @@ pub struct ReindexReport {
|
|||||||
/// Per-item progress callback: (current_item, total_items, item_description).
|
/// Per-item progress callback: (current_item, total_items, item_description).
|
||||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
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.
|
/// Repair a missing or previously deployed FTS schema and report whether its
|
||||||
pub fn reindex_all(
|
/// derived content still needs to be rebuilt.
|
||||||
conn: &Connection,
|
pub fn prepare_search_index(conn: &Connection) -> EngineResult<bool> {
|
||||||
project_id: &str,
|
if fts::schema_is_current(conn)? {
|
||||||
main_language: &str,
|
return search_index_rebuild_required(conn);
|
||||||
) -> EngineResult<ReindexReport> {
|
}
|
||||||
reindex_all_with_progress(conn, project_id, main_language, None)
|
|
||||||
|
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 search_index_rebuild_required(conn: &Connection) -> EngineResult<bool> {
|
||||||
pub fn reindex_all_with_progress(
|
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,
|
conn: &Connection,
|
||||||
project_id: &str,
|
|
||||||
main_language: &str,
|
|
||||||
on_item: Option<ItemProgressFn>,
|
on_item: Option<ItemProgressFn>,
|
||||||
) -> EngineResult<ReindexReport> {
|
) -> EngineResult<ReindexReport> {
|
||||||
// Wipe existing FTS content
|
conn.begin_savepoint()?;
|
||||||
fts::clear_indexes(conn)?;
|
let result = (|| {
|
||||||
|
fts::recreate_tables(conn)?;
|
||||||
// Reindex all posts
|
let report = index_all_projects(conn, on_item.as_ref())?;
|
||||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
setting::set_setting_value(conn, REBUILD_REQUIRED_SETTING, "false", now_unix_ms())?;
|
||||||
|
Ok(report)
|
||||||
// Reindex all media
|
})();
|
||||||
let media_items = media_q::list_media_by_project(conn, project_id)?;
|
match result {
|
||||||
|
Ok(report) => {
|
||||||
let total = posts.len() + media_items.len();
|
conn.release_savepoint()?;
|
||||||
|
Ok(report)
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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
|
fn index_all_projects(
|
||||||
.iter()
|
conn: &Connection,
|
||||||
.map(|t| fts::PostTranslationFts {
|
on_item: Option<&ItemProgressFn>,
|
||||||
title: t.title.clone(),
|
) -> EngineResult<ReindexReport> {
|
||||||
excerpt: t.excerpt.clone(),
|
let projects = project_q::list_projects(conn)?;
|
||||||
content: t.content.clone(),
|
let total = projects.iter().try_fold(0usize, |total, project| {
|
||||||
language: t.language.clone(),
|
Ok::<_, diesel::result::Error>(
|
||||||
})
|
total
|
||||||
.collect();
|
+ 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);
|
for project in projects {
|
||||||
fts::index_post(
|
let main_language = project
|
||||||
conn,
|
.data_path
|
||||||
&post.id,
|
.as_deref()
|
||||||
&post.title,
|
.and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok())
|
||||||
post.excerpt.as_deref(),
|
.and_then(|metadata| metadata.main_language)
|
||||||
post.content.as_deref(),
|
.unwrap_or_else(|| "en".to_string());
|
||||||
&post.tags,
|
|
||||||
&post.categories,
|
|
||||||
&trans_data,
|
|
||||||
language,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
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();
|
Ok(report)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -139,15 +206,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reindex_empty_project() {
|
fn rebuild_empty_index() {
|
||||||
let (db, project_id) = setup();
|
let (db, _) = setup();
|
||||||
let report = reindex_all(db.conn(), &project_id, "en").unwrap();
|
let report = rebuild_search_index(db.conn(), None).unwrap();
|
||||||
assert_eq!(report.posts_indexed, 0);
|
assert_eq!(report.posts_indexed, 0);
|
||||||
assert_eq!(report.media_indexed, 0);
|
assert_eq!(report.media_indexed, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reindex_with_posts() {
|
fn rebuild_with_posts() {
|
||||||
let (db, project_id) = setup();
|
let (db, project_id) = setup();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
@@ -165,7 +232,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.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.posts_indexed, 1);
|
||||||
assert_eq!(report.media_indexed, 0);
|
assert_eq!(report.media_indexed, 0);
|
||||||
|
|
||||||
@@ -173,4 +240,50 @@ mod tests {
|
|||||||
let results = crate::db::fts::search_posts(db.conn(), "test", "en").unwrap();
|
let results = crate::db::fts::search_posts(db.conn(), "test", "en").unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -686,6 +686,9 @@ pub struct BdsApp {
|
|||||||
task_manager: Arc<TaskManager>,
|
task_manager: Arc<TaskManager>,
|
||||||
task_snapshots: Vec<TaskSnapshot>,
|
task_snapshots: Vec<TaskSnapshot>,
|
||||||
output_entries: Vec<OutputEntry>,
|
output_entries: Vec<OutputEntry>,
|
||||||
|
search_index_rebuild_required: bool,
|
||||||
|
search_index_rebuild_running: bool,
|
||||||
|
search_index_rebuild_task_id: Option<TaskId>,
|
||||||
|
|
||||||
// Platform
|
// Platform
|
||||||
_menu_bar: Option<muda::Menu>,
|
_menu_bar: Option<muda::Menu>,
|
||||||
@@ -747,10 +750,13 @@ impl BdsApp {
|
|||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut db = Database::open(&db_path).ok();
|
let db = Database::open(&db_path).ok();
|
||||||
if let Some(ref mut db) = db {
|
if let Some(ref db) = db {
|
||||||
let _ = db.migrate();
|
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
|
// Load projects
|
||||||
let projects = db
|
let projects = db
|
||||||
@@ -831,6 +837,9 @@ impl BdsApp {
|
|||||||
task_manager: Arc::new(TaskManager::default()),
|
task_manager: Arc::new(TaskManager::default()),
|
||||||
task_snapshots: Vec::new(),
|
task_snapshots: Vec::new(),
|
||||||
output_entries: 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_bar: Some(menu_bar),
|
||||||
menu_registry: registry,
|
menu_registry: registry,
|
||||||
ui_locale: locale,
|
ui_locale: locale,
|
||||||
@@ -841,7 +850,8 @@ impl BdsApp {
|
|||||||
project_dropdown_open: false,
|
project_dropdown_open: false,
|
||||||
theme_badge: String::from("pico"),
|
theme_badge: String::from("pico"),
|
||||||
toasts: Vec::new(),
|
toasts: Vec::new(),
|
||||||
active_modal: None,
|
active_modal: search_index_rebuild_required
|
||||||
|
.then_some(modal::ModalState::SearchIndexRepair),
|
||||||
preview_session: None,
|
preview_session: None,
|
||||||
embedded_preview: None,
|
embedded_preview: None,
|
||||||
main_window_id: None,
|
main_window_id: None,
|
||||||
@@ -889,6 +899,9 @@ impl BdsApp {
|
|||||||
task_manager: Arc::new(TaskManager::default()),
|
task_manager: Arc::new(TaskManager::default()),
|
||||||
task_snapshots: Vec::new(),
|
task_snapshots: Vec::new(),
|
||||||
output_entries: 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_bar: None,
|
||||||
menu_registry: MenuRegistry::empty(),
|
menu_registry: MenuRegistry::empty(),
|
||||||
ui_locale: UiLocale::En,
|
ui_locale: UiLocale::En,
|
||||||
@@ -1260,7 +1273,9 @@ impl BdsApp {
|
|||||||
// ── Tasks ──
|
// ── Tasks ──
|
||||||
Message::TaskTick => {
|
Message::TaskTick => {
|
||||||
self.refresh_task_snapshots();
|
self.refresh_task_snapshots();
|
||||||
self.auto_save_due_post_editors();
|
if !self.search_index_rebuild_running {
|
||||||
|
self.auto_save_due_post_editors();
|
||||||
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1334,52 +1349,10 @@ impl BdsApp {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
Message::ReindexText => {
|
Message::ReindexText => {
|
||||||
let locale = self.ui_locale;
|
if !self.search_index_rebuild_running {
|
||||||
self.spawn_engine_task(
|
self.active_modal = Some(modal::ModalState::SearchIndexRepair);
|
||||||
"engine.reindexStarted",
|
}
|
||||||
move |db_path, project_id, data_dir, tm, tid| {
|
Task::none()
|
||||||
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
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Message::RegenerateCalendar => {
|
Message::RegenerateCalendar => {
|
||||||
let locale = self.ui_locale;
|
let locale = self.ui_locale;
|
||||||
@@ -1577,6 +1550,7 @@ impl BdsApp {
|
|||||||
label,
|
label,
|
||||||
result,
|
result,
|
||||||
} => {
|
} => {
|
||||||
|
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
|
||||||
match &result {
|
match &result {
|
||||||
Ok(detail) => {
|
Ok(detail) => {
|
||||||
self.task_manager.complete(task_id);
|
self.task_manager.complete(task_id);
|
||||||
@@ -1592,6 +1566,15 @@ impl BdsApp {
|
|||||||
self.notify(ToastLevel::Error, &message);
|
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();
|
let sidebar_task = self.refresh_counts();
|
||||||
self.refresh_task_snapshots();
|
self.refresh_task_snapshots();
|
||||||
sidebar_task
|
sidebar_task
|
||||||
@@ -1670,6 +1653,10 @@ impl BdsApp {
|
|||||||
|
|
||||||
// ── Sidebar filters ──
|
// ── Sidebar filters ──
|
||||||
Message::PostSearchChanged(query) => {
|
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 {
|
let filter = match self.sidebar_view {
|
||||||
SidebarView::Pages => &mut self.page_filter,
|
SidebarView::Pages => &mut self.page_filter,
|
||||||
_ => &mut self.post_filter,
|
_ => &mut self.post_filter,
|
||||||
@@ -1767,6 +1754,10 @@ impl BdsApp {
|
|||||||
self.refresh_sidebar_posts()
|
self.refresh_sidebar_posts()
|
||||||
}
|
}
|
||||||
Message::MediaSearchChanged(query) => {
|
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.media_filter.search_query = query;
|
||||||
self.refresh_sidebar_media()
|
self.refresh_sidebar_media()
|
||||||
}
|
}
|
||||||
@@ -1838,6 +1829,7 @@ impl BdsApp {
|
|||||||
Message::ConfirmModal(action) => {
|
Message::ConfirmModal(action) => {
|
||||||
self.active_modal = None;
|
self.active_modal = None;
|
||||||
match action {
|
match action {
|
||||||
|
modal::ConfirmAction::RebuildSearchIndex => self.start_search_index_rebuild(),
|
||||||
modal::ConfirmAction::DeleteProject(id) => {
|
modal::ConfirmAction::DeleteProject(id) => {
|
||||||
Task::done(Message::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", ¤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.
|
/// 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>
|
fn spawn_engine_task<F>(&mut self, label_key: &str, work: F) -> Task<Message>
|
||||||
where
|
where
|
||||||
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<String, String>
|
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,
|
/// Called after state-changing operations (project switch, tab open/close,
|
||||||
/// offline toggle) so that menu items reflect what's actually possible.
|
/// offline toggle) so that menu items reflect what's actually possible.
|
||||||
fn sync_menu_state(&self) {
|
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
|
let active_tab_type = self
|
||||||
.active_tab
|
.active_tab
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|id| self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type));
|
.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);
|
let has_post_tab = active_tab_type == Some(&TabType::Post);
|
||||||
|
|
||||||
// File group: need active project for most, need open tab for Save
|
// 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 => {
|
SettingsMsg::RegenerateThumbnails => {
|
||||||
let locale = self.ui_locale;
|
let locale = self.ui_locale;
|
||||||
return self.spawn_engine_task(
|
return self.spawn_engine_task(
|
||||||
@@ -7022,6 +7082,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::state::sidebar_filter::PostFilter;
|
use crate::state::sidebar_filter::PostFilter;
|
||||||
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
|
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
|
||||||
|
use crate::views::modal;
|
||||||
use crate::views::post_editor::PostEditorState;
|
use crate::views::post_editor::PostEditorState;
|
||||||
use crate::views::script_editor::ScriptEditorState;
|
use crate::views::script_editor::ScriptEditorState;
|
||||||
use crate::views::settings_view::SettingsViewState;
|
use crate::views::settings_view::SettingsViewState;
|
||||||
@@ -7109,6 +7170,35 @@ mod tests {
|
|||||||
BdsApp::new_for_tests(db, project, tmp.path().to_path_buf())
|
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]
|
#[test]
|
||||||
fn post_editor_save_flow_persists_changes() {
|
fn post_editor_save_flow_persists_changes() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ pub enum ModalState {
|
|||||||
message: String,
|
message: String,
|
||||||
on_confirm: ConfirmAction,
|
on_confirm: ConfirmAction,
|
||||||
},
|
},
|
||||||
|
SearchIndexRepair,
|
||||||
|
SearchIndexRebuilding,
|
||||||
/// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion.
|
/// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion.
|
||||||
PostInsertLink {
|
PostInsertLink {
|
||||||
post_id: String,
|
post_id: String,
|
||||||
@@ -107,6 +109,7 @@ pub enum PostInsertLinkTab {
|
|||||||
/// What action to perform when modal is confirmed.
|
/// What action to perform when modal is confirmed.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ConfirmAction {
|
pub enum ConfirmAction {
|
||||||
|
RebuildSearchIndex,
|
||||||
DeleteProject(String),
|
DeleteProject(String),
|
||||||
DeletePost(String),
|
DeletePost(String),
|
||||||
DeleteMedia(String),
|
DeleteMedia(String),
|
||||||
@@ -369,6 +372,55 @@ pub fn view(
|
|||||||
.into()
|
.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 {
|
ModalState::PostInsertLink {
|
||||||
post_id: _post_id,
|
post_id: _post_id,
|
||||||
title: _title,
|
title: _title,
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ pub enum SettingsMsg {
|
|||||||
RebuildScripts,
|
RebuildScripts,
|
||||||
RebuildTemplates,
|
RebuildTemplates,
|
||||||
RebuildLinks,
|
RebuildLinks,
|
||||||
|
RebuildSearchIndex,
|
||||||
RegenerateThumbnails,
|
RegenerateThumbnails,
|
||||||
OpenDataFolder,
|
OpenDataFolder,
|
||||||
/// Navigate to a specific section from sidebar; expand it, collapse all others.
|
/// 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))
|
.on_press(Message::Settings(SettingsMsg::RebuildLinks))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.width(Length::Fill),
|
.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))
|
button(text(t(locale, "settings.regenerateThumbnails")).size(13))
|
||||||
.on_press(Message::Settings(SettingsMsg::RegenerateThumbnails))
|
.on_press(Message::Settings(SettingsMsg::RegenerateThumbnails))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
|
|||||||
@@ -179,6 +179,13 @@ modal-confirmDelete-delete = Löschen
|
|||||||
modal-confirm-title = Bestätigen
|
modal-confirm-title = Bestätigen
|
||||||
modal-confirm-cancel = Abbrechen
|
modal-confirm-cancel = Abbrechen
|
||||||
modal-confirm-confirm = Bestätigen
|
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-title = Link einfügen
|
||||||
modal-postInsertLink-description = Wählen Sie einen Beitrag, den Sie verlinken möchten.
|
modal-postInsertLink-description = Wählen Sie einen Beitrag, den Sie verlinken möchten.
|
||||||
modal-postInsertLink-cancel = Abbrechen
|
modal-postInsertLink-cancel = Abbrechen
|
||||||
@@ -353,6 +360,7 @@ settings-sshUsername = SSH-Benutzername
|
|||||||
settings-sshRemotePath = Remote-Pfad
|
settings-sshRemotePath = Remote-Pfad
|
||||||
settings-clear = Leeren
|
settings-clear = Leeren
|
||||||
settings-rebuildPosts = Beiträge neu aufbauen
|
settings-rebuildPosts = Beiträge neu aufbauen
|
||||||
|
settings-rebuildSearchIndex = Suchindex neu aufbauen
|
||||||
settings-rebuildMedia = Medien neu aufbauen
|
settings-rebuildMedia = Medien neu aufbauen
|
||||||
settings-rebuildScripts = Skripte neu aufbauen
|
settings-rebuildScripts = Skripte neu aufbauen
|
||||||
settings-rebuildTemplates = Vorlagen neu aufbauen
|
settings-rebuildTemplates = Vorlagen neu aufbauen
|
||||||
|
|||||||
@@ -179,6 +179,13 @@ modal-confirmDelete-delete = Delete
|
|||||||
modal-confirm-title = Confirm
|
modal-confirm-title = Confirm
|
||||||
modal-confirm-cancel = Cancel
|
modal-confirm-cancel = Cancel
|
||||||
modal-confirm-confirm = Confirm
|
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-title = Insert Link
|
||||||
modal-postInsertLink-description = Select a post to link to.
|
modal-postInsertLink-description = Select a post to link to.
|
||||||
modal-postInsertLink-cancel = Cancel
|
modal-postInsertLink-cancel = Cancel
|
||||||
@@ -353,6 +360,7 @@ settings-sshUsername = SSH Username
|
|||||||
settings-sshRemotePath = Remote Path
|
settings-sshRemotePath = Remote Path
|
||||||
settings-clear = Clear
|
settings-clear = Clear
|
||||||
settings-rebuildPosts = Rebuild Posts
|
settings-rebuildPosts = Rebuild Posts
|
||||||
|
settings-rebuildSearchIndex = Rebuild Search Index
|
||||||
settings-rebuildMedia = Rebuild Media
|
settings-rebuildMedia = Rebuild Media
|
||||||
settings-rebuildScripts = Rebuild Scripts
|
settings-rebuildScripts = Rebuild Scripts
|
||||||
settings-rebuildTemplates = Rebuild Templates
|
settings-rebuildTemplates = Rebuild Templates
|
||||||
|
|||||||
@@ -179,6 +179,13 @@ modal-confirmDelete-delete = Eliminar
|
|||||||
modal-confirm-title = Confirmar
|
modal-confirm-title = Confirmar
|
||||||
modal-confirm-cancel = Cancelar
|
modal-confirm-cancel = Cancelar
|
||||||
modal-confirm-confirm = Confirmar
|
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-title = Insertar enlace
|
||||||
modal-postInsertLink-description = Selecciona una entrada a la que vincular.
|
modal-postInsertLink-description = Selecciona una entrada a la que vincular.
|
||||||
modal-postInsertLink-cancel = Cancelar
|
modal-postInsertLink-cancel = Cancelar
|
||||||
@@ -353,6 +360,7 @@ settings-sshUsername = Nombre de usuario SSH
|
|||||||
settings-sshRemotePath = Ruta remota
|
settings-sshRemotePath = Ruta remota
|
||||||
settings-clear = Limpiar
|
settings-clear = Limpiar
|
||||||
settings-rebuildPosts = Reconstruir artículos
|
settings-rebuildPosts = Reconstruir artículos
|
||||||
|
settings-rebuildSearchIndex = Reconstruir índice de búsqueda
|
||||||
settings-rebuildMedia = Reconstruir medios
|
settings-rebuildMedia = Reconstruir medios
|
||||||
settings-rebuildScripts = Reconstruir scripts
|
settings-rebuildScripts = Reconstruir scripts
|
||||||
settings-rebuildTemplates = Reconstruir plantillas
|
settings-rebuildTemplates = Reconstruir plantillas
|
||||||
|
|||||||
@@ -179,6 +179,13 @@ modal-confirmDelete-delete = Supprimer
|
|||||||
modal-confirm-title = Confirmer
|
modal-confirm-title = Confirmer
|
||||||
modal-confirm-cancel = Annuler
|
modal-confirm-cancel = Annuler
|
||||||
modal-confirm-confirm = Confirmer
|
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-title = Insérer un lien
|
||||||
modal-postInsertLink-description = Sélectionnez un article à lier.
|
modal-postInsertLink-description = Sélectionnez un article à lier.
|
||||||
modal-postInsertLink-cancel = Annuler
|
modal-postInsertLink-cancel = Annuler
|
||||||
@@ -353,6 +360,7 @@ settings-sshUsername = Nom d'utilisateur SSH
|
|||||||
settings-sshRemotePath = Chemin distant
|
settings-sshRemotePath = Chemin distant
|
||||||
settings-clear = Effacer
|
settings-clear = Effacer
|
||||||
settings-rebuildPosts = Reconstruire les articles
|
settings-rebuildPosts = Reconstruire les articles
|
||||||
|
settings-rebuildSearchIndex = Reconstruire l’index de recherche
|
||||||
settings-rebuildMedia = Reconstruire les médias
|
settings-rebuildMedia = Reconstruire les médias
|
||||||
settings-rebuildScripts = Reconstruire les scripts
|
settings-rebuildScripts = Reconstruire les scripts
|
||||||
settings-rebuildTemplates = Reconstruire les modèles
|
settings-rebuildTemplates = Reconstruire les modèles
|
||||||
|
|||||||
@@ -179,6 +179,13 @@ modal-confirmDelete-delete = Elimina
|
|||||||
modal-confirm-title = Conferma
|
modal-confirm-title = Conferma
|
||||||
modal-confirm-cancel = Annulla
|
modal-confirm-cancel = Annulla
|
||||||
modal-confirm-confirm = Conferma
|
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-title = Inserisci link
|
||||||
modal-postInsertLink-description = Seleziona un post a cui collegarti.
|
modal-postInsertLink-description = Seleziona un post a cui collegarti.
|
||||||
modal-postInsertLink-cancel = Annulla
|
modal-postInsertLink-cancel = Annulla
|
||||||
@@ -353,6 +360,7 @@ settings-sshUsername = Nome utente SSH
|
|||||||
settings-sshRemotePath = Percorso remoto
|
settings-sshRemotePath = Percorso remoto
|
||||||
settings-clear = Cancella
|
settings-clear = Cancella
|
||||||
settings-rebuildPosts = Ricostruisci articoli
|
settings-rebuildPosts = Ricostruisci articoli
|
||||||
|
settings-rebuildSearchIndex = Ricostruisci indice di ricerca
|
||||||
settings-rebuildMedia = Ricostruisci media
|
settings-rebuildMedia = Ricostruisci media
|
||||||
settings-rebuildScripts = Ricostruisci script
|
settings-rebuildScripts = Ricostruisci script
|
||||||
settings-rebuildTemplates = Ricostruisci modelli
|
settings-rebuildTemplates = Ricostruisci modelli
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ surface SearchControlSurface {
|
|||||||
provides:
|
provides:
|
||||||
SearchPostsRequested(query, filters)
|
SearchPostsRequested(query, filters)
|
||||||
SearchMediaRequested(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 {
|
surface SearchIndexRuntimeSurface {
|
||||||
@@ -21,6 +26,12 @@ surface SearchIndexRuntimeSurface {
|
|||||||
provides:
|
provides:
|
||||||
SearchIndexUpdated(post)
|
SearchIndexUpdated(post)
|
||||||
SearchIndexUpdated(media)
|
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 {
|
value StemmerLanguage {
|
||||||
@@ -165,3 +176,13 @@ rule IndexMedia {
|
|||||||
tags: tags
|
tags: tags
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rule PrepareIncompatibleSearchIndex {
|
||||||
|
when: SearchIndexSchemaMismatchDetected()
|
||||||
|
ensures: SearchIndexRepairRequired()
|
||||||
|
}
|
||||||
|
|
||||||
|
rule RebuildSearchIndex {
|
||||||
|
when: RebuildSearchIndexRequested()
|
||||||
|
ensures: SearchIndexesRebuilt()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user