feat: auto-rename untitled notes + Rust title resolution tests

- Add auto_rename_untitled Rust command: one-shot rename of untitled-*
  files based on H1 heading, with collision handling (-2, -3)
- Wire auto-rename into frontend save flow (useAppSave.ts)
- Update Rust tests: extract_title now uses H1 > frontmatter > filename
- Add extract_h1_title tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-04-06 13:14:43 +02:00
parent d50f3479dc
commit 9c87eca226
14 changed files with 151 additions and 23 deletions

View File

@@ -1,5 +1,4 @@
---
title: Untitled note 360
type: Note
status: Active
---

View File

@@ -1,5 +1,4 @@
---
title: Untitled note 345
type: Note
status: Active
---

View File

@@ -0,0 +1,7 @@
---
type: Project
status: Active
---
Appended by raw editor test

View File

@@ -1,4 +0,0 @@
---
type: Note
status: Active
---

View File

@@ -0,0 +1,7 @@
---
type: Note
status: Active
---
[[2024-03|March 2024]]
[[2024-03|March 2024]]

View File

@@ -0,0 +1,15 @@
---
type: Project
status: Active
---
## Objective
## Key Results
## Notes

View File

@@ -45,6 +45,16 @@ pub fn rename_note(
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,
note_path: String,
) -> Result<Option<RenameResult>, String> {
let vault_path = expand_tilde(&vault_path);
let note_path = expand_tilde(&note_path);
vault::auto_rename_untitled(&vault_path, &note_path)
}
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);

View File

@@ -119,6 +119,7 @@ pub fn run() {
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,
commands::get_file_history,

View File

@@ -539,8 +539,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"), None).unwrap();
// No frontmatter title → derived from filename slug (H1 is body content)
assert_eq!(entry.title, "AGENTS");
// H1 is now the primary title source
assert_eq!(entry.title, "AGENTS.md \u{2014} Vault Instructions for AI Agents");
// Config files have no frontmatter type field — type is None
assert_eq!(entry.is_a, None);
}

View File

@@ -20,7 +20,8 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{
detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult,
auto_rename_untitled, detect_renames, rename_note, update_wikilinks_for_renames,
DetectedRename, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};
pub use trash::{batch_delete_notes, delete_note};

View File

@@ -94,8 +94,8 @@ fn test_parse_empty_frontmatter() {
"just-a-title.md",
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
);
// No title in frontmatter → derived from filename slug (H1 is body content)
assert_eq!(entry.title, "Just A Title");
// H1 is now the primary title source
assert_eq!(entry.title, "Just a Title");
assert!(entry.aliases.is_empty());
assert!(entry.belongs_to.is_empty());

View File

@@ -341,29 +341,65 @@ mod tests {
assert_eq!(slug_to_title("a--b"), "A B");
}
// --- extract_h1_title tests ---
#[test]
fn test_extract_h1_title_basic() {
assert_eq!(extract_h1_title("# Hello World\n\nBody."), Some("Hello World".to_string()));
}
#[test]
fn test_extract_h1_title_after_frontmatter() {
let content = "---\ntype: Note\n---\n# My Note\n\nBody.";
assert_eq!(extract_h1_title(content), Some("My Note".to_string()));
}
#[test]
fn test_extract_h1_title_with_empty_lines_before() {
let content = "---\ntype: Note\n---\n\n# Spaced Title\n\nBody.";
assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string()));
}
#[test]
fn test_extract_h1_title_none_when_no_h1() {
assert_eq!(extract_h1_title("Just body text."), None);
}
#[test]
fn test_extract_h1_title_none_when_h1_not_first() {
assert_eq!(extract_h1_title("Some text\n# Not first\n"), None);
}
// --- extract_title tests ---
#[test]
fn test_extract_title_from_frontmatter() {
fn test_extract_title_h1_takes_priority_over_frontmatter() {
assert_eq!(
extract_title(Some("My Great Note"), "", "my-great-note.md"),
"My Great Note"
extract_title(Some("FM Title"), "---\ntitle: FM Title\n---\n# H1 Title\n\nBody.", "note.md"),
"H1 Title"
);
}
#[test]
fn test_extract_title_ignores_h1_uses_filename() {
// H1 is body content, not a title source
fn test_extract_title_h1_when_no_frontmatter_title() {
assert_eq!(
extract_title(None, "# Hello World\n\nBody text.", "some-file.md"),
"Some File"
"Hello World"
);
}
#[test]
fn test_extract_title_ignores_h1_after_frontmatter() {
fn test_extract_title_h1_after_frontmatter() {
let content = "---\nIs A: Note\n---\n# My Note\n\nBody.";
assert_eq!(extract_title(None, content, "fallback.md"), "Fallback");
assert_eq!(extract_title(None, content, "fallback.md"), "My Note");
}
#[test]
fn test_extract_title_frontmatter_when_no_h1() {
assert_eq!(
extract_title(Some("My Great Note"), "Just body text.", "my-great-note.md"),
"My Great Note"
);
}
#[test]
@@ -375,11 +411,10 @@ mod tests {
}
#[test]
fn test_extract_title_empty_fm_falls_back_to_filename() {
// Empty frontmatter title falls back to filename, not H1
fn test_extract_title_h1_wins_over_empty_frontmatter() {
assert_eq!(
extract_title(Some(""), "# From H1\n", "empty-h1.md"),
"Empty H1"
"From H1"
);
}

View File

@@ -251,6 +251,42 @@ pub fn rename_note(
})
}
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
fn is_untitled_filename(filename: &str) -> bool {
let stem = filename.strip_suffix(".md").unwrap_or(filename);
// Match: untitled-note-{digits} or untitled-{type}-{digits}
stem.starts_with("untitled-") && stem.rsplit('-').next().is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()))
}
/// Auto-rename an untitled note based on its H1 heading.
/// Returns `Some(RenameResult)` if renamed, `None` if conditions not met.
/// This is a ONE-SHOT rename: only fires for untitled-* files with an H1.
pub fn auto_rename_untitled(
vault_path: &str,
note_path: &str,
) -> Result<Option<RenameResult>, String> {
let path = Path::new(note_path);
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
if !is_untitled_filename(&filename) {
return Ok(None);
}
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
let h1_title = match super::parsing::extract_h1_title(&content) {
Some(t) => t,
None => return Ok(None),
};
let result = rename_note(vault_path, note_path, &h1_title, None)?;
Ok(Some(result))
}
/// A detected rename: old path → new path (both relative to vault root).
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DetectedRename {

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
import { needsRenameOnSave } from './useNoteRename'
import { flushEditorContent } from '../utils/autoSave'
import { isTauri } from '../mock-tauri'
import type { VaultEntry } from '../types'
interface TabState {
@@ -55,7 +57,27 @@ export function useAppSave({
const onNotePersisted = useCallback((path: string) => {
clearUnsaved(path)
if (path.endsWith('.yml')) reloadViews?.()
}, [clearUnsaved, reloadViews])
// Auto-rename untitled notes when they have an H1 heading
const filename = path.split('/').pop() ?? ''
const stem = filename.replace(/\.md$/, '')
if (isTauri() && stem.startsWith('untitled-') && /\d+$/.test(stem)) {
invoke<{ new_path: string; updated_files: number } | null>('auto_rename_untitled', {
vaultPath: resolvedPath,
notePath: path,
}).then((result) => {
if (result) {
// Re-read the renamed file content and entry, then replace
Promise.all([
invoke<VaultEntry>('reload_vault_entry', { path: result.new_path }),
invoke<string>('get_note_content', { path: result.new_path }),
]).then(([newEntry, newContent]) => {
replaceEntry(path, { ...newEntry, path: result.new_path }, newContent)
loadModifiedFiles()
}).catch(() => { /* ignore reload failure */ })
}
}).catch(() => { /* auto-rename is best-effort */ })
}
}, [clearUnsaved, reloadViews, resolvedPath, replaceEntry, loadModifiedFiles])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted,