Repository navigation
Expand file tree
/
Copy pathvault-analytics.py
More file actions
226 lines (182 loc) · 7.13 KB
/
Copy pathvault-analytics.py
File metadata and controls
226 lines (182 loc) · 7.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
"""
Obsidian Python Bridge Script: Vault Analytics (data-science flex)
POC of Python's data stack on your vault:
- **With pandas (+matplotlib)** installed: real DataFrame statistics and a
PNG word-count histogram embedded into the dashboard note.
- **Without**: identical report, computed in pure stdlib — the script
degrades gracefully instead of failing.
Either way you get a generated dashboard note: total notes/words, mean,
median, longest-notes leaderboard, and the chart when available.
Requirements:
- Obsidian Python Bridge plugin v2.0.0+
- Optional: ``pip install pandas matplotlib`` for the full experience.
"""
import os
import statistics
import sys
from typing import Any
# --- Library Import ---
try:
from ObsidianPluginDevPythonToJS import (
ObsidianCommError,
ObsidianPluginDevPythonToJS,
_handle_cli_args,
define_settings,
)
except ImportError:
print("ERROR: ObsidianPluginDevPythonToJS.py library not found.", file=sys.stderr)
sys.exit(1)
try:
from ObsidianPluginDevPythonToJS import is_handling_event
except ImportError:
def is_handling_event() -> bool: # type: ignore[misc]
return bool(os.environ.get("OBSIDIAN_EVENT_NAME"))
if is_handling_event():
sys.exit(0)
# --- Optional heavy deps: the entire point is graceful degradation ---
try:
import pandas as pd # type: ignore
HAS_PANDAS = True
except ImportError:
pd = None # type: ignore[assignment]
HAS_PANDAS = False
try:
import matplotlib
matplotlib.use("Agg") # headless — we only save PNGs
import matplotlib.pyplot as plt # type: ignore
HAS_MPL = True
except ImportError:
plt = None # type: ignore[assignment]
HAS_MPL = False
MY_SCRIPT_SETTINGS = [
{"key": "top_n", "type": "number", "label": "Leaderboard size", "default": 10},
{
"key": "create_chart",
"type": "toggle",
"label": "Generate PNG histogram",
"description": "Requires matplotlib. Silently skipped otherwise.",
"default": True,
},
{
"key": "report_path",
"type": "text",
"label": "Dashboard note path",
"default": "Vault Analytics.md",
},
]
define_settings(MY_SCRIPT_SETTINGS)
_handle_cli_args()
# --- Pure core ---
def compute_metrics(documents: dict[str, str]) -> list[dict[str, Any]]:
"""Per-note word/char counts, sorted by word count desc."""
rows = [
{"title": title, "words": len(body.split()), "chars": len(body)}
for title, body in documents.items()
]
rows.sort(key=lambda r: r["words"], reverse=True)
return rows
def summarise(word_counts: list[int]) -> dict[str, float]:
"""Stdlib descriptive stats over non-empty counts."""
if not word_counts:
return {}
return {
"notes": len(word_counts),
"total_words": sum(word_counts),
"mean": round(statistics.fmean(word_counts), 1),
"median": round(statistics.median(word_counts), 1),
"max": max(word_counts),
}
def render_report(
metrics: list[dict[str, Any]],
summary: dict[str, float],
top_n: int,
chart_filename: str | None,
engine: str,
) -> str:
lines: list[str] = ["# 📊 Vault Analytics", "", f"_Engine: {engine}_", "", "## Summary", ""]
if summary:
lines += [
f"- Notes analysed: **{summary['notes']}**",
f"- Total words: **{summary['total_words']:,}**",
f"- Mean words/note: **{summary['mean']}** · median **{summary['median']}** · max **{summary['max']:,}**",
]
else:
lines.append("_No notes found._")
if chart_filename:
lines += ["", f""]
lines += ["", f"## Top {top_n} longest notes", "", "| Note | Words | Chars |", "|---|---|---|"]
for row in metrics[: max(top_n, 0)]:
lines.append(f"| [[{row['title']}]] | {row['words']:,} | {row['chars']:,} |")
lines.append("")
return "\n".join(lines)
def build_histogram_png(word_counts: list[int], png_path: str) -> bool:
"""Save a histogram PNG; False when matplotlib is unavailable."""
if not HAS_MPL or not word_counts:
return False
plt.figure(figsize=(8, 4))
plt.hist(word_counts, bins=30, color="#7c3aed", edgecolor="white")
plt.title("Words per note")
plt.xlabel("Words")
plt.ylabel("Notes")
plt.tight_layout()
plt.savefig(png_path, dpi=120)
plt.close()
return True
def pick_engine(create_chart_requested: bool) -> tuple[str, bool]:
"""Return (engine_label, chart_possible)."""
if HAS_PANDAS and HAS_MPL:
return "pandas + matplotlib", create_chart_requested
if HAS_PANDAS:
return "pandas (no matplotlib)", False
return "pure stdlib fallback", False
# --- Orchestration ---
def run(obsidian: ObsidianPluginDevPythonToJS) -> None:
try:
settings = obsidian.get_script_settings()
top_n = int(settings.get("top_n", MY_SCRIPT_SETTINGS[0]["default"]))
want_chart = bool(settings.get("create_chart", MY_SCRIPT_SETTINGS[1]["default"]))
report_path = str(settings.get("report_path", MY_SCRIPT_SETTINGS[2]["default"]))
except ObsidianCommError:
top_n, want_chart, report_path = 10, True, "Vault Analytics.md"
rel_paths = obsidian.get_all_note_paths(absolute=False)
documents: dict[str, str] = {}
for rel in rel_paths:
title = os.path.splitext(os.path.basename(rel))[0]
try:
documents[title] = obsidian.get_note_content(rel) or ""
except ObsidianCommError as e:
print(f"WARNING: skipping '{rel}': {e}", file=sys.stderr)
if not documents:
obsidian.show_notification("Empty vault — nothing to analyse.", 4000)
return
# Optional pandas pass (kept tiny: the DataFrame IS the demo).
global_summary: dict[str, float] = {}
if HAS_PANDAS:
frame = pd.DataFrame(compute_metrics(documents))
global_summary["mean"] = round(float(frame["words"].mean()), 1)
global_summary["std"] = round(float(frame["words"].std(ddof=0)), 1)
metrics = compute_metrics(documents)
base_summary = summarise([m["words"] for m in metrics])
base_summary.update(global_summary)
engine, chart_possible = pick_engine(want_chart)
chart_filename: str | None = None
if chart_possible:
png_abs = os.path.splitext(report_path)[0] + ".png"
if build_histogram_png([m["words"] for m in metrics], png_abs):
chart_filename = os.path.basename(png_abs)
report = render_report(metrics, base_summary, top_n, chart_filename, engine)
try:
obsidian.create_note(report_path, report)
obsidian.show_notification(f"Analytics ready ({engine}) → {report_path}", 5000)
except ObsidianCommError as e:
print(f"ERROR: could not write dashboard: {e}", file=sys.stderr)
obsidian.show_notification(f"Failed: {e}", 6000)
if __name__ == "__main__":
try:
run(ObsidianPluginDevPythonToJS())
except ObsidianCommError as e:
print(f"ERROR: Obsidian Communication Error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"ERROR: Unexpected {type(e).__name__}: {e}", file=sys.stderr)
sys.exit(1)