use std::fs; use std::path::Path; use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}; use quick_xml::{Reader, Writer, XmlVersion, escape::escape}; use crate::engine::EngineError; use crate::engine::EngineResult; use crate::model::Project; use crate::util::atomic_write_str; use crate::util::timestamp::unix_ms_to_iso; /// A navigation menu item per menu.allium. #[derive(Debug, Clone, PartialEq)] pub struct MenuItem { pub kind: MenuItemKind, pub label: String, pub slug: Option, pub children: Vec, } #[derive(Debug, Clone, PartialEq)] pub enum MenuItemKind { Home, Page, Submenu, CategoryArchive, } impl MenuItemKind { fn as_str(&self) -> &'static str { match self { Self::Home => "home", Self::Page => "page", Self::Submenu => "submenu", Self::CategoryArchive => "category-archive", } } fn from_str(s: &str) -> Self { match s { "home" => Self::Home, "submenu" => Self::Submenu, "category-archive" | "category_archive" => Self::CategoryArchive, _ => Self::Page, } } } /// Read the navigation menu from meta/menu.opml. pub fn read_menu(data_dir: &Path) -> EngineResult> { let path = data_dir.join("meta").join("menu.opml"); if !path.exists() { return Ok(vec![MenuItem { kind: MenuItemKind::Home, label: "Home".to_string(), slug: None, children: Vec::new(), }]); } let content = fs::read_to_string(&path)?; parse_opml(&content) } /// Write the navigation menu to meta/menu.opml. /// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended. pub fn write_menu(data_dir: &Path, project: &Project, items: &[MenuItem]) -> EngineResult<()> { let normalized = normalize_menu(items); let timestamp = if project.updated_at > 0 { project.updated_at } else { project.created_at }; let opml = serialize_opml(&project.name, timestamp, &normalized)?; let path = data_dir.join("meta").join("menu.opml"); atomic_write_str(&path, &opml)?; Ok(()) } /// Return the default menu OPML for new projects. pub fn default_menu_opml(project_name: &str, timestamp: i64) -> String { let items = vec![MenuItem { kind: MenuItemKind::Home, label: "Home".to_string(), slug: None, children: Vec::new(), }]; serialize_opml(project_name, timestamp, &items).expect("writing menu XML to memory cannot fail") } /// Per menu.allium HomeAlwaysPresent: ensure Home is always first. fn normalize_menu(items: &[MenuItem]) -> Vec { let without_home: Vec<_> = items.iter().filter_map(normalize_non_home).collect(); let mut result = vec![MenuItem { kind: MenuItemKind::Home, label: "Home".to_string(), slug: None, children: Vec::new(), }]; result.extend(without_home); result } fn normalize_non_home(item: &MenuItem) -> Option { 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> { let mut reader = Reader::from_str(content); reader.config_mut().trim_text(true); let mut items = Vec::new(); let mut outlines: Vec> = Vec::new(); let mut elements: Vec> = Vec::new(); loop { match reader.read_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 event.name().as_ref() == b"outline" => { if collect_outline(&elements, &outlines) { attach_outline(parse_outline(&event)?, &mut outlines, &mut items); } } Ok(Event::End(event)) => { let name = event.name().as_ref().to_vec(); let open = elements .pop() .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 ".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(_) => {} Err(error) => return Err(EngineError::Parse(error.to_string())), } } if !outlines.is_empty() || !elements.is_empty() { return Err(EngineError::Parse("unclosed ".to_string())); } Ok(normalize_menu(&items)) } fn collect_outline(elements: &[Vec], outlines: &[Option]) -> 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 { 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()))?; let value = attribute .decoded_and_normalized_value(XmlVersion::Implicit1_0, event.decoder()) .map_err(|error| EngineError::Parse(error.to_string()))? .into_owned(); match attribute.key.as_ref() { b"text" => label = 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, slug, children: Vec::new(), }) } fn attach_outline(item: MenuItem, parents: &mut [Option], items: &mut Vec) { if let Some(Some(parent)) = parents.last_mut() { parent.children.push(item); } else { items.push(item); } } /// Serialize menu items to OPML 2.0 format. fn serialize_opml(project_name: &str, timestamp: i64, items: &[MenuItem]) -> EngineResult { let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2); writer.config_mut().add_space_before_slash_in_empty_elements = true; writer .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None))) .map_err(|error| EngineError::Parse(error.to_string()))?; let mut opml = BytesStart::new("opml"); opml.push_attribute(("version", "2.0")); writer .write_event(Event::Start(opml)) .map_err(|error| EngineError::Parse(error.to_string()))?; writer .write_event(Event::Start(BytesStart::new("head"))) .map_err(|error| EngineError::Parse(error.to_string()))?; write_text_element(&mut writer, "title", project_name) .map_err(|error| EngineError::Parse(error.to_string()))?; let timestamp = unix_ms_to_iso(timestamp); write_text_element(&mut writer, "dateCreated", ×tamp) .map_err(|error| EngineError::Parse(error.to_string()))?; write_text_element(&mut writer, "dateModified", ×tamp) .map_err(|error| EngineError::Parse(error.to_string()))?; writer .write_event(Event::End(BytesEnd::new("head"))) .map_err(|error| EngineError::Parse(error.to_string()))?; writer .write_event(Event::Start(BytesStart::new("body"))) .map_err(|error| EngineError::Parse(error.to_string()))?; for item in items { write_outline(&mut writer, item).map_err(|error| EngineError::Parse(error.to_string()))?; } writer .write_event(Event::End(BytesEnd::new("body"))) .map_err(|error| EngineError::Parse(error.to_string()))?; writer .write_event(Event::End(BytesEnd::new("opml"))) .map_err(|error| EngineError::Parse(error.to_string()))?; let mut rendered = String::from_utf8(writer.into_inner()) .map_err(|error| EngineError::Parse(error.to_string()))?; rendered.push('\n'); Ok(rendered) } fn write_text_element( writer: &mut Writer>, name: &str, value: &str, ) -> quick_xml::Result<()> { writer.write_event(Event::Start(BytesStart::new(name)))?; writer.write_event(Event::Text(BytesText::new(value)))?; writer.write_event(Event::End(BytesEnd::new(name)))?; Ok(()) } fn write_outline(writer: &mut Writer>, item: &MenuItem) -> quick_xml::Result<()> { let label = escape(&item.label); let mut outline = BytesStart::new("outline"); outline.push_attribute(("text", label.as_ref())); outline.push_attribute(("type", item.kind.as_str())); 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))?; } else { writer.write_event(Event::Start(outline))?; for child in &item.children { write_outline(writer, child)?; } writer.write_event(Event::End(BytesEnd::new("outline")))?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn default_opml_has_home() { let opml = default_menu_opml("Test Blog", 1_711_833_600_000); assert!(opml.contains("type=\"home\"")); assert!(opml.contains("text=\"Home\"")); } #[test] fn roundtrip_menu() { let items = vec![ MenuItem { kind: MenuItemKind::Home, label: "Home".into(), slug: None, children: Vec::new(), }, MenuItem { kind: MenuItemKind::Page, label: "About".into(), slug: Some("/about".into()), children: Vec::new(), }, MenuItem { kind: MenuItemKind::Submenu, label: "Categories".into(), slug: None, children: vec![MenuItem { kind: MenuItemKind::CategoryArchive, label: "Tech".into(), slug: Some("/category/tech/".into()), children: Vec::new(), }], }, ]; let opml = serialize_opml("Test Blog", 1_711_833_600_000, &items).unwrap(); let parsed = parse_opml(&opml).unwrap(); assert_eq!(parsed.len(), 3); assert_eq!(parsed[0].kind, MenuItemKind::Home); assert_eq!(parsed[1].label, "About"); assert_eq!(parsed[1].slug.as_deref(), Some("/about")); assert_eq!(parsed[2].children.len(), 1); assert_eq!(parsed[2].children[0].label, "Tech"); } #[test] fn normalize_prepends_home() { let items = vec![MenuItem { kind: MenuItemKind::Page, label: "About".into(), slug: Some("/about".into()), children: Vec::new(), }]; let normalized = normalize_menu(&items); assert_eq!(normalized.len(), 2); assert_eq!(normalized[0].kind, MenuItemKind::Home); assert_eq!(normalized[1].label, "About"); } #[test] fn read_menu_missing_file_returns_home() { let dir = tempfile::TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join("meta")).unwrap(); let items = read_menu(dir.path()).unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0].kind, MenuItemKind::Home); } #[test] fn write_and_read_menu() { let dir = tempfile::TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join("meta")).unwrap(); let items = vec![MenuItem { kind: MenuItemKind::Page, label: "Blog".into(), slug: Some("/blog".into()), children: Vec::new(), }]; write_menu( dir.path(), &Project { id: "project-1".into(), name: "Test Blog".into(), slug: "test-blog".into(), description: None, data_path: None, is_active: true, created_at: 1_711_833_600_000, updated_at: 1_711_833_600_000, }, &items, ) .unwrap(); let read = read_menu(dir.path()).unwrap(); // Home is always prepended assert_eq!(read.len(), 2); assert_eq!(read[0].kind, MenuItemKind::Home); assert_eq!(read[1].label, "Blog"); } #[test] fn reads_single_quoted_xml_attributes() { let parsed = parse_opml( "", ) .unwrap(); assert_eq!(parsed[1].label, "About"); assert_eq!(parsed[1].slug.as_deref(), Some("/about")); } #[test] fn canonical_bds2_attributes_round_trip_without_legacy_output() { let parsed = parse_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("Test Blog", 1_711_833_600_000, &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 serializer_matches_bds2_head_spacing_and_trailing_newline() { let items = vec![MenuItem { kind: MenuItemKind::Home, label: "Home".into(), slug: None, children: Vec::new(), }]; let serialized = serialize_opml("Test Blog", 1_711_833_600_000, &items).unwrap(); assert_eq!( serialized, concat!( "\n", "\n", " \n", " Test Blog\n", " 2024-03-30T21:20:00.000Z\n", " 2024-03-30T21:20:00.000Z\n", " \n", " \n", " \n", " \n", "\n", ) ); } #[test] fn parser_ignores_foreign_outlines_and_drops_children_of_non_submenus() { let parsed = parse_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(); std::fs::create_dir_all(dir.path().join("meta")).unwrap(); std::fs::write( dir.path().join("meta/menu.opml"), "", ) .unwrap(); assert!(read_menu(dir.path()).is_err()); } }