Add semantic post tag suggestions

This commit is contained in:
2026-07-20 22:52:03 +02:00
parent d1829f0e9b
commit b641f6492b
12 changed files with 500 additions and 27 deletions

View File

@@ -11,7 +11,7 @@ use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
use crate::app::Message;
use crate::components::{inputs, popover};
use crate::i18n::t;
use crate::i18n::{t, tw};
use crate::views::status_bar;
/// Per editor_post.allium TranslationFlag value.
@@ -76,6 +76,7 @@ pub struct PostEditorState {
pub quick_actions_open: bool,
pub tags_input: String,
pub categories_input: String,
pub available_tags: Vec<String>,
pub semantic_tag_suggestions: Vec<String>,
pub active_language: String,
pub canonical_language: String,
@@ -124,6 +125,7 @@ impl Clone for PostEditorState {
quick_actions_open: self.quick_actions_open,
tags_input: self.tags_input.clone(),
categories_input: self.categories_input.clone(),
available_tags: self.available_tags.clone(),
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
active_language: self.active_language.clone(),
canonical_language: self.canonical_language.clone(),
@@ -190,6 +192,7 @@ impl PostEditorState {
quick_actions_open: false,
tags_input: String::new(),
categories_input: String::new(),
available_tags: Vec::new(),
semantic_tag_suggestions: Vec::new(),
active_language: canonical_lang.clone(),
canonical_language: canonical_lang,
@@ -208,6 +211,18 @@ impl PostEditorState {
self.last_edit_at_ms = bds_core::util::now_unix_ms();
}
pub fn restore_view_state(&mut self, previous: &Self) {
self.metadata_expanded = previous.metadata_expanded;
self.excerpt_expanded = previous.excerpt_expanded;
self.editor_mode.clone_from(&previous.editor_mode);
self.quick_actions_open = previous.quick_actions_open;
self.tags_input.clone_from(&previous.tags_input);
self.categories_input.clone_from(&previous.categories_input);
self.semantic_tag_suggestions
.clone_from(&previous.semantic_tag_suggestions);
self.switch_language(&previous.active_language);
}
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
let new_content = {
let mut buffer = self.editor_buffer.borrow_mut();
@@ -628,7 +643,12 @@ 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() {
let semantic_suggestions = visible_semantic_tag_suggestions(
&state.semantic_tag_suggestions,
&state.tags,
&state.tags_input,
);
let semantic_tags: Element<'a, Message> = if semantic_suggestions.is_empty() {
Space::new(0, 0).into()
} else {
let mut chips = row![
@@ -638,11 +658,11 @@ pub fn view<'a>(
]
.spacing(6)
.align_y(iced::Alignment::Center);
for tag in &state.semantic_tag_suggestions {
for tag in semantic_suggestions {
chips = chips.push(
button(text(format!("+ {tag}")).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
tag.clone(),
tag.to_string(),
)))
.padding([4, 8])
.style(inputs::secondary_button),
@@ -650,6 +670,43 @@ pub fn view<'a>(
}
chips.into()
};
let matching_suggestions =
matching_tag_suggestions(&state.available_tags, &state.tags, &state.tags_input);
let query_addable =
tag_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)
{
Space::new(0, 0).into()
} else {
let mut chips = row![
text(t(locale, "editor.matchingTags"))
.size(11)
.color(inputs::LABEL_COLOR)
]
.spacing(6)
.align_y(iced::Alignment::Center);
for tag in matching_suggestions {
chips = chips.push(
button(text(tag).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
tag.to_string(),
)))
.padding([4, 8])
.style(inputs::secondary_button),
);
}
if query_addable {
let query = state.tags_input.trim().to_string();
chips = chips.push(
button(text(tw(locale, "editor.createTag", &[("name", &query)])).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(query)))
.padding([4, 8])
.style(inputs::secondary_button),
);
}
chips.wrap().into()
};
// Categories chip input
let categories_section = chip_input_field(
@@ -808,6 +865,7 @@ pub fn view<'a>(
meta_row2,
tags_section,
semantic_tags,
matching_tags,
categories_section,
outlinks_section,
backlinks_section,
@@ -992,6 +1050,54 @@ pub fn view<'a>(
.into()
}
fn matching_tag_suggestions<'a>(
available: &'a [String],
selected: &[String],
query: &str,
) -> Vec<&'a str> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Vec::new();
}
available
.iter()
.filter(|tag| {
!selected
.iter()
.any(|current| current.eq_ignore_ascii_case(tag))
})
.filter(|tag| tag.to_lowercase().contains(&query))
.take(8)
.map(String::as_str)
.collect()
}
fn tag_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))
&& !selected.iter().any(|tag| tag.eq_ignore_ascii_case(query))
}
fn visible_semantic_tag_suggestions<'a>(
semantic: &'a [String],
selected: &[String],
query: &str,
) -> Vec<&'a str> {
if !query.trim().is_empty() {
return Vec::new();
}
semantic
.iter()
.filter(|tag| {
!selected
.iter()
.any(|current| current.eq_ignore_ascii_case(tag))
})
.map(String::as_str)
.collect()
}
/// Chip input: shows existing chips as removable buttons + a text input to add new ones.
fn chip_input_field<'a>(
label: &str,
@@ -1210,6 +1316,48 @@ mod tests {
)
}
#[test]
fn partial_tag_query_matches_existing_tags_case_insensitively() {
let available = vec![
"Photography".to_string(),
"Photo Essay".to_string(),
"Rust".to_string(),
];
let selected = vec!["Photography".to_string()];
assert_eq!(
matching_tag_suggestions(&available, &selected, "PHO"),
vec!["Photo Essay"]
);
}
#[test]
fn partial_tag_query_limits_matches_and_exact_names_are_not_addable() {
let available = (0..10)
.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"));
}
#[test]
fn semantic_tag_suggestions_are_hidden_while_filtering_and_exclude_selected_tags() {
let selected = vec!["science".to_string()];
let semantic = vec![
"science".to_string(),
"space".to_string(),
"history".to_string(),
];
assert_eq!(
visible_semantic_tag_suggestions(&semantic, &selected, ""),
vec!["space", "history"]
);
assert!(visible_semantic_tag_suggestions(&semantic, &selected, "spa").is_empty());
}
#[test]
fn insert_markdown_at_cursor_uses_buffer_cursor() {
let mut state = sample_state();