Skip to main content

mdvault_core/scripting/
index_bindings.rs

1//! Index query bindings for Lua.
2//!
3//! This module provides Lua bindings for querying the vault index:
4//! - `mdv.current_note()` - Get the current note being processed
5//! - `mdv.backlinks(path)` - Get notes linking to a path
6//! - `mdv.outlinks(path)` - Get notes a path links to
7//! - `mdv.query(opts)` - Query the vault index
8
9use std::path::Path;
10
11use mlua::{Function, Lua, Result as LuaResult, Table, Value};
12
13use super::vault_context::VaultContext;
14use crate::index::NoteQuery;
15use crate::types::validation::yaml_to_lua_table;
16
17/// Register index query bindings on an existing mdv table.
18///
19/// This adds `mdv.current_note()`, `mdv.backlinks()`, `mdv.outlinks()`, and
20/// `mdv.query()` functions that have access to the vault index.
21pub fn register_index_bindings(lua: &Lua) -> LuaResult<()> {
22    let mdv: Table = lua.globals().get("mdv")?;
23
24    mdv.set("current_note", create_current_note_fn(lua)?)?;
25    mdv.set("backlinks", create_backlinks_fn(lua)?)?;
26    mdv.set("outlinks", create_outlinks_fn(lua)?)?;
27    mdv.set("query", create_query_fn(lua)?)?;
28    mdv.set("find_project", create_find_project_fn(lua)?)?;
29
30    Ok(())
31}
32
33/// Create the `mdv.current_note()` function.
34///
35/// Returns the current note being processed, or nil if not available.
36///
37/// # Examples (in Lua)
38///
39/// ```lua
40/// local note = mdv.current_note()
41/// if note then
42///     print("Processing: " .. note.path)
43///     print("Type: " .. note.type)
44/// end
45/// ```
46fn create_current_note_fn(lua: &Lua) -> LuaResult<Function> {
47    lua.create_function(|lua, ()| {
48        let ctx = lua
49            .app_data_ref::<VaultContext>()
50            .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
51
52        let current = match &ctx.current_note {
53            Some(note) => note,
54            None => return Ok(Value::Nil),
55        };
56
57        // Build note table
58        let note_table = lua.create_table()?;
59        note_table.set("path", current.path.as_str())?;
60        note_table.set("type", current.note_type.as_str())?;
61        note_table.set("content", current.content.as_str())?;
62
63        if let Some(title) = &current.title {
64            note_table.set("title", title.as_str())?;
65        }
66
67        if let Some(fm) = &current.frontmatter {
68            let fm_table = yaml_to_lua_table(lua, fm)?;
69            note_table.set("frontmatter", fm_table)?;
70        }
71
72        Ok(Value::Table(note_table))
73    })
74}
75
76/// Create the `mdv.backlinks(path)` function.
77///
78/// Returns a list of notes that link to the specified path.
79///
80/// # Examples (in Lua)
81///
82/// ```lua
83/// local links = mdv.backlinks("projects/my-project.md")
84/// for _, link in ipairs(links) do
85///     print(link.source_path .. " links to this note")
86/// end
87/// ```
88fn create_backlinks_fn(lua: &Lua) -> LuaResult<Function> {
89    lua.create_function(|lua, path: String| {
90        let ctx = lua
91            .app_data_ref::<VaultContext>()
92            .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
93
94        let db = match &ctx.index_db {
95            Some(db) => db,
96            None => {
97                return Err(mlua::Error::runtime(
98                    "Index database not available. Run 'mdv reindex' first.",
99                ));
100            }
101        };
102
103        // Resolve path
104        let resolved_path = resolve_note_path(&ctx.vault_root, &path);
105
106        // Get note ID
107        let note = match db.get_note_by_path(Path::new(&resolved_path)) {
108            Ok(Some(n)) => n,
109            Ok(None) => {
110                // Return empty table if note not found
111                return Ok(Value::Table(lua.create_table()?));
112            }
113            Err(e) => return Err(mlua::Error::runtime(format!("Index error: {}", e))),
114        };
115
116        let note_id = match note.id {
117            Some(id) => id,
118            None => return Ok(Value::Table(lua.create_table()?)),
119        };
120
121        // Get backlinks
122        let backlinks = db
123            .get_backlinks(note_id)
124            .map_err(|e| mlua::Error::runtime(format!("Index error: {}", e)))?;
125
126        // Convert to Lua table
127        let result = lua.create_table()?;
128        for (i, link) in backlinks.iter().enumerate() {
129            let link_table = lua.create_table()?;
130
131            // Get source note path
132            if let Ok(Some(source_note)) = db.get_note_by_id(link.source_id) {
133                link_table
134                    .set("source_path", source_note.path.to_string_lossy().to_string())?;
135                link_table.set("source_title", source_note.title)?;
136                link_table.set("source_type", source_note.note_type.as_str())?;
137            }
138
139            if let Some(text) = &link.link_text {
140                link_table.set("link_text", text.as_str())?;
141            }
142            if let Some(context) = &link.context {
143                link_table.set("context", context.as_str())?;
144            }
145            link_table.set("link_type", link.link_type.as_str())?;
146
147            result.set(i + 1, link_table)?;
148        }
149
150        Ok(Value::Table(result))
151    })
152}
153
154/// Create the `mdv.outlinks(path)` function.
155///
156/// Returns a list of notes that the specified path links to.
157///
158/// # Examples (in Lua)
159///
160/// ```lua
161/// local links = mdv.outlinks("projects/my-project.md")
162/// for _, link in ipairs(links) do
163///     print("Links to: " .. link.target_path)
164/// end
165/// ```
166fn create_outlinks_fn(lua: &Lua) -> LuaResult<Function> {
167    lua.create_function(|lua, path: String| {
168        let ctx = lua
169            .app_data_ref::<VaultContext>()
170            .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
171
172        let db = match &ctx.index_db {
173            Some(db) => db,
174            None => {
175                return Err(mlua::Error::runtime(
176                    "Index database not available. Run 'mdv reindex' first.",
177                ));
178            }
179        };
180
181        // Resolve path
182        let resolved_path = resolve_note_path(&ctx.vault_root, &path);
183
184        // Get note ID
185        let note = match db.get_note_by_path(Path::new(&resolved_path)) {
186            Ok(Some(n)) => n,
187            Ok(None) => {
188                // Return empty table if note not found
189                return Ok(Value::Table(lua.create_table()?));
190            }
191            Err(e) => return Err(mlua::Error::runtime(format!("Index error: {}", e))),
192        };
193
194        let note_id = match note.id {
195            Some(id) => id,
196            None => return Ok(Value::Table(lua.create_table()?)),
197        };
198
199        // Get outgoing links
200        let outlinks = db
201            .get_outgoing_links(note_id)
202            .map_err(|e| mlua::Error::runtime(format!("Index error: {}", e)))?;
203
204        // Convert to Lua table
205        let result = lua.create_table()?;
206        for (i, link) in outlinks.iter().enumerate() {
207            let link_table = lua.create_table()?;
208
209            link_table.set("target_path", link.target_path.as_str())?;
210
211            // Get target note info if resolved
212            if let Some(target_id) = link.target_id {
213                if let Ok(Some(target_note)) = db.get_note_by_id(target_id) {
214                    link_table.set("target_title", target_note.title)?;
215                    link_table.set("target_type", target_note.note_type.as_str())?;
216                    link_table.set("resolved", true)?;
217                } else {
218                    link_table.set("resolved", false)?;
219                }
220            } else {
221                link_table.set("resolved", false)?;
222            }
223
224            if let Some(text) = &link.link_text {
225                link_table.set("link_text", text.as_str())?;
226            }
227            link_table.set("link_type", link.link_type.as_str())?;
228
229            result.set(i + 1, link_table)?;
230        }
231
232        Ok(Value::Table(result))
233    })
234}
235
236/// Create the `mdv.query(opts)` function.
237///
238/// Query the vault index with filters.
239///
240/// # Examples (in Lua)
241///
242/// ```lua
243/// -- Find all open tasks
244/// local tasks = mdv.query({ type = "task" })
245/// for _, note in ipairs(tasks) do
246///     print(note.path .. ": " .. note.title)
247/// end
248///
249/// -- Find recent notes
250/// local recent = mdv.query({ limit = 10 })
251/// ```
252fn create_query_fn(lua: &Lua) -> LuaResult<Function> {
253    lua.create_function(|lua, opts: Option<Table>| {
254        let ctx = lua
255            .app_data_ref::<VaultContext>()
256            .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
257
258        let db = match &ctx.index_db {
259            Some(db) => db,
260            None => {
261                return Err(mlua::Error::runtime(
262                    "Index database not available. Run 'mdv reindex' first.",
263                ));
264            }
265        };
266
267        // Build query from options
268        let mut query = NoteQuery::default();
269
270        if let Some(opts) = opts {
271            // Type filter
272            if let Ok(type_str) = opts.get::<String>("type") {
273                query.note_type = Some(type_str.parse().unwrap_or_default());
274            }
275
276            // Path prefix filter
277            if let Ok(prefix) = opts.get::<String>("path_prefix") {
278                query.path_prefix = Some(std::path::PathBuf::from(prefix));
279            }
280
281            // Limit
282            if let Ok(limit) = opts.get::<i64>("limit") {
283                query.limit = Some(limit as u32);
284            }
285
286            // Offset
287            if let Ok(offset) = opts.get::<i64>("offset") {
288                query.offset = Some(offset as u32);
289            }
290        }
291
292        // Execute query
293        let notes = db
294            .query_notes(&query)
295            .map_err(|e| mlua::Error::runtime(format!("Query error: {}", e)))?;
296
297        // Convert to Lua table
298        let result = lua.create_table()?;
299        for (i, note) in notes.iter().enumerate() {
300            let note_table = lua.create_table()?;
301            note_table.set("path", note.path.to_string_lossy().to_string())?;
302            note_table.set("type", note.note_type.as_str())?;
303            note_table.set("title", note.title.clone())?;
304            note_table.set("modified", note.modified.to_rfc3339())?;
305
306            if let Some(created) = note.created {
307                note_table.set("created", created.to_rfc3339())?;
308            }
309
310            // Parse and include frontmatter if available
311            if let Some(fm_json) = &note.frontmatter_json
312                && let Ok(fm) = serde_json::from_str::<serde_json::Value>(fm_json)
313            {
314                let fm_yaml = json_to_yaml(&fm);
315                let fm_lua = yaml_to_lua_table(lua, &fm_yaml)?;
316                note_table.set("frontmatter", fm_lua)?;
317            }
318
319            result.set(i + 1, note_table)?;
320        }
321
322        Ok(Value::Table(result))
323    })
324}
325
326/// Create the `mdv.find_project(id)` function.
327///
328/// Finds a project note by its 'project-id' field.
329/// Returns the note table or nil if not found.
330#[allow(clippy::collapsible_if)]
331fn create_find_project_fn(lua: &Lua) -> LuaResult<Function> {
332    lua.create_function(|lua, id: String| {
333        let ctx = lua
334            .app_data_ref::<VaultContext>()
335            .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
336
337        let db = match &ctx.index_db {
338            Some(db) => db,
339            None => {
340                return Err(mlua::Error::runtime(
341                    "Index database not available. Run 'mdv reindex' first.",
342                ));
343            }
344        };
345
346        // Query for projects
347        let query = NoteQuery {
348            note_type: Some(crate::index::NoteType::Project),
349            ..Default::default()
350        };
351
352        let notes = db
353            .query_notes(&query)
354            .map_err(|e| mlua::Error::runtime(format!("Query error: {}", e)))?;
355
356        // Find match
357        for note in notes {
358            if let Some(fm_json) = &note.frontmatter_json {
359                if let Ok(fm) = serde_json::from_str::<serde_json::Value>(fm_json) {
360                    if fm.get("project-id").and_then(|v| v.as_str()) == Some(id.as_str())
361                    {
362                        // Found it! Convert to note table.
363                        let note_table = lua.create_table()?;
364                        note_table
365                            .set("path", note.path.to_string_lossy().to_string())?;
366                        note_table.set("type", note.note_type.as_str())?;
367                        note_table.set("title", note.title.clone())?;
368                        note_table.set("modified", note.modified.to_rfc3339())?;
369
370                        if let Some(created) = note.created {
371                            note_table.set("created", created.to_rfc3339())?;
372                        }
373
374                        let fm_yaml = json_to_yaml(&fm);
375                        let fm_lua = yaml_to_lua_table(lua, &fm_yaml)?;
376                        note_table.set("frontmatter", fm_lua)?;
377
378                        return Ok(Value::Table(note_table));
379                    }
380                }
381            }
382        }
383
384        Ok(Value::Nil)
385    })
386}
387
388/// Resolve a note path relative to vault root.
389fn resolve_note_path(_vault_root: &std::path::Path, path: &str) -> String {
390    // If path doesn't end with .md, append it
391    if path.ends_with(".md") { path.to_string() } else { format!("{}.md", path) }
392}
393
394/// Convert serde_json::Value to serde_yaml::Value.
395fn json_to_yaml(json: &serde_json::Value) -> serde_yaml::Value {
396    match json {
397        serde_json::Value::Null => serde_yaml::Value::Null,
398        serde_json::Value::Bool(b) => serde_yaml::Value::Bool(*b),
399        serde_json::Value::Number(n) => {
400            if let Some(i) = n.as_i64() {
401                serde_yaml::Value::Number(i.into())
402            } else if let Some(f) = n.as_f64() {
403                serde_yaml::Value::Number(serde_yaml::Number::from(f))
404            } else {
405                serde_yaml::Value::Null
406            }
407        }
408        serde_json::Value::String(s) => serde_yaml::Value::String(s.clone()),
409        serde_json::Value::Array(arr) => {
410            serde_yaml::Value::Sequence(arr.iter().map(json_to_yaml).collect())
411        }
412        serde_json::Value::Object(obj) => {
413            let mut map = serde_yaml::Mapping::new();
414            for (k, v) in obj {
415                map.insert(serde_yaml::Value::String(k.clone()), json_to_yaml(v));
416            }
417            serde_yaml::Value::Mapping(map)
418        }
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_resolve_note_path_with_extension() {
428        let vault_root = std::path::Path::new("/vault");
429        let result = resolve_note_path(vault_root, "notes/test.md");
430        assert_eq!(result, "notes/test.md");
431    }
432
433    #[test]
434    fn test_resolve_note_path_without_extension() {
435        let vault_root = std::path::Path::new("/vault");
436        let result = resolve_note_path(vault_root, "notes/test");
437        assert_eq!(result, "notes/test.md");
438    }
439
440    #[test]
441    fn test_json_to_yaml() {
442        let json = serde_json::json!({
443            "string": "value",
444            "number": 42,
445            "bool": true,
446            "array": [1, 2, 3]
447        });
448
449        let yaml = json_to_yaml(&json);
450
451        if let serde_yaml::Value::Mapping(map) = yaml {
452            assert!(map.contains_key(serde_yaml::Value::String("string".into())));
453            assert!(map.contains_key(serde_yaml::Value::String("number".into())));
454        } else {
455            panic!("Expected mapping");
456        }
457    }
458}