note stats plugin logger fix
This commit is contained in:
parent
e341ef423e
commit
f42901b1d8
|
|
@ -103,19 +103,25 @@ When a note is saved, the plugin:
|
||||||
3. Click to expand/collapse the stats panel
|
3. Click to expand/collapse the stats panel
|
||||||
4. Statistics update in real-time as you type
|
4. Statistics update in real-time as you type
|
||||||
|
|
||||||
### In Docker Logs
|
### In Server Logs
|
||||||
```bash
|
```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:
|
INFO: note_stats projects/website.md | 1,234 words | 6 sentences | ~6m read | 89 lines | 15 links (5 internal) | 8/12 tasks
|
||||||
1,234 words | 6m read | 89 lines
|
|
||||||
15 links (5 internal)
|
|
||||||
8/12 tasks completed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional sections (`lists`, `tables`, `links`, `tasks`) only appear when their count is non-zero.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
@ -124,11 +130,11 @@ No configuration needed. The plugin works out of the box.
|
||||||
|
|
||||||
### Customization (Optional)
|
### 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
|
```python
|
||||||
# Line 36
|
WORDS_PER_MINUTE = 200 # Change to your average reading speed
|
||||||
words_per_minute = 200 # Change to your reading speed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,61 @@
|
||||||
"""
|
"""
|
||||||
Note Statistics Plugin for NoteDiscovery
|
Note Statistics Plugin for NoteDiscovery
|
||||||
Calculates and logs statistics about your notes
|
|
||||||
|
|
||||||
Shows:
|
Computes per-note metrics (words, sentences, reading time, links, tasks, …)
|
||||||
- Word count
|
returned via /api/plugins/note_stats/calculate and consumed by the frontend
|
||||||
- Character count
|
stats panel. On save we also emit a one-line INFO summary for quick
|
||||||
- Reading time estimate
|
visibility in the server logs.
|
||||||
- Number of links
|
|
||||||
- Number of code blocks
|
|
||||||
- Line count
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
# Average reading speed used to derive `reading_time_minutes`.
|
||||||
|
WORDS_PER_MINUTE = 200
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
class Plugin:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.name = "Note Statistics"
|
self.name = "Note Statistics"
|
||||||
self.version = "1.0.0"
|
self.version = "1.0.0"
|
||||||
self.enabled = True
|
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))
|
sentences = len(re.findall(r'[.!?]+(?:\s|$)', content))
|
||||||
|
|
||||||
# List items: lines starting with -, *, + or a number (e.g. 1., 10.), excluding tasks [-]
|
# Bullet/numbered list items, excluding task checkboxes like "- [ ]".
|
||||||
list_items = len(
|
list_items = len(re.findall(r'^\s*(?:[-*+]|\d+\.)\s+(?!\[)', content, re.MULTILINE))
|
||||||
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))
|
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))
|
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))
|
wikilinks = len(re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content))
|
||||||
|
|
||||||
# Total links and internal links
|
|
||||||
links = markdown_links + wikilinks
|
links = markdown_links + wikilinks
|
||||||
internal_links = markdown_internal_links + wikilinks # All wikilinks are internal
|
internal_links = markdown_internal_links + wikilinks # wikilinks are always internal
|
||||||
|
|
||||||
# Code block count
|
|
||||||
code_blocks = len(re.findall(r'```[\s\S]*?```', content))
|
code_blocks = len(re.findall(r'```[\s\S]*?```', content))
|
||||||
|
|
||||||
# Inline code count
|
|
||||||
inline_code = len(re.findall(r'`[^`]+`', content))
|
inline_code = len(re.findall(r'`[^`]+`', content))
|
||||||
|
|
||||||
# Heading count by level
|
|
||||||
h1_count = len(re.findall(r'^# ', content, re.MULTILINE))
|
h1_count = len(re.findall(r'^# ', content, re.MULTILINE))
|
||||||
h2_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))
|
h3_count = len(re.findall(r'^### ', content, re.MULTILINE))
|
||||||
|
|
||||||
# Task count (checkboxes)
|
|
||||||
total_tasks = len(re.findall(r'- \[[ x]\]', content))
|
total_tasks = len(re.findall(r'- \[[ x]\]', content))
|
||||||
completed_tasks = len(re.findall(r'- \[x\]', content, re.IGNORECASE))
|
completed_tasks = len(re.findall(r'- \[x\]', content, re.IGNORECASE))
|
||||||
pending_tasks = total_tasks - completed_tasks
|
|
||||||
|
|
||||||
# Image count
|
|
||||||
images = len(re.findall(r'!\[([^\]]*)\]\(([^\)]+)\)', content))
|
images = len(re.findall(r'!\[([^\]]*)\]\(([^\)]+)\)', content))
|
||||||
|
|
||||||
# Blockquote count
|
|
||||||
blockquotes = len(re.findall(r'^> ', content, re.MULTILINE))
|
blockquotes = len(re.findall(r'^> ', content, re.MULTILINE))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'words': words,
|
'words': words,
|
||||||
'sentences': sentences,
|
'sentences': sentences,
|
||||||
|
|
@ -109,87 +76,34 @@ class Plugin:
|
||||||
'h1': h1_count,
|
'h1': h1_count,
|
||||||
'h2': h2_count,
|
'h2': h2_count,
|
||||||
'h3': h3_count,
|
'h3': h3_count,
|
||||||
'total': h1_count + h2_count + h3_count
|
'total': h1_count + h2_count + h3_count,
|
||||||
},
|
},
|
||||||
'tasks': {
|
'tasks': {
|
||||||
'total': total_tasks,
|
'total': total_tasks,
|
||||||
'completed': completed_tasks,
|
'completed': completed_tasks,
|
||||||
'pending': pending_tasks,
|
'pending': total_tasks - completed_tasks,
|
||||||
'completion_rate': round(completed_tasks / total_tasks * 100) if total_tasks > 0 else 0
|
'completion_rate': round(completed_tasks / total_tasks * 100) if total_tasks else 0,
|
||||||
},
|
},
|
||||||
'images': images,
|
'images': images,
|
||||||
'blockquotes': blockquotes
|
'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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue