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"),
("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_TYPST: &str = "typst";
pub const SYSTEM_TAG_SCRIPTS: &str = "scripts";
pub const SYSTEM_TAG_HELP: &str = "help";
#[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,
}
#[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 engine = build_embedding_engine(&cfg.embeddings.model)?;
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
))
})?;
let store = Self {
inner,
layout: Arc::new(layout),
};
store.ensure_system_books(cfg)?;
store.ensure_artefacts_directory(cfg)?;
crate::scripting::configure(cfg.scripting.clone(), store.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);
if p.is_absolute() {
p
} else {
self.layout.root.join(p)
}
}
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 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,
};
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 content: Vec<u8> = match kind {
NK::Paragraph => {
if let Some(parent_dir) = abs_path.parent() {
std::fs::create_dir_all(parent_dir)?;
}
let template = 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();
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> {
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,
});
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();
Some(Snapshot {
id,
parent_id,
created_at,
word_count,
preview,
})
})
.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()?;
for id in ids {
fire_hook("hook.on_delete", vec![bund_string(&id.to_string())]);
}
Ok(())
}
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)
}
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()
}
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())
}