use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::config::Config;
use crate::epub::{self, EpubChapter, EpubMeta};
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
pub fn run(
project: &Path,
book_name: Option<&str>,
output: Option<&Path>,
title: Option<&str>,
author: Option<&str>,
) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
let book = resolve_user_book(&h, book_name)?;
let chapters = collect_chapters(&store, &h, &book)?;
if chapters.is_empty() {
return Err(Error::Store(format!(
"epub: `{}` has no chapters to export",
book.title,
)));
}
let meta = EpubMeta {
title: title
.map(str::to_string)
.unwrap_or_else(|| book.title.clone()),
author: author
.map(str::to_string)
.or_else(|| cfg.editor.comment_author.clone())
.unwrap_or_else(|| "Unknown Author".to_string()),
language: crate::ai::prompts::iso_from_long(&cfg.language).to_string(),
identifier: format!("urn:uuid:{}", Uuid::now_v7()),
};
let dest = output
.map(PathBuf::from)
.unwrap_or_else(|| layout.root.join(format!("{}.epub", book.slug)));
eprintln!(
"Exporting `{}` → EPUB ({} chapter{})…",
book.title,
chapters.len(),
if chapters.len() == 1 { "" } else { "s" },
);
let report = epub::write_epub(&meta, &chapters, &dest)
.map_err(|e| Error::Store(format!("epub write: {e:#}")))?;
println!(
"EPUB: {} ({} chapters · {} KB)",
dest.display(),
report.chapters,
report.bytes / 1024,
);
Ok(())
}
fn collect_chapters(
store: &Store,
h: &Hierarchy,
book: &Node,
) -> Result<Vec<EpubChapter>> {
let mut chapters = Vec::new();
for chapter in h.children_of(Some(book.id)) {
if chapter.kind != NodeKind::Chapter {
continue;
}
let mut body = String::new();
append_branch_prose(store, h, chapter, &mut body, false)?;
chapters.push(EpubChapter {
title: clean_title(&chapter.title),
body_xhtml: body,
});
}
Ok(chapters)
}
fn append_branch_prose(
store: &Store,
h: &Hierarchy,
branch: &Node,
body: &mut String,
is_sub: bool,
) -> Result<()> {
if is_sub {
body.push_str(&format!(
"<h2>{}</h2>\n",
epub::escape_xml(&clean_title(&branch.title)),
));
}
for child in h.children_of(Some(branch.id)) {
match child.kind {
NodeKind::Paragraph => {
let raw = read_paragraph(store, child)?;
let xhtml = epub::typst_to_xhtml(&raw);
body.push_str(&xhtml);
}
NodeKind::Subchapter => {
append_branch_prose(store, h, child, body, true)?;
}
_ => {}
}
}
Ok(())
}
fn read_paragraph(store: &Store, node: &Node) -> Result<String> {
let bytes = store
.get_content(node.id)
.map_err(|e| Error::Store(format!("epub: read paragraph {}: {e}", node.id)))?
.unwrap_or_default();
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub(crate) fn clean_title(title: &str) -> String {
let t = title.trim();
if let Some(idx) = t.find(": ") {
let head = &t[..idx];
if head
.to_ascii_lowercase()
.starts_with("chapter")
{
return t[idx + 2..].trim().to_string();
}
}
if let Some(idx) = t.find(". ") {
let head = &t[..idx];
if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) {
return t[idx + 2..].trim().to_string();
}
}
t.to_string()
}
fn resolve_user_book(h: &Hierarchy, book_name: Option<&str>) -> Result<Node> {
let user_books: Vec<&Node> = h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
.collect();
match book_name {
Some(name) => {
let needle = name.trim().to_ascii_lowercase();
user_books
.iter()
.copied()
.find(|b| {
b.title.to_ascii_lowercase() == needle
|| b.slug.to_ascii_lowercase() == needle
})
.cloned()
.ok_or_else(|| {
let listing = user_books
.iter()
.map(|b| format!("`{}` (slug: {})", b.title, b.slug))
.collect::<Vec<_>>()
.join(", ");
Error::Store(format!(
"epub: no book matches `--book-name {name}`. Available: {}",
if listing.is_empty() {
"no user books".into()
} else {
listing
},
))
})
}
None => match user_books.as_slice() {
[book] => Ok((*book).clone()),
[] => Err(Error::Store(
"epub: project has no user books — add one with `inkhaven add book <title>`".into(),
)),
_ => {
let listing = user_books
.iter()
.map(|b| format!("`{}`", b.title))
.collect::<Vec<_>>()
.join(", ");
Err(Error::Store(format!(
"epub: project has {n} user books — pass --book-name <name>. Available: {listing}",
n = user_books.len(),
)))
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_title_strips_chapter_prefix() {
assert_eq!(clean_title("Chapter 3: The Lacquered Box"), "The Lacquered Box");
assert_eq!(clean_title("Chapter 12: Crossing"), "Crossing");
}
#[test]
fn clean_title_strips_numeric_prefix() {
assert_eq!(clean_title("001. Approach"), "Approach");
assert_eq!(clean_title("42. Reverie"), "Reverie");
}
#[test]
fn clean_title_leaves_plain_titles() {
assert_eq!(clean_title("The Wharf"), "The Wharf");
assert_eq!(clean_title("A Tale: Continued"), "A Tale: Continued");
}
#[test]
fn clean_title_trims_whitespace() {
assert_eq!(clean_title(" Padded "), "Padded");
}
}