Compare commits
10 Commits
v0.2026031
...
v0.2026031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d8618c16 | ||
|
|
698c4cece1 | ||
|
|
acd048b144 | ||
|
|
9ce74e7081 | ||
|
|
deec1be7e0 | ||
|
|
b2bb7cf661 | ||
|
|
112f68c66d | ||
|
|
28fa9673b7 | ||
|
|
18e173faca | ||
|
|
a16b477878 |
@@ -410,7 +410,7 @@ flowchart TD
|
||||
C --> G[Write cache atomically\n.tmp → rename]
|
||||
E --> G
|
||||
F --> G
|
||||
G --> H([VaultEntry[] ready])
|
||||
G --> H([VaultEntry list ready])
|
||||
```
|
||||
|
||||
## Theme System
|
||||
@@ -569,7 +569,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days |
|
||||
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | Frontmatter migration utilities |
|
||||
| `migration.rs` | Frontmatter migration: `migrate_is_a_to_type`, `migrate_to_flat_vault` |
|
||||
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
|
||||
| `getting_started.rs` | Creates the Getting Started demo vault |
|
||||
|
||||
@@ -611,6 +611,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `purge_trash` | Delete notes trashed >30 days ago |
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `migrate_to_flat_vault` | Move notes from type subfolders to vault root, update wikilinks |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_getting_started_vault` | Bootstrap demo vault |
|
||||
|
||||
|
||||
@@ -182,35 +182,6 @@ Broader audiences will follow as the onboarding experience matures and the conve
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
A living snapshot of what's built. Updated as features ship.
|
||||
|
||||
### ✅ Working today
|
||||
|
||||
- BlockNote editor with Markdown files on disk; Cmd+S save; dirty state indicator
|
||||
- 4-panel layout: sidebar / note list / editor / inspector
|
||||
- Tabs with drag-to-reorder; quick open (Cmd+P); virtual list (handles 9000+ notes)
|
||||
- `type:` as canonical frontmatter field; inspector with editable properties
|
||||
- Bidirectional relationships; editable relation chips; wikilink autocomplete (`[[`)
|
||||
- Sidebar sections with custom icons and colors
|
||||
- Git integration: commit & push, version history per note, dirty state tracking
|
||||
- Dynamic vault picker; create or clone vaults from GitHub
|
||||
- GitHub OAuth (device flow); AI chat panel; Claude CLI agent panel
|
||||
- Auto-updater; universal macOS binary; CI with coverage gates
|
||||
|
||||
### 🚧 Ahead (consolidation sprint → features)
|
||||
|
||||
**Consolidation sprint (current):**
|
||||
Fixing architectural foundations before building further — cache model, type field canonicalization, allContent removal, hardcoded paths.
|
||||
|
||||
**Next features:**
|
||||
Inbox section, semantic properties (status chips, progress indicators), default relationships in properties panel, workspace filter, mobile apps (iPhone for capture, iPad as desktop mirror).
|
||||
|
||||
*For the full roadmap, see [ROADMAP.md](./ROADMAP.md).*
|
||||
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **Opinionated but not rigid** — ship the method and the defaults; allow customization where it matters
|
||||
|
||||
@@ -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,144 @@ 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,13 +11,13 @@ 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::{
|
||||
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
|
||||
parse_iso_date, title_case_folder,
|
||||
parse_iso_date,
|
||||
};
|
||||
|
||||
use gray_matter::engine::YAML;
|
||||
@@ -322,40 +322,10 @@ fn extract_properties(
|
||||
properties
|
||||
}
|
||||
|
||||
/// Infer entity type from a parent folder name.
|
||||
fn infer_type_from_folder(folder: &str) -> String {
|
||||
match folder {
|
||||
"person" => "Person",
|
||||
"project" => "Project",
|
||||
"procedure" => "Procedure",
|
||||
"responsibility" => "Responsibility",
|
||||
"event" => "Event",
|
||||
"topic" => "Topic",
|
||||
"experiment" => "Experiment",
|
||||
"type" => "Type",
|
||||
"note" => "Note",
|
||||
"quarter" => "Quarter",
|
||||
"measure" => "Measure",
|
||||
"target" => "Target",
|
||||
"journal" => "Journal",
|
||||
"month" => "Month",
|
||||
"config" => "Config",
|
||||
"essay" => "Essay",
|
||||
"evergreen" => "Evergreen",
|
||||
_ => return title_case_folder(folder),
|
||||
}
|
||||
.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 +518,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 +559,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 +705,38 @@ 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 +1038,32 @@ 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 +1113,17 @@ 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()]
|
||||
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 +1139,14 @@ 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 ---
|
||||
|
||||
@@ -193,24 +193,6 @@ pub(super) fn extract_outgoing_links(content: &str) -> Vec<String> {
|
||||
links
|
||||
}
|
||||
|
||||
pub(super) fn capitalize_first(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Title-case a folder name: split on hyphens and underscores, capitalize each word, join with spaces.
|
||||
/// Example: "monday-ideas" → "Monday Ideas", "key_result" → "Key Result"
|
||||
pub(super) fn title_case_folder(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(capitalize_first)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
|
||||
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
|
||||
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
|
||||
@@ -491,51 +473,6 @@ mod tests {
|
||||
assert_eq!(strip_markdown_chars(""), "");
|
||||
}
|
||||
|
||||
// --- capitalize_first tests ---
|
||||
|
||||
#[test]
|
||||
fn test_capitalize_first_normal() {
|
||||
assert_eq!(capitalize_first("person"), "Person");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capitalize_first_already_capitalized() {
|
||||
assert_eq!(capitalize_first("Project"), "Project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capitalize_first_empty() {
|
||||
assert_eq!(capitalize_first(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capitalize_first_single_char() {
|
||||
assert_eq!(capitalize_first("a"), "A");
|
||||
}
|
||||
|
||||
// --- title_case_folder tests ---
|
||||
|
||||
#[test]
|
||||
fn test_title_case_folder_hyphenated() {
|
||||
assert_eq!(title_case_folder("monday-ideas"), "Monday Ideas");
|
||||
assert_eq!(title_case_folder("key-result"), "Key Result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_case_folder_underscored() {
|
||||
assert_eq!(title_case_folder("my_custom_type"), "My Custom Type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_case_folder_single_word() {
|
||||
assert_eq!(title_case_folder("recipe"), "Recipe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_case_folder_empty() {
|
||||
assert_eq!(title_case_folder(""), "");
|
||||
}
|
||||
|
||||
// --- without_h1_line tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -166,31 +166,6 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
.unwrap_or(abs_path)
|
||||
}
|
||||
|
||||
/// Result of a move-to-type-folder operation.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MoveResult {
|
||||
/// New absolute file path after move (same as old if no move happened).
|
||||
pub new_path: String,
|
||||
/// Number of other files updated (wikilink replacements).
|
||||
pub updated_links: usize,
|
||||
/// Whether the file was actually moved (false if already in the right folder).
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
/// Convert a type name to a folder slug. All known types are single lowercase words;
|
||||
/// unknown types are slugified (lowercase, non-alphanumeric → hyphen).
|
||||
fn type_to_folder_slug(type_name: &str) -> String {
|
||||
type_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<&str>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
|
||||
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
|
||||
let dest = dest_dir.join(filename);
|
||||
@@ -215,134 +190,6 @@ fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a note to the folder corresponding to its new type, and update wikilinks across the vault.
|
||||
///
|
||||
/// Returns `MoveResult` with `moved: false` if the note is already in the correct folder.
|
||||
/// Creates the target folder if it does not exist.
|
||||
pub fn move_note_to_type_folder(
|
||||
vault_path: &str,
|
||||
note_path: &str,
|
||||
new_type: &str,
|
||||
) -> Result<MoveResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(note_path);
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", note_path));
|
||||
}
|
||||
let new_type = new_type.trim();
|
||||
if new_type.is_empty() {
|
||||
return Err("Type cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let folder_slug = type_to_folder_slug(new_type);
|
||||
|
||||
// Check if already in the correct folder
|
||||
let current_folder = old_file
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if current_folder == folder_slug {
|
||||
return Ok(MoveResult {
|
||||
new_path: note_path.to_string(),
|
||||
updated_links: 0,
|
||||
moved: false,
|
||||
});
|
||||
}
|
||||
|
||||
let filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Create target directory if needed
|
||||
let dest_dir = vault.join(&folder_slug);
|
||||
if !dest_dir.exists() {
|
||||
fs::create_dir_all(&dest_dir)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", dest_dir.display(), e))?;
|
||||
}
|
||||
|
||||
// Determine destination path (handle collisions)
|
||||
let new_file = unique_dest_path(&dest_dir, &filename);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
// Read content and move
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
|
||||
fs::write(&new_file, &content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
|
||||
fs::remove_file(old_file)
|
||||
.map_err(|e| format!("Failed to remove old file {}: {}", note_path, e))?;
|
||||
|
||||
// Extract title for wikilink matching
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let old_title = super::extract_title(&content, &old_filename);
|
||||
|
||||
// Update wikilinks across the vault (title stays the same, path changes)
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(note_path, &vault_prefix);
|
||||
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix);
|
||||
|
||||
// Build pattern matching old path stem (e.g. "note/weekly-review")
|
||||
let re = match build_wikilink_pattern(&old_title, old_path_stem) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Ok(MoveResult {
|
||||
new_path: new_path_str,
|
||||
updated_links: 0,
|
||||
moved: true,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the replacement: if path-style wikilinks were used, update to new path.
|
||||
// Title-style wikilinks [[My Note]] stay the same (title hasn't changed).
|
||||
let files = collect_md_files(vault, &new_file);
|
||||
let updated_links = files
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let file_content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !re.is_match(&file_content) {
|
||||
return false;
|
||||
}
|
||||
// Replace path-based wikilinks (old_path_stem → new_path_stem)
|
||||
// and keep title-based wikilinks as-is.
|
||||
let replaced = re.replace_all(&file_content, |caps: ®ex::Captures| {
|
||||
let full_match = caps.get(0).map(|m| m.as_str()).unwrap_or("");
|
||||
let pipe = caps.get(1);
|
||||
// If the match used the path stem, replace with new path stem
|
||||
if full_match.contains(old_path_stem) {
|
||||
match pipe {
|
||||
Some(p) => format!("[[{}{}]]", new_path_stem, p.as_str()),
|
||||
None => format!("[[{}]]", new_path_stem),
|
||||
}
|
||||
} else {
|
||||
// Title-based link — keep as-is (title hasn't changed)
|
||||
full_match.to_string()
|
||||
}
|
||||
});
|
||||
if replaced != file_content {
|
||||
fs::write(path, replaced.as_ref()).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(MoveResult {
|
||||
new_path: new_path_str,
|
||||
updated_links,
|
||||
moved: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
|
||||
///
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
@@ -753,213 +600,6 @@ mod tests {
|
||||
assert!(vault.join("note/my-note.md").exists());
|
||||
}
|
||||
|
||||
// --- move_note_to_type_folder tests ---
|
||||
|
||||
#[test]
|
||||
fn test_type_to_folder_slug_known_types() {
|
||||
assert_eq!(type_to_folder_slug("Person"), "person");
|
||||
assert_eq!(type_to_folder_slug("Project"), "project");
|
||||
assert_eq!(type_to_folder_slug("Quarter"), "quarter");
|
||||
assert_eq!(type_to_folder_slug("Note"), "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_to_folder_slug_unknown_types() {
|
||||
assert_eq!(type_to_folder_slug("Key Result"), "key-result");
|
||||
assert_eq!(type_to_folder_slug("My Custom Type"), "my-custom-type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_basic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent here.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(result.new_path.contains("/quarter/weekly-review.md"));
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_already_in_correct_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"quarter/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n",
|
||||
);
|
||||
|
||||
let path = vault.join("quarter/weekly-review.md");
|
||||
let result =
|
||||
move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "Quarter")
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.moved);
|
||||
assert_eq!(result.new_path, path.to_str().unwrap());
|
||||
assert_eq!(result.updated_links, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_creates_target_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# My Note\n",
|
||||
);
|
||||
|
||||
let dest_dir = vault.join("quarter");
|
||||
assert!(!dest_dir.exists());
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(dest_dir.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_filename_collision() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# My Note\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"quarter/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# Existing Note\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(result.new_path.contains("/quarter/my-note-2.md"));
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
// Original file should still exist
|
||||
assert!(vault.join("quarter/my-note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_updates_path_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"project/my-project.md",
|
||||
"---\ntype: Project\n---\n# My Project\n\nSee [[note/weekly-review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert_eq!(result.updated_links, 1);
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[quarter/weekly-review]]"));
|
||||
assert!(!project_content.contains("[[note/weekly-review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_preserves_title_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"---\ntype: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
// Title-based wikilinks should be unchanged
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Weekly Review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_collision_preserves_both_contents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let moving_content =
|
||||
"---\ntype: Quarter\n---\n# Migrate newsletter to Beehiiv\n\nImportant content.\n";
|
||||
let existing_content =
|
||||
"---\ntype: Quarter\n---\n# Feedback for Laputa\n\nCompletely different note.\n";
|
||||
create_test_file(vault, "note/my-note.md", moving_content);
|
||||
create_test_file(vault, "quarter/my-note.md", existing_content);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
// Must get a unique path, not the existing file's path
|
||||
assert!(result.new_path.contains("/quarter/my-note-2.md"));
|
||||
// Moved note must retain its own content
|
||||
let moved_content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert_eq!(moved_content, moving_content);
|
||||
// Existing note must be untouched
|
||||
let untouched = fs::read_to_string(vault.join("quarter/my-note.md")).unwrap();
|
||||
assert_eq!(untouched, existing_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
|
||||
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
|
||||
@@ -1054,46 +694,4 @@ mod tests {
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_empty_type_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let path = vault.join("note/test.md");
|
||||
let result = move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_nonexistent_file_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("note/nope.md").to_str().unwrap(),
|
||||
"Quarter",
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_preserves_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let original = "---\ntype: Quarter\ntitle: My Note\n---\n# My Note\n\nImportant content.\n";
|
||||
create_test_file(vault, "note/my-note.md", original);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* BlockNote container */
|
||||
/* BlockNote container
|
||||
padding: 0 4px prevents BlockNote's side menu (≈42px wide) from being
|
||||
clipped — the menu hangs left of the editor body (40px padding), and
|
||||
overflow-y:auto forces overflow-x:auto (CSS spec). The 4px padding
|
||||
keeps the menu inside the padding box, which is the overflow clip edge. */
|
||||
.editor__blocknote-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -32,6 +36,7 @@
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: text;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Drag-over state: subtle border highlight */
|
||||
|
||||
@@ -22,6 +22,11 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** Returns true if the click target is an interactive area the container should not steal focus from. */
|
||||
function isInteractiveTarget(target: HTMLElement): boolean {
|
||||
return !!(target.closest('[contenteditable="true"]') || target.closest('.bn-side-menu'))
|
||||
}
|
||||
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
@@ -40,9 +45,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
|
||||
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('[contenteditable="true"]')) return
|
||||
if (!editable || isInteractiveTarget(e.target as HTMLElement)) return
|
||||
const blocks = editor.document
|
||||
if (blocks.length > 0) {
|
||||
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
|
||||
|
||||
@@ -250,4 +250,36 @@ describe('useImageDrop — Tauri native drag-drop', () => {
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores Tauri events during internal HTML5 drags (tab/block reorder)', async () => {
|
||||
const onImageUrl = vi.fn()
|
||||
renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
// Simulate an internal drag start (e.g. tab drag)
|
||||
act(() => { document.dispatchEvent(new Event('dragstart')) })
|
||||
|
||||
// Tauri drop event during internal drag should be ignored
|
||||
act(() => {
|
||||
capturedDragDropHandler!({
|
||||
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
|
||||
})
|
||||
})
|
||||
expect(onImageUrl).not.toHaveBeenCalled()
|
||||
|
||||
// End the internal drag
|
||||
act(() => { document.dispatchEvent(new Event('dragend')) })
|
||||
|
||||
// After internal drag ends, Tauri events should work again
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
vi.mocked(invoke).mockResolvedValue('/vault/attachments/photo.png')
|
||||
act(() => {
|
||||
capturedDragDropHandler!({
|
||||
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
|
||||
})
|
||||
})
|
||||
// onImageUrl is called async (after copyImageToVault resolves)
|
||||
await waitFor(() => expect(onImageUrl).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,89 +53,89 @@ interface UseImageDropOptions {
|
||||
vaultPath?: string
|
||||
}
|
||||
|
||||
/** Track whether an internal HTML5 drag (tab, block) is in progress.
|
||||
* Internal drags fire `dragstart` on a DOM element; OS file drags do not. */
|
||||
function useInternalDragFlag() {
|
||||
const ref = useRef(false)
|
||||
useEffect(() => {
|
||||
const start = () => { ref.current = true }
|
||||
const end = () => { ref.current = false }
|
||||
document.addEventListener('dragstart', start)
|
||||
document.addEventListener('dragend', end)
|
||||
return () => { document.removeEventListener('dragstart', start); document.removeEventListener('dragend', end) }
|
||||
}, [])
|
||||
return ref
|
||||
}
|
||||
|
||||
/** Process a Tauri native file drop payload — copy images to vault. */
|
||||
function handleTauriDrop(
|
||||
paths: string[],
|
||||
vaultPath: string | undefined,
|
||||
callback: ((url: string) => void) | undefined,
|
||||
) {
|
||||
const imagePaths = paths.filter(isImagePath)
|
||||
if (imagePaths.length > 0 && vaultPath && callback) {
|
||||
for (const p of imagePaths) void copyImageToVault(p, vaultPath).then(callback)
|
||||
}
|
||||
}
|
||||
|
||||
export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) {
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const onImageUrlRef = useRef(onImageUrl)
|
||||
useEffect(() => { onImageUrlRef.current = onImageUrl }, [onImageUrl])
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
const internalDragRef = useInternalDragFlag()
|
||||
|
||||
// HTML5 DnD visual feedback (works in browser mode; BlockNote handles the actual upload)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
const onOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer || !hasImageFiles(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
setIsDragOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
if (!container.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false)
|
||||
}
|
||||
const onLeave = (e: DragEvent) => {
|
||||
if (!container.contains(e.relatedTarget as Node)) setIsDragOver(false)
|
||||
}
|
||||
const onDrop = () => { setIsDragOver(false) }
|
||||
|
||||
const handleDrop = () => {
|
||||
// Only reset visual state; BlockNote's native dropFile plugin handles
|
||||
// the actual upload (via editor.uploadFile) and block insertion.
|
||||
setIsDragOver(false)
|
||||
}
|
||||
|
||||
container.addEventListener('dragover', handleDragOver)
|
||||
container.addEventListener('dragleave', handleDragLeave)
|
||||
container.addEventListener('drop', handleDrop)
|
||||
|
||||
container.addEventListener('dragover', onOver)
|
||||
container.addEventListener('dragleave', onLeave)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', handleDragOver)
|
||||
container.removeEventListener('dragleave', handleDragLeave)
|
||||
container.removeEventListener('drop', handleDrop)
|
||||
container.removeEventListener('dragover', onOver)
|
||||
container.removeEventListener('dragleave', onLeave)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
// Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD
|
||||
// Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD.
|
||||
// Skipped entirely when an internal drag is in progress (tabs, blocks).
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
|
||||
let unlisten: (() => void) | null = null
|
||||
let mounted = true
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { getCurrentWebview } = await import('@tauri-apps/api/webview')
|
||||
if (!mounted) return
|
||||
unlisten = await getCurrentWebview().onDragDropEvent((event) => {
|
||||
const payload = event.payload
|
||||
if (payload.type === 'over') {
|
||||
// Tauri 'over' events don't include paths and can't distinguish
|
||||
// OS file drags from internal drags (tabs, blocks). Let the HTML5
|
||||
// dragover handler drive isDragOver — it checks hasImageFiles().
|
||||
} else if (payload.type === 'drop') {
|
||||
unlisten = await getCurrentWebview().onDragDropEvent(({ payload }) => {
|
||||
if (internalDragRef.current) return
|
||||
if (payload.type === 'drop') {
|
||||
setIsDragOver(false)
|
||||
const imagePaths = payload.paths.filter(isImagePath)
|
||||
const vault = vaultPathRef.current
|
||||
const callback = onImageUrlRef.current
|
||||
if (imagePaths.length > 0 && vault && callback) {
|
||||
for (const sourcePath of imagePaths) {
|
||||
void copyImageToVault(sourcePath, vault).then(callback)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handleTauriDrop(payload.paths, vaultPathRef.current, onImageUrlRef.current)
|
||||
} else if (payload.type !== 'over') {
|
||||
setIsDragOver(false)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// Tauri webview API not available (e.g. older Tauri version)
|
||||
}
|
||||
} catch { /* Tauri webview API not available */ }
|
||||
})()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
unlisten?.()
|
||||
}
|
||||
}, [])
|
||||
return () => { mounted = false; unlisten?.() }
|
||||
}, [internalDragRef])
|
||||
|
||||
return { isDragOver }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useNoteActions,
|
||||
DEFAULT_TEMPLATES,
|
||||
resolveTemplate,
|
||||
slugCollides,
|
||||
} from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
@@ -111,6 +112,38 @@ describe('needsRenameOnSave', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('slugCollides', () => {
|
||||
it('detects collision with existing filename', () => {
|
||||
const entries = [makeEntry({ filename: 'my-note.md', path: '/vault/my-note.md' })]
|
||||
expect(slugCollides('My Note', entries)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no collision', () => {
|
||||
const entries = [makeEntry({ filename: 'other.md', path: '/vault/other.md' })]
|
||||
expect(slugCollides('My Note', entries)).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes the current note path from collision check', () => {
|
||||
const entries = [makeEntry({ filename: 'my-note.md', path: '/vault/my-note.md' })]
|
||||
expect(slugCollides('My Note', entries, '/vault/my-note.md')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewNote slug collision', () => {
|
||||
it('auto-suffixes slug when collision detected', () => {
|
||||
const entries = [makeEntry({ filename: 'my-project.md', path: '/vault/my-project.md' })]
|
||||
const { entry } = resolveNewNote('My Project', 'Project', '/vault', null, entries)
|
||||
expect(entry.path).toBe('/vault/my-project-2.md')
|
||||
expect(entry.filename).toBe('my-project-2.md')
|
||||
})
|
||||
|
||||
it('skips suffix when no collision', () => {
|
||||
const entries = [makeEntry({ filename: 'other.md', path: '/vault/other.md' })]
|
||||
const { entry } = resolveNewNote('My Project', 'Project', '/vault', null, entries)
|
||||
expect(entry.path).toBe('/vault/my-project.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewEntry', () => {
|
||||
it('creates a VaultEntry with correct fields', () => {
|
||||
const entry = buildNewEntry({
|
||||
@@ -190,8 +223,8 @@ describe('entryMatchesTarget', () => {
|
||||
expect(entryMatchesTarget(entry, 'mp')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by path suffix (type/slug)', () => {
|
||||
const entry = makeEntry({ path: '/Users/luca/Laputa/project/my-project.md' })
|
||||
it('matches by filename stem from path-style wikilink', () => {
|
||||
const entry = makeEntry({ path: '/Users/luca/Laputa/my-project.md', filename: 'my-project.md' })
|
||||
expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -270,18 +303,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 +330,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 +343,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 +452,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 +469,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 +524,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 +542,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 +748,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 +763,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 +781,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 +798,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 +817,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 +837,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 +880,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 +952,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'
|
||||
@@ -116,6 +95,12 @@ export function needsRenameOnSave(title: string, filename: string): boolean {
|
||||
return `${slugify(title)}.md` !== filename
|
||||
}
|
||||
|
||||
/** Check if a slug would collide with an existing entry's filename. */
|
||||
export function slugCollides(title: string, entries: VaultEntry[], excludePath?: string): boolean {
|
||||
const slug = slugify(title)
|
||||
return entries.some(e => e.filename === `${slug}.md` && e.path !== excludePath)
|
||||
}
|
||||
|
||||
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
|
||||
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): string {
|
||||
const baseName = `Untitled ${type.toLowerCase()}`
|
||||
@@ -152,13 +137,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. */
|
||||
@@ -218,11 +196,18 @@ export function buildNoteContent(title: string, type: string, status: string | n
|
||||
return `${lines.join('\n')}\n\n# ${title}\n${body}`
|
||||
}
|
||||
|
||||
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)
|
||||
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null, entries?: VaultEntry[]): { entry: VaultEntry; content: string } {
|
||||
let slug = slugify(title)
|
||||
// Detect slug collision and auto-suffix
|
||||
if (entries) {
|
||||
let counter = 2
|
||||
while (entries.some(e => e.filename === `${slug}.md`)) {
|
||||
slug = `${slugify(title)}-${counter}`
|
||||
counter++
|
||||
}
|
||||
}
|
||||
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 +227,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
|
||||
@@ -393,7 +377,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, config.vaultPath, template))
|
||||
persistNew(resolveNewNote(title, type, config.vaultPath, template, entries))
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
@@ -402,7 +386,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template, entries)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
@@ -419,7 +403,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
* Returns true on success, false on failure (shows toast on error). */
|
||||
const handleCreateNoteForRelationship = useCallback(async (title: string): Promise<boolean> => {
|
||||
const template = resolveTemplate(entries, 'Note')
|
||||
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template)
|
||||
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template, entries)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
try {
|
||||
@@ -516,30 +500,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' },
|
||||
|
||||
@@ -20,23 +20,28 @@ export function wikilinkDisplay(ref: string): string {
|
||||
|
||||
/**
|
||||
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
|
||||
* Handles pipe syntax, case-insensitive matching, title/alias/filename/path lookup.
|
||||
* Resolution order: filename stem (primary) → alias → title (fallback).
|
||||
* Handles pipe syntax, case-insensitive matching.
|
||||
*/
|
||||
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
|
||||
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
|
||||
const keyLower = key.toLowerCase()
|
||||
const suffix = '/' + key + '.md'
|
||||
const lastSegment = key.split('/').pop() ?? key
|
||||
const asWords = lastSegment.replace(/-/g, ' ').toLowerCase()
|
||||
|
||||
return entries.find(e => {
|
||||
if (e.title.toLowerCase() === keyLower) return true
|
||||
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return true
|
||||
// 1. Filename stem match (primary — filename IS identity in flat vault)
|
||||
const byStem = entries.find(e => {
|
||||
const stem = e.filename.replace(/\.md$/, '')
|
||||
if (stem.toLowerCase() === keyLower) return true
|
||||
if (e.path.endsWith(suffix)) return true
|
||||
if (stem.toLowerCase() === lastSegment.toLowerCase()) return true
|
||||
if (e.title.toLowerCase() === asWords) return true
|
||||
return false
|
||||
return stem.toLowerCase() === keyLower || stem.toLowerCase() === lastSegment.toLowerCase()
|
||||
})
|
||||
if (byStem) return byStem
|
||||
|
||||
// 2. Alias match
|
||||
const byAlias = entries.find(e => e.aliases.some(a => a.toLowerCase() === keyLower))
|
||||
if (byAlias) return byAlias
|
||||
|
||||
// 3. Title match (fallback)
|
||||
return entries.find(e =>
|
||||
e.title.toLowerCase() === keyLower || e.title.toLowerCase() === asWords,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Changing note type preserves content (data corruption fix)', () => {
|
||||
test.describe('Changing note type is frontmatter-only (flat vault)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('type change does not load a different note into the editor', async ({ page }) => {
|
||||
test('type change preserves content and does not move file', async ({ page }) => {
|
||||
// 1. 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 })
|
||||
@@ -16,7 +16,6 @@ test.describe('Changing note type preserves content (data corruption fix)', () =
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 2. Get the editor's H1 heading text before the type change.
|
||||
// The note's title is shown as an H1 in the BlockNote editor.
|
||||
const editorContainer = page.locator('.bn-editor')
|
||||
await expect(editorContainer).toBeVisible({ timeout: 5000 })
|
||||
const headingBefore = await editorContainer.locator('h1').first().textContent()
|
||||
@@ -36,13 +35,11 @@ test.describe('Changing note type preserves content (data corruption fix)', () =
|
||||
await expect(option).toBeVisible({ timeout: 3000 })
|
||||
await option.click()
|
||||
|
||||
// 5. Wait for the move to complete (toast confirms it)
|
||||
const toastSlug = targetType.toLowerCase()
|
||||
const toast = page.getByText(`Note moved to ${toastSlug}/`)
|
||||
// 5. In flat vault, type change only updates frontmatter — shows "Property updated" toast
|
||||
const toast = page.getByText('Property updated')
|
||||
await expect(toast).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// 6. CRITICAL: verify the editor still shows the SAME note's heading.
|
||||
// The data-corruption bug would replace this with another note's content.
|
||||
await page.waitForTimeout(300)
|
||||
const headingAfter = await editorContainer.locator('h1').first().textContent()
|
||||
expect(headingAfter).toBe(headingBefore)
|
||||
@@ -60,53 +57,4 @@ test.describe('Changing note type preserves content (data corruption fix)', () =
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
|
||||
test('changing type of existing note preserves its content', async ({ page }) => {
|
||||
// 1. Click the second note in the note list (different note than test 1)
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
const notes = noteListContainer.locator('.cursor-pointer')
|
||||
const noteCount = await notes.count()
|
||||
const noteIndex = noteCount > 1 ? 1 : 0
|
||||
await notes.nth(noteIndex).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 2. Capture the H1 heading
|
||||
const editorContainer = page.locator('.bn-editor')
|
||||
await expect(editorContainer).toBeVisible({ timeout: 5000 })
|
||||
const headingBefore = await editorContainer.locator('h1').first().textContent()
|
||||
expect(headingBefore).toBeTruthy()
|
||||
|
||||
// 3. Change the type
|
||||
const typeSelector = page.locator('[data-testid="type-selector"]')
|
||||
await expect(typeSelector).toBeVisible({ timeout: 5000 })
|
||||
const selectTrigger = typeSelector.locator('button[role="combobox"]')
|
||||
const currentType = (await selectTrigger.textContent())?.trim() ?? ''
|
||||
const targetType = currentType === 'Experiment' ? 'Person' : 'Experiment'
|
||||
|
||||
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()
|
||||
|
||||
// 4. Wait for move
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// 5. CRITICAL: the H1 heading must still be the original note's title
|
||||
const headingAfter = await editorContainer.locator('h1').first().textContent()
|
||||
expect(headingAfter).toBe(headingBefore)
|
||||
|
||||
// 6. Restore the original type
|
||||
const restoredTrigger = typeSelector.locator('button[role="combobox"]')
|
||||
await restoredTrigger.click()
|
||||
await page.waitForTimeout(300)
|
||||
const restoreOption = page.getByRole('option', { name: currentType, exact: true })
|
||||
if (await restoreOption.isVisible()) {
|
||||
await restoreOption.click()
|
||||
await page.waitForTimeout(1000)
|
||||
} else {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ test.describe('Note filename updates on title change + save', () => {
|
||||
test('saving a note whose filename already matches does not trigger rename', async ({ page }) => {
|
||||
// Click on an existing note in the note list that already has a matching filename.
|
||||
// The default sidebar shows "Notes" section with several notes visible.
|
||||
const noteItem = page.locator('.truncate.text-foreground.font-medium').filter({ hasText: /Refactoring/ }).first()
|
||||
const noteItem = page.locator('.truncate.text-foreground.font-medium').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
|
||||
183
tests/smoke/fresh-install-regression-qa.spec.ts
Normal file
183
tests/smoke/fresh-install-regression-qa.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut, openCommandPalette, findCommand } from './helpers'
|
||||
|
||||
/**
|
||||
* Fresh-install regression QA: verify all 7 Done tasks work on a fresh
|
||||
* MacBook without pre-configured environment.
|
||||
*
|
||||
* Tasks under test:
|
||||
* 1. Search/qmd bundling + auto-index
|
||||
* 2. MCP server foundation + auto-registration
|
||||
* 3. AGENTS.md vault-level instructions
|
||||
* 4. AI Agent panel: vault-native AI
|
||||
* 5. Claude API wiring + full agent loop
|
||||
* 6. AI panel UI (3 layers + blue glow)
|
||||
* 7. /api/ai/agent endpoint fix (uses Tauri invoke, not fetch)
|
||||
*/
|
||||
|
||||
test.describe('Fresh-install regression: AI panel renders and works', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('AI panel opens with Ctrl+I and has 3-layer structure', async ({ page }) => {
|
||||
// Select a note for context
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Open AI panel
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
const panel = page.getByTestId('ai-panel')
|
||||
await expect(panel).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Layer 1: Header with title and buttons
|
||||
await expect(panel.locator('text=AI Chat')).toBeVisible()
|
||||
await expect(panel.locator('button[title="New conversation"]')).toBeVisible()
|
||||
await expect(panel.locator('button[title="Close AI panel"]')).toBeVisible()
|
||||
|
||||
// Layer 2: Message area (empty state with robot icon suggestion)
|
||||
await expect(
|
||||
panel.locator('text=Ask about this note').or(panel.locator('text=Open a note')),
|
||||
).toBeVisible()
|
||||
|
||||
// Layer 3: Input area with send button
|
||||
await expect(page.getByTestId('agent-send')).toBeVisible()
|
||||
})
|
||||
|
||||
test('AI panel shows context bar when note is selected', async ({ page }) => {
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Context bar should show active note title
|
||||
await expect(page.getByTestId('context-bar')).toBeVisible()
|
||||
})
|
||||
|
||||
test('AI panel input is focusable and sendable', async ({ page }) => {
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Input should auto-focus
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('Test message')
|
||||
|
||||
// Send button should be enabled when input has text
|
||||
const sendBtn = page.getByTestId('agent-send')
|
||||
await expect(sendBtn).toBeEnabled()
|
||||
|
||||
// Click send — should produce a response (mock in dev mode)
|
||||
await sendBtn.click()
|
||||
const response = page.getByTestId('ai-message').last()
|
||||
await expect(response).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('AI panel close with Escape key', async ({ page }) => {
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
const panel = page.getByTestId('ai-panel')
|
||||
await expect(panel).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Focus the panel and press Escape
|
||||
await panel.focus()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(panel).not.toBeVisible({ timeout: 2000 })
|
||||
})
|
||||
|
||||
test('AI panel blue glow animation CSS exists', async ({ page }) => {
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
const panel = page.getByTestId('ai-panel')
|
||||
await expect(panel).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Verify the ai-border-pulse keyframe is defined in the page CSS
|
||||
const hasAnimation = await page.evaluate(() => {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
if (rule instanceof CSSKeyframesRule && rule.name === 'ai-border-pulse') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin sheets */ }
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasAnimation).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Fresh-install regression: search and command palette', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('search UI renders and is accessible via Cmd+P', async ({ page }) => {
|
||||
// Cmd+P should open search/note switcher
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Search input should be visible
|
||||
const searchInput = page.locator('input[placeholder*="Search"]').or(
|
||||
page.locator('input[placeholder*="command"]'),
|
||||
)
|
||||
await expect(searchInput).toBeVisible({ timeout: 2000 })
|
||||
})
|
||||
|
||||
test('Cmd+K command palette includes Repair Vault', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Repair Vault')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Fresh-install regression: no /api/ai/agent endpoint', () => {
|
||||
test('fetching /api/ai/agent returns 404 or no response', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// In dev mode, the Vite proxy plugin handles /api/ai/agent,
|
||||
// but in production Tauri there is no HTTP server at all.
|
||||
// The key verification is that ai-agent.ts uses invoke(), not fetch().
|
||||
// We verify this indirectly by checking the AiPanel doesn't make fetch calls.
|
||||
const fetchCalls: string[] = []
|
||||
page.on('request', req => {
|
||||
if (req.url().includes('/api/ai/agent')) {
|
||||
fetchCalls.push(req.url())
|
||||
}
|
||||
})
|
||||
|
||||
// Open AI panel and send a message
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
await input.fill('Test')
|
||||
await page.getByTestId('agent-send').click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// No fetch to /api/ai/agent should have been made
|
||||
expect(fetchCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -3,112 +3,108 @@ import { test, expect } from '@playwright/test'
|
||||
const DROP_OVERLAY = '.editor__drop-overlay'
|
||||
const EDITOR_CONTAINER = '.editor__blocknote-container'
|
||||
|
||||
async function openFirstNote(page: import('@playwright/test').Page) {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
/** Dispatch a DragEvent with an image file on the editor container to show the overlay. */
|
||||
async function showOverlayViaImageDragover(page: import('@playwright/test').Page) {
|
||||
await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }))
|
||||
}, EDITOR_CONTAINER)
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Image drop overlay — internal drag does not trigger overlay', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Open the first note to mount the editor
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
|
||||
})
|
||||
test.beforeEach(async ({ page }) => { await openFirstNote(page) })
|
||||
|
||||
test('internal drag (no image files) does not show the overlay', async ({ page }) => {
|
||||
// Simulate an internal drag (e.g. block or tab) — dataTransfer has no files
|
||||
await page.locator(EDITOR_CONTAINER).first().dispatchEvent('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
|
||||
// The overlay should NOT appear for non-file drags
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('dragover with image file shows the overlay', async ({ page }) => {
|
||||
// Simulate an OS file drag with an image file in dataTransfer
|
||||
// Playwright dispatchEvent can't set dataTransfer items directly,
|
||||
// so we use page.evaluate to dispatch a proper DragEvent with file items
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
|
||||
const event = new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
el.dispatchEvent(event)
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
await showOverlayViaImageDragover(page)
|
||||
await expect(page.locator(DROP_OVERLAY)).toContainText('Drop image here')
|
||||
})
|
||||
|
||||
test('dragleave after image dragover hides the overlay', async ({ page }) => {
|
||||
// First show the overlay via image dragover
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
await showOverlayViaImageDragover(page)
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
|
||||
// Now simulate dragleave (cursor left the container)
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
el.dispatchEvent(new DragEvent('dragleave', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
relatedTarget: document.body,
|
||||
}))
|
||||
await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)!
|
||||
el.dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true, relatedTarget: document.body }))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('drop resets the overlay', async ({ page }) => {
|
||||
// Show overlay via image dragover
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
await showOverlayViaImageDragover(page)
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
|
||||
// Simulate drop
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
el.dispatchEvent(new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)!
|
||||
el.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true }))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Block handle (side menu) is not clipped by editor overflow', () => {
|
||||
test.beforeEach(async ({ page }) => { await openFirstNote(page) })
|
||||
|
||||
test('side menu is fully visible within container bounds on hover', async ({ page }) => {
|
||||
await page.locator('.bn-block-outer').first().hover()
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const menu = document.querySelector('[class*="sideMenu"], .bn-side-menu, [data-side-menu]')
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
if (!menu || !container) return null
|
||||
const mr = menu.getBoundingClientRect()
|
||||
const cr = container.getBoundingClientRect()
|
||||
return { menuLeft: mr.left, containerLeft: cr.left }
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.menuLeft).toBeGreaterThanOrEqual(result!.containerLeft)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tab drag does not trigger image drop overlay', () => {
|
||||
test('dragging a tab over the editor does not show the overlay', async ({ page }) => {
|
||||
await openFirstNote(page)
|
||||
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
const secondNote = noteList.locator('.cursor-pointer').nth(1)
|
||||
if (await secondNote.count() === 0) { test.skip(); return }
|
||||
await secondNote.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await page.evaluate((sel) => {
|
||||
document.dispatchEvent(new Event('dragstart', { bubbles: true }))
|
||||
const el = document.querySelector(sel)!
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('text/plain', '0')
|
||||
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
|
||||
await page.evaluate(() => { document.dispatchEvent(new Event('dragend', { bubbles: true })) })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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