refactor: extract_title reads from frontmatter title, not H1

Title is now sourced from the `title` frontmatter field with filename-
derived fallback (slug_to_title). H1 headings are treated as body content.
Added `title` to Frontmatter struct and SKIP_KEYS. Made title_to_slug
pub(super) for reuse across vault submodules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-17 16:41:49 +01:00
parent e74d0e46c8
commit 663e9563b2
10 changed files with 189 additions and 53 deletions

61
prompt.txt Normal file
View File

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

View File

@@ -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(&note, "---\nStatus: Active\n---\n# Test\n").unwrap();
let note = dir.path().join("test.md");
std::fs::write(&note, "---\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(&note, "---\nStatus: Done\n---\n# Test\n").unwrap();
std::fs::write(&note, "---\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()));
}

View File

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

View File

@@ -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<String>,
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
pub is_a: Option<StringOrList>,
#[serde(default)]
@@ -138,6 +140,7 @@ impl StringOrList {
/// Parse frontmatter from raw YAML data extracted by gray_matter.
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
static KNOWN_KEYS: &[&str] = &[
"title",
"type",
"Is A",
"is_a",
@@ -178,6 +181,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> 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",

View File

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

View File

@@ -397,7 +397,15 @@ pub fn vault_health_check(vault_path: &str) -> Result<VaultHealthReport, String>
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();

View File

@@ -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<VaultEntry, String> {
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);

View File

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

View File

@@ -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::<Vec<_>>()
.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<u64> {
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 ---

View File

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