Implement semantic embeddings and duplicate review

This commit is contained in:
2026-07-19 18:06:47 +02:00
parent fd4fd744e0
commit e8764262a3
42 changed files with 3808 additions and 79 deletions

View File

@@ -33,6 +33,7 @@ use crate::views::{
DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag,
DashboardTimelineMonth,
},
duplicates::DuplicatesState,
git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState},
import_editor::{
ImportAnalysisEvent, ImportEditorMsg, ImportEditorState, ImportExecutionEvent,
@@ -241,6 +242,23 @@ pub enum Message {
result: Result<engine::generation::GenerationReport, String>,
},
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
DuplicatesRefresh,
DuplicatesLoaded(Result<engine::embedding::DuplicateSearchResult, String>),
DuplicatesToggle(String, String),
DuplicatesCheckAll,
DuplicatesUncheckAll,
DuplicatesDismiss(String, String),
DuplicatesDismissSelected,
DuplicatesDismissed(Result<(), String>),
DuplicatesShowMore,
DuplicatesOpenPost(String),
EmbeddingReindex,
EmbeddingBackfill,
LoadSemanticTagSuggestions(String),
SemanticTagSuggestionsLoaded {
post_id: String,
result: Result<Vec<String>, String>,
},
// Git
GitRefresh,
@@ -927,6 +945,7 @@ pub struct BdsApp {
settings_state: Option<SettingsViewState>,
dashboard_state: Option<DashboardState>,
site_validation_state: SiteValidationState,
duplicates_state: DuplicatesState,
metadata_diff_state: MetadataDiffState,
translation_validation_state: crate::views::translation_validation::TranslationValidationState,
git_state: GitUiState,
@@ -1091,6 +1110,7 @@ impl BdsApp {
settings_state: None,
dashboard_state: None,
site_validation_state: SiteValidationState::default(),
duplicates_state: DuplicatesState::default(),
metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::default(),
git_state: GitUiState::default(),
@@ -1169,6 +1189,7 @@ impl BdsApp {
settings_state: None,
dashboard_state: None,
site_validation_state: SiteValidationState::default(),
duplicates_state: DuplicatesState::default(),
metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::default(),
git_state: GitUiState::default(),
@@ -1495,14 +1516,22 @@ impl BdsApp {
Message::OpenTab(tab) => {
self.flush_active_post_editor();
let idx = tabs::open_tab(&mut self.tabs, tab);
let mut semantic_post_id = None;
if let Some(t) = self.tabs.get(idx) {
self.active_tab = Some(t.id.clone());
let tab_clone = t.clone();
if tab_clone.tab_type == TabType::Post {
semantic_post_id = Some(tab_clone.id.clone());
}
self.load_editor_for_tab(&tab_clone);
}
self.enforce_panel_tab_fallback();
self.sync_menu_state();
self.sync_embedded_preview_for_active_post()
let mut tasks = vec![self.sync_embedded_preview_for_active_post()];
if let Some(post_id) = semantic_post_id {
tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id)));
}
Task::batch(tasks)
}
Message::CloseTab(id) => {
if self.active_tab.as_deref() == Some(id.as_str()) {
@@ -1573,17 +1602,24 @@ impl BdsApp {
}
let sidebar_task = self.refresh_counts();
self.sync_menu_state();
sidebar_task
Task::batch([sidebar_task, Task::done(Message::EmbeddingBackfill)])
}
Message::SwitchProject(project_id) => {
self.project_dropdown_open = false;
if let Some(ref db) = self.db {
if let (Some(outgoing), Some(data_dir)) = (&self.active_project, &self.data_dir)
{
let _ =
engine::embedding::EmbeddingService::production(db.conn(), data_dir)
.flush_project(&outgoing.id);
}
match engine::project::set_active_project(db.conn(), &project_id) {
Ok(()) => {
self.reset_git_for_project_change();
self.active_project =
self.projects.iter().find(|p| p.id == project_id).cloned();
self.preview_session = None;
self.duplicates_state = DuplicatesState::default();
self.hide_embedded_preview();
self.data_dir = self
.active_project
@@ -1626,7 +1662,11 @@ impl BdsApp {
}
}
self.sync_menu_state();
Task::batch([self.refresh_counts(), self.refresh_git_if_visible()])
Task::batch([
self.refresh_counts(),
self.refresh_git_if_visible(),
Task::done(Message::EmbeddingBackfill),
])
}
Message::ProjectSwitched(result) => {
match result {
@@ -2119,6 +2159,118 @@ impl BdsApp {
}
Task::none()
}
Message::DuplicatesRefresh => self.start_duplicate_search(0),
Message::DuplicatesShowMore => {
let next = self.duplicates_state.page.saturating_add(1);
self.start_duplicate_search(next)
}
Message::DuplicatesLoaded(result) => {
self.duplicates_state.is_loading = false;
self.duplicates_state.has_run = true;
match result {
Ok(result) => {
self.duplicates_state.result = result;
self.duplicates_state.error = None;
self.duplicates_state.selected.retain(|pair| {
self.duplicates_state.result.pairs.iter().any(|candidate| {
candidate.post_id_a == pair.0 && candidate.post_id_b == pair.1
})
});
}
Err(error) => self.duplicates_state.error = Some(error),
}
Task::none()
}
Message::DuplicatesToggle(a, b) => {
if !self
.duplicates_state
.selected
.remove(&(a.clone(), b.clone()))
{
self.duplicates_state.selected.insert((a, b));
}
Task::none()
}
Message::DuplicatesCheckAll => {
self.duplicates_state.selected = self
.duplicates_state
.result
.pairs
.iter()
.map(|pair| (pair.post_id_a.clone(), pair.post_id_b.clone()))
.collect();
Task::none()
}
Message::DuplicatesUncheckAll => {
self.duplicates_state.selected.clear();
Task::none()
}
Message::DuplicatesDismiss(a, b) => self.dismiss_duplicate_pairs(vec![(a, b)]),
Message::DuplicatesDismissSelected => self
.dismiss_duplicate_pairs(self.duplicates_state.selected.iter().cloned().collect()),
Message::DuplicatesDismissed(result) => {
match result {
Ok(()) => {
self.duplicates_state.selected.clear();
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "duplicates.dismissed"),
);
return self.start_duplicate_search(self.duplicates_state.page);
}
Err(error) => self.notify(ToastLevel::Error, &error),
}
Task::none()
}
Message::DuplicatesOpenPost(post_id) => {
let Some(db) = &self.db else {
return Task::none();
};
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id)
else {
return Task::none();
};
Task::done(Message::OpenTab(Tab {
id: post.id,
tab_type: TabType::Post,
title: post.title,
is_transient: false,
is_dirty: false,
}))
}
Message::EmbeddingReindex => self.start_embedding_reindex(),
Message::EmbeddingBackfill => self.start_embedding_backfill(),
Message::LoadSemanticTagSuggestions(post_id) => {
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
let db_path = self.db_path.clone();
let returned_post_id = post_id.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
.suggest_tags(&post_id)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::SemanticTagSuggestionsLoaded {
post_id: returned_post_id.clone(),
result,
},
)
}
Message::SemanticTagSuggestionsLoaded { post_id, result } => {
if let (Some(state), Ok(suggestions)) =
(self.post_editors.get_mut(&post_id), result)
{
state.semantic_tag_suggestions = suggestions;
}
Task::none()
}
message @ (Message::MainWindowLoaded(_) | Message::EmbeddedPreviewReady(_)) => {
self.handle_preview_message(message)
}
@@ -2129,6 +2281,7 @@ impl BdsApp {
self.refresh_task_snapshots();
self.process_chat_events();
self.persist_due_chat_surface_state();
let _ = engine::embedding::EmbeddingService::flush_due();
if !self.search_index_rebuild_running {
self.auto_save_due_post_editors();
}
@@ -2646,6 +2799,151 @@ impl BdsApp {
}
}
fn start_duplicate_search(&mut self, page: usize) -> Task<Message> {
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
return Task::none();
};
self.duplicates_state.enabled = engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled);
self.duplicates_state.page = page;
self.duplicates_state.error = None;
if !self.duplicates_state.enabled {
self.duplicates_state.is_loading = false;
self.duplicates_state.has_run = false;
self.duplicates_state.result = Default::default();
return Task::none();
}
self.duplicates_state.is_loading = true;
let db_path = self.db_path.clone();
let data_dir = data_dir.clone();
let project_id = project.id.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
.find_duplicates(&project_id, page)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
Message::DuplicatesLoaded,
)
}
fn dismiss_duplicate_pairs(&mut self, pairs: Vec<(String, String)>) -> Task<Message> {
if pairs.is_empty() {
return Task::none();
}
let (Some(_), Some(data_dir)) = (&self.db, &self.data_dir) else {
return Task::none();
};
let db_path = self.db_path.clone();
let data_dir = data_dir.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
.dismiss_duplicate_pairs(&pairs)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
Message::DuplicatesDismissed,
)
}
fn start_embedding_reindex(&mut self) -> Task<Message> {
let Some(data_dir) = &self.data_dir else {
return Task::none();
};
if !engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
{
self.notify(
ToastLevel::Warning,
&t(self.ui_locale, "duplicates.disabled"),
);
return Task::none();
}
let locale = self.ui_locale;
self.spawn_engine_task(
"menu.item.rebuildEmbeddingIndex",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let service = engine::embedding::EmbeddingService::production(db.conn(), &data_dir);
let indexed = service
.reindex_all_with_progress(&project_id, |current, total| {
tm.report_progress(
tid,
Some(current as f32 / total.max(1) as f32),
Some(tw(
locale,
"embeddings.indexingProgress",
&[
("current", &current.to_string()),
("total", &total.to_string()),
],
)),
);
!tm.is_cancelled(tid)
})
.map_err(|error| error.to_string())?;
service
.flush_project(&project_id)
.map_err(|error| error.to_string())?;
Ok(tw(
locale,
"embeddings.reindexed",
&[("count", &indexed.len().to_string())],
))
},
)
}
fn start_embedding_backfill(&mut self) -> Task<Message> {
let Some(data_dir) = &self.data_dir else {
return Task::none();
};
if !engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
{
return Task::none();
}
let locale = self.ui_locale;
self.spawn_engine_task(
"embeddings.indexing",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let indexed = engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
.index_unindexed_with_progress(&project_id, |current, total| {
tm.report_progress(
tid,
Some(current as f32 / total.max(1) as f32),
Some(tw(
locale,
"embeddings.indexingProgress",
&[
("current", &current.to_string()),
("total", &total.to_string()),
],
)),
);
!tm.is_cancelled(tid)
})
.map_err(|error| error.to_string())?;
Ok(tw(
locale,
"embeddings.indexed",
&[("count", &indexed.len().to_string())],
))
},
)
}
pub fn view(&self) -> Element<'_, Message> {
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
let active_post_filter = match self.sidebar_view {
@@ -2706,6 +3004,7 @@ impl BdsApp {
self.settings_state.as_ref(),
self.dashboard_state.as_ref(),
&self.site_validation_state,
&self.duplicates_state,
&self.metadata_diff_state,
&self.translation_validation_state,
&self.git_state,
@@ -3397,6 +3696,11 @@ impl BdsApp {
}
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
MenuAction::ReindexText => Task::done(Message::ReindexText),
MenuAction::RebuildEmbeddingIndex => Task::done(Message::EmbeddingReindex),
MenuAction::FindDuplicates => {
self.open_singleton_tab(TabType::FindDuplicates, "tabBar.findDuplicates");
Task::done(Message::DuplicatesRefresh)
}
MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff),
MenuAction::RegenerateCalendar => Task::done(Message::RegenerateCalendar),
MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations),
@@ -4777,7 +5081,7 @@ impl BdsApp {
..Default::default()
};
let ids = bds_core::db::fts::search_posts_filtered(
let mut ids = bds_core::db::fts::search_posts_filtered(
db.conn(),
query,
&self.content_language,
@@ -4786,6 +5090,19 @@ impl BdsApp {
.map(|results| results.post_ids)
.unwrap_or_default();
if let Some(data_dir) = &self.data_dir
&& let Ok(scores) = engine::embedding::EmbeddingService::production(db.conn(), data_dir)
.compute_similarities(current_post_id, &ids)
{
ids.sort_by(|a, b| {
scores
.get(b)
.copied()
.unwrap_or_default()
.total_cmp(&scores.get(a).copied().unwrap_or_default())
});
}
ids.into_iter()
.filter_map(|post_id| {
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
@@ -6199,6 +6516,7 @@ impl BdsApp {
blog_languages: Vec::new(),
},
);
let semantic_was_enabled = meta.semantic_similarity_enabled;
meta.name = state.project_name.clone();
meta.description = {
let value = state.project_description.text();
@@ -6249,6 +6567,8 @@ impl BdsApp {
let file_result = engine::meta::write_project_json(data_dir, &meta);
match (db_result, file_result) {
(Ok(()), Ok(())) => {
let semantic_should_backfill =
state.semantic_similarity_enabled && !semantic_was_enabled;
if let Some(listing) =
self.projects.iter_mut().find(|p| p.id == project.id)
{
@@ -6258,6 +6578,9 @@ impl BdsApp {
self.blog_languages = state.blog_languages.clone();
self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
if semantic_should_backfill {
return Task::done(Message::EmbeddingBackfill);
}
}
(Err(e), _) => self.notify_operation_failed("common.save", e),
(_, Err(e)) => self.notify_operation_failed("common.save", e),
@@ -7927,6 +8250,12 @@ impl BdsApp {
}
}
impl Drop for BdsApp {
fn drop(&mut self) {
let _ = engine::embedding::EmbeddingService::flush_all();
}
}
fn content_sample(content: &str, max_len: usize) -> String {
content.chars().take(max_len).collect()
}
@@ -9184,7 +9513,10 @@ mod tests {
let mut app = make_app(db, project, &tmp);
let _ = app.refresh_counts();
let dash = app.dashboard_state.expect("dashboard state should be set");
let dash = app
.dashboard_state
.clone()
.expect("dashboard state should be set");
let now = chrono::Utc::now();
assert_eq!(dash.stats.total_posts, 2);
assert_eq!(dash.stats.published_count, 1);
@@ -9552,10 +9884,10 @@ mod tests {
db_path.as_path(),
&project.id,
"en",
Some(tmp.path()),
&filter,
false,
50,
0,
(50, 0),
);
assert_eq!(posts.len(), 1);

View File

@@ -148,6 +148,19 @@ impl BdsApp {
}
state.tags_input.clear();
}
PostEditorMsg::AddSuggestedTag(tag) => {
if !state
.tags
.iter()
.any(|current| current.eq_ignore_ascii_case(&tag))
{
state.tags.push(tag.clone());
state.mark_dirty();
}
state
.semantic_tag_suggestions
.retain(|candidate| !candidate.eq_ignore_ascii_case(&tag));
}
PostEditorMsg::RemoveTag(tag) => {
state.tags.retain(|t| t != &tag);
state.mark_dirty();

View File

@@ -1,4 +1,5 @@
use super::*;
use chrono::Datelike;
impl BdsApp {
/// Number of items to load per sidebar page.
@@ -14,6 +15,7 @@ impl BdsApp {
let db_path = self.db_path.clone();
let project_id = project.id.clone();
let content_language = self.content_language.clone();
let data_dir = self.data_dir.clone();
let filter = match self.sidebar_view {
SidebarView::Pages => self.page_filter.clone(),
_ => self.post_filter.clone(),
@@ -27,10 +29,10 @@ impl BdsApp {
&db_path,
&project_id,
&content_language,
data_dir.as_deref(),
&filter,
is_pages,
Self::SIDEBAR_PAGE_SIZE + 1,
0,
(Self::SIDEBAR_PAGE_SIZE + 1, 0),
)
})
.await
@@ -99,6 +101,7 @@ impl BdsApp {
let db_path = self.db_path.clone();
let project_id = project.id.clone();
let content_language = self.content_language.clone();
let data_dir = self.data_dir.clone();
let offset = self.sidebar_posts.len() as i64;
let filter = match self.sidebar_view {
SidebarView::Pages => self.page_filter.clone(),
@@ -113,10 +116,10 @@ impl BdsApp {
&db_path,
&project_id,
&content_language,
data_dir.as_deref(),
&filter,
is_pages,
Self::SIDEBAR_PAGE_SIZE + 1,
offset,
(Self::SIDEBAR_PAGE_SIZE + 1, offset),
)
})
.await
@@ -163,15 +166,16 @@ impl BdsApp {
db_path: &Path,
project_id: &str,
content_language: &str,
data_dir: Option<&Path>,
filter: &PostFilter,
is_pages: bool,
limit: i64,
offset: i64,
pagination: (i64, i64),
) -> Vec<Post> {
let Ok(db) = Database::open(db_path) else {
return Vec::new();
};
let (limit, offset) = pagination;
let params = Self::build_post_filter_params(filter, is_pages);
if filter.search_query.trim().is_empty() {
return bds_core::db::queries::post::list_posts_filtered(
@@ -184,6 +188,11 @@ impl BdsApp {
.unwrap_or_default();
}
let semantic_enabled = data_dir.is_some_and(|dir| {
engine::meta::read_project_json(dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
});
let requested = limit.saturating_add(offset).max(0) as usize;
let fts_filters = bds_core::db::fts::PostSearchFilters {
status: params.status.as_deref(),
tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()),
@@ -193,12 +202,16 @@ impl BdsApp {
month: params.month,
from: params.from,
to: params.to,
limit: Some(limit as usize),
offset: Some(offset as usize),
limit: Some(if semantic_enabled {
requested
} else {
limit as usize
}),
offset: Some(if semantic_enabled { 0 } else { offset as usize }),
..Default::default()
};
let ids = bds_core::db::fts::search_posts_filtered(
let fts_ids = bds_core::db::fts::search_posts_filtered(
db.conn(),
&params.search_query,
content_language,
@@ -207,11 +220,60 @@ impl BdsApp {
.map(|results| results.post_ids)
.unwrap_or_default();
ids.into_iter()
let mut ids = Vec::new();
if semantic_enabled
&& let Some(data_dir) = data_dir
&& let Ok(similar) =
engine::embedding::EmbeddingService::production(db.conn(), data_dir)
.semantic_search(project_id, &params.search_query, requested)
{
ids.extend(similar.into_iter().map(|item| item.post_id));
}
ids.extend(fts_ids);
let mut seen = HashSet::new();
let posts = ids
.into_iter()
.filter(|post_id| seen.insert(post_id.clone()))
.filter_map(|post_id| {
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
})
.filter(|post| post.project_id == project_id)
.filter(|post| {
params
.status
.as_ref()
.is_none_or(|status| post.status.as_str() == status)
})
.filter(|post| {
params
.language
.as_ref()
.is_none_or(|language| post.language.as_deref() == Some(language))
})
.filter(|post| {
params
.tags
.iter()
.all(|wanted| post.tags.iter().any(|tag| tag.eq_ignore_ascii_case(wanted)))
})
.filter(|post| {
params.categories.iter().all(|wanted| {
post.categories
.iter()
.any(|category| category.eq_ignore_ascii_case(wanted))
})
})
.filter(|post| params.from.is_none_or(|from| post.created_at >= from))
.filter(|post| params.to.is_none_or(|to| post.created_at <= to))
.filter(|post| {
if params.year.is_none() && params.month.is_none() {
return true;
}
chrono::DateTime::from_timestamp_millis(post.created_at).is_some_and(|date| {
params.year.is_none_or(|year| date.year() == year)
&& params.month.is_none_or(|month| date.month() == month)
})
})
.filter(|post| {
let is_page_post = post
.categories
@@ -222,8 +284,12 @@ impl BdsApp {
} else {
!is_page_post
}
})
.collect()
});
if semantic_enabled {
posts.skip(offset as usize).take(limit as usize).collect()
} else {
posts.collect()
}
}
pub(super) fn query_sidebar_media_blocking(

View File

@@ -35,6 +35,8 @@ pub enum MenuAction {
EditMenu,
RebuildDatabase,
ReindexText,
RebuildEmbeddingIndex,
FindDuplicates,
MetadataDiff,
RegenerateCalendar,
ValidateTranslations,
@@ -69,6 +71,8 @@ impl MenuAction {
MenuAction::EditMenu,
MenuAction::RebuildDatabase,
MenuAction::ReindexText,
MenuAction::RebuildEmbeddingIndex,
MenuAction::FindDuplicates,
MenuAction::MetadataDiff,
MenuAction::RegenerateCalendar,
MenuAction::ValidateTranslations,
@@ -101,6 +105,8 @@ impl MenuAction {
"edit_menu" => Self::EditMenu,
"rebuild_database" => Self::RebuildDatabase,
"reindex_text" => Self::ReindexText,
"rebuild_embedding_index" => Self::RebuildEmbeddingIndex,
"find_duplicates" => Self::FindDuplicates,
"metadata_diff" => Self::MetadataDiff,
"regenerate_calendar" => Self::RegenerateCalendar,
"validate_translations" => Self::ValidateTranslations,
@@ -136,6 +142,8 @@ impl MenuAction {
Self::EditMenu => "menu.item.editMenu",
Self::RebuildDatabase => "menu.item.rebuildDatabase",
Self::ReindexText => "menu.item.reindexText",
Self::RebuildEmbeddingIndex => "menu.item.rebuildEmbeddingIndex",
Self::FindDuplicates => "menu.item.findDuplicates",
Self::MetadataDiff => "menu.item.metadataDiff",
Self::RegenerateCalendar => "menu.item.regenerateCalendar",
Self::ValidateTranslations => "menu.item.validateTranslations",
@@ -173,6 +181,8 @@ pub(crate) fn action_enabled(
| MenuAction::EditMenu
| MenuAction::RebuildDatabase
| MenuAction::ReindexText
| MenuAction::RebuildEmbeddingIndex
| MenuAction::FindDuplicates
| MenuAction::MetadataDiff
| MenuAction::RegenerateCalendar
| MenuAction::ValidateTranslations
@@ -205,6 +215,8 @@ pub(crate) fn action_enabled(
| MenuAction::EditMenu
| MenuAction::RebuildDatabase
| MenuAction::ReindexText
| MenuAction::RebuildEmbeddingIndex
| MenuAction::FindDuplicates
| MenuAction::MetadataDiff
| MenuAction::RegenerateCalendar
| MenuAction::ValidateTranslations
@@ -413,6 +425,13 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::RebuildEmbeddingIndex,
locale,
None,
));
let _ = blog_menu.append(&item(&mut reg, MenuAction::FindDuplicates, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(

View File

@@ -0,0 +1,182 @@
use std::collections::HashSet;
use bds_core::engine::embedding::DuplicateSearchResult;
use bds_core::i18n::UiLocale;
use iced::widget::text::Shaping;
use iced::widget::{Space, button, checkbox, column, container, row, scrollable, text};
use iced::{Color, Element, Length};
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::{t, tw};
#[derive(Debug, Clone, Default)]
pub struct DuplicatesState {
pub enabled: bool,
pub is_loading: bool,
pub has_run: bool,
pub page: usize,
pub result: DuplicateSearchResult,
pub selected: HashSet<(String, String)>,
pub error: Option<String>,
}
pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> {
let refresh = if state.is_loading {
button(text(t(locale, "duplicates.searching")).size(13)).style(inputs::secondary_button)
} else {
button(text(t(locale, "common.refresh")).size(13))
.on_press(Message::DuplicatesRefresh)
.style(inputs::secondary_button)
}
.padding([6, 16]);
let dismiss_checked = if state.selected.is_empty() || state.is_loading {
button(text(t(locale, "duplicates.dismissChecked")).size(13))
.style(inputs::secondary_button)
} else {
button(
text(tw(
locale,
"duplicates.dismissCheckedCount",
&[("count", &state.selected.len().to_string())],
))
.size(13),
)
.on_press(Message::DuplicatesDismissSelected)
.style(inputs::primary_button)
}
.padding([6, 16]);
let toolbar = inputs::toolbar(
vec![
text(t(locale, "duplicates.title"))
.size(20)
.shaping(Shaping::Advanced)
.into(),
text(tw(
locale,
"duplicates.count",
&[("count", &state.result.pairs.len().to_string())],
))
.size(12)
.color(inputs::LABEL_COLOR)
.into(),
],
vec![
button(text(t(locale, "duplicates.checkAll")).size(13))
.on_press(Message::DuplicatesCheckAll)
.padding([6, 12])
.style(inputs::secondary_button)
.into(),
button(text(t(locale, "duplicates.uncheckAll")).size(13))
.on_press(Message::DuplicatesUncheckAll)
.padding([6, 12])
.style(inputs::secondary_button)
.into(),
dismiss_checked.into(),
refresh.into(),
],
);
let body: Element<'_, Message> = if !state.enabled {
inputs::card(
text(t(locale, "duplicates.disabled"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else if state.is_loading && !state.has_run {
inputs::card(
text(t(locale, "duplicates.searching"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else if let Some(error) = &state.error {
inputs::card(
text(error.clone())
.size(14)
.color(Color::from_rgb(0.90, 0.38, 0.38)),
)
.into()
} else if state.has_run && state.result.pairs.is_empty() {
inputs::card(
text(t(locale, "duplicates.empty"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else {
let mut pairs = column!().spacing(8);
for pair in &state.result.pairs {
let key = (pair.post_id_a.clone(), pair.post_id_b.clone());
let checked = state.selected.contains(&key);
let badge = if pair.exact_match {
t(locale, "duplicates.exactMatch")
} else {
format!("{:.1}%", pair.similarity * 100.0)
};
pairs = pairs.push(inputs::card(
row![
checkbox("", checked)
.on_toggle({
let a = pair.post_id_a.clone();
let b = pair.post_id_b.clone();
move |_| Message::DuplicatesToggle(a.clone(), b.clone())
})
.size(16),
button(
text(pair.title_a.clone())
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::DuplicatesOpenPost(pair.post_id_a.clone()))
.padding([5, 8])
.style(inputs::disclosure_button),
text("").size(14).color(inputs::LABEL_COLOR),
button(
text(pair.title_b.clone())
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::DuplicatesOpenPost(pair.post_id_b.clone()))
.padding([5, 8])
.style(inputs::disclosure_button),
Space::with_width(Length::Fill),
text(badge).size(12).color(if pair.exact_match {
Color::from_rgb(0.96, 0.68, 0.28)
} else {
Color::from_rgb(0.55, 0.76, 0.92)
}),
button(text(t(locale, "duplicates.dismiss")).size(12))
.on_press(Message::DuplicatesDismiss(
pair.post_id_a.clone(),
pair.post_id_b.clone()
))
.padding([5, 12])
.style(inputs::secondary_button),
]
.spacing(8)
.align_y(iced::Alignment::Center),
));
}
if state.result.has_more {
pairs = pairs.push(
button(text(t(locale, "duplicates.showMore")).size(13))
.on_press(Message::DuplicatesShowMore)
.padding([7, 16])
.style(inputs::secondary_button),
);
}
scrollable(container(pairs).padding(2))
.height(Length::Fill)
.into()
};
container(column![toolbar, body].spacing(12))
.padding(16)
.width(Length::Fill)
.height(Length::Fill)
.into()
}

View File

@@ -2,6 +2,7 @@ pub mod activity_bar;
pub mod chat_surfaces;
pub mod chat_view;
pub mod dashboard;
pub mod duplicates;
pub mod git;
pub mod import_editor;
pub mod media_editor;

View File

@@ -76,6 +76,7 @@ pub struct PostEditorState {
pub quick_actions_open: bool,
pub tags_input: String,
pub categories_input: String,
pub semantic_tag_suggestions: Vec<String>,
pub active_language: String,
pub canonical_language: String,
pub blog_languages: Vec<String>,
@@ -123,6 +124,7 @@ impl Clone for PostEditorState {
quick_actions_open: self.quick_actions_open,
tags_input: self.tags_input.clone(),
categories_input: self.categories_input.clone(),
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
active_language: self.active_language.clone(),
canonical_language: self.canonical_language.clone(),
blog_languages: self.blog_languages.clone(),
@@ -188,6 +190,7 @@ impl PostEditorState {
quick_actions_open: false,
tags_input: String::new(),
categories_input: String::new(),
semantic_tag_suggestions: Vec::new(),
active_language: canonical_lang.clone(),
canonical_language: canonical_lang,
blog_languages: blog_languages.to_vec(),
@@ -342,6 +345,7 @@ pub enum PostEditorMsg {
SwitchLanguage(String),
TagsInputChanged(String),
TagsInputSubmit,
AddSuggestedTag(String),
RemoveTag(String),
CategoriesInputChanged(String),
CategoriesInputSubmit,
@@ -619,6 +623,28 @@ pub fn view<'a>(
Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|tag| Message::PostEditor(PostEditorMsg::RemoveTag(tag)),
);
let semantic_tags: Element<'a, Message> = if state.semantic_tag_suggestions.is_empty() {
Space::new(0, 0).into()
} else {
let mut chips = row![
text(t(locale, "editor.semanticTagSuggestions"))
.size(11)
.color(inputs::LABEL_COLOR)
]
.spacing(6)
.align_y(iced::Alignment::Center);
for tag in &state.semantic_tag_suggestions {
chips = chips.push(
button(text(format!("+ {tag}")).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
tag.clone(),
)))
.padding([4, 8])
.style(inputs::secondary_button),
);
}
chips.into()
};
// Categories chip input
let categories_section = chip_input_field(
@@ -776,6 +802,7 @@ pub fn view<'a>(
meta_row1,
meta_row2,
tags_section,
semantic_tags,
categories_section,
outlinks_section,
backlinks_section,

View File

@@ -18,6 +18,7 @@ use crate::views::{
activity_bar,
chat_view::{self, ChatEditorState},
dashboard::DashboardState,
duplicates::{self, DuplicatesState},
git::{self, GitDiffState, GitUiState},
media_editor::{self, MediaEditorState},
metadata_diff::{self, MetadataDiffState},
@@ -128,6 +129,7 @@ pub fn view<'a>(
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
git_state: &'a GitUiState,
@@ -158,6 +160,7 @@ pub fn view<'a>(
settings_state,
dashboard_state,
site_validation_state,
duplicates_state,
metadata_diff_state,
translation_validation_state,
git_diffs,
@@ -400,6 +403,7 @@ fn route_content_area<'a>(
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
git_diffs: &'a HashMap<String, GitDiffState>,
@@ -489,6 +493,7 @@ fn route_content_area<'a>(
}
}
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale),
ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale),
ContentRoute::TranslationValidation => {
translation_validation::view(translation_validation_state, locale)
@@ -524,6 +529,7 @@ enum ContentRoute<'a> {
Tags,
Settings,
SiteValidation,
FindDuplicates,
MetadataDiff,
TranslationValidation,
GitDiff(&'a str),
@@ -612,11 +618,11 @@ fn route_kind<'a>(
TabType::SiteValidation => ContentRoute::SiteValidation,
TabType::MetadataDiff => ContentRoute::MetadataDiff,
TabType::GitDiff => ContentRoute::GitDiff(tab_id),
TabType::FindDuplicates => ContentRoute::FindDuplicates,
TabType::Style
| TabType::MenuEditor
| TabType::Documentation
| TabType::ApiDocumentation
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
| TabType::ApiDocumentation => ContentRoute::Placeholder(&tab.title),
TabType::TranslationValidation => ContentRoute::TranslationValidation,
}
}
@@ -678,7 +684,6 @@ mod tests {
TabType::MenuEditor,
TabType::Documentation,
TabType::ApiDocumentation,
TabType::FindDuplicates,
];
for tab_type in unsupported {
@@ -703,6 +708,35 @@ mod tests {
}
}
#[test]
fn duplicate_tab_routes_to_real_review_surface() {
let tabs = vec![tab(
"find_duplicates",
TabType::FindDuplicates,
"Duplicates",
)];
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation = SiteValidationState::default();
let route = route_kind(
&tabs,
Some("find_duplicates"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
&empty_imports,
None,
None,
None,
&site_validation,
);
assert!(matches!(route, ContentRoute::FindDuplicates));
}
#[test]
fn git_diff_tab_routes_to_real_view() {
let empty_posts = HashMap::new();