Implement the OPML menu editor

This commit is contained in:
2026-07-19 19:37:00 +02:00
parent 7d20139531
commit 92a942d52f
18 changed files with 1955 additions and 43 deletions

View File

@@ -14,6 +14,7 @@ The project is under active development. Core blogging workflows are broadly ava
- 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.
- Read-only in-app browsers for the bundled global `DOCUMENTATION.md` and the generated Lua API reference, public types, and runnable examples, 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.
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.

View File

@@ -88,7 +88,7 @@ Available:
- User-authored Lua macro invocation during rendering.
- Localhost-only Axum preview server, draft routes, embedded Wry preview, and external-browser preview.
- Complete site generation with pages, archives, feeds, sitemap, static assets, changed-file tracking, parallel page rendering, and Pagefind output; full generation and validation apply run as grouped section tasks followed by search indexing.
- OPML/menu document loading and normalized Home-first menu output.
- Canonical bDS2-compatible OPML/menu document loading, recursive Home-first normalization, and renderer consumption of the saved tree.
Open:

View File

@@ -60,18 +60,15 @@ Done:
- Safe native GFM rendering for headings, links, code blocks, lists, and tables, including in-document anchors and confirmed external links without HTML, CSS, or JavaScript execution.
- Project switches preserve the singleton global documentation tabs and their loaded content.
### Menu editor and deep links — Partly done
### Menu editor and deep links — Done
Done:
- Menu file parsing/rendering and Home-first normalization.
- Localized OPML tree editor with page/submenu/category drafts, metadata-backed new categories, sibling moves, indent/unindent, protected Home, validated drag/drop, delayed submenu expansion, save/reload, and accessible equivalent controls.
- Canonical bDS2 OPML attributes and recursive normalization feed the same renderer used by preview and generation.
- macOS URL plumbing and the sole bDS2-compatible Blogmark action at `ruds://new-post`; RuDS neither registers nor accepts bDS2's `bds2://` scheme.
Open:
- OPML/menu editor UI.
- Replace the Menu Editor placeholder.
### CLI, domain events, and MCP — Done
Done:

View File

@@ -31,7 +31,7 @@ impl MenuItemKind {
Self::Home => "home",
Self::Page => "page",
Self::Submenu => "submenu",
Self::CategoryArchive => "category_archive",
Self::CategoryArchive => "category-archive",
}
}
@@ -39,7 +39,7 @@ impl MenuItemKind {
match s {
"home" => Self::Home,
"submenu" => Self::Submenu,
"category_archive" => Self::CategoryArchive,
"category-archive" | "category_archive" => Self::CategoryArchive,
_ => Self::Page,
}
}
@@ -83,11 +83,7 @@ pub fn default_menu_opml() -> String {
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
let without_home: Vec<MenuItem> = items
.iter()
.filter(|i| i.kind != MenuItemKind::Home)
.cloned()
.collect();
let without_home: Vec<_> = items.iter().filter_map(normalize_non_home).collect();
let mut result = vec![MenuItem {
kind: MenuItemKind::Home,
label: "Home".to_string(),
@@ -98,29 +94,70 @@ fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
result
}
fn normalize_non_home(item: &MenuItem) -> Option<MenuItem> {
if item.kind == MenuItemKind::Home {
return None;
}
Some(MenuItem {
kind: item.kind.clone(),
label: item.label.clone(),
slug: match item.kind {
MenuItemKind::Page | MenuItemKind::CategoryArchive => item.slug.clone(),
MenuItemKind::Home | MenuItemKind::Submenu => None,
},
children: if item.kind == MenuItemKind::Submenu {
item.children
.iter()
.filter_map(normalize_non_home)
.collect()
} else {
Vec::new()
},
})
}
/// Parse OPML 2.0 XML into menu items.
fn parse_opml(content: &str) -> EngineResult<Vec<MenuItem>> {
let mut reader = Reader::from_str(content);
reader.config_mut().trim_text(true);
let mut items = Vec::new();
let mut parents = Vec::new();
let mut in_body = false;
let mut outlines: Vec<Option<MenuItem>> = Vec::new();
let mut elements: Vec<Vec<u8>> = Vec::new();
loop {
match reader.read_event() {
Ok(Event::Start(event)) if event.name().as_ref() == b"body" => in_body = true,
Ok(Event::End(event)) if event.name().as_ref() == b"body" => in_body = false,
Ok(Event::Start(event)) if in_body && event.name().as_ref() == b"outline" => {
parents.push(parse_outline(&event)?);
Ok(Event::Start(event)) => {
let name = event.name().as_ref().to_vec();
if name == b"outline" {
let collect = collect_outline(&elements, &outlines);
outlines.push(collect.then(|| parse_outline(&event)).transpose()?);
}
elements.push(name);
}
Ok(Event::Empty(event)) if in_body && event.name().as_ref() == b"outline" => {
attach_outline(parse_outline(&event)?, &mut parents, &mut items);
Ok(Event::Empty(event)) if event.name().as_ref() == b"outline" => {
if collect_outline(&elements, &outlines) {
attach_outline(parse_outline(&event)?, &mut outlines, &mut items);
}
}
Ok(Event::End(event)) if event.name().as_ref() == b"outline" => {
let item = parents
Ok(Event::End(event)) => {
let name = event.name().as_ref().to_vec();
let open = elements
.pop()
.ok_or_else(|| EngineError::Parse("unexpected </outline>".to_string()))?;
attach_outline(item, &mut parents, &mut items);
.ok_or_else(|| EngineError::Parse("unexpected closing element".to_string()))?;
if open != name {
return Err(EngineError::Parse("mismatched closing element".to_string()));
}
if name == b"outline" {
let item = outlines
.pop()
.ok_or_else(|| EngineError::Parse("unexpected </outline>".to_string()))?;
if let Some(mut item) = item {
if item.kind != MenuItemKind::Submenu {
item.children.clear();
}
attach_outline(item, &mut outlines, &mut items);
}
}
}
Ok(Event::Eof) => break,
Ok(_) => {}
@@ -128,16 +165,26 @@ fn parse_opml(content: &str) -> EngineResult<Vec<MenuItem>> {
}
}
if !parents.is_empty() {
if !outlines.is_empty() || !elements.is_empty() {
return Err(EngineError::Parse("unclosed <outline>".to_string()));
}
Ok(normalize_menu(&items))
}
fn collect_outline(elements: &[Vec<u8>], outlines: &[Option<MenuItem>]) -> bool {
elements == [b"opml".as_slice(), b"body".as_slice()]
|| (elements.last().is_some_and(|name| name == b"outline")
&& outlines.last().is_some_and(Option::is_some))
}
fn parse_outline(event: &BytesStart<'_>) -> EngineResult<MenuItem> {
let mut label = "Untitled".to_string();
let mut kind = MenuItemKind::Page;
let mut slug = None;
let mut label = String::new();
let mut kind_value = None;
let mut legacy_kind = None;
let mut page_slug = None;
let mut category_name = None;
let mut legacy_slug = None;
let mut html_url = None;
for attribute in event.attributes() {
let attribute = attribute.map_err(|error| EngineError::Parse(error.to_string()))?;
@@ -147,12 +194,28 @@ fn parse_outline(event: &BytesStart<'_>) -> EngineResult<MenuItem> {
.into_owned();
match attribute.key.as_ref() {
b"text" => label = value,
b"type" => kind = MenuItemKind::from_str(&value),
b"htmlUrl" => slug = Some(value),
b"type" => kind_value = Some(value),
b"kind" => legacy_kind = Some(value),
b"pageSlug" => page_slug = Some(value),
b"categoryName" => category_name = Some(value),
b"slug" => legacy_slug = Some(value),
b"htmlUrl" => html_url = Some(value),
_ => {}
}
}
let kind = kind_value
.or(legacy_kind)
.as_deref()
.map(MenuItemKind::from_str)
.unwrap_or(MenuItemKind::Page);
let slug = match kind {
MenuItemKind::Home | MenuItemKind::Submenu => None,
MenuItemKind::Page => page_slug.or(legacy_slug).or(html_url),
MenuItemKind::CategoryArchive => category_name.or(legacy_slug).or(html_url),
}
.filter(|value| !value.is_empty());
Ok(MenuItem {
kind,
label,
@@ -161,8 +224,8 @@ fn parse_outline(event: &BytesStart<'_>) -> EngineResult<MenuItem> {
})
}
fn attach_outline(item: MenuItem, parents: &mut [MenuItem], items: &mut Vec<MenuItem>) {
if let Some(parent) = parents.last_mut() {
fn attach_outline(item: MenuItem, parents: &mut [Option<MenuItem>], items: &mut Vec<MenuItem>) {
if let Some(Some(parent)) = parents.last_mut() {
parent.children.push(item);
} else {
items.push(item);
@@ -212,12 +275,22 @@ fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
fn write_outline(writer: &mut Writer<Vec<u8>>, item: &MenuItem) -> quick_xml::Result<()> {
let label = escape(&item.label);
let slug = item.slug.as_deref().map(escape);
let mut outline = BytesStart::new("outline");
outline.push_attribute(("text", label.as_ref()));
outline.push_attribute(("type", item.kind.as_str()));
if let Some(slug) = &slug {
outline.push_attribute(("htmlUrl", slug.as_ref()));
match item.kind {
MenuItemKind::Home => outline.push_attribute(("pageSlug", "home")),
MenuItemKind::Page => {
if let Some(slug) = item.slug.as_deref().map(escape) {
outline.push_attribute(("pageSlug", slug.as_ref()));
}
}
MenuItemKind::CategoryArchive => {
if let Some(slug) = item.slug.as_deref().map(escape) {
outline.push_attribute(("categoryName", slug.as_ref()));
}
}
MenuItemKind::Submenu => {}
}
if item.children.is_empty() {
writer.write_event(Event::Empty(outline))?;
@@ -330,6 +403,67 @@ mod tests {
assert_eq!(parsed[1].slug.as_deref(), Some("/about"));
}
#[test]
fn canonical_bds2_attributes_round_trip_without_legacy_output() {
let parsed = parse_opml(
"<opml version='2.0'><body><outline text='Home' type='home' pageSlug='home'/><outline text='Sections' type='submenu'><outline text='About' type='page' pageSlug='about'/><outline text='Notes' type='category-archive' categoryName='notes'/></outline></body></opml>",
)
.unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[1].children[0].slug.as_deref(), Some("about"));
assert_eq!(parsed[1].children[1].kind, MenuItemKind::CategoryArchive);
assert_eq!(parsed[1].children[1].slug.as_deref(), Some("notes"));
let serialized = serialize_opml(&parsed).unwrap();
assert!(serialized.contains("type=\"home\" pageSlug=\"home\""));
assert!(serialized.contains("type=\"page\" pageSlug=\"about\""));
assert!(serialized.contains("type=\"category-archive\" categoryName=\"notes\""));
assert!(!serialized.contains("htmlUrl"));
assert!(!serialized.contains("category_archive"));
}
#[test]
fn parser_ignores_foreign_outlines_and_drops_children_of_non_submenus() {
let parsed = parse_opml(
"<opml><head><outline text='Head'/></head><body><section><outline text='Foreign'/></section><outline text='Page' type='page' pageSlug='page'><outline text='Dropped' type='page' pageSlug='dropped'/></outline><outline text='Kept' type='submenu'><outline text='Child' type='page' pageSlug='child'/></outline></body></opml>",
)
.unwrap();
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[1].label, "Page");
assert!(parsed[1].children.is_empty());
assert_eq!(parsed[2].label, "Kept");
assert_eq!(parsed[2].children[0].label, "Child");
}
#[test]
fn normalization_removes_home_entries_at_every_depth() {
let normalized = normalize_menu(&[
MenuItem {
kind: MenuItemKind::Home,
label: "Duplicate".into(),
slug: None,
children: Vec::new(),
},
MenuItem {
kind: MenuItemKind::Submenu,
label: "Sections".into(),
slug: None,
children: vec![MenuItem {
kind: MenuItemKind::Home,
label: "Nested Home".into(),
slug: None,
children: Vec::new(),
}],
},
]);
assert_eq!(normalized[0].kind, MenuItemKind::Home);
assert_eq!(normalized[0].label, "Home");
assert!(normalized[1].children.is_empty());
}
#[test]
fn read_menu_rejects_malformed_xml() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -1008,7 +1008,7 @@ fn menu_item_href(item: &menu::MenuItem, language: &str, main_language: &str) ->
MenuItemKind::CategoryArchive => item
.slug
.as_deref()
.map(|slug| format!("{}/category/{}/", prefix_or_root(&prefix), slugify(slug)))
.map(|slug| format!("{prefix}/category/{}/", slugify(slug)))
.unwrap_or_else(|| "#".to_string()),
}
}
@@ -1625,3 +1625,48 @@ fn queries_category_settings(
) -> Result<HashMap<String, CategorySettings>, Box<dyn Error + Send + Sync>> {
Ok(crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default())
}
#[cfg(test)]
mod menu_tests {
use super::*;
use crate::engine::menu::{MenuItem, MenuItemKind};
#[test]
fn renderer_consumes_the_saved_opml_tree() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
crate::engine::menu::write_menu(
dir.path(),
&[
MenuItem {
kind: MenuItemKind::Page,
label: "About".into(),
slug: Some("about".into()),
children: Vec::new(),
},
MenuItem {
kind: MenuItemKind::Submenu,
label: "Topics".into(),
slug: None,
children: vec![MenuItem {
kind: MenuItemKind::CategoryArchive,
label: "Long Form".into(),
slug: Some("Long Form".into()),
children: Vec::new(),
}],
},
],
)
.unwrap();
let rendered = build_menu_items(dir.path(), "en", "en").unwrap();
assert_eq!(rendered[0]["href"], "/");
assert_eq!(rendered[1]["href"], "/about/");
assert_eq!(rendered[2]["children"][0]["href"], "/category/long-form/");
let translated = build_menu_items(dir.path(), "de", "en").unwrap();
assert_eq!(
translated[2]["children"][0]["href"],
"/de/category/long-form/"
);
}
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#b5bac4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5h7l2 2h9v12H3z"/><path d="M8 12h8M8 16h5"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#b5bac4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 11 9-8 9 8"/><path d="M5 10v10h14V10"/><path d="M9 20v-6h6v6"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#b5bac4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h6"/>
</svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#b5bac4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M8 12h12M12 18h8"/><path d="m4 15 3 3-3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 247 B

View File

@@ -40,6 +40,7 @@ use crate::views::{
ImportAnalysisEvent, ImportEditorMsg, ImportEditorState, ImportExecutionEvent,
},
media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState},
menu_editor::{MenuEditorMsg, MenuEditorState, MenuEditorStatus},
metadata_diff::MetadataDiffState,
modal,
post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink},
@@ -318,6 +319,7 @@ pub enum Message {
Tags(TagsMsg),
Settings(SettingsMsg),
ImportEditor(ImportEditorMsg),
MenuEditor(MenuEditorMsg),
// Editor data loading
PostLoaded(Result<Post, String>),
@@ -953,6 +955,7 @@ pub struct BdsApp {
guide_documentation: DocumentationState,
api_documentation: DocumentationState,
metadata_diff_state: MetadataDiffState,
menu_editor_state: MenuEditorState,
translation_validation_state: crate::views::translation_validation::TranslationValidationState,
git_state: GitUiState,
git_diffs: HashMap<String, GitDiffState>,
@@ -1120,6 +1123,7 @@ impl BdsApp {
guide_documentation: DocumentationState::new(DocumentationKind::Guide),
api_documentation: DocumentationState::new(DocumentationKind::Api),
metadata_diff_state: MetadataDiffState::default(),
menu_editor_state: MenuEditorState::default(),
translation_validation_state: Default::default(),
git_state: GitUiState::default(),
git_diffs: HashMap::new(),
@@ -1201,6 +1205,7 @@ impl BdsApp {
guide_documentation: DocumentationState::new(DocumentationKind::Guide),
api_documentation: DocumentationState::new(DocumentationKind::Api),
metadata_diff_state: MetadataDiffState::default(),
menu_editor_state: MenuEditorState::default(),
translation_validation_state: Default::default(),
git_state: GitUiState::default(),
git_diffs: HashMap::new(),
@@ -1612,11 +1617,22 @@ impl BdsApp {
}
let sidebar_task = self.refresh_counts();
let documentation_task = self.reload_changed_documentation();
let menu_editor_task = if self
.tabs
.iter()
.any(|tab| tab.tab_type == TabType::MenuEditor)
{
Task::done(Message::MenuEditor(MenuEditorMsg::Reload))
} else {
self.menu_editor_state = MenuEditorState::default();
Task::none()
};
self.sync_menu_state();
Task::batch([
sidebar_task,
Task::done(Message::EmbeddingBackfill),
documentation_task,
menu_editor_task,
])
}
Message::SwitchProject(project_id) => {
@@ -2755,6 +2771,7 @@ impl BdsApp {
Message::Tags(msg) => self.handle_tags_msg(msg),
Message::Settings(msg) => self.handle_settings_msg(msg),
Message::ImportEditor(msg) => self.handle_import_editor_msg(msg),
Message::MenuEditor(msg) => self.handle_menu_editor_msg(msg),
// ── Editor data loading ──
Message::PostLoaded(result) => {
@@ -3141,6 +3158,7 @@ impl BdsApp {
&self.guide_documentation,
&self.api_documentation,
&self.metadata_diff_state,
&self.menu_editor_state,
&self.translation_validation_state,
&self.git_state,
&self.git_diffs,
@@ -3180,7 +3198,44 @@ impl BdsApp {
Subscription::none()
};
Subscription::batch([menu_sub, task_tick, domain_event_tick, toast_tick, drag_sub])
let menu_interaction_sub = if self.menu_editor_state.draft.is_some() {
iced::event::listen_with(|event, _status, _id| match event {
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => Some(Message::MenuEditor(MenuEditorMsg::CancelDraft)),
_ => None,
})
} else if self.menu_editor_state.dragging_id.is_some() {
iced::event::listen_with(|event, _status, _id| match event {
iced::Event::Mouse(iced::mouse::Event::ButtonReleased(
iced::mouse::Button::Left,
)) => Some(Message::MenuEditor(MenuEditorMsg::Drop)),
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => Some(Message::MenuEditor(MenuEditorMsg::DragCancel)),
_ => None,
})
} else {
Subscription::none()
};
let menu_expand_tick = if self.menu_editor_state.hover_expand.is_some() {
iced::time::every(std::time::Duration::from_millis(50))
.map(|_| Message::MenuEditor(MenuEditorMsg::ExpandTick))
} else {
Subscription::none()
};
Subscription::batch([
menu_sub,
task_tick,
domain_event_tick,
toast_tick,
drag_sub,
menu_interaction_sub,
menu_expand_tick,
])
}
// ── Private helpers ──
@@ -3784,6 +3839,7 @@ impl BdsApp {
Task::done(Message::TemplateEditor(TemplateEditorMsg::Save))
}
Some(TabType::Scripts) => Task::done(Message::ScriptEditor(ScriptEditorMsg::Save)),
Some(TabType::MenuEditor) => Task::done(Message::MenuEditor(MenuEditorMsg::Save)),
_ => Task::none(),
},
MenuAction::OpenInBrowser => self.preview_active_post(),
@@ -3827,7 +3883,7 @@ impl BdsApp {
MenuAction::PreviewPost => self.preview_active_post(),
MenuAction::EditMenu => {
self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor");
Task::none()
Task::done(Message::MenuEditor(MenuEditorMsg::Reload))
}
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
MenuAction::ReindexText => Task::done(Message::ReindexText),
@@ -4275,6 +4331,184 @@ impl BdsApp {
}
}
fn reload_menu_editor(&mut self) {
let result = match (&self.db, &self.active_project, &self.data_dir) {
(Some(db), Some(project), Some(data_dir)) => {
crate::views::menu_editor::load(db, &project.id, data_dir)
}
_ => Err(t(self.ui_locale, "menuEditor.noProject")),
};
match result {
Ok(state) => self.menu_editor_state = state,
Err(error) => {
self.menu_editor_state.status = MenuEditorStatus::LoadFailed;
self.menu_editor_state.error = Some(error.clone());
self.notify(
ToastLevel::Error,
&tw(
self.ui_locale,
"menuEditor.loadFailedDetail",
&[("error", &error)],
),
);
}
}
}
fn handle_menu_editor_msg(&mut self, message: MenuEditorMsg) -> Task<Message> {
match message {
MenuEditorMsg::Reload => self.reload_menu_editor(),
MenuEditorMsg::Select(id) => self.menu_editor_state.selected_id = Some(id),
MenuEditorMsg::StartDraft(kind) => {
self.menu_editor_state.start_draft(kind);
}
MenuEditorMsg::DraftChanged(value) => self.menu_editor_state.draft_changed(value),
MenuEditorMsg::ChoosePage(id) => {
let _ = self.menu_editor_state.choose_page(&id);
}
MenuEditorMsg::ChooseCategory(name) => {
let _ = self.menu_editor_state.choose_category(&name);
}
MenuEditorMsg::SubmitDraft => {
let kind = self
.menu_editor_state
.draft
.as_ref()
.map(|draft| draft.kind);
match kind {
Some(crate::views::menu_editor::DraftKind::Page) => {
let label = t(self.ui_locale, "menuEditor.newSubmenu");
let _ = self.menu_editor_state.submit_submenu(&label);
}
Some(crate::views::menu_editor::DraftKind::Category) => {
let previous = self.menu_editor_state.clone();
if let Ok((name, is_new)) = self.menu_editor_state.submit_category()
&& is_new
&& let Some(data_dir) = &self.data_dir
&& let Err(error) = engine::meta::add_category(data_dir, &name)
{
self.menu_editor_state = previous;
self.notify(
ToastLevel::Error,
&tw(
self.ui_locale,
"menuEditor.categoryCreateFailed",
&[("error", &error.to_string())],
),
);
}
}
None => {}
}
}
MenuEditorMsg::CancelDraft => {
self.menu_editor_state.cancel_draft();
}
MenuEditorMsg::Move(direction) => {
self.menu_editor_state.move_selected(direction);
}
MenuEditorMsg::Indent => {
self.menu_editor_state.indent_selected();
}
MenuEditorMsg::Unindent => {
self.menu_editor_state.unindent_selected();
}
MenuEditorMsg::Delete => {
self.menu_editor_state.delete_selected();
}
MenuEditorMsg::Save => {
if self.menu_editor_state.draft.is_some() {
self.notify(
ToastLevel::Warning,
&t(self.ui_locale, "menuEditor.finishDraft"),
);
return Task::none();
}
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
self.menu_editor_state.status = MenuEditorStatus::Saving;
let result =
engine::menu::write_menu(&data_dir, &self.menu_editor_state.persisted_items())
.and_then(|()| engine::menu::read_menu(&data_dir));
match result {
Ok(items) => {
let project_id = self
.menu_editor_state
.project_id
.clone()
.unwrap_or_default();
let pages = self.menu_editor_state.pages.clone();
let categories = self.menu_editor_state.categories.clone();
self.menu_editor_state =
MenuEditorState::from_persisted(project_id, items, pages, categories);
self.notify(ToastLevel::Success, &t(self.ui_locale, "menuEditor.saved"));
}
Err(error) => {
self.menu_editor_state.status = MenuEditorStatus::Ready;
self.menu_editor_state.error = Some(error.to_string());
self.notify(
ToastLevel::Error,
&tw(
self.ui_locale,
"menuEditor.saveFailed",
&[("error", &error.to_string())],
),
);
}
}
}
MenuEditorMsg::ToggleExpanded(id) => {
if !self.menu_editor_state.collapsed.insert(id.clone()) {
self.menu_editor_state.collapsed.remove(&id);
}
}
MenuEditorMsg::DragStart(id) => {
if id != crate::views::menu_editor::HOME_ID {
self.menu_editor_state.selected_id = Some(id.clone());
self.menu_editor_state.dragging_id = Some(id);
}
}
MenuEditorMsg::DragOver(id, position) => {
self.menu_editor_state
.drag_over(id, position, std::time::Instant::now());
}
MenuEditorMsg::DragLeave(id) => {
if self
.menu_editor_state
.drop_target
.as_ref()
.is_some_and(|(target, _)| target == &id)
{
self.menu_editor_state.drop_target = None;
self.menu_editor_state.hover_expand = None;
}
}
MenuEditorMsg::Drop => {
if let (Some(dragged), Some((target, position))) = (
self.menu_editor_state.dragging_id.clone(),
self.menu_editor_state.drop_target.clone(),
) {
self.menu_editor_state
.drop_item(&dragged, &target, position);
}
self.menu_editor_state.dragging_id = None;
self.menu_editor_state.drop_target = None;
self.menu_editor_state.hover_expand = None;
}
MenuEditorMsg::DragCancel => {
self.menu_editor_state.dragging_id = None;
self.menu_editor_state.drop_target = None;
self.menu_editor_state.hover_expand = None;
}
MenuEditorMsg::ExpandTick => {
self.menu_editor_state
.expand_hovered(std::time::Instant::now());
}
}
Task::none()
}
fn update_import_paths(
&mut self,
definition_id: &str,
@@ -8434,6 +8668,7 @@ mod tests {
use crate::views::chat_view::ChatEditorState;
use crate::views::documentation::{DocumentLoad, DocumentationKind};
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
use crate::views::menu_editor::{MenuEditorMsg, MenuEditorState, MenuEditorStatus};
use crate::views::modal;
use crate::views::post_editor::{PostEditorMsg, PostEditorState};
use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState};
@@ -8444,7 +8679,7 @@ mod tests {
use bds_core::db::queries::project::insert_project;
use bds_core::engine::generation::GenerationReport;
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
use bds_core::engine::{ai, chat, media, post, script, template, wordpress_import};
use bds_core::engine::{ai, chat, media, menu, meta, post, script, template, wordpress_import};
use bds_core::i18n::UiLocale;
use bds_core::model::{ChatRole, Project, ScriptKind, TemplateKind};
use chrono::{Datelike, TimeZone};
@@ -8587,6 +8822,77 @@ mod tests {
assert_eq!(app.guide_documentation.signature, 42);
}
#[test]
fn menu_editor_persists_pages_new_categories_and_reload_roundtrip() {
let (db, project, temp) = setup();
let page = post::create_post(
db.conn(),
temp.path(),
&project.id,
"About",
Some("About body"),
Vec::new(),
vec!["page".to_string()],
None,
None,
None,
)
.unwrap();
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
let _ = app.update(Message::MenuEditor(MenuEditorMsg::Reload));
assert_eq!(app.menu_editor_state.status, MenuEditorStatus::Ready);
assert!(
app.menu_editor_state
.pages
.iter()
.any(|item| item.id == page.id)
);
let _ = app.update(Message::MenuEditor(MenuEditorMsg::StartDraft(
crate::views::menu_editor::DraftKind::Page,
)));
let _ = app.update(Message::MenuEditor(MenuEditorMsg::ChoosePage(page.id)));
let _ = app.update(Message::MenuEditor(MenuEditorMsg::StartDraft(
crate::views::menu_editor::DraftKind::Category,
)));
let _ = app.update(Message::MenuEditor(MenuEditorMsg::DraftChanged(
"Long Form".to_string(),
)));
let _ = app.update(Message::MenuEditor(MenuEditorMsg::SubmitDraft));
assert!(
meta::read_categories_json(temp.path())
.unwrap()
.contains(&"Long Form".to_string())
);
assert!(
meta::read_category_meta_json(temp.path())
.unwrap()
.contains_key("Long Form")
);
let _ = app.update(Message::MenuEditor(MenuEditorMsg::Save));
let saved = menu::read_menu(temp.path()).unwrap();
assert_eq!(saved[0].kind, menu::MenuItemKind::Home);
assert!(
saved
.iter()
.any(|item| item.kind == menu::MenuItemKind::Page
&& item.slug.as_deref() == Some("about"))
);
assert!(
saved
.iter()
.any(|item| item.kind == menu::MenuItemKind::CategoryArchive
&& item.slug.as_deref() == Some("Long Form"))
);
assert!(!app.menu_editor_state.dirty);
app.menu_editor_state = MenuEditorState::default();
let _ = app.update(Message::MenuEditor(MenuEditorMsg::Reload));
assert_eq!(app.menu_editor_state.items.len(), 3);
}
#[test]
fn stale_documentation_load_cannot_overwrite_a_newer_guide_read() {
let (db, project, temp) = setup();

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ pub mod duplicates;
pub mod git;
pub mod import_editor;
pub mod media_editor;
pub mod menu_editor;
pub mod metadata_diff;
pub mod modal;
pub mod panel;

View File

@@ -22,6 +22,7 @@ use crate::views::{
duplicates::{self, DuplicatesState},
git::{self, GitDiffState, GitUiState},
media_editor::{self, MediaEditorState},
menu_editor::{self, MenuEditorState},
metadata_diff::{self, MetadataDiffState},
modal, panel,
post_editor::{self, PostEditorState},
@@ -134,6 +135,7 @@ pub fn view<'a>(
guide_documentation: &'a DocumentationState,
api_documentation: &'a DocumentationState,
metadata_diff_state: &'a MetadataDiffState,
menu_editor_state: &'a MenuEditorState,
translation_validation_state: &'a TranslationValidationState,
git_state: &'a GitUiState,
git_diffs: &'a HashMap<String, GitDiffState>,
@@ -167,6 +169,7 @@ pub fn view<'a>(
guide_documentation,
api_documentation,
metadata_diff_state,
menu_editor_state,
translation_validation_state,
git_diffs,
);
@@ -412,6 +415,7 @@ fn route_content_area<'a>(
guide_documentation: &'a DocumentationState,
api_documentation: &'a DocumentationState,
metadata_diff_state: &'a MetadataDiffState,
menu_editor_state: &'a MenuEditorState,
translation_validation_state: &'a TranslationValidationState,
git_diffs: &'a HashMap<String, GitDiffState>,
) -> Element<'a, Message> {
@@ -504,6 +508,7 @@ fn route_content_area<'a>(
ContentRoute::Documentation => documentation::view(guide_documentation, locale),
ContentRoute::ApiDocumentation => documentation::view(api_documentation, locale),
ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale),
ContentRoute::MenuEditor => menu_editor::view(menu_editor_state, locale),
ContentRoute::TranslationValidation => {
translation_validation::view(translation_validation_state, locale)
}
@@ -542,6 +547,7 @@ enum ContentRoute<'a> {
Documentation,
ApiDocumentation,
MetadataDiff,
MenuEditor,
TranslationValidation,
GitDiff(&'a str),
Placeholder(&'a str),
@@ -632,7 +638,8 @@ fn route_kind<'a>(
TabType::FindDuplicates => ContentRoute::FindDuplicates,
TabType::Documentation => ContentRoute::Documentation,
TabType::ApiDocumentation => ContentRoute::ApiDocumentation,
TabType::Style | TabType::MenuEditor => ContentRoute::Placeholder(&tab.title),
TabType::MenuEditor => ContentRoute::MenuEditor,
TabType::Style => ContentRoute::Placeholder(&tab.title),
TabType::TranslationValidation => ContentRoute::TranslationValidation,
}
}
@@ -689,7 +696,7 @@ mod tests {
let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation_state = SiteValidationState::default();
let unsupported = [TabType::Style, TabType::MenuEditor];
let unsupported = [TabType::Style];
for tab_type in unsupported {
let tabs = vec![tab("tool", tab_type.clone(), "Tool")];
@@ -713,6 +720,31 @@ mod tests {
}
}
#[test]
fn menu_editor_tab_routes_to_real_editor() {
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation = SiteValidationState::default();
let tabs = vec![tab("menu", TabType::MenuEditor, "Menu")];
let route = route_kind(
&tabs,
Some("menu"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
&empty_imports,
None,
None,
None,
&site_validation,
);
assert!(matches!(route, ContentRoute::MenuEditor));
}
#[test]
fn documentation_tabs_route_to_real_read_only_views() {
let empty_posts = HashMap::new();

View File

@@ -78,6 +78,36 @@ common-save = Speichern
common-open = Öffnen
common-cancel = Abbrechen
common-delete = Löschen
menuEditor-title = Menü-Editor
menuEditor-description = Erstellen Sie die in meta/menu.opml gespeicherte Seitennavigation. Ziehen Sie Einträge oder verwenden Sie die zugänglichen Verschiebesteuerungen.
menuEditor-addEntry = Seite oder Untermenü hinzufügen
menuEditor-addCategory = Kategoriearchiv hinzufügen
menuEditor-reload = Neu laden
menuEditor-moveUp = Nach oben
menuEditor-drag = Menüeintrag ziehen
menuEditor-moveDown = Nach unten
menuEditor-indent = Einrücken
menuEditor-unindent = Ausrücken
menuEditor-loading = Menü wird geladen…
menuEditor-loadFailed = Das Menü konnte nicht geladen werden.
menuEditor-loadFailedDetail = Das Menü konnte nicht geladen werden: { $error }
menuEditor-saveFailed = Das Menü konnte nicht gespeichert werden: { $error }
menuEditor-saved = Menü gespeichert.
menuEditor-empty = Das Menü ist leer.
menuEditor-noProject = Kein aktives Projekt verfügbar.
menuEditor-finishDraft = Schließen Sie den neuen Eintrag ab oder brechen Sie ihn vor dem Speichern ab.
menuEditor-draft = Neuer Eintrag
menuEditor-pagePlaceholder = Seiten suchen oder Untermenütitel eingeben
menuEditor-categoryPlaceholder = Kategorie suchen oder eingeben
menuEditor-createSubmenu = Untermenü erstellen
menuEditor-useCategory = Kategorie verwenden
menuEditor-categoryRequired = Geben Sie einen Kategorienamen ein.
menuEditor-categoryCreateFailed = Die Kategorie konnte nicht erstellt werden: { $error }
menuEditor-newSubmenu = Neues Untermenü
menuEditor-kindHome = Startseite
menuEditor-kindPage = Seite
menuEditor-kindSubmenu = Untermenü
menuEditor-kindCategory = Kategoriearchiv
chat-sidebar-empty = Noch keine Unterhaltungen
chat-new = Neue Unterhaltung
chat-newWithModel = Unterhaltung mit { $model }

View File

@@ -78,6 +78,36 @@ common-save = Save
common-open = Open
common-cancel = Cancel
common-delete = Delete
menuEditor-title = Menu Editor
menuEditor-description = Build the site navigation stored in meta/menu.opml. Drag entries or use the accessible move controls.
menuEditor-addEntry = Add page or submenu
menuEditor-addCategory = Add category archive
menuEditor-reload = Reload
menuEditor-moveUp = Move up
menuEditor-drag = Drag menu entry
menuEditor-moveDown = Move down
menuEditor-indent = Indent
menuEditor-unindent = Unindent
menuEditor-loading = Loading menu…
menuEditor-loadFailed = The menu could not be loaded.
menuEditor-loadFailedDetail = The menu could not be loaded: { $error }
menuEditor-saveFailed = The menu could not be saved: { $error }
menuEditor-saved = Menu saved.
menuEditor-empty = The menu is empty.
menuEditor-noProject = No active project is available.
menuEditor-finishDraft = Finish or cancel the new entry before saving.
menuEditor-draft = New entry
menuEditor-pagePlaceholder = Search pages or enter a submenu title
menuEditor-categoryPlaceholder = Search or enter a category
menuEditor-createSubmenu = Create submenu
menuEditor-useCategory = Use category
menuEditor-categoryRequired = Enter a category name.
menuEditor-categoryCreateFailed = The category could not be created: { $error }
menuEditor-newSubmenu = New Submenu
menuEditor-kindHome = Home
menuEditor-kindPage = Page
menuEditor-kindSubmenu = Submenu
menuEditor-kindCategory = Category archive
chat-sidebar-empty = No conversations yet
chat-new = New Chat
chat-newWithModel = Chat with { $model }

View File

@@ -78,6 +78,36 @@ common-save = Guardar
common-open = Abrir
common-cancel = Cancelar
common-delete = Eliminar
menuEditor-title = Editor de menú
menuEditor-description = Cree la navegación del sitio guardada en meta/menu.opml. Arrastre las entradas o use los controles de movimiento accesibles.
menuEditor-addEntry = Añadir página o submenú
menuEditor-addCategory = Añadir archivo de categoría
menuEditor-reload = Recargar
menuEditor-moveUp = Subir
menuEditor-drag = Arrastrar entrada del menú
menuEditor-moveDown = Bajar
menuEditor-indent = Aumentar sangría
menuEditor-unindent = Reducir sangría
menuEditor-loading = Cargando menú…
menuEditor-loadFailed = No se pudo cargar el menú.
menuEditor-loadFailedDetail = No se pudo cargar el menú: { $error }
menuEditor-saveFailed = No se pudo guardar el menú: { $error }
menuEditor-saved = Menú guardado.
menuEditor-empty = El menú está vacío.
menuEditor-noProject = No hay ningún proyecto activo disponible.
menuEditor-finishDraft = Termine o cancele la nueva entrada antes de guardar.
menuEditor-draft = Nueva entrada
menuEditor-pagePlaceholder = Buscar páginas o introducir el título de un submenú
menuEditor-categoryPlaceholder = Buscar o introducir una categoría
menuEditor-createSubmenu = Crear submenú
menuEditor-useCategory = Usar categoría
menuEditor-categoryRequired = Introduzca un nombre de categoría.
menuEditor-categoryCreateFailed = No se pudo crear la categoría: { $error }
menuEditor-newSubmenu = Nuevo submenú
menuEditor-kindHome = Inicio
menuEditor-kindPage = Página
menuEditor-kindSubmenu = Submenú
menuEditor-kindCategory = Archivo de categoría
chat-sidebar-empty = No hay conversaciones
chat-new = Nueva conversación
chat-newWithModel = Conversación con { $model }

View File

@@ -78,6 +78,36 @@ common-save = Enregistrer
common-open = Ouvrir
common-cancel = Annuler
common-delete = Supprimer
menuEditor-title = Éditeur de menu
menuEditor-description = Construisez la navigation du site enregistrée dans meta/menu.opml. Faites glisser les entrées ou utilisez les commandes de déplacement accessibles.
menuEditor-addEntry = Ajouter une page ou un sous-menu
menuEditor-addCategory = Ajouter une archive de catégorie
menuEditor-reload = Recharger
menuEditor-moveUp = Monter
menuEditor-drag = Faire glisser lentrée du menu
menuEditor-moveDown = Descendre
menuEditor-indent = Indenter
menuEditor-unindent = Désindenter
menuEditor-loading = Chargement du menu…
menuEditor-loadFailed = Impossible de charger le menu.
menuEditor-loadFailedDetail = Impossible de charger le menu : { $error }
menuEditor-saveFailed = Impossible denregistrer le menu : { $error }
menuEditor-saved = Menu enregistré.
menuEditor-empty = Le menu est vide.
menuEditor-noProject = Aucun projet actif nest disponible.
menuEditor-finishDraft = Terminez ou annulez la nouvelle entrée avant denregistrer.
menuEditor-draft = Nouvelle entrée
menuEditor-pagePlaceholder = Rechercher des pages ou saisir le titre dun sous-menu
menuEditor-categoryPlaceholder = Rechercher ou saisir une catégorie
menuEditor-createSubmenu = Créer le sous-menu
menuEditor-useCategory = Utiliser la catégorie
menuEditor-categoryRequired = Saisissez un nom de catégorie.
menuEditor-categoryCreateFailed = Impossible de créer la catégorie : { $error }
menuEditor-newSubmenu = Nouveau sous-menu
menuEditor-kindHome = Accueil
menuEditor-kindPage = Page
menuEditor-kindSubmenu = Sous-menu
menuEditor-kindCategory = Archive de catégorie
chat-sidebar-empty = Aucune conversation
chat-new = Nouvelle conversation
chat-newWithModel = Conversation avec { $model }

View File

@@ -78,6 +78,36 @@ common-save = Salva
common-open = Apri
common-cancel = Annulla
common-delete = Elimina
menuEditor-title = Editor del menu
menuEditor-description = Crea la navigazione del sito salvata in meta/menu.opml. Trascina le voci o usa i comandi di spostamento accessibili.
menuEditor-addEntry = Aggiungi pagina o sottomenu
menuEditor-addCategory = Aggiungi archivio categoria
menuEditor-reload = Ricarica
menuEditor-moveUp = Sposta su
menuEditor-drag = Trascina la voce del menu
menuEditor-moveDown = Sposta giù
menuEditor-indent = Aumenta rientro
menuEditor-unindent = Riduci rientro
menuEditor-loading = Caricamento menu…
menuEditor-loadFailed = Impossibile caricare il menu.
menuEditor-loadFailedDetail = Impossibile caricare il menu: { $error }
menuEditor-saveFailed = Impossibile salvare il menu: { $error }
menuEditor-saved = Menu salvato.
menuEditor-empty = Il menu è vuoto.
menuEditor-noProject = Nessun progetto attivo disponibile.
menuEditor-finishDraft = Completa o annulla la nuova voce prima di salvare.
menuEditor-draft = Nuova voce
menuEditor-pagePlaceholder = Cerca pagine o inserisci il titolo di un sottomenu
menuEditor-categoryPlaceholder = Cerca o inserisci una categoria
menuEditor-createSubmenu = Crea sottomenu
menuEditor-useCategory = Usa categoria
menuEditor-categoryRequired = Inserisci un nome di categoria.
menuEditor-categoryCreateFailed = Impossibile creare la categoria: { $error }
menuEditor-newSubmenu = Nuovo sottomenu
menuEditor-kindHome = Home
menuEditor-kindPage = Pagina
menuEditor-kindSubmenu = Sottomenu
menuEditor-kindCategory = Archivio categoria
chat-sidebar-empty = Nessuna conversazione
chat-new = Nuova conversazione
chat-newWithModel = Conversazione con { $model }