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:
lucaronin
2026-03-11 22:38:25 +01:00
parent 33c4daaa8d
commit 7249a9eb32
3 changed files with 157 additions and 3 deletions

View File

@@ -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.')
})
})

View File

@@ -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?/, '')