From 69f50e595094e720c1a3fa8814b7fc48babab272 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 15 Mar 2026 19:43:00 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20flatten=20vault=20structure=20=E2=80=94?= =?UTF-8?q?=20remove=20type-based=20folders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scan_vault now scans root + system folders (type/, config/, theme/) only - Type determined purely by frontmatter, no folder inference - Remove move_note_to_type_folder (Rust command + frontend logic) - Remove TYPE_FOLDER_MAP — new notes created at vault root - Add migrate_to_flat_vault command (moves subfolder notes to root, updates path-based wikilinks to title-based, cleans empty dirs) - Migration runs automatically via Cmd+K > Repair Vault - Update getting_started sample files for flat structure - Update all tests (611 cargo + 2131 vitest passing) Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands.rs | 30 +- src-tauri/src/lib.rs | 2 +- src-tauri/src/vault/getting_started.rs | 142 ++++---- src-tauri/src/vault/migration.rs | 358 +++++++++++++++++++ src-tauri/src/vault/mod.rs | 190 +++++----- src/hooks/useNoteActions.test.ts | 191 ++-------- src/hooks/useNoteActions.ts | 60 +--- src/mock-tauri/mock-handlers.ts | 22 -- tests/smoke/move-note-to-type-folder.spec.ts | 67 ---- 9 files changed, 570 insertions(+), 492 deletions(-) delete mode 100644 tests/smoke/move-note-to-type-folder.spec.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ad574f6d..256f70e3 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::{MoveResult, RenameResult, VaultEntry}; +use crate::vault::{MigrationResult, RenameResult, VaultEntry}; use crate::vault_config::VaultConfig; use crate::vault_list::VaultList; use crate::{ @@ -91,17 +91,6 @@ pub fn rename_note( vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref()) } -#[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); @@ -132,6 +121,12 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result { vault::migrate_is_a_to_type(&vault_path) } +#[tauri::command] +pub fn migrate_to_flat_vault(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::migrate_to_flat_vault(&vault_path) +} + #[tauri::command] pub fn create_getting_started_vault(target_path: Option) -> Result { let path = match target_path { @@ -551,11 +546,20 @@ pub fn restore_default_themes(vault_path: String) -> Result { #[tauri::command] pub fn repair_vault(vault_path: String) -> Result { let vault_path = expand_tilde(&vault_path); + // Migrate to flat vault (move notes from type folders to root) + let migration = vault::migrate_to_flat_vault(&vault_path)?; // Repair themes theme::restore_default_themes(&vault_path)?; // Repair config files (config/agents.md, type/config.md, AGENTS.md stub) vault::repair_config_files(&vault_path)?; - Ok("Vault repaired".to_string()) + if migration.moved > 0 { + Ok(format!( + "Vault repaired — migrated {} notes to flat structure", + migration.moved + )) + } else { + Ok("Vault repaired".to_string()) + } } // ── Settings & config commands ────────────────────────────────────────────── diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 643477ce..7b996440 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -118,7 +118,6 @@ 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, @@ -146,6 +145,7 @@ pub fn run() { commands::batch_delete_notes, commands::empty_trash, commands::migrate_is_a_to_type, + commands::migrate_to_flat_vault, commands::batch_archive_notes, commands::batch_trash_notes, commands::get_settings, diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index 57a391dd..6cdc207c 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -19,31 +19,23 @@ struct SampleFile { } /// Content for config/agents.md — vault instructions for AI agents. -/// This file has no YAML frontmatter — it is a convention file for AI agents, -/// not a vault note. The vault scanner will still pick it up as a regular entry. -pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents +pub(super) const AGENTS_MD: &str = r#"--- +type: Config +--- + +# AGENTS.md — Vault Instructions for AI Agents This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph. ## Structure -Files are organized in folders by type: +Notes live at the vault root as flat `.md` files. Type is determined by the `type` field in frontmatter, not by folder. System folders: -| Folder | Type | Purpose | -|--------|------|---------| -| `note/` | Note | General-purpose documents, research, meeting notes | -| `project/` | Project | Time-bounded efforts with clear goals | -| `person/` | Person | People — colleagues, collaborators, contacts | -| `topic/` | Topic | Subject areas that group related notes | -| `responsibility/` | Responsibility | Long-running duties with KPIs | -| `procedure/` | Procedure | Recurring workflows (weekly, monthly) | -| `event/` | Event | Something that happened on a specific date | -| `quarter/` | Quarter | Time containers (e.g. 24Q1) | -| `measure/` | Measure | Trackable metrics tied to responsibilities | -| `target/` | Target | Time-bound goals for a measure | -| `type/` | Type | Type definitions — icon, color, ordering | - -Custom folders are valid — the folder name becomes the type (capitalized). +| Folder | Purpose | +|--------|---------| +| `type/` | Type definitions — icon, color, ordering | +| `config/` | Vault configuration files | +| `theme/` | Visual theme definitions | ## Frontmatter @@ -53,11 +45,11 @@ YAML frontmatter between `---` delimiters defines metadata: --- type: Project Status: Active -Owner: "[[person/jane-doe]]" -Belongs to: "[[quarter/24q1]]" +Owner: "[[Jane Doe]]" +Belongs to: "[[24Q1]]" Related to: - - "[[topic/growth]]" - - "[[note/research-findings]]" + - "[[Growth]]" + - "[[Research Findings]]" --- ``` @@ -65,7 +57,7 @@ Related to: | Field | Purpose | |-------|---------| -| `type` | Entity type (usually inferred from folder) | +| `type` | Entity type (required — determines note category) | | `Status` | Active, Done, Paused, Archived, Dropped | | `Owner` | Person responsible (wikilink) | | `Belongs to` | Parent relationship(s) | @@ -78,17 +70,17 @@ Related to: Any YAML field containing `[[wikilinks]]` becomes a navigable relationship: ```yaml -Has Measures: ["[[measure/revenue]]", "[[measure/churn]]"] -Resources: "[[note/api-docs]]" +Has Measures: ["[[Revenue]]", "[[Churn]]"] +Resources: "[[API Docs]]" ``` ## Wikilinks Connect notes with double-bracket syntax: -- `[[note/my-note]]` — link by path -- `[[My Note Title]]` — link by title or alias -- `[[note/my-note|display text]]` — link with custom display text +- `[[My Note Title]]` — link by title (primary) +- `[[my-note-title]]` — link by filename stem +- `[[My Note|display text]]` — link with custom display text Wikilinks work in both frontmatter values and markdown body. Backlinks are computed automatically — linking A to B makes B show a backlink to A. @@ -111,8 +103,8 @@ Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor n - First `# Heading` in a file becomes its title - One entity per file -- Filenames use kebab-case: `my-note-title.md` -- Type is inferred from parent folder if not set in frontmatter +- Filenames use kebab-case: `my-note-title.md` (= slugified title) +- Type is set via `type:` in frontmatter (required for non-root notes) - Relationships are bidirectional via automatic backlinks "#; @@ -142,13 +134,13 @@ const SAMPLE_FILES: &[SampleFile] = &[ content: "---\ntype: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n", }, SampleFile { - rel_path: "note/welcome-to-laputa.md", + rel_path: "welcome-to-laputa.md", content: r#"--- type: Note Related to: - - "[[note/editor-basics]]" - - "[[note/using-properties]]" - - "[[note/wiki-links-and-relationships]]" + - "[[Editor Basics]]" + - "[[Using Properties]]" + - "[[Wiki-Links and Relationships]]" --- # Welcome to Laputa @@ -157,15 +149,15 @@ Welcome to your new knowledge vault! Laputa helps you organize your thoughts, pr ## How it works -Every note is a markdown file with optional YAML frontmatter at the top. Notes live in folders that define their **type** — a file in the `project/` folder is automatically a Project, a file in `person/` is a Person, and so on. +Every note is a markdown file with optional YAML frontmatter at the top. The `type` field in frontmatter determines what kind of note it is — Project, Person, Note, etc. ## What to explore -- [[note/editor-basics]] — Learn about headings, lists, checkboxes, and formatting -- [[note/using-properties]] — See how frontmatter properties work (status, dates, relationships) -- [[note/wiki-links-and-relationships]] — Connect your notes with `[[wiki-links]]` -- [[project/sample-project]] — A sample project with relationships and status -- [[person/sample-collaborator]] — A sample person entry +- [[Editor Basics]] — Learn about headings, lists, checkboxes, and formatting +- [[Using Properties]] — See how frontmatter properties work (status, dates, relationships) +- [[Wiki-Links and Relationships]] — Connect your notes with `[[wiki-links]]` +- [[Sample Project]] — A sample project with relationships and status +- [[Sample Collaborator]] — A sample person entry ## Tips @@ -177,10 +169,10 @@ Every note is a markdown file with optional YAML frontmatter at the top. Notes l "#, }, SampleFile { - rel_path: "note/editor-basics.md", + rel_path: "editor-basics.md", content: r#"--- type: Note -Related to: "[[note/welcome-to-laputa]]" +Related to: "[[Welcome to Laputa]]" --- # Editor Basics @@ -225,13 +217,13 @@ function hello() { "#, }, SampleFile { - rel_path: "note/using-properties.md", + rel_path: "using-properties.md", content: r#"--- type: Note Status: Active Related to: - - "[[note/welcome-to-laputa]]" - - "[[note/wiki-links-and-relationships]]" + - "[[Welcome to Laputa]]" + - "[[Wiki-Links and Relationships]]" --- # Using Properties @@ -259,12 +251,12 @@ You can add any custom property. If the value contains `[[wiki-links]]`, Laputa "#, }, SampleFile { - rel_path: "note/wiki-links-and-relationships.md", + rel_path: "wiki-links-and-relationships.md", content: r#"--- type: Note Related to: - - "[[note/welcome-to-laputa]]" - - "[[note/using-properties]]" + - "[[Welcome to Laputa]]" + - "[[Using Properties]]" --- # Wiki-Links and Relationships @@ -273,7 +265,7 @@ Wiki-links are the core of Laputa's knowledge graph. They let you connect any no ## Creating links -Type `[[` in the editor to open the link suggestion menu. Start typing to search for a note, then select it. The link will look like this: [[note/welcome-to-laputa]]. +Type `[[` in the editor to open the link suggestion menu. Start typing to search for a note, then select it. The link will look like this: [[Welcome to Laputa]]. ## Backlinks @@ -284,10 +276,10 @@ When note A links to note B, note B automatically shows a **backlink** to note A You can also define relationships in the frontmatter: ```yaml -Belongs to: "[[project/sample-project]]" +Belongs to: "[[Sample Project]]" Related to: - - "[[note/editor-basics]]" - - "[[note/using-properties]]" + - "[[Editor Basics]]" + - "[[Using Properties]]" ``` These appear as clickable pills in the inspector and are navigable with a single click. @@ -298,12 +290,12 @@ Over time, your wiki-links form a rich web of connections. Use the **Referenced "#, }, SampleFile { - rel_path: "project/sample-project.md", + rel_path: "sample-project.md", content: r#"--- type: Project Status: Active -Owner: "[[person/sample-collaborator]]" -Related to: "[[topic/getting-started]]" +Owner: "[[Sample Collaborator]]" +Related to: "[[Getting Started]]" --- # Sample Project @@ -323,11 +315,11 @@ Projects are time-bounded efforts with clear goals. They have a **status** (Acti ## Notes -This project is owned by [[person/sample-collaborator]] and relates to [[topic/getting-started]]. You can see these relationships in the inspector panel on the right. +This project is owned by [[Sample Collaborator]] and relates to [[Getting Started]]. You can see these relationships in the inspector panel on the right. "#, }, SampleFile { - rel_path: "person/sample-collaborator.md", + rel_path: "sample-collaborator.md", content: r#"--- type: Person --- @@ -344,11 +336,11 @@ This is an example person entry. In your vault, you might create entries for col ## Connections -This person is the owner of [[project/sample-project]]. Check the **Referenced By** section in the inspector to see all notes that link back here. +This person is the owner of [[Sample Project]]. Check the **Referenced By** section in the inspector to see all notes that link back here. "#, }, SampleFile { - rel_path: "topic/getting-started.md", + rel_path: "getting-started.md", content: r#"--- type: Topic --- @@ -359,11 +351,11 @@ This topic groups notes related to learning and getting started with Laputa. ## Related notes -- [[note/welcome-to-laputa]] — Start here for an overview -- [[note/editor-basics]] — Formatting and editor features -- [[note/using-properties]] — Frontmatter and the inspector -- [[note/wiki-links-and-relationships]] — Building your knowledge graph -- [[project/sample-project]] — A sample project with relationships +- [[Welcome to Laputa]] — Start here for an overview +- [[Editor Basics]] — Formatting and editor features +- [[Using Properties]] — Frontmatter and the inspector +- [[Wiki-Links and Relationships]] — Building your knowledge graph +- [[Sample Project]] — A sample project with relationships "#, }, ]; @@ -475,18 +467,16 @@ mod tests { let result = create_getting_started_vault(vault_path.to_str().unwrap()); assert!(result.is_ok()); - // Verify key files exist + // Verify key files exist (flat vault: notes at root, types in type/) assert!(vault_path.join("config/agents.md").exists()); assert!(vault_path.join("AGENTS.md").exists()); - assert!(vault_path.join("note/welcome-to-laputa.md").exists()); - assert!(vault_path.join("note/editor-basics.md").exists()); - assert!(vault_path.join("note/using-properties.md").exists()); - assert!(vault_path - .join("note/wiki-links-and-relationships.md") - .exists()); - assert!(vault_path.join("project/sample-project.md").exists()); - assert!(vault_path.join("person/sample-collaborator.md").exists()); - assert!(vault_path.join("topic/getting-started.md").exists()); + assert!(vault_path.join("welcome-to-laputa.md").exists()); + assert!(vault_path.join("editor-basics.md").exists()); + assert!(vault_path.join("using-properties.md").exists()); + assert!(vault_path.join("wiki-links-and-relationships.md").exists()); + assert!(vault_path.join("sample-project.md").exists()); + assert!(vault_path.join("sample-collaborator.md").exists()); + assert!(vault_path.join("getting-started.md").exists()); assert!(vault_path.join("type/project.md").exists()); assert!(vault_path.join("type/note.md").exists()); assert!(vault_path.join("type/person.md").exists()); @@ -546,7 +536,7 @@ mod tests { create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); let entries = crate::vault::scan_vault(&vault_path).unwrap(); - // SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes + // SAMPLE_FILES (root + type/) + config/agents.md + AGENTS.md stub + 3 vault theme notes assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3); } diff --git a/src-tauri/src/vault/migration.rs b/src-tauri/src/vault/migration.rs index 983ce553..f61ca3f0 100644 --- a/src-tauri/src/vault/migration.rs +++ b/src-tauri/src/vault/migration.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; use walkdir::WalkDir; @@ -115,6 +116,231 @@ pub fn migrate_is_a_to_type(vault_path: &str) -> Result { Ok(migrated) } +/// Folders that are system folders and should NOT be migrated (notes stay there). +const SYSTEM_FOLDERS: &[&str] = &["type", "config", "theme"]; + +/// Result of the flat vault migration. +#[derive(Debug, Serialize, Deserialize)] +pub struct MigrationResult { + /// Number of files moved to the vault root. + pub moved: usize, + /// Number of files skipped (already at root, or in system folders). + pub skipped: usize, + /// Files that could not be moved (collision, error). + pub errors: Vec, +} + +/// Determine a unique destination path at vault root, appending -2, -3, etc. if needed. +fn unique_root_path(vault: &Path, filename: &str) -> std::path::PathBuf { + let dest = vault.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 = vault.join(format!("{}-{}{}", stem, counter, ext)); + if !candidate.exists() { + return candidate; + } + counter += 1; + } +} + +/// Check if a directory entry is inside a system folder. +fn is_in_system_folder(path: &Path, vault: &Path) -> bool { + let rel = path + .strip_prefix(vault) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + SYSTEM_FOLDERS + .iter() + .any(|sf| rel.starts_with(&format!("{}/", sf)) || rel == *sf) +} + +/// Migrate an existing vault to flat structure: move all .md files from type-based +/// subfolders to the vault root. System folders (type/, config/, theme/) are skipped. +/// Wikilinks that used path-based references are updated to use the title. +pub fn migrate_to_flat_vault(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + if !vault.exists() || !vault.is_dir() { + return Err(format!( + "Vault path does not exist or is not a directory: {}", + vault_path + )); + } + + // Collect all .md files in subfolders (not root, not system folders) + let mut files_to_move: Vec = Vec::new(); + for entry in WalkDir::new(vault) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) { + continue; + } + // Skip files already at vault root + if path.parent() == Some(vault) { + continue; + } + // Skip system folders + if is_in_system_folder(path, vault) { + continue; + } + files_to_move.push(path.to_path_buf()); + } + + let mut result = MigrationResult { + moved: 0, + skipped: 0, + errors: Vec::new(), + }; + + // Build a map of old path stems → titles for wikilink updating + let mut path_stem_to_title: Vec<(String, String)> = Vec::new(); + + for file in &files_to_move { + let filename = file + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Read content to extract title + let content = match fs::read_to_string(file) { + Ok(c) => c, + Err(e) => { + result + .errors + .push(format!("Failed to read {}: {}", file.display(), e)); + continue; + } + }; + + let title = super::extract_title(&content, &filename); + + // Compute old path stem for wikilink replacement + let vault_prefix = format!("{}/", vault.to_string_lossy()); + let old_path_stem = file + .to_string_lossy() + .strip_prefix(&vault_prefix) + .unwrap_or(&file.to_string_lossy()) + .strip_suffix(".md") + .unwrap_or(&file.to_string_lossy()) + .to_string(); + + path_stem_to_title.push((old_path_stem, title)); + + // Move file to vault root + let new_path = unique_root_path(vault, &filename); + match fs::rename(file, &new_path) { + Ok(()) => result.moved += 1, + Err(e) => { + // Try copy+delete for cross-device moves + match fs::read_to_string(file) + .and_then(|c| fs::write(&new_path, c)) + .and_then(|()| fs::remove_file(file)) + { + Ok(()) => result.moved += 1, + Err(_) => { + result.errors.push(format!( + "Failed to move {} → {}: {}", + file.display(), + new_path.display(), + e + )); + } + } + } + } + } + + // Update wikilinks: replace path-based wikilinks [[folder/slug]] with [[Title]] + if !path_stem_to_title.is_empty() { + update_wikilinks_for_migration(vault, &path_stem_to_title); + } + + // Clean up empty directories (except system folders) + cleanup_empty_dirs(vault); + + Ok(result) +} + +/// Update wikilinks across all vault .md files, replacing path-based references +/// with title-based ones. +fn update_wikilinks_for_migration(vault: &Path, replacements: &[(String, String)]) { + let all_md: Vec = WalkDir::new(vault) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md")) + .map(|e| e.path().to_path_buf()) + .collect(); + + for file in &all_md { + let Ok(mut content) = fs::read_to_string(file) else { + continue; + }; + let mut changed = false; + for (old_stem, title) in replacements { + // Replace [[old/path/stem]] with [[Title]] + let old_link = format!("[[{}]]", old_stem); + if content.contains(&old_link) { + content = content.replace(&old_link, &format!("[[{}]]", title)); + changed = true; + } + // Replace [[old/path/stem|display]] with [[Title|display]] + let old_prefix = format!("[[{}|", old_stem); + if content.contains(&old_prefix) { + content = content.replace(&old_prefix, &format!("[[{}|", title)); + changed = true; + } + } + if changed { + let _ = fs::write(file, &content); + } + } +} + +/// Remove empty directories in the vault (excluding system folders). +fn cleanup_empty_dirs(vault: &Path) { + let Ok(entries) = fs::read_dir(vault) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if SYSTEM_FOLDERS.contains(&name.as_str()) { + continue; + } + // Skip hidden directories (e.g., .git) + if name.starts_with('.') { + continue; + } + // Check if directory is empty + let is_empty = fs::read_dir(&path) + .map(|mut d| d.next().is_none()) + .unwrap_or(false); + if is_empty { + let _ = fs::remove_dir(&path); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -253,4 +479,136 @@ mod tests { let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap(); assert_eq!(count, 0, "non-markdown files should be ignored"); } + + // --- migrate_to_flat_vault tests --- + + fn write_sub_file(dir: &Path, subdir: &str, name: &str, content: &str) -> std::path::PathBuf { + let sub = dir.join(subdir); + fs::create_dir_all(&sub).unwrap(); + let path = sub.join(name); + fs::write(&path, content).unwrap(); + path + } + + #[test] + fn test_flat_migration_moves_files_to_root() { + let tmp = tempdir().unwrap(); + let vault = tmp.path(); + write_sub_file(vault, "note", "my-note.md", "# My Note\n\nContent.\n"); + write_sub_file( + vault, + "project", + "alpha.md", + "---\ntype: Project\n---\n# Alpha\n", + ); + + let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap(); + assert_eq!(result.moved, 2); + assert!(vault.join("my-note.md").exists()); + assert!(vault.join("alpha.md").exists()); + assert!(!vault.join("note/my-note.md").exists()); + assert!(!vault.join("project/alpha.md").exists()); + } + + #[test] + fn test_flat_migration_skips_system_folders() { + let tmp = tempdir().unwrap(); + let vault = tmp.path(); + write_sub_file(vault, "type", "project.md", "---\ntype: Type\n---\n# Project\n"); + write_sub_file(vault, "config", "agents.md", "# Agents\n"); + write_sub_file(vault, "note", "test.md", "# Test\n"); + + let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap(); + assert_eq!(result.moved, 1); + // System files should stay + assert!(vault.join("type/project.md").exists()); + assert!(vault.join("config/agents.md").exists()); + // Regular file should be moved + assert!(vault.join("test.md").exists()); + } + + #[test] + fn test_flat_migration_skips_root_files() { + let tmp = tempdir().unwrap(); + let vault = tmp.path(); + write_file(vault, "already-at-root.md", "# Already Root\n"); + write_sub_file(vault, "note", "in-sub.md", "# In Sub\n"); + + let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap(); + assert_eq!(result.moved, 1); + assert!(vault.join("already-at-root.md").exists()); + assert!(vault.join("in-sub.md").exists()); + } + + #[test] + fn test_flat_migration_handles_filename_collision() { + let tmp = tempdir().unwrap(); + let vault = tmp.path(); + write_file(vault, "test.md", "# Root Test\n"); + write_sub_file(vault, "note", "test.md", "# Note Test\n"); + + let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap(); + assert_eq!(result.moved, 1); + assert!(vault.join("test.md").exists()); + assert!(vault.join("test-2.md").exists()); + } + + #[test] + fn test_flat_migration_updates_path_wikilinks() { + let tmp = tempdir().unwrap(); + let vault = tmp.path(); + write_sub_file( + vault, + "person", + "alice.md", + "---\ntype: Person\n---\n# Alice\n", + ); + write_file( + vault, + "root-note.md", + "# Root\n\nSee [[person/alice]] for details.\n", + ); + + let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap(); + assert_eq!(result.moved, 1); + + let content = fs::read_to_string(vault.join("root-note.md")).unwrap(); + assert!( + content.contains("[[Alice]]"), + "should update path-based wikilink to title: {}", + content + ); + assert!(!content.contains("[[person/alice]]")); + } + + #[test] + fn test_flat_migration_cleans_up_empty_dirs() { + let tmp = tempdir().unwrap(); + let vault = tmp.path(); + write_sub_file(vault, "note", "test.md", "# Test\n"); + + let _ = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap(); + assert!(!vault.join("note").exists(), "empty note/ dir should be removed"); + } + + #[test] + fn test_flat_migration_preserves_nonempty_dirs() { + let tmp = tempdir().unwrap(); + let vault = tmp.path(); + write_sub_file(vault, "custom", "note.md", "# Note\n"); + // Add a non-md file so dir isn't empty after migration + write_sub_file(vault, "custom", "image.png", "binary data"); + + let _ = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap(); + assert!( + vault.join("custom").exists(), + "non-empty dir should be preserved" + ); + } + + #[test] + fn test_flat_migration_nonexistent_vault() { + let result = migrate_to_flat_vault("/tmp/nonexistent-vault-flat-test"); + assert!(result.is_err()); + } } diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 651ad572..af76e9db 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -11,8 +11,8 @@ pub use cache::{invalidate_cache, scan_vault_cached}; 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::{move_note_to_type_folder, rename_note, MoveResult, RenameResult}; +pub use migration::{migrate_is_a_to_type, migrate_to_flat_vault, MigrationResult}; +pub use rename::{rename_note, RenameResult}; pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash}; use parsing::{ @@ -347,15 +347,10 @@ fn infer_type_from_folder(folder: &str) -> String { .to_string() } -/// Resolve `is_a` from frontmatter, falling back to parent folder inference. -fn resolve_is_a(fm_is_a: Option, path: &Path) -> Option { - fm_is_a - .and_then(|a| a.into_vec().into_iter().next()) - .or_else(|| { - path.parent() - .and_then(|p| p.file_name()) - .map(|f| infer_type_from_folder(&f.to_string_lossy())) - }) +/// 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 { + fm_is_a.and_then(|a| a.into_vec().into_iter().next()) } /// Parse created_at from frontmatter (prefer "Created at" over "Created time"). @@ -548,7 +543,32 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> { fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e)) } -/// Scan a directory recursively for .md files and return VaultEntry for each. +/// Folders that are scanned recursively (system folders). +/// All other .md files must live at the vault root. +const SYSTEM_FOLDERS: &[&str] = &["type", "config", "theme"]; + +/// Check if a path is a .md file. +fn is_md_file(path: &Path) -> bool { + path.is_file() && path.extension().is_some_and(|ext| ext == "md") +} + +/// Parse all .md files in a single directory (non-recursive). +fn scan_dir_flat(dir: &Path, entries: &mut Vec) { + let Ok(read_dir) = fs::read_dir(dir) else { + return; + }; + for item in read_dir.flatten() { + let p = item.path(); + if is_md_file(&p) { + match parse_md_file(&p) { + Ok(e) => entries.push(e), + Err(e) => log::warn!("Skipping file: {}", e), + } + } + } +} + +/// Scan a vault for .md files: root-level files plus system subfolders (type/, config/, theme/). pub fn scan_vault(vault_path: &Path) -> Result, String> { if !vault_path.exists() { return Err(format!( @@ -564,22 +584,25 @@ pub fn scan_vault(vault_path: &Path) -> Result, String> { } let mut entries = Vec::new(); - for entry in WalkDir::new(vault_path) - .follow_links(true) - .into_iter() - .filter_map(|e| e.ok()) - { - let entry_path = entry.path(); - if entry_path.is_file() - && entry_path - .extension() - .map(|ext| ext == "md") - .unwrap_or(false) - { - match parse_md_file(entry_path) { - Ok(vault_entry) => entries.push(vault_entry), - Err(e) => { - log::warn!("Skipping file: {}", e); + + // Scan root-level .md files + scan_dir_flat(vault_path, &mut entries); + + // Scan system subfolders recursively + for folder in SYSTEM_FOLDERS { + let sub = vault_path.join(folder); + if sub.is_dir() { + for item in WalkDir::new(&sub) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let p = item.path(); + if is_md_file(p) { + match parse_md_file(p) { + Ok(e) => entries.push(e), + Err(e) => log::warn!("Skipping file: {}", e), + } } } } @@ -707,22 +730,35 @@ mod tests { } #[test] - fn test_scan_vault_recursive() { + fn test_scan_vault_flat_root_plus_system_folders() { let dir = TempDir::new().unwrap(); create_test_file(dir.path(), "root.md", "# Root Note\n"); create_test_file( dir.path(), - "sub/nested.md", - "---\nIs A: Task\n---\n# Nested\n", + "type/project.md", + "---\ntype: Type\n---\n# Project\n", + ); + create_test_file( + dir.path(), + "config/agents.md", + "# Agents\n", + ); + // Files in non-system subfolders should be IGNORED by scan_vault + create_test_file( + dir.path(), + "old-folder/nested.md", + "---\ntype: Task\n---\n# Nested\n", ); create_test_file(dir.path(), "not-markdown.txt", "This should be ignored"); let entries = scan_vault(dir.path()).unwrap(); - assert_eq!(entries.len(), 2); + assert_eq!(entries.len(), 3, "should find root + type/ + config/ files only"); let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect(); assert!(filenames.contains(&"root.md")); - assert!(filenames.contains(&"nested.md")); + assert!(filenames.contains(&"project.md")); + assert!(filenames.contains(&"agents.md")); + assert!(!filenames.contains(&"nested.md"), "non-system subfolder files should be excluded"); } #[test] @@ -1024,78 +1060,36 @@ References: ); } - // --- infer_type_from_folder tests --- + // --- flat vault: type is frontmatter-only --- #[test] - fn test_infer_type_from_known_folders() { + fn test_no_type_inference_from_folder() { let dir = TempDir::new().unwrap(); - let known_folders = vec![ - ("person", "Person"), - ("project", "Project"), - ("procedure", "Procedure"), - ("responsibility", "Responsibility"), - ("event", "Event"), - ("topic", "Topic"), - ("experiment", "Experiment"), - ("note", "Note"), - ("quarter", "Quarter"), - ("measure", "Measure"), - ("target", "Target"), - ("journal", "Journal"), - ("month", "Month"), - ("essay", "Essay"), - ("evergreen", "Evergreen"), - ]; - for (folder, expected_type) in known_folders { - create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n"); - let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap(); - assert_eq!( - entry.is_a, - Some(expected_type.to_string()), - "folder '{}' should infer type '{}'", - folder, - expected_type - ); - } + // File in a folder named "person" should NOT infer type from folder + create_test_file(dir.path(), "person/test.md", "# Test\n"); + let entry = parse_md_file(&dir.path().join("person/test.md")).unwrap(); + assert_eq!(entry.is_a, None, "flat vault: folder should not infer type"); } #[test] - fn test_infer_type_from_unknown_folder_capitalizes() { + fn test_type_from_frontmatter_only() { let dir = TempDir::new().unwrap(); - create_test_file(dir.path(), "recipe/test.md", "# Test\n"); - let entry = parse_md_file(&dir.path().join("recipe/test.md")).unwrap(); - assert_eq!(entry.is_a, Some("Recipe".to_string())); + create_test_file( + dir.path(), + "test.md", + "---\ntype: Person\n---\n# Test\n", + ); + let entry = parse_md_file(&dir.path().join("test.md")).unwrap(); + assert_eq!(entry.is_a, Some("Person".to_string())); } #[test] - fn test_infer_type_from_hyphenated_folder_title_cases() { - let dir = TempDir::new().unwrap(); - let cases = vec![ - ("monday-ideas", "Monday Ideas"), - ("key-result", "Key Result"), - ("my_custom_type", "My Custom Type"), - ("mix-and_match", "Mix And Match"), - ]; - for (folder, expected) in cases { - create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n"); - let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap(); - assert_eq!( - entry.is_a, - Some(expected.to_string()), - "folder '{}' should infer type '{}'", - folder, - expected - ); - } - } - - #[test] - fn test_infer_type_frontmatter_overrides_folder() { + fn test_type_from_frontmatter_in_subfolder() { let dir = TempDir::new().unwrap(); create_test_file( dir.path(), "person/test.md", - "---\nIs A: Custom\n---\n# Test\n", + "---\ntype: Custom\n---\n# Test\n", ); let entry = parse_md_file(&dir.path().join("person/test.md")).unwrap(); assert_eq!(entry.is_a, Some("Custom".to_string())); @@ -1145,14 +1139,14 @@ References: } #[test] - fn test_type_relationship_from_folder_inference() { + fn test_no_type_relationship_without_frontmatter() { let dir = TempDir::new().unwrap(); let content = "# A Person\n\nSome content."; let entry = parse_test_entry(&dir, "person/someone.md", content); - assert_eq!(entry.is_a, Some("Person".to_string())); - assert_eq!( - entry.relationships.get("Type").unwrap(), - &vec!["[[type/person]]".to_string()] + assert_eq!(entry.is_a, None, "flat vault: no type inference from folder"); + assert!( + entry.relationships.get("Type").is_none(), + "no type relationship without frontmatter type" ); } @@ -1168,11 +1162,11 @@ References: } #[test] - fn test_type_folder_inferred_as_type() { + fn test_type_folder_not_inferred_without_frontmatter() { let dir = TempDir::new().unwrap(); let content = "# Some Type\n"; let entry = parse_test_entry(&dir, "type/some-type.md", content); - assert_eq!(entry.is_a, Some("Type".to_string())); + assert_eq!(entry.is_a, None, "flat vault: type folder no longer infers type"); } // --- type key (post-migration) tests --- diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index cacba958..18082112 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -270,18 +270,18 @@ describe('DEFAULT_TEMPLATES', () => { }) describe('resolveNewNote', () => { - it('uses TYPE_FOLDER_MAP for known types', () => { + it('creates notes at vault root', () => { const { entry, content } = resolveNewNote('My Project', 'Project', '/my/vault') - expect(entry.path).toBe('/my/vault/project/my-project.md') + expect(entry.path).toBe('/my/vault/my-project.md') expect(entry.isA).toBe('Project') expect(entry.status).toBe('Active') expect(content).toContain('type: Project') expect(content).toContain('status: Active') }) - it('falls back to slugified type for custom types', () => { + it('creates custom type notes at vault root', () => { const { entry } = resolveNewNote('First Recipe', 'Recipe', '/my/vault') - expect(entry.path).toBe('/my/vault/recipe/first-recipe.md') + expect(entry.path).toBe('/my/vault/first-recipe.md') }) it('omits status for Topic type', () => { @@ -297,7 +297,7 @@ describe('resolveNewNote', () => { it('uses provided vault path instead of hardcoded path', () => { const { entry } = resolveNewNote('Test', 'Note', '/other/vault') - expect(entry.path).toBe('/other/vault/note/test.md') + expect(entry.path).toBe('/other/vault/test.md') expect(entry.path).not.toContain('/Users/luca/Laputa') }) @@ -310,7 +310,6 @@ describe('resolveNewNote', () => { it('produces a valid path when type is all special characters', () => { const { entry } = resolveNewNote('My Note', '+++', '/vault') - // folder should not be empty, path should not have double slashes expect(entry.path).not.toContain('//') expect(entry.path).toMatch(/\.md$/) }) @@ -420,9 +419,9 @@ describe('buildDailyNoteContent', () => { }) describe('resolveDailyNote', () => { - it('creates entry in journal folder with date as filename', () => { + it('creates entry at vault root with date as filename', () => { const { entry } = resolveDailyNote('2026-03-02', '/my/vault') - expect(entry.path).toBe('/my/vault/journal/2026-03-02.md') + expect(entry.path).toBe('/my/vault/2026-03-02.md') expect(entry.filename).toBe('2026-03-02.md') expect(entry.title).toBe('2026-03-02') expect(entry.isA).toBe('Journal') @@ -437,33 +436,32 @@ describe('resolveDailyNote', () => { it('uses provided vault path instead of hardcoded path', () => { const { entry } = resolveDailyNote('2026-03-02', '/other/vault') - expect(entry.path).toBe('/other/vault/journal/2026-03-02.md') + expect(entry.path).toBe('/other/vault/2026-03-02.md') expect(entry.path).not.toContain('/Users/luca/Laputa') }) }) describe('findDailyNote', () => { - it('finds entry by journal path suffix', () => { + it('finds entry by filename and Journal type', () => { const entries = [ - makeEntry({ path: '/Users/luca/Laputa/journal/2026-03-02.md' }), - makeEntry({ path: '/Users/luca/Laputa/note/other.md' }), + makeEntry({ path: '/Users/luca/Laputa/2026-03-02.md', filename: '2026-03-02.md', isA: 'Journal' }), + makeEntry({ path: '/Users/luca/Laputa/other.md', filename: 'other.md' }), ] const found = findDailyNote(entries, '2026-03-02') expect(found).toBeDefined() - expect(found!.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md') + expect(found!.path).toBe('/Users/luca/Laputa/2026-03-02.md') }) it('returns undefined when no matching entry exists', () => { - const entries = [makeEntry({ path: '/Users/luca/Laputa/note/other.md' })] + const entries = [makeEntry({ path: '/Users/luca/Laputa/other.md', filename: 'other.md' })] expect(findDailyNote(entries, '2026-03-02')).toBeUndefined() }) - it('works with different vault paths', () => { + it('does not match non-Journal entries with same filename', () => { const entries = [ - makeEntry({ path: '/other/vault/journal/2026-03-02.md' }), + makeEntry({ path: '/vault/2026-03-02.md', filename: '2026-03-02.md', isA: 'Note' }), ] - const found = findDailyNote(entries, '2026-03-02') - expect(found).toBeDefined() + expect(findDailyNote(entries, '2026-03-02')).toBeUndefined() }) }) @@ -493,7 +491,7 @@ describe('useNoteActions hook', () => { const [createdEntry] = addEntry.mock.calls[0] expect(createdEntry.title).toBe('Test Note') expect(createdEntry.isA).toBe('Note') - expect(createdEntry.path).toContain('note/test-note.md') + expect(createdEntry.path).toContain('test-note.md') }) it('handleCreateNote opens tab immediately (before addEntry resolves)', () => { @@ -511,7 +509,7 @@ describe('useNoteActions hook', () => { // Tab should be open with the new note expect(result.current.tabs).toHaveLength(1) expect(result.current.tabs[0].entry.title).toBe('Fast Note') - expect(result.current.activeTabPath).toContain('note/fast-note.md') + expect(result.current.activeTabPath).toContain('fast-note.md') }) it('handleCreateType creates type entry', () => { @@ -717,13 +715,12 @@ describe('useNoteActions hook', () => { expect(addEntry).toHaveBeenCalledTimes(1) const [createdEntry] = addEntry.mock.calls[0] expect(createdEntry.isA).toBe('Journal') - expect(createdEntry.path).toContain('journal/') - expect(createdEntry.path).toMatch(/journal\/\d{4}-\d{2}-\d{2}\.md$/) + expect(createdEntry.path).toMatch(/\d{4}-\d{2}-\d{2}\.md$/) }) it('handleOpenDailyNote opens existing daily note instead of creating', async () => { const today = todayDateString() - const existing = makeEntry({ path: `/Users/luca/Laputa/journal/${today}.md`, title: today }) + const existing = makeEntry({ path: `/Users/luca/Laputa/${today}.md`, filename: `${today}.md`, title: today, isA: 'Journal' }) const { result } = renderHook(() => useNoteActions(makeConfig([existing]))) await act(async () => { @@ -733,7 +730,7 @@ describe('useNoteActions hook', () => { // Should open existing note, not create a new one expect(addEntry).not.toHaveBeenCalled() - expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/journal/${today}.md`) + expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/${today}.md`) }) describe('pending save lifecycle', () => { @@ -751,7 +748,7 @@ describe('useNoteActions hook', () => { await new Promise((r) => setTimeout(r, 0)) }) - expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/pending-test.md')) + expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('pending-test.md')) }) it('createAndPersist calls removePendingSave when persist completes (non-Tauri)', async () => { @@ -768,7 +765,7 @@ describe('useNoteActions hook', () => { await new Promise((r) => setTimeout(r, 0)) }) - expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/persist-ok.md')) + expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('persist-ok.md')) }) it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => { @@ -787,9 +784,9 @@ describe('useNoteActions hook', () => { await new Promise((r) => setTimeout(r, 0)) }) - expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md')) - expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md')) - expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md')) + expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('fail-save.md')) + expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('fail-save.md')) + expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('fail-save.md')) expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error') }) @@ -807,8 +804,8 @@ describe('useNoteActions hook', () => { await new Promise((r) => setTimeout(r, 0)) }) - expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md')) - expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note')) + expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md')) + expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'), expect.stringContaining('Untitled note')) }) it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => { @@ -850,7 +847,7 @@ describe('useNoteActions hook', () => { }) it.each([ - ['handleCreateNote', 'Failing Note', 'Note', 'note/failing-note.md'], + ['handleCreateNote', 'Failing Note', 'Note', 'failing-note.md'], ['handleCreateType', 'Recipe', 'Type', 'type/recipe.md'], ])('reverts optimistic creation via %s when disk write fails', async (method, title, type, pathFragment) => { vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full')) @@ -922,142 +919,20 @@ describe('useNoteActions hook', () => { }) }) - describe('move note to type folder on type change', () => { - it('calls move_note_to_type_folder when type key is updated', async () => { - const entry = makeEntry({ path: '/test/vault/note/my-note.md', filename: 'my-note.md', title: 'My Note', isA: 'Note' }) - const replaceEntry = vi.fn() - const config = makeConfig([entry]) - config.replaceEntry = replaceEntry - - vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { - if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: true } - if (cmd === 'get_note_content') return '---\ntype: Quarter\n---\n# My Note\n' - return '' - }) - - const { result } = renderHook(() => useNoteActions(config)) - - await act(async () => { - await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'type', 'Quarter') - }) - - expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({ - vault_path: '/test/vault', - note_path: '/test/vault/note/my-note.md', - new_type: 'Quarter', - })) - expect(replaceEntry).toHaveBeenCalledWith( - '/test/vault/note/my-note.md', - expect.objectContaining({ path: '/test/vault/quarter/my-note.md' }), - ) - expect(setToastMessage).toHaveBeenCalledWith('Note moved to quarter/') - }) - - it('does not call move when type key is not being changed', async () => { + describe('type change is frontmatter-only (flat vault)', () => { + it('does not move file when type key is updated', async () => { const config = makeConfig() vi.mocked(mockInvoke).mockResolvedValue('') const { result } = renderHook(() => useNoteActions(config)) await act(async () => { - await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done') + await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', 'Quarter') }) - expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything()) - }) - - it('does not move when result.moved is false (already in correct folder)', async () => { - const entry = makeEntry({ path: '/test/vault/quarter/my-note.md', filename: 'my-note.md', isA: 'Quarter' }) - const replaceEntry = vi.fn() - const config = makeConfig([entry]) - config.replaceEntry = replaceEntry - - vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { - if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: false } - return '' - }) - - const { result } = renderHook(() => useNoteActions(config)) - - await act(async () => { - await result.current.handleUpdateFrontmatter('/test/vault/quarter/my-note.md', 'type', 'Quarter') - }) - - expect(replaceEntry).not.toHaveBeenCalled() - // Should still show 'Property updated' toast (not move toast) + // In flat vault, type changes only update frontmatter — no file movement expect(setToastMessage).toHaveBeenCalledWith('Property updated') }) - - it('handles Is A key (case-insensitive)', async () => { - const entry = makeEntry({ path: '/test/vault/note/my-note.md' }) - const config = makeConfig([entry]) - config.replaceEntry = vi.fn() - - vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { - if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/my-note.md', updated_links: 0, moved: true } - if (cmd === 'get_note_content') return '---\nIs A: Project\n---\n# My Note\n' - return '' - }) - - const { result } = renderHook(() => useNoteActions(config)) - - await act(async () => { - await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'Is A', 'Project') - }) - - expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({ - new_type: 'Project', - })) - }) - - it('preserves note content after type change — never loads another note (regression)', async () => { - // The mock updateMockFrontmatter returns '---\nupdated: true\n---\n' — - // this represents the note's own content after the frontmatter update. - const frontmatterUpdatedContent = '---\nupdated: true\n---\n' - const wrongContent = '---\ntype: Project\n---\n# Feedback for Laputa\n\nCompletely different note.\n' - - const entry = makeEntry({ path: '/test/vault/note/migrate-newsletter.md', filename: 'migrate-newsletter.md', title: 'Migrate newsletter to Beehiiv', isA: 'Note' }) - const replaceEntry = vi.fn() - const config = makeConfig([entry]) - config.replaceEntry = replaceEntry - - vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { - if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/migrate-newsletter.md', updated_links: 0, moved: true } - // Simulate the bug: get_note_content returns a DIFFERENT note's content - // (e.g. path collision, stale cache, or filesystem race) - if (cmd === 'get_note_content') return wrongContent - return '' - }) - - const { result } = renderHook(() => useNoteActions(config)) - - // Open the tab first so we have a tab to check - act(() => { result.current.openTabWithContent(entry, '---\ntype: Note\n---\n# Migrate\n') }) - - await act(async () => { - await result.current.handleUpdateFrontmatter('/test/vault/note/migrate-newsletter.md', 'type', 'Project') - }) - - // The tab content must be the note's OWN content (from the frontmatter update), - // NEVER the content of a different note loaded via get_note_content. - const tab = result.current.tabs.find(t => t.entry.path === '/test/vault/project/migrate-newsletter.md') - expect(tab).toBeDefined() - expect(tab!.content).toBe(frontmatterUpdatedContent) - expect(tab!.content).not.toBe(wrongContent) - }) - - it('does not move when value is empty or null-like', async () => { - const config = makeConfig() - vi.mocked(mockInvoke).mockResolvedValue('') - - const { result } = renderHook(() => useNoteActions(config)) - - await act(async () => { - await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', '') - }) - - expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything()) - }) }) describe('rename note updates wikilinks', () => { diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index c89e8c1f..1ce6c158 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -20,12 +20,6 @@ interface RenameResult { updated_files: number } -interface MoveResult { - new_path: string - updated_links: number - moved: boolean -} - export interface NoteActionsConfig { addEntry: (entry: VaultEntry) => void removeEntry: (path: string) => void @@ -46,21 +40,6 @@ export interface NoteActionsConfig { replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void } -async function performMoveToTypeFolder( - vaultPath: string, notePath: string, newType: string, -): Promise { - if (isTauri()) { - return invoke('move_note_to_type_folder', { vaultPath, notePath, newType }) - } - return mockInvoke('move_note_to_type_folder', { vault_path: vaultPath, note_path: notePath, new_type: newType }) -} - -/** Check if a frontmatter key represents the note type. */ -function isTypeKey(key: string): boolean { - const k = key.toLowerCase().replace(/\s+/g, '_') - return k === 'type' || k === 'is_a' -} - /** Check if a frontmatter key represents the note title. */ function isTitleKey(key: string): boolean { return key.toLowerCase().replace(/\s+/g, '_') === 'title' @@ -152,13 +131,6 @@ function applyMockFrontmatterDelete(path: string, key: string): string { return content } -const TYPE_FOLDER_MAP: Record = { - Note: 'note', Project: 'project', Experiment: 'experiment', - Responsibility: 'responsibility', Procedure: 'procedure', - Person: 'person', Event: 'event', Topic: 'topic', - Journal: 'journal', -} - const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal']) /** Default templates for built-in types. Used when the type entry has no custom template. */ @@ -219,10 +191,9 @@ export function buildNoteContent(title: string, type: string, status: string | n } export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } { - const folder = TYPE_FOLDER_MAP[type] || slugify(type) const slug = slugify(title) const status = NO_STATUS_TYPES.has(type) ? null : 'Active' - const entry = buildNewEntry({ path: `${vaultPath}/${folder}/${slug}.md`, slug, title, type, status }) + const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status }) return { entry, content: buildNoteContent(title, type, status, template) } } @@ -242,13 +213,12 @@ export function buildDailyNoteContent(date: string): string { } export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } { - const entry = buildNewEntry({ path: `${vaultPath}/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null }) + const entry = buildNewEntry({ path: `${vaultPath}/${date}.md`, slug: date, title: date, type: 'Journal', status: null }) return { entry, content: buildDailyNoteContent(date) } } export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry | undefined { - const suffix = `journal/${date}.md` - return entries.find(e => e.path.endsWith(suffix)) + return entries.find(e => e.filename === `${date}.md` && e.isA === 'Journal') } type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void @@ -516,30 +486,6 @@ export function useNoteActions(config: NoteActionsConfig) { console.error('Failed to rename note after title change:', err) } } - if (isTypeKey(key) && typeof value === 'string' && value !== '') { - try { - const result = await performMoveToTypeFolder(config.vaultPath, path, value) - if (result.moved) { - const newFilename = result.new_path.split('/').pop() ?? '' - // Update the vault entry with the new path. Only pass the changed - // fields — avoid spreading a stale closure entry which would revert - // the isA update that runFrontmatterOp already applied. - config.replaceEntry?.(path, { path: result.new_path, filename: newFilename } as Partial & { path: string }) - // Preserve the tab content already set by runFrontmatterOp. - // Re-reading from disk via loadNoteContent is unnecessary (the move - // does not change content) and dangerous: if the path collides or a - // stale cache intervenes it could return a different note's content. - setTabs(prev => prev.map(t => t.entry.path === path - ? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: t.content } - : t)) - if (activeTabPathRef.current === path) handleSwitchTab(result.new_path) - const folder = result.new_path.split('/').slice(-2, -1)[0] ?? '' - setToastMessage(`Note moved to ${folder}/`) - } - } catch (err) { - console.error('Failed to move note to type folder:', err) - } - } }, [runFrontmatterOp, config.vaultPath, config.replaceEntry, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]), handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]), handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]), diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 6ae15c60..0552284c 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -224,28 +224,6 @@ 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(/\/$/, '') - // Handle collisions: append -2, -3, etc. if the target path already exists - // (mirrors the Rust unique_dest_path logic). - let newPath = `${vaultPath}/${slug}/${filename}` - if (newPath in MOCK_CONTENT && newPath !== args.note_path) { - const stem = filename.replace(/\.md$/, '') - const ext = filename.endsWith('.md') ? '.md' : '' - let counter = 2 - while (`${vaultPath}/${slug}/${stem}-${counter}${ext}` in MOCK_CONTENT) counter++ - newPath = `${vaultPath}/${slug}/${stem}-${counter}${ext}` - } - 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' }, diff --git a/tests/smoke/move-note-to-type-folder.spec.ts b/tests/smoke/move-note-to-type-folder.spec.ts deleted file mode 100644 index 3b7dffe5..00000000 --- a/tests/smoke/move-note-to-type-folder.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { test, expect } from '@playwright/test' - -test.describe('Move note to type folder on type change', () => { - test.beforeEach(async ({ page }) => { - await page.setViewportSize({ width: 1600, height: 900 }) - await page.goto('/') - await page.waitForLoadState('networkidle') - }) - - test('changing type shows move toast and note appears under new section', async ({ page }) => { - // Click the first note in the note list to open it - const noteListContainer = page.locator('[data-testid="note-list-container"]') - await noteListContainer.waitFor({ timeout: 5000 }) - const firstNote = noteListContainer.locator('.cursor-pointer').first() - await firstNote.click() - await page.waitForTimeout(500) - - // The Properties panel should show a type selector - const typeSelector = page.locator('[data-testid="type-selector"]') - await expect(typeSelector).toBeVisible({ timeout: 5000 }) - - // Read the current type - const selectTrigger = typeSelector.locator('button[role="combobox"]') - const currentType = (await selectTrigger.textContent())?.trim() ?? 'Note' - - // Pick a different type to change to - const targetType = currentType === 'Note' ? 'Experiment' : 'Note' - - // Open the type selector dropdown and select the target type - await selectTrigger.click() - await page.waitForTimeout(300) - const option = page.getByRole('option', { name: targetType, exact: true }) - await expect(option).toBeVisible({ timeout: 3000 }) - await option.click() - - // Toast should confirm the move - const toastSlug = targetType.toLowerCase() - const toast = page.getByText(`Note moved to ${toastSlug}/`) - await expect(toast).toBeVisible({ timeout: 5000 }) - - // Restore original type to avoid leaving vault dirty - await page.waitForTimeout(2500) // wait for toast to dismiss - const restoredTrigger = typeSelector.locator('button[role="combobox"]') - await restoredTrigger.click() - await page.waitForTimeout(300) - const restoreOption = page.getByRole('option', { name: currentType, exact: true }) - await expect(restoreOption).toBeVisible({ timeout: 3000 }) - await restoreOption.click() - await page.waitForTimeout(1000) - }) - - test('type selector is visible in properties panel for opened note', async ({ page }) => { - // Click the first note in the note list - const noteListContainer = page.locator('[data-testid="note-list-container"]') - await noteListContainer.waitFor({ timeout: 5000 }) - const firstNote = noteListContainer.locator('.cursor-pointer').first() - await firstNote.click() - await page.waitForTimeout(500) - - // Type selector should be visible in the properties panel - const typeSelector = page.locator('[data-testid="type-selector"]') - await expect(typeSelector).toBeVisible({ timeout: 5000 }) - - // It should show "Type" label - await expect(typeSelector.getByText('Type')).toBeVisible() - }) -})