Files
RuDS/crates/bds-core/src/engine/menu.rs

541 lines
19 KiB
Rust

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<String>,
pub children: Vec<MenuItem>,
}
#[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<Vec<MenuItem>> {
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<MenuItem> {
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<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 outlines: Vec<Option<MenuItem>> = Vec::new();
let mut elements: Vec<Vec<u8>> = 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 </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(_) => {}
Err(error) => return Err(EngineError::Parse(error.to_string())),
}
}
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 = 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<MenuItem>], items: &mut Vec<MenuItem>) {
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<String> {
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", &timestamp)
.map_err(|error| EngineError::Parse(error.to_string()))?;
write_text_element(&mut writer, "dateModified", &timestamp)
.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<Vec<u8>>,
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<Vec<u8>>, 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(
"<opml version='2.0'><body><outline text='About' type='page' htmlUrl='/about'/></body></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(
"<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("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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<opml version=\"2.0\">\n",
" <head>\n",
" <title>Test Blog</title>\n",
" <dateCreated>2024-03-30T21:20:00.000Z</dateCreated>\n",
" <dateModified>2024-03-30T21:20:00.000Z</dateModified>\n",
" </head>\n",
" <body>\n",
" <outline text=\"Home\" type=\"home\" pageSlug=\"home\" />\n",
" </body>\n",
"</opml>\n",
)
);
}
#[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();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
std::fs::write(
dir.path().join("meta/menu.opml"),
"<opml><body><outline></body></opml>",
)
.unwrap();
assert!(read_menu(dir.path()).is_err());
}
}