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.
@@ -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