smarana 0.10.12

An extensible note taking system for typst.
use regex::Regex;
use rusqlite::{params, Connection, Result};
use serde::Serialize;
use std::path::{Path, PathBuf};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Mirrors the `make-slug` function in `library.typ` and `db.rs`.
/// Converts a note title into its label slug.
/// "I am Dancing" → "I-am-Dancing"
pub fn make_slug(title: &str) -> String {
    let re = Regex::new(r"[^a-zA-Z0-9]+").unwrap();
    let slug = re.replace_all(title, "-").trim_matches('-').to_string();
    if slug.is_empty() {
        "untitled".to_string()
    } else {
        slug
    }
}

fn db_path(notebook_path: &Path) -> PathBuf {
    notebook_path.join(".smarana").join("index.db")
}

// ---------------------------------------------------------------------------
// Link parsing
// ---------------------------------------------------------------------------

/// Scans `content` for all Typst `@Slug` reference tokens and returns a
/// deduplicated, sorted list of slugs found.
///
/// A valid Typst cross-note `@Slug` is always preceded by whitespace, `[`,
/// or the very start of the file. This excludes emails (`user@host`) and URL
/// fragments (`/@slug` inside a `#link("...")` argument).
pub fn parse_links(content: &str) -> Vec<String> {
    let re = Regex::new(r"@([a-zA-Z0-9][a-zA-Z0-9_-]*)").unwrap();
    let bytes = content.as_bytes();
    let mut seen = std::collections::HashSet::new();
    let mut slugs: Vec<String> = Vec::new();

    for cap in re.captures_iter(content) {
        let at_pos = cap.get(0).unwrap().start();
        // The byte immediately before '@' must be whitespace, '[', or
        // we must be at the very start of the file.
        let valid = at_pos == 0
            || matches!(bytes[at_pos - 1] as char, ' ' | '\t' | '\n' | '\r' | '[');
        if valid {
            let slug = cap.get(1).unwrap().as_str().to_string();
            if seen.insert(slug.clone()) {
                slugs.push(slug);
            }
        }
    }
    slugs.sort();
    slugs
}

// ---------------------------------------------------------------------------
// note_links table operations
// ---------------------------------------------------------------------------

/// Replaces all link rows for `source` with the provided `slugs`.
/// Called once per note during sync.
pub fn upsert_links(conn: &Connection, source: &str, slugs: &[String]) -> Result<()> {
    conn.execute("DELETE FROM note_links WHERE source = ?1", params![source])?;
    for slug in slugs {
        conn.execute(
            "INSERT OR IGNORE INTO note_links (source, target_slug) VALUES (?1, ?2)",
            params![source, slug],
        )?;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Rename-rewrite logic
// ---------------------------------------------------------------------------

/// One rename event: a note whose slug changed between syncs.
#[allow(dead_code)]
pub struct SlugRename {
    pub filename: String,
    pub old_slug: String,
    pub new_slug: String,
}

/// Given a set of renames, rewrites every `.typ` file in `notebook_path`
/// (except `smarana.typ`) that contains a stale `@OldSlug` reference.
///
/// Rewrites:
///   1. Body `@OldSlug` → `@NewSlug`  (only when preceded by ws / `[` / BOF)
///   2. `uplink: "OldSlug"` → `uplink: "NewSlug"`
///   3. `uplink: [@OldSlug]` → `uplink: [@NewSlug]`
///
/// Returns paths of files that were modified on disk.
pub fn rewrite_renames(notebook_path: &Path, renames: &[SlugRename]) -> Vec<PathBuf> {
    if renames.is_empty() {
        return Vec::new();
    }

    let mut modified_paths: Vec<PathBuf> = Vec::new();

    let entries = match std::fs::read_dir(notebook_path) {
        Ok(e) => e,
        Err(e) => {
            eprintln!("links: failed to read notebook dir: {}", e);
            return Vec::new();
        }
    };

    let typ_files: Vec<PathBuf> = entries
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.is_file()
                && p.extension().map(|x| x == "typ").unwrap_or(false)
                && p.file_name().map(|n| n != "smarana.typ").unwrap_or(false)
                && !p.to_string_lossy().contains(".smarana")
        })
        .collect();

    for file_path in &typ_files {
        let content = match std::fs::read_to_string(file_path) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("links: failed to read {:?}: {}", file_path, e);
                continue;
            }
        };

        let mut new_content = content.clone();
        let mut changed = false;

        for rename in renames {
            // Quick pre-check: skip file if old slug string isn't present at all.
            if !new_content.contains(&*rename.old_slug) {
                continue;
            }
            let updated = rewrite_single_slug(&new_content, &rename.old_slug, &rename.new_slug);
            if updated != new_content {
                new_content = updated;
                changed = true;
            }
        }

        if changed {
            if let Err(e) = std::fs::write(file_path, &new_content) {
                eprintln!("links: failed to write {:?}: {}", file_path, e);
            } else {
                crate::vprintln!(
                    "links: rewrote slug references in {:?}",
                    file_path.file_name().unwrap_or_default()
                );
                modified_paths.push(file_path.clone());
            }
        }
    }

    modified_paths
}

/// Performs all textual replacements for one old→new slug pair in `content`.
///
/// Uses manual preceding-byte inspection instead of regex groups or
/// lookbehinds — both are either complex or unsupported in Rust's `regex`.
fn rewrite_single_slug(content: &str, old_slug: &str, new_slug: &str) -> String {
    let escaped = regex::escape(old_slug);

    // ── 1. Body @OldSlug references ──────────────────────────────────────────
    // Find every `@OldSlug` token where:
    //   - the char AFTER the slug is not a label-id char (guards prefix matches)
    //   - the char BEFORE `@` is whitespace, '[', or start-of-file
    // This avoids emails/URLs without any lookbehind or lookahead (Rust's regex
    // crate supports neither).
    let after_body = {
        let pattern = format!(r"@{escaped}");
        let re = Regex::new(&pattern).unwrap();
        let bytes = content.as_bytes();
        let mut out = String::with_capacity(content.len() + 64);
        let mut last: usize = 0;
        for m in re.find_iter(content) {
            let at = m.start();
            let end = m.end();
            
            let valid_before = at == 0
                || matches!(bytes[at - 1] as char, ' ' | '\t' | '\n' | '\r' | '[');
                
            let valid_after = end == bytes.len()
                || !matches!(bytes[end] as char, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-');

            out.push_str(&content[last..at]);
            if valid_before && valid_after {
                out.push('@');
                out.push_str(new_slug);
            } else {
                out.push_str(m.as_str());
            }
            last = end;
        }
        out.push_str(&content[last..]);
        out
    };

    // ── 2. uplink: "OldSlug" (string form) ───────────────────────────────────
    let uplink_str_pattern = format!(r#"(uplink\s*:\s*)"{}""#, escaped);
    let re_uplink_str = Regex::new(&uplink_str_pattern).unwrap();
    let after_uplink_str = re_uplink_str
        .replace_all(&after_body, format!(r#"${{1}}"{}""#, new_slug).as_str())
        .to_string();

    // ── 3. uplink: [@OldSlug] (ref form) ─────────────────────────────────────
    let uplink_ref_pattern = format!(r"(uplink\s*:\s*\[\s*)@{}(\s*\])", escaped);
    let re_uplink_ref = Regex::new(&uplink_ref_pattern).unwrap();
    re_uplink_ref
        .replace_all(
            &after_uplink_str,
            format!("${{1}}@{}${{2}}", new_slug).as_str(),
        )
        .to_string()
}

// ---------------------------------------------------------------------------
// Backlinks query
// ---------------------------------------------------------------------------

#[derive(Debug, Serialize)]
pub struct BacklinkEntry {
    pub filename: String,
    pub title: String,
}

/// Returns all notes that contain a reference to `target_slug`.
/// Accepts either a raw slug or a human-readable title (auto-slugified).
pub fn backlinks(notebook_path: &Path, target_slug_or_title: &str) -> Result<Vec<BacklinkEntry>> {
    let slug = make_slug(target_slug_or_title);
    let path = db_path(notebook_path);
    let conn = Connection::open(&path)?;

    let mut stmt = conn.prepare(
        "SELECT n.filename, n.title
         FROM note_links nl
         JOIN notes n ON n.filename = nl.source
         WHERE nl.target_slug = ?1
         ORDER BY n.date DESC, n.time DESC",
    )?;

    let entries = stmt
        .query_map(params![slug], |row| {
            Ok(BacklinkEntry {
                filename: row.get(0)?,
                title: row.get(1)?,
            })
        })?
        .filter_map(|r| r.ok())
        .collect();

    Ok(entries)
}