fix: update note list snippet on save so all notes show preview
The snippet was extracted once at vault load time (Rust backend) and never updated when content was saved. Notes created or edited during a session showed stale/empty snippets until the next app restart. Added extractSnippet() to the frontend (mirroring Rust logic) and wired it into useEditorSaveWithLinks so snippet + wordCount are updated alongside outgoingLinks on every save. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks } from '../utils/wikilinks'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function useEditorSaveWithLinks(config: {
|
||||
@@ -12,7 +12,11 @@ export function useEditorSaveWithLinks(config: {
|
||||
}) {
|
||||
const { updateEntry } = config
|
||||
const saveContent = useCallback((path: string, content: string) => {
|
||||
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
|
||||
updateEntry(path, {
|
||||
outgoingLinks: extractOutgoingLinks(content),
|
||||
snippet: extractSnippet(content),
|
||||
wordCount: countWords(content),
|
||||
})
|
||||
}, [updateEntry])
|
||||
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext } from './wikilinks'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext, extractSnippet } from './wikilinks'
|
||||
|
||||
interface TestBlock {
|
||||
type?: string
|
||||
@@ -485,3 +485,97 @@ describe('extractBacklinkContext', () => {
|
||||
expect(result).toBe('Short [[My Note]].')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSnippet', () => {
|
||||
it('extracts first paragraph after frontmatter and title', () => {
|
||||
const content = '---\ntype: Note\n---\n\n# My Note\n\nThis is the first paragraph of content.\n\n## Section Two\n\nMore content here.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('This is the first paragraph')
|
||||
expect(snippet).toContain('More content here')
|
||||
})
|
||||
|
||||
it('strips markdown formatting (bold, italic, code)', () => {
|
||||
const content = '# Title\n\nSome **bold** and *italic* and `code` text.'
|
||||
expect(extractSnippet(content)).toBe('Some bold and italic and code text.')
|
||||
})
|
||||
|
||||
it('strips markdown links, keeps display text', () => {
|
||||
const content = '# Title\n\nSee [this link](https://example.com) and [[wiki link]].'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('this link')
|
||||
expect(snippet).not.toContain('https://example.com')
|
||||
expect(snippet).toContain('wiki link')
|
||||
expect(snippet).not.toContain('[[')
|
||||
})
|
||||
|
||||
it('uses display text from aliased wikilinks', () => {
|
||||
const content = '# Title\n\nDiscussed in [[meetings/standup|standup]] today.'
|
||||
expect(extractSnippet(content)).toBe('Discussed in standup today.')
|
||||
})
|
||||
|
||||
it('truncates long content to ~160 chars with ellipsis', () => {
|
||||
const content = `# Title\n\n${'word '.repeat(100)}`
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet.length).toBeLessThanOrEqual(165)
|
||||
expect(snippet).toMatch(/\.\.\.$/)
|
||||
})
|
||||
|
||||
it('returns empty string for note with only title', () => {
|
||||
const content = '---\ntype: Note\n---\n\n# Just a Title\n'
|
||||
expect(extractSnippet(content)).toBe('')
|
||||
})
|
||||
|
||||
it('skips code fence delimiters', () => {
|
||||
const content = '# Title\n\n```rust\nfn main() {}\n```\n\nReal content here.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).not.toContain('```')
|
||||
expect(snippet).toContain('Real content here')
|
||||
})
|
||||
|
||||
it('returns empty for content with only headings', () => {
|
||||
const content = '# Title\n\n## Section One\n\n### Sub Section\n'
|
||||
expect(extractSnippet(content)).toBe('')
|
||||
})
|
||||
|
||||
it('handles content without frontmatter or title', () => {
|
||||
const content = 'Just plain text content without any heading.'
|
||||
expect(extractSnippet(content)).toBe('Just plain text content without any heading.')
|
||||
})
|
||||
|
||||
it('skips horizontal rules', () => {
|
||||
const content = '# Title\n\n---\n\nContent after rule.'
|
||||
expect(extractSnippet(content)).toBe('Content after rule.')
|
||||
})
|
||||
|
||||
it('handles strikethrough', () => {
|
||||
const content = '# Title\n\n~~deleted~~ text remains.'
|
||||
expect(extractSnippet(content)).toBe('deleted text remains.')
|
||||
})
|
||||
|
||||
it('extracts snippet from project-template note with body text', () => {
|
||||
const content = [
|
||||
'---', 'type: Project', 'status: Active', '---', '',
|
||||
'# Ship MVP of Tolaria', '',
|
||||
'## Objective', '',
|
||||
'Ship the minimum viable product for Tolaria marketplace.', '',
|
||||
'## Key Results', '',
|
||||
'- 100 beta users signed up',
|
||||
].join('\n')
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('Ship the minimum viable product')
|
||||
})
|
||||
|
||||
it('includes list items in snippet', () => {
|
||||
const content = '# Title\n\n- First item\n- Second item\n- Third item'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('First item')
|
||||
expect(snippet).toContain('Second item')
|
||||
})
|
||||
|
||||
it('includes code content lines (not fences) in snippet', () => {
|
||||
const content = '# Title\n\n```\nfn main() {}\n```\n\nSome text.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('fn main()')
|
||||
expect(snippet).toContain('Some text.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,6 +154,62 @@ export function extractBacklinkContext(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if a line is useful for snippet extraction (not blank, heading, code fence, or rule). */
|
||||
function isSnippetLine(line: string): boolean {
|
||||
const t = line.trim()
|
||||
return t !== '' && !t.startsWith('#') && !t.startsWith('```') && !t.startsWith('---')
|
||||
}
|
||||
|
||||
/** Remove the first H1 heading line, allowing leading blank lines. */
|
||||
function removeH1Line(body: string): string {
|
||||
const lines = body.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith('# ')) return lines.slice(i + 1).join('\n')
|
||||
if (lines[i].trim() !== '') return body
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** Strip markdown formatting chars: bold, italic, code, strikethrough, and resolve links. */
|
||||
function stripMarkdownChars(s: string): string {
|
||||
let result = ''
|
||||
let i = 0
|
||||
while (i < s.length) {
|
||||
if (s[i] === '[' && s[i + 1] === '[') {
|
||||
i += 2
|
||||
let inner = ''
|
||||
while (i < s.length - 1 && !(s[i] === ']' && s[i + 1] === ']')) { inner += s[i]; i++ }
|
||||
if (i < s.length - 1) i += 2
|
||||
const pipe = inner.indexOf('|')
|
||||
result += pipe !== -1 ? inner.slice(pipe + 1) : inner
|
||||
} else if (s[i] === '[') {
|
||||
i++
|
||||
let text = ''
|
||||
while (i < s.length && s[i] !== ']') { text += s[i]; i++ }
|
||||
if (i < s.length) i++
|
||||
if (i < s.length && s[i] === '(') { i++; while (i < s.length && s[i] !== ')') i++; if (i < s.length) i++ }
|
||||
result += text
|
||||
} else if (s[i] === '*' || s[i] === '_' || s[i] === '`' || s[i] === '~') {
|
||||
i++
|
||||
} else {
|
||||
result += s[i]
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Extract a snippet: first ~160 chars of body content, stripped of markdown.
|
||||
* Mirrors the Rust extract_snippet() logic for frontend use. */
|
||||
export function extractSnippet(content: string): string {
|
||||
const [, body] = splitFrontmatter(content)
|
||||
const withoutH1 = removeH1Line(body)
|
||||
const clean = withoutH1.split('\n').filter(isSnippetLine).join(' ')
|
||||
const stripped = stripMarkdownChars(clean)
|
||||
if (stripped.length <= 160) return stripped
|
||||
return stripped.slice(0, 160) + '...'
|
||||
}
|
||||
|
||||
export function countWords(content: string): number {
|
||||
const [, body] = splitFrontmatter(content)
|
||||
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
|
||||
|
||||
Reference in New Issue
Block a user