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

@@ -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/"
);
}
}