navi

Obsidian-style interactive graph viewer for org-roam — native window, no Emacs package required.
Log | Files | Refs | README

db.rs (5266B)


      1 use rusqlite::{Connection, OpenFlags};
      2 use std::collections::HashMap;
      3 
      4 #[derive(Debug, Clone)]
      5 pub struct RawNode {
      6     pub id: String,
      7     pub title: String,
      8     pub file: String,
      9     pub level: i64,
     10     pub pos: i64,
     11     pub mtime: i64,
     12     pub aliases: Vec<String>,
     13     pub tags: Vec<String>,
     14 }
     15 
     16 pub fn load_graph(db_path: &str) -> rusqlite::Result<(Vec<RawNode>, Vec<(String, String)>)> {
     17     let conn = Connection::open_with_flags(
     18         db_path,
     19         OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
     20     )?;
     21 
     22     let mut nodes: HashMap<String, RawNode> = HashMap::new();
     23 
     24     // Try with files JOIN first; mtime may be Emacs `(HIGH LOW ...)` string
     25     let rows_result = conn.prepare(
     26         "SELECT n.id, n.title, n.file, n.level, n.pos, f.mtime
     27          FROM nodes n LEFT JOIN files f ON n.file = f.file",
     28     );
     29 
     30     match rows_result {
     31         Ok(mut stmt) => {
     32             let iter = stmt.query_map([], |row| {
     33                 Ok((
     34                     row.get::<_, String>(0)?,
     35                     row.get::<_, Option<String>>(1)?.unwrap_or_default(),
     36                     row.get::<_, Option<String>>(2)?.unwrap_or_default(),
     37                     row.get::<_, Option<i64>>(3)?.unwrap_or(0),
     38                     row.get::<_, Option<i64>>(4)?.unwrap_or(0),
     39                     row.get::<_, Option<String>>(5)?,   // may be "(HIGH LOW ...)" or integer
     40                 ))
     41             })?;
     42             for row in iter.flatten() {
     43                 let (id, title, file, level, pos, mtime_raw) = row;
     44                 let title = title.trim_matches('"').to_string();
     45                 let file = file.trim_matches('"').to_string();
     46                 let mtime = parse_mtime(mtime_raw.as_deref());
     47                 nodes.insert(id.clone(),
     48                     RawNode { id, title, file, level, pos, mtime, aliases: vec![], tags: vec![] });
     49             }
     50         }
     51         Err(_) => {
     52             let mut stmt = conn.prepare(
     53                 "SELECT id, COALESCE(title,''), COALESCE(file,''), COALESCE(level,0), COALESCE(pos,0) FROM nodes",
     54             )?;
     55             let iter = stmt.query_map([], |row| {
     56                 Ok((
     57                     row.get::<_, String>(0)?,
     58                     row.get::<_, String>(1)?,
     59                     row.get::<_, String>(2)?,
     60                     row.get::<_, i64>(3)?,
     61                     row.get::<_, i64>(4)?,
     62                 ))
     63             })?;
     64             for row in iter.flatten() {
     65                 let (id, title, file, level, pos) = row;
     66                 let title = title.trim_matches('"').to_string();
     67                 let file = file.trim_matches('"').to_string();
     68                 nodes.insert(id.clone(),
     69                     RawNode { id, title, file, level, pos, mtime: 0, aliases: vec![], tags: vec![] });
     70             }
     71         }
     72     }
     73 
     74     // Edges
     75     let mut edges: Vec<(String, String)> = Vec::new();
     76     if let Ok(mut stmt) = conn.prepare("SELECT source, dest FROM links") {
     77         let iter = stmt.query_map([], |row| {
     78             Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
     79         });
     80         if let Ok(iter) = iter {
     81             for row in iter.flatten() {
     82                 if nodes.contains_key(&row.0) && nodes.contains_key(&row.1) {
     83                     edges.push(row);
     84                 }
     85             }
     86         }
     87     }
     88 
     89     // Aliases
     90     if let Ok(mut stmt) = conn.prepare("SELECT node_id, alias FROM aliases") {
     91         let iter = stmt.query_map([], |row| {
     92             Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
     93         });
     94         if let Ok(iter) = iter {
     95             for row in iter.flatten() {
     96                 if let Some(n) = nodes.get_mut(&row.0) {
     97                     let alias = row.1.trim_matches('"').to_string();
     98                     if !alias.is_empty() {
     99                         n.aliases.push(alias);
    100                     }
    101                 }
    102             }
    103         }
    104     }
    105 
    106     // Tags
    107     if let Ok(mut stmt) = conn.prepare("SELECT node_id, tag FROM tags") {
    108         let iter = stmt.query_map([], |row| {
    109             Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
    110         });
    111         if let Ok(iter) = iter {
    112             for row in iter.flatten() {
    113                 if let Some(n) = nodes.get_mut(&row.0) {
    114                     if !row.1.is_empty() {
    115                         n.tags.push(row.1);
    116                     }
    117                 }
    118             }
    119         }
    120     }
    121 
    122     let node_list: Vec<RawNode> = nodes.into_values().collect();
    123     Ok((node_list, edges))
    124 }
    125 
    126 /// Parse Emacs mtime which may be:
    127 ///   - A plain integer string "1779015285"
    128 ///   - Emacs internal time "(HIGH LOW MICROSEC PICOSEC)" → HIGH*65536 + LOW
    129 ///   - nil / empty → 0
    130 fn parse_mtime(raw: Option<&str>) -> i64 {
    131     let s = match raw { Some(s) if !s.is_empty() => s, _ => return 0 };
    132     // Try plain integer first
    133     if let Ok(n) = s.trim().parse::<i64>() { return n; }
    134     // Emacs list: strip parens, split on whitespace, take HIGH and LOW
    135     let inner = s.trim().trim_start_matches('(').trim_end_matches(')');
    136     let parts: Vec<&str> = inner.split_whitespace().collect();
    137     if parts.len() >= 2 {
    138         let high: i64 = parts[0].parse().unwrap_or(0);
    139         let low:  i64 = parts[1].parse().unwrap_or(0);
    140         return high * 65536 + low;
    141     }
    142     0
    143 }