From b641f6492b4dfd63331fed11de4f196e9521ec97 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 20 Jul 2026 22:52:03 +0200 Subject: [PATCH] Add semantic post tag suggestions --- AGENTS.md | 9 + README.md | 2 +- crates/bds-core/src/engine/embedding.rs | 48 ++++- crates/bds-ui/src/app.rs | 242 +++++++++++++++++++++-- crates/bds-ui/src/app/editor_handlers.rs | 38 +++- crates/bds-ui/src/app/engine_handlers.rs | 22 ++- crates/bds-ui/src/views/post_editor.rs | 156 ++++++++++++++- locales/ui/de.ftl | 2 + locales/ui/en.ftl | 2 + locales/ui/es.ftl | 2 + locales/ui/fr.ftl | 2 + locales/ui/it.ftl | 2 + 12 files changed, 500 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 305f6ad..e643c07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,15 @@ Invariants and behaviours in the allium spec should be covered by unit tests of - Before implementing or changing UI, read and follow `docs/UI_STYLE_GUIDE.md`. - Reuse the shared styling primitives described there so new sidebars and editor areas remain consistent with the post editor. +## Computer Use for the macOS app + +- Build the real app bundle with `cargo bundle-macos`. +- Use Computer Use against the exact app path `target/release/Blogging Desktop Server.app` (resolved to its absolute path), then raise that window before interacting with it. +- Do not use `cargo run` for Computer Use: it starts RuDS as a child of the command runner and Computer Use cannot attach to it as a macOS app. +- Do not run `open` on `target/debug/bds-ui`; macOS hands the executable to Ghostty instead of launching RuDS as an app. +- Do not target only the `de.rfc1437.ruds` bundle identifier because both the installed app and the repository build may share it; always use the exact repository bundle path. +- Do not improvise with AppleScript, Dock automation, or raw screen-capture workarounds. Use the Computer Use skill and its `node_repl` runtime. + ## Plan Mode - Make the plan extremely concise. Sacrifice grammar for the sake of concision. diff --git a/README.md b/README.md index 5594e2f..1a83756 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,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. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, 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 frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. -- Optional on-device multilingual semantic search and tag suggestions backed by a persistent USearch index, with duplicate-post review and dismissal 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 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. - 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. - Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. diff --git a/crates/bds-core/src/engine/embedding.rs b/crates/bds-core/src/engine/embedding.rs index 48e0976..2a29912 100644 --- a/crates/bds-core/src/engine/embedding.rs +++ b/crates/bds-core/src/engine/embedding.rs @@ -419,13 +419,24 @@ impl<'a> EmbeddingService<'a> { } pub fn suggest_tags(&self, post_id: &str) -> EngineResult> { + if !self.enabled() { + return Ok(Vec::new()); + } let source = qp::get_post_by_id(self.conn, post_id)?; let current = source .tags .iter() .map(|tag| tag.to_lowercase()) .collect::>(); - let similar = self.find_similar(post_id, 10)?; + let Some(key) = qe::get_key_for_post(self.conn, &source.project_id, post_id)? else { + return Ok(Vec::new()); + }; + let similar = self.search_index( + &source.project_id, + &decode_vector(&key.vector)?, + 10, + Some(key.label as u64), + )?; let mut scores = HashMap::::new(); for neighbor in similar { if let Ok(post) = qp::get_post_by_id(self.conn, &neighbor.post_id) { @@ -1280,6 +1291,41 @@ mod tests { ); } + #[test] + fn tag_suggestions_read_the_existing_index_without_running_inference() { + let (db, data, cache, project_id, backend) = setup_service(true); + insert_post( + &db, + &project_id, + "source", + "Space", + "rocket launch", + &["space"], + ); + insert_post( + &db, + &project_id, + "neighbor", + "Science", + "rocket mission", + &["science"], + ); + let service = EmbeddingService::with_backend( + db.conn(), + data.path(), + cache.path().into(), + backend.clone(), + ); + service.index_unindexed(&project_id).unwrap(); + let inference_count = backend.embedded(); + let mut changed = qp::get_post_by_id(db.conn(), "source").unwrap(); + changed.content = Some("content changed after indexing".into()); + qp::update_post(db.conn(), &changed).unwrap(); + + assert_eq!(service.suggest_tags("source").unwrap(), vec!["science"]); + assert_eq!(backend.embedded(), inference_count); + } + #[test] fn duplicate_search_paginates_and_batch_dismisses_in_chunks() { let (db, data, cache, project_id, backend) = setup_service(true); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 9d6bda3..69140c5 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -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 { + 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 { 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 { + 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::>(); + 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(); diff --git a/crates/bds-ui/src/app/editor_handlers.rs b/crates/bds-ui/src/app/editor_handlers.rs index 044d4cc..692921c 100644 --- a/crates/bds-ui/src/app/editor_handlers.rs +++ b/crates/bds-ui/src/app/editor_handlers.rs @@ -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), diff --git a/crates/bds-ui/src/app/engine_handlers.rs b/crates/bds-ui/src/app/engine_handlers.rs index 935238f..6ca47a3 100644 --- a/crates/bds-ui/src/app/engine_handlers.rs +++ b/crates/bds-ui/src/app/engine_handlers.rs @@ -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, diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 8651d9d..c7a90e6 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -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, pub semantic_tag_suggestions: Vec, 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::>(); + + 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(); diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index b0f0e3b..2b4293e 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Embedding-Index neu erstellen menu-item-findDuplicates = Doppelte Beiträge finden common-refresh = Aktualisieren editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen +editor-matchingTags = Passende Tags +editor-createTag = Tag erstellen: { $name } duplicates-title = Doppelte Beiträge finden duplicates-searching = Duplikate werden gesucht… duplicates-count = { $count } mögliche Paare diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 0d73e3b..109601f 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Rebuild Embedding Index menu-item-findDuplicates = Find Duplicate Posts common-refresh = Refresh editor-semanticTagSuggestions = Suggested from similar posts +editor-matchingTags = Matching tags +editor-createTag = Create tag: { $name } duplicates-title = Find Duplicate Posts duplicates-searching = Searching for duplicates… duplicates-count = { $count } potential pairs diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index fc03fa9..c1c37e2 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Reconstruir índice semántico menu-item-findDuplicates = Buscar publicaciones duplicadas common-refresh = Actualizar editor-semanticTagSuggestions = Sugerencias de publicaciones similares +editor-matchingTags = Etiquetas coincidentes +editor-createTag = Crear etiqueta: { $name } duplicates-title = Buscar publicaciones duplicadas duplicates-searching = Buscando duplicados… duplicates-count = { $count } pares potenciales diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index c39aa6f..d38b19c 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Reconstruire l’index sémantique menu-item-findDuplicates = Rechercher les articles en double common-refresh = Actualiser editor-semanticTagSuggestions = Suggestions d’articles similaires +editor-matchingTags = Étiquettes correspondantes +editor-createTag = Créer l’étiquette : { $name } duplicates-title = Rechercher les articles en double duplicates-searching = Recherche des doublons… duplicates-count = { $count } paires potentielles diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 5ab1d4a..f7eca45 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Ricostruisci indice semantico menu-item-findDuplicates = Trova post duplicati common-refresh = Aggiorna editor-semanticTagSuggestions = Suggerimenti da post simili +editor-matchingTags = Tag corrispondenti +editor-createTag = Crea tag: { $name } duplicates-title = Trova post duplicati duplicates-searching = Ricerca duplicati… duplicates-count = { $count } coppie potenziali