use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::config::Config;
use crate::scrivener::binder::{parse_project, BinderItem};
use crate::scrivener::mapping::{classify, node_kind_for, Classification};
use crate::scrivener::rtf::rtf_to_typst;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use crate::store::{InsertPosition, Store, SYSTEM_TAG_NOTES, SYSTEM_TAG_PLACES, SYSTEM_TAG_CHARACTERS, SYSTEM_TAG_ARTEFACTS};
#[derive(Debug, Clone, Default)]
pub struct ImportOpts {
pub draft_as_book: Option<String>,
pub skip_research: bool,
pub dry_run: bool,
}
#[derive(Debug, Default)]
pub struct ImportReport {
pub books_created: usize,
pub chapters_created: usize,
pub subchapters_created: usize,
pub paragraphs_created: usize,
pub paragraphs_skipped: usize,
pub errors: Vec<String>,
}
impl ImportReport {
pub fn total_created(&self) -> usize {
self.books_created
+ self.chapters_created
+ self.subchapters_created
+ self.paragraphs_created
}
}
pub fn import_scrivener_project(
scriv_root: &Path,
store: &Store,
cfg: &Config,
opts: &ImportOpts,
) -> Result<ImportReport> {
let binder = parse_project(scriv_root)
.with_context(|| format!("parse .scrivx in {}", scriv_root.display()))?;
let docs_dir = scriv_root.join("Files").join("Docs");
let mut report = ImportReport::default();
let mut ctx = WalkCtx {
scriv_root: scriv_root.to_path_buf(),
docs_dir,
store,
cfg,
opts,
report: &mut report,
};
for item in &binder {
ctx.walk_top(item)?;
}
Ok(report)
}
struct WalkCtx<'a> {
scriv_root: PathBuf,
docs_dir: PathBuf,
store: &'a Store,
cfg: &'a Config,
opts: &'a ImportOpts,
report: &'a mut ImportReport,
}
impl<'a> WalkCtx<'a> {
fn walk_top(&mut self, item: &BinderItem) -> Result<()> {
let is_draft = item.kind == "DraftFolder";
if is_draft {
let title = self
.opts
.draft_as_book
.clone()
.unwrap_or_else(|| item.title.clone());
let book_id = self.create_book(&title, None)?;
self.report.books_created += 1;
for (i, child) in item.children.iter().enumerate() {
self.walk_in_draft(child, book_id, 1, i as u32)?;
}
return Ok(());
}
if self.opts.skip_research {
return Ok(());
}
match classify(item, None) {
Classification::SystemBook(tag) => {
self.import_into_system_book(item, tag)?;
}
Classification::SkipKeepChildren => {
for child in &item.children {
self.walk_top(child)?;
}
}
_ => {
}
}
Ok(())
}
fn walk_in_draft(
&mut self,
item: &BinderItem,
parent_id: uuid::Uuid,
depth: usize,
order_hint: u32,
) -> Result<()> {
let _ = order_hint;
let classification = classify(item, Some(depth));
match classification {
Classification::Paragraph => {
self.create_paragraph(&item.title, parent_id, &item.uuid)?;
self.report.paragraphs_created += 1;
}
Classification::Chapter | Classification::Subchapter => {
let kind = node_kind_for(&classification).unwrap();
let branch_id = self.create_branch(kind, &item.title, Some(parent_id))?;
match kind {
NodeKind::Chapter => self.report.chapters_created += 1,
NodeKind::Subchapter => self.report.subchapters_created += 1,
_ => {}
}
for (i, child) in item.children.iter().enumerate() {
self.walk_in_draft(child, branch_id, depth + 1, i as u32)?;
}
}
Classification::SkipKeepChildren => {
for (i, child) in item.children.iter().enumerate() {
self.walk_in_draft(child, parent_id, depth, i as u32)?;
}
}
Classification::SkipSubtree => {
self.report.paragraphs_skipped += 1;
}
Classification::UserBook | Classification::SystemBook(_) => {}
}
Ok(())
}
fn create_book(
&mut self,
title: &str,
system_tag: Option<&str>,
) -> Result<uuid::Uuid> {
if self.opts.dry_run {
return Ok(uuid::Uuid::nil());
}
let hierarchy = Hierarchy::load(self.store)
.map_err(|e| anyhow::anyhow!("hierarchy: {e}"))?;
let mut node = self
.store
.create_node(
self.cfg,
&hierarchy,
NodeKind::Book,
title,
None,
None,
InsertPosition::End,
)
.map_err(|e| anyhow::anyhow!("create_node book `{title}`: {e}"))?;
if let Some(tag) = system_tag {
node.system_tag = Some(tag.to_string());
node.protected = true;
self.store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow::anyhow!("tag book `{title}`: {e}"))?;
}
Ok(node.id)
}
fn create_branch(
&mut self,
kind: NodeKind,
title: &str,
parent_id: Option<uuid::Uuid>,
) -> Result<uuid::Uuid> {
if self.opts.dry_run {
return Ok(uuid::Uuid::nil());
}
let hierarchy = Hierarchy::load(self.store)
.map_err(|e| anyhow::anyhow!("hierarchy: {e}"))?;
let parent_node: Option<Node> = parent_id
.and_then(|id| hierarchy.get(id).cloned());
let parent_ref = parent_node.as_ref();
let node = self
.store
.create_node(
self.cfg,
&hierarchy,
kind,
title,
parent_ref,
None,
InsertPosition::End,
)
.map_err(|e| {
anyhow::anyhow!("create_node {:?} `{title}`: {e}", kind)
})?;
Ok(node.id)
}
fn create_paragraph(
&mut self,
title: &str,
parent_id: uuid::Uuid,
scriv_uuid: &uuid::Uuid,
) -> Result<()> {
if self.opts.dry_run {
return Ok(());
}
let rtf_path = self.docs_dir.join(format!("{}.rtf", scriv_uuid));
let body = if rtf_path.is_file() {
match std::fs::read(&rtf_path) {
Ok(bytes) => match rtf_to_typst(&bytes) {
Ok(s) => s,
Err(e) => {
self.report.errors.push(format!(
"rtf `{}`: {e}",
rtf_path.display()
));
String::new()
}
},
Err(e) => {
self.report.errors.push(format!(
"read `{}`: {e}",
rtf_path.display()
));
String::new()
}
}
} else {
String::new()
};
let hierarchy = Hierarchy::load(self.store)
.map_err(|e| anyhow::anyhow!("hierarchy: {e}"))?;
let parent_node = hierarchy
.get(parent_id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("parent {parent_id} missing"))?;
let mut node = self
.store
.create_node(
self.cfg,
&hierarchy,
NodeKind::Paragraph,
title,
Some(&parent_node),
None,
InsertPosition::End,
)
.map_err(|e| anyhow::anyhow!("create_node paragraph: {e}"))?;
if !body.is_empty() {
if let Some(rel) = node.file.as_ref() {
let abs = self.store.project_root().join(rel);
if let Err(e) = std::fs::write(&abs, body.as_bytes()) {
self.report.errors.push(format!(
"write {}: {e}",
abs.display()
));
}
}
if let Err(e) = self
.store
.update_paragraph_content(&mut node, body.as_bytes())
{
self.report.errors.push(format!(
"store update `{title}`: {e}"
));
}
}
Ok(())
}
fn import_into_system_book(
&mut self,
item: &BinderItem,
tag: &str,
) -> Result<()> {
let hierarchy = Hierarchy::load(self.store)
.map_err(|e| anyhow::anyhow!("hierarchy: {e}"))?;
let book_id = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(tag)
})
.map(|n| n.id);
let book_id = match book_id {
Some(id) => id,
None => {
let title = match tag {
"places" => "Places",
"characters" => "Characters",
"notes" => "Notes",
"artefacts" => "Artefacts",
other => other,
};
self.create_book(title, Some(tag))?
}
};
for child in &item.children {
self.flatten_into_system_book(child, book_id)?;
}
Ok(())
}
fn flatten_into_system_book(
&mut self,
item: &BinderItem,
book_id: uuid::Uuid,
) -> Result<()> {
if item.kind == "Text" {
self.create_paragraph(&item.title, book_id, &item.uuid)?;
self.report.paragraphs_created += 1;
}
for child in &item.children {
self.flatten_into_system_book(child, book_id)?;
}
Ok(())
}
}
#[allow(dead_code)]
pub const SYSTEM_TAGS: &[&str] = &[
SYSTEM_TAG_NOTES,
SYSTEM_TAG_PLACES,
SYSTEM_TAG_CHARACTERS,
SYSTEM_TAG_ARTEFACTS,
];