use regex::Regex;
use rusqlite::{params, Connection, Result};
use serde::Serialize;
use std::path::{Path, PathBuf};
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")
}
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();
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
}
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(())
}
#[allow(dead_code)]
pub struct SlugRename {
pub filename: String,
pub old_slug: String,
pub new_slug: String,
}
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 {
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
}
fn rewrite_single_slug(content: &str, old_slug: &str, new_slug: &str) -> String {
let escaped = regex::escape(old_slug);
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
};
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();
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()
}
#[derive(Debug, Serialize)]
pub struct BacklinkEntry {
pub filename: String,
pub title: String,
}
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)
}