diff --git a/demo-vault-v2/career-tracks-depend-on-company-shape.md b/demo-vault-v2/career-tracks-depend-on-company-shape.md index 4c267118..a69dde29 100644 --- a/demo-vault-v2/career-tracks-depend-on-company-shape.md +++ b/demo-vault-v2/career-tracks-depend-on-company-shape.md @@ -1,5 +1,4 @@ --- -title: Untitled note 360 type: Note status: Active --- diff --git a/demo-vault-v2/renamed-title-xyz.md b/demo-vault-v2/renamed-title-xyz.md index 82cc845b..a69dde29 100644 --- a/demo-vault-v2/renamed-title-xyz.md +++ b/demo-vault-v2/renamed-title-xyz.md @@ -1,5 +1,4 @@ --- -title: Untitled note 345 type: Note status: Active --- diff --git a/demo-vault-v2/responsive-rename-test-renamed.md b/demo-vault-v2/responsive-rename-test-renamed.md new file mode 100644 index 00000000..b59b95b1 --- /dev/null +++ b/demo-vault-v2/responsive-rename-test-renamed.md @@ -0,0 +1,7 @@ +--- +type: Project +status: Active +--- + + +Appended by raw editor test \ No newline at end of file diff --git a/demo-vault-v2/responsive-rename-test.md b/demo-vault-v2/responsive-rename-test.md deleted file mode 100644 index a69dde29..00000000 --- a/demo-vault-v2/responsive-rename-test.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -type: Note -status: Active ---- diff --git a/demo-vault-v2/untitled-note-1775473969.md b/demo-vault-v2/untitled-note-1775473969.md new file mode 100644 index 00000000..6ffd9a23 --- /dev/null +++ b/demo-vault-v2/untitled-note-1775473969.md @@ -0,0 +1,7 @@ +--- +type: Note +status: Active +--- +[[2024-03|March 2024]] + +[[2024-03|March 2024]] diff --git a/demo-vault-v2/untitled-project-1775473964.md b/demo-vault-v2/untitled-project-1775473964.md new file mode 100644 index 00000000..8f781418 --- /dev/null +++ b/demo-vault-v2/untitled-project-1775473964.md @@ -0,0 +1,15 @@ +--- +type: Project +status: Active +--- + +## Objective + + + +## Key Results + + + +## Notes + diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs index 50d516cc..a2be3ea0 100644 --- a/src-tauri/src/commands/vault.rs +++ b/src-tauri/src/commands/vault.rs @@ -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, 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, String> { let vault_path = expand_tilde(&vault_path); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 924ee724..75f3663f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index 0d196cd0..4ff0043f 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -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); } diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 8baad6f1..2c4367d8 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -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}; diff --git a/src-tauri/src/vault/mod_tests.rs b/src-tauri/src/vault/mod_tests.rs index bfda30c0..826d5cd5 100644 --- a/src-tauri/src/vault/mod_tests.rs +++ b/src-tauri/src/vault/mod_tests.rs @@ -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()); diff --git a/src-tauri/src/vault/parsing.rs b/src-tauri/src/vault/parsing.rs index 7b503546..a053158b 100644 --- a/src-tauri/src/vault/parsing.rs +++ b/src-tauri/src/vault/parsing.rs @@ -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" ); } diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index a0189f58..4a7e49b6 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -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, 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 { diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index af11b4a7..25d243fc 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -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('reload_vault_entry', { path: result.new_path }), + invoke('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,