From 7249a9eb3238e390e09a362013cd1d5cfb5ae478 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 11 Mar 2026 22:38:25 +0100 Subject: [PATCH] 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 --- src/hooks/useEditorSaveWithLinks.ts | 8 ++- src/utils/wikilinks.test.ts | 96 ++++++++++++++++++++++++++++- src/utils/wikilinks.ts | 56 +++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/src/hooks/useEditorSaveWithLinks.ts b/src/hooks/useEditorSaveWithLinks.ts index 29d70ab6..d4bb2dad 100644 --- a/src/hooks/useEditorSaveWithLinks.ts +++ b/src/hooks/useEditorSaveWithLinks.ts @@ -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 diff --git a/src/utils/wikilinks.test.ts b/src/utils/wikilinks.test.ts index b6b16d12..803d36d5 100644 --- a/src/utils/wikilinks.test.ts +++ b/src/utils/wikilinks.test.ts @@ -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.') + }) +}) diff --git a/src/utils/wikilinks.ts b/src/utils/wikilinks.ts index 95e882bb..fcbf4b86 100644 --- a/src/utils/wikilinks.ts +++ b/src/utils/wikilinks.ts @@ -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?/, '')