Add category suggestions to the post editor.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user