use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::config::Config;
use crate::epub::{self, EpubChapter, EpubCover, 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_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
let book = crate::cli::resolve_user_book(&h, book_name, "epub")
.map_err(Error::Store)?
.clone();
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 cover = detect_cover(&layout.root);
if let Some(c) = &cover {
eprintln!(" embedding cover ({}, {} KB)", c.media_type, c.bytes.len() / 1024);
}
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()),
cover,
};
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 detect_cover(root: &Path) -> Option<EpubCover> {
for (name, ext, mt) in [
("cover.png", "png", "image/png"),
("cover.jpg", "jpg", "image/jpeg"),
("cover.jpeg", "jpg", "image/jpeg"),
] {
let path = root.join(name);
if !path.is_file() {
continue;
}
match std::fs::read(&path) {
Ok(bytes) if !bytes.is_empty() => {
return Some(EpubCover {
bytes,
media_type: mt.to_string(),
file_ext: ext.to_string(),
});
}
Ok(_) => {
tracing::warn!(
target: "inkhaven::epub",
"cover `{}` is empty — skipping",
path.display(),
);
}
Err(e) => {
tracing::warn!(
target: "inkhaven::epub",
"cover `{}` unreadable ({e}) — skipping",
path.display(),
);
}
}
}
None
}
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()
}
#[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");
}
#[test]
fn detect_cover_none_when_absent() {
let tmp = tempfile::tempdir().unwrap();
assert!(detect_cover(tmp.path()).is_none());
}
#[test]
fn detect_cover_finds_png() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("cover.png"), b"\x89PNG fake").unwrap();
let cover = detect_cover(tmp.path()).expect("cover detected");
assert_eq!(cover.media_type, "image/png");
assert_eq!(cover.file_ext, "png");
assert_eq!(cover.bytes, b"\x89PNG fake");
}
#[test]
fn detect_cover_prefers_png_over_jpg() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("cover.png"), b"png").unwrap();
std::fs::write(tmp.path().join("cover.jpg"), b"jpg").unwrap();
assert_eq!(detect_cover(tmp.path()).unwrap().media_type, "image/png");
}
#[test]
fn detect_cover_jpeg_maps_to_jpeg_mime() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("cover.jpeg"), b"jpegbytes").unwrap();
let cover = detect_cover(tmp.path()).unwrap();
assert_eq!(cover.media_type, "image/jpeg");
assert_eq!(cover.file_ext, "jpg");
}
#[test]
fn detect_cover_skips_empty_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("cover.png"), b"").unwrap();
assert!(detect_cover(tmp.path()).is_none());
}
}