smarana 0.10.12

An extensible note taking system for typst.
use regex::Regex;
use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Frontmatter {
    pub title: Option<String>,
    pub summary: Option<String>,
    pub date: Option<String>,
    pub time: Option<String>,
    pub tags: Option<Vec<String>>,
    pub note_type: Option<String>,
    pub uplink: Option<String>,
}

fn find_balanced_paren(input: &str, start_pos: usize) -> Option<usize> {
    let mut count = 0;
    let bytes = input.as_bytes();
    for i in start_pos..bytes.len() {
        match bytes[i] as char {
            '(' => count += 1,
            ')' => {
                count -= 1;
                if count == 0 {
                    return Some(i);
                }
            }
            _ => {}
        }
    }
    None
}

fn parse_string_array(inner: &str, key: &str) -> Option<Vec<String>> {
    let pattern = format!(r#"{}\s*:\s*\("#, regex::escape(key));
    let re = Regex::new(&pattern).ok()?;
    
    if let Some(m) = re.find(inner) {
        // Find the opening paren position within inner
        let paren_start = inner[..m.end()].rfind('(')?;
        if let Some(paren_end) = find_balanced_paren(inner, paren_start) {
            let array_inner = &inner[paren_start + 1..paren_end];
            let re_string = Regex::new(r#""([^"]*)""#).unwrap();
            
            let matches: Vec<_> = re_string.captures_iter(array_inner).collect();
            let items: Vec<String> = matches.iter()
                .map(|c| c.get(1).unwrap().as_str().to_string())
                .collect();
                
            if items.len() == 1 {
                // Typst requires a trailing comma for single-element arrays: ("tag",)
                // Check if there is a comma anywhere in array_inner (outside the quoted tag is implied as there's only one tag)
                if !array_inner.contains(',') {
                    return None;
                }
            }
            
            if !items.is_empty() {
                return Some(items);
            }
        }
    }
    None
}

pub fn parse_frontmatter(content: &str, filename: &str) -> Frontmatter {
    let mut fm = Frontmatter::default();
    
    // 1. Extract title from `#let title = "..."` (new template format)
    let re_let_title = Regex::new(r#"#let\s+title\s*=\s*"([^"]*)""#).unwrap();
    if let Some(caps) = re_let_title.captures(content) {
        fm.title = Some(caps.get(1).unwrap().as_str().to_string());
    }

    // 2. Parse the `#show: card.with(` payload for remaining fields.
    let re_start = Regex::new(r"(?s)#show:\s*card\.with\s*\(").unwrap();
    if let Some(m) = re_start.find(content) {
        let start_paren = m.end() - 1;
        if let Some(end_paren) = find_balanced_paren(content, start_paren) {
            let raw_inner = &content[start_paren + 1..end_paren];
            
            // Strip out Typst comments so commented out directives are ignored
            let re_line = Regex::new(r"(?m)//.*").unwrap();
            let partially_cleaned = re_line.replace_all(raw_inner, "");
            let re_block = Regex::new(r"(?s)/\*.*?\*/").unwrap();
            let inner = re_block.replace_all(&partially_cleaned, "").to_string();
            
            // Extract tags array
            fm.tags = parse_string_array(&inner, "tags");
            
            // Extract explicit string keys like `date: "2025-04-14",`
            let re_kv = Regex::new(r#"([a-zA-Z0-9_]+)\s*:\s*"([^"]*)""#).unwrap();
            for caps in re_kv.captures_iter(&inner) {
                let key = caps.get(1).unwrap().as_str();
                let val = caps.get(2).unwrap().as_str().to_string();
                
                match key {
                    // Only set title from card.with if not already found via #let
                    "title" if fm.title.is_none() => fm.title = Some(val),
                    "summary" => fm.summary = Some(val),
                    "date" => fm.date = Some(val),
                    "time" => fm.time = Some(val),
                    "type" => fm.note_type = Some(val),
                    "uplink" => fm.uplink = Some(val),
                    _ => {} // gracefully ignore unknown keys
                }
            }

            // Extract typst markup references like `uplink: [@Slug]`
            let re_ref = Regex::new(r#"([a-zA-Z0-9_]+)\s*:\s*\[\s*@([a-zA-Z0-9_-]+)\s*\]"#).unwrap();
            for caps in re_ref.captures_iter(&inner) {
                let key = caps.get(1).unwrap().as_str();
                let val = caps.get(2).unwrap().as_str().to_string();
                
                if key == "uplink" {
                    fm.uplink = Some(val);
                }
            }
        } else {
            eprintln!("Warning: Malformed frontmatter in '{}' (unbalanced parentheses)", filename);
        }
    } else {
        eprintln!("Warning: No frontmatter card found in '{}' (expected #show: card.with(...))", filename);
    }
    
    fm
}