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/__init__.py +3 -0
- brainplug/__main__.py +300 -0
- brainplug/agent.py +749 -0
- brainplug/analysis.py +453 -0
- brainplug/app.py +184 -0
- brainplug/brain.py +184 -0
- brainplug/connect.py +354 -0
- brainplug/demo.py +165 -0
- brainplug/export.py +142 -0
- brainplug/memory.py +356 -0
- brainplug/notion_api.py +135 -0
- brainplug/pipeline.py +95 -0
- brainplug/render.py +47 -0
- brainplug/sources.py +1193 -0
- brainplug/stopwords.py +33 -0
- brainplug/sync.py +109 -0
- brainplug/templates/app.html +212 -0
- brainplug/templates/viewer.html +1050 -0
- brainplug/vendor/INTER_LICENSE +93 -0
- brainplug/vendor/THREE_LICENSE +21 -0
- brainplug/vendor/inter-latin.woff2 +0 -0
- brainplug/vendor/three.module.min.js +6 -0
- brainplug-0.4.0.dist-info/METADATA +364 -0
- brainplug-0.4.0.dist-info/RECORD +28 -0
- brainplug-0.4.0.dist-info/WHEEL +5 -0
- brainplug-0.4.0.dist-info/entry_points.txt +2 -0
- brainplug-0.4.0.dist-info/licenses/LICENSE +21 -0
- brainplug-0.4.0.dist-info/top_level.txt +1 -0
brainplug/notion_api.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Read your Notion pages through the official Notion API.
|
|
2
|
+
|
|
3
|
+
Why: a Notion *export* does not include the day a page was created (unless the
|
|
4
|
+
page has a date property), so those pages come out as "no date". The API does
|
|
5
|
+
return ``created_time`` for every page.
|
|
6
|
+
|
|
7
|
+
Setup (once): create an internal integration at notion.so/my-integrations,
|
|
8
|
+
share the pages you want with it (page menu > Connections), and put its secret
|
|
9
|
+
in the ``NOTION_TOKEN`` environment variable. Then::
|
|
10
|
+
|
|
11
|
+
python -m brainplug build --notion -o my_brain.html
|
|
12
|
+
|
|
13
|
+
Requests go from your computer straight to api.notion.com and nowhere else.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
from .sources import MAX_NOTE_BYTES, Item, parse_date
|
|
26
|
+
|
|
27
|
+
API = "https://api.notion.com/v1"
|
|
28
|
+
VERSION = "2022-06-28"
|
|
29
|
+
MAX_BLOCKS = 600 # per page
|
|
30
|
+
MAX_DEPTH = 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotionError(ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _call(token: str, method: str, url: str, body: dict | None = None, retries: int = 4) -> dict:
|
|
38
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
39
|
+
for attempt in range(retries + 1):
|
|
40
|
+
req = urllib.request.Request(url, data=data, method=method, headers={
|
|
41
|
+
"Authorization": f"Bearer {token}", "Notion-Version": VERSION,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
})
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req, timeout=30) as res:
|
|
46
|
+
return json.loads(res.read().decode("utf-8"))
|
|
47
|
+
except urllib.error.HTTPError as err:
|
|
48
|
+
if err.code == 429 and attempt < retries: # rate limit: wait as long as Notion asks
|
|
49
|
+
time.sleep(min(30.0, float(err.headers.get("Retry-After") or 1)))
|
|
50
|
+
continue
|
|
51
|
+
if err.code == 401:
|
|
52
|
+
raise NotionError("Notion rejected the token. Check NOTION_TOKEN.") from err
|
|
53
|
+
if err.code in (403, 404):
|
|
54
|
+
raise NotionError("Notion: that page is not shared with your integration.") from err
|
|
55
|
+
raise NotionError(f"Notion API error {err.code}.") from err
|
|
56
|
+
except urllib.error.URLError as err:
|
|
57
|
+
raise NotionError(f"Could not reach Notion: {err.reason}") from err
|
|
58
|
+
raise NotionError("Notion API: too many retries.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _plain(rich: list | None) -> str:
|
|
62
|
+
return "".join(str(part.get("plain_text", "")) for part in rich or [] if isinstance(part, dict))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _title(page: dict) -> str:
|
|
66
|
+
for prop in (page.get("properties") or {}).values():
|
|
67
|
+
if isinstance(prop, dict) and prop.get("type") == "title":
|
|
68
|
+
return _plain(prop.get("title")).strip()
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tags(page: dict) -> list[str]:
|
|
73
|
+
out = []
|
|
74
|
+
for prop in (page.get("properties") or {}).values():
|
|
75
|
+
if isinstance(prop, dict) and prop.get("type") == "multi_select":
|
|
76
|
+
out += [str(opt.get("name", "")) for opt in prop.get("multi_select") or []]
|
|
77
|
+
return sorted({t for t in out if t})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _blocks(token: str, base: str, block_id: str, depth: int, budget: list[int]) -> list[str]:
|
|
81
|
+
lines, cursor = [], None
|
|
82
|
+
while budget[0] > 0:
|
|
83
|
+
url = f"{base}/blocks/{block_id}/children?page_size=100" + (f"&start_cursor={cursor}" if cursor else "")
|
|
84
|
+
data = _call(token, "GET", url)
|
|
85
|
+
for block in data.get("results") or []:
|
|
86
|
+
budget[0] -= 1
|
|
87
|
+
kind = block.get("type")
|
|
88
|
+
if kind in ("child_page", "child_database"):
|
|
89
|
+
continue # those are pages of their own
|
|
90
|
+
body = block.get(kind) if isinstance(block.get(kind), dict) else {}
|
|
91
|
+
text = _plain(body.get("rich_text"))
|
|
92
|
+
if text.strip() and kind != "code":
|
|
93
|
+
lines.append(text)
|
|
94
|
+
if block.get("has_children") and depth < MAX_DEPTH and budget[0] > 0:
|
|
95
|
+
lines += _blocks(token, base, block["id"], depth + 1, budget)
|
|
96
|
+
cursor = data.get("next_cursor")
|
|
97
|
+
if not data.get("has_more") or not cursor:
|
|
98
|
+
break
|
|
99
|
+
return lines
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_notion(token: str, base: str = API, say: Callable[[str], None] | None = None,
|
|
103
|
+
limit: int = 5000) -> list[Item]:
|
|
104
|
+
"""Every page shared with the integration, with its real creation date."""
|
|
105
|
+
if not token:
|
|
106
|
+
raise NotionError("Set the NOTION_TOKEN environment variable to your integration secret.")
|
|
107
|
+
pages, cursor = [], None
|
|
108
|
+
while len(pages) < limit:
|
|
109
|
+
body = {"filter": {"property": "object", "value": "page"}, "page_size": 100}
|
|
110
|
+
if cursor:
|
|
111
|
+
body["start_cursor"] = cursor
|
|
112
|
+
data = _call(token, "POST", f"{base}/search", body)
|
|
113
|
+
pages += [p for p in data.get("results") or [] if not (p.get("archived") or p.get("in_trash"))]
|
|
114
|
+
cursor = data.get("next_cursor")
|
|
115
|
+
if not data.get("has_more") or not cursor:
|
|
116
|
+
break
|
|
117
|
+
items = []
|
|
118
|
+
for n, page in enumerate(pages[:limit], 1):
|
|
119
|
+
if say and n % 25 == 0:
|
|
120
|
+
say(f"Notion: {n}/{len(pages)} pages")
|
|
121
|
+
created = parse_date(page.get("created_time"))
|
|
122
|
+
if created is None:
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
lines = _blocks(token, base, page["id"], 0, [MAX_BLOCKS])
|
|
126
|
+
except NotionError:
|
|
127
|
+
continue # one unreadable page should not stop the rest
|
|
128
|
+
text = re.sub(r"\s+", " ", re.sub(r"https?://\S+", " ", " ".join(lines))).strip()
|
|
129
|
+
if len(text.split()) < 3:
|
|
130
|
+
continue
|
|
131
|
+
items.append(Item(
|
|
132
|
+
id=f"notion:{page['id'].replace('-', '')}", title=_title(page) or "Untitled", created=created,
|
|
133
|
+
source="notion", kind="note", text=text[:MAX_NOTE_BYTES], tags=_tags(page),
|
|
134
|
+
))
|
|
135
|
+
return items
|
brainplug/pipeline.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""One function that goes from your files to the finished HTML.
|
|
2
|
+
|
|
3
|
+
The command line (``build``), the sync command and the local app all call it,
|
|
4
|
+
so they always behave the same.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
from . import analysis, sources
|
|
17
|
+
from .render import render
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def local_tz(name: str | None = None):
|
|
21
|
+
"""A named time zone, or None for this computer's own zone. None (rather than
|
|
22
|
+
today's UTC offset) keeps summer and winter time right for every date."""
|
|
23
|
+
return ZoneInfo(name) if name else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Options:
|
|
28
|
+
inputs: list[str] = field(default_factory=list)
|
|
29
|
+
out: str = "brainplug.html"
|
|
30
|
+
me: list[str] = field(default_factory=list) # your email(s) and your WhatsApp name
|
|
31
|
+
pdf: bool = False # also read PDFs found inside folders
|
|
32
|
+
notion: bool = False # read Notion through its API (NOTION_TOKEN)
|
|
33
|
+
sessions: bool = False # also read Claude Code and Codex sessions on this computer
|
|
34
|
+
topics: int | None = None
|
|
35
|
+
private: bool = False
|
|
36
|
+
rename: dict[int, str] = field(default_factory=dict)
|
|
37
|
+
title: str = "My Brainplug"
|
|
38
|
+
lang: str = "en"
|
|
39
|
+
tz: str | None = None
|
|
40
|
+
vault: str | None = None # write Markdown notes into this Obsidian vault
|
|
41
|
+
json: str | None = None # also save the raw analysis as JSON
|
|
42
|
+
dataset: str | None = None # also save a redacted JSON Lines dataset
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def describe(items: list[sources.Item]) -> str:
|
|
46
|
+
counts: dict[str, int] = {}
|
|
47
|
+
for item in items:
|
|
48
|
+
counts[item.source] = counts.get(item.source, 0) + 1
|
|
49
|
+
parts = [f"{n} from {analysis.SOURCE_NAMES.get(s, s.title())}"
|
|
50
|
+
for s, n in sorted(counts.items(), key=lambda kv: -kv[1])]
|
|
51
|
+
return f"Loaded {len(items)} items" + (": " + ", ".join(parts) if parts else "") + "."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load(o: Options, say: Callable[[str], None] = print) -> list[sources.Item]:
|
|
55
|
+
notes: list[str] = []
|
|
56
|
+
inputs = list(o.inputs) + ([str(p) for p in sources.session_folders()] if o.sessions else [])
|
|
57
|
+
items = sources.load(inputs, me=o.me, pdf=o.pdf, notes=notes)
|
|
58
|
+
if o.notion:
|
|
59
|
+
from .notion_api import load_notion
|
|
60
|
+
known = {i.id for i in items}
|
|
61
|
+
items += [i for i in load_notion(os.environ.get("NOTION_TOKEN", ""), say=say) if i.id not in known]
|
|
62
|
+
items.sort(key=lambda i: i.created)
|
|
63
|
+
for note in notes:
|
|
64
|
+
say(note)
|
|
65
|
+
return items
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run(o: Options, say: Callable[[str], None] = print) -> tuple[list[sources.Item], dict]:
|
|
69
|
+
"""Load, analyse and write everything that was asked for."""
|
|
70
|
+
items = load(o, say)
|
|
71
|
+
say(describe(items))
|
|
72
|
+
payload = analysis.analyse(items, tz=local_tz(o.tz), n_topics=o.topics, private=o.private,
|
|
73
|
+
rename=o.rename, title=o.title, names=sources.topic_names_in(o.inputs))
|
|
74
|
+
say(f"Found {len(payload['topics'])} topics:")
|
|
75
|
+
for t in payload["topics"]:
|
|
76
|
+
say(f" {t['id'] + 1:>2}. {t['name']:<34} {t['count']:>5} items ({', '.join(t['terms'])})")
|
|
77
|
+
out = render(payload, o.out, lang=o.lang)
|
|
78
|
+
say(f"\nSaved {out.resolve()}")
|
|
79
|
+
if o.json:
|
|
80
|
+
Path(o.json).parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
Path(o.json).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
82
|
+
say(f"Saved {Path(o.json).resolve()}")
|
|
83
|
+
if o.vault:
|
|
84
|
+
from .export import export_vault
|
|
85
|
+
n = export_vault(items, payload, o.vault)
|
|
86
|
+
say(f"Wrote {n} notes to {Path(o.vault).resolve() / 'Brainplug'}")
|
|
87
|
+
if o.dataset:
|
|
88
|
+
from .export import export_dataset
|
|
89
|
+
n = export_dataset(items, payload, o.dataset)
|
|
90
|
+
say(f"Wrote {n} redacted items to {Path(o.dataset).resolve()}")
|
|
91
|
+
say("Redaction is best effort (emails, links, phone and long numbers). Read the file before sharing it.")
|
|
92
|
+
if o.private:
|
|
93
|
+
say("Private mode: titles and text are not in the HTML. Topic names still are,")
|
|
94
|
+
say("so check them (and use --rename) before sharing.")
|
|
95
|
+
return items, payload
|
brainplug/render.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Fill the HTML template with the analysis results.
|
|
2
|
+
|
|
3
|
+
The output is a single self-contained file: the data, three.js and the font are
|
|
4
|
+
all inlined, so it opens offline and nothing is ever sent anywhere.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
HERE = Path(__file__).parent
|
|
15
|
+
TEMPLATE = HERE / "templates" / "viewer.html"
|
|
16
|
+
THREE = HERE / "vendor" / "three.module.min.js"
|
|
17
|
+
FONT = HERE / "vendor" / "inter-latin.woff2"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def template_html() -> str:
|
|
21
|
+
"""The viewer with three.js already inlined and the data still to fill in.
|
|
22
|
+
The web app (docs/app) ships this file and fills it in the browser."""
|
|
23
|
+
return (
|
|
24
|
+
TEMPLATE.read_text(encoding="utf-8")
|
|
25
|
+
.replace("__FONT__", base64.b64encode(FONT.read_bytes()).decode("ascii"))
|
|
26
|
+
.replace("__THREE__", THREE.read_text(encoding="utf-8"))
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_html(payload: dict, lang: str = "en") -> str:
|
|
31
|
+
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
32
|
+
# Your text may contain "</script>" or "<!--" (notes about code do). Written as \u003c,
|
|
33
|
+
# a "<" can never end or confuse the <script> block the data sits in.
|
|
34
|
+
data = data.replace("<", "\\u003c")
|
|
35
|
+
return (
|
|
36
|
+
template_html()
|
|
37
|
+
.replace("__LANG__", "es" if lang == "es" else "en")
|
|
38
|
+
.replace("__TITLE__", html.escape(payload["meta"]["title"]))
|
|
39
|
+
.replace("__DATA__", data) # last, so your text is never scanned for placeholders
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render(payload: dict, out: str | Path, lang: str = "en") -> Path:
|
|
44
|
+
out = Path(out)
|
|
45
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
out.write_text(render_html(payload, lang), encoding="utf-8")
|
|
47
|
+
return out
|