From 2d2c162c3c719349f8ce9ff63b153c8a6f9aa1e2 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 8 Mar 2026 21:32:07 +0100 Subject: [PATCH] feat: add move_note_to_type_folder backend command Adds a new Tauri command that moves a note file to the folder corresponding to its new type when Is A is changed. Handles: - folder creation, filename collision (-2 suffix), wikilink updates, and no-op when already in the correct folder. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands.rs | 13 +- src-tauri/src/lib.rs | 1 + src-tauri/src/vault/mod.rs | 2 +- src-tauri/src/vault/rename.rs | 395 ++++++++++++++++++++++++++++++++ src/mock-tauri/mock-handlers.ts | 13 ++ 5 files changed, 422 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2f7b54b2..95e20508 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -13,7 +13,7 @@ use crate::indexing::{IndexStatus, IndexingProgress}; use crate::search::SearchResponse; use crate::settings::Settings; use crate::theme::{ThemeFile, VaultSettings}; -use crate::vault::{RenameResult, VaultEntry}; +use crate::vault::{MoveResult, RenameResult, VaultEntry}; use crate::vault_config::VaultConfig; use crate::vault_list::VaultList; use crate::{ @@ -90,6 +90,17 @@ pub fn rename_note( vault::rename_note(&vault_path, &old_path, &new_title) } +#[tauri::command] +pub fn move_note_to_type_folder( + vault_path: String, + note_path: String, + new_type: String, +) -> Result { + let vault_path = expand_tilde(&vault_path); + let note_path = expand_tilde(¬e_path); + vault::move_note_to_type_folder(&vault_path, ¬e_path, &new_type) +} + #[tauri::command] pub fn purge_trash(vault_path: String) -> Result, String> { let vault_path = expand_tilde(&vault_path); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c95a51d..b7a4ccbc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -118,6 +118,7 @@ pub fn run() { commands::update_frontmatter, commands::delete_frontmatter_property, commands::rename_note, + commands::move_note_to_type_folder, commands::get_file_history, commands::get_modified_files, commands::get_file_diff, diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 978dd953..bba5a281 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -12,7 +12,7 @@ pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files} pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists}; pub use image::{copy_image_to_vault, save_image}; pub use migration::migrate_is_a_to_type; -pub use rename::{rename_note, RenameResult}; +pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult}; pub use trash::{delete_note, purge_trash}; use parsing::{ diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index 6299f179..bd52f0f9 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -166,6 +166,183 @@ 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); + if !dest.exists() { + return dest; + } + let stem = Path::new(filename) + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let ext = Path::new(filename) + .extension() + .map(|s| format!(".{}", s.to_string_lossy())) + .unwrap_or_default(); + let mut counter = 2; + loop { + let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext)); + if !candidate.exists() { + return candidate; + } + counter += 1; + } +} + +/// 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. pub fn rename_note( vault_path: &str, @@ -471,4 +648,222 @@ mod tests { assert!(content.contains("title: Renamed Note")); assert!(content.contains("# Renamed Note")); } + + // --- 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_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); + } } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 833b332c..20d8bd47 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -219,6 +219,19 @@ export const mockHandlers: Record any> = { load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }), save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null }, rename_note: handleRenameNote, + move_note_to_type_folder: (args: { vault_path: string; note_path: string; new_type: string }) => { + const slug = args.new_type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + const currentFolder = args.note_path.replace(/\/[^/]+$/, '').split('/').pop() ?? '' + if (currentFolder === slug) return { new_path: args.note_path, updated_links: 0, moved: false } + const filename = args.note_path.split('/').pop() ?? '' + const vaultPath = args.vault_path.replace(/\/$/, '') + const newPath = `${vaultPath}/${slug}/${filename}` + const content = MOCK_CONTENT[args.note_path] ?? '' + delete MOCK_CONTENT[args.note_path] + MOCK_CONTENT[newPath] = content + syncWindowContent() + return { new_path: newPath, updated_links: 0, moved: true } + }, github_list_repos: () => [ { name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' }, { name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },