Implement the OPML menu editor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
3
crates/bds-ui/assets/icons/menu-category.svg
Normal file
3
crates/bds-ui/assets/icons/menu-category.svg
Normal 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 |
3
crates/bds-ui/assets/icons/menu-home.svg
Normal file
3
crates/bds-ui/assets/icons/menu-home.svg
Normal 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 |
3
crates/bds-ui/assets/icons/menu-page.svg
Normal file
3
crates/bds-ui/assets/icons/menu-page.svg
Normal 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 |
3
crates/bds-ui/assets/icons/menu-submenu.svg
Normal file
3
crates/bds-ui/assets/icons/menu-submenu.svg
Normal 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 |
@@ -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();
|
||||
|
||||
1234
crates/bds-ui/src/views/menu_editor.rs
Normal file
1234
crates/bds-ui/src/views/menu_editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 l’entré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 d’enregistrer le menu : { $error }
|
||||
menuEditor-saved = Menu enregistré.
|
||||
menuEditor-empty = Le menu est vide.
|
||||
menuEditor-noProject = Aucun projet actif n’est disponible.
|
||||
menuEditor-finishDraft = Terminez ou annulez la nouvelle entrée avant d’enregistrer.
|
||||
menuEditor-draft = Nouvelle entrée
|
||||
menuEditor-pagePlaceholder = Rechercher des pages ou saisir le titre d’un 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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user