Add category suggestions to the post editor.

This commit is contained in:
2026-08-15 10:41:23 +02:00
parent 1f03d91c5f
commit ef9a55c2a2
10 changed files with 227 additions and 12 deletions

View File

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

View File

@@ -6949,6 +6949,27 @@ impl BdsApp {
Task::none() Task::none()
} }
fn ensure_post_editor_category(&mut self, post_id: &str, name: &str) -> Task<Message> {
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<Message> { fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
let (title, insertion_point) = match self.post_editors.get(post_id) { let (title, insertion_point) = match self.post_editors.get(post_id) {
Some(state) => (state.title.clone(), state.insertion_point()), Some(state) => (state.title.clone(), state.insertion_point()),
@@ -8768,6 +8789,7 @@ impl BdsApp {
state.new_category_name.clear(); state.new_category_name.clear();
} }
self.dashboard_state = Some(self.hydrate_dashboard_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")); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
} }
Err(e) => self.notify_operation_failed("common.save", e), Err(e) => self.notify_operation_failed("common.save", e),
@@ -8853,6 +8875,7 @@ impl BdsApp {
Ok(()) => { Ok(()) => {
self.settings_state = Some(self.hydrate_settings_state()); self.settings_state = Some(self.hydrate_settings_state());
self.dashboard_state = Some(self.hydrate_dashboard_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")); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
} }
Err(e) => self.notify_operation_failed("common.save", e), Err(e) => self.notify_operation_failed("common.save", e),
@@ -8894,6 +8917,7 @@ impl BdsApp {
Ok(()) => { Ok(()) => {
self.settings_state = Some(self.hydrate_settings_state()); self.settings_state = Some(self.hydrate_settings_state());
self.dashboard_state = Some(self.hydrate_dashboard_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")); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
} }
Err(e) => self.notify_operation_failed("common.save", e), Err(e) => self.notify_operation_failed("common.save", e),
@@ -9566,6 +9590,20 @@ impl BdsApp {
} }
} }
fn project_category_names(&self) -> Vec<String> {
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<PostTranslation> { fn post_translations_for_editor(&self, post: &Post) -> Vec<PostTranslation> {
let Some(db) = &self.db else { let Some(db) = &self.db else {
return Vec::new(); return Vec::new();
@@ -9635,6 +9673,7 @@ impl BdsApp {
linked_media, linked_media,
); );
editor.available_tags = self.project_tag_names(&post.project_id); 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); self.post_editors.insert(post.id.clone(), editor);
} }
Err(e) => { Err(e) => {
@@ -13744,6 +13783,64 @@ mod tests {
assert!(portable.contains("New Tag")); 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] #[test]
fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() { fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() {
let (db, project, tmp) = setup(); let (db, project, tmp) = setup();

View File

@@ -11,6 +11,10 @@ impl BdsApp {
post_id: String, post_id: String,
tag: String, tag: String,
}, },
EnsureCategory {
post_id: String,
category: String,
},
LoadSemanticTags(String), LoadSemanticTags(String),
AddGalleryImages(String), AddGalleryImages(String),
DetectLanguage(String), DetectLanguage(String),
@@ -194,9 +198,33 @@ impl BdsApp {
} }
PostEditorMsg::CategoriesInputSubmit => { PostEditorMsg::CategoriesInputSubmit => {
let cat = state.categories_input.trim().to_string(); let cat = state.categories_input.trim().to_string();
if !cat.is_empty() && !state.categories.contains(&cat) { if !cat.is_empty()
state.categories.push(cat); && !state
.categories
.iter()
.any(|current| current.eq_ignore_ascii_case(&cat))
{
state.categories.push(cat.clone());
state.mark_dirty(); 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(); state.categories_input.clear();
} }
@@ -321,6 +349,9 @@ impl BdsApp {
DeferredPostAction::EnsureTag { post_id, tag } => { DeferredPostAction::EnsureTag { post_id, tag } => {
self.ensure_post_editor_tag(&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) => { DeferredPostAction::LoadSemanticTags(post_id) => {
Task::done(Message::LoadSemanticTagSuggestions(post_id)) Task::done(Message::LoadSemanticTagSuggestions(post_id))
} }

View File

@@ -88,6 +88,7 @@ pub struct PostEditorState {
pub tags_input: String, pub tags_input: String,
pub categories_input: String, pub categories_input: String,
pub available_tags: Vec<String>, pub available_tags: Vec<String>,
pub available_categories: Vec<String>,
pub semantic_tag_suggestions: Vec<String>, pub semantic_tag_suggestions: Vec<String>,
pub ai_activity: Option<String>, pub ai_activity: Option<String>,
pub active_language: String, pub active_language: String,
@@ -139,6 +140,7 @@ impl Clone for PostEditorState {
tags_input: self.tags_input.clone(), tags_input: self.tags_input.clone(),
categories_input: self.categories_input.clone(), categories_input: self.categories_input.clone(),
available_tags: self.available_tags.clone(), available_tags: self.available_tags.clone(),
available_categories: self.available_categories.clone(),
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(), semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
ai_activity: self.ai_activity.clone(), ai_activity: self.ai_activity.clone(),
active_language: self.active_language.clone(), active_language: self.active_language.clone(),
@@ -208,6 +210,7 @@ impl PostEditorState {
tags_input: String::new(), tags_input: String::new(),
categories_input: String::new(), categories_input: String::new(),
available_tags: Vec::new(), available_tags: Vec::new(),
available_categories: Vec::new(),
semantic_tag_suggestions: Vec::new(), semantic_tag_suggestions: Vec::new(),
ai_activity: None, ai_activity: None,
active_language: canonical_lang.clone(), active_language: canonical_lang.clone(),
@@ -445,6 +448,7 @@ pub enum PostEditorMsg {
RemoveTag(String), RemoveTag(String),
CategoriesInputChanged(String), CategoriesInputChanged(String),
CategoriesInputSubmit, CategoriesInputSubmit,
AddSuggestedCategory(String),
RemoveCategory(String), RemoveCategory(String),
Save, Save,
Publish, Publish,
@@ -770,9 +774,9 @@ pub fn view<'a>(
chips.into() chips.into()
}; };
let matching_suggestions = 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 = 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() let matching_tags: Element<'a, Message> = if state.tags_input.trim().is_empty()
|| (matching_suggestions.is_empty() && !query_addable) || (matching_suggestions.is_empty() && !query_addable)
{ {
@@ -819,6 +823,53 @@ pub fn view<'a>(
Message::PostEditor(PostEditorMsg::CategoriesInputSubmit), Message::PostEditor(PostEditorMsg::CategoriesInputSubmit),
|cat| Message::PostEditor(PostEditorMsg::RemoveCategory(cat)), |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 // Post links sections
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() { let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
@@ -968,6 +1019,7 @@ pub fn view<'a>(
semantic_tags, semantic_tags,
matching_tags, matching_tags,
categories_section, categories_section,
category_suggestions,
outlinks_section, outlinks_section,
backlinks_section, backlinks_section,
linked_media_section, linked_media_section,
@@ -1152,7 +1204,7 @@ pub fn view<'a>(
.into() .into()
} }
fn matching_tag_suggestions<'a>( fn matching_taxonomy_suggestions<'a>(
available: &'a [String], available: &'a [String],
selected: &[String], selected: &[String],
query: &str, query: &str,
@@ -1174,7 +1226,7 @@ fn matching_tag_suggestions<'a>(
.collect() .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(); let query = query.trim();
!query.is_empty() !query.is_empty()
&& !available.iter().any(|tag| tag.eq_ignore_ascii_case(query)) && !available.iter().any(|tag| tag.eq_ignore_ascii_case(query))
@@ -1437,7 +1489,7 @@ mod tests {
let selected = vec!["Photography".to_string()]; let selected = vec!["Photography".to_string()];
assert_eq!( assert_eq!(
matching_tag_suggestions(&available, &selected, "PHO"), matching_taxonomy_suggestions(&available, &selected, "PHO"),
vec!["Photo Essay"] vec!["Photo Essay"]
); );
} }
@@ -1448,9 +1500,27 @@ mod tests {
.map(|index| format!("tag-{index}")) .map(|index| format!("tag-{index}"))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(matching_tag_suggestions(&available, &[], "tag").len(), 8); assert_eq!(
assert!(!tag_query_addable(&available, &[], " TAG-3 ")); matching_taxonomy_suggestions(&available, &[], "tag").len(),
assert!(tag_query_addable(&available, &[], "new tag")); 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] #[test]

View File

@@ -31,6 +31,8 @@ common-refresh = Aktualisieren
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
editor-matchingTags = Passende Tags editor-matchingTags = Passende Tags
editor-createTag = Tag erstellen: { $name } editor-createTag = Tag erstellen: { $name }
editor-matchingCategories = Passende Kategorien
editor-createCategory = Kategorie erstellen: { $name }
duplicates-title = Doppelte Beiträge finden duplicates-title = Doppelte Beiträge finden
duplicates-searching = Duplikate werden gesucht… duplicates-searching = Duplikate werden gesucht…
duplicates-complete = Duplikatsuche abgeschlossen duplicates-complete = Duplikatsuche abgeschlossen

View File

@@ -31,6 +31,8 @@ common-refresh = Refresh
editor-semanticTagSuggestions = Suggested from similar posts editor-semanticTagSuggestions = Suggested from similar posts
editor-matchingTags = Matching tags editor-matchingTags = Matching tags
editor-createTag = Create tag: { $name } editor-createTag = Create tag: { $name }
editor-matchingCategories = Matching categories
editor-createCategory = Create category: { $name }
duplicates-title = Find Duplicate Posts duplicates-title = Find Duplicate Posts
duplicates-searching = Searching for duplicates… duplicates-searching = Searching for duplicates…
duplicates-complete = Duplicate search complete duplicates-complete = Duplicate search complete

View File

@@ -31,6 +31,8 @@ common-refresh = Actualizar
editor-semanticTagSuggestions = Sugerencias de publicaciones similares editor-semanticTagSuggestions = Sugerencias de publicaciones similares
editor-matchingTags = Etiquetas coincidentes editor-matchingTags = Etiquetas coincidentes
editor-createTag = Crear etiqueta: { $name } editor-createTag = Crear etiqueta: { $name }
editor-matchingCategories = Categorías coincidentes
editor-createCategory = Crear categoría: { $name }
duplicates-title = Buscar publicaciones duplicadas duplicates-title = Buscar publicaciones duplicadas
duplicates-searching = Buscando duplicados… duplicates-searching = Buscando duplicados…
duplicates-complete = Búsqueda de duplicados completada duplicates-complete = Búsqueda de duplicados completada

View File

@@ -31,6 +31,8 @@ common-refresh = Actualiser
editor-semanticTagSuggestions = Suggestions darticles similaires editor-semanticTagSuggestions = Suggestions darticles similaires
editor-matchingTags = Étiquettes correspondantes editor-matchingTags = Étiquettes correspondantes
editor-createTag = Créer létiquette : { $name } 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-title = Rechercher les articles en double
duplicates-searching = Recherche des doublons… duplicates-searching = Recherche des doublons…
duplicates-complete = Recherche des doublons terminée duplicates-complete = Recherche des doublons terminée

View File

@@ -31,6 +31,8 @@ common-refresh = Aggiorna
editor-semanticTagSuggestions = Suggerimenti da post simili editor-semanticTagSuggestions = Suggerimenti da post simili
editor-matchingTags = Tag corrispondenti editor-matchingTags = Tag corrispondenti
editor-createTag = Crea tag: { $name } editor-createTag = Crea tag: { $name }
editor-matchingCategories = Categorie corrispondenti
editor-createCategory = Crea categoria: { $name }
duplicates-title = Trova post duplicati duplicates-title = Trova post duplicati
duplicates-searching = Ricerca duplicati… duplicates-searching = Ricerca duplicati…
duplicates-complete = Ricerca duplicati completata duplicates-complete = Ricerca duplicati completata

View File

@@ -37,7 +37,7 @@ value PostEditorMetadata {
language: String? -- select from supported languages language: String? -- select from supported languages
do_not_translate: Boolean -- checkbox do_not_translate: Boolean -- checkbox
slug: String -- read-only text input slug: String -- read-only text input
categories: List<String> -- chip input categories: List<String> -- autocomplete chip input
template_slug: String? -- select (shown only when templates exist) template_slug: String? -- select (shown only when templates exist)
post_links: PostLinksPanel post_links: PostLinksPanel
linked_media: List<LinkedMediaItem> linked_media: List<LinkedMediaItem>
@@ -149,6 +149,13 @@ surface PostEditorSurface {
-- it is an index read (no model inference) so it works offline and -- it is an index read (no model inference) so it works offline and
-- yields nothing when similarity is disabled or unindexed. -- 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 @guarantee TranslationFlagsBar
-- Row of flag emoji buttons inline with metadata toggle. -- Row of flag emoji buttons inline with metadata toggle.
-- One flag per language: canonical language + each translation. -- One flag per language: canonical language + each translation.