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