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:
@@ -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(¬e_path);
|
||||
vault::move_note_to_type_folder(&vault_path, ¬e_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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user