feat: H1-as-title — title resolution, TitleField hiding, breadcrumb filename
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 358
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
|
||||
4
demo-vault-v2/responsive-rename-test.md
Normal file
4
demo-vault-v2/responsive-rename-test.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/test-note-abc.md
Normal file
4
demo-vault-v2/test-note-abc.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775473305.md
Normal file
4
demo-vault-v2/untitled-note-1775473305.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-1775473680.md
Normal file
5
demo-vault-v2/untitled-note-1775473680.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
5
demo-vault-v2/untitled-note-361.md
Normal file
5
demo-vault-v2/untitled-note-361.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 361
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
@@ -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<String>,
|
||||
/// 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")]
|
||||
|
||||
@@ -57,6 +57,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
|
||||
|
||||
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
|
||||
let has_h1 = parsing::extract_h1_title(&content).is_some();
|
||||
let snippet = extract_snippet(&content);
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
@@ -116,6 +117,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
has_h1,
|
||||
file_kind: "markdown".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,15 +21,40 @@ pub(super) fn slug_to_title(stem: &str) -> 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<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
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(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
|
||||
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', () => {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">{typeLabel}</span>
|
||||
<span className="shrink-0 text-border">›</span>
|
||||
{emojiIcon && <span className="shrink-0">{emojiIcon}</span>}
|
||||
<span className="truncate font-medium text-foreground">{entry.title}</span>
|
||||
<span className="truncate font-medium text-foreground">{filenameStem}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `---
|
||||
|
||||
@@ -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 && (
|
||||
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
|
||||
<div className="editor-content-wrapper">
|
||||
<div ref={titleSectionRef} className="title-section">
|
||||
{!emojiIcon && (
|
||||
<div className="title-section__add-icon">
|
||||
<NoteIcon icon={null} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
|
||||
</div>
|
||||
<div ref={titleSectionRef} className="title-section" data-has-h1={hasH1 || undefined}>
|
||||
{!hasH1 && (
|
||||
<>
|
||||
{!emojiIcon && (
|
||||
<div className="title-section__add-icon">
|
||||
<NoteIcon icon={null} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
|
||||
{emojiIcon && (
|
||||
<NoteIcon icon={emojiIcon} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
|
||||
)}
|
||||
<TitleField
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable
|
||||
notePath={path}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
|
||||
/>
|
||||
</div>
|
||||
<div className="title-section__separator" />
|
||||
</>
|
||||
)}
|
||||
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
|
||||
{emojiIcon && (
|
||||
<NoteIcon icon={emojiIcon} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
|
||||
)}
|
||||
<TitleField
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable
|
||||
notePath={path}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
|
||||
/>
|
||||
</div>
|
||||
<div className="title-section__separator" />
|
||||
</div>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable />
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,10 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): 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 () => {
|
||||
|
||||
@@ -36,7 +36,8 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): 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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 <type>" name by checking existing entries and pending names. */
|
||||
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): 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 {
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface VaultEntry {
|
||||
outgoingLinks: string[]
|
||||
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
|
||||
properties: Record<string, string | number | boolean | null>
|
||||
/** 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'
|
||||
|
||||
Reference in New Issue
Block a user