From a1688f84889403ff3296bb5c8d0d6eac282c0816 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 16:45:30 +0100 Subject: [PATCH] feat: title/filename sync on note open + always write title in rename - New sync_title_on_open function: detects desync between title frontmatter and filename, corrects it (filename wins as source of truth) - Registered sync_note_title Tauri command - rename_note now always writes title to frontmatter (not just when present) Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands.rs | 10 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/vault/mod.rs | 2 + src-tauri/src/vault/rename.rs | 11 +- src-tauri/src/vault/title_sync.rs | 179 ++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/vault/title_sync.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f2aad9c1..5aa6213d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -166,6 +166,16 @@ pub fn reload_vault_entry(path: String) -> Result { vault::reload_entry(std::path::Path::new(path.as_ref())) } +/// Sync the `title` frontmatter field with the filename on note open. +/// Returns `true` if the file was modified (title was absent or desynced). +#[tauri::command] +pub fn sync_note_title(path: String) -> Result { + use vault::SyncAction; + let path = expand_tilde(&path); + let action = vault::sync_title_on_open(std::path::Path::new(path.as_ref()))?; + Ok(matches!(action, SyncAction::Updated { .. })) +} + #[tauri::command] pub fn save_image(vault_path: String, filename: String, data: String) -> Result { let vault_path = expand_tilde(&vault_path); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2ac777d5..d44eef2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -140,6 +140,7 @@ pub fn run() { commands::stream_claude_agent, commands::reload_vault, commands::reload_vault_entry, + commands::sync_note_title, commands::save_image, commands::copy_image_to_vault, commands::purge_trash, diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 36d9fba5..2b945f53 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -8,6 +8,7 @@ mod image; mod migration; mod parsing; mod rename; +mod title_sync; mod trash; pub use cache::{invalidate_cache, scan_vault_cached}; @@ -18,6 +19,7 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul pub use image::{copy_image_to_vault, save_image}; pub use migration::{flatten_vault, migrate_is_a_to_type, vault_health_check, VaultHealthReport}; pub use rename::{rename_note, RenameResult}; +pub use title_sync::{sync_title_on_open, SyncAction}; pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash}; use file::read_file_metadata; diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index 06542ae4..309f0226 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -165,14 +165,13 @@ fn frontmatter_has_title_key(content: &str) -> bool { .unwrap_or(false) } -/// Update H1 and optionally the `title:` frontmatter field in content. +/// Update H1 and the `title:` frontmatter field in content. +/// Always writes `title` to frontmatter (creates it if absent). fn update_note_title_in_content(content: &str, new_title: &str) -> String { let mut updated = update_h1_title(content, new_title); - if frontmatter_has_title_key(content) { - let value = FrontmatterValue::String(new_title.to_string()); - if let Ok(c) = update_frontmatter_content(&updated, "title", Some(value)) { - updated = c; - } + let value = FrontmatterValue::String(new_title.to_string()); + if let Ok(c) = update_frontmatter_content(&updated, "title", Some(value)) { + updated = c; } updated } diff --git a/src-tauri/src/vault/title_sync.rs b/src-tauri/src/vault/title_sync.rs new file mode 100644 index 00000000..75e7d2ea --- /dev/null +++ b/src-tauri/src/vault/title_sync.rs @@ -0,0 +1,179 @@ +use std::fs; +use std::path::Path; + +use crate::frontmatter::{update_frontmatter_content, FrontmatterValue}; + +use super::parsing::slug_to_title; +use super::rename::title_to_slug; + +/// Result of a title sync check. +#[derive(Debug, PartialEq)] +pub enum SyncAction { + /// Title and filename are already in sync. + InSync, + /// Title was absent or desynced; frontmatter was updated on disk. + Updated { title: String }, +} + +/// Extract the raw `title:` value from frontmatter in file content. +fn extract_raw_title(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 +} + +/// Sync the `title` frontmatter field with the filename. +/// +/// Rules (filename is source of truth): +/// - If `title` is absent → derive from filename, write to frontmatter +/// - If `title` is present but its slug doesn't match the filename stem → overwrite +/// - If both are in sync → no-op +pub fn sync_title_on_open(path: &Path) -> Result { + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + let filename = path + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let stem = filename.strip_suffix(".md").unwrap_or(&filename); + let expected_title = slug_to_title(stem); + + let fm_title = extract_raw_title(&content); + + match fm_title { + Some(ref title) if title_to_slug(title) == stem => Ok(SyncAction::InSync), + _ => { + // Title absent or desynced — filename wins + let value = FrontmatterValue::String(expected_title.clone()); + let updated = update_frontmatter_content(&content, "title", Some(value)) + .map_err(|e| format!("Failed to update frontmatter: {}", e))?; + fs::write(path, &updated) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; + Ok(SyncAction::Updated { + title: expected_title, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write_note(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + fs::write(&path, content).unwrap(); + path + } + + #[test] + fn test_sync_adds_title_when_absent() { + let dir = TempDir::new().unwrap(); + let path = write_note( + dir.path(), + "career-tracks.md", + "---\ntype: Note\n---\n# Career Tracks\n", + ); + let result = sync_title_on_open(&path).unwrap(); + assert_eq!( + result, + SyncAction::Updated { + title: "Career Tracks".to_string() + } + ); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("title: Career Tracks")); + } + + #[test] + fn test_sync_noop_when_in_sync() { + let dir = TempDir::new().unwrap(); + let path = write_note( + dir.path(), + "my-note.md", + "---\ntitle: My Note\ntype: Note\n---\n# My Note\n", + ); + let result = sync_title_on_open(&path).unwrap(); + assert_eq!(result, SyncAction::InSync); + } + + #[test] + fn test_sync_overwrites_desynced_title() { + let dir = TempDir::new().unwrap(); + // Filename says "new-name" but title says "Old Name" + let path = write_note( + dir.path(), + "new-name.md", + "---\ntitle: Old Name\ntype: Note\n---\n# Old Name\n", + ); + let result = sync_title_on_open(&path).unwrap(); + assert_eq!( + result, + SyncAction::Updated { + title: "New Name".to_string() + } + ); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("title: New Name")); + assert!(!content.contains("title: Old Name")); + } + + #[test] + fn test_sync_adds_frontmatter_when_none_exists() { + let dir = TempDir::new().unwrap(); + let path = write_note(dir.path(), "plain-note.md", "# Plain Note\n\nSome content.\n"); + let result = sync_title_on_open(&path).unwrap(); + assert_eq!( + result, + SyncAction::Updated { + title: "Plain Note".to_string() + } + ); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.starts_with("---\n")); + assert!(content.contains("title: Plain Note")); + } + + #[test] + fn test_sync_e2e_filename() { + let dir = TempDir::new().unwrap(); + let path = write_note(dir.path(), "e2e-test.md", "---\ntype: Note\n---\n"); + let result = sync_title_on_open(&path).unwrap(); + assert_eq!( + result, + SyncAction::Updated { + title: "E2e Test".to_string() + } + ); + } + + #[test] + fn test_sync_preserves_other_frontmatter() { + let dir = TempDir::new().unwrap(); + let path = write_note( + dir.path(), + "my-note.md", + "---\ntype: Project\nstatus: Active\n---\n# My Note\n", + ); + sync_title_on_open(&path).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("type: Project")); + assert!(content.contains("status: Active")); + assert!(content.contains("title: My Note")); + } +}