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

@@ -1647,10 +1647,17 @@ impl BdsApp {
if self.active_tab.as_deref() != Some(id.as_str()) {
self.flush_active_post_editor();
}
self.active_tab = Some(id);
self.active_tab = Some(id.clone());
}
self.enforce_panel_tab_fallback();
self.sync_embedded_previews()
let semantic_task = self
.tabs
.iter()
.find(|tab| tab.id == id && tab.tab_type == TabType::Post)
.map_or_else(Task::none, |_| {
Task::done(Message::LoadSemanticTagSuggestions(id))
});
Task::batch([self.sync_embedded_previews(), semantic_task])
}
// ── Project management ──
Message::ProjectsLoaded(projects) => {
@@ -4013,6 +4020,7 @@ impl BdsApp {
if entity == DomainEntity::Tag {
self.tags_view_state = None;
self.refresh_post_editor_tag_options();
if let Some(tab) = self
.tabs
.iter()
@@ -4026,6 +4034,9 @@ impl BdsApp {
let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned();
let Some(tab) = tab else { return };
let previous_post_state = (entity == DomainEntity::Post)
.then(|| self.post_editors.get(entity_id).cloned())
.flatten();
let dirty = match entity {
DomainEntity::Post => self
.post_editors
@@ -4064,6 +4075,11 @@ impl BdsApp {
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {}
}
self.load_editor_for_tab(&tab);
if let (Some(previous), Some(current)) =
(previous_post_state, self.post_editors.get_mut(entity_id))
{
current.restore_view_state(&previous);
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == entity_id) {
tab.title = match entity {
DomainEntity::Post => self
@@ -6018,6 +6034,29 @@ impl BdsApp {
}))
}
fn ensure_post_editor_tag(&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();
};
if bds_core::db::queries::tag::get_tag_by_project_and_name(
db.conn(),
&post.project_id,
name,
)
.is_err()
&& let Err(error) =
engine::tag::create_tag(db.conn(), data_dir, &post.project_id, name, None)
{
self.notify_operation_failed("editor.tags", error);
return Task::none();
}
self.refresh_post_editor_tag_options();
Task::none()
}
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
let state = match self.post_editors.get(post_id) {
Some(s) => s.clone(),
@@ -8245,6 +8284,45 @@ impl BdsApp {
}
}
fn project_tag_names(&self, project_id: &str) -> Vec<String> {
let Some(db) = self.db.as_ref() else {
return Vec::new();
};
let mut names = bds_core::db::queries::tag::list_tags_by_project(db.conn(), project_id)
.unwrap_or_default()
.into_iter()
.map(|tag| tag.name)
.collect::<Vec<_>>();
for tag in bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id)
.unwrap_or_default()
.into_iter()
.flat_map(|post| post.tags)
{
if !names
.iter()
.any(|existing| existing.eq_ignore_ascii_case(&tag))
{
names.push(tag);
}
}
names.sort_by_key(|name| name.to_lowercase());
names
}
fn refresh_post_editor_tag_options(&mut self) {
let Some(project_id) = self
.active_project
.as_ref()
.map(|project| project.id.clone())
else {
return;
};
let names = self.project_tag_names(&project_id);
for editor in self.post_editors.values_mut() {
editor.available_tags.clone_from(&names);
}
}
/// Load editor state when a tab is opened for an entity.
fn load_editor_for_tab(&mut self, tab: &Tab) {
let Some(ref db) = self.db else { return };
@@ -8297,18 +8375,17 @@ impl BdsApp {
let (outlinks, backlinks) = self.load_post_links(&post.id);
let linked_media =
self.load_post_media_items(&post.id, post.content.as_deref());
self.post_editors.insert(
post.id.clone(),
PostEditorState::from_post(
&post,
default_post_editor_mode(self.settings_state.as_ref()),
&self.blog_languages,
&translations,
outlinks,
backlinks,
linked_media,
),
let mut editor = PostEditorState::from_post(
&post,
default_post_editor_mode(self.settings_state.as_ref()),
&self.blog_languages,
&translations,
outlinks,
backlinks,
linked_media,
);
editor.available_tags = self.project_tag_names(&post.project_id);
self.post_editors.insert(post.id.clone(), editor);
}
Err(e) => {
self.notify_operation_failed("activity.posts", e);
@@ -9519,10 +9596,12 @@ mod tests {
use bds_core::engine::generation::GenerationReport;
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
use bds_core::engine::{
ai, blogmark, chat, media, menu, meta, post, script, template, wordpress_import,
ai, blogmark, chat, media, menu, meta, post, script, tag, template, wordpress_import,
};
use bds_core::i18n::UiLocale;
use bds_core::model::{ChatRole, DomainEvent, Project, ScriptKind, TemplateKind};
use bds_core::model::{
ChatRole, DomainEntity, DomainEvent, NotificationAction, Project, ScriptKind, TemplateKind,
};
use chrono::{Datelike, TimeZone};
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -11327,6 +11406,139 @@ mod tests {
);
}
#[test]
fn post_editor_loads_existing_project_tags_for_partial_suggestions() {
let (db, project, tmp) = setup();
tag::create_tag(db.conn(), tmp.path(), &project.id, "Photography", None).unwrap();
let tagged = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Existing",
Some("Body"),
vec!["Photo Essay".to_string()],
vec![],
None,
Some("en"),
None,
)
.unwrap();
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_tags,
vec!["Photo Essay", "Photography"]
);
assert_eq!(tagged.tags, vec!["Photo Essay"]);
}
#[test]
fn adding_new_tag_from_post_editor_creates_portable_tag_metadata() {
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.clone(), &tmp);
let _ = app.open_tab(Tab {
id: edited.id.clone(),
tab_type: TabType::Post,
title: edited.title,
is_transient: false,
is_dirty: false,
});
let _ = app.handle_post_editor_msg(PostEditorMsg::AddSuggestedTag("New Tag".into()));
assert!(
bds_core::db::queries::tag::get_tag_by_project_and_name(
app.db.as_ref().unwrap().conn(),
&project.id,
"new tag",
)
.is_ok()
);
assert!(
app.post_editors[&edited.id]
.tags
.contains(&"New Tag".to_string())
);
let portable = std::fs::read_to_string(tmp.path().join("meta/tags.json")).unwrap();
assert!(portable.contains("New Tag"));
}
#[test]
fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() {
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.clone(), &tmp);
let _ = app.open_tab(Tab {
id: edited.id.clone(),
tab_type: TabType::Post,
title: edited.title,
is_transient: false,
is_dirty: false,
});
app.post_editors
.get_mut(&edited.id)
.unwrap()
.semantic_tag_suggestions = vec!["science".to_string()];
app.handle_domain_event(DomainEvent::EntityChanged {
project_id: project.id,
entity: DomainEntity::Post,
entity_id: edited.id.clone(),
action: NotificationAction::Updated,
});
assert_eq!(
app.post_editors[&edited.id].semantic_tag_suggestions,
vec!["science"]
);
}
#[test]
fn switching_tabs_flushes_active_post_editor() {
let (db, project, tmp) = setup();

View File

@@ -7,6 +7,11 @@ impl BdsApp {
SyncEmbeddedPreview,
Analyze(String),
AnalyzeTaxonomy(String),
EnsureTag {
post_id: String,
tag: String,
},
LoadSemanticTags(String),
AddGalleryImages(String),
DetectLanguage(String),
OpenTranslate(String),
@@ -127,6 +132,9 @@ impl BdsApp {
}
PostEditorMsg::ToggleMetadata => {
state.metadata_expanded = !state.metadata_expanded;
if state.metadata_expanded {
deferred = DeferredPostAction::LoadSemanticTags(state.post_id.clone());
}
}
PostEditorMsg::ToggleExcerpt => {
state.excerpt_expanded = !state.excerpt_expanded;
@@ -139,12 +147,24 @@ impl BdsApp {
}
PostEditorMsg::TagsInputChanged(s) => {
state.tags_input = s;
if state.tags_input.trim().is_empty() {
deferred = DeferredPostAction::LoadSemanticTags(state.post_id.clone());
}
}
PostEditorMsg::TagsInputSubmit => {
let tag = state.tags_input.trim().to_string();
if !tag.is_empty() && !state.tags.contains(&tag) {
state.tags.push(tag);
if !tag.is_empty()
&& !state
.tags
.iter()
.any(|current| current.eq_ignore_ascii_case(&tag))
{
state.tags.push(tag.clone());
state.mark_dirty();
deferred = DeferredPostAction::EnsureTag {
post_id: state.post_id.clone(),
tag,
};
}
state.tags_input.clear();
}
@@ -156,10 +176,12 @@ impl BdsApp {
{
state.tags.push(tag.clone());
state.mark_dirty();
deferred = DeferredPostAction::EnsureTag {
post_id: state.post_id.clone(),
tag: tag.clone(),
};
}
state
.semantic_tag_suggestions
.retain(|candidate| !candidate.eq_ignore_ascii_case(&tag));
state.tags_input.clear();
}
PostEditorMsg::RemoveTag(tag) => {
state.tags.retain(|t| t != &tag);
@@ -286,6 +308,12 @@ impl BdsApp {
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id),
DeferredPostAction::EnsureTag { post_id, tag } => {
self.ensure_post_editor_tag(&post_id, &tag)
}
DeferredPostAction::LoadSemanticTags(post_id) => {
Task::done(Message::LoadSemanticTagSuggestions(post_id))
}
DeferredPostAction::AddGalleryImages(post_id) => self.add_gallery_images(&post_id),
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),

View File

@@ -219,6 +219,12 @@ impl BdsApp {
} => {
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
let refresh_semantic_tags = !cancelled
&& result.is_ok()
&& matches!(
operation,
"embeddings.indexing" | "menu.item.rebuildEmbeddingIndex"
);
match &result {
_ if cancelled => {}
Ok(detail) => {
@@ -265,8 +271,22 @@ impl BdsApp {
self.sync_menu_state();
}
let sidebar_task = self.refresh_counts();
let semantic_task = if refresh_semantic_tags {
self.active_tab
.as_ref()
.filter(|id| {
self.tabs
.iter()
.any(|tab| tab.id == id.as_str() && tab.tab_type == TabType::Post)
})
.map_or_else(Task::none, |id| {
Task::done(Message::LoadSemanticTagSuggestions(id.clone()))
})
} else {
Task::none()
};
self.refresh_task_snapshots();
sidebar_task
Task::batch([sidebar_task, semantic_task])
}
Message::SiteGenerationSectionDone {
group_id,

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();