use anyhow::{anyhow, Context, Result};
use quick_xml::events::Event;
use quick_xml::Reader;
use std::path::Path;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct BinderItem {
pub uuid: Uuid,
pub kind: String,
pub title: String,
pub children: Vec<BinderItem>,
}
impl BinderItem {
pub fn walk(&self, depth: usize, visit: &mut dyn FnMut(usize, &BinderItem)) {
visit(depth, self);
for child in &self.children {
child.walk(depth + 1, visit);
}
}
}
pub fn parse_project(scriv_root: &Path) -> Result<Vec<BinderItem>> {
let stem = scriv_root
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| {
anyhow!("scriv path `{}` has no stem", scriv_root.display())
})?;
let scrivx = scriv_root.join(format!("{stem}.scrivx"));
if !scrivx.is_file() {
return Err(anyhow!(
".scrivx not found at {} — is this a valid Scrivener project?",
scrivx.display()
));
}
let bytes = std::fs::read(&scrivx)
.with_context(|| format!("read {}", scrivx.display()))?;
parse_scrivx(&bytes)
}
pub fn parse_scrivx(bytes: &[u8]) -> Result<Vec<BinderItem>> {
let mut reader = Reader::from_reader(bytes);
reader.config_mut().trim_text(true);
let mut stack: Vec<PartialItem> = Vec::new();
let mut result: Vec<BinderItem> = Vec::new();
let mut current_text: Option<TextBuf> = None;
let mut buf: Vec<u8> = Vec::new();
loop {
let event = reader
.read_event_into(&mut buf)
.with_context(|| ".scrivx parse error".to_string())?;
match event {
Event::Start(e) => {
let name = std::str::from_utf8(e.name().as_ref())
.unwrap_or("")
.to_string();
match name.as_str() {
"BinderItem" => {
let mut uuid: Option<Uuid> = None;
let mut kind: String = "Unknown".into();
for attr in e.attributes().with_checks(false) {
let attr = attr
.map_err(|err| anyhow!("attr parse: {err}"))?;
let key = std::str::from_utf8(
attr.key.as_ref(),
)
.unwrap_or("");
let val_bytes = attr.value.as_ref();
let val = std::str::from_utf8(val_bytes)
.unwrap_or("")
.to_string();
match key {
"UUID" | "ID" => {
if let Ok(u) = Uuid::parse_str(&val) {
uuid = Some(u);
} else {
uuid = Some(deterministic_uuid(&val));
}
}
"Type" => kind = val,
_ => {}
}
}
stack.push(PartialItem {
uuid: uuid.unwrap_or_else(Uuid::nil),
kind,
title: String::new(),
children: Vec::new(),
});
}
"Title" => {
current_text = Some(TextBuf::Title);
}
_ => {}
}
}
Event::Text(e) => {
if let Some(TextBuf::Title) = current_text {
if let Some(top) = stack.last_mut() {
let txt = e
.unescape()
.map_err(|err| anyhow!("title decode: {err}"))?;
top.title.push_str(&txt);
}
}
}
Event::End(e) => {
let name = std::str::from_utf8(e.name().as_ref())
.unwrap_or("")
.to_string();
match name.as_str() {
"Title" => {
current_text = None;
}
"BinderItem" => {
if let Some(p) = stack.pop() {
let item = BinderItem {
uuid: p.uuid,
kind: p.kind,
title: p.title,
children: p.children,
};
if let Some(parent) = stack.last_mut() {
parent.children.push(item);
} else {
result.push(item);
}
}
}
_ => {}
}
}
Event::Eof => break,
_ => {}
}
buf.clear();
}
Ok(result)
}
#[derive(Debug)]
struct PartialItem {
uuid: Uuid,
kind: String,
title: String,
children: Vec<BinderItem>,
}
#[derive(Debug)]
enum TextBuf {
Title,
}
fn deterministic_uuid(s: &str) -> Uuid {
let mut bytes = [0u8; 16];
let src = s.as_bytes();
for (i, b) in src.iter().enumerate() {
bytes[i % 16] ^= *b;
}
Uuid::from_bytes(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_binder() {
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
<ScrivenerProject>
<Binder>
<BinderItem UUID="00000000-0000-0000-0000-000000000001" Type="DraftFolder">
<Title>Manuscript</Title>
<Children>
<BinderItem UUID="00000000-0000-0000-0000-000000000002" Type="Folder">
<Title>Chapter One</Title>
<Children>
<BinderItem UUID="00000000-0000-0000-0000-000000000003" Type="Text">
<Title>The Storm</Title>
</BinderItem>
</Children>
</BinderItem>
</Children>
</BinderItem>
</Binder>
</ScrivenerProject>"#;
let items = parse_scrivx(xml).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].kind, "DraftFolder");
assert_eq!(items[0].title, "Manuscript");
assert_eq!(items[0].children.len(), 1);
let ch1 = &items[0].children[0];
assert_eq!(ch1.title, "Chapter One");
assert_eq!(ch1.kind, "Folder");
assert_eq!(ch1.children.len(), 1);
let storm = &ch1.children[0];
assert_eq!(storm.title, "The Storm");
assert_eq!(storm.kind, "Text");
}
#[test]
fn walks_in_preorder() {
let xml = br#"<ScrivenerProject><Binder>
<BinderItem UUID="00000000-0000-0000-0000-000000000001" Type="DraftFolder">
<Title>Root</Title>
<Children>
<BinderItem UUID="00000000-0000-0000-0000-000000000002" Type="Text">
<Title>A</Title>
</BinderItem>
<BinderItem UUID="00000000-0000-0000-0000-000000000003" Type="Text">
<Title>B</Title>
</BinderItem>
</Children>
</BinderItem>
</Binder></ScrivenerProject>"#;
let items = parse_scrivx(xml).unwrap();
let mut titles: Vec<String> = Vec::new();
for item in &items {
item.walk(0, &mut |_d, i| titles.push(i.title.clone()));
}
assert_eq!(titles, vec!["Root", "A", "B"]);
}
}