From 23b632ac9ff2e4eb06e71f9aba723a24225796dd Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 16 Mar 2026 04:33:35 +0100 Subject: [PATCH] fix: fall back to sub-heading text when snippet has no paragraph content Notes with only headings/rules after H1 (e.g. project templates, daily notes) previously showed empty snippets. Now extract_snippet collects sub-heading text as fallback. Also hides the snippet div when empty to avoid blank gaps in the note list. Bumps cache version to 8 for rescan. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/vault/cache.rs | 2 +- src-tauri/src/vault/parsing.rs | 53 +++++++++++++++++++++++++++++++--- src/components/NoteItem.tsx | 8 +++-- src/utils/wikilinks.test.ts | 14 +++++++-- src/utils/wikilinks.ts | 27 +++++++++++++++-- 5 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index 9204bf18..1f33b84b 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry}; // --- Vault Cache --- /// Bump this when VaultEntry fields change to force a full rescan. -const CACHE_VERSION: u32 = 7; +const CACHE_VERSION: u32 = 8; #[derive(Debug, Serialize, Deserialize)] struct VaultCache { diff --git a/src-tauri/src/vault/parsing.rs b/src-tauri/src/vault/parsing.rs index ad07b1b4..71271374 100644 --- a/src-tauri/src/vault/parsing.rs +++ b/src-tauri/src/vault/parsing.rs @@ -40,6 +40,19 @@ fn is_snippet_line(line: &str) -> bool { !t.is_empty() && !t.starts_with('#') && !t.starts_with("```") && !t.starts_with("---") } +/// Extract sub-heading text (## , ### , etc.) stripped of the `#` prefix. +fn extract_subheading_text(line: &str) -> Option<&str> { + let t = line.trim(); + let stripped = t.trim_start_matches('#'); + if stripped.len() < t.len() && stripped.starts_with(' ') { + let text = stripped.trim(); + if !text.is_empty() { + return Some(text); + } + } + None +} + /// Strip leading list markers (*, -, +, 1.) from a line. fn strip_list_marker(line: &str) -> &str { let t = line.trim_start(); @@ -94,10 +107,21 @@ pub(super) fn extract_snippet(content: &str) -> String { .join(" "); let stripped = strip_markdown_chars(&clean); let trimmed = stripped.trim(); - if trimmed.is_empty() { + if !trimmed.is_empty() { + return truncate_with_ellipsis(trimmed, 160); + } + // Fallback: collect sub-heading text when no paragraph content exists + let heading_text: String = body + .lines() + .filter_map(extract_subheading_text) + .collect::>() + .join(" "); + let heading_trimmed = strip_markdown_chars(&heading_text); + let heading_trimmed = heading_trimmed.trim(); + if heading_trimmed.is_empty() { return String::new(); } - truncate_with_ellipsis(trimmed, 160) + truncate_with_ellipsis(heading_trimmed, 160) } fn without_h1_line(s: &str) -> Option<&str> { @@ -329,10 +353,10 @@ mod tests { } #[test] - fn test_extract_snippet_only_headings() { + fn test_extract_snippet_only_headings_uses_fallback() { let content = "# Title\n\n## Section One\n\n### Sub Section\n"; let snippet = extract_snippet(content); - assert_eq!(snippet, ""); + assert_eq!(snippet, "Section One Sub Section"); } #[test] @@ -405,6 +429,27 @@ mod tests { assert_eq!(snippet, "First step Second step Third step"); } + #[test] + fn test_extract_snippet_only_subheadings_fallback() { + let content = "---\ntype: Project\n---\n# My Project\n\n## Description\n\n---\n\n## Key Results\n\n---\n"; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Description Key Results"); + } + + #[test] + fn test_extract_snippet_subheadings_with_emoji() { + let content = "# Daily\n\n## Intentions\n\n## Reflections\n"; + let snippet = extract_snippet(content); + assert_eq!(snippet, "Intentions Reflections"); + } + + #[test] + fn test_extract_snippet_paragraph_takes_priority_over_headings() { + let content = "# Title\n\n## Section One\n\nActual paragraph content.\n\n## Section Two\n"; + let snippet = extract_snippet(content); + assert!(snippet.starts_with("Actual paragraph content"), "paragraph content should be preferred over headings, got: {}", snippet); + } + // --- count_body_words tests --- #[test] diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index fd7859fc..abbca745 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -128,9 +128,11 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig -
- {entry.snippet} -
+ {entry.snippet && ( +
+ {entry.snippet} +
+ )} {entry.trashed && entry.trashedAt ? :
{relativeDate(getDisplayDate(entry))}
diff --git a/src/utils/wikilinks.test.ts b/src/utils/wikilinks.test.ts index df5422e1..8e887c00 100644 --- a/src/utils/wikilinks.test.ts +++ b/src/utils/wikilinks.test.ts @@ -532,9 +532,19 @@ describe('extractSnippet', () => { expect(snippet).toContain('Real content here') }) - it('returns empty for content with only headings', () => { + it('falls back to sub-heading text when no paragraph content', () => { const content = '# Title\n\n## Section One\n\n### Sub Section\n' - expect(extractSnippet(content)).toBe('') + expect(extractSnippet(content)).toBe('Section One Sub Section') + }) + + it('falls back to sub-headings for headings-and-rules-only notes', () => { + const content = '---\ntype: Project\n---\n# My Project\n\n## Description\n\n---\n\n## Key Results\n\n---\n' + expect(extractSnippet(content)).toBe('Description Key Results') + }) + + it('prefers paragraph content over sub-heading fallback', () => { + const content = '# Title\n\n## Section One\n\nActual paragraph content.\n\n## Section Two\n' + expect(extractSnippet(content)).toMatch(/^Actual paragraph content/) }) it('handles content without frontmatter or title', () => { diff --git a/src/utils/wikilinks.ts b/src/utils/wikilinks.ts index 37c2e3e6..366f93d2 100644 --- a/src/utils/wikilinks.ts +++ b/src/utils/wikilinks.ts @@ -212,6 +212,17 @@ function stripMarkdownChars(s: string): string { return result } +/** Extract sub-heading text (## , ### , etc.) stripped of the # prefix. */ +function extractSubheadingText(line: string): string | null { + const t = line.trim() + const stripped = t.replace(/^#+/, '') + if (stripped.length < t.length && stripped.startsWith(' ')) { + const text = stripped.trim() + return text || null + } + return null +} + /** 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 { @@ -219,9 +230,19 @@ export function extractSnippet(content: string): string { const withoutH1 = removeH1Line(body) const clean = withoutH1.split('\n').filter(isSnippetLine).map(stripListMarker).join(' ') const stripped = stripMarkdownChars(clean).trim() - if (!stripped) return '' - if (stripped.length <= 160) return stripped - return stripped.slice(0, 160) + '...' + if (stripped) { + if (stripped.length <= 160) return stripped + return stripped.slice(0, 160) + '...' + } + // Fallback: collect sub-heading text when no paragraph content exists + const headingText = withoutH1.split('\n') + .map(extractSubheadingText) + .filter((t): t is string => t !== null) + .join(' ') + const headingStripped = stripMarkdownChars(headingText).trim() + if (!headingStripped) return '' + if (headingStripped.length <= 160) return headingStripped + return headingStripped.slice(0, 160) + '...' } export function countWords(content: string): number {