diff --git a/prompt.txt b/prompt.txt new file mode 100644 index 00000000..f267c68c --- /dev/null +++ b/prompt.txt @@ -0,0 +1,61 @@ +Title/filename sync: derive title from filename, keep in sync via frontmatter + +## Goal +Every note has a `title` field in frontmatter that stores the human-readable title with original casing and spacing. The filename is always the slug of the title. The two are kept in sync at all times within Laputa. + +## Current state +`extract_title` reads the first H1 heading as the display title, with filename as fallback. This approach is fragile: H1 can diverge from the filename, and using filename directly loses casing information. + +## Expected behavior + +### Title/filename contract +- `title` frontmatter = human-readable title (e.g. `Career Tracks Depend on Company Shape`) +- filename = slug of title (e.g. `career-tracks-depend-on-company-shape.md`) +- The two are always kept in sync within Laputa + +### On note open (sync check) +When Laputa opens a note, it checks for desync: +- If `title` frontmatter is absent → derive title from filename (hyphens → spaces), write it to frontmatter +- If `title` frontmatter is present but its slug doesn't match the filename → **filename wins**: overwrite `title` with the filename-derived value +- If both are in sync → no-op + +This means: edits made outside Laputa that change only the filename or only the title frontmatter are auto-corrected on next open. **Filename is the source of truth.** + +### On title edit (inside Laputa) +When the user renames a note inside Laputa: +- Update `title` frontmatter to the new value +- Rename the file to the new slug +- Update all wikilinks in the vault (existing rename flow) + +### Title display +`extract_title` should read `title` from frontmatter (never from H1). H1 in the body is just content. + +## Keyboard access +- No new keyboard interactions — title editing is part of the existing rename flow +- QA: Cmd+P → open note → verify title in header matches frontmatter `title` field + +## Edge cases +1. Note has no `title` frontmatter → derive from filename on open (silent, idempotent) +2. `title` frontmatter desync from filename (edited outside Laputa) → filename wins, title overwritten +3. Filename has hyphens that are actual content (e.g. `e2e-test.md`) → becomes `E2e Test` — acceptable tradeoff +4. Fresh note created in Laputa → title set immediately from user input, filename = slug +5. Note renamed outside Laputa (filename changed, title not) → title updated to match new filename on next open + +## Acceptance criteria +- [ ] Every note has `title` in frontmatter after being opened in Laputa +- [ ] Filename = slug of `title` at all times within Laputa +- [ ] On open: desync is detected and resolved (filename wins) +- [ ] `extract_title` reads from frontmatter `title`, never H1 +- [ ] Rename flow updates both `title` frontmatter and filename atomically +- [ ] **Keyboard access:** open note → rename via title field → file renamed + wikilinks updated +- [ ] No regressions (`pnpm test` + `cargo test` pass) +- [ ] **Native app QA (keyboard only):** Open note → edit title → confirm filename changed + wikilinks updated; then manually desync title in a file → reopen in Laputa → confirm title auto-corrected + +--- +CONTEXT: +- Worktree: /Users/luca/Workspace/laputa-worktrees/title-filename-sync +- Dev port: 5393 (use --port 5393 when running pnpm dev) +- Run pnpm test before finishing +- Push directly to main when done: git push origin HEAD:main — NEVER open PRs, NEVER --no-verify +- Finish signal: openclaw system event --text 'laputa-task-done:6g9hCFpjP7qRRhqv:title-filename-sync' --mode now +- See CLAUDE.md for all coding guidelines diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 79a8d231..f2aad9c1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -708,15 +708,15 @@ mod tests { #[test] fn test_reload_vault_entry_reads_from_disk() { let dir = tempfile::TempDir::new().unwrap(); - let note = dir.path().join("note.md"); - std::fs::write(¬e, "---\nStatus: Active\n---\n# Test\n").unwrap(); + let note = dir.path().join("test.md"); + std::fs::write(¬e, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap(); let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap(); assert_eq!(entry.title, "Test"); assert_eq!(entry.status, Some("Active".to_string())); // Modify file on disk - std::fs::write(¬e, "---\nStatus: Done\n---\n# Test\n").unwrap(); + std::fs::write(¬e, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap(); let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap(); assert_eq!(fresh.status, Some("Done".to_string())); } diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index ec67d5e1..1add13ce 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -676,7 +676,7 @@ mod tests { let (_lock, _cache_tmp, dir) = setup_git_vault(); let vault = dir.path(); - create_test_file(vault, "existing.md", "# Existing\n"); + create_test_file(vault, "existing.md", "---\ntitle: Existing\n---\n# Existing\n"); git_add_commit(vault, "init"); // Prime cache @@ -686,13 +686,13 @@ mod tests { // Create files in a new protected subdirectory (simulates asset creation) create_test_file( vault, - "assets/default.md", - "---\nIs A: Theme\n---\n# Default Theme\n", + "assets/default-theme.md", + "---\ntitle: Default Theme\nIs A: Theme\n---\n# Default Theme\n", ); create_test_file( vault, - "assets/dark.md", - "---\nIs A: Theme\n---\n# Dark Theme\n", + "assets/dark-theme.md", + "---\ntitle: Dark Theme\nIs A: Theme\n---\n# Dark Theme\n", ); // Cache same commit — files in new subdirectory must appear diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 164228d4..feb2cfbf 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -5,6 +5,8 @@ use std::collections::HashMap; /// Intermediate struct to capture YAML frontmatter fields. #[derive(Debug, Deserialize, Default)] pub(crate) struct Frontmatter { + #[serde(default)] + pub title: Option, #[serde(rename = "type", alias = "Is A", alias = "is_a")] pub is_a: Option, #[serde(default)] @@ -138,6 +140,7 @@ impl StringOrList { /// Parse frontmatter from raw YAML data extracted by gray_matter. fn parse_frontmatter(data: &HashMap) -> Frontmatter { static KNOWN_KEYS: &[&str] = &[ + "title", "type", "Is A", "is_a", @@ -178,6 +181,7 @@ fn parse_frontmatter(data: &HashMap) -> Frontmatter { /// Known non-relationship frontmatter keys to skip (case-insensitive comparison). /// Only skip keys that can never contain wikilinks. const SKIP_KEYS: &[&str] = &[ + "title", "is a", "type", "aliases", diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index 446d58ae..12e81b97 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -566,10 +566,8 @@ mod tests { create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap(); - assert_eq!( - entry.title, - "AGENTS.md \u{2014} Vault Instructions for AI Agents" - ); + // No frontmatter title → derived from filename "AGENTS.md" + assert_eq!(entry.title, "AGENTS"); // Config files have no frontmatter type field — type is None assert_eq!(entry.is_a, None); } diff --git a/src-tauri/src/vault/migration.rs b/src-tauri/src/vault/migration.rs index 0fc9a0df..686bc209 100644 --- a/src-tauri/src/vault/migration.rs +++ b/src-tauri/src/vault/migration.rs @@ -397,7 +397,15 @@ pub fn vault_health_check(vault_path: &str) -> Result Err(_) => continue, }; let parsed = matter.parse(&content); - let title = super::parsing::extract_title(&parsed.content, &filename); + let fm_title = parsed.data.as_ref().and_then(|pod| { + if let gray_matter::Pod::Hash(ref map) = pod { + if let Some(gray_matter::Pod::String(s)) = map.get("title") { + return Some(s.as_str()); + } + } + None + }); + let title = super::parsing::extract_title(fm_title, &filename); let expected_stem = slugify(&title); let expected_filename = format!("{}.md", expected_stem); let current_stem = filename.strip_suffix(".md").unwrap_or(&filename); @@ -740,11 +748,11 @@ mod tests { fn test_health_check_detects_title_mismatch() { let tmp = tempdir().unwrap(); let vault = tmp.path(); - // Filename is "wrong-name.md" but title is "My Actual Title" + // Filename is "wrong-name.md" but title frontmatter says "My Actual Title" write_file( vault, "wrong-name.md", - "---\ntype: Note\n---\n# My Actual Title\n", + "---\ntitle: My Actual Title\ntype: Note\n---\n# My Actual Title\n", ); let report = vault_health_check(vault.to_str().unwrap()).unwrap(); diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index e741c758..36d9fba5 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -22,7 +22,7 @@ pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, p use file::read_file_metadata; use frontmatter::{extract_fm_and_rels, parse_created_at, resolve_is_a}; -use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title}; +use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title, slug_to_title}; use gray_matter::engine::YAML; use gray_matter::Matter; @@ -43,7 +43,7 @@ pub fn parse_md_file(path: &Path) -> Result { let parsed = matter.parse(&content); let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data); - let title = extract_title(&parsed.content, &filename); + let title = extract_title(frontmatter.title.as_deref(), &filename); let snippet = extract_snippet(&content); let word_count = count_body_words(&content); let outgoing_links = extract_outgoing_links(&parsed.content); diff --git a/src-tauri/src/vault/mod_tests.rs b/src-tauri/src/vault/mod_tests.rs index 95e5a8c7..f24d49e0 100644 --- a/src-tauri/src/vault/mod_tests.rs +++ b/src-tauri/src/vault/mod_tests.rs @@ -23,20 +23,20 @@ fn test_reload_entry_returns_fresh_data() { let dir = TempDir::new().unwrap(); create_test_file( dir.path(), - "note.md", - "---\nStatus: Active\n---\n# My Note\n\nOriginal.", + "my-note.md", + "---\ntitle: My Note\nStatus: Active\n---\n# My Note\n\nOriginal.", ); - let entry = reload_entry(&dir.path().join("note.md")).unwrap(); + let entry = reload_entry(&dir.path().join("my-note.md")).unwrap(); assert_eq!(entry.title, "My Note"); assert_eq!(entry.status, Some("Active".to_string())); // Modify on disk and reload — must see the new content create_test_file( dir.path(), - "note.md", - "---\nStatus: Done\n---\n# My Note\n\nUpdated.", + "my-note.md", + "---\ntitle: My Note\nStatus: Done\n---\n# My Note\n\nUpdated.", ); - let fresh = reload_entry(&dir.path().join("note.md")).unwrap(); + let fresh = reload_entry(&dir.path().join("my-note.md")).unwrap(); assert_eq!(fresh.status, Some("Done".to_string())); } @@ -47,7 +47,7 @@ fn test_reload_entry_nonexistent_file() { assert!(result.unwrap_err().contains("does not exist")); } -const FULL_FM_CONTENT: &str = "---\nIs A: Project\naliases:\n - Laputa\n - Castle in the Sky\nBelongs to:\n - Studio Ghibli\nRelated to:\n - Miyazaki\nStatus: Active\nOwner: Luca\nCadence: Weekly\n---\n# Laputa Project\n\nThis is a project note.\n"; +const FULL_FM_CONTENT: &str = "---\ntitle: Laputa Project\nIs A: Project\naliases:\n - Laputa\n - Castle in the Sky\nBelongs to:\n - Studio Ghibli\nRelated to:\n - Miyazaki\nStatus: Active\nOwner: Luca\nCadence: Weekly\n---\n# Laputa Project\n\nThis is a project note.\n"; #[test] fn test_parse_full_frontmatter_identity() { @@ -85,10 +85,11 @@ fn test_parse_empty_frontmatter() { let dir = TempDir::new().unwrap(); let entry = parse_test_entry( &dir, - "empty-fm.md", + "just-a-title.md", "---\n---\n# Just a Title\n\nNo frontmatter fields.", ); - assert_eq!(entry.title, "Just a Title"); + // No title in frontmatter → derived from filename + assert_eq!(entry.title, "Just A Title"); assert!(entry.aliases.is_empty()); assert!(entry.belongs_to.is_empty()); @@ -99,11 +100,11 @@ fn test_parse_empty_frontmatter() { fn test_parse_no_frontmatter() { let dir = TempDir::new().unwrap(); let content = "# A Note Without Frontmatter\n\nJust markdown."; - create_test_file(dir.path(), "no-fm.md", content); + create_test_file(dir.path(), "a-note-without-frontmatter.md", content); - let entry = parse_md_file(&dir.path().join("no-fm.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md")).unwrap(); + // No title in frontmatter → derived from filename assert_eq!(entry.title, "A Note Without Frontmatter"); - // is_a is inferred from parent folder name (temp dir), not None } #[test] diff --git a/src-tauri/src/vault/parsing.rs b/src-tauri/src/vault/parsing.rs index 59c4fa2b..a56606d0 100644 --- a/src-tauri/src/vault/parsing.rs +++ b/src-tauri/src/vault/parsing.rs @@ -1,20 +1,36 @@ //! Pure text-processing helpers for markdown content parsing. //! Snippet extraction, markdown stripping, date parsing, and string utilities. -/// Extract the title from a markdown file's content. -/// Tries the first H1 heading (`# Title`), falls back to filename without extension. -pub(super) fn extract_title(content: &str, filename: &str) -> String { - for line in content.lines() { - let trimmed = line.trim(); - if let Some(heading) = trimmed.strip_prefix("# ") { - let title = heading.trim(); - if !title.is_empty() { - return title.to_string(); +/// Derive a human-readable title from a filename stem (slug). +/// Converts hyphens to spaces and title-cases each word. +/// Example: `career-tracks-depend-on-company-shape` → `Career Tracks Depend on Company Shape` +pub(super) fn slug_to_title(stem: &str) -> String { + stem.split('-') + .filter(|s| !s.is_empty()) + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(c) => { + let upper: String = c.to_uppercase().collect(); + format!("{}{}", upper, chars.as_str()) + } + None => String::new(), } + }) + .collect::>() + .join(" ") +} + +/// Extract the display title for a note. +/// Reads `title` from frontmatter; falls back to deriving a title from the filename. +pub(super) fn extract_title(fm_title: Option<&str>, filename: &str) -> String { + if let Some(title) = fm_title { + if !title.is_empty() { + return title.to_string(); } } - // Fallback: filename without .md extension - filename.strip_suffix(".md").unwrap_or(filename).to_string() + let stem = filename.strip_suffix(".md").unwrap_or(filename); + slug_to_title(stem) } /// Remove YAML frontmatter (triple-dash delimited) from content. @@ -271,27 +287,54 @@ pub(super) fn parse_iso_date(date_str: &str) -> Option { mod tests { use super::*; - // --- extract_title tests --- + // --- slug_to_title tests --- #[test] - fn test_extract_title_from_h1() { - let content = "---\nIs A: Note\n---\n# My Great Note\n\nSome content here."; - assert_eq!(extract_title(content, "my-great-note.md"), "My Great Note"); + fn test_slug_to_title_basic() { + assert_eq!(slug_to_title("career-tracks"), "Career Tracks"); } #[test] - fn test_extract_title_fallback_to_filename() { - let content = "Just some content without a heading."; + fn test_slug_to_title_single_word() { + assert_eq!(slug_to_title("hello"), "Hello"); + } + + #[test] + fn test_slug_to_title_empty() { + assert_eq!(slug_to_title(""), ""); + } + + #[test] + fn test_slug_to_title_e2e() { + assert_eq!(slug_to_title("e2e-test"), "E2e Test"); + } + + #[test] + fn test_slug_to_title_multiple_hyphens() { + assert_eq!(slug_to_title("a--b"), "A B"); + } + + // --- extract_title tests --- + + #[test] + fn test_extract_title_from_frontmatter() { assert_eq!( - extract_title(content, "fallback-title.md"), - "fallback-title" + extract_title(Some("My Great Note"), "my-great-note.md"), + "My Great Note" ); } #[test] - fn test_extract_title_empty_h1_falls_back() { - let content = "# \n\nSome content."; - assert_eq!(extract_title(content, "empty-h1.md"), "empty-h1"); + fn test_extract_title_fallback_to_filename() { + assert_eq!( + extract_title(None, "fallback-title.md"), + "Fallback Title" + ); + } + + #[test] + fn test_extract_title_empty_fm_falls_back() { + assert_eq!(extract_title(Some(""), "empty-h1.md"), "Empty H1"); } // --- extract_snippet tests --- diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index c970881a..06542ae4 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -16,7 +16,7 @@ pub struct RenameResult { } /// Convert a title to a filename slug (lowercase, hyphens, no special chars). -fn title_to_slug(title: &str) -> String { +pub(super) fn title_to_slug(title: &str) -> String { title .to_lowercase() .chars() @@ -128,6 +128,26 @@ fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize { .count() } +/// Extract the value of the `title:` frontmatter field from raw content. +fn extract_fm_title_value(content: &str) -> Option { + if !content.starts_with("---\n") { + return None; + } + let fm = content[4..].split("\n---").next()?; + for line in fm.lines() { + let t = line.trim_start(); + for prefix in &["title:", "\"title\":"] { + if let Some(rest) = t.strip_prefix(prefix) { + let val = rest.trim().trim_matches('"').trim_matches('\''); + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + } + None +} + /// Check if frontmatter contains a `title:` key. fn frontmatter_has_title_key(content: &str) -> bool { if !content.starts_with("---\n") { @@ -219,7 +239,8 @@ pub fn rename_note( .file_name() .map(|f| f.to_string_lossy().to_string()) .unwrap_or_default(); - let extracted_title = super::extract_title(&content, &old_filename); + let fm_title = extract_fm_title_value(&content); + let extracted_title = super::extract_title(fm_title.as_deref(), &old_filename); let old_title = old_title_hint.unwrap_or(&extracted_title); // Check both title and filename: even if the title in content matches,