diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index efcb16e1..8e9a7826 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } futures-util = "0.3" base64 = "0.22" +regex = "1" dirs = "5" [dev-dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 54164800..dc4b28aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ pub mod vault; use ai_chat::{AiChatRequest, AiChatResponse}; use git::{GitCommit, ModifiedFile}; -use vault::VaultEntry; +use vault::{VaultEntry, RenameResult}; use frontmatter::FrontmatterValue; #[tauri::command] @@ -63,6 +63,11 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result Result { + vault::rename_note(&vault_path, &old_path, &new_title) +} + #[tauri::command] fn purge_trash(vault_path: String) -> Result, String> { vault::purge_trash(&vault_path) @@ -103,6 +108,7 @@ pub fn run() { get_note_content, update_frontmatter, delete_frontmatter_property, + rename_note, get_file_history, get_modified_files, get_file_diff, diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index c76245e4..50ecb9b6 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -1,5 +1,6 @@ use gray_matter::engine::YAML; use gray_matter::Matter; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; @@ -802,6 +803,200 @@ pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result String { + title + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Update the first H1 heading in markdown content to a new title. +fn update_h1_title(content: &str, new_title: &str) -> String { + let has_h1 = content.lines().any(|l| l.trim().starts_with("# ")); + if !has_h1 { + return content.to_string(); + } + + let result: Vec = content.lines().map(|l| { + if l.trim().starts_with("# ") { format!("# {}", new_title) } else { l.to_string() } + }).collect(); + + let joined = result.join("\n"); + if content.ends_with('\n') && !joined.ends_with('\n') { + format!("{}\n", joined) + } else { + joined + } +} + +/// Build a regex that matches wiki links referencing old title or path stem. +fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option { + let pattern_str = format!( + r"\[\[(?:{}|{})(\|[^\]]*?)?\]\]", + regex::escape(old_title), + regex::escape(old_path_stem), + ); + Regex::new(&pattern_str).ok() +} + +/// Check if a path is a vault markdown file eligible for wikilink replacement. +fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool { + path.is_file() + && path != exclude + && path.extension().is_some_and(|ext| ext == "md") +} + +/// Replace wikilink references in a single file's content. Returns updated content if changed. +fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option { + if !re.is_match(content) { + return None; + } + let replaced = re.replace_all(content, |caps: ®ex::Captures| { + match caps.get(1) { + Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()), + None => format!("[[{}]]", new_title), + } + }); + if replaced != content { Some(replaced.into_owned()) } else { None } +} + +/// Parameters for a vault-wide wikilink replacement. +struct WikilinkReplacement<'a> { + vault_path: &'a Path, + old_title: &'a str, + new_title: &'a str, + old_path_stem: &'a str, + exclude_path: &'a Path, +} + +/// Collect all .md file paths in vault eligible for wikilink replacement. +fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec { + WalkDir::new(vault_path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .map(|e| e.into_path()) + .filter(|p| is_replaceable_md_file(p, exclude)) + .collect() +} + +/// Replace wiki link references across all vault markdown files. +fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize { + let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) { + Some(r) => r, + None => return 0, + }; + + let files = collect_md_files(params.vault_path, params.exclude_path); + files.iter().filter(|path| { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return false, + }; + match replace_wikilinks_in_content(&content, &re, params.new_title) { + Some(new_content) => fs::write(path, &new_content).is_ok(), + None => false, + } + }).count() +} + +/// Check if frontmatter contains a `title:` key. +fn frontmatter_has_title_key(content: &str) -> bool { + if !content.starts_with("---\n") { + return false; + } + content[4..].split("\n---").next() + .map(|fm| fm.lines().any(|l| { + let t = l.trim_start(); + t.starts_with("title:") || t.starts_with("\"title\":") + })) + .unwrap_or(false) +} + +/// Update H1 and optionally the `title:` frontmatter field in content. +fn update_note_title_in_content(content: &str, new_title: &str) -> String { + let mut updated = update_h1_title(content, new_title); + if frontmatter_has_title_key(content) { + let value = crate::frontmatter::FrontmatterValue::String(new_title.to_string()); + if let Ok(c) = update_frontmatter_content(&updated, "title", Some(value)) { + updated = c; + } + } + updated +} + +/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review"). +fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str { + abs_path + .strip_prefix(vault_prefix) + .unwrap_or(abs_path) + .strip_suffix(".md") + .unwrap_or(abs_path) +} + +/// Rename a note: update its title, rename the file, and update wiki links across the vault. +pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result { + let vault = Path::new(vault_path); + let old_file = Path::new(old_path); + + if !old_file.exists() { + return Err(format!("File does not exist: {}", old_path)); + } + let new_title = new_title.trim(); + if new_title.is_empty() { + return Err("New title cannot be empty".to_string()); + } + + let content = fs::read_to_string(old_file) + .map_err(|e| format!("Failed to read {}: {}", old_path, e))?; + let old_filename = old_file.file_name() + .map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + let old_title = extract_title(&content, &old_filename); + + if old_title == new_title { + return Ok(RenameResult { new_path: old_path.to_string(), updated_files: 0 }); + } + + // Update content (H1 + frontmatter title) + let updated_content = update_note_title_in_content(&content, new_title); + + // Compute new path and write file + let parent_dir = old_file.parent().ok_or("Cannot determine parent directory")?; + let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title))); + let new_path_str = new_file.to_string_lossy().to_string(); + + fs::write(&new_file, &updated_content) + .map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?; + if old_file != new_file { + fs::remove_file(old_file) + .map_err(|e| format!("Failed to remove old file {}: {}", old_path, e))?; + } + + // Update wikilinks across the vault + let vault_prefix = format!("{}/", vault.to_string_lossy()); + let old_path_stem = to_path_stem(old_path, &vault_prefix); + let updated_files = update_wikilinks_in_vault(&WikilinkReplacement { + vault_path: vault, old_title: &old_title, new_title, old_path_stem, exclude_path: &new_file, + }); + + Ok(RenameResult { new_path: new_path_str, updated_files }) +} + #[cfg(test)] mod tests { use super::*; @@ -1785,4 +1980,137 @@ References: assert_eq!(deleted.len(), 1); assert!(deleted[0].contains("old.md")); } + + // --- rename_note tests --- + + #[test] + fn test_title_to_slug() { + assert_eq!(title_to_slug("Weekly Review"), "weekly-review"); + assert_eq!(title_to_slug("My Note! "), "my-note"); + assert_eq!(title_to_slug("Hello World"), "hello-world"); + } + + #[test] + fn test_update_h1_title() { + let content = "---\nIs A: Note\n---\n# Old Title\n\nContent here.\n"; + let updated = update_h1_title(content, "New Title"); + assert!(updated.contains("# New Title")); + assert!(!updated.contains("# Old Title")); + assert!(updated.contains("Content here.")); + } + + #[test] + fn test_rename_note_basic() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n"); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "Sprint Retrospective", + ).unwrap(); + + assert!(result.new_path.ends_with("sprint-retrospective.md")); + assert!(!old_path.exists()); + assert!(Path::new(&result.new_path).exists()); + + let new_content = fs::read_to_string(&result.new_path).unwrap(); + assert!(new_content.contains("# Sprint Retrospective")); + } + + #[test] + fn test_rename_note_updates_wikilinks() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n"); + create_test_file(vault, "note/other.md", "---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n"); + create_test_file(vault, "project/my-project.md", "---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n"); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "Sprint Retrospective", + ).unwrap(); + + assert_eq!(result.updated_files, 2); + + let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap(); + assert!(other_content.contains("[[Sprint Retrospective]]")); + assert!(!other_content.contains("[[Weekly Review]]")); + + let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap(); + assert!(project_content.contains("[[Sprint Retrospective]]")); + } + + #[test] + fn test_rename_note_same_title_noop() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n"); + + let old_path = vault.join("note/my-note.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "My Note", + ).unwrap(); + + assert_eq!(result.new_path, old_path.to_str().unwrap()); + assert_eq!(result.updated_files, 0); + } + + #[test] + fn test_rename_note_empty_title_error() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/test.md", "# Test\n"); + + let old_path = vault.join("note/test.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + " ", + ); + assert!(result.is_err()); + } + + #[test] + fn test_rename_note_preserves_pipe_alias_in_wikilinks() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n"); + create_test_file(vault, "note/ref.md", "# Ref\n\nSee [[Weekly Review|my review]] for info.\n"); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "Sprint Retro", + ).unwrap(); + + assert_eq!(result.updated_files, 1); + let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap(); + assert!(ref_content.contains("[[Sprint Retro|my review]]")); + } + + #[test] + fn test_rename_note_updates_title_frontmatter() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/old.md", "---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n"); + + let old_path = vault.join("note/old.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "New Name", + ).unwrap(); + + let content = fs::read_to_string(&result.new_path).unwrap(); + assert!(content.contains("title: New Name")); + assert!(content.contains("# New Name")); + } }