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:
lucaronin
2026-03-17 16:45:30 +01:00
parent 663e9563b2
commit 9112a24fcb
5 changed files with 197 additions and 6 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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
}

View 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"));
}
}