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) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-16 04:33:35 +01:00
parent 5cfc80dad5
commit 23b632ac9f
5 changed files with 91 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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::<Vec<&str>>()
.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]

View File

@@ -128,9 +128,11 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
<StateBadge archived={entry.archived} trashed={entry.trashed} />
</div>
</div>
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
{entry.snippet && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{entry.trashed && entry.trashedAt
? <TrashDateLine entry={entry} />
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>

View File

@@ -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', () => {

View File

@@ -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 {