Add semantic post tag suggestions
This commit is contained in:
@@ -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`.
|
- 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.
|
- 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
|
## Plan Mode
|
||||||
|
|
||||||
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
|
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -419,13 +419,24 @@ impl<'a> EmbeddingService<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn suggest_tags(&self, post_id: &str) -> EngineResult<Vec<String>> {
|
pub fn suggest_tags(&self, post_id: &str) -> EngineResult<Vec<String>> {
|
||||||
|
if !self.enabled() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
let source = qp::get_post_by_id(self.conn, post_id)?;
|
let source = qp::get_post_by_id(self.conn, post_id)?;
|
||||||
let current = source
|
let current = source
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tag| tag.to_lowercase())
|
.map(|tag| tag.to_lowercase())
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
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::<String, (String, f32)>::new();
|
let mut scores = HashMap::<String, (String, f32)>::new();
|
||||||
for neighbor in similar {
|
for neighbor in similar {
|
||||||
if let Ok(post) = qp::get_post_by_id(self.conn, &neighbor.post_id) {
|
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]
|
#[test]
|
||||||
fn duplicate_search_paginates_and_batch_dismisses_in_chunks() {
|
fn duplicate_search_paginates_and_batch_dismisses_in_chunks() {
|
||||||
let (db, data, cache, project_id, backend) = setup_service(true);
|
let (db, data, cache, project_id, backend) = setup_service(true);
|
||||||
|
|||||||
@@ -1647,10 +1647,17 @@ impl BdsApp {
|
|||||||
if self.active_tab.as_deref() != Some(id.as_str()) {
|
if self.active_tab.as_deref() != Some(id.as_str()) {
|
||||||
self.flush_active_post_editor();
|
self.flush_active_post_editor();
|
||||||
}
|
}
|
||||||
self.active_tab = Some(id);
|
self.active_tab = Some(id.clone());
|
||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
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 ──
|
// ── Project management ──
|
||||||
Message::ProjectsLoaded(projects) => {
|
Message::ProjectsLoaded(projects) => {
|
||||||
@@ -4013,6 +4020,7 @@ impl BdsApp {
|
|||||||
|
|
||||||
if entity == DomainEntity::Tag {
|
if entity == DomainEntity::Tag {
|
||||||
self.tags_view_state = None;
|
self.tags_view_state = None;
|
||||||
|
self.refresh_post_editor_tag_options();
|
||||||
if let Some(tab) = self
|
if let Some(tab) = self
|
||||||
.tabs
|
.tabs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -4026,6 +4034,9 @@ impl BdsApp {
|
|||||||
|
|
||||||
let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned();
|
let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned();
|
||||||
let Some(tab) = tab else { return };
|
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 {
|
let dirty = match entity {
|
||||||
DomainEntity::Post => self
|
DomainEntity::Post => self
|
||||||
.post_editors
|
.post_editors
|
||||||
@@ -4064,6 +4075,11 @@ impl BdsApp {
|
|||||||
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {}
|
DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {}
|
||||||
}
|
}
|
||||||
self.load_editor_for_tab(&tab);
|
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) {
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == entity_id) {
|
||||||
tab.title = match entity {
|
tab.title = match entity {
|
||||||
DomainEntity::Post => self
|
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> {
|
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
||||||
let state = match self.post_editors.get(post_id) {
|
let state = match self.post_editors.get(post_id) {
|
||||||
Some(s) => s.clone(),
|
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.
|
/// Load editor state when a tab is opened for an entity.
|
||||||
fn load_editor_for_tab(&mut self, tab: &Tab) {
|
fn load_editor_for_tab(&mut self, tab: &Tab) {
|
||||||
let Some(ref db) = self.db else { return };
|
let Some(ref db) = self.db else { return };
|
||||||
@@ -8297,18 +8375,17 @@ impl BdsApp {
|
|||||||
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
||||||
let linked_media =
|
let linked_media =
|
||||||
self.load_post_media_items(&post.id, post.content.as_deref());
|
self.load_post_media_items(&post.id, post.content.as_deref());
|
||||||
self.post_editors.insert(
|
let mut editor = PostEditorState::from_post(
|
||||||
post.id.clone(),
|
&post,
|
||||||
PostEditorState::from_post(
|
default_post_editor_mode(self.settings_state.as_ref()),
|
||||||
&post,
|
&self.blog_languages,
|
||||||
default_post_editor_mode(self.settings_state.as_ref()),
|
&translations,
|
||||||
&self.blog_languages,
|
outlinks,
|
||||||
&translations,
|
backlinks,
|
||||||
outlinks,
|
linked_media,
|
||||||
backlinks,
|
|
||||||
linked_media,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
editor.available_tags = self.project_tag_names(&post.project_id);
|
||||||
|
self.post_editors.insert(post.id.clone(), editor);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.notify_operation_failed("activity.posts", e);
|
self.notify_operation_failed("activity.posts", e);
|
||||||
@@ -9519,10 +9596,12 @@ mod tests {
|
|||||||
use bds_core::engine::generation::GenerationReport;
|
use bds_core::engine::generation::GenerationReport;
|
||||||
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
|
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
|
||||||
use bds_core::engine::{
|
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::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 chrono::{Datelike, TimeZone};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
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]
|
#[test]
|
||||||
fn switching_tabs_flushes_active_post_editor() {
|
fn switching_tabs_flushes_active_post_editor() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ impl BdsApp {
|
|||||||
SyncEmbeddedPreview,
|
SyncEmbeddedPreview,
|
||||||
Analyze(String),
|
Analyze(String),
|
||||||
AnalyzeTaxonomy(String),
|
AnalyzeTaxonomy(String),
|
||||||
|
EnsureTag {
|
||||||
|
post_id: String,
|
||||||
|
tag: String,
|
||||||
|
},
|
||||||
|
LoadSemanticTags(String),
|
||||||
AddGalleryImages(String),
|
AddGalleryImages(String),
|
||||||
DetectLanguage(String),
|
DetectLanguage(String),
|
||||||
OpenTranslate(String),
|
OpenTranslate(String),
|
||||||
@@ -127,6 +132,9 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
PostEditorMsg::ToggleMetadata => {
|
PostEditorMsg::ToggleMetadata => {
|
||||||
state.metadata_expanded = !state.metadata_expanded;
|
state.metadata_expanded = !state.metadata_expanded;
|
||||||
|
if state.metadata_expanded {
|
||||||
|
deferred = DeferredPostAction::LoadSemanticTags(state.post_id.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PostEditorMsg::ToggleExcerpt => {
|
PostEditorMsg::ToggleExcerpt => {
|
||||||
state.excerpt_expanded = !state.excerpt_expanded;
|
state.excerpt_expanded = !state.excerpt_expanded;
|
||||||
@@ -139,12 +147,24 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
PostEditorMsg::TagsInputChanged(s) => {
|
PostEditorMsg::TagsInputChanged(s) => {
|
||||||
state.tags_input = s;
|
state.tags_input = s;
|
||||||
|
if state.tags_input.trim().is_empty() {
|
||||||
|
deferred = DeferredPostAction::LoadSemanticTags(state.post_id.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PostEditorMsg::TagsInputSubmit => {
|
PostEditorMsg::TagsInputSubmit => {
|
||||||
let tag = state.tags_input.trim().to_string();
|
let tag = state.tags_input.trim().to_string();
|
||||||
if !tag.is_empty() && !state.tags.contains(&tag) {
|
if !tag.is_empty()
|
||||||
state.tags.push(tag);
|
&& !state
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|current| current.eq_ignore_ascii_case(&tag))
|
||||||
|
{
|
||||||
|
state.tags.push(tag.clone());
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
|
deferred = DeferredPostAction::EnsureTag {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
tag,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
state.tags_input.clear();
|
state.tags_input.clear();
|
||||||
}
|
}
|
||||||
@@ -156,10 +176,12 @@ impl BdsApp {
|
|||||||
{
|
{
|
||||||
state.tags.push(tag.clone());
|
state.tags.push(tag.clone());
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
|
deferred = DeferredPostAction::EnsureTag {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
tag: tag.clone(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
state
|
state.tags_input.clear();
|
||||||
.semantic_tag_suggestions
|
|
||||||
.retain(|candidate| !candidate.eq_ignore_ascii_case(&tag));
|
|
||||||
}
|
}
|
||||||
PostEditorMsg::RemoveTag(tag) => {
|
PostEditorMsg::RemoveTag(tag) => {
|
||||||
state.tags.retain(|t| t != &tag);
|
state.tags.retain(|t| t != &tag);
|
||||||
@@ -286,6 +308,12 @@ impl BdsApp {
|
|||||||
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
|
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
|
||||||
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
||||||
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_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::AddGalleryImages(post_id) => self.add_gallery_images(&post_id),
|
||||||
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
||||||
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
|
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ impl BdsApp {
|
|||||||
} => {
|
} => {
|
||||||
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
|
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 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 {
|
match &result {
|
||||||
_ if cancelled => {}
|
_ if cancelled => {}
|
||||||
Ok(detail) => {
|
Ok(detail) => {
|
||||||
@@ -265,8 +271,22 @@ impl BdsApp {
|
|||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
}
|
}
|
||||||
let sidebar_task = self.refresh_counts();
|
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();
|
self.refresh_task_snapshots();
|
||||||
sidebar_task
|
Task::batch([sidebar_task, semantic_task])
|
||||||
}
|
}
|
||||||
Message::SiteGenerationSectionDone {
|
Message::SiteGenerationSectionDone {
|
||||||
group_id,
|
group_id,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
|
|||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::{inputs, popover};
|
use crate::components::{inputs, popover};
|
||||||
use crate::i18n::t;
|
use crate::i18n::{t, tw};
|
||||||
use crate::views::status_bar;
|
use crate::views::status_bar;
|
||||||
|
|
||||||
/// Per editor_post.allium TranslationFlag value.
|
/// Per editor_post.allium TranslationFlag value.
|
||||||
@@ -76,6 +76,7 @@ pub struct PostEditorState {
|
|||||||
pub quick_actions_open: bool,
|
pub quick_actions_open: bool,
|
||||||
pub tags_input: String,
|
pub tags_input: String,
|
||||||
pub categories_input: String,
|
pub categories_input: String,
|
||||||
|
pub available_tags: Vec<String>,
|
||||||
pub semantic_tag_suggestions: Vec<String>,
|
pub semantic_tag_suggestions: Vec<String>,
|
||||||
pub active_language: String,
|
pub active_language: String,
|
||||||
pub canonical_language: String,
|
pub canonical_language: String,
|
||||||
@@ -124,6 +125,7 @@ impl Clone for PostEditorState {
|
|||||||
quick_actions_open: self.quick_actions_open,
|
quick_actions_open: self.quick_actions_open,
|
||||||
tags_input: self.tags_input.clone(),
|
tags_input: self.tags_input.clone(),
|
||||||
categories_input: self.categories_input.clone(),
|
categories_input: self.categories_input.clone(),
|
||||||
|
available_tags: self.available_tags.clone(),
|
||||||
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
|
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
|
||||||
active_language: self.active_language.clone(),
|
active_language: self.active_language.clone(),
|
||||||
canonical_language: self.canonical_language.clone(),
|
canonical_language: self.canonical_language.clone(),
|
||||||
@@ -190,6 +192,7 @@ impl PostEditorState {
|
|||||||
quick_actions_open: false,
|
quick_actions_open: false,
|
||||||
tags_input: String::new(),
|
tags_input: String::new(),
|
||||||
categories_input: String::new(),
|
categories_input: String::new(),
|
||||||
|
available_tags: Vec::new(),
|
||||||
semantic_tag_suggestions: Vec::new(),
|
semantic_tag_suggestions: Vec::new(),
|
||||||
active_language: canonical_lang.clone(),
|
active_language: canonical_lang.clone(),
|
||||||
canonical_language: canonical_lang,
|
canonical_language: canonical_lang,
|
||||||
@@ -208,6 +211,18 @@ impl PostEditorState {
|
|||||||
self.last_edit_at_ms = bds_core::util::now_unix_ms();
|
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) {
|
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
|
||||||
let new_content = {
|
let new_content = {
|
||||||
let mut buffer = self.editor_buffer.borrow_mut();
|
let mut buffer = self.editor_buffer.borrow_mut();
|
||||||
@@ -628,7 +643,12 @@ pub fn view<'a>(
|
|||||||
Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|
Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|
||||||
|tag| Message::PostEditor(PostEditorMsg::RemoveTag(tag)),
|
|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()
|
Space::new(0, 0).into()
|
||||||
} else {
|
} else {
|
||||||
let mut chips = row![
|
let mut chips = row![
|
||||||
@@ -638,11 +658,11 @@ pub fn view<'a>(
|
|||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
.align_y(iced::Alignment::Center);
|
.align_y(iced::Alignment::Center);
|
||||||
for tag in &state.semantic_tag_suggestions {
|
for tag in semantic_suggestions {
|
||||||
chips = chips.push(
|
chips = chips.push(
|
||||||
button(text(format!("+ {tag}")).size(11))
|
button(text(format!("+ {tag}")).size(11))
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
|
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
|
||||||
tag.clone(),
|
tag.to_string(),
|
||||||
)))
|
)))
|
||||||
.padding([4, 8])
|
.padding([4, 8])
|
||||||
.style(inputs::secondary_button),
|
.style(inputs::secondary_button),
|
||||||
@@ -650,6 +670,43 @@ pub fn view<'a>(
|
|||||||
}
|
}
|
||||||
chips.into()
|
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
|
// Categories chip input
|
||||||
let categories_section = chip_input_field(
|
let categories_section = chip_input_field(
|
||||||
@@ -808,6 +865,7 @@ pub fn view<'a>(
|
|||||||
meta_row2,
|
meta_row2,
|
||||||
tags_section,
|
tags_section,
|
||||||
semantic_tags,
|
semantic_tags,
|
||||||
|
matching_tags,
|
||||||
categories_section,
|
categories_section,
|
||||||
outlinks_section,
|
outlinks_section,
|
||||||
backlinks_section,
|
backlinks_section,
|
||||||
@@ -992,6 +1050,54 @@ pub fn view<'a>(
|
|||||||
.into()
|
.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.
|
/// Chip input: shows existing chips as removable buttons + a text input to add new ones.
|
||||||
fn chip_input_field<'a>(
|
fn chip_input_field<'a>(
|
||||||
label: &str,
|
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]
|
#[test]
|
||||||
fn insert_markdown_at_cursor_uses_buffer_cursor() {
|
fn insert_markdown_at_cursor_uses_buffer_cursor() {
|
||||||
let mut state = sample_state();
|
let mut state = sample_state();
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Embedding-Index neu erstellen
|
|||||||
menu-item-findDuplicates = Doppelte Beiträge finden
|
menu-item-findDuplicates = Doppelte Beiträge finden
|
||||||
common-refresh = Aktualisieren
|
common-refresh = Aktualisieren
|
||||||
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
|
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
|
||||||
|
editor-matchingTags = Passende Tags
|
||||||
|
editor-createTag = Tag erstellen: { $name }
|
||||||
duplicates-title = Doppelte Beiträge finden
|
duplicates-title = Doppelte Beiträge finden
|
||||||
duplicates-searching = Duplikate werden gesucht…
|
duplicates-searching = Duplikate werden gesucht…
|
||||||
duplicates-count = { $count } mögliche Paare
|
duplicates-count = { $count } mögliche Paare
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Rebuild Embedding Index
|
|||||||
menu-item-findDuplicates = Find Duplicate Posts
|
menu-item-findDuplicates = Find Duplicate Posts
|
||||||
common-refresh = Refresh
|
common-refresh = Refresh
|
||||||
editor-semanticTagSuggestions = Suggested from similar posts
|
editor-semanticTagSuggestions = Suggested from similar posts
|
||||||
|
editor-matchingTags = Matching tags
|
||||||
|
editor-createTag = Create tag: { $name }
|
||||||
duplicates-title = Find Duplicate Posts
|
duplicates-title = Find Duplicate Posts
|
||||||
duplicates-searching = Searching for duplicates…
|
duplicates-searching = Searching for duplicates…
|
||||||
duplicates-count = { $count } potential pairs
|
duplicates-count = { $count } potential pairs
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Reconstruir índice semántico
|
|||||||
menu-item-findDuplicates = Buscar publicaciones duplicadas
|
menu-item-findDuplicates = Buscar publicaciones duplicadas
|
||||||
common-refresh = Actualizar
|
common-refresh = Actualizar
|
||||||
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
|
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
|
||||||
|
editor-matchingTags = Etiquetas coincidentes
|
||||||
|
editor-createTag = Crear etiqueta: { $name }
|
||||||
duplicates-title = Buscar publicaciones duplicadas
|
duplicates-title = Buscar publicaciones duplicadas
|
||||||
duplicates-searching = Buscando duplicados…
|
duplicates-searching = Buscando duplicados…
|
||||||
duplicates-count = { $count } pares potenciales
|
duplicates-count = { $count } pares potenciales
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Reconstruire l’index sémantique
|
|||||||
menu-item-findDuplicates = Rechercher les articles en double
|
menu-item-findDuplicates = Rechercher les articles en double
|
||||||
common-refresh = Actualiser
|
common-refresh = Actualiser
|
||||||
editor-semanticTagSuggestions = Suggestions d’articles similaires
|
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-title = Rechercher les articles en double
|
||||||
duplicates-searching = Recherche des doublons…
|
duplicates-searching = Recherche des doublons…
|
||||||
duplicates-count = { $count } paires potentielles
|
duplicates-count = { $count } paires potentielles
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ menu-item-rebuildEmbeddingIndex = Ricostruisci indice semantico
|
|||||||
menu-item-findDuplicates = Trova post duplicati
|
menu-item-findDuplicates = Trova post duplicati
|
||||||
common-refresh = Aggiorna
|
common-refresh = Aggiorna
|
||||||
editor-semanticTagSuggestions = Suggerimenti da post simili
|
editor-semanticTagSuggestions = Suggerimenti da post simili
|
||||||
|
editor-matchingTags = Tag corrispondenti
|
||||||
|
editor-createTag = Crea tag: { $name }
|
||||||
duplicates-title = Trova post duplicati
|
duplicates-title = Trova post duplicati
|
||||||
duplicates-searching = Ricerca duplicati…
|
duplicates-searching = Ricerca duplicati…
|
||||||
duplicates-count = { $count } coppie potenziali
|
duplicates-count = { $count } coppie potenziali
|
||||||
|
|||||||
Reference in New Issue
Block a user