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:
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 360
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 345
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
|
||||
7
demo-vault-v2/responsive-rename-test-renamed.md
Normal file
7
demo-vault-v2/responsive-rename-test-renamed.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
|
||||
Appended by raw editor test
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
7
demo-vault-v2/untitled-note-1775473969.md
Normal file
7
demo-vault-v2/untitled-note-1775473969.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
15
demo-vault-v2/untitled-project-1775473964.md
Normal file
15
demo-vault-v2/untitled-project-1775473964.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
|
||||
## Key Results
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -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(¬e_path);
|
||||
vault::auto_rename_untitled(&vault_path, ¬e_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user