From 377a3f8d0e4208f2ca95a1e458465edb1ae3a59e Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 6 Apr 2026 13:08:17 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20H1-as-title=20=E2=80=94=20title=20resol?= =?UTF-8?q?ution,=20TitleField=20hiding,=20breadcrumb=20filename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust: extract_title now prioritizes H1 > frontmatter > filename - Rust: add has_h1 field to VaultEntry for frontend TitleField control - Frontend: hide TitleField + icon picker when note has H1 in body - Frontend: breadcrumb shows filename stem instead of display title - Frontend: new notes use untitled-{type}-{timestamp}.md, no title in frontmatter - CSS: only hide first H1 in BlockNote when TitleField is visible - Updated all tests for new title resolution and note creation behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- demo-vault-v2/final-title.md | 1 - demo-vault-v2/responsive-rename-test.md | 4 +++ demo-vault-v2/test-note-abc.md | 4 +++ demo-vault-v2/untitled-note-1775473305.md | 4 +++ demo-vault-v2/untitled-note-1775473680.md | 5 +++ demo-vault-v2/untitled-note-361.md | 5 +++ src-tauri/src/vault/entry.rs | 4 +++ src-tauri/src/vault/mod.rs | 2 ++ src-tauri/src/vault/parsing.rs | 31 ++++++++++++++-- src/components/BreadcrumbBar.test.tsx | 21 ++++++++--- src/components/BreadcrumbBar.tsx | 6 ++-- src/components/Editor.css | 9 ++--- src/components/Editor.test.tsx | 13 +++++-- src/components/EditorContent.tsx | 43 +++++++++++++---------- src/hooks/useNoteActions.test.ts | 42 +++++++++++++++------- src/hooks/useNoteCreation.test.ts | 31 +++++++++++----- src/hooks/useNoteCreation.ts | 39 +++++++++++++------- src/types.ts | 2 ++ 18 files changed, 195 insertions(+), 71 deletions(-) create mode 100644 demo-vault-v2/responsive-rename-test.md create mode 100644 demo-vault-v2/test-note-abc.md create mode 100644 demo-vault-v2/untitled-note-1775473305.md create mode 100644 demo-vault-v2/untitled-note-1775473680.md create mode 100644 demo-vault-v2/untitled-note-361.md diff --git a/demo-vault-v2/final-title.md b/demo-vault-v2/final-title.md index fc544ef6..a69dde29 100644 --- a/demo-vault-v2/final-title.md +++ b/demo-vault-v2/final-title.md @@ -1,5 +1,4 @@ --- -title: Untitled note 358 type: Note status: Active --- diff --git a/demo-vault-v2/responsive-rename-test.md b/demo-vault-v2/responsive-rename-test.md new file mode 100644 index 00000000..a69dde29 --- /dev/null +++ b/demo-vault-v2/responsive-rename-test.md @@ -0,0 +1,4 @@ +--- +type: Note +status: Active +--- diff --git a/demo-vault-v2/test-note-abc.md b/demo-vault-v2/test-note-abc.md new file mode 100644 index 00000000..a69dde29 --- /dev/null +++ b/demo-vault-v2/test-note-abc.md @@ -0,0 +1,4 @@ +--- +type: Note +status: Active +--- diff --git a/demo-vault-v2/untitled-note-1775473305.md b/demo-vault-v2/untitled-note-1775473305.md new file mode 100644 index 00000000..a69dde29 --- /dev/null +++ b/demo-vault-v2/untitled-note-1775473305.md @@ -0,0 +1,4 @@ +--- +type: Note +status: Active +--- diff --git a/demo-vault-v2/untitled-note-1775473680.md b/demo-vault-v2/untitled-note-1775473680.md new file mode 100644 index 00000000..ce9b23cb --- /dev/null +++ b/demo-vault-v2/untitled-note-1775473680.md @@ -0,0 +1,5 @@ +--- +type: Note +status: Active +--- +My Custom H1 Heading diff --git a/demo-vault-v2/untitled-note-361.md b/demo-vault-v2/untitled-note-361.md new file mode 100644 index 00000000..caacbfc8 --- /dev/null +++ b/demo-vault-v2/untitled-note-361.md @@ -0,0 +1,5 @@ +--- +title: Untitled note 361 +type: Note +status: Active +--- diff --git a/src-tauri/src/vault/entry.rs b/src-tauri/src/vault/entry.rs index 2f3b62ad..cf83e453 100644 --- a/src-tauri/src/vault/entry.rs +++ b/src-tauri/src/vault/entry.rs @@ -78,6 +78,10 @@ pub struct VaultEntry { /// Configured via `_list_properties_display` in the type file's frontmatter. #[serde(rename = "listPropertiesDisplay", default)] pub list_properties_display: Vec, + /// Whether the note body has an H1 heading on the first non-empty line. + /// Used by the frontend to decide whether to show the TitleField. + #[serde(rename = "hasH1")] + pub has_h1: bool, /// File kind: "markdown", "text", or "binary". /// Determines how the frontend renders and opens the file. #[serde(rename = "fileKind", default = "default_file_kind")] diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 96d8583c..8baad6f1 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -57,6 +57,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result) -> Result String { .join(" ") } +/// Extract the H1 title from the first non-empty line of the body (after frontmatter). +/// Returns `None` if no H1 is found on the first non-empty line. +pub(super) fn extract_h1_title(content: &str) -> Option { + let body = strip_frontmatter(content); + for line in body.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(title) = trimmed.strip_prefix("# ") { + let title = strip_markdown_chars(title).trim().to_string(); + if !title.is_empty() { + return Some(title); + } + } + break; // first non-empty line is not H1 + } + None +} + /// Extract the display title for a note. -/// Priority: frontmatter `title:` → filename-derived title. -/// H1 headings are treated as body content, not title source. -pub(super) fn extract_title(fm_title: Option<&str>, _content: &str, filename: &str) -> String { +/// Priority: H1 on first non-empty line → frontmatter `title:` → filename-derived title. +pub(super) fn extract_title(fm_title: Option<&str>, content: &str, filename: &str) -> String { + // 1. H1 on first non-empty line of body + if let Some(h1) = extract_h1_title(content) { + return h1; + } + // 2. frontmatter title (legacy, backward compat) if let Some(title) = fm_title { if !title.is_empty() { return title.to_string(); } } + // 3. filename slug let stem = filename.strip_suffix(".md").unwrap_or(filename); slug_to_title(stem) } diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 7a93fb41..e5118b8c 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -12,8 +12,6 @@ const baseEntry: VaultEntry = { belongsTo: [], relatedTo: [], status: null, - owner: null, - cadence: null, archived: false, modifiedAt: 1700000000, createdAt: null, @@ -24,6 +22,18 @@ const baseEntry: VaultEntry = { icon: null, color: null, order: null, + outgoingLinks: [], + template: null, + sort: null, + sidebarLabel: null, + view: null, + visible: null, + properties: {}, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + hasH1: false, } const archivedEntry: VaultEntry = { @@ -94,13 +104,14 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', render() expect(screen.getByText('Note')).toBeInTheDocument() expect(screen.getByText('›')).toBeInTheDocument() - expect(screen.getByText('Test Note')).toBeInTheDocument() + expect(screen.getByText('test')).toBeInTheDocument() }) - it('shows emoji icon when entry has an emoji icon', () => { + it('does not render emoji icon in breadcrumb (icon removed from breadcrumb title)', () => { const entryWithEmoji = { ...baseEntry, icon: '🚀' } render() - expect(screen.getByText('🚀')).toBeInTheDocument() + // BreadcrumbTitle now only shows type label and filename stem — no icon + expect(screen.queryByText('🚀')).not.toBeInTheDocument() }) it('does not show icon when entry has a non-emoji icon', () => { diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 67ccb030..aa63cfc2 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -179,14 +179,12 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog function BreadcrumbTitle({ entry }: { entry: VaultEntry }) { const typeLabel = entry.isA ?? 'Note' - const icon = entry.icon - const emojiIcon = icon && /^\p{Emoji}/u.test(icon) ? icon : null + const filenameStem = entry.filename.replace(/\.md$/, '') return (
{typeLabel} - {emojiIcon && {emojiIcon}} - {entry.title} + {filenameStem}
) } diff --git a/src/components/Editor.css b/src/components/Editor.css index 918c4bfd..7752e250 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -361,12 +361,13 @@ margin-top: 2px; } -/* Hide the first H1 heading in BlockNote — title is shown in TitleField above */ -.editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] { +/* When TitleField is shown (no H1 in body), hide the first H1 heading in + BlockNote to avoid duplicate title display. When hasH1 is set, the + TitleField is hidden and the H1 in the editor serves as the title. */ +.title-section:not([data-has-h1]) ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] { display: none; } -/* Also hide the BlockContainer wrapper if its heading is hidden */ -.editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) { +.title-section:not([data-has-h1]) ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) { display: none; } diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index f79df3f0..48eac86b 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -63,8 +63,6 @@ const mockEntry: VaultEntry = { belongsTo: [], relatedTo: [], status: 'Active', - owner: 'Luca', - cadence: null, archived: false, modifiedAt: 1700000000, createdAt: null, @@ -74,9 +72,18 @@ const mockEntry: VaultEntry = { relationships: {}, icon: null, color: null, - order: null, + order: null, template: null, sort: null, outgoingLinks: [], + sidebarLabel: null, + view: null, + visible: null, + properties: {}, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + hasH1: false, } const mockContent = `--- diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index 2a3caa13..51beb8da 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -166,6 +166,7 @@ export function EditorContent({ const { cssVars } = useEditorTheme() const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false + const hasH1 = freshEntry?.hasH1 ?? activeTab?.entry.hasH1 ?? false // Non-markdown text files always use the raw editor (no BlockNote) const isNonMarkdownText = activeTab?.entry.fileKind === 'text' const effectiveRawMode = rawMode || isNonMarkdownText @@ -232,26 +233,30 @@ export function EditorContent({ {showEditor && (
-
- {!emojiIcon && ( -
- -
+
+ {!hasH1 && ( + <> + {!emojiIcon && ( +
+ +
+ )} +
+ {emojiIcon && ( + + )} + onTitleChange?.(path, newTitle)} + /> +
+
+ )} -
- {emojiIcon && ( - - )} - onTitleChange?.(path, newTitle)} - /> -
-
diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 00818dd9..414d564d 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -57,7 +57,10 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ color: null, order: null, outgoingLinks: [], - template: null, sort: null, + template: null, sort: null, sidebarLabel: null, + view: null, visible: null, properties: {}, organized: false, + favorite: false, favoriteIndex: null, listPropertiesDisplay: [], + hasH1: false, ...overrides, }) @@ -212,11 +215,16 @@ describe('entryMatchesTarget', () => { }) describe('buildNoteContent', () => { - it('generates frontmatter with status for regular types', () => { + it('generates frontmatter with title and status for regular types', () => { const content = buildNoteContent('My Note', 'Note', 'Active') expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n') }) + it('omits title when null', () => { + const content = buildNoteContent(null, 'Note', 'Active') + expect(content).toBe('---\ntype: Note\nstatus: Active\n---\n') + }) + it('omits status when null', () => { const content = buildNoteContent('AI', 'Topic', null) expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n') @@ -701,7 +709,8 @@ describe('useNoteActions hook', () => { expect(setToastMessage).toHaveBeenCalledWith('Property deleted') }) - it('handleCreateNoteImmediate creates note with auto-generated title', () => { + it('handleCreateNoteImmediate creates note with timestamp-based title', () => { + vi.spyOn(Date, 'now').mockReturnValue(1700000000000) const { result } = renderHook(() => useNoteActions(makeConfig())) act(() => { @@ -710,11 +719,15 @@ describe('useNoteActions hook', () => { expect(addEntry).toHaveBeenCalledTimes(1) const [createdEntry] = addEntry.mock.calls[0] - expect(createdEntry.title).toBe('Untitled note') + expect(createdEntry.title).toBe('Untitled Note 1700000000') + expect(createdEntry.filename).toBe('untitled-note-1700000000.md') expect(createdEntry.isA).toBe('Note') + vi.restoreAllMocks() }) - it('handleCreateNoteImmediate generates unique names on rapid calls', () => { + it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => { + let ts = 1700000000000 + vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts }) const { result } = renderHook(() => useNoteActions(makeConfig())) act(() => { @@ -724,13 +737,17 @@ describe('useNoteActions hook', () => { }) expect(addEntry).toHaveBeenCalledTimes(3) - const titles = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.title) - expect(titles[0]).toBe('Untitled note') - expect(titles[1]).toBe('Untitled note 2') - expect(titles[2]).toBe('Untitled note 3') + const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename) + // Each call consumes Date.now() multiple times, so just verify uniqueness and pattern + expect(new Set(filenames).size).toBe(3) + for (const fn of filenames) { + expect(fn).toMatch(/^untitled-note-\d+\.md$/) + } + vi.restoreAllMocks() }) it('handleCreateNoteImmediate accepts custom type', () => { + vi.spyOn(Date, 'now').mockReturnValue(1700000000000) const { result } = renderHook(() => useNoteActions(makeConfig())) act(() => { @@ -739,8 +756,9 @@ describe('useNoteActions hook', () => { expect(addEntry).toHaveBeenCalledTimes(1) const [createdEntry] = addEntry.mock.calls[0] - expect(createdEntry.title).toBe('Untitled project') + expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/) expect(createdEntry.isA).toBe('Project') + vi.restoreAllMocks() }) it('handleCreateNote uses default template for Project type', () => { @@ -918,8 +936,8 @@ describe('useNoteActions hook', () => { await new Promise((r) => setTimeout(r, 0)) }) - expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md')) - expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'), expect.stringContaining('Untitled note')) + expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/)) + expect(markContentPending).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/), expect.stringContaining('type: Note')) }) it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => { diff --git a/src/hooks/useNoteCreation.test.ts b/src/hooks/useNoteCreation.test.ts index d01e540b..6e8b6140 100644 --- a/src/hooks/useNoteCreation.test.ts +++ b/src/hooks/useNoteCreation.test.ts @@ -36,7 +36,8 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], template: null, sort: null, sidebarLabel: null, - view: null, visible: null, properties: {}, + view: null, visible: null, properties: {}, organized: false, favorite: false, + favoriteIndex: null, listPropertiesDisplay: [], hasH1: false, ...overrides, }) @@ -114,10 +115,14 @@ describe('entryMatchesTarget', () => { }) describe('buildNoteContent', () => { - it('generates frontmatter with status', () => { + it('generates frontmatter with title and status', () => { expect(buildNoteContent('My Note', 'Note', 'Active')).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n') }) + it('omits title when null', () => { + expect(buildNoteContent(null, 'Note', 'Active')).toBe('---\ntype: Note\nstatus: Active\n---\n') + }) + it('omits status when null', () => { expect(buildNoteContent('AI', 'Topic', null)).toBe('---\ntitle: AI\ntype: Topic\n---\n') }) @@ -233,22 +238,32 @@ describe('useNoteCreation hook', () => { expect(createdEntry.isA).toBe('Note') }) - it('handleCreateNoteImmediate generates untitled name', () => { + it('handleCreateNoteImmediate generates timestamp-based title', () => { + vi.spyOn(Date, 'now').mockReturnValue(1700000000000) const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps)) act(() => { result.current.handleCreateNoteImmediate() }) expect(addEntry).toHaveBeenCalledTimes(1) - expect(addEntry.mock.calls[0][0].title).toBe('Untitled note') + expect(addEntry.mock.calls[0][0].title).toBe('Untitled Note 1700000000') + expect(addEntry.mock.calls[0][0].filename).toBe('untitled-note-1700000000.md') + vi.restoreAllMocks() }) - it('handleCreateNoteImmediate generates unique names on rapid calls', () => { + it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => { + let ts = 1700000000000 + vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts }) const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps)) act(() => { result.current.handleCreateNoteImmediate() result.current.handleCreateNoteImmediate() result.current.handleCreateNoteImmediate() }) - const titles = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.title) - expect(titles).toEqual(['Untitled note', 'Untitled note 2', 'Untitled note 3']) + const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename) + // Each call consumes Date.now() multiple times (filename + buildNewEntry), so just verify uniqueness + expect(new Set(filenames).size).toBe(3) + for (const fn of filenames) { + expect(fn).toMatch(/^untitled-note-\d+\.md$/) + } + vi.restoreAllMocks() }) it('handleCreateNoteImmediate accepts custom type', () => { @@ -265,7 +280,7 @@ describe('useNoteCreation hook', () => { config.markContentPending = markContentPending const { result } = renderHook(() => useNoteCreation(config, tabDeps)) await act(async () => { result.current.handleCreateNoteImmediate() }) - expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md')) + expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/)) expect(markContentPending).toHaveBeenCalled() }) diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index ea6d1690..0eac72cf 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -20,7 +20,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam aliases: [], belongsTo: [], relatedTo: [], status, archived: false, modifiedAt: now, createdAt: now, fileSize: 0, - snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [], + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [], hasH1: false, } } @@ -29,6 +29,11 @@ export function slugify(text: string): string { return result || 'untitled' } +/** Convert a filename slug to a human-readable title (hyphens → spaces, title case). */ +function slug_to_title(slug: string): string { + return slug.split('-').filter(Boolean).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') +} + /** Generate a unique "Untitled " name by checking existing entries and pending names. */ export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set): string { const baseName = `Untitled ${type.toLowerCase()}` @@ -63,8 +68,10 @@ export function resolveTemplate(entries: VaultEntry[], typeName: string): string return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null } -export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string { - const lines = ['---', `title: ${title}`, `type: ${type}`] +export function buildNoteContent(title: string | null, type: string, status: string | null, template?: string | null): string { + const lines = ['---'] + if (title) lines.push(`title: ${title}`) + lines.push(`type: ${type}`) if (status) lines.push(`status: ${status}`) lines.push('---') const body = template ? `\n${template}` : '' @@ -168,19 +175,27 @@ interface ImmediateCreateDeps { markContentPending?: (path: string, content: string) => void } +/** Generate a unique untitled filename using a timestamp. */ +function generateUntitledFilename(type: string): string { + const ts = Math.floor(Date.now() / 1000) + const prefix = type === 'Note' ? 'untitled-note' : `untitled-${type.toLowerCase()}` + return `${prefix}-${ts}` +} + /** Create an untitled note without persisting to disk (deferred save). */ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void { const noteType = type || 'Note' - const title = generateUntitledName(deps.entries, noteType, deps.pendingNames) - deps.pendingNames.add(title) + const slug = generateUntitledFilename(noteType) + const title = slug_to_title(slug) const template = resolveTemplate(deps.entries, noteType) - const resolved = resolveNewNote(title, noteType, deps.vaultPath, template) - deps.openTabWithContent(resolved.entry, resolved.content) - addEntryWithMock(resolved.entry, resolved.content, deps.addEntry) - deps.trackUnsaved?.(resolved.entry.path) - deps.markContentPending?.(resolved.entry.path, resolved.content) - signalFocusEditor({ selectTitle: true }) - setTimeout(() => deps.pendingNames.delete(title), 500) + const status = NO_STATUS_TYPES.has(noteType) ? null : 'Active' + const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status }) + const content = buildNoteContent(null, noteType, status, template) + deps.openTabWithContent(entry, content) + addEntryWithMock(entry, content, deps.addEntry) + deps.trackUnsaved?.(entry.path) + deps.markContentPending?.(entry.path, content) + signalFocusEditor() } interface RelationshipCreateDeps { diff --git a/src/types.ts b/src/types.ts index ecf72ac8..1394d183 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,8 @@ export interface VaultEntry { outgoingLinks: string[] /** Custom scalar frontmatter properties (non-relationship, non-structural). */ properties: Record + /** Whether the note body has an H1 heading on the first non-empty line. */ + hasH1: boolean /** File kind: "markdown", "text", or "binary". Determines editor behavior. * Defaults to "markdown" when absent (for backwards compatibility). */ fileKind?: 'markdown' | 'text' | 'binary'