use anyhow::{anyhow, Result};
use rust_dynamic::value::Value;
use rust_multistackvm::multistackvm::VM;
use std::collections::HashMap;
use super::helpers::{
active_store, pull, push, require_depth, value_to_i64, value_to_string, value_to_uuid,
};
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
pub fn register(vm: &mut VM) -> Result<()> {
vm.register_inline("ink.node.list".to_string(), ink_node_list)
.map_err(|e| anyhow!("register ink.node.list: {e}"))?;
vm.register_inline("ink.node.get".to_string(), ink_node_get)
.map_err(|e| anyhow!("register ink.node.get: {e}"))?;
vm.register_inline("ink.node.children".to_string(), ink_node_children)
.map_err(|e| anyhow!("register ink.node.children: {e}"))?;
vm.register_inline("ink.paragraph.text".to_string(), ink_paragraph_text)
.map_err(|e| anyhow!("register ink.paragraph.text: {e}"))?;
vm.register_inline("ink.search.text".to_string(), ink_search_text)
.map_err(|e| anyhow!("register ink.search.text: {e}"))?;
vm.register_inline("ink.snapshot.list".to_string(), ink_snapshot_list)
.map_err(|e| anyhow!("register ink.snapshot.list: {e}"))?;
vm.register_inline("ink.path.to_uuid".to_string(), ink_path_to_uuid)
.map_err(|e| anyhow!("register ink.path.to_uuid: {e}"))?;
vm.register_inline("ink.tree.add".to_string(), ink_tree_add)
.map_err(|e| anyhow!("register ink.tree.add: {e}"))?;
vm.register_inline("ink.tree.delete".to_string(), ink_tree_delete)
.map_err(|e| anyhow!("register ink.tree.delete: {e}"))?;
vm.register_inline("ink.tree.rename".to_string(), ink_tree_rename)
.map_err(|e| anyhow!("register ink.tree.rename: {e}"))?;
vm.register_inline("ink.tree.move_up".to_string(), ink_tree_move_up)
.map_err(|e| anyhow!("register ink.tree.move_up: {e}"))?;
vm.register_inline("ink.tree.move_down".to_string(), ink_tree_move_down)
.map_err(|e| anyhow!("register ink.tree.move_down: {e}"))?;
vm.register_inline("ink.tree.morph".to_string(), ink_tree_morph)
.map_err(|e| anyhow!("register ink.tree.morph: {e}"))?;
vm.register_inline("ink.paragraph.set_status".to_string(), ink_paragraph_set_status)
.map_err(|e| anyhow!("register ink.paragraph.set_status: {e}"))?;
vm.register_inline("ink.paragraph.set_target".to_string(), ink_paragraph_set_target)
.map_err(|e| anyhow!("register ink.paragraph.set_target: {e}"))?;
vm.register_inline("ink.paragraph.target".to_string(), ink_paragraph_target)
.map_err(|e| anyhow!("register ink.paragraph.target: {e}"))?;
vm.register_inline("ink.paragraph.save".to_string(), ink_paragraph_save)
.map_err(|e| anyhow!("register ink.paragraph.save: {e}"))?;
vm.register_inline("ink.event.list".to_string(), ink_event_list)
.map_err(|e| anyhow!("register ink.event.list: {e}"))?;
vm.register_inline("ink.event.list_orphans".to_string(), ink_event_list_orphans)
.map_err(|e| anyhow!("register ink.event.list_orphans: {e}"))?;
vm.register_inline("ink.event.add".to_string(), ink_event_add)
.map_err(|e| anyhow!("register ink.event.add: {e}"))?;
vm.register_inline("ink.event.set_end".to_string(), ink_event_set_end)
.map_err(|e| anyhow!("register ink.event.set_end: {e}"))?;
vm.register_inline("ink.event.set_precision".to_string(), ink_event_set_precision)
.map_err(|e| anyhow!("register ink.event.set_precision: {e}"))?;
vm.register_inline("ink.event.set_track".to_string(), ink_event_set_track)
.map_err(|e| anyhow!("register ink.event.set_track: {e}"))?;
vm.register_inline("ink.event.link_paragraph".to_string(), ink_event_link_paragraph)
.map_err(|e| anyhow!("register ink.event.link_paragraph: {e}"))?;
vm.register_inline("ink.thread.list".to_string(), ink_thread_list)
.map_err(|e| anyhow!("register ink.thread.list: {e}"))?;
vm.register_inline("ink.tag.list".to_string(), ink_tag_list)
.map_err(|e| anyhow!("register ink.tag.list: {e}"))?;
vm.register_inline("ink.tag.list_for".to_string(), ink_tag_list_for)
.map_err(|e| anyhow!("register ink.tag.list_for: {e}"))?;
vm.register_inline("ink.tag.search".to_string(), ink_tag_search)
.map_err(|e| anyhow!("register ink.tag.search: {e}"))?;
vm.register_inline("ink.tag.add".to_string(), ink_tag_add)
.map_err(|e| anyhow!("register ink.tag.add: {e}"))?;
vm.register_inline("ink.tag.remove".to_string(), ink_tag_remove)
.map_err(|e| anyhow!("register ink.tag.remove: {e}"))?;
vm.register_inline("ink.db.sync".to_string(), ink_db_sync)
.map_err(|e| anyhow!("register ink.db.sync: {e}"))?;
vm.register_inline("ink.db.checkpoint".to_string(), ink_db_checkpoint)
.map_err(|e| anyhow!("register ink.db.checkpoint: {e}"))?;
vm.register_inline("ink.db.reindex".to_string(), ink_db_reindex)
.map_err(|e| anyhow!("register ink.db.reindex: {e}"))?;
Ok(())
}
type BundError = easy_error::Error;
type BundResult<'a> = std::result::Result<&'a mut VM, BundError>;
fn to_bund_err(e: anyhow::Error) -> BundError {
easy_error::err_msg(e.to_string())
}
fn ink_node_list(vm: &mut VM) -> BundResult<'_> {
do_ink_node_list(vm).map_err(to_bund_err)
}
fn do_ink_node_list(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.node.list";
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy load: {e}"))?;
let items: Vec<Value> = hierarchy.iter().map(node_summary_dict).collect();
push(vm, Value::from_list(items));
Ok(vm)
}
fn ink_node_get(vm: &mut VM) -> BundResult<'_> {
do_ink_node_get(vm).map_err(to_bund_err)
}
fn do_ink_node_get(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.node.get";
require_depth(vm, 1, tag)?;
let id = value_to_uuid(pull(vm, tag)?, tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy load: {e}"))?;
let out = match hierarchy.get(id) {
Some(node) => node_full_dict(node),
None => Value::nodata(),
};
push(vm, out);
Ok(vm)
}
fn ink_node_children(vm: &mut VM) -> BundResult<'_> {
do_ink_node_children(vm).map_err(to_bund_err)
}
fn do_ink_node_children(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.node.children";
require_depth(vm, 1, tag)?;
let arg = pull(vm, tag)?;
let s = value_to_string(arg, "parent", tag)?;
let parent_id = if s.is_empty() {
None
} else {
Some(uuid::Uuid::parse_str(&s).map_err(|e| anyhow!("{tag} UUID parse failed: {e}"))?)
};
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy load: {e}"))?;
let items: Vec<Value> = hierarchy
.children_of(parent_id)
.into_iter()
.map(node_summary_dict)
.collect();
push(vm, Value::from_list(items));
Ok(vm)
}
fn ink_paragraph_text(vm: &mut VM) -> BundResult<'_> {
do_ink_paragraph_text(vm).map_err(to_bund_err)
}
fn do_ink_paragraph_text(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.paragraph.text";
require_depth(vm, 1, tag)?;
let id = value_to_uuid(pull(vm, tag)?, tag)?;
let store = active_store(tag)?;
let out = match store
.get_content(id)
.map_err(|e| anyhow!("{tag} get_content: {e}"))?
{
Some(bytes) => Value::from_string(String::from_utf8_lossy(&bytes).into_owned()),
None => Value::nodata(),
};
push(vm, out);
Ok(vm)
}
fn ink_search_text(vm: &mut VM) -> BundResult<'_> {
do_ink_search_text(vm).map_err(to_bund_err)
}
fn do_ink_search_text(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.search.text";
require_depth(vm, 2, tag)?;
let limit_v = pull(vm, tag)?;
let query_v = pull(vm, tag)?;
let query = value_to_string(query_v, "query", tag)?;
let limit = value_to_i64(limit_v, "limit", tag)?.max(0) as usize;
let store = active_store(tag)?;
let hits = store
.search_text(&query, limit)
.map_err(|e| anyhow!("{tag} search_text: {e}"))?;
let items: Vec<Value> = hits.into_iter().map(search_hit_dict).collect();
push(vm, Value::from_list(items));
Ok(vm)
}
fn ink_snapshot_list(vm: &mut VM) -> BundResult<'_> {
do_ink_snapshot_list(vm).map_err(to_bund_err)
}
fn do_ink_snapshot_list(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.snapshot.list";
require_depth(vm, 1, tag)?;
let id = value_to_uuid(pull(vm, tag)?, tag)?;
let store = active_store(tag)?;
let snaps = store
.list_snapshots(id)
.map_err(|e| anyhow!("{tag} list_snapshots: {e}"))?;
let items: Vec<Value> = snaps
.into_iter()
.map(|s| {
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("id".into(), Value::from_string(s.id.to_string()));
h.insert(
"created_at".into(),
Value::from_string(s.created_at.to_rfc3339()),
);
h.insert("word_count".into(), Value::from_int(s.word_count as i64));
h.insert("preview".into(), Value::from_string(s.preview));
Value::from_dict(h)
})
.collect();
push(vm, Value::from_list(items));
Ok(vm)
}
fn node_summary_dict(n: &Node) -> Value {
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("id".into(), Value::from_string(n.id.to_string()));
h.insert("kind".into(), Value::from_string(n.kind.as_str()));
h.insert("title".into(), Value::from_string(&n.title));
h.insert("slug".into(), Value::from_string(&n.slug));
Value::from_dict(h)
}
fn node_full_dict(n: &Node) -> Value {
let mut h: HashMap<String, Value> = HashMap::new();
h.insert("id".into(), Value::from_string(n.id.to_string()));
h.insert("kind".into(), Value::from_string(n.kind.as_str()));
h.insert("title".into(), Value::from_string(&n.title));
h.insert("slug".into(), Value::from_string(&n.slug));
h.insert("order".into(), Value::from_int(n.order as i64));
h.insert(
"word_count".into(),
Value::from_int(n.word_count as i64),
);
h.insert(
"modified_at".into(),
Value::from_string(n.modified_at.to_rfc3339()),
);
h.insert(
"parent_id".into(),
match n.parent_id {
Some(p) => Value::from_string(p.to_string()),
None => Value::nodata(),
},
);
h.insert(
"system_tag".into(),
match &n.system_tag {
Some(s) => Value::from_string(s),
None => Value::nodata(),
},
);
h.insert(
"status".into(),
match &n.status {
Some(s) => Value::from_string(s),
None => Value::nodata(),
},
);
h.insert(
"content_type".into(),
match &n.content_type {
Some(s) => Value::from_string(s),
None => Value::nodata(),
},
);
Value::from_dict(h)
}
fn search_hit_dict(hit: serde_json::Value) -> Value {
let mut h: HashMap<String, Value> = HashMap::new();
if let Some(id) = hit.get("id").and_then(|v| v.as_str()) {
h.insert("id".into(), Value::from_string(id));
}
if let Some(score) = hit.get("score").and_then(|v| v.as_f64()) {
h.insert("score".into(), Value::from_float(score));
}
if let Some(meta) = hit.get("metadata") {
if let Some(title) = meta.get("title").and_then(|v| v.as_str()) {
h.insert("title".into(), Value::from_string(title));
}
if let Some(kind) = meta.get("kind").and_then(|v| v.as_str()) {
h.insert("kind".into(), Value::from_string(kind));
}
}
if let Some(doc) = hit.get("document").and_then(|v| v.as_str()) {
h.insert("document".into(), Value::from_string(doc));
}
Value::from_dict(h)
}
use crate::scripting::stdlib::helpers::{active_config, resolve_path};
use crate::store::InsertPosition;
fn ink_path_to_uuid(vm: &mut VM) -> BundResult<'_> {
do_ink_path_to_uuid(vm).map_err(to_bund_err)
}
fn do_ink_path_to_uuid(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.path.to_uuid";
require_depth(vm, 1, tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let out = match resolve_path(&hierarchy, &path, tag)? {
Some(id) => Value::from_string(id.to_string()),
None => Value::nodata(),
};
push(vm, out);
Ok(vm)
}
fn ink_tree_add(vm: &mut VM) -> BundResult<'_> {
do_ink_tree_add(vm).map_err(to_bund_err)
}
fn do_ink_tree_add(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tree.add";
require_depth(vm, 3, tag)?;
let title = value_to_string(pull(vm, tag)?, "title", tag)?;
let kind_str = value_to_string(pull(vm, tag)?, "kind", tag)?;
let parent_path = value_to_string(pull(vm, tag)?, "parent_path", tag)?;
let (kind, post_morph_ct) = match kind_str.as_str() {
"book" => (NodeKind::Book, None),
"chapter" => (NodeKind::Chapter, None),
"subchapter" => (NodeKind::Subchapter, None),
"paragraph" => (NodeKind::Paragraph, None),
"hjson" => (NodeKind::Paragraph, Some("hjson")),
"script" | "bund" => (NodeKind::Script, None),
other => return Err(anyhow!("{tag}: unknown kind `{other}`")),
};
let store = active_store(tag)?;
let cfg = active_config(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let parent_id = resolve_path(&hierarchy, &parent_path, tag)?;
let parent_node: Option<Node> = parent_id.and_then(|id| hierarchy.get(id).cloned());
let created = store
.create_node(
cfg,
&hierarchy,
kind,
&title,
parent_node.as_ref(),
None,
InsertPosition::End,
)
.map_err(|e| anyhow!("{tag} create: {e}"))?;
let final_id = if let Some(ct) = post_morph_ct {
let h2 = Hierarchy::load(store).map_err(|e| anyhow!("{tag} reload: {e}"))?;
let morphed = store
.convert_leaf(&h2, created.id, NodeKind::Paragraph, Some(ct))
.map_err(|e| anyhow!("{tag} morph: {e}"))?;
morphed.id
} else {
created.id
};
push(vm, Value::from_string(final_id.to_string()));
Ok(vm)
}
fn ink_tree_delete(vm: &mut VM) -> BundResult<'_> {
do_ink_tree_delete(vm).map_err(to_bund_err)
}
fn do_ink_tree_delete(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tree.delete";
require_depth(vm, 1, tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: cannot delete root"))?;
let node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node {node_id} vanished"))?;
let ids: Vec<uuid::Uuid> = hierarchy.collect_subtree(node.id).into_iter().collect();
let layout = crate::project::ProjectLayout::new(store.project_root());
let fs_rel = hierarchy.fs_path(&node, &layout);
store
.delete_subtree(&fs_rel, &ids)
.map_err(|e| anyhow!("{tag} delete: {e}"))?;
Ok(vm)
}
fn ink_tree_rename(vm: &mut VM) -> BundResult<'_> {
do_ink_tree_rename(vm).map_err(to_bund_err)
}
fn do_ink_tree_rename(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tree.rename";
require_depth(vm, 2, tag)?;
let new_title = value_to_string(pull(vm, tag)?, "new_title", tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: cannot rename root"))?;
store
.rename_node(&hierarchy, node_id, &new_title)
.map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn ink_tree_move_up(vm: &mut VM) -> BundResult<'_> {
do_ink_tree_move(vm, MoveDir::Up).map_err(to_bund_err)
}
fn ink_tree_move_down(vm: &mut VM) -> BundResult<'_> {
do_ink_tree_move(vm, MoveDir::Down).map_err(to_bund_err)
}
#[derive(Clone, Copy)]
enum MoveDir {
Up,
Down,
}
fn do_ink_tree_move(vm: &mut VM, dir: MoveDir) -> Result<&mut VM> {
let tag = match dir {
MoveDir::Up => "ink.tree.move_up",
MoveDir::Down => "ink.tree.move_down",
};
require_depth(vm, 1, tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: cannot move root"))?;
let node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node missing"))?;
let siblings = hierarchy.children_of(node.parent_id);
let pos = siblings
.iter()
.position(|n| n.id == node.id)
.ok_or_else(|| anyhow!("{tag}: node not among siblings"))?;
let neighbour_idx = match dir {
MoveDir::Up => pos.checked_sub(1),
MoveDir::Down => {
if pos + 1 < siblings.len() {
Some(pos + 1)
} else {
None
}
}
};
let Some(idx) = neighbour_idx else {
return Ok(vm);
};
let neighbour_id = siblings[idx].id;
store
.swap_siblings(&hierarchy, node.id, neighbour_id)
.map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn ink_tree_morph(vm: &mut VM) -> BundResult<'_> {
do_ink_tree_morph(vm).map_err(to_bund_err)
}
fn do_ink_tree_morph(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tree.morph";
require_depth(vm, 1, tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: cannot morph root"))?;
let node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node missing"))?;
let (new_kind, new_ct) = match (node.kind, node.content_type.as_deref()) {
(NodeKind::Paragraph, None | Some("typst")) => {
(NodeKind::Paragraph, Some("hjson"))
}
(NodeKind::Paragraph, Some("hjson")) => (NodeKind::Script, Some("bund")),
(NodeKind::Script, _) => (NodeKind::Paragraph, None),
(k, ct) => {
return Err(anyhow!(
"{tag}: {} ({ct:?}) is not a text leaf",
k.as_str()
));
}
};
store
.convert_leaf(&hierarchy, node_id, new_kind, new_ct)
.map_err(|e| anyhow!("{tag}: {e}"))?;
Ok(vm)
}
fn ink_paragraph_set_status(vm: &mut VM) -> BundResult<'_> {
do_ink_paragraph_set_status(vm).map_err(to_bund_err)
}
fn do_ink_paragraph_set_status(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.paragraph.set_status";
require_depth(vm, 2, tag)?;
let status = value_to_string(pull(vm, tag)?, "status", tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
if !matches!(
status.as_str(),
"None" | "Napkin" | "First" | "Second" | "Third" | "Final" | "Ready"
) {
return Err(anyhow!(
"{tag}: unknown status `{status}`. Use None / Napkin / First / Second / Third / Final / Ready."
));
}
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: empty path"))?;
let mut node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node missing"))?;
node.status = if status == "None" {
None
} else {
Some(status)
};
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
Ok(vm)
}
fn ink_paragraph_save(vm: &mut VM) -> BundResult<'_> {
do_ink_paragraph_save(vm).map_err(to_bund_err)
}
fn do_ink_paragraph_save(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.paragraph.save";
require_depth(vm, 2, tag)?;
let body = value_to_string(pull(vm, tag)?, "body", tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: empty path"))?;
let mut node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node missing"))?;
if !matches!(node.kind, NodeKind::Paragraph | NodeKind::Script) {
return Err(anyhow!(
"{tag}: {} is not a text leaf — only paragraphs / scripts are saveable",
node.kind.as_str()
));
}
if let Some(rel) = node.file.clone() {
let abs = store.project_root().join(rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow!("{tag} mkdir: {e}"))?;
}
std::fs::write(&abs, body.as_bytes())
.map_err(|e| anyhow!("{tag} write: {e}"))?;
}
store
.update_paragraph_content(&mut node, body.as_bytes())
.map_err(|e| anyhow!("{tag}: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
Ok(vm)
}
fn ink_db_sync(vm: &mut VM) -> BundResult<'_> {
do_ink_db_call(vm, "ink.db.sync", DbOp::Sync).map_err(to_bund_err)
}
fn ink_db_checkpoint(vm: &mut VM) -> BundResult<'_> {
do_ink_db_call(vm, "ink.db.checkpoint", DbOp::Checkpoint).map_err(to_bund_err)
}
fn ink_db_reindex(vm: &mut VM) -> BundResult<'_> {
do_ink_db_call(vm, "ink.db.reindex", DbOp::Reindex).map_err(to_bund_err)
}
enum DbOp {
Sync,
Checkpoint,
Reindex,
}
fn do_ink_db_call<'a>(vm: &'a mut VM, tag: &str, op: DbOp) -> Result<&'a mut VM> {
let store = active_store(tag)?;
match op {
DbOp::Sync => store.sync().map_err(|e| anyhow!("{tag}: {e}"))?,
DbOp::Checkpoint => store.checkpoint().map_err(|e| anyhow!("{tag}: {e}"))?,
DbOp::Reindex => {
let hierarchy = Hierarchy::load(store)
.map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let mut updated = 0usize;
for node in hierarchy.iter() {
if !matches!(node.kind, NodeKind::Paragraph | NodeKind::Script) {
continue;
}
let Some(rel) = node.file.as_ref() else {
continue;
};
let abs = store.project_root().join(rel);
if !abs.is_file() {
continue;
}
let bytes = std::fs::read(&abs).map_err(|e| anyhow!("{tag} read: {e}"))?;
let current = store
.get_content(node.id)
.map_err(|e| anyhow!("{tag} get: {e}"))?;
if current.as_deref() == Some(bytes.as_slice()) {
continue;
}
let mut n = node.clone();
store
.update_paragraph_content(&mut n, &bytes)
.map_err(|e| anyhow!("{tag} update: {e}"))?;
updated += 1;
}
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
push(vm, Value::from_int(updated as i64));
return Ok(vm);
}
}
Ok(vm)
}
fn ink_paragraph_set_target(vm: &mut VM) -> BundResult<'_> {
do_ink_paragraph_set_target(vm).map_err(to_bund_err)
}
fn do_ink_paragraph_set_target(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.paragraph.set_target";
require_depth(vm, 2, tag)?;
let target = value_to_i64(pull(vm, tag)?, "target", tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: empty path"))?;
let mut node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node missing"))?;
if node.kind != NodeKind::Paragraph {
return Err(anyhow!("{tag}: `{}` is not a paragraph", node.title));
}
if target <= 0 {
node.target_words = None;
node.target_hit_at_status = None;
} else {
node.target_words = Some(target.clamp(0, i32::MAX as i64) as i32);
}
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
Ok(vm)
}
fn ink_paragraph_target(vm: &mut VM) -> BundResult<'_> {
do_ink_paragraph_target(vm).map_err(to_bund_err)
}
fn do_ink_paragraph_target(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.paragraph.target";
require_depth(vm, 1, tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: empty path"))?;
let node = hierarchy
.get(node_id)
.ok_or_else(|| anyhow!("{tag}: node missing"))?;
match node.target_words {
Some(n) => push(vm, Value::from_int(n as i64)),
None => push(vm, Value::nodata()),
}
Ok(vm)
}
fn ink_tag_list(vm: &mut VM) -> BundResult<'_> {
do_ink_tag_list(vm).map_err(to_bund_err)
}
fn do_ink_tag_list(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tag.list";
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let mut seen: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for (n, _) in hierarchy.flatten() {
for t in &n.tags {
let key = t.to_ascii_lowercase();
seen.entry(key).or_insert_with(|| t.clone());
}
}
let items: Vec<Value> = seen
.into_values()
.map(Value::from_string)
.collect();
push(vm, Value::from_list(items));
Ok(vm)
}
fn ink_tag_list_for(vm: &mut VM) -> BundResult<'_> {
do_ink_tag_list_for(vm).map_err(to_bund_err)
}
fn do_ink_tag_list_for(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tag.list_for";
require_depth(vm, 1, tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let Some(id) = resolve_path(&hierarchy, &path, tag)? else {
push(vm, Value::nodata());
return Ok(vm);
};
let Some(node) = hierarchy.get(id) else {
push(vm, Value::nodata());
return Ok(vm);
};
let items: Vec<Value> = node
.tags
.iter()
.map(|t| Value::from_string(t.clone()))
.collect();
push(vm, Value::from_list(items));
Ok(vm)
}
fn ink_tag_search(vm: &mut VM) -> BundResult<'_> {
do_ink_tag_search(vm).map_err(to_bund_err)
}
fn do_ink_tag_search(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tag.search";
require_depth(vm, 1, tag)?;
let needle = value_to_string(pull(vm, tag)?, "tag", tag)?
.trim()
.to_ascii_lowercase();
if needle.is_empty() {
push(vm, Value::from_list(Vec::new()));
return Ok(vm);
}
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let mut hits: Vec<Value> = Vec::new();
for (n, _depth) in hierarchy.flatten() {
if !n.tags.iter().any(|t| t.to_ascii_lowercase() == needle) {
continue;
}
let mut parts = n.path.clone();
parts.push(n.slug.clone());
hits.push(Value::from_string(parts.join("/")));
}
push(vm, Value::from_list(hits));
Ok(vm)
}
fn ink_tag_add(vm: &mut VM) -> BundResult<'_> {
do_ink_tag_add(vm).map_err(to_bund_err)
}
fn do_ink_tag_add(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tag.add";
require_depth(vm, 2, tag)?;
let new_tag = value_to_string(pull(vm, tag)?, "tag", tag)?
.trim()
.to_owned();
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
if new_tag.is_empty() {
return Err(anyhow!("{tag}: empty tag"));
}
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: empty path"))?;
let mut node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node missing"))?;
if !node.tags.iter().any(|t| t == &new_tag) {
node.tags.push(new_tag);
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
}
Ok(vm)
}
fn ink_tag_remove(vm: &mut VM) -> BundResult<'_> {
do_ink_tag_remove(vm).map_err(to_bund_err)
}
fn do_ink_tag_remove(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.tag.remove";
require_depth(vm, 2, tag)?;
let drop_tag = value_to_string(pull(vm, tag)?, "tag", tag)?;
let path = value_to_string(pull(vm, tag)?, "path", tag)?;
if drop_tag.trim().is_empty() {
return Err(anyhow!("{tag}: empty tag"));
}
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let node_id = resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: empty path"))?;
let mut node = hierarchy
.get(node_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node missing"))?;
let before = node.tags.len();
node.tags.retain(|t| t != &drop_tag);
if node.tags.len() != before {
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
}
Ok(vm)
}
fn require_timeline_enabled(tag: &str) -> Result<&'static crate::config::Config> {
let cfg = crate::scripting::active_config()
.ok_or_else(|| anyhow!("{tag}: no active Config"))?;
if !cfg.timeline.enabled {
return Err(anyhow!(
"{tag}: requires `timeline.enabled: true` in inkhaven.hjson"
));
}
Ok(cfg)
}
fn calendar_for(tag: &str) -> Result<crate::timeline::Calendar> {
let cfg = require_timeline_enabled(tag)?;
Ok(crate::timeline::Calendar::from_config(
cfg.timeline.calendar.clone(),
))
}
fn event_dict(node: &Node) -> Value {
let ev = match node.event.as_ref() {
Some(e) => e,
None => return Value::nodata(),
};
let mut h = HashMap::new();
h.insert("id".to_string(), Value::from_string(node.id.to_string()));
h.insert("title".to_string(), Value::from_string(node.title.clone()));
h.insert("slug".to_string(), Value::from_string(node.slug.clone()));
h.insert(
"path".to_string(),
Value::from_string({
let mut parts = node.path.clone();
parts.push(node.slug.clone());
parts.join("/")
}),
);
h.insert("start_ticks".to_string(), Value::from_int(ev.start_ticks));
h.insert(
"end_ticks".to_string(),
match ev.end_ticks {
Some(t) => Value::from_int(t),
None => Value::nodata(),
},
);
h.insert(
"precision".to_string(),
Value::from_string(ev.precision.as_str().to_string()),
);
h.insert(
"track".to_string(),
match &ev.track {
Some(t) => Value::from_string(t.clone()),
None => Value::nodata(),
},
);
h.insert(
"is_orphan".to_string(),
Value::from_bool(node.tags.iter().any(|t| t.eq_ignore_ascii_case("orphan"))),
);
h.insert(
"linked_paragraphs".to_string(),
Value::from_list(
node.linked_paragraphs
.iter()
.map(|u| Value::from_string(u.to_string()))
.collect(),
),
);
h.insert(
"characters".to_string(),
Value::from_list(
ev.characters
.iter()
.map(|u| Value::from_string(u.to_string()))
.collect(),
),
);
h.insert(
"places".to_string(),
Value::from_list(
ev.places
.iter()
.map(|u| Value::from_string(u.to_string()))
.collect(),
),
);
Value::from_dict(h)
}
fn ink_event_list(vm: &mut VM) -> BundResult<'_> {
do_ink_event_list(vm).map_err(to_bund_err)
}
fn do_ink_event_list(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.event.list";
let _cfg = require_timeline_enabled(tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let mut items: Vec<(i64, Value)> = hierarchy
.iter()
.filter_map(|n| {
n.event.as_ref().map(|ev| (ev.start_ticks, event_dict(n)))
})
.collect();
items.sort_by_key(|(start, _)| *start);
let list: Vec<Value> = items.into_iter().map(|(_, v)| v).collect();
push(vm, Value::from_list(list));
Ok(vm)
}
fn ink_event_list_orphans(vm: &mut VM) -> BundResult<'_> {
do_ink_event_list_orphans(vm).map_err(to_bund_err)
}
fn do_ink_event_list_orphans(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.event.list_orphans";
let _cfg = require_timeline_enabled(tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let items: Vec<Value> = hierarchy
.iter()
.filter(|n| {
n.event.is_some()
&& n.tags.iter().any(|t| t.eq_ignore_ascii_case("orphan"))
})
.map(event_dict)
.collect();
push(vm, Value::from_list(items));
Ok(vm)
}
fn ink_thread_list(vm: &mut VM) -> BundResult<'_> {
do_ink_thread_list(vm).map_err(to_bund_err)
}
fn do_ink_thread_list(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.thread.list";
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let threads_root = match hierarchy.iter().find(|n| {
n.kind == crate::store::NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_THREADS)
}) {
Some(n) => n.clone(),
None => {
push(vm, Value::from_list(Vec::new()));
return Ok(vm);
}
};
let items: Vec<Value> = hierarchy
.children_of(Some(threads_root.id))
.into_iter()
.filter(|n| n.kind == crate::store::NodeKind::Chapter)
.map(|thread_chapter| {
let waypoint_count = hierarchy
.children_of(Some(thread_chapter.id))
.into_iter()
.filter(|n| n.kind == crate::store::NodeKind::Paragraph)
.count();
let mut h: HashMap<String, Value> = HashMap::new();
h.insert(
"id".into(),
Value::from_string(thread_chapter.id.to_string()),
);
h.insert("title".into(), Value::from_string(&thread_chapter.title));
h.insert("slug".into(), Value::from_string(&thread_chapter.slug));
h.insert(
"waypoint_count".into(),
Value::from_int(waypoint_count as i64),
);
Value::from_dict(h)
})
.collect();
push(vm, Value::from_list(items));
Ok(vm)
}
fn ink_event_add(vm: &mut VM) -> BundResult<'_> {
do_ink_event_add(vm).map_err(to_bund_err)
}
fn do_ink_event_add(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.event.add";
require_depth(vm, 3, tag)?;
let spec = value_to_string(pull(vm, tag)?, "spec", tag)?;
let title = value_to_string(pull(vm, tag)?, "title", tag)?;
let book_name = value_to_string(pull(vm, tag)?, "book-name", tag)?;
if title.trim().is_empty() {
return Err(anyhow!("{tag}: empty title"));
}
let cfg = require_timeline_enabled(tag)?;
let calendar = calendar_for(tag)?;
let (start, prec) = calendar
.parse(&spec)
.map_err(|e| anyhow!("{tag}: {e}"))?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let needle = book_name.trim().to_ascii_lowercase();
let book = hierarchy
.children_of(None)
.into_iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.is_none()
&& (n.title.to_ascii_lowercase() == needle
|| n.slug.to_ascii_lowercase() == needle)
})
.cloned()
.ok_or_else(|| anyhow!("{tag}: no user book matches `{book_name}`"))?;
let timeline_chapter_id = store
.ensure_timeline_chapter(cfg, book.id)
.map_err(|e| anyhow!("{tag}: ensure_timeline_chapter: {e}"))?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy reload: {e}"))?;
let chapter = hierarchy
.get(timeline_chapter_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: Timeline chapter vanished after creation"))?;
let mut node = store
.create_node(
cfg,
&hierarchy,
NodeKind::Paragraph,
&title,
Some(&chapter),
None,
InsertPosition::End,
)
.map_err(|e| anyhow!("{tag}: create_node: {e}"))?;
node.event = Some(crate::store::node::EventData {
start_ticks: start.ticks(),
end_ticks: None,
precision: prec,
characters: Vec::new(),
places: Vec::new(),
track: None,
});
crate::store::reconcile_event_orphan_tag(&mut node);
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
let id_str = node.id.to_string();
crate::scripting::hooks::fire(
"hook.on_event_added",
vec![Value::from_string(id_str.clone())],
);
push(vm, Value::from_string(id_str));
Ok(vm)
}
fn ink_event_set_end(vm: &mut VM) -> BundResult<'_> {
do_ink_event_set_end(vm).map_err(to_bund_err)
}
fn do_ink_event_set_end(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.event.set_end";
require_depth(vm, 2, tag)?;
let spec = value_to_string(pull(vm, tag)?, "spec", tag)?;
let id = value_to_uuid(pull(vm, tag)?, tag)?;
let new_end: Option<i64> = if spec.trim().is_empty() || spec.eq_ignore_ascii_case("none") {
None
} else {
let calendar = calendar_for(tag)?;
let (p, _prec) = calendar
.parse(&spec)
.map_err(|e| anyhow!("{tag}: {e}"))?;
Some(p.ticks())
};
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let mut node = hierarchy
.get(id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node {id} missing"))?;
let Some(ev) = node.event.as_mut() else {
return Err(anyhow!("{tag}: node {id} is not an event"));
};
if let Some(end_t) = new_end {
if end_t < ev.start_ticks {
return Err(anyhow!(
"{tag}: end ({end_t}) < start ({}) — events can't run backwards",
ev.start_ticks
));
}
}
ev.end_ticks = new_end;
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
Ok(vm)
}
fn ink_event_set_precision(vm: &mut VM) -> BundResult<'_> {
do_ink_event_set_precision(vm).map_err(to_bund_err)
}
fn do_ink_event_set_precision(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.event.set_precision";
require_depth(vm, 2, tag)?;
let prec_str = value_to_string(pull(vm, tag)?, "precision", tag)?;
let id = value_to_uuid(pull(vm, tag)?, tag)?;
let _cfg = require_timeline_enabled(tag)?;
let prec = crate::timeline::Precision::from_str(&prec_str).ok_or_else(|| {
anyhow!("{tag}: unknown precision `{prec_str}` (try day/month/year/season/hour/week/tick)")
})?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let mut node = hierarchy
.get(id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node {id} missing"))?;
let Some(ev) = node.event.as_mut() else {
return Err(anyhow!("{tag}: node {id} is not an event"));
};
ev.precision = prec;
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
Ok(vm)
}
fn ink_event_set_track(vm: &mut VM) -> BundResult<'_> {
do_ink_event_set_track(vm).map_err(to_bund_err)
}
fn do_ink_event_set_track(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.event.set_track";
require_depth(vm, 2, tag)?;
let track = value_to_string(pull(vm, tag)?, "track", tag)?;
let id = value_to_uuid(pull(vm, tag)?, tag)?;
let _cfg = require_timeline_enabled(tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let mut node = hierarchy
.get(id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: node {id} missing"))?;
let Some(ev) = node.event.as_mut() else {
return Err(anyhow!("{tag}: node {id} is not an event"));
};
ev.track = if track.trim().is_empty() {
None
} else {
Some(track)
};
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
Ok(vm)
}
fn ink_event_link_paragraph(vm: &mut VM) -> BundResult<'_> {
do_ink_event_link_paragraph(vm).map_err(to_bund_err)
}
fn do_ink_event_link_paragraph(vm: &mut VM) -> Result<&mut VM> {
let tag = "ink.event.link_paragraph";
require_depth(vm, 2, tag)?;
let path = value_to_string(pull(vm, tag)?, "paragraph-path", tag)?;
let event_id = value_to_uuid(pull(vm, tag)?, tag)?;
let _cfg = require_timeline_enabled(tag)?;
let store = active_store(tag)?;
let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
let para_id = super::helpers::resolve_path(&hierarchy, &path, tag)?
.ok_or_else(|| anyhow!("{tag}: empty path"))?;
{
let para = hierarchy
.get(para_id)
.ok_or_else(|| anyhow!("{tag}: paragraph at `{path}` missing"))?;
if para.kind != NodeKind::Paragraph {
return Err(anyhow!("{tag}: `{path}` is not a Paragraph"));
}
}
let mut node = hierarchy
.get(event_id)
.cloned()
.ok_or_else(|| anyhow!("{tag}: event {event_id} missing"))?;
if node.event.is_none() {
return Err(anyhow!("{tag}: node {event_id} is not an event"));
}
if !node.linked_paragraphs.contains(¶_id) {
node.linked_paragraphs.push(para_id);
}
crate::store::reconcile_event_orphan_tag(&mut node);
node.modified_at = chrono::Utc::now();
store
.raw()
.update_metadata(node.id, node.to_json())
.map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
Ok(vm)
}