1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
//! `inkhaven import-help --documents-directory <PATH>`
//!
//! Walks the given directory and imports it under the Help system book:
//! subdirectories become chapters / subchapters / (flattened) and files
//! become paragraphs, mirroring the TUI's F3 directory-import semantics.
//!
//! Depth mapping (rooted at the Help book):
//! Help (book)
//! └── top-level dir → Chapter
//! └── nested dir → Subchapter
//! └── deeper dirs → flattened: their files become paragraphs of
//! the enclosing subchapter (unless `unbounded_subchapters` is
//! on, in which case Subchapter nests indefinitely).
//!
//! Files at the source root land directly under Help as paragraphs.
//!
//! Hidden entries (dotfiles) are skipped. The Help book's read-only flag
//! lives in the TUI editor — the store accepts writes here so importing
//! works.
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_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
// Locate the Help system book. `ensure_system_books` (called inside
// `Store::open`) guarantees it exists, but we still go through the
// hierarchy lookup so a hypothetical migration that loses the tag
// surfaces a clear error rather than a silent failure.
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(),
)
})?;
// Wipe Help's existing contents before importing so repeated runs
// don't accumulate stale chapters or duplicate paragraphs. The Help
// book itself stays (it's a system book and other features depend on
// its tagged identity); only its descendants are removed.
let wiped = wipe_help_contents(&store, &hierarchy, help.id)?;
if wiped > 0 {
eprintln!("cleared {wiped} existing item(s) from Help");
}
// Reload the hierarchy so subsequent `import_*` paths see the now-empty
// Help book instead of the stale snapshot they'd otherwise inherit
// from the in-memory `hierarchy` we captured above.
let _ = Hierarchy::load(&store)?;
let mut counts = Counts::default();
// Children of the source directory become either branches or paragraphs
// directly under Help. Sorted dirs-first / alphabetical so the output
// is deterministic.
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(())
}
/// Remove every direct child of the Help book (along with their entire
/// subtrees) so a fresh `import-help` produces a clean view. The Help book
/// itself is preserved — it's a system book whose identity (tag, protected
/// flag, fixed root position) other features rely on.
///
/// Returns the count of top-level subtrees that were wiped. Per-child
/// failures are logged to stderr but do not abort the wipe — partial
/// cleanup is better than aborting and leaving the project half-imported.
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;
};
// The subtree to delete: this child plus every descendant.
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(),
_ => {
// Branch fs path: walk the hierarchy's `fs_path` against the
// stored layout. We can't borrow `ProjectLayout` directly
// from the store, so we reconstruct the relative path from
// its absolute form below.
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)
}
/// Create a branch for `source` under `parent_id` and recurse into its
/// children. The branch's kind is determined by the parent's kind; when we
/// run out of legal depth we flatten files into the parent instead.
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 => {
// Depth limit hit — flatten all remaining files into `parent`.
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(())
}
/// Walk `source` recursively and import every regular file as a paragraph
/// under `parent_id`. Used when we've hit the depth limit and can no longer
/// create deeper branches.
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
}
}
/// Accessor for the Store's project root. Lives here because the field is
/// private; we go through a tiny helper rather than expose it everywhere.
fn layout_root(store: &Store) -> PathBuf {
store.project_root().to_path_buf()
}