pub mod hierarchy;
pub mod node;
use std::path::Path;
use std::sync::Arc;
use crate::storage::{DocumentStorage, EmbeddingEngine, Model};
use serde_json::Value as JsonValue;
use uuid::Uuid;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::{BOOKS_DIR, ProjectLayout};
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind as NK};
pub use node::NodeKind;
pub const SYSTEM_BOOKS: &[(&str, &str)] = &[
("notes", "Notes"),
("research", "Research"),
("prompts", "Prompts"),
("places", "Places"),
("characters", "Characters"),
("artefacts", "Artefacts"),
("threads", "Threads"),
("language", "Language"),
("typst", "Typst"),
("scripts", "Scripts"),
("help", "Help"),
];
pub const SYSTEM_TAG_NOTES: &str = "notes";
pub const SYSTEM_TAG_PROMPTS: &str = "prompts";
pub const SYSTEM_TAG_PLACES: &str = "places";
pub const SYSTEM_TAG_CHARACTERS: &str = "characters";
pub const SYSTEM_TAG_ARTEFACTS: &str = "artefacts";
pub const SYSTEM_TAG_THREADS: &str = "threads";
pub const SYSTEM_TAG_LANGUAGES: &str = "language";
pub const SYSTEM_TAG_TYPST: &str = "typst";
pub const SYSTEM_TAG_SCRIPTS: &str = "scripts";
pub const SYSTEM_TAG_HELP: &str = "help";
pub const SYSTEM_TAG_BOOK_TIMELINE: &str = "book_timeline";
#[derive(Debug, Clone, Copy)]
pub enum InsertPosition {
End,
After(Uuid),
Before(Uuid),
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub id: Uuid,
#[allow(dead_code)]
pub parent_id: Uuid,
pub created_at: chrono::DateTime<chrono::Utc>,
pub word_count: u64,
pub preview: String,
pub annotation: String,
}
#[derive(Clone)]
pub struct Store {
inner: DocumentStorage,
layout: Arc<ProjectLayout>,
}
impl Store {
pub fn open(layout: ProjectLayout, cfg: &Config) -> Result<Self> {
let root = layout
.store_root()
.to_str()
.ok_or_else(|| Error::Store("project root path is not valid UTF-8".into()))?;
let perf = perf_trace_enabled();
let t0 = std::time::Instant::now();
let engine = build_embedding_engine(&cfg.embeddings.model)?;
perf_mark(perf, "store.open.embedding_engine", t0.elapsed());
let t1 = std::time::Instant::now();
let inner = DocumentStorage::with_embedding(root, engine).map_err(|e| {
Error::Store(format!(
"couldn't open the document store at {} — {}.\n\
Another inkhaven process may be using the project, or the database \
may be corrupt. If you have a backup, restore it; otherwise \
`inkhaven init` a fresh project and re-add your work.",
layout.root.display(),
e
))
})?;
perf_mark(perf, "store.open.duckdb_open", t1.elapsed());
let store = Self {
inner,
layout: Arc::new(layout),
};
let t2 = std::time::Instant::now();
store.ensure_system_books(cfg)?;
perf_mark(perf, "store.open.ensure_system_books", t2.elapsed());
store.ensure_artefacts_directory(cfg)?;
crate::scripting::configure(cfg.scripting.clone(), store.clone(), cfg.clone());
Ok(store)
}
fn ensure_artefacts_directory(&self, cfg: &Config) -> Result<()> {
let abs = self.resolve_artefacts_dir(cfg);
std::fs::create_dir_all(&abs).map_err(Error::Io)?;
Ok(())
}
pub fn resolve_artefacts_dir(&self, cfg: &Config) -> std::path::PathBuf {
let raw = cfg.artefacts_directory.trim();
if raw.is_empty() {
return default_user_artefacts_dir(&self.layout.root);
}
let p = std::path::PathBuf::from(raw);
match crate::path_safety::resolve_within_or_absolute(&self.layout.root, &p) {
Ok(resolved) => resolved,
Err(e) => {
tracing::warn!(
target: "inkhaven::security",
"artefacts_directory `{raw}` rejected ({e}); falling back to default per-user dir",
);
default_user_artefacts_dir(&self.layout.root)
}
}
}
pub fn ensure_timeline_chapter(
&self,
cfg: &Config,
book_id: Uuid,
) -> Result<Uuid> {
let hierarchy = crate::store::hierarchy::Hierarchy::load(self)?;
let book = hierarchy
.get(book_id)
.cloned()
.ok_or_else(|| Error::Store(format!(
"ensure_timeline_chapter: book {book_id} missing"
)))?;
if book.kind != NK::Book {
return Err(Error::Store(format!(
"ensure_timeline_chapter: `{}` is not a Book", book.title
)));
}
if let Some(existing) = hierarchy.iter().find(|n| {
n.parent_id == Some(book_id)
&& n.system_tag.as_deref() == Some(SYSTEM_TAG_BOOK_TIMELINE)
}) {
return Ok(existing.id);
}
let mut created = self.create_node(
cfg,
&hierarchy,
NK::Chapter,
"Timeline",
Some(&book),
None,
InsertPosition::End,
)?;
created.system_tag = Some(SYSTEM_TAG_BOOK_TIMELINE.to_owned());
created.modified_at = chrono::Utc::now();
self.inner
.update_metadata(created.id, created.to_json())
.map_err(|e| Error::Store(format!(
"ensure_timeline_chapter: stamp system_tag: {e}"
)))?;
self.sync()?;
Ok(created.id)
}
pub fn provision_user_book(
&self,
cfg: &Config,
book_node: &Node,
) -> Result<()> {
if book_node.kind != NK::Book || book_node.parent_id.is_some() {
return Ok(());
}
if book_node.system_tag.is_some() {
return Ok(());
}
let sub = self.resolve_artefacts_dir(cfg).join(&book_node.slug);
std::fs::create_dir_all(&sub).map_err(Error::Io)?;
self.ensure_typst_skeleton(cfg, &book_node.title)?;
Ok(())
}
fn ensure_typst_skeleton(&self, cfg: &Config, book_title: &str) -> Result<()> {
let hierarchy = Hierarchy::load(self)?;
let Some(typst_book) = hierarchy
.iter()
.find(|n| n.kind == NK::Book && n.system_tag.as_deref() == Some(SYSTEM_TAG_TYPST))
.cloned()
else {
return Ok(());
};
let chapter = match hierarchy
.iter()
.find(|n| n.kind == NK::Chapter
&& n.parent_id == Some(typst_book.id)
&& n.title == book_title)
.cloned()
{
Some(n) => n,
None => self.create_node(
cfg,
&hierarchy,
NK::Chapter,
book_title,
Some(&typst_book),
None,
InsertPosition::End,
)?,
};
let globals_body = cfg.typst_templates.globals_typ_body();
let seeds: [(&str, String); 3] = [
(
"index.typ",
"= index.typ\n\n#import \"globals.typ\": *\n#import \"settings.typ\": *\n"
.into(),
),
(
"settings.typ",
"= settings.typ\n\n// Document-wide #set / #show rules go here.\n".into(),
),
("globals.typ", globals_body),
];
for (title, body) in &seeds {
let h = Hierarchy::load(self)?;
let already = h.iter().any(|n| {
n.kind == NK::Paragraph
&& n.parent_id == Some(chapter.id)
&& n.title == *title
});
if already {
continue;
}
let mut created = self.create_node(
cfg,
&h,
NK::Paragraph,
title,
Some(&chapter),
None,
InsertPosition::End,
)?;
if let Some(rel) = &created.file {
let abs = self.layout.root.join(rel);
std::fs::write(&abs, body.as_bytes()).map_err(Error::Io)?;
self.update_paragraph_content(&mut created, body.as_bytes())?;
}
}
Ok(())
}
fn ensure_system_books(&self, cfg: &Config) -> Result<()> {
let hierarchy = Hierarchy::load(self)?;
let mut existing_by_tag: std::collections::HashMap<String, Node> =
std::collections::HashMap::new();
for node in hierarchy.iter() {
if node.kind == NK::Book {
if let Some(tag) = node.system_tag.as_deref() {
existing_by_tag.insert(tag.to_string(), node.clone());
}
}
}
for (idx, (tag, title)) in SYSTEM_BOOKS.iter().enumerate() {
let target_order = idx as u32;
match existing_by_tag.get(*tag).cloned() {
Some(mut node) => {
if !node.protected {
node.protected = true;
self.inner
.update_metadata(node.id, node.to_json())
.map_err(|e| {
Error::Store(format!("update_metadata: {e}"))
})?;
}
}
None => {
let h = Hierarchy::load(self)?;
for n in h.children_of(None) {
if n.order >= target_order {
let mut bumped = n.clone();
bumped.order += 1;
self.inner
.update_metadata(bumped.id, bumped.to_json())
.map_err(|e| {
Error::Store(format!("update_metadata (bump): {e}"))
})?;
}
}
let h = Hierarchy::load(self)?;
let mut node = self.create_node(
cfg,
&h,
NK::Book,
title,
None,
None,
InsertPosition::End,
)?;
node.order = target_order;
node.protected = true;
node.system_tag = Some(tag.to_string());
self.inner
.update_metadata(node.id, node.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
}
}
}
let healed = Hierarchy::load(self)?;
let mut by_order: std::collections::HashMap<u32, Vec<Node>> =
std::collections::HashMap::new();
for n in healed.children_of(None) {
if n.system_tag.is_some() {
by_order.entry(n.order).or_default().push(n.clone());
}
}
let any_collision = by_order.values().any(|v| v.len() > 1);
if any_collision {
for (idx, (tag, _title)) in SYSTEM_BOOKS.iter().enumerate() {
let target = idx as u32;
if let Some(node) = healed.iter().find(|n| {
n.kind == NK::Book && n.system_tag.as_deref() == Some(*tag)
}) {
if node.order != target {
let mut updated = node.clone();
updated.order = target;
self.inner
.update_metadata(updated.id, updated.to_json())
.map_err(|e| {
Error::Store(format!("update_metadata (heal): {e}"))
})?;
}
}
}
}
self.sync()?;
Ok(())
}
pub fn raw(&self) -> &DocumentStorage {
&self.inner
}
pub fn project_root(&self) -> &std::path::Path {
&self.layout.root
}
pub fn sync(&self) -> Result<()> {
self.inner.sync().map_err(|e| Error::Store(e.to_string()))
}
pub fn checkpoint(&self) -> Result<()> {
self.inner
.checkpoint()
.map_err(|e| Error::Store(e.to_string()))
}
pub fn integrity_check(&self) -> Result<(String, String)> {
self.inner
.integrity_check()
.map_err(|e| Error::Store(e.to_string()))
}
pub fn row_count(&self) -> Result<usize> {
self.inner
.row_count()
.map_err(|e| Error::Store(e.to_string()))
}
pub fn vector_count(&self) -> Result<usize> {
self.inner
.vector_count()
.map_err(|e| Error::Store(e.to_string()))
}
pub fn put_node(&self, node: &mut Node, content: &[u8]) -> Result<()> {
let id = self
.inner
.add_document(node.to_json(), content)
.map_err(|e| Error::Store(format!("add_document: {e}")))?;
node.id = id;
self.inner
.update_metadata(id, node.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
fire_hook(
"hook.on_create",
vec![
bund_string(&id.to_string()),
bund_string(node.kind.as_str()),
],
);
Ok(())
}
pub fn search_text(&self, query: &str, limit: usize) -> Result<Vec<JsonValue>> {
self.inner
.search_document_text(query, limit)
.map_err(|e| Error::Store(format!("search_document_text: {e}")))
}
pub fn get_content(&self, id: Uuid) -> Result<Option<Vec<u8>>> {
self.inner
.get_content(id)
.map_err(|e| Error::Store(format!("get_content: {e}")))
}
pub fn create_node(
&self,
cfg: &Config,
hierarchy: &Hierarchy,
kind: NodeKind,
title: &str,
parent: Option<&Node>,
slug_override: Option<&str>,
position: InsertPosition,
) -> Result<Node> {
hierarchy.validate_placement(cfg, parent, kind)?;
let slug_seed = slug_override.unwrap_or(title);
let mut slug = slug::slugify(slug_seed);
if slug.is_empty() {
return Err(Error::Store(format!(
"could not derive a slug from `{slug_seed}`; pass --slug"
)));
}
let parent_id = parent.map(|p| p.id);
let siblings = hierarchy.children_of(parent_id);
if siblings.iter().any(|n| n.slug == slug) {
let base = slug.clone();
let mut n = 2;
while siblings.iter().any(|s| s.slug == slug) {
slug = format!("{base}-{n}");
n += 1;
}
}
let order = match position {
InsertPosition::End => hierarchy.next_order(parent_id),
InsertPosition::After(anchor_id) => {
let Some(anchor) = hierarchy.get(anchor_id) else {
return Err(Error::Store(format!("insert-after: missing anchor {anchor_id}")));
};
if anchor.parent_id != parent_id {
return Err(Error::Store(
"insert-after: anchor does not share the requested parent".into(),
));
}
let anchor_order = anchor.order;
let mut to_shift: Vec<(Uuid, u32)> = hierarchy
.children_of(parent_id)
.into_iter()
.filter(|n| n.order > anchor_order && n.id != anchor_id)
.map(|n| (n.id, n.order))
.collect();
to_shift.sort_by_key(|(_, ord)| std::cmp::Reverse(*ord));
for (id, old_order) in to_shift {
self.shift_sibling_order(hierarchy, id, old_order + 1)?;
}
anchor_order + 1
}
InsertPosition::Before(anchor_id) => {
let Some(anchor) = hierarchy.get(anchor_id) else {
return Err(Error::Store(format!(
"insert-before: missing anchor {anchor_id}"
)));
};
if anchor.parent_id != parent_id {
return Err(Error::Store(
"insert-before: anchor does not share the requested parent".into(),
));
}
let anchor_order = anchor.order;
let mut to_shift: Vec<(Uuid, u32)> = hierarchy
.children_of(parent_id)
.into_iter()
.filter(|n| n.order >= anchor_order)
.map(|n| (n.id, n.order))
.collect();
to_shift.sort_by_key(|(_, ord)| std::cmp::Reverse(*ord));
for (id, old_order) in to_shift {
self.shift_sibling_order(hierarchy, id, old_order + 1)?;
}
anchor_order
}
};
let path_chain: Vec<String> = match parent {
None => Vec::new(),
Some(p) => {
let mut chain: Vec<String> = hierarchy
.ancestors(p)
.into_iter()
.map(|a| a.slug.clone())
.collect();
chain.push(p.slug.clone());
chain
}
};
let mut node = Node {
id: Uuid::nil(),
kind,
title: title.to_string(),
slug,
path: path_chain,
parent_id,
order,
file: None,
word_count: 0,
modified_at: chrono::Utc::now(),
protected: false,
system_tag: None,
image_ext: None,
image_caption: None,
image_alt: None,
content_type: None,
status: None,
target_words: None,
target_hit_at_status: None,
linked_paragraphs: Vec::new(),
bookmark: false,
tags: Vec::new(),
ai_memory: Vec::new(),
event: None,
};
let rel_path = match parent {
None => std::path::PathBuf::from(BOOKS_DIR).join(node.fs_name()),
Some(p) => hierarchy.fs_path(p, &self.layout).join(node.fs_name()),
};
let abs_path = self.layout.root.join(&rel_path);
let in_help_book = parent
.map(|p| {
let mut cur: Option<&Node> = Some(p);
while let Some(n) = cur {
if n.system_tag.as_deref() == Some(SYSTEM_TAG_HELP) {
return true;
}
cur = n.parent_id.and_then(|id| hierarchy.get(id));
}
false
})
.unwrap_or(false);
let content: Vec<u8> = match kind {
NK::Paragraph => {
if let Some(parent_dir) = abs_path.parent() {
std::fs::create_dir_all(parent_dir)?;
}
let template = if in_help_book {
node.content_type = Some("markdown".to_string());
format!("# {}\n\n", node.title)
} else {
format!("= {}\n\n", node.title)
};
std::fs::write(&abs_path, &template)?;
node.file = Some(rel_path.to_string_lossy().into_owned());
node.word_count = template.split_whitespace().count() as u64;
template.into_bytes()
}
NK::Script => {
if let Some(parent_dir) = abs_path.parent() {
std::fs::create_dir_all(parent_dir)?;
}
let template = format!(
"// {}\n// Bund script — evaluated into the Adam VM at\n\
// project open. Register hooks via:\n\
// \"hook.on_save\" {{ drop \"saved\" println }} register\n\n",
node.title
);
std::fs::write(&abs_path, &template)?;
node.file = Some(rel_path.to_string_lossy().into_owned());
node.word_count = template.split_whitespace().count() as u64;
node.content_type = Some("bund".to_string());
template.into_bytes()
}
_ => {
std::fs::create_dir_all(&abs_path)?;
node.title.clone().into_bytes()
}
};
self.put_node(&mut node, &content)?;
self.sync()?;
Ok(node)
}
fn shift_sibling_order(
&self,
hierarchy: &Hierarchy,
node_id: Uuid,
new_order: u32,
) -> Result<()> {
let node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| Error::Store(format!("shift_sibling_order: missing {node_id}")))?;
if node.order == new_order {
return Ok(());
}
let old_rel = hierarchy.fs_path(&node, &self.layout);
let old_abs = self.layout.root.join(&old_rel);
let mut new_node = node.clone();
new_node.order = new_order;
let new_name = new_node.fs_name();
let parent_dir = old_abs
.parent()
.ok_or_else(|| Error::Store("filesystem entry has no parent directory".into()))?;
let new_abs = parent_dir.join(&new_name);
let needs_rename = old_abs != new_abs;
if needs_rename {
if new_abs.exists() {
return Err(Error::Store(format!(
"shift_sibling_order: target `{}` already exists",
new_abs.display()
)));
}
std::fs::rename(&old_abs, &new_abs)?;
}
let new_rel = new_abs
.strip_prefix(&self.layout.root)
.unwrap_or(&new_abs)
.to_string_lossy()
.into_owned();
if new_node.file.is_some() {
new_node.file = Some(new_rel.clone());
}
self.inner
.update_metadata(new_node.id, new_node.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
if node.kind != NK::Paragraph {
self.rewrite_descendant_files(hierarchy, &node, &old_rel, &new_rel)?;
}
Ok(())
}
pub fn rename_node(&self, hierarchy: &Hierarchy, node_id: Uuid, new_title: &str) -> Result<()> {
let mut node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| Error::Store(format!("rename_node: missing {node_id}")))?;
let trimmed = new_title.trim();
if trimmed.is_empty() {
return Err(Error::Store("rename: title cannot be empty".into()));
}
node.title = trimmed.to_string();
node.modified_at = chrono::Utc::now();
if matches!(node.kind, NodeKind::Paragraph) {
let new_slug_base = slug::slugify(trimmed);
if !new_slug_base.is_empty() && new_slug_base != node.slug {
let mut new_slug = new_slug_base.clone();
let mut n = 2;
let siblings = hierarchy.children_of(node.parent_id);
while siblings
.iter()
.any(|s| s.id != node.id && s.slug == new_slug)
{
new_slug = format!("{new_slug_base}-{n}");
n += 1;
}
if new_slug != node.slug {
if let Some(rel_old) = node.file.as_ref().cloned() {
let old_abs = self.layout.root.join(&rel_old);
node.slug = new_slug.clone();
let new_name = node.fs_name();
let parent_rel = std::path::Path::new(&rel_old)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
let new_rel = parent_rel.join(&new_name);
let new_abs = self.layout.root.join(&new_rel);
if old_abs != new_abs {
if let Err(e) = std::fs::rename(&old_abs, &new_abs) {
tracing::warn!(
target: "inkhaven::rename",
"rename {} → {} failed: {e}",
old_abs.display(),
new_abs.display(),
);
node.slug = rel_old
.rsplit('/')
.next()
.and_then(|n| {
n.trim_end_matches(".typ")
.trim_end_matches(".hjson")
.splitn(2, '-')
.nth(1)
.map(|s| s.to_string())
})
.unwrap_or(node.slug);
} else {
node.file =
Some(new_rel.to_string_lossy().into_owned());
}
}
} else {
node.slug = new_slug;
}
}
}
}
self.inner
.update_metadata(node.id, node.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
self.inner
.reembed_document(node.id)
.map_err(|e| Error::Store(format!("reembed_document: {e}")))?;
self.sync()?;
fire_hook(
"hook.on_rename",
vec![bund_string(&node.id.to_string()), bund_string(trimmed)],
);
Ok(())
}
pub fn swap_siblings(
&self,
hierarchy: &Hierarchy,
a_id: Uuid,
b_id: Uuid,
) -> Result<()> {
if a_id == b_id {
return Err(Error::Store("cannot swap a node with itself".into()));
}
let a = hierarchy
.get(a_id)
.ok_or_else(|| Error::Store(format!("node {a_id} missing from hierarchy")))?
.clone();
let b = hierarchy
.get(b_id)
.ok_or_else(|| Error::Store(format!("node {b_id} missing from hierarchy")))?
.clone();
if a.parent_id != b.parent_id {
return Err(Error::Store("can only swap siblings".into()));
}
let a_old_rel = hierarchy.fs_path(&a, &self.layout);
let b_old_rel = hierarchy.fs_path(&b, &self.layout);
let a_old_abs = self.layout.root.join(&a_old_rel);
let b_old_abs = self.layout.root.join(&b_old_rel);
let mut a_new = a.clone();
a_new.order = b.order;
let mut b_new = b.clone();
b_new.order = a.order;
let a_new_name = a_new.fs_name();
let b_new_name = b_new.fs_name();
let parent_dir = a_old_abs
.parent()
.ok_or_else(|| Error::Store("filesystem entry has no parent directory".into()))?;
let a_new_abs = parent_dir.join(&a_new_name);
let b_new_abs = parent_dir.join(&b_new_name);
let tmp_a = parent_dir.join(format!(".inkhaven-mv-a-{}", a.id.as_simple()));
let tmp_b = parent_dir.join(format!(".inkhaven-mv-b-{}", b.id.as_simple()));
std::fs::rename(&a_old_abs, &tmp_a)?;
if let Err(e) = std::fs::rename(&b_old_abs, &tmp_b) {
let _ = std::fs::rename(&tmp_a, &a_old_abs);
return Err(Error::Io(e));
}
if let Err(e) = std::fs::rename(&tmp_a, &a_new_abs) {
let _ = std::fs::rename(&tmp_b, &b_old_abs);
return Err(Error::Io(e));
}
std::fs::rename(&tmp_b, &b_new_abs)?;
let a_new_rel = a_new_abs
.strip_prefix(&self.layout.root)
.unwrap_or(&a_new_abs)
.to_string_lossy()
.into_owned();
let b_new_rel = b_new_abs
.strip_prefix(&self.layout.root)
.unwrap_or(&b_new_abs)
.to_string_lossy()
.into_owned();
if a_new.file.is_some() {
a_new.file = Some(a_new_rel.clone());
}
if b_new.file.is_some() {
b_new.file = Some(b_new_rel.clone());
}
self.inner
.update_metadata(a_new.id, a_new.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
self.inner
.update_metadata(b_new.id, b_new.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
if a.kind != NK::Paragraph {
self.rewrite_descendant_files(hierarchy, &a, &a_old_rel, &a_new_rel)?;
}
if b.kind != NK::Paragraph {
self.rewrite_descendant_files(hierarchy, &b, &b_old_rel, &b_new_rel)?;
}
self.sync()?;
Ok(())
}
fn rewrite_descendant_files(
&self,
hierarchy: &Hierarchy,
moved: &Node,
old_rel: &std::path::Path,
new_rel: &str,
) -> Result<()> {
let old_prefix = old_rel.to_string_lossy().into_owned();
for descendant_id in hierarchy.collect_subtree(moved.id) {
if descendant_id == moved.id {
continue;
}
let Some(descendant) = hierarchy.get(descendant_id) else {
continue;
};
if descendant.kind != NK::Paragraph {
continue;
}
let Some(old_file) = descendant.file.as_ref() else {
continue;
};
if let Some(rest) = old_file.strip_prefix(&old_prefix) {
let new_file = format!("{new_rel}{rest}");
let mut updated = descendant.clone();
updated.file = Some(new_file);
self.inner
.update_metadata(updated.id, updated.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
}
}
Ok(())
}
pub fn create_snapshot(&self, parent: &Node, content: &[u8]) -> Result<Uuid> {
self.create_snapshot_annotated(parent, content, "")
}
pub fn create_snapshot_annotated(
&self,
parent: &Node,
content: &[u8],
annotation: &str,
) -> Result<Uuid> {
let preview = first_prose_line(content);
let word_count = std::str::from_utf8(content)
.map(|s| s.split_whitespace().count() as u64)
.unwrap_or(0);
let meta = serde_json::json!({
"kind": "snapshot",
"parent_id": parent.id.to_string(),
"parent_title": parent.title,
"created_at": chrono::Utc::now().to_rfc3339(),
"word_count": word_count,
"preview": preview,
"annotation": annotation,
});
let id = self
.inner
.add_document_no_embed(meta, content)
.map_err(|e| Error::Store(format!("create_snapshot: {e}")))?;
self.sync()?;
fire_hook(
"hook.on_snapshot",
vec![
bund_string(&parent.id.to_string()),
bund_string(&id.to_string()),
],
);
Ok(id)
}
pub fn list_snapshots(&self, parent_id: Uuid) -> Result<Vec<Snapshot>> {
let pid = parent_id.to_string();
let raw = self
.inner
.list_metadata()
.map_err(|e| Error::Store(format!("list_metadata: {e}")))?;
let mut out: Vec<Snapshot> = raw
.into_iter()
.filter_map(|(id, meta)| {
let kind = meta.get("kind").and_then(|v| v.as_str())?;
if kind != "snapshot" {
return None;
}
let pid_in = meta.get("parent_id").and_then(|v| v.as_str())?;
if pid_in != pid {
return None;
}
let created_at = meta
.get("created_at")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(chrono::Utc::now);
let word_count = meta.get("word_count").and_then(|v| v.as_u64()).unwrap_or(0);
let preview = meta
.get("preview")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let annotation = meta
.get("annotation")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(Snapshot {
id,
parent_id,
created_at,
word_count,
preview,
annotation,
})
})
.collect();
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(out)
}
pub fn snapshot_content(&self, snapshot_id: Uuid) -> Result<Option<Vec<u8>>> {
self.inner
.get_content(snapshot_id)
.map_err(|e| Error::Store(format!("snapshot_content: {e}")))
}
pub fn image_bytes(&self, image_id: Uuid) -> Result<Option<Vec<u8>>> {
self.inner
.get_content(image_id)
.map_err(|e| Error::Store(format!("image_bytes: {e}")))
}
pub fn create_image_node(
&self,
cfg: &Config,
hierarchy: &Hierarchy,
title: &str,
ext: &str,
bytes: &[u8],
parent: Option<&Node>,
position: InsertPosition,
) -> Result<Node> {
let mut node = self.create_node(
cfg,
hierarchy,
NK::Image,
title,
parent,
None,
position,
)?;
node.image_ext = Some(ext.to_lowercase());
let abs = self.layout.root.join(
node.file.as_deref().unwrap_or(""),
);
std::fs::write(&abs, bytes).map_err(Error::Io)?;
let abs_typ = abs.clone();
let abs_image =
self.layout.root.join(
std::path::PathBuf::from(node.file.clone().unwrap_or_default())
.with_extension(&node.image_ext.clone().unwrap_or_default()),
);
if abs_typ != abs_image && abs_typ.exists() {
let _ = std::fs::rename(&abs_typ, &abs_image);
}
if let Some(rel) = node.file.as_ref() {
let rel_image =
std::path::PathBuf::from(rel).with_extension(&node.image_ext.clone().unwrap_or_default());
node.file = Some(rel_image.to_string_lossy().into_owned());
}
self.inner
.update_content(node.id, bytes)
.map_err(|e| Error::Store(format!("update_content (image): {e}")))?;
self.inner
.update_metadata(node.id, node.to_json())
.map_err(|e| Error::Store(format!("update_metadata (image): {e}")))?;
self.sync()?;
Ok(node)
}
pub fn delete_snapshot(&self, snapshot_id: Uuid) -> Result<()> {
self.inner
.delete_document(snapshot_id)
.map_err(|e| Error::Store(format!("delete_snapshot {snapshot_id}: {e}")))?;
self.sync()?;
Ok(())
}
pub fn delete_subtree(&self, fs_rel: &Path, ids: &[Uuid]) -> Result<()> {
let abs = self.layout.root.join(fs_rel);
if abs.is_dir() {
std::fs::remove_dir_all(&abs)?;
} else if abs.is_file() {
std::fs::remove_file(&abs)?;
}
for id in ids {
if let Err(e) = self.inner.delete_document(*id) {
tracing::warn!(uuid = %id, "delete_document failed: {e}");
}
}
self.sync()?;
let deleted: std::collections::HashSet<Uuid> = ids.iter().copied().collect();
let scrubbed = self.scrub_linked_paragraphs(&deleted);
if scrubbed > 0 {
tracing::info!(
target: "inkhaven::delete",
"delete_subtree: scrubbed paragraph links from {scrubbed} other paragraph(s)",
);
}
for id in ids {
fire_hook("hook.on_delete", vec![bund_string(&id.to_string())]);
}
Ok(())
}
fn scrub_linked_paragraphs(
&self,
deleted: &std::collections::HashSet<Uuid>,
) -> usize {
let hierarchy = match crate::store::hierarchy::Hierarchy::load(self) {
Ok(h) => h,
Err(e) => {
tracing::warn!(
target: "inkhaven::delete",
"scrub: hierarchy reload failed: {e}",
);
return 0;
}
};
let mut touched = 0usize;
for (n, _) in hierarchy.flatten() {
let para_hit = n
.linked_paragraphs
.iter()
.any(|id| deleted.contains(id));
let event_hit = n.event.as_ref().is_some_and(|e| {
e.characters.iter().any(|id| deleted.contains(id))
|| e.places.iter().any(|id| deleted.contains(id))
});
if !para_hit && !event_hit {
continue;
}
let mut updated = n.clone();
if para_hit {
updated.linked_paragraphs.retain(|id| !deleted.contains(id));
}
if event_hit {
if let Some(ev) = updated.event.as_mut() {
ev.characters.retain(|id| !deleted.contains(id));
ev.places.retain(|id| !deleted.contains(id));
}
}
updated.modified_at = chrono::Utc::now();
reconcile_event_orphan_tag(&mut updated);
if let Err(e) = self.inner.update_metadata(updated.id, updated.to_json()) {
tracing::warn!(
target: "inkhaven::delete",
uuid = %updated.id,
"scrub: update_metadata failed: {e}",
);
continue;
}
touched += 1;
}
touched
}
pub fn convert_leaf(
&self,
hierarchy: &Hierarchy,
node_id: Uuid,
new_kind: NodeKind,
new_content_type: Option<&str>,
) -> Result<Node> {
let node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| Error::Store(format!("convert_leaf: missing {node_id}")))?;
if !matches!(node.kind, NodeKind::Paragraph | NodeKind::Script) {
return Err(Error::Store(format!(
"convert_leaf: can't convert a {} (only paragraph / script)",
node.kind.as_str()
)));
}
if !matches!(new_kind, NodeKind::Paragraph | NodeKind::Script) {
return Err(Error::Store(format!(
"convert_leaf: new kind {} is not a text leaf",
new_kind.as_str()
)));
}
match (new_kind, new_content_type) {
(NodeKind::Paragraph, None | Some("typst") | Some("hjson")) => {}
(NodeKind::Script, Some("bund")) => {}
(k, ct) => {
return Err(Error::Store(format!(
"convert_leaf: content_type {ct:?} not valid for {}",
k.as_str()
)));
}
}
let Some(old_rel) = node.file.clone() else {
return Err(Error::Store(
"convert_leaf: node has no file on disk".into(),
));
};
let old_abs = self.layout.root.join(&old_rel);
let mut new_node = node.clone();
new_node.kind = new_kind;
new_node.content_type = new_content_type.map(str::to_string);
if new_node.content_type.as_deref() == Some("typst") {
new_node.content_type = None;
}
new_node.modified_at = chrono::Utc::now();
let new_name = new_node.fs_name();
let parent_dir = old_abs
.parent()
.ok_or_else(|| Error::Store("convert_leaf: no parent directory".into()))?;
let new_abs = parent_dir.join(&new_name);
if new_abs != old_abs {
if new_abs.exists() {
return Err(Error::Store(format!(
"convert_leaf: target `{}` already exists",
new_abs.display()
)));
}
std::fs::rename(&old_abs, &new_abs)?;
let new_rel = new_abs
.strip_prefix(&self.layout.root)
.unwrap_or(&new_abs)
.to_string_lossy()
.into_owned();
new_node.file = Some(new_rel);
}
self.inner
.update_metadata(new_node.id, new_node.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
self.sync()?;
Ok(new_node)
}
pub fn update_paragraph_content(&self, node: &mut Node, content: &[u8]) -> Result<()> {
let id = node.id;
node.word_count = std::str::from_utf8(content)
.map(|s| s.split_whitespace().count() as u64)
.unwrap_or(0);
node.modified_at = chrono::Utc::now();
self.inner
.update_content(id, content)
.map_err(|e| Error::Store(format!("update_content: {e}")))?;
self.inner
.update_metadata(id, node.to_json())
.map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
self.inner
.reembed_document(id)
.map_err(|e| Error::Store(format!("reembed_document: {e}")))?;
fire_hook("hook.on_save", vec![bund_string(&id.to_string())]);
Ok(())
}
}
fn fire_hook(name: &str, args: Vec<rust_dynamic::value::Value>) {
crate::scripting::hooks::fire(name, args);
}
fn bund_string(s: &str) -> rust_dynamic::value::Value {
rust_dynamic::value::Value::from_string(s)
}
pub(crate) fn reconcile_event_orphan_tag(node: &mut Node) {
let Some(ev) = node.event.as_ref() else {
return;
};
let is_orphan = ev.is_orphan(&node.linked_paragraphs);
let pos = node.tags.iter().position(|t| t.eq_ignore_ascii_case("orphan"));
match (is_orphan, pos) {
(true, None) => {
node.tags.push("orphan".to_owned());
fire_hook(
"hook.on_event_orphaned",
vec![bund_string(&node.id.to_string())],
);
}
(false, Some(i)) => {
node.tags.remove(i);
}
_ => {}
}
}
fn first_prose_line(content: &[u8]) -> String {
let s = std::str::from_utf8(content).unwrap_or("");
for line in s.lines() {
let t = line.trim();
if t.is_empty() || t.starts_with('=') || t.starts_with("//") {
continue;
}
let chars: Vec<char> = t.chars().collect();
if chars.len() > 80 {
let mut o: String = chars.iter().take(79).collect();
o.push('…');
return o;
}
return t.to_string();
}
String::new()
}
pub(crate) fn perf_trace_enabled() -> bool {
std::env::var("INKHAVEN_PERF_TRACE")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
}
pub(crate) fn perf_mark(on: bool, label: &str, elapsed: std::time::Duration) {
if on {
eprintln!("[perf] {label} {:.2}ms", elapsed.as_secs_f64() * 1000.0);
}
}
fn build_embedding_engine(model_name: &str) -> Result<EmbeddingEngine> {
let model = match model_name {
"MultilingualE5Small" => Model::MultilingualE5Small,
"MultilingualE5Base" => Model::MultilingualE5Base,
"MultilingualE5Large" => Model::MultilingualE5Large,
"BGEM3" => Model::BGEM3,
"BGESmallENV15" => Model::BGESmallENV15,
"BGEBaseENV15" => Model::BGEBaseENV15,
"BGELargeENV15" => Model::BGELargeENV15,
other => {
return Err(Error::Config(format!(
"unknown embedding model `{other}`; see fastembed::EmbeddingModel for options"
)));
}
};
EmbeddingEngine::new(model, embedding_cache_dir())
.map_err(|e| Error::Store(e.to_string()))
}
fn embedding_cache_dir() -> Option<std::path::PathBuf> {
let dirs = directories::ProjectDirs::from("dev", "inkhaven", "inkhaven")?;
let path = dirs.cache_dir().join("embeddings");
let _ = std::fs::create_dir_all(&path);
Some(path)
}
pub fn default_user_backup_dir(project_root: &std::path::Path) -> std::path::PathBuf {
let project_id = project_basename(project_root);
project_root
.parent()
.unwrap_or(std::path::Path::new("."))
.join("inkhaven-backups")
.join(project_id)
}
pub fn default_user_artefacts_dir(project_root: &std::path::Path) -> std::path::PathBuf {
let project_id = project_basename(project_root);
project_root
.parent()
.unwrap_or(std::path::Path::new("."))
.join("inkhaven-artefacts")
.join(project_id)
}
fn project_basename(project_root: &std::path::Path) -> String {
project_root
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "default".into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::node::EventData;
fn make_event_node() -> Node {
Node {
id: Uuid::nil(),
kind: NK::Paragraph,
title: "Storm".into(),
slug: "storm".into(),
path: Vec::new(),
parent_id: None,
order: 0,
file: None,
word_count: 0,
modified_at: chrono::Utc::now(),
protected: false,
system_tag: None,
image_ext: None,
image_caption: None,
image_alt: None,
content_type: None,
status: None,
target_words: None,
target_hit_at_status: None,
linked_paragraphs: Vec::new(),
bookmark: false,
tags: Vec::new(),
ai_memory: Vec::new(),
event: Some(EventData {
start_ticks: 0,
end_ticks: None,
precision: crate::timeline::Precision::Day,
characters: Vec::new(),
places: Vec::new(),
track: None,
}),
}
}
#[test]
fn orphan_tag_added_when_all_links_empty() {
let mut n = make_event_node();
reconcile_event_orphan_tag(&mut n);
assert!(n.tags.iter().any(|t| t == "orphan"));
}
#[test]
fn orphan_tag_removed_when_link_added() {
let mut n = make_event_node();
n.tags.push("orphan".into());
n.linked_paragraphs.push(Uuid::new_v4());
reconcile_event_orphan_tag(&mut n);
assert!(!n.tags.iter().any(|t| t == "orphan"));
}
#[test]
fn orphan_tag_noop_on_non_event_node() {
let mut n = make_event_node();
n.event = None;
n.tags.push("orphan".into());
reconcile_event_orphan_tag(&mut n);
assert!(n.tags.iter().any(|t| t == "orphan"));
}
#[test]
fn orphan_tag_kept_when_event_links_present_but_paragraphs_empty() {
let mut n = make_event_node();
if let Some(ev) = n.event.as_mut() {
ev.characters.push(Uuid::new_v4());
}
reconcile_event_orphan_tag(&mut n);
assert!(!n.tags.iter().any(|t| t == "orphan"));
}
}