|
|
|
|
@@ -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::<String>()
|
|
|
|
|
.split('-')
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.collect::<Vec<&str>>()
|
|
|
|
|
.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<MoveResult, String> {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|