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) <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,16 @@ pub fn reload_vault_entry(path: String) -> Result<VaultEntry, String> {
|
||||
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<bool, String> {
|
||||
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<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
179
src-tauri/src/vault/title_sync.rs
Normal file
179
src-tauri/src/vault/title_sync.rs
Normal file
@@ -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<String> {
|
||||
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<SyncAction, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user