use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use crate::store::{InsertPosition, Store, SYSTEM_TAG_HELP};
#[derive(Default)]
struct Counts {
branches: usize,
paragraphs: usize,
}
pub fn run(project: &Path, documents_dir: &Path) -> Result<()> {
if !documents_dir.is_dir() {
return Err(Error::Store(format!(
"--documents-directory `{}` is not a directory",
documents_dir.display()
)));
}
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let hierarchy = Hierarchy::load(&store)?;
let help = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(SYSTEM_TAG_HELP)
})
.cloned()
.ok_or_else(|| {
Error::Store(
"Help system book not found — re-open the project to seed it".into(),
)
})?;
let wiped = wipe_help_contents(&store, &hierarchy, help.id)?;
if wiped > 0 {
eprintln!("cleared {wiped} existing item(s) from Help");
}
let _ = Hierarchy::load(&store)?;
let mut counts = Counts::default();
let entries = read_sorted_children(documents_dir);
for entry in entries {
let res = if entry.is_dir() {
import_dir(&store, &cfg, &entry, help.id, &mut counts)
} else {
import_file(&store, &cfg, &entry, help.id, &mut counts)
};
if let Err(e) = res {
eprintln!(
"warning: {}: {e} — continuing with remaining entries",
entry.display()
);
}
}
eprintln!(
"imported {} branch(es) and {} paragraph(s) into Help from {}",
counts.branches,
counts.paragraphs,
documents_dir.display()
);
Ok(())
}
fn wipe_help_contents(store: &Store, hierarchy: &Hierarchy, help_id: Uuid) -> Result<usize> {
let layout = store.project_root().to_path_buf();
let direct_children: Vec<Uuid> = hierarchy
.iter()
.filter(|n| n.parent_id == Some(help_id))
.map(|n| n.id)
.collect();
let mut wiped = 0usize;
for child_id in direct_children {
let Some(node) = hierarchy.get(child_id) else {
continue;
};
let ids = hierarchy.collect_subtree(child_id);
let fs_rel = match node.kind {
NodeKind::Paragraph => node
.file
.as_ref()
.map(std::path::PathBuf::from)
.unwrap_or_default(),
_ => {
let abs = layout.join(
hierarchy.fs_path(node, &crate::project::ProjectLayout::new(&layout)),
);
abs.strip_prefix(&layout)
.unwrap_or(&abs)
.to_path_buf()
}
};
if let Err(e) = store.delete_subtree(&fs_rel, &ids) {
eprintln!(
"warning: couldn't fully wipe `{}` from Help: {e}",
node.title
);
continue;
}
wiped += 1;
}
Ok(wiped)
}
fn import_dir(
store: &Store,
cfg: &Config,
source: &Path,
parent_id: Uuid,
counts: &mut Counts,
) -> Result<()> {
let hierarchy = Hierarchy::load(store)?;
let parent = hierarchy
.get(parent_id)
.cloned()
.ok_or_else(|| Error::Store(format!("import: parent {parent_id} vanished")))?;
let kind = match next_branch_kind(&parent, cfg) {
Some(k) => k,
None => {
return flatten_files_into(store, cfg, source, parent_id, counts);
}
};
let title = derive_branch_title(source);
let created = store.create_node(
cfg,
&hierarchy,
kind,
&title,
Some(&parent),
None,
InsertPosition::End,
)?;
counts.branches += 1;
let children = read_sorted_children(source);
let mut first_err: Option<Error> = None;
for child in children {
let res = if child.is_dir() {
import_dir(store, cfg, &child, created.id, counts)
} else {
import_file(store, cfg, &child, created.id, counts)
};
if let Err(e) = res {
eprintln!(
"warning: {}: {e} — continuing with remaining entries",
child.display()
);
if first_err.is_none() {
first_err = Some(e);
}
}
}
match first_err {
None => Ok(()),
Some(e) => Err(e),
}
}
fn import_file(
store: &Store,
cfg: &Config,
file: &Path,
parent_id: Uuid,
counts: &mut Counts,
) -> Result<()> {
let title = derive_paragraph_title(file);
let bytes = std::fs::read(file).map_err(Error::Io)?;
let hierarchy = Hierarchy::load(store)?;
let parent = hierarchy
.get(parent_id)
.cloned()
.ok_or_else(|| Error::Store(format!("import: parent {parent_id} vanished")))?;
let created = store.create_node(
cfg,
&hierarchy,
NodeKind::Paragraph,
&title,
Some(&parent),
None,
InsertPosition::End,
)?;
if let Some(rel) = &created.file {
let abs = layout_root(store).join(rel);
std::fs::write(&abs, &bytes).map_err(Error::Io)?;
let mut node = created.clone();
store.update_paragraph_content(&mut node, &bytes)?;
}
counts.paragraphs += 1;
Ok(())
}
fn flatten_files_into(
store: &Store,
cfg: &Config,
source: &Path,
parent_id: Uuid,
counts: &mut Counts,
) -> Result<()> {
let mut first_err: Option<Error> = None;
for entry in walkdir::WalkDir::new(source)
.sort_by_file_name()
.follow_links(false)
{
let entry = match entry {
Ok(e) => e,
Err(e) => {
eprintln!("warning: walkdir: {e}");
if first_err.is_none() {
first_err = Some(Error::Store(format!("walkdir: {e}")));
}
continue;
}
};
if !entry.file_type().is_file() {
continue;
}
let name = entry.file_name().to_str().unwrap_or("");
if name.starts_with('.') {
continue;
}
if let Err(e) = import_file(store, cfg, entry.path(), parent_id, counts) {
eprintln!(
"warning: {}: {e} — continuing with remaining files",
entry.path().display()
);
if first_err.is_none() {
first_err = Some(e);
}
}
}
match first_err {
None => Ok(()),
Some(e) => Err(e),
}
}
fn next_branch_kind(parent: &Node, cfg: &Config) -> Option<NodeKind> {
match parent.kind {
NodeKind::Book => Some(NodeKind::Chapter),
NodeKind::Chapter => Some(NodeKind::Subchapter),
NodeKind::Subchapter => {
if cfg.hierarchy.unbounded_subchapters {
Some(NodeKind::Subchapter)
} else {
None
}
}
NodeKind::Paragraph | NodeKind::Image | NodeKind::Script => None,
}
}
fn read_sorted_children(source: &Path) -> Vec<PathBuf> {
let Ok(rd) = std::fs::read_dir(source) else {
return Vec::new();
};
let mut entries: Vec<_> = rd
.filter_map(std::result::Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.map(|s| !s.starts_with('.'))
.unwrap_or(true)
})
.collect();
entries.sort_by(|a, b| {
let a_dir = a.path().is_dir();
let b_dir = b.path().is_dir();
match (a_dir, b_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.file_name().cmp(&b.file_name()),
}
});
entries.into_iter().map(|e| e.path()).collect()
}
fn derive_branch_title(path: &Path) -> String {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("imported");
prettify_segment(name)
}
fn derive_paragraph_title(path: &Path) -> String {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("imported");
prettify_segment(stem)
}
fn prettify_segment(raw: &str) -> String {
let pretty: String = raw
.replace('_', " ")
.replace('-', " ")
.split_whitespace()
.map(|w| {
let mut c = w.chars();
match c.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(c).collect::<String>(),
}
})
.collect::<Vec<_>>()
.join(" ");
if pretty.trim().is_empty() {
raw.to_string()
} else {
pretty
}
}
fn layout_root(store: &Store) -> PathBuf {
store.project_root().to_path_buf()
}