diff --git a/documentation/PLUGIN_NOTE_STATISTICS.md b/documentation/PLUGIN_NOTE_STATISTICS.md index 1dd8c67..0ed68ad 100644 --- a/documentation/PLUGIN_NOTE_STATISTICS.md +++ b/documentation/PLUGIN_NOTE_STATISTICS.md @@ -103,19 +103,25 @@ When a note is saved, the plugin: 3. Click to expand/collapse the stats panel 4. Statistics update in real-time as you type -### In Docker Logs +### In Server Logs ```bash -docker-compose logs -f | grep "📊" +# Docker (any host OS) +docker-compose logs -f | grep "note_stats" + +# Running locally (Linux / macOS) +python run.py 2>&1 | grep "note_stats" + +# Running locally (Windows / PowerShell) +python run.py 2>&1 | findstr "note_stats" ``` -Example output: +Example output (single line, prefixed with uvicorn's `INFO:`): ``` -📊 projects/website.md: - 1,234 words | 6m read | 89 lines - 15 links (5 internal) - 8/12 tasks completed +INFO: note_stats projects/website.md | 1,234 words | 6 sentences | ~6m read | 89 lines | 15 links (5 internal) | 8/12 tasks ``` +Optional sections (`lists`, `tables`, `links`, `tasks`) only appear when their count is non-zero. + --- ## Configuration @@ -124,11 +130,11 @@ No configuration needed. The plugin works out of the box. ### Customization (Optional) -To change reading speed calculation, edit `note_stats.py`: +To change the reading-speed assumption used for `reading_time_minutes`, +edit the constant near the top of `note_stats.py`: ```python -# Line 36 -words_per_minute = 200 # Change to your reading speed +WORDS_PER_MINUTE = 200 # Change to your average reading speed ``` --- diff --git a/plugins/note_stats.py b/plugins/note_stats.py index 45423f6..f72882a 100644 --- a/plugins/note_stats.py +++ b/plugins/note_stats.py @@ -1,94 +1,61 @@ """ Note Statistics Plugin for NoteDiscovery -Calculates and logs statistics about your notes -Shows: -- Word count -- Character count -- Reading time estimate -- Number of links -- Number of code blocks -- Line count +Computes per-note metrics (words, sentences, reading time, links, tasks, …) +returned via /api/plugins/note_stats/calculate and consumed by the frontend +stats panel. On save we also emit a one-line INFO summary for quick +visibility in the server logs. """ +import logging import re +logger = logging.getLogger("uvicorn.error") + +# Average reading speed used to derive `reading_time_minutes`. +WORDS_PER_MINUTE = 200 + class Plugin: def __init__(self): self.name = "Note Statistics" self.version = "1.0.0" self.enabled = True - self.stats_history = {} - - def calculate_stats(self, content: str) -> dict: - """Calculate comprehensive note statistics""" - # Word count (split by whitespace and filter empty) - words = len([w for w in re.findall(r'\S+', content) if w]) - - # Character count (excluding whitespace) - chars = len(re.sub(r'\s', '', content)) - - # Total character count (including whitespace) - total_chars = len(content) - - # Reading time (average 200 words per minute) - reading_time = max(1, round(words / 200)) - - # Line count - lines = len(content.split('\n')) - - # Paragraph count (blocks separated by blank lines) - paragraphs = len([p for p in content.split('\n\n') if p.strip()]) - # Sentence count: punctuation [.!?]+ followed by space or end-of-string + def calculate_stats(self, content: str) -> dict: + """Compute the full metric set returned to the frontend / API.""" + words = len(re.findall(r'\S+', content)) + chars = len(re.sub(r'\s', '', content)) + total_chars = len(content) + reading_time = max(1, round(words / WORDS_PER_MINUTE)) + lines = len(content.split('\n')) + paragraphs = len([p for p in content.split('\n\n') if p.strip()]) sentences = len(re.findall(r'[.!?]+(?:\s|$)', content)) - # List items: lines starting with -, *, + or a number (e.g. 1., 10.), excluding tasks [-] - list_items = len( - re.findall(r'^\s*(?:[-*+]|\d+\.)\s+(?!\[)', content, re.MULTILINE) - ) + # Bullet/numbered list items, excluding task checkboxes like "- [ ]". + list_items = len(re.findall(r'^\s*(?:[-*+]|\d+\.)\s+(?!\[)', content, re.MULTILINE)) + # Markdown table separator rows: | --- | :--: | + tables = len(re.findall(r'^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$', content, re.MULTILINE)) - # Tables: count markdown table separator rows (| --- | --- |) - tables = len( - re.findall(r'^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$', content, re.MULTILINE) - ) - - # Markdown link count (standard [text](url) format) markdown_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content)) - - # Internal link count (standard markdown links to .md files) markdown_internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content)) - - # Wikilink count ([[note]] or [[note|display text]] format - Obsidian style) wikilinks = len(re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content)) - - # Total links and internal links links = markdown_links + wikilinks - internal_links = markdown_internal_links + wikilinks # All wikilinks are internal - - # Code block count + internal_links = markdown_internal_links + wikilinks # wikilinks are always internal + code_blocks = len(re.findall(r'```[\s\S]*?```', content)) - - # Inline code count inline_code = len(re.findall(r'`[^`]+`', content)) - - # Heading count by level + h1_count = len(re.findall(r'^# ', content, re.MULTILINE)) h2_count = len(re.findall(r'^## ', content, re.MULTILINE)) h3_count = len(re.findall(r'^### ', content, re.MULTILINE)) - - # Task count (checkboxes) + total_tasks = len(re.findall(r'- \[[ x]\]', content)) completed_tasks = len(re.findall(r'- \[x\]', content, re.IGNORECASE)) - pending_tasks = total_tasks - completed_tasks - - # Image count + images = len(re.findall(r'!\[([^\]]*)\]\(([^\)]+)\)', content)) - - # Blockquote count blockquotes = len(re.findall(r'^> ', content, re.MULTILINE)) - + return { 'words': words, 'sentences': sentences, @@ -109,87 +76,34 @@ class Plugin: 'h1': h1_count, 'h2': h2_count, 'h3': h3_count, - 'total': h1_count + h2_count + h3_count + 'total': h1_count + h2_count + h3_count, }, 'tasks': { 'total': total_tasks, 'completed': completed_tasks, - 'pending': pending_tasks, - 'completion_rate': round(completed_tasks / total_tasks * 100) if total_tasks > 0 else 0 + 'pending': total_tasks - completed_tasks, + 'completion_rate': round(completed_tasks / total_tasks * 100) if total_tasks else 0, }, 'images': images, - 'blockquotes': blockquotes - } - - def format_stats(self, stats: dict) -> str: - """Format statistics as a readable string""" - lines = [ - f"📊 Statistics:", - f" Words: {stats['words']:,}", - f" Reading time: ~{stats['reading_time_minutes']} min", - f" Lines: {stats['lines']:,}", - ] - - if stats['links'] > 0: - lines.append(f" Links: {stats['links']} ({stats['internal_links']} internal, {stats['external_links']} external)") - - if stats['code_blocks'] > 0: - lines.append(f" Code blocks: {stats['code_blocks']}") - - if stats['tasks']['total'] > 0: - lines.append(f" Tasks: {stats['tasks']['completed']}/{stats['tasks']['total']} completed ({stats['tasks']['completion_rate']}%)") - - if stats['headings']['total'] > 0: - lines.append(f" Headings: {stats['headings']['total']} (H1: {stats['headings']['h1']}, H2: {stats['headings']['h2']}, H3: {stats['headings']['h3']})") - - return '\n'.join(lines) - - def on_note_save(self, note_path: str, content: str) -> str | None: - """Calculate and log statistics when note is saved""" - stats = self.calculate_stats(content) - - # Store stats history - self.stats_history[note_path] = stats - - # Log key statistics - print(f"📊 {note_path}:") - print( - f" {stats['words']:,} words | " - f"{stats['sentences']:,} sentences | " - f"{stats['reading_time_minutes']}m read | " - f"{stats['lines']:,} lines | " - f"{stats['list_items']:,} lists | " - f"{stats['tables']:,} tables" - ) - - if stats['links'] > 0: - print(f" {stats['links']} links ({stats['internal_links']} internal)") - - if stats['tasks']['total'] > 0: - print(f" {stats['tasks']['completed']}/{stats['tasks']['total']} tasks completed") - - return None # Don't modify content, just observe - - def get_stats(self, note_path: str) -> dict: - """Get cached statistics for a note""" - return self.stats_history.get(note_path, {}) - - def get_total_stats(self) -> dict: - """Get aggregated statistics across all notes""" - if not self.stats_history: - return {} - - total_words = sum(s['words'] for s in self.stats_history.values()) - total_notes = len(self.stats_history) - total_links = sum(s['links'] for s in self.stats_history.values()) - total_tasks = sum(s['tasks']['total'] for s in self.stats_history.values()) - - return { - 'total_notes': total_notes, - 'total_words': total_words, - 'average_words_per_note': round(total_words / total_notes) if total_notes > 0 else 0, - 'total_links': total_links, - 'total_tasks': total_tasks, - 'total_reading_time': sum(s['reading_time_minutes'] for s in self.stats_history.values()) + 'blockquotes': blockquotes, } + def on_note_save(self, note_path: str, content: str) -> str | None: + """Emit a one-line summary on save. Doesn't modify content.""" + s = self.calculate_stats(content) + parts = [ + f"{s['words']:,} words", + f"{s['sentences']:,} sentences", + f"~{s['reading_time_minutes']}m read", + f"{s['lines']:,} lines", + ] + if s['list_items']: + parts.append(f"{s['list_items']:,} lists") + if s['tables']: + parts.append(f"{s['tables']:,} tables") + if s['links']: + parts.append(f"{s['links']} links ({s['internal_links']} internal)") + if s['tasks']['total']: + parts.append(f"{s['tasks']['completed']}/{s['tasks']['total']} tasks") + logger.info("note_stats %s | %s", note_path, " | ".join(parts)) + return None