feat: flatten vault structure — remove type-based folders

- 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) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-15 19:43:00 +01:00
parent c431c1adf7
commit 69f50e5950
9 changed files with 570 additions and 492 deletions

View File

@@ -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<MoveResult, String> {
let vault_path = expand_tilde(&vault_path);
let note_path = expand_tilde(&note_path);
vault::move_note_to_type_folder(&vault_path, &note_path, &new_type)
}
#[tauri::command]
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -132,6 +121,12 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn migrate_to_flat_vault(vault_path: String) -> Result<MigrationResult, String> {
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<String>) -> Result<String, String> {
let path = match target_path {
@@ -551,11 +546,20 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
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 ──────────────────────────────────────────────

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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<usize, String> {
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<String>,
}
/// 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<MigrationResult, String> {
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<std::path::PathBuf> = 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<std::path::PathBuf> = 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());
}
}

View File

@@ -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<StringOrList>, path: &Path) -> Option<String> {
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<StringOrList>, _path: &Path) -> Option<String> {
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<VaultEntry>) {
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<Vec<VaultEntry>, String> {
if !vault_path.exists() {
return Err(format!(
@@ -564,22 +584,25 @@ pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, 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 ---

View File

@@ -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', () => {

View File

@@ -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<VaultEntry> & { path: string }) => void
}
async function performMoveToTypeFolder(
vaultPath: string, notePath: string, newType: string,
): Promise<MoveResult> {
if (isTauri()) {
return invoke<MoveResult>('move_note_to_type_folder', { vaultPath, notePath, newType })
}
return mockInvoke<MoveResult>('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<string, string> = {
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<VaultEntry> & { 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]),

View File

@@ -224,28 +224,6 @@ export const mockHandlers: Record<string, (args: any) => 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' },

View File

@@ -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()
})
})