From ef9a55c2a267006f8feb6b9c890e11b50409b241 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 15 Aug 2026 10:41:23 +0200 Subject: [PATCH] Add category suggestions to the post editor. --- README.md | 2 +- crates/bds-ui/src/app.rs | 97 ++++++++++++++++++++++++ crates/bds-ui/src/app/editor_handlers.rs | 35 ++++++++- crates/bds-ui/src/views/post_editor.rs | 86 +++++++++++++++++++-- locales/ui/de.ftl | 2 + locales/ui/en.ftl | 2 + locales/ui/es.ftl | 2 + locales/ui/fr.ftl | 2 + locales/ui/it.ftl | 2 + specs/editor_post.allium | 9 ++- 10 files changed, 227 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1c1acc6..6af96a7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The project is under active development. Core blogging workflows are broadly ava - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, and bounded process-local compiled Lua reuse with a fresh sandbox per invocation, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. - SQLite and filesystem persistence with byte-canonical bDS2 frontmatter, media sidecars, metadata JSON, and OPML menus; rebuild; bidirectional metadata diff/repair including project categories and publishing preferences; stale post-path cleanup on republish; bDS2-compatible checksums and NFD slug generation; and FTS5 search. -- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace. +- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag and category autocomplete and duplicate-post review in the desktop workspace. - Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links. - A localized Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by matching category archives and menu entries. - A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence. diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 0e8f769..4015ed0 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -6949,6 +6949,27 @@ impl BdsApp { Task::none() } + fn ensure_post_editor_category(&mut self, post_id: &str, name: &str) -> Task { + let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else { + return Task::none(); + }; + let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) else { + return Task::none(); + }; + let categories = engine::meta::read_categories_json(data_dir).unwrap_or_default(); + if !categories + .iter() + .any(|category| category.eq_ignore_ascii_case(name)) + && let Err(error) = + engine::meta::add_category(db.conn(), data_dir, &post.project_id, name) + { + self.notify_operation_failed("editor.categories", error); + return Task::none(); + } + self.refresh_post_editor_category_options(); + Task::none() + } + fn insert_link_modal(&mut self, post_id: &str) -> Task { let (title, insertion_point) = match self.post_editors.get(post_id) { Some(state) => (state.title.clone(), state.insertion_point()), @@ -8768,6 +8789,7 @@ impl BdsApp { state.new_category_name.clear(); } self.dashboard_state = Some(self.hydrate_dashboard_state()); + self.refresh_post_editor_category_options(); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } Err(e) => self.notify_operation_failed("common.save", e), @@ -8853,6 +8875,7 @@ impl BdsApp { Ok(()) => { self.settings_state = Some(self.hydrate_settings_state()); self.dashboard_state = Some(self.hydrate_dashboard_state()); + self.refresh_post_editor_category_options(); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } Err(e) => self.notify_operation_failed("common.save", e), @@ -8894,6 +8917,7 @@ impl BdsApp { Ok(()) => { self.settings_state = Some(self.hydrate_settings_state()); self.dashboard_state = Some(self.hydrate_dashboard_state()); + self.refresh_post_editor_category_options(); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } Err(e) => self.notify_operation_failed("common.save", e), @@ -9566,6 +9590,20 @@ impl BdsApp { } } + fn project_category_names(&self) -> Vec { + self.data_dir + .as_deref() + .and_then(|path| engine::meta::read_categories_json(path).ok()) + .unwrap_or_default() + } + + fn refresh_post_editor_category_options(&mut self) { + let names = self.project_category_names(); + for editor in self.post_editors.values_mut() { + editor.available_categories.clone_from(&names); + } + } + fn post_translations_for_editor(&self, post: &Post) -> Vec { let Some(db) = &self.db else { return Vec::new(); @@ -9635,6 +9673,7 @@ impl BdsApp { linked_media, ); editor.available_tags = self.project_tag_names(&post.project_id); + editor.available_categories = self.project_category_names(); self.post_editors.insert(post.id.clone(), editor); } Err(e) => { @@ -13744,6 +13783,64 @@ mod tests { assert!(portable.contains("New Tag")); } + #[test] + fn post_editor_suggests_and_persists_project_categories() { + let (db, project, tmp) = setup(); + let edited = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Edited", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let mut app = make_app(db, project, &tmp); + let _ = app.open_tab(Tab { + id: edited.id.clone(), + tab_type: TabType::Post, + title: edited.title, + is_transient: false, + is_dirty: false, + }); + + assert_eq!( + app.post_editors[&edited.id].available_categories, + vec!["article", "aside", "page", "picture"] + ); + + app.settings_state = Some(app.hydrate_settings_state()); + let _ = app.handle_settings_msg(SettingsMsg::AddCategoryNameChanged("guides".into())); + let _ = app.handle_settings_msg(SettingsMsg::AddCategory); + + assert!( + app.post_editors[&edited.id] + .available_categories + .contains(&"guides".to_string()) + ); + + let _ = app.handle_post_editor_msg(PostEditorMsg::AddSuggestedCategory("notes".into())); + + assert!( + app.post_editors[&edited.id] + .categories + .contains(&"notes".to_string()) + ); + assert_eq!( + meta::read_categories_json(tmp.path()).unwrap(), + vec!["article", "aside", "guides", "notes", "page", "picture"] + ); + assert!( + app.post_editors[&edited.id] + .available_categories + .contains(&"notes".to_string()) + ); + } + #[test] fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/app/editor_handlers.rs b/crates/bds-ui/src/app/editor_handlers.rs index 66dfbef..89f3b6d 100644 --- a/crates/bds-ui/src/app/editor_handlers.rs +++ b/crates/bds-ui/src/app/editor_handlers.rs @@ -11,6 +11,10 @@ impl BdsApp { post_id: String, tag: String, }, + EnsureCategory { + post_id: String, + category: String, + }, LoadSemanticTags(String), AddGalleryImages(String), DetectLanguage(String), @@ -194,9 +198,33 @@ impl BdsApp { } PostEditorMsg::CategoriesInputSubmit => { let cat = state.categories_input.trim().to_string(); - if !cat.is_empty() && !state.categories.contains(&cat) { - state.categories.push(cat); + if !cat.is_empty() + && !state + .categories + .iter() + .any(|current| current.eq_ignore_ascii_case(&cat)) + { + state.categories.push(cat.clone()); state.mark_dirty(); + deferred = DeferredPostAction::EnsureCategory { + post_id: state.post_id.clone(), + category: cat, + }; + } + state.categories_input.clear(); + } + PostEditorMsg::AddSuggestedCategory(category) => { + if !state + .categories + .iter() + .any(|current| current.eq_ignore_ascii_case(&category)) + { + state.categories.push(category.clone()); + state.mark_dirty(); + deferred = DeferredPostAction::EnsureCategory { + post_id: state.post_id.clone(), + category, + }; } state.categories_input.clear(); } @@ -321,6 +349,9 @@ impl BdsApp { DeferredPostAction::EnsureTag { post_id, tag } => { self.ensure_post_editor_tag(&post_id, &tag) } + DeferredPostAction::EnsureCategory { post_id, category } => { + self.ensure_post_editor_category(&post_id, &category) + } DeferredPostAction::LoadSemanticTags(post_id) => { Task::done(Message::LoadSemanticTagSuggestions(post_id)) } diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 57bff8b..08c1de3 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -88,6 +88,7 @@ pub struct PostEditorState { pub tags_input: String, pub categories_input: String, pub available_tags: Vec, + pub available_categories: Vec, pub semantic_tag_suggestions: Vec, pub ai_activity: Option, pub active_language: String, @@ -139,6 +140,7 @@ impl Clone for PostEditorState { tags_input: self.tags_input.clone(), categories_input: self.categories_input.clone(), available_tags: self.available_tags.clone(), + available_categories: self.available_categories.clone(), semantic_tag_suggestions: self.semantic_tag_suggestions.clone(), ai_activity: self.ai_activity.clone(), active_language: self.active_language.clone(), @@ -208,6 +210,7 @@ impl PostEditorState { tags_input: String::new(), categories_input: String::new(), available_tags: Vec::new(), + available_categories: Vec::new(), semantic_tag_suggestions: Vec::new(), ai_activity: None, active_language: canonical_lang.clone(), @@ -445,6 +448,7 @@ pub enum PostEditorMsg { RemoveTag(String), CategoriesInputChanged(String), CategoriesInputSubmit, + AddSuggestedCategory(String), RemoveCategory(String), Save, Publish, @@ -770,9 +774,9 @@ pub fn view<'a>( chips.into() }; let matching_suggestions = - matching_tag_suggestions(&state.available_tags, &state.tags, &state.tags_input); + matching_taxonomy_suggestions(&state.available_tags, &state.tags, &state.tags_input); let query_addable = - tag_query_addable(&state.available_tags, &state.tags, &state.tags_input); + taxonomy_query_addable(&state.available_tags, &state.tags, &state.tags_input); let matching_tags: Element<'a, Message> = if state.tags_input.trim().is_empty() || (matching_suggestions.is_empty() && !query_addable) { @@ -819,6 +823,53 @@ pub fn view<'a>( Message::PostEditor(PostEditorMsg::CategoriesInputSubmit), |cat| Message::PostEditor(PostEditorMsg::RemoveCategory(cat)), ); + let matching_categories = matching_taxonomy_suggestions( + &state.available_categories, + &state.categories, + &state.categories_input, + ); + let category_query_addable = taxonomy_query_addable( + &state.available_categories, + &state.categories, + &state.categories_input, + ); + let category_suggestions: Element<'a, Message> = if state.categories_input.trim().is_empty() + || (matching_categories.is_empty() && !category_query_addable) + { + Space::new().into() + } else { + let mut chips = row![ + text(t(locale, "editor.matchingCategories")) + .size(11) + .color(inputs::LABEL_COLOR) + ] + .spacing(6) + .align_y(iced::Alignment::Center); + for category in matching_categories { + chips = chips.push( + keyboard::button(text(category).size(11)) + .on_press(Message::PostEditor(PostEditorMsg::AddSuggestedCategory( + category.to_string(), + ))) + .padding([4, 8]) + .style(inputs::secondary_button), + ); + } + if category_query_addable { + let query = state.categories_input.trim().to_string(); + chips = chips.push( + keyboard::button( + text(tw(locale, "editor.createCategory", &[("name", &query)])).size(11), + ) + .on_press(Message::PostEditor(PostEditorMsg::AddSuggestedCategory( + query, + ))) + .padding([4, 8]) + .style(inputs::secondary_button), + ); + } + chips.wrap().into() + }; // Post links sections let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() { @@ -968,6 +1019,7 @@ pub fn view<'a>( semantic_tags, matching_tags, categories_section, + category_suggestions, outlinks_section, backlinks_section, linked_media_section, @@ -1152,7 +1204,7 @@ pub fn view<'a>( .into() } -fn matching_tag_suggestions<'a>( +fn matching_taxonomy_suggestions<'a>( available: &'a [String], selected: &[String], query: &str, @@ -1174,7 +1226,7 @@ fn matching_tag_suggestions<'a>( .collect() } -fn tag_query_addable(available: &[String], selected: &[String], query: &str) -> bool { +fn taxonomy_query_addable(available: &[String], selected: &[String], query: &str) -> bool { let query = query.trim(); !query.is_empty() && !available.iter().any(|tag| tag.eq_ignore_ascii_case(query)) @@ -1437,7 +1489,7 @@ mod tests { let selected = vec!["Photography".to_string()]; assert_eq!( - matching_tag_suggestions(&available, &selected, "PHO"), + matching_taxonomy_suggestions(&available, &selected, "PHO"), vec!["Photo Essay"] ); } @@ -1448,9 +1500,27 @@ mod tests { .map(|index| format!("tag-{index}")) .collect::>(); - assert_eq!(matching_tag_suggestions(&available, &[], "tag").len(), 8); - assert!(!tag_query_addable(&available, &[], " TAG-3 ")); - assert!(tag_query_addable(&available, &[], "new tag")); + assert_eq!( + matching_taxonomy_suggestions(&available, &[], "tag").len(), + 8 + ); + assert!(!taxonomy_query_addable(&available, &[], " TAG-3 ")); + assert!(taxonomy_query_addable(&available, &[], "new tag")); + } + + #[test] + fn partial_category_query_matches_existing_categories_case_insensitively() { + let available = vec![ + "Article".to_string(), + "Photo Essay".to_string(), + "Photography".to_string(), + ]; + let selected = vec!["Photography".to_string()]; + + assert_eq!( + matching_taxonomy_suggestions(&available, &selected, "PHO"), + vec!["Photo Essay"] + ); } #[test] diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 464b8d6..daa9b88 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -31,6 +31,8 @@ common-refresh = Aktualisieren editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen editor-matchingTags = Passende Tags editor-createTag = Tag erstellen: { $name } +editor-matchingCategories = Passende Kategorien +editor-createCategory = Kategorie erstellen: { $name } duplicates-title = Doppelte Beiträge finden duplicates-searching = Duplikate werden gesucht… duplicates-complete = Duplikatsuche abgeschlossen diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 503d47b..5841445 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -31,6 +31,8 @@ common-refresh = Refresh editor-semanticTagSuggestions = Suggested from similar posts editor-matchingTags = Matching tags editor-createTag = Create tag: { $name } +editor-matchingCategories = Matching categories +editor-createCategory = Create category: { $name } duplicates-title = Find Duplicate Posts duplicates-searching = Searching for duplicates… duplicates-complete = Duplicate search complete diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 33a42c6..5a1e7f5 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -31,6 +31,8 @@ common-refresh = Actualizar editor-semanticTagSuggestions = Sugerencias de publicaciones similares editor-matchingTags = Etiquetas coincidentes editor-createTag = Crear etiqueta: { $name } +editor-matchingCategories = Categorías coincidentes +editor-createCategory = Crear categoría: { $name } duplicates-title = Buscar publicaciones duplicadas duplicates-searching = Buscando duplicados… duplicates-complete = Búsqueda de duplicados completada diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index bb4bf59..a5ba2ad 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -31,6 +31,8 @@ common-refresh = Actualiser editor-semanticTagSuggestions = Suggestions d’articles similaires editor-matchingTags = Étiquettes correspondantes editor-createTag = Créer l’étiquette : { $name } +editor-matchingCategories = Catégories correspondantes +editor-createCategory = Créer la catégorie : { $name } duplicates-title = Rechercher les articles en double duplicates-searching = Recherche des doublons… duplicates-complete = Recherche des doublons terminée diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index f1a2e62..56269a1 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -31,6 +31,8 @@ common-refresh = Aggiorna editor-semanticTagSuggestions = Suggerimenti da post simili editor-matchingTags = Tag corrispondenti editor-createTag = Crea tag: { $name } +editor-matchingCategories = Categorie corrispondenti +editor-createCategory = Crea categoria: { $name } duplicates-title = Trova post duplicati duplicates-searching = Ricerca duplicati… duplicates-complete = Ricerca duplicati completata diff --git a/specs/editor_post.allium b/specs/editor_post.allium index 3ca3f68..a8b28cd 100644 --- a/specs/editor_post.allium +++ b/specs/editor_post.allium @@ -37,7 +37,7 @@ value PostEditorMetadata { language: String? -- select from supported languages do_not_translate: Boolean -- checkbox slug: String -- read-only text input - categories: List -- chip input + categories: List -- autocomplete chip input template_slug: String? -- select (shown only when templates exist) post_links: PostLinksPanel linked_media: List @@ -149,6 +149,13 @@ surface PostEditorSurface { -- it is an index read (no model inference) so it works offline and -- yields nothing when similarity is disabled or unindexed. + @guarantee CategoryAutocomplete + -- Category input with autocomplete. + -- While typing: substring match on existing category names (case-insensitive), + -- top 8 shown, plus a "create category" row when the query is new. + -- Selecting or creating a category adds it to the post and makes newly + -- created categories available in project metadata for future suggestions. + @guarantee TranslationFlagsBar -- Row of flag emoji buttons inline with metadata toggle. -- One flag per language: canonical language + each translation.