NoteDiscovery/plugins/note_stats.py

109 lines
4.3 KiB
Python
Raw Normal View History

2025-11-05 16:48:41 +00:00
"""
Note Statistics Plugin for NoteDiscovery
2026-06-30 14:48:16 +00:00
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.
2025-11-05 16:48:41 +00:00
"""
2026-06-30 14:48:16 +00:00
import logging
2025-11-05 16:48:41 +00:00
import re
2026-06-30 14:48:16 +00:00
logger = logging.getLogger("uvicorn.error")
WORDS_PER_MINUTE = 200
2025-11-05 16:48:41 +00:00
class Plugin:
def __init__(self):
self.name = "Note Statistics"
self.version = "1.0.0"
self.enabled = True
2026-06-30 14:48:16 +00:00
2025-11-05 16:48:41 +00:00
def calculate_stats(self, content: str) -> dict:
2026-06-30 14:48:16 +00:00
"""Compute the full metric set returned to the frontend / API."""
words = len(re.findall(r'\S+', content))
2025-11-05 16:48:41 +00:00
chars = len(re.sub(r'\s', '', content))
total_chars = len(content)
2026-06-30 14:48:16 +00:00
reading_time = max(1, round(words / WORDS_PER_MINUTE))
2025-11-05 16:48:41 +00:00
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))
2026-06-30 14:48:16 +00:00
# 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))
2025-12-03 15:00:25 +00:00
markdown_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content))
markdown_internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content))
wikilinks = len(re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content))
links = markdown_links + wikilinks
2026-06-30 14:48:16 +00:00
internal_links = markdown_internal_links + wikilinks # wikilinks are always internal
2025-11-05 16:48:41 +00:00
code_blocks = len(re.findall(r'```[\s\S]*?```', content))
inline_code = len(re.findall(r'`[^`]+`', content))
2026-06-30 14:48:16 +00:00
2025-11-05 16:48:41 +00:00
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))
2026-06-30 14:48:16 +00:00
2025-11-05 16:48:41 +00:00
total_tasks = len(re.findall(r'- \[[ x]\]', content))
completed_tasks = len(re.findall(r'- \[x\]', content, re.IGNORECASE))
2026-06-30 14:48:16 +00:00
2025-11-05 16:48:41 +00:00
images = len(re.findall(r'!\[([^\]]*)\]\(([^\)]+)\)', content))
blockquotes = len(re.findall(r'^> ', content, re.MULTILINE))
2026-06-30 14:48:16 +00:00
2025-11-05 16:48:41 +00:00
return {
'words': words,
'sentences': sentences,
2025-11-05 16:48:41 +00:00
'characters': chars,
'total_characters': total_chars,
'reading_time_minutes': reading_time,
'lines': lines,
'paragraphs': paragraphs,
'list_items': list_items,
'tables': tables,
2025-11-05 16:48:41 +00:00
'links': links,
'internal_links': internal_links,
'external_links': links - internal_links,
2025-12-03 15:00:25 +00:00
'wikilinks': wikilinks,
2025-11-05 16:48:41 +00:00
'code_blocks': code_blocks,
'inline_code': inline_code,
'headings': {
'h1': h1_count,
'h2': h2_count,
'h3': h3_count,
2026-06-30 14:48:16 +00:00
'total': h1_count + h2_count + h3_count,
2025-11-05 16:48:41 +00:00
},
'tasks': {
'total': total_tasks,
'completed': completed_tasks,
2026-06-30 14:48:16 +00:00
'pending': total_tasks - completed_tasks,
'completion_rate': round(completed_tasks / total_tasks * 100) if total_tasks else 0,
2025-11-05 16:48:41 +00:00
},
'images': images,
2026-06-30 14:48:16 +00:00
'blockquotes': blockquotes,
2025-11-05 16:48:41 +00:00
}
2026-06-30 14:48:16 +00:00
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