brainplug 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
brainplug/analysis.py ADDED
@@ -0,0 +1,453 @@
1
+ """Turn items into topics, connections, two 3D layouts and summary numbers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from collections import Counter
7
+ from datetime import datetime, timedelta, tzinfo
8
+
9
+ import numpy as np
10
+ from sklearn.cluster import KMeans
11
+ from sklearn.decomposition import PCA, TruncatedSVD
12
+ from sklearn.feature_extraction.text import TfidfVectorizer
13
+ from sklearn.metrics import silhouette_score
14
+ from sklearn.neighbors import NearestNeighbors
15
+ from sklearn.preprocessing import normalize
16
+
17
+ from . import __version__, brain
18
+ from .sources import Item
19
+ from .stopwords import STOP_WORDS
20
+
21
+ PALETTE = [
22
+ "#4cc9f0", "#f72585", "#b8f35a", "#ffb703", "#9d4edd", "#2ec4b6", "#ff6b6b",
23
+ "#ffd6a5", "#80ffdb", "#c77dff", "#f15bb5", "#00bbf9", "#fee440", "#a0c4ff",
24
+ "#ff9f1c", "#06d6a0",
25
+ ]
26
+ KINDS = ["chat", "note", "message"]
27
+ SOURCE_NAMES = {
28
+ "chatgpt": "ChatGPT", "claude": "Claude", "gemini": "Gemini", "copilot": "Copilot",
29
+ "perplexity": "Perplexity", "obsidian": "Obsidian", "notion": "Notion", "notes": "Notes",
30
+ "word": "Word", "pdf": "PDF", "gmail": "Gmail", "whatsapp": "WhatsApp", "memory": "Memory",
31
+ "demo": "Demo", "claudecode": "Claude Code", "codex": "Codex",
32
+ }
33
+ TOKEN_PATTERN = r"(?u)\b[^\W\d_][^\W\d_]+\b"
34
+
35
+
36
+ def _stamps(item: Item) -> list[datetime]:
37
+ """When you wrote: one timestamp per message, or the note's own date."""
38
+ return item.parts or [item.created]
39
+
40
+
41
+ # --------------------------------------------------------------------------- #
42
+ # topics
43
+ # --------------------------------------------------------------------------- #
44
+ def documents(items: list[Item]) -> list[str]:
45
+ # Titles and tags are strong hints, so they count twice. Only your own
46
+ # words are used: they say what *you* care about. A WhatsApp "title" is a
47
+ # person's name, which says nothing about the topic, so it is left out.
48
+ return [(f"{i.text[:20000]}" if i.source == "whatsapp" else
49
+ f"{i.title}. {i.title}. {' '.join(i.tags)} {' '.join(i.tags)} {i.text[:20000]}")
50
+ for i in items]
51
+
52
+
53
+ def vectorize(docs: list[str]):
54
+ options = dict(lowercase=True, stop_words=STOP_WORDS, token_pattern=TOKEN_PATTERN,
55
+ ngram_range=(1, 2), max_features=8000, sublinear_tf=True)
56
+ try:
57
+ vec = TfidfVectorizer(min_df=2 if len(docs) >= 20 else 1, max_df=0.5, **options)
58
+ return vec, vec.fit_transform(docs)
59
+ except ValueError: # tiny or very repetitive record: keep every term
60
+ vec = TfidfVectorizer(min_df=1, max_df=1.0, **options)
61
+ return vec, vec.fit_transform(docs)
62
+
63
+
64
+ def embed(X, seed: int = 0, model: dict | None = None) -> np.ndarray:
65
+ n_comp = max(2, min(100, X.shape[0] - 1, X.shape[1] - 1))
66
+ svd = TruncatedSVD(n_components=n_comp, random_state=seed)
67
+ with warnings.catch_warnings():
68
+ warnings.simplefilter("ignore") # identical documents make the variance ratio 0/0
69
+ Z = svd.fit_transform(X)
70
+ if model is not None:
71
+ model["svd"] = svd
72
+ return normalize(Z)
73
+
74
+
75
+ def choose_k(Z: np.ndarray, seed: int = 0) -> int:
76
+ n = len(Z)
77
+ if n < 12:
78
+ return max(1, min(3, n))
79
+ lo, hi = 4, int(min(14, max(5, n // 12)))
80
+ best_k, best_s = lo, -1.0
81
+ for k in range(lo, hi + 1):
82
+ labels = KMeans(n_clusters=k, n_init=5, random_state=seed).fit_predict(Z)
83
+ s = silhouette_score(Z, labels, metric="cosine", sample_size=min(n, 2000),
84
+ random_state=seed)
85
+ s += 0.004 * k # small bonus for more topics so the map isn't too coarse
86
+ if s > best_s:
87
+ best_k, best_s = k, s
88
+ return best_k
89
+
90
+
91
+ def label_topics(X, labels: np.ndarray, vocab: np.ndarray, top: int = 3) -> list[list[str]]:
92
+ overall = np.asarray(X.mean(axis=0)).ravel()
93
+ names = []
94
+ for k in range(labels.max() + 1):
95
+ score = np.asarray(X[labels == k].mean(axis=0)).ravel() - 0.5 * overall
96
+ picked: list[str] = []
97
+ for idx in np.argsort(score)[::-1]:
98
+ term = vocab[idx]
99
+ # skip terms that repeat a word already picked ("pandas" vs "pandas groupby")
100
+ if any(w in p.split() for w in term.split() for p in picked):
101
+ continue
102
+ picked.append(term)
103
+ if len(picked) == top:
104
+ break
105
+ names.append(picked)
106
+ return names
107
+
108
+
109
+ def item_terms(X, vocab: np.ndarray, top: int = 4) -> list[list[str]]:
110
+ out = []
111
+ X = X.tocsr()
112
+ for i in range(X.shape[0]):
113
+ row = X.getrow(i)
114
+ order = np.argsort(row.data)[::-1][: top * 2]
115
+ picked: list[str] = []
116
+ for j in order:
117
+ term = vocab[row.indices[j]]
118
+ if not any(w in p.split() for w in term.split() for p in picked):
119
+ picked.append(term)
120
+ if len(picked) == top:
121
+ break
122
+ out.append(picked)
123
+ return out
124
+
125
+
126
+ # --------------------------------------------------------------------------- #
127
+ # connections
128
+ # --------------------------------------------------------------------------- #
129
+ def connections(Z: np.ndarray, items: list[Item], per_item: int = 3,
130
+ min_sim: float = 0.5, limit: int = 8000) -> list[list]:
131
+ """Edges [i, j, weight, kind]: kind 0 = similar content, 1 = explicit link."""
132
+ n = len(items)
133
+ edges: dict[tuple[int, int], tuple[float, int]] = {}
134
+ if n > 2:
135
+ nn = NearestNeighbors(n_neighbors=min(per_item + 1, n), metric="cosine").fit(Z)
136
+ dist, idx = nn.kneighbors(Z)
137
+ for i in range(n):
138
+ for d, j in zip(dist[i], idx[i]):
139
+ if i != j and 1 - d >= min_sim:
140
+ key = (min(i, int(j)), max(i, int(j)))
141
+ edges[key] = (max(edges.get(key, (0.0, 0))[0], float(1 - d)), 0)
142
+ by_title: dict[str, int] = {}
143
+ for i, item in enumerate(items):
144
+ by_title.setdefault(item.title.strip().lower(), i)
145
+ for i, item in enumerate(items):
146
+ for target in item.links: # [[wikilinks]] and Notion page links
147
+ j = by_title.get(target.strip().lower())
148
+ if j is not None and j != i:
149
+ edges[(min(i, j), max(i, j))] = (1.0, 1)
150
+ out = [[a, b, round(w, 2), kind] for (a, b), (w, kind) in edges.items()]
151
+ out.sort(key=lambda e: (-e[3], -e[2]))
152
+ return out[:limit]
153
+
154
+
155
+ def topic_bridges(Z: np.ndarray, labels: np.ndarray, k: int, ratio: float = 0.6,
156
+ top: int = 3) -> list[dict]:
157
+ """Pairs of topics joined by items that sit between both: an item is a bridge
158
+ when it is almost as close to another topic's centre as to its own."""
159
+ if k < 2:
160
+ return []
161
+ centres = normalize(np.vstack([Z[labels == t].mean(axis=0) for t in range(k)]))
162
+ sim = Z @ centres.T
163
+ rows = np.arange(len(Z))
164
+ own = sim[rows, labels]
165
+ sim[rows, labels] = -1
166
+ second, s2 = sim.argmax(axis=1), sim.max(axis=1)
167
+ pairs: Counter = Counter()
168
+ examples: dict[tuple[int, int], list] = {}
169
+ for i in np.where((s2 >= ratio * own) & (s2 > 0.2))[0]:
170
+ key = (int(min(labels[i], second[i])), int(max(labels[i], second[i])))
171
+ pairs[key] += 1
172
+ examples.setdefault(key, []).append((float(s2[i]), int(i)))
173
+ return [{"a": a, "b": b, "n": n, "ex": [i for _, i in sorted(examples[(a, b)], reverse=True)[:3]]}
174
+ for (a, b), n in pairs.most_common(top)]
175
+
176
+
177
+ # --------------------------------------------------------------------------- #
178
+ # numbers
179
+ # --------------------------------------------------------------------------- #
180
+ def _longest_streak(days: set) -> int:
181
+ best = cur = 0
182
+ prev = None
183
+ for d in sorted(days):
184
+ cur = cur + 1 if prev is not None and (d - prev).days == 1 else 1
185
+ best = max(best, cur)
186
+ prev = d
187
+ return best
188
+
189
+
190
+ def summary(items: list[Item], dated: np.ndarray, tz: tzinfo, private: bool) -> dict:
191
+ timed = [it for it, ok in zip(items, dated) if ok]
192
+ stamps = [s.astimezone(tz) for it in timed for s in _stamps(it)]
193
+ heat = np.zeros((7, 24), dtype=int)
194
+ for s in stamps:
195
+ heat[s.weekday(), s.hour] += 1
196
+ months = Counter(it.created.astimezone(tz).strftime("%Y-%m") for it in timed)
197
+ busiest = max(months.items(), key=lambda kv: kv[1])
198
+ days = {s.date() for s in stamps}
199
+ longest = max(items, key=lambda it: it.words)
200
+ by_source = Counter(it.source for it in items)
201
+ words_by_source: Counter = Counter()
202
+ for it in items:
203
+ words_by_source[it.source] += it.words
204
+ return {
205
+ "items": len(items),
206
+ "kinds": {k: sum(it.kind == k for it in items) for k in KINDS},
207
+ "writes": len(stamps),
208
+ "words": sum(it.words for it in items),
209
+ "first": min(it.created for it in timed).astimezone(tz).strftime("%Y-%m-%d"),
210
+ "last": max(it.created for it in timed).astimezone(tz).strftime("%Y-%m-%d"),
211
+ "active_days": len(days),
212
+ "streak": _longest_streak(days),
213
+ "busiest_month": {"month": busiest[0], "count": int(busiest[1])},
214
+ "busiest_weekday": int(np.argmax(heat.sum(axis=1))),
215
+ "busiest_hour": int(np.argmax(heat.sum(axis=0))),
216
+ "night_share": round(int(heat[:, 0:6].sum()) / max(1, len(stamps)), 3),
217
+ "longest": {"title": None if private else longest.title, "words": longest.words},
218
+ "sources": [
219
+ {"id": s, "name": SOURCE_NAMES.get(s, s.title()), "count": c,
220
+ "words": int(words_by_source[s])}
221
+ for s, c in by_source.most_common()
222
+ ],
223
+ "undated": int((~dated).sum()),
224
+ "heat": heat.tolist(),
225
+ }
226
+
227
+
228
+ def month_range(first: datetime, last: datetime) -> list[str]:
229
+ out, y, m = [], first.year, first.month
230
+ while (y, m) <= (last.year, last.month):
231
+ out.append(f"{y:04d}-{m:02d}")
232
+ y, m = (y + 1, 1) if m == 12 else (y, m + 1)
233
+ return out
234
+
235
+
236
+ # --------------------------------------------------------------------------- #
237
+ # review: what changed, what you dropped, what connects
238
+ # --------------------------------------------------------------------------- #
239
+ def review(items: list[Item], labels: np.ndarray, dated: np.ndarray, k: int,
240
+ edges: list[list], bridges: list[dict], tz: tzinfo, window_days: int = 60) -> dict:
241
+ idx = [i for i in range(len(items)) if dated[i]]
242
+ last = max(items[i].created for i in idx)
243
+ win = timedelta(days=window_days)
244
+ recent, before = np.zeros(k, dtype=int), np.zeros(k, dtype=int)
245
+ first_seen: list = [None] * k
246
+ last_seen: list = [None] * k
247
+ for i in idx:
248
+ c, t = items[i].created, int(labels[i])
249
+ if c > last - win:
250
+ recent[t] += 1
251
+ elif c > last - 2 * win:
252
+ before[t] += 1
253
+ first_seen[t] = c if first_seen[t] is None else min(first_seen[t], c)
254
+ last_seen[t] = c if last_seen[t] is None else max(last_seen[t], c)
255
+ delta = recent - before
256
+ sizes = np.bincount(labels, minlength=k)
257
+
258
+ def row(t):
259
+ return {"k": int(t), "now": int(recent[t]), "before": int(before[t])}
260
+
261
+ rising = [row(t) for t in np.argsort(-delta, kind="stable") if delta[t] > 0][:3]
262
+ fading = [row(t) for t in np.argsort(delta, kind="stable") if delta[t] < 0][:3]
263
+ dormant = sorted(
264
+ ({"k": t, "days": (last - last_seen[t]).days,
265
+ "last": last_seen[t].astimezone(tz).strftime("%Y-%m-%d")}
266
+ for t in range(k) if last_seen[t] is not None and (last - last_seen[t]).days >= 90),
267
+ key=lambda d: -sizes[d["k"]],
268
+ )[:3]
269
+ seen = [t for t in range(k) if first_seen[t] is not None]
270
+ newest = None
271
+ if seen:
272
+ t = max(seen, key=lambda t: first_seen[t])
273
+ if (last - first_seen[t]).days <= 150 and first_seen[t] > min(first_seen[s] for s in seen):
274
+ newest = {"k": t, "since": first_seen[t].astimezone(tz).strftime("%Y-%m")}
275
+
276
+ # connections between different apps; the most telling ones join different kinds
277
+ # of writing (a note and a chat, say), and each item is shown only once
278
+ cross = [(items[a].kind != items[b].kind, w, a, b) for a, b, w, _kind in edges
279
+ if items[a].source != items[b].source]
280
+ cross.sort(reverse=True)
281
+ picked, shown = [], set()
282
+ for _, _, a, b in cross:
283
+ if a not in shown and b not in shown:
284
+ picked.append([a, b])
285
+ shown.update((a, b))
286
+ if len(picked) == 6:
287
+ break
288
+ target = (last.astimezone(tz).year - 1, last.astimezone(tz).month)
289
+ year_ago = [i for i in idx
290
+ if (items[i].created.astimezone(tz).year, items[i].created.astimezone(tz).month) == target]
291
+ return {
292
+ "window": window_days,
293
+ "rising": rising, "fading": fading, "dormant": dormant, "newest": newest,
294
+ "bridges": bridges,
295
+ "cross": picked,
296
+ "cross_total": len(cross),
297
+ "deep": [int(i) for i in np.argsort([-it.words for it in items], kind="stable")[:5]],
298
+ "year_ago": year_ago[:8],
299
+ }
300
+
301
+
302
+ SIGNATURE = 12 # words kept with a topic name, so it is recognised after the topic shifts a little
303
+
304
+
305
+ def match_names(terms: list[list[str]], names: list, skip: set | None = None) -> dict[int, str]:
306
+ """Give topics the names people saved (Brainplug/Topic names.md), matched by their words:
307
+ a name fits a topic when at least a third of its saved words are among the topic's words."""
308
+ pairs = []
309
+ for i, words in enumerate(terms):
310
+ for j, (name, saved) in enumerate(names):
311
+ shared = len(set(words) & set(saved))
312
+ if i not in (skip or set()) and saved and shared * 3 >= len(saved):
313
+ pairs.append((-shared / len(saved), -shared, i, j, name))
314
+ out: dict[int, str] = {}
315
+ used: set[int] = set()
316
+ for _score, _shared, i, j, name in sorted(pairs):
317
+ if i not in out and j not in used:
318
+ out[i] = name
319
+ used.add(j)
320
+ return out
321
+
322
+
323
+ # --------------------------------------------------------------------------- #
324
+ # main entry
325
+ # --------------------------------------------------------------------------- #
326
+ def analyse(items: list[Item], tz: tzinfo | None = None, n_topics: int | None = None, private: bool = False,
327
+ rename: dict[int, str] | None = None, title: str = "My Brainplug",
328
+ seed: int = 0, keep: dict | None = None, names: list | None = None) -> dict:
329
+ """Build everything the viewer needs. Pass a dict as ``keep`` to also get the
330
+ fitted model back (vectorizer, SVD, matrices, labels), e.g. for search."""
331
+ if len(items) < 3:
332
+ raise ValueError("Need at least 3 items (chats, notes, documents or messages) to build a brainplug.")
333
+ rng = np.random.default_rng(seed)
334
+ n = len(items)
335
+ dated = np.array([it.dated for it in items])
336
+ if not dated.any(): # nothing carries a real date: fall back to file dates
337
+ dated[:] = True
338
+
339
+ vec, X = vectorize(documents(items))
340
+ vocab = vec.get_feature_names_out()
341
+ model: dict = {}
342
+ Z = embed(X, seed, model)
343
+ k = max(1, min(n_topics or choose_k(Z, seed), len(PALETTE), n))
344
+ with warnings.catch_warnings():
345
+ warnings.simplefilter("ignore") # duplicates make k-means warn; the result is still fine
346
+ labels = KMeans(n_clusters=k, n_init=10, random_state=seed).fit(Z).labels_
347
+ labels = np.unique(labels, return_inverse=True)[1] # drop clusters that ended up empty
348
+ k = int(labels.max()) + 1
349
+
350
+ # biggest topic first, so topic 1 is always the main one
351
+ order = np.argsort(-np.bincount(labels, minlength=k), kind="stable")
352
+ remap = np.empty(k, dtype=int)
353
+ remap[order] = np.arange(k)
354
+ labels = remap[labels]
355
+ sizes = np.bincount(labels, minlength=k)
356
+
357
+ terms = label_topics(X, labels, vocab)
358
+ sig = label_topics(X, labels, vocab, top=SIGNATURE) # words a saved topic name is matched by; never in the file
359
+ rename = {**match_names(sig, names or [], skip=set(rename or {})), **(rename or {})}
360
+ topics = [{
361
+ "id": i,
362
+ "name": rename.get(i) or (" · ".join(terms[i][:2]) if terms[i] else f"Topic {i + 1}"),
363
+ # keywords are words taken from your text: a private file keeps none of them
364
+ "terms": [] if private else terms[i], "color": PALETTE[i], "count": int(sizes[i]),
365
+ "share": round(float(sizes[i]) / n, 3),
366
+ } for i in range(k)]
367
+
368
+ edges = connections(Z, items)
369
+ if keep is not None:
370
+ keep.update(vec=vec, svd=model["svd"], X=X, Z=Z, labels=labels, sig=sig)
371
+
372
+ # ---- time, normalised to 0..1 -------------------------------------- #
373
+ stamps = np.array([it.created.timestamp() for it in items])
374
+ t0, t1 = stamps[dated].min(), stamps[dated].max()
375
+ span = max(1.0, t1 - t0)
376
+ when = np.where(dated, np.clip((stamps - t0) / span, 0, 1), 0.0)
377
+
378
+ # ---- layout 1: brain ------------------------------------------------ #
379
+ pos = np.zeros((n, 3))
380
+ anchors = brain.anchors(k, seed=11 + seed)
381
+ for i in range(k):
382
+ idx = np.where(labels == i)[0]
383
+ if len(idx) >= 4:
384
+ local = PCA(n_components=3, random_state=seed).fit_transform(Z[idx])
385
+ local /= np.abs(local).max(axis=0) + 1e-9
386
+ else:
387
+ local = rng.normal(scale=0.5, size=(len(idx), 3))
388
+ radius = 0.16 + 0.22 * np.sqrt(len(idx) / sizes.max())
389
+ pos[idx] = brain.place_cluster(anchors[i], local, radius, rng)
390
+
391
+ # ---- layout 2: galaxy ----------------------------------------------- #
392
+ gal = brain.galaxy(labels, np.where(dated, when, rng.random(n)), k, rng)
393
+
394
+ words = np.array([it.words for it in items], dtype=float)
395
+ size = 0.7 + 1.6 * np.log1p(words) / np.log1p(words.max())
396
+ points = [
397
+ [*(round(float(v), 3) for v in pos[i]), *(round(float(v), 3) for v in gal[i]),
398
+ int(labels[i]), round(float(size[i]), 2), round(float(when[i]), 4),
399
+ KINDS.index(items[i].kind)]
400
+ for i in range(n)
401
+ ]
402
+
403
+ own_terms = [None] * n if private else item_terms(X, vocab)
404
+ items_out = []
405
+ for i, it in enumerate(items):
406
+ local = it.created.astimezone(tz)
407
+ items_out.append({
408
+ "t": None if private else it.title,
409
+ "d": local.strftime("%Y-%m-%d"), "h": local.hour,
410
+ "n": it.size, "p": len(_stamps(it)), "w": it.words, "k": int(labels[i]), "s": it.source,
411
+ "x": None if private else " ".join(it.text.split())[:180],
412
+ "tm": own_terms[i], "u": 0 if dated[i] else 1,
413
+ })
414
+
415
+ # ---- spine / core: every message you wrote, oldest first ------------ #
416
+ rows = sorted((s.timestamp(), int(labels[i]))
417
+ for i, it in enumerate(items) if dated[i] for s in _stamps(it))
418
+ if len(rows) > 3000:
419
+ rows = [rows[int(j * len(rows) / 3000)] for j in range(3000)]
420
+ along = np.linspace(0.02, 0.98, len(rows))
421
+ spine_xyz = brain.spine_points(len(rows), rng, jitter=0.85, t=along)
422
+ spine_t = np.clip((np.array([r[0] for r in rows]) - t0) / span, 0, 1)
423
+ core_xyz = brain.galaxy_core(spine_t, rng)
424
+ spine = [
425
+ [*(round(float(v), 3) for v in spine_xyz[j]), *(round(float(v), 3) for v in core_xyz[j]),
426
+ rows[j][1], round(float(spine_t[j]), 4)]
427
+ for j in range(len(rows))
428
+ ]
429
+
430
+ # ---- waves: items per month and topic -------------------------------- #
431
+ first = datetime.fromtimestamp(t0, tz)
432
+ last = datetime.fromtimestamp(t1, tz)
433
+ months = month_range(first, last)
434
+ midx = {m: i for i, m in enumerate(months)}
435
+ waves = np.zeros((k, len(months)), dtype=int)
436
+ for i, it in enumerate(items):
437
+ if dated[i]:
438
+ waves[labels[i], midx[it.created.astimezone(tz).strftime("%Y-%m")]] += 1
439
+
440
+ return {
441
+ "meta": {"title": title, "private": private, "version": __version__,
442
+ "generated": datetime.now(tz).strftime("%Y-%m-%d")},
443
+ "numbers": summary(items, dated, tz, private),
444
+ "topics": topics,
445
+ "points": points,
446
+ "items": items_out,
447
+ "edges": edges,
448
+ "spine": spine,
449
+ "months": months,
450
+ "waves": waves.tolist(),
451
+ "review": review(items, labels, dated, k, edges, topic_bridges(Z, labels, k), tz),
452
+ "shell": [round(float(v), 3) for v in brain.shell(seed=3 + seed).ravel()],
453
+ }
brainplug/app.py ADDED
@@ -0,0 +1,184 @@
1
+ """A tiny local web app: drop your files in the browser, get your brainplug.
2
+
3
+ It listens only on this computer (127.0.0.1) and needs a secret token that is
4
+ part of the URL it opens, so no other website or device can talk to it.
5
+ Nothing is uploaded anywhere: "uploading" just copies files to a temp folder.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import json
12
+ import secrets
13
+ import shutil
14
+ import signal
15
+ import sys
16
+ import tempfile
17
+ import webbrowser
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from pathlib import Path, PurePosixPath
20
+ from urllib.parse import parse_qs, urlparse
21
+
22
+ from . import analysis, sources
23
+ from .render import render_html
24
+
25
+ PAGE = Path(__file__).parent / "templates" / "app.html"
26
+ CHUNK = 1 << 20
27
+
28
+
29
+ class State:
30
+ def __init__(self) -> None:
31
+ self.token = secrets.token_urlsafe(18)
32
+ self.dir = Path(tempfile.mkdtemp(prefix="brainplug_app_"))
33
+ self.uploads = self.dir / "uploads"
34
+ self.uploads.mkdir()
35
+ self.result: str | None = None
36
+
37
+ def reset(self) -> None:
38
+ shutil.rmtree(self.uploads, ignore_errors=True)
39
+ self.uploads.mkdir()
40
+
41
+ def close(self) -> None:
42
+ shutil.rmtree(self.dir, ignore_errors=True)
43
+
44
+
45
+ def safe_relative(name: str) -> PurePosixPath | None:
46
+ """Turn an uploaded file name into a path that cannot escape the upload folder."""
47
+ parts = [p for p in PurePosixPath(name.replace("\\", "/")).parts if p not in ("/", "..", ".", "")]
48
+ parts = [p.replace(":", "_") for p in parts]
49
+ return PurePosixPath(*parts) if parts else None
50
+
51
+
52
+ def build(state: State, options: dict) -> dict:
53
+ inputs = sorted(state.uploads.iterdir())
54
+ inputs += [Path(p.strip().strip('"')) for p in options.get("paths") or [] if p.strip()]
55
+ if not inputs:
56
+ raise ValueError("Add at least one file or folder first.")
57
+ me = [a.strip() for a in str(options.get("me") or "").split(",") if a.strip()]
58
+ notes: list[str] = []
59
+ items = sources.load(inputs, me=me or None, pdf=bool(options.get("pdf")), notes=notes)
60
+ from .pipeline import local_tz
61
+ payload = analysis.analyse(
62
+ items, tz=local_tz(), n_topics=int(options["topics"]) if options.get("topics") else None,
63
+ private=bool(options.get("private")), title=str(options.get("title") or "My Brainplug"),
64
+ names=sources.topic_names_in(inputs),
65
+ )
66
+ state.result = render_html(payload, lang=str(options.get("lang") or "en"))
67
+ return {
68
+ "ok": True, "items": len(items), "notes": notes,
69
+ "sources": payload["numbers"]["sources"],
70
+ "topics": [{"name": t["name"], "count": t["count"]} for t in payload["topics"]],
71
+ }
72
+
73
+
74
+ def make_handler(state: State):
75
+ class Handler(BaseHTTPRequestHandler):
76
+ server_version = "Brainplug"
77
+
78
+ def log_message(self, *args) -> None: # keep the terminal quiet
79
+ pass
80
+
81
+ def _send(self, code: int, body: bytes | str, ctype: str = "application/json", extra: dict | None = None):
82
+ data = body.encode("utf-8") if isinstance(body, str) else body
83
+ self.send_response(code)
84
+ self.send_header("Content-Type", f"{ctype}; charset=utf-8")
85
+ self.send_header("Content-Length", str(len(data)))
86
+ self.send_header("Cache-Control", "no-store")
87
+ for key, value in (extra or {}).items():
88
+ self.send_header(key, value)
89
+ self.end_headers()
90
+ self.wfile.write(data)
91
+
92
+ def _allowed(self, query: dict) -> bool:
93
+ host = (self.headers.get("Host") or "").split(":")[0]
94
+ token = self.headers.get("X-Brainplug-Token") or (query.get("t") or [""])[0]
95
+ return host in ("127.0.0.1", "localhost") and secrets.compare_digest(token, state.token)
96
+
97
+ def _route(self):
98
+ url = urlparse(self.path)
99
+ query = parse_qs(url.query)
100
+ if not self._allowed(query):
101
+ self._send(403, json.dumps({"ok": False, "error": "Forbidden"}))
102
+ return None, None
103
+ return url.path, query
104
+
105
+ def do_GET(self):
106
+ path, query = self._route()
107
+ if path == "/":
108
+ self._send(200, PAGE.read_text(encoding="utf-8").replace("__TOKEN__", state.token), "text/html")
109
+ elif path == "/result" and state.result:
110
+ extra = {"Content-Disposition": 'attachment; filename="brainplug.html"'} if "download" in query else None
111
+ self._send(200, state.result, "text/html", extra)
112
+ elif path is not None:
113
+ self._send(404, json.dumps({"ok": False, "error": "Not found"}))
114
+
115
+ def do_PUT(self):
116
+ path, query = self._route()
117
+ if path != "/upload":
118
+ if path is not None:
119
+ self._send(404, json.dumps({"ok": False, "error": "Not found"}))
120
+ return
121
+ rel = safe_relative((query.get("name") or [""])[0])
122
+ length = self.headers.get("Content-Length")
123
+ if rel is None or length is None:
124
+ self._send(400, json.dumps({"ok": False, "error": "Bad upload"}))
125
+ return
126
+ target = state.uploads / rel
127
+ target.parent.mkdir(parents=True, exist_ok=True)
128
+ left = int(length)
129
+ with open(target, "wb") as fh:
130
+ while left > 0:
131
+ chunk = self.rfile.read(min(CHUNK, left))
132
+ if not chunk:
133
+ break
134
+ fh.write(chunk)
135
+ left -= len(chunk)
136
+ self._send(200, json.dumps({"ok": True}))
137
+
138
+ def do_POST(self):
139
+ path, _ = self._route()
140
+ if path is None:
141
+ return
142
+ raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
143
+ if path == "/reset":
144
+ state.reset()
145
+ self._send(200, json.dumps({"ok": True}))
146
+ elif path == "/build":
147
+ try:
148
+ self._send(200, json.dumps(build(state, json.loads(raw or b"{}"))))
149
+ except Exception as err: # show the reason in the page instead of a stack trace
150
+ self._send(200, json.dumps({"ok": False, "error": str(err) or type(err).__name__}))
151
+ else:
152
+ self._send(404, json.dumps({"ok": False, "error": "Not found"}))
153
+
154
+ return Handler
155
+
156
+
157
+ def start(port: int = 8765) -> tuple[ThreadingHTTPServer, State]:
158
+ state = State()
159
+ last: Exception | None = None
160
+ for candidate in range(port, port + 20):
161
+ try:
162
+ return ThreadingHTTPServer(("127.0.0.1", candidate), make_handler(state)), state
163
+ except OSError as err:
164
+ last = err
165
+ state.close()
166
+ raise OSError(f"No free port between {port} and {port + 19}") from last
167
+
168
+
169
+ def serve(port: int = 8765, open_browser: bool = True) -> int:
170
+ server, state = start(port)
171
+ atexit.register(state.close) # also clean up when the terminal window is simply closed
172
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
173
+ url = f"http://127.0.0.1:{server.server_address[1]}/?t={state.token}"
174
+ print(f"Brainplug is running on this computer only:\n\n {url}\n\nPress Ctrl+C to stop.")
175
+ if open_browser:
176
+ webbrowser.open(url)
177
+ try:
178
+ server.serve_forever()
179
+ except KeyboardInterrupt:
180
+ print("\nStopped. Temporary copies deleted.")
181
+ finally:
182
+ server.server_close()
183
+ state.close()
184
+ return 0