diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index af76e9db..4cfb2c72 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -17,7 +17,7 @@ pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, p use parsing::{ contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title, - parse_iso_date, title_case_folder, + parse_iso_date, }; use gray_matter::engine::YAML; @@ -322,31 +322,6 @@ fn extract_properties( properties } -/// Infer entity type from a parent folder name. -fn infer_type_from_folder(folder: &str) -> String { - match folder { - "person" => "Person", - "project" => "Project", - "procedure" => "Procedure", - "responsibility" => "Responsibility", - "event" => "Event", - "topic" => "Topic", - "experiment" => "Experiment", - "type" => "Type", - "note" => "Note", - "quarter" => "Quarter", - "measure" => "Measure", - "target" => "Target", - "journal" => "Journal", - "month" => "Month", - "config" => "Config", - "essay" => "Essay", - "evergreen" => "Evergreen", - _ => return title_case_folder(folder), - } - .to_string() -} - /// Resolve `is_a` from frontmatter only. Folder-based inference is no longer used /// (flat vault: all notes live at vault root, type is purely frontmatter). fn resolve_is_a(fm_is_a: Option, _path: &Path) -> Option { diff --git a/src-tauri/src/vault/parsing.rs b/src-tauri/src/vault/parsing.rs index 8f9193fa..4d69e10d 100644 --- a/src-tauri/src/vault/parsing.rs +++ b/src-tauri/src/vault/parsing.rs @@ -193,24 +193,6 @@ pub(super) fn extract_outgoing_links(content: &str) -> Vec { links } -pub(super) fn capitalize_first(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - } -} - -/// Title-case a folder name: split on hyphens and underscores, capitalize each word, join with spaces. -/// Example: "monday-ideas" → "Monday Ideas", "key_result" → "Key Result" -pub(super) fn title_case_folder(s: &str) -> String { - s.split(['-', '_']) - .filter(|w| !w.is_empty()) - .map(capitalize_first) - .collect::>() - .join(" ") -} - /// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch). /// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats. pub(super) fn parse_iso_date(date_str: &str) -> Option { @@ -491,51 +473,6 @@ mod tests { assert_eq!(strip_markdown_chars(""), ""); } - // --- capitalize_first tests --- - - #[test] - fn test_capitalize_first_normal() { - assert_eq!(capitalize_first("person"), "Person"); - } - - #[test] - fn test_capitalize_first_already_capitalized() { - assert_eq!(capitalize_first("Project"), "Project"); - } - - #[test] - fn test_capitalize_first_empty() { - assert_eq!(capitalize_first(""), ""); - } - - #[test] - fn test_capitalize_first_single_char() { - assert_eq!(capitalize_first("a"), "A"); - } - - // --- title_case_folder tests --- - - #[test] - fn test_title_case_folder_hyphenated() { - assert_eq!(title_case_folder("monday-ideas"), "Monday Ideas"); - assert_eq!(title_case_folder("key-result"), "Key Result"); - } - - #[test] - fn test_title_case_folder_underscored() { - assert_eq!(title_case_folder("my_custom_type"), "My Custom Type"); - } - - #[test] - fn test_title_case_folder_single_word() { - assert_eq!(title_case_folder("recipe"), "Recipe"); - } - - #[test] - fn test_title_case_folder_empty() { - assert_eq!(title_case_folder(""), ""); - } - // --- without_h1_line tests --- #[test] diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index 3c055316..056db4fb 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -166,31 +166,6 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str { .unwrap_or(abs_path) } -/// Result of a move-to-type-folder operation. -#[derive(Debug, Serialize, Deserialize)] -pub struct MoveResult { - /// New absolute file path after move (same as old if no move happened). - pub new_path: String, - /// Number of other files updated (wikilink replacements). - pub updated_links: usize, - /// Whether the file was actually moved (false if already in the right folder). - pub moved: bool, -} - -/// Convert a type name to a folder slug. All known types are single lowercase words; -/// unknown types are slugified (lowercase, non-alphanumeric → hyphen). -fn type_to_folder_slug(type_name: &str) -> String { - type_name - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-") -} - /// Determine a unique destination path, appending -2, -3, etc. if a file already exists. fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf { let dest = dest_dir.join(filename); @@ -215,134 +190,6 @@ fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf { } } -/// Move a note to the folder corresponding to its new type, and update wikilinks across the vault. -/// -/// Returns `MoveResult` with `moved: false` if the note is already in the correct folder. -/// Creates the target folder if it does not exist. -pub fn move_note_to_type_folder( - vault_path: &str, - note_path: &str, - new_type: &str, -) -> Result { - let vault = Path::new(vault_path); - let old_file = Path::new(note_path); - - if !old_file.exists() { - return Err(format!("File does not exist: {}", note_path)); - } - let new_type = new_type.trim(); - if new_type.is_empty() { - return Err("Type cannot be empty".to_string()); - } - - let folder_slug = type_to_folder_slug(new_type); - - // Check if already in the correct folder - let current_folder = old_file - .parent() - .and_then(|p| p.file_name()) - .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_default(); - if current_folder == folder_slug { - return Ok(MoveResult { - new_path: note_path.to_string(), - updated_links: 0, - moved: false, - }); - } - - let filename = old_file - .file_name() - .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_default(); - - // Create target directory if needed - let dest_dir = vault.join(&folder_slug); - if !dest_dir.exists() { - fs::create_dir_all(&dest_dir) - .map_err(|e| format!("Failed to create directory {}: {}", dest_dir.display(), e))?; - } - - // Determine destination path (handle collisions) - let new_file = unique_dest_path(&dest_dir, &filename); - let new_path_str = new_file.to_string_lossy().to_string(); - - // Read content and move - let content = - fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", note_path, e))?; - fs::write(&new_file, &content) - .map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?; - fs::remove_file(old_file) - .map_err(|e| format!("Failed to remove old file {}: {}", note_path, e))?; - - // Extract title for wikilink matching - let old_filename = old_file - .file_name() - .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_default(); - let old_title = super::extract_title(&content, &old_filename); - - // Update wikilinks across the vault (title stays the same, path changes) - let vault_prefix = format!("{}/", vault.to_string_lossy()); - let old_path_stem = to_path_stem(note_path, &vault_prefix); - let new_path_stem = to_path_stem(&new_path_str, &vault_prefix); - - // Build pattern matching old path stem (e.g. "note/weekly-review") - let re = match build_wikilink_pattern(&old_title, old_path_stem) { - Some(r) => r, - None => { - return Ok(MoveResult { - new_path: new_path_str, - updated_links: 0, - moved: true, - }) - } - }; - - // Determine the replacement: if path-style wikilinks were used, update to new path. - // Title-style wikilinks [[My Note]] stay the same (title hasn't changed). - let files = collect_md_files(vault, &new_file); - let updated_links = files - .iter() - .filter(|path| { - let file_content = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => return false, - }; - if !re.is_match(&file_content) { - return false; - } - // Replace path-based wikilinks (old_path_stem → new_path_stem) - // and keep title-based wikilinks as-is. - let replaced = re.replace_all(&file_content, |caps: ®ex::Captures| { - let full_match = caps.get(0).map(|m| m.as_str()).unwrap_or(""); - let pipe = caps.get(1); - // If the match used the path stem, replace with new path stem - if full_match.contains(old_path_stem) { - match pipe { - Some(p) => format!("[[{}{}]]", new_path_stem, p.as_str()), - None => format!("[[{}]]", new_path_stem), - } - } else { - // Title-based link — keep as-is (title hasn't changed) - full_match.to_string() - } - }); - if replaced != file_content { - fs::write(path, replaced.as_ref()).is_ok() - } else { - false - } - }) - .count(); - - Ok(MoveResult { - new_path: new_path_str, - updated_links, - moved: true, - }) -} - /// Rename a note: update its title, rename the file, and update wiki links across the vault. /// /// When `old_title_hint` is provided it is used instead of extracting the title from @@ -753,213 +600,6 @@ mod tests { assert!(vault.join("note/my-note.md").exists()); } - // --- move_note_to_type_folder tests --- - - #[test] - fn test_type_to_folder_slug_known_types() { - assert_eq!(type_to_folder_slug("Person"), "person"); - assert_eq!(type_to_folder_slug("Project"), "project"); - assert_eq!(type_to_folder_slug("Quarter"), "quarter"); - assert_eq!(type_to_folder_slug("Note"), "note"); - } - - #[test] - fn test_type_to_folder_slug_unknown_types() { - assert_eq!(type_to_folder_slug("Key Result"), "key-result"); - assert_eq!(type_to_folder_slug("My Custom Type"), "my-custom-type"); - } - - #[test] - fn test_move_note_basic() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - create_test_file( - vault, - "note/weekly-review.md", - "---\ntype: Quarter\n---\n# Weekly Review\n\nContent here.\n", - ); - - let old_path = vault.join("note/weekly-review.md"); - let result = move_note_to_type_folder( - vault.to_str().unwrap(), - old_path.to_str().unwrap(), - "Quarter", - ) - .unwrap(); - - assert!(result.moved); - assert!(result.new_path.contains("/quarter/weekly-review.md")); - assert!(!old_path.exists()); - assert!(Path::new(&result.new_path).exists()); - } - - #[test] - fn test_move_note_already_in_correct_folder() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - create_test_file( - vault, - "quarter/weekly-review.md", - "---\ntype: Quarter\n---\n# Weekly Review\n", - ); - - let path = vault.join("quarter/weekly-review.md"); - let result = - move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "Quarter") - .unwrap(); - - assert!(!result.moved); - assert_eq!(result.new_path, path.to_str().unwrap()); - assert_eq!(result.updated_links, 0); - } - - #[test] - fn test_move_note_creates_target_folder() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - create_test_file( - vault, - "note/my-note.md", - "---\ntype: Quarter\n---\n# My Note\n", - ); - - let dest_dir = vault.join("quarter"); - assert!(!dest_dir.exists()); - - let old_path = vault.join("note/my-note.md"); - let result = move_note_to_type_folder( - vault.to_str().unwrap(), - old_path.to_str().unwrap(), - "Quarter", - ) - .unwrap(); - - assert!(result.moved); - assert!(dest_dir.exists()); - assert!(Path::new(&result.new_path).exists()); - } - - #[test] - fn test_move_note_filename_collision() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - create_test_file( - vault, - "note/my-note.md", - "---\ntype: Quarter\n---\n# My Note\n", - ); - create_test_file( - vault, - "quarter/my-note.md", - "---\ntype: Quarter\n---\n# Existing Note\n", - ); - - let old_path = vault.join("note/my-note.md"); - let result = move_note_to_type_folder( - vault.to_str().unwrap(), - old_path.to_str().unwrap(), - "Quarter", - ) - .unwrap(); - - assert!(result.moved); - assert!(result.new_path.contains("/quarter/my-note-2.md")); - assert!(!old_path.exists()); - assert!(Path::new(&result.new_path).exists()); - // Original file should still exist - assert!(vault.join("quarter/my-note.md").exists()); - } - - #[test] - fn test_move_note_updates_path_wikilinks() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - create_test_file( - vault, - "note/weekly-review.md", - "---\ntype: Quarter\n---\n# Weekly Review\n\nContent.\n", - ); - create_test_file( - vault, - "project/my-project.md", - "---\ntype: Project\n---\n# My Project\n\nSee [[note/weekly-review]] for details.\n", - ); - - let old_path = vault.join("note/weekly-review.md"); - let result = move_note_to_type_folder( - vault.to_str().unwrap(), - old_path.to_str().unwrap(), - "Quarter", - ) - .unwrap(); - - assert!(result.moved); - assert_eq!(result.updated_links, 1); - - let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap(); - assert!(project_content.contains("[[quarter/weekly-review]]")); - assert!(!project_content.contains("[[note/weekly-review]]")); - } - - #[test] - fn test_move_note_preserves_title_wikilinks() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - create_test_file( - vault, - "note/weekly-review.md", - "---\ntype: Quarter\n---\n# Weekly Review\n", - ); - create_test_file( - vault, - "note/other.md", - "---\ntype: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n", - ); - - let old_path = vault.join("note/weekly-review.md"); - let result = move_note_to_type_folder( - vault.to_str().unwrap(), - old_path.to_str().unwrap(), - "Quarter", - ) - .unwrap(); - - assert!(result.moved); - // Title-based wikilinks should be unchanged - let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap(); - assert!(other_content.contains("[[Weekly Review]]")); - } - - #[test] - fn test_move_note_collision_preserves_both_contents() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - let moving_content = - "---\ntype: Quarter\n---\n# Migrate newsletter to Beehiiv\n\nImportant content.\n"; - let existing_content = - "---\ntype: Quarter\n---\n# Feedback for Laputa\n\nCompletely different note.\n"; - create_test_file(vault, "note/my-note.md", moving_content); - create_test_file(vault, "quarter/my-note.md", existing_content); - - let old_path = vault.join("note/my-note.md"); - let result = move_note_to_type_folder( - vault.to_str().unwrap(), - old_path.to_str().unwrap(), - "Quarter", - ) - .unwrap(); - - assert!(result.moved); - // Must get a unique path, not the existing file's path - assert!(result.new_path.contains("/quarter/my-note-2.md")); - // Moved note must retain its own content - let moved_content = fs::read_to_string(&result.new_path).unwrap(); - assert_eq!(moved_content, moving_content); - // Existing note must be untouched - let untouched = fs::read_to_string(vault.join("quarter/my-note.md")).unwrap(); - assert_eq!(untouched, existing_content); - } - #[test] fn test_rename_note_with_old_title_hint_updates_wikilinks() { // Simulates H1 sync: content already saved with new H1, but wikilinks still use old title. @@ -1055,45 +695,4 @@ mod tests { assert_eq!(result.updated_files, 0); } - #[test] - fn test_move_note_empty_type_error() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - create_test_file(vault, "note/test.md", "# Test\n"); - - let path = vault.join("note/test.md"); - let result = move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), ""); - assert!(result.is_err()); - } - - #[test] - fn test_move_note_nonexistent_file_error() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - let result = move_note_to_type_folder( - vault.to_str().unwrap(), - vault.join("note/nope.md").to_str().unwrap(), - "Quarter", - ); - assert!(result.is_err()); - } - - #[test] - fn test_move_note_preserves_content() { - let dir = TempDir::new().unwrap(); - let vault = dir.path(); - let original = "---\ntype: Quarter\ntitle: My Note\n---\n# My Note\n\nImportant content.\n"; - create_test_file(vault, "note/my-note.md", original); - - let old_path = vault.join("note/my-note.md"); - let result = move_note_to_type_folder( - vault.to_str().unwrap(), - old_path.to_str().unwrap(), - "Quarter", - ) - .unwrap(); - - let content = fs::read_to_string(&result.new_path).unwrap(); - assert_eq!(content, original); - } }