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

@@ -6949,6 +6949,27 @@ impl BdsApp {
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> {
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<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> {
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();

View File

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

View File

@@ -88,6 +88,7 @@ pub struct PostEditorState {
pub tags_input: String,
pub categories_input: String,
pub available_tags: Vec<String>,
pub available_categories: Vec<String>,
pub semantic_tag_suggestions: Vec<String>,
pub ai_activity: Option<String>,
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::<Vec<_>>();
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]