feat: note subtitle — show metadata (date + word count) (#94)

* feat: show metadata subtitle (date + word count) instead of snippet

Replace the note list subtitle with a compact metadata summary showing
relative date and word count (e.g. "2d ago · 342 words") instead of
the first paragraph of note content. Adds word_count to VaultEntry on
the Rust backend, computed during vault scan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: cargo fmt formatting

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-26 20:50:29 +01:00
committed by GitHub
parent 1c2f0ee193
commit 4664f3360e
30 changed files with 279 additions and 21 deletions

View File

@@ -12,8 +12,8 @@ pub use rename::{rename_note, RenameResult};
pub use trash::purge_trash;
use parsing::{
capitalize_first, contains_wikilink, extract_outgoing_links, extract_snippet, extract_title,
parse_iso_date,
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
extract_title, parse_iso_date,
};
use gray_matter::engine::YAML;
@@ -60,6 +60,9 @@ pub struct VaultEntry {
pub color: Option<String>,
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
pub order: Option<i64>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
/// All wikilink targets found in the note body (excludes frontmatter).
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
#[serde(rename = "outgoingLinks", default)]
@@ -268,6 +271,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let title = extract_title(&parsed.content, &filename);
let snippet = extract_snippet(&content);
let word_count = count_body_words(&content);
let outgoing_links = extract_outgoing_links(&parsed.content);
let (modified_at, file_size) = read_file_metadata(path)?;
let created_at = parse_created_at(&frontmatter);
@@ -318,6 +322,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
icon: frontmatter.icon,
color: frontmatter.color,
order: frontmatter.order,
word_count,
outgoing_links,
})
}
@@ -579,6 +584,27 @@ mod tests {
assert_eq!(entry.snippet, "Hello, world! This is a snippet.");
}
#[test]
fn test_parse_md_file_has_word_count() {
let dir = TempDir::new().unwrap();
let content =
"---\nIs A: Note\n---\n# Test Note\n\nHello world. This is a test with seven words.";
create_test_file(dir.path(), "test.md", content);
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
assert_eq!(entry.word_count, 9);
}
#[test]
fn test_parse_md_file_word_count_empty_body() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Note\n---\n# Empty Note\n";
create_test_file(dir.path(), "test.md", content);
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
assert_eq!(entry.word_count, 0);
}
#[test]
fn test_parse_relationships_array() {
let dir = TempDir::new().unwrap();

View File

@@ -46,6 +46,18 @@ fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
format!("{}...", &s[..idx])
}
/// Count the number of words in the note body (excluding frontmatter and H1 title).
pub(super) fn count_body_words(content: &str) -> u32 {
let without_fm = strip_frontmatter(content);
let body = without_h1_line(without_fm).unwrap_or(without_fm);
body.split_whitespace()
.filter(|w| {
!w.chars()
.all(|c| matches!(c, '#' | '*' | '_' | '`' | '~' | '-' | '>' | '|'))
})
.count() as u32
}
/// Extract a snippet: first ~160 chars of content after frontmatter/title, stripped of markdown.
pub(super) fn extract_snippet(content: &str) -> String {
let without_fm = strip_frontmatter(content);
@@ -291,6 +303,46 @@ mod tests {
assert_eq!(snippet, "Content after rule.");
}
// --- count_body_words tests ---
#[test]
fn test_count_body_words_basic() {
let content = "---\nIs A: Note\n---\n# My Note\n\nHello world, this is a test.";
assert_eq!(count_body_words(content), 6);
}
#[test]
fn test_count_body_words_no_frontmatter() {
let content = "# Title\n\nOne two three four five.";
assert_eq!(count_body_words(content), 5);
}
#[test]
fn test_count_body_words_empty_body() {
let content = "---\nIs A: Note\n---\n# Just a Title\n";
assert_eq!(count_body_words(content), 0);
}
#[test]
fn test_count_body_words_no_content() {
assert_eq!(count_body_words(""), 0);
}
#[test]
fn test_count_body_words_excludes_markdown_markers() {
let content = "# Title\n\n## Section\n\nReal words here. ---\n\n> quote text";
// "Real", "words", "here.", "quote", "text" = 5 real words
// "##", "Section", "---", ">" are markdown markers (## is a heading, --- is a rule, > is blockquote)
// "Section" passes the filter (not all markdown chars), so count includes it
assert_eq!(count_body_words(content), 6);
}
#[test]
fn test_count_body_words_plain_text_only() {
let content = "Just plain text without any heading.";
assert_eq!(count_body_words(content), 6);
}
// --- strip_markdown_chars tests ---
#[test]