Add category suggestions to the post editor.
This commit is contained in:
@@ -14,7 +14,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, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, and bounded process-local compiled Lua reuse with a fresh sandbox per invocation, 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, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, and bounded process-local compiled Lua reuse with a fresh sandbox per invocation, 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 byte-canonical bDS2 frontmatter, media sidecars, metadata JSON, and OPML menus; rebuild; bidirectional metadata diff/repair including project categories and publishing preferences; stale post-path cleanup on republish; bDS2-compatible checksums and NFD slug generation; and FTS5 search.
|
- SQLite and filesystem persistence with byte-canonical bDS2 frontmatter, media sidecars, metadata JSON, and OPML menus; rebuild; bidirectional metadata diff/repair including project categories and publishing preferences; stale post-path cleanup on republish; bDS2-compatible checksums and NFD slug generation; and FTS5 search.
|
||||||
- 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.
|
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag and category 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 Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by matching category archives and menu entries.
|
- A localized Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by matching category archives and menu entries.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -6949,6 +6949,27 @@ impl BdsApp {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_post_editor_category(&mut self, post_id: &str, name: &str) -> Task<Message> {
|
||||||
|
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let categories = engine::meta::read_categories_json(data_dir).unwrap_or_default();
|
||||||
|
if !categories
|
||||||
|
.iter()
|
||||||
|
.any(|category| category.eq_ignore_ascii_case(name))
|
||||||
|
&& let Err(error) =
|
||||||
|
engine::meta::add_category(db.conn(), data_dir, &post.project_id, name)
|
||||||
|
{
|
||||||
|
self.notify_operation_failed("editor.categories", error);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.refresh_post_editor_category_options();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
||||||
let (title, insertion_point) = match self.post_editors.get(post_id) {
|
let (title, insertion_point) = match self.post_editors.get(post_id) {
|
||||||
Some(state) => (state.title.clone(), state.insertion_point()),
|
Some(state) => (state.title.clone(), state.insertion_point()),
|
||||||
@@ -8768,6 +8789,7 @@ impl BdsApp {
|
|||||||
state.new_category_name.clear();
|
state.new_category_name.clear();
|
||||||
}
|
}
|
||||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||||
|
self.refresh_post_editor_category_options();
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||||
}
|
}
|
||||||
Err(e) => self.notify_operation_failed("common.save", e),
|
Err(e) => self.notify_operation_failed("common.save", e),
|
||||||
@@ -8853,6 +8875,7 @@ impl BdsApp {
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.settings_state = Some(self.hydrate_settings_state());
|
self.settings_state = Some(self.hydrate_settings_state());
|
||||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||||
|
self.refresh_post_editor_category_options();
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||||
}
|
}
|
||||||
Err(e) => self.notify_operation_failed("common.save", e),
|
Err(e) => self.notify_operation_failed("common.save", e),
|
||||||
@@ -8894,6 +8917,7 @@ impl BdsApp {
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.settings_state = Some(self.hydrate_settings_state());
|
self.settings_state = Some(self.hydrate_settings_state());
|
||||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||||
|
self.refresh_post_editor_category_options();
|
||||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||||
}
|
}
|
||||||
Err(e) => self.notify_operation_failed("common.save", e),
|
Err(e) => self.notify_operation_failed("common.save", e),
|
||||||
@@ -9566,6 +9590,20 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn project_category_names(&self) -> Vec<String> {
|
||||||
|
self.data_dir
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|path| engine::meta::read_categories_json(path).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_post_editor_category_options(&mut self) {
|
||||||
|
let names = self.project_category_names();
|
||||||
|
for editor in self.post_editors.values_mut() {
|
||||||
|
editor.available_categories.clone_from(&names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn post_translations_for_editor(&self, post: &Post) -> Vec<PostTranslation> {
|
fn post_translations_for_editor(&self, post: &Post) -> Vec<PostTranslation> {
|
||||||
let Some(db) = &self.db else {
|
let Some(db) = &self.db else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -9635,6 +9673,7 @@ impl BdsApp {
|
|||||||
linked_media,
|
linked_media,
|
||||||
);
|
);
|
||||||
editor.available_tags = self.project_tag_names(&post.project_id);
|
editor.available_tags = self.project_tag_names(&post.project_id);
|
||||||
|
editor.available_categories = self.project_category_names();
|
||||||
self.post_editors.insert(post.id.clone(), editor);
|
self.post_editors.insert(post.id.clone(), editor);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -13744,6 +13783,64 @@ mod tests {
|
|||||||
assert!(portable.contains("New Tag"));
|
assert!(portable.contains("New Tag"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_editor_suggests_and_persists_project_categories() {
|
||||||
|
let (db, project, tmp) = setup();
|
||||||
|
let edited = post::create_post(
|
||||||
|
db.conn(),
|
||||||
|
tmp.path(),
|
||||||
|
&project.id,
|
||||||
|
"Edited",
|
||||||
|
Some("Body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some("en"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut app = make_app(db, project, &tmp);
|
||||||
|
let _ = app.open_tab(Tab {
|
||||||
|
id: edited.id.clone(),
|
||||||
|
tab_type: TabType::Post,
|
||||||
|
title: edited.title,
|
||||||
|
is_transient: false,
|
||||||
|
is_dirty: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.post_editors[&edited.id].available_categories,
|
||||||
|
vec!["article", "aside", "page", "picture"]
|
||||||
|
);
|
||||||
|
|
||||||
|
app.settings_state = Some(app.hydrate_settings_state());
|
||||||
|
let _ = app.handle_settings_msg(SettingsMsg::AddCategoryNameChanged("guides".into()));
|
||||||
|
let _ = app.handle_settings_msg(SettingsMsg::AddCategory);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.post_editors[&edited.id]
|
||||||
|
.available_categories
|
||||||
|
.contains(&"guides".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = app.handle_post_editor_msg(PostEditorMsg::AddSuggestedCategory("notes".into()));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.post_editors[&edited.id]
|
||||||
|
.categories
|
||||||
|
.contains(&"notes".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
meta::read_categories_json(tmp.path()).unwrap(),
|
||||||
|
vec!["article", "aside", "guides", "notes", "page", "picture"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.post_editors[&edited.id]
|
||||||
|
.available_categories
|
||||||
|
.contains(&"notes".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() {
|
fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ impl BdsApp {
|
|||||||
post_id: String,
|
post_id: String,
|
||||||
tag: String,
|
tag: String,
|
||||||
},
|
},
|
||||||
|
EnsureCategory {
|
||||||
|
post_id: String,
|
||||||
|
category: String,
|
||||||
|
},
|
||||||
LoadSemanticTags(String),
|
LoadSemanticTags(String),
|
||||||
AddGalleryImages(String),
|
AddGalleryImages(String),
|
||||||
DetectLanguage(String),
|
DetectLanguage(String),
|
||||||
@@ -194,9 +198,33 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
PostEditorMsg::CategoriesInputSubmit => {
|
PostEditorMsg::CategoriesInputSubmit => {
|
||||||
let cat = state.categories_input.trim().to_string();
|
let cat = state.categories_input.trim().to_string();
|
||||||
if !cat.is_empty() && !state.categories.contains(&cat) {
|
if !cat.is_empty()
|
||||||
state.categories.push(cat);
|
&& !state
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.any(|current| current.eq_ignore_ascii_case(&cat))
|
||||||
|
{
|
||||||
|
state.categories.push(cat.clone());
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
|
deferred = DeferredPostAction::EnsureCategory {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
category: cat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
state.categories_input.clear();
|
||||||
|
}
|
||||||
|
PostEditorMsg::AddSuggestedCategory(category) => {
|
||||||
|
if !state
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.any(|current| current.eq_ignore_ascii_case(&category))
|
||||||
|
{
|
||||||
|
state.categories.push(category.clone());
|
||||||
|
state.mark_dirty();
|
||||||
|
deferred = DeferredPostAction::EnsureCategory {
|
||||||
|
post_id: state.post_id.clone(),
|
||||||
|
category,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
state.categories_input.clear();
|
state.categories_input.clear();
|
||||||
}
|
}
|
||||||
@@ -321,6 +349,9 @@ impl BdsApp {
|
|||||||
DeferredPostAction::EnsureTag { post_id, tag } => {
|
DeferredPostAction::EnsureTag { post_id, tag } => {
|
||||||
self.ensure_post_editor_tag(&post_id, &tag)
|
self.ensure_post_editor_tag(&post_id, &tag)
|
||||||
}
|
}
|
||||||
|
DeferredPostAction::EnsureCategory { post_id, category } => {
|
||||||
|
self.ensure_post_editor_category(&post_id, &category)
|
||||||
|
}
|
||||||
DeferredPostAction::LoadSemanticTags(post_id) => {
|
DeferredPostAction::LoadSemanticTags(post_id) => {
|
||||||
Task::done(Message::LoadSemanticTagSuggestions(post_id))
|
Task::done(Message::LoadSemanticTagSuggestions(post_id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ pub struct PostEditorState {
|
|||||||
pub tags_input: String,
|
pub tags_input: String,
|
||||||
pub categories_input: String,
|
pub categories_input: String,
|
||||||
pub available_tags: Vec<String>,
|
pub available_tags: Vec<String>,
|
||||||
|
pub available_categories: Vec<String>,
|
||||||
pub semantic_tag_suggestions: Vec<String>,
|
pub semantic_tag_suggestions: Vec<String>,
|
||||||
pub ai_activity: Option<String>,
|
pub ai_activity: Option<String>,
|
||||||
pub active_language: String,
|
pub active_language: String,
|
||||||
@@ -139,6 +140,7 @@ impl Clone for PostEditorState {
|
|||||||
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(),
|
available_tags: self.available_tags.clone(),
|
||||||
|
available_categories: self.available_categories.clone(),
|
||||||
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
|
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
|
||||||
ai_activity: self.ai_activity.clone(),
|
ai_activity: self.ai_activity.clone(),
|
||||||
active_language: self.active_language.clone(),
|
active_language: self.active_language.clone(),
|
||||||
@@ -208,6 +210,7 @@ impl PostEditorState {
|
|||||||
tags_input: String::new(),
|
tags_input: String::new(),
|
||||||
categories_input: String::new(),
|
categories_input: String::new(),
|
||||||
available_tags: Vec::new(),
|
available_tags: Vec::new(),
|
||||||
|
available_categories: Vec::new(),
|
||||||
semantic_tag_suggestions: Vec::new(),
|
semantic_tag_suggestions: Vec::new(),
|
||||||
ai_activity: None,
|
ai_activity: None,
|
||||||
active_language: canonical_lang.clone(),
|
active_language: canonical_lang.clone(),
|
||||||
@@ -445,6 +448,7 @@ pub enum PostEditorMsg {
|
|||||||
RemoveTag(String),
|
RemoveTag(String),
|
||||||
CategoriesInputChanged(String),
|
CategoriesInputChanged(String),
|
||||||
CategoriesInputSubmit,
|
CategoriesInputSubmit,
|
||||||
|
AddSuggestedCategory(String),
|
||||||
RemoveCategory(String),
|
RemoveCategory(String),
|
||||||
Save,
|
Save,
|
||||||
Publish,
|
Publish,
|
||||||
@@ -770,9 +774,9 @@ pub fn view<'a>(
|
|||||||
chips.into()
|
chips.into()
|
||||||
};
|
};
|
||||||
let matching_suggestions =
|
let matching_suggestions =
|
||||||
matching_tag_suggestions(&state.available_tags, &state.tags, &state.tags_input);
|
matching_taxonomy_suggestions(&state.available_tags, &state.tags, &state.tags_input);
|
||||||
let query_addable =
|
let query_addable =
|
||||||
tag_query_addable(&state.available_tags, &state.tags, &state.tags_input);
|
taxonomy_query_addable(&state.available_tags, &state.tags, &state.tags_input);
|
||||||
let matching_tags: Element<'a, Message> = if state.tags_input.trim().is_empty()
|
let matching_tags: Element<'a, Message> = if state.tags_input.trim().is_empty()
|
||||||
|| (matching_suggestions.is_empty() && !query_addable)
|
|| (matching_suggestions.is_empty() && !query_addable)
|
||||||
{
|
{
|
||||||
@@ -819,6 +823,53 @@ pub fn view<'a>(
|
|||||||
Message::PostEditor(PostEditorMsg::CategoriesInputSubmit),
|
Message::PostEditor(PostEditorMsg::CategoriesInputSubmit),
|
||||||
|cat| Message::PostEditor(PostEditorMsg::RemoveCategory(cat)),
|
|cat| Message::PostEditor(PostEditorMsg::RemoveCategory(cat)),
|
||||||
);
|
);
|
||||||
|
let matching_categories = matching_taxonomy_suggestions(
|
||||||
|
&state.available_categories,
|
||||||
|
&state.categories,
|
||||||
|
&state.categories_input,
|
||||||
|
);
|
||||||
|
let category_query_addable = taxonomy_query_addable(
|
||||||
|
&state.available_categories,
|
||||||
|
&state.categories,
|
||||||
|
&state.categories_input,
|
||||||
|
);
|
||||||
|
let category_suggestions: Element<'a, Message> = if state.categories_input.trim().is_empty()
|
||||||
|
|| (matching_categories.is_empty() && !category_query_addable)
|
||||||
|
{
|
||||||
|
Space::new().into()
|
||||||
|
} else {
|
||||||
|
let mut chips = row![
|
||||||
|
text(t(locale, "editor.matchingCategories"))
|
||||||
|
.size(11)
|
||||||
|
.color(inputs::LABEL_COLOR)
|
||||||
|
]
|
||||||
|
.spacing(6)
|
||||||
|
.align_y(iced::Alignment::Center);
|
||||||
|
for category in matching_categories {
|
||||||
|
chips = chips.push(
|
||||||
|
keyboard::button(text(category).size(11))
|
||||||
|
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedCategory(
|
||||||
|
category.to_string(),
|
||||||
|
)))
|
||||||
|
.padding([4, 8])
|
||||||
|
.style(inputs::secondary_button),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if category_query_addable {
|
||||||
|
let query = state.categories_input.trim().to_string();
|
||||||
|
chips = chips.push(
|
||||||
|
keyboard::button(
|
||||||
|
text(tw(locale, "editor.createCategory", &[("name", &query)])).size(11),
|
||||||
|
)
|
||||||
|
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedCategory(
|
||||||
|
query,
|
||||||
|
)))
|
||||||
|
.padding([4, 8])
|
||||||
|
.style(inputs::secondary_button),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
chips.wrap().into()
|
||||||
|
};
|
||||||
|
|
||||||
// Post links sections
|
// Post links sections
|
||||||
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
|
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
|
||||||
@@ -968,6 +1019,7 @@ pub fn view<'a>(
|
|||||||
semantic_tags,
|
semantic_tags,
|
||||||
matching_tags,
|
matching_tags,
|
||||||
categories_section,
|
categories_section,
|
||||||
|
category_suggestions,
|
||||||
outlinks_section,
|
outlinks_section,
|
||||||
backlinks_section,
|
backlinks_section,
|
||||||
linked_media_section,
|
linked_media_section,
|
||||||
@@ -1152,7 +1204,7 @@ pub fn view<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matching_tag_suggestions<'a>(
|
fn matching_taxonomy_suggestions<'a>(
|
||||||
available: &'a [String],
|
available: &'a [String],
|
||||||
selected: &[String],
|
selected: &[String],
|
||||||
query: &str,
|
query: &str,
|
||||||
@@ -1174,7 +1226,7 @@ fn matching_tag_suggestions<'a>(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tag_query_addable(available: &[String], selected: &[String], query: &str) -> bool {
|
fn taxonomy_query_addable(available: &[String], selected: &[String], query: &str) -> bool {
|
||||||
let query = query.trim();
|
let query = query.trim();
|
||||||
!query.is_empty()
|
!query.is_empty()
|
||||||
&& !available.iter().any(|tag| tag.eq_ignore_ascii_case(query))
|
&& !available.iter().any(|tag| tag.eq_ignore_ascii_case(query))
|
||||||
@@ -1437,7 +1489,7 @@ mod tests {
|
|||||||
let selected = vec!["Photography".to_string()];
|
let selected = vec!["Photography".to_string()];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
matching_tag_suggestions(&available, &selected, "PHO"),
|
matching_taxonomy_suggestions(&available, &selected, "PHO"),
|
||||||
vec!["Photo Essay"]
|
vec!["Photo Essay"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1448,9 +1500,27 @@ mod tests {
|
|||||||
.map(|index| format!("tag-{index}"))
|
.map(|index| format!("tag-{index}"))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
assert_eq!(matching_tag_suggestions(&available, &[], "tag").len(), 8);
|
assert_eq!(
|
||||||
assert!(!tag_query_addable(&available, &[], " TAG-3 "));
|
matching_taxonomy_suggestions(&available, &[], "tag").len(),
|
||||||
assert!(tag_query_addable(&available, &[], "new tag"));
|
8
|
||||||
|
);
|
||||||
|
assert!(!taxonomy_query_addable(&available, &[], " TAG-3 "));
|
||||||
|
assert!(taxonomy_query_addable(&available, &[], "new tag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_category_query_matches_existing_categories_case_insensitively() {
|
||||||
|
let available = vec![
|
||||||
|
"Article".to_string(),
|
||||||
|
"Photo Essay".to_string(),
|
||||||
|
"Photography".to_string(),
|
||||||
|
];
|
||||||
|
let selected = vec!["Photography".to_string()];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
matching_taxonomy_suggestions(&available, &selected, "PHO"),
|
||||||
|
vec!["Photo Essay"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ common-refresh = Aktualisieren
|
|||||||
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
|
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
|
||||||
editor-matchingTags = Passende Tags
|
editor-matchingTags = Passende Tags
|
||||||
editor-createTag = Tag erstellen: { $name }
|
editor-createTag = Tag erstellen: { $name }
|
||||||
|
editor-matchingCategories = Passende Kategorien
|
||||||
|
editor-createCategory = Kategorie 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-complete = Duplikatsuche abgeschlossen
|
duplicates-complete = Duplikatsuche abgeschlossen
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ common-refresh = Refresh
|
|||||||
editor-semanticTagSuggestions = Suggested from similar posts
|
editor-semanticTagSuggestions = Suggested from similar posts
|
||||||
editor-matchingTags = Matching tags
|
editor-matchingTags = Matching tags
|
||||||
editor-createTag = Create tag: { $name }
|
editor-createTag = Create tag: { $name }
|
||||||
|
editor-matchingCategories = Matching categories
|
||||||
|
editor-createCategory = Create category: { $name }
|
||||||
duplicates-title = Find Duplicate Posts
|
duplicates-title = Find Duplicate Posts
|
||||||
duplicates-searching = Searching for duplicates…
|
duplicates-searching = Searching for duplicates…
|
||||||
duplicates-complete = Duplicate search complete
|
duplicates-complete = Duplicate search complete
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ common-refresh = Actualizar
|
|||||||
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
|
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
|
||||||
editor-matchingTags = Etiquetas coincidentes
|
editor-matchingTags = Etiquetas coincidentes
|
||||||
editor-createTag = Crear etiqueta: { $name }
|
editor-createTag = Crear etiqueta: { $name }
|
||||||
|
editor-matchingCategories = Categorías coincidentes
|
||||||
|
editor-createCategory = Crear categoría: { $name }
|
||||||
duplicates-title = Buscar publicaciones duplicadas
|
duplicates-title = Buscar publicaciones duplicadas
|
||||||
duplicates-searching = Buscando duplicados…
|
duplicates-searching = Buscando duplicados…
|
||||||
duplicates-complete = Búsqueda de duplicados completada
|
duplicates-complete = Búsqueda de duplicados completada
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ common-refresh = Actualiser
|
|||||||
editor-semanticTagSuggestions = Suggestions d’articles similaires
|
editor-semanticTagSuggestions = Suggestions d’articles similaires
|
||||||
editor-matchingTags = Étiquettes correspondantes
|
editor-matchingTags = Étiquettes correspondantes
|
||||||
editor-createTag = Créer l’étiquette : { $name }
|
editor-createTag = Créer l’étiquette : { $name }
|
||||||
|
editor-matchingCategories = Catégories correspondantes
|
||||||
|
editor-createCategory = Créer la catégorie : { $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-complete = Recherche des doublons terminée
|
duplicates-complete = Recherche des doublons terminée
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ common-refresh = Aggiorna
|
|||||||
editor-semanticTagSuggestions = Suggerimenti da post simili
|
editor-semanticTagSuggestions = Suggerimenti da post simili
|
||||||
editor-matchingTags = Tag corrispondenti
|
editor-matchingTags = Tag corrispondenti
|
||||||
editor-createTag = Crea tag: { $name }
|
editor-createTag = Crea tag: { $name }
|
||||||
|
editor-matchingCategories = Categorie corrispondenti
|
||||||
|
editor-createCategory = Crea categoria: { $name }
|
||||||
duplicates-title = Trova post duplicati
|
duplicates-title = Trova post duplicati
|
||||||
duplicates-searching = Ricerca duplicati…
|
duplicates-searching = Ricerca duplicati…
|
||||||
duplicates-complete = Ricerca duplicati completata
|
duplicates-complete = Ricerca duplicati completata
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ value PostEditorMetadata {
|
|||||||
language: String? -- select from supported languages
|
language: String? -- select from supported languages
|
||||||
do_not_translate: Boolean -- checkbox
|
do_not_translate: Boolean -- checkbox
|
||||||
slug: String -- read-only text input
|
slug: String -- read-only text input
|
||||||
categories: List<String> -- chip input
|
categories: List<String> -- autocomplete chip input
|
||||||
template_slug: String? -- select (shown only when templates exist)
|
template_slug: String? -- select (shown only when templates exist)
|
||||||
post_links: PostLinksPanel
|
post_links: PostLinksPanel
|
||||||
linked_media: List<LinkedMediaItem>
|
linked_media: List<LinkedMediaItem>
|
||||||
@@ -149,6 +149,13 @@ surface PostEditorSurface {
|
|||||||
-- it is an index read (no model inference) so it works offline and
|
-- it is an index read (no model inference) so it works offline and
|
||||||
-- yields nothing when similarity is disabled or unindexed.
|
-- yields nothing when similarity is disabled or unindexed.
|
||||||
|
|
||||||
|
@guarantee CategoryAutocomplete
|
||||||
|
-- Category input with autocomplete.
|
||||||
|
-- While typing: substring match on existing category names (case-insensitive),
|
||||||
|
-- top 8 shown, plus a "create category" row when the query is new.
|
||||||
|
-- Selecting or creating a category adds it to the post and makes newly
|
||||||
|
-- created categories available in project metadata for future suggestions.
|
||||||
|
|
||||||
@guarantee TranslationFlagsBar
|
@guarantee TranslationFlagsBar
|
||||||
-- Row of flag emoji buttons inline with metadata toggle.
|
-- Row of flag emoji buttons inline with metadata toggle.
|
||||||
-- One flag per language: canonical language + each translation.
|
-- One flag per language: canonical language + each translation.
|
||||||
|
|||||||
Reference in New Issue
Block a user