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:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user