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/sources.py
ADDED
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
"""Read everything you have written into one common format.
|
|
2
|
+
|
|
3
|
+
Supported inputs (files, folders or ZIPs, in any mix):
|
|
4
|
+
|
|
5
|
+
* ChatGPT and Claude exports -> ``conversations.json`` (or the export ZIP)
|
|
6
|
+
* Gemini via Google Takeout -> ``MyActivity.json`` (or the Takeout ZIP)
|
|
7
|
+
* Live capture -> the JSON saved by the Brainplug browser extension
|
|
8
|
+
* Claude Code and Codex -> their session logs (``~/.claude/projects``, ``~/.codex/sessions``):
|
|
9
|
+
what you typed, never the agent's work, with pasted keys masked
|
|
10
|
+
* Obsidian vaults / any folder of Markdown or text notes
|
|
11
|
+
* Notion exports ("Markdown & CSV"); see ``notion_api`` for real creation dates
|
|
12
|
+
* Gmail via Google Takeout -> ``.mbox`` (only the mail *you* sent)
|
|
13
|
+
* WhatsApp chat exports -> ``.txt`` or the ZIP (only the messages *you* sent)
|
|
14
|
+
* Word and PDF documents -> ``.docx``, ``.pdf``
|
|
15
|
+
* Memories saved by your AI -> the notes in ``Brainplug/Memory`` (see ``memory``)
|
|
16
|
+
|
|
17
|
+
Every loader returns ``Item`` objects that hold **only your own words**:
|
|
18
|
+
your side of a chat, your notes, the text you typed in an email (quoted
|
|
19
|
+
replies and signatures are removed), your side of a WhatsApp chat. Word and
|
|
20
|
+
PDF files are the exception: they are read whole, so only add your own.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import copy
|
|
26
|
+
import email
|
|
27
|
+
import html
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import tempfile
|
|
32
|
+
import zipfile
|
|
33
|
+
import zlib
|
|
34
|
+
from collections import Counter
|
|
35
|
+
from dataclasses import dataclass, field, replace
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from email import policy
|
|
38
|
+
from email.utils import getaddresses, parsedate_to_datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any, Callable, Iterable, Iterator
|
|
41
|
+
|
|
42
|
+
NOTE_SUFFIXES = {".md", ".markdown", ".txt"}
|
|
43
|
+
SKIP_DIRS = {"node_modules", "templates", "Templates", "__MACOSX"}
|
|
44
|
+
MAX_NOTE_BYTES = 400_000
|
|
45
|
+
MAX_ZIP_DEPTH = 4 # the top-level ZIP is depth 1
|
|
46
|
+
MAX_ZIP_ENTRIES = 500_000
|
|
47
|
+
ZIP_RATIO_MIN_BYTES = 100 * 1024**2
|
|
48
|
+
MAX_ZIP_RATIO = 1000
|
|
49
|
+
MAX_ZIP_BYTES = 50 * 1024**3 # shared by all descendants of one top-level ZIP
|
|
50
|
+
MAX_ZIP_DIRECTORY_BYTES = 128 * 1024**2
|
|
51
|
+
ZIP_COPY_CHUNK = 1024**2
|
|
52
|
+
NOTION_ID = re.compile(r"\s[0-9a-f]{32}$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Item:
|
|
57
|
+
"""One thing you wrote: an AI chat, a note or document, or messages to people."""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
title: str
|
|
61
|
+
created: datetime
|
|
62
|
+
source: str # chatgpt, claude, gemini, obsidian, notion, notes, word, pdf, gmail, whatsapp…
|
|
63
|
+
kind: str # chat (with an AI), note (notes and documents), message (mail, WhatsApp)
|
|
64
|
+
text: str # only your own words
|
|
65
|
+
parts: list[datetime] = field(default_factory=list) # one per message you wrote
|
|
66
|
+
size: int = 1 # messages in the thread (both sides); 1 for a note
|
|
67
|
+
links: list[str] = field(default_factory=list) # titles this item links to
|
|
68
|
+
tags: list[str] = field(default_factory=list)
|
|
69
|
+
dated: bool = True # False when the source carries no real date
|
|
70
|
+
msgs: list[str] = field(default_factory=list) # chats: each message you wrote, in step with parts
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def words(self) -> int:
|
|
74
|
+
return len(self.text.split())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------------- #
|
|
78
|
+
# dates
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
_MONTHS = {}
|
|
81
|
+
for _i, _names in enumerate([
|
|
82
|
+
"january enero jan ene", "february febrero feb", "march marzo mar", "april abril apr abr",
|
|
83
|
+
"may mayo", "june junio jun", "july julio jul", "august agosto aug ago",
|
|
84
|
+
"september septiembre sep sept", "october octubre oct", "november noviembre nov",
|
|
85
|
+
"december diciembre dec dic",
|
|
86
|
+
], start=1):
|
|
87
|
+
for _n in _names.split():
|
|
88
|
+
_MONTHS[_n] = _i
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _aware(dt: datetime) -> datetime:
|
|
92
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _from_epoch(value: Any) -> datetime | None:
|
|
96
|
+
try:
|
|
97
|
+
return datetime.fromtimestamp(float(value), tz=timezone.utc) if value else None
|
|
98
|
+
except (TypeError, ValueError, OSError, OverflowError):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_date(value: Any) -> datetime | None:
|
|
103
|
+
"""Tolerant date parser: ISO, 03/03/2025, 'March 3, 2025 10:00 AM', '3 de marzo de 2025'."""
|
|
104
|
+
if value in (None, "", []):
|
|
105
|
+
return None
|
|
106
|
+
s = str(value).strip().strip("\"'")
|
|
107
|
+
try:
|
|
108
|
+
return _aware(datetime.fromisoformat(s.replace("Z", "+00:00")))
|
|
109
|
+
except ValueError:
|
|
110
|
+
pass
|
|
111
|
+
hour = minute = 0
|
|
112
|
+
t = re.search(r"(\d{1,2}):(\d{2})(?::\d{2})?\s*([AaPp])?", s)
|
|
113
|
+
if t:
|
|
114
|
+
hour, minute = int(t.group(1)) % 24, int(t.group(2))
|
|
115
|
+
if t.group(3): # 12-hour clock
|
|
116
|
+
hour = hour % 12 + (12 if t.group(3).lower() == "p" else 0)
|
|
117
|
+
y = mo = d = None
|
|
118
|
+
m = re.search(r"(\d{4})-(\d{1,2})-(\d{1,2})", s)
|
|
119
|
+
if m:
|
|
120
|
+
y, mo, d = map(int, m.groups())
|
|
121
|
+
if y is None:
|
|
122
|
+
m = re.search(r"(\d{1,2})[/.](\d{1,2})[/.](\d{4})", s)
|
|
123
|
+
if m:
|
|
124
|
+
d, mo, y = map(int, m.groups())
|
|
125
|
+
if y is None:
|
|
126
|
+
m = re.search(r"([^\W\d_]+)\.?\s+(\d{1,2}),?\s+(\d{4})", s)
|
|
127
|
+
if m and m.group(1).lower() in _MONTHS:
|
|
128
|
+
mo, d, y = _MONTHS[m.group(1).lower()], int(m.group(2)), int(m.group(3))
|
|
129
|
+
if y is None:
|
|
130
|
+
m = re.search(r"(\d{1,2})\s+(?:de\s+)?([^\W\d_]+)\.?,?\s+(?:de\s+)?(\d{4})", s)
|
|
131
|
+
if m and m.group(2).lower() in _MONTHS:
|
|
132
|
+
d, mo, y = int(m.group(1)), _MONTHS[m.group(2).lower()], int(m.group(3))
|
|
133
|
+
if y is None:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
return datetime(y, mo, d, hour, minute, tzinfo=timezone.utc)
|
|
137
|
+
except ValueError:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --------------------------------------------------------------------------- #
|
|
142
|
+
# AI chats: ChatGPT and Claude
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
def _chatgpt_text(content: dict) -> str:
|
|
145
|
+
out = []
|
|
146
|
+
for part in content.get("parts") or []:
|
|
147
|
+
if isinstance(part, str):
|
|
148
|
+
out.append(part)
|
|
149
|
+
elif isinstance(part, dict) and isinstance(part.get("text"), str):
|
|
150
|
+
out.append(part["text"])
|
|
151
|
+
if not out and isinstance(content.get("text"), str):
|
|
152
|
+
out.append(content["text"])
|
|
153
|
+
return "\n".join(p for p in out if p.strip()).strip()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_chatgpt(data: list[dict]) -> list[Item]:
|
|
157
|
+
items = []
|
|
158
|
+
for i, conv in enumerate(data):
|
|
159
|
+
mine, total = [], 0
|
|
160
|
+
for node in (conv.get("mapping") or {}).values():
|
|
161
|
+
msg = (node or {}).get("message")
|
|
162
|
+
if not msg:
|
|
163
|
+
continue
|
|
164
|
+
role = (msg.get("author") or {}).get("role")
|
|
165
|
+
if role not in ("user", "assistant"):
|
|
166
|
+
continue
|
|
167
|
+
text = _chatgpt_text(msg.get("content") or {})
|
|
168
|
+
if not text:
|
|
169
|
+
continue
|
|
170
|
+
total += 1
|
|
171
|
+
if role == "user":
|
|
172
|
+
mine.append((_from_epoch(msg.get("create_time")), text))
|
|
173
|
+
created = _from_epoch(conv.get("create_time")) or next((t for t, _ in mine if t), None)
|
|
174
|
+
if created is None or not mine:
|
|
175
|
+
continue
|
|
176
|
+
mine.sort(key=lambda m: m[0] or created)
|
|
177
|
+
items.append(Item(
|
|
178
|
+
id=f"chatgpt:{conv.get('id') or conv.get('conversation_id') or i}",
|
|
179
|
+
title=(conv.get("title") or "Untitled").strip() or "Untitled",
|
|
180
|
+
created=created, source="chatgpt", kind="chat",
|
|
181
|
+
text="\n".join(t for _, t in mine),
|
|
182
|
+
parts=[t or created for t, _ in mine], size=total, msgs=[t for _, t in mine],
|
|
183
|
+
))
|
|
184
|
+
return items
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_claude(data: list[dict]) -> list[Item]:
|
|
188
|
+
items = []
|
|
189
|
+
for i, conv in enumerate(data):
|
|
190
|
+
mine, total = [], 0
|
|
191
|
+
for msg in conv.get("chat_messages") or []:
|
|
192
|
+
sender = msg.get("sender")
|
|
193
|
+
if sender not in ("human", "assistant"):
|
|
194
|
+
continue
|
|
195
|
+
text = (msg.get("text") or "").strip()
|
|
196
|
+
if not text:
|
|
197
|
+
text = "\n".join(
|
|
198
|
+
c.get("text", "") for c in msg.get("content") or []
|
|
199
|
+
if isinstance(c, dict) and c.get("type") == "text"
|
|
200
|
+
).strip()
|
|
201
|
+
if not text:
|
|
202
|
+
continue
|
|
203
|
+
total += 1
|
|
204
|
+
if sender == "human":
|
|
205
|
+
mine.append((parse_date(msg.get("created_at")), text))
|
|
206
|
+
created = parse_date(conv.get("created_at")) or next((t for t, _ in mine if t), None)
|
|
207
|
+
if created is None or not mine:
|
|
208
|
+
continue
|
|
209
|
+
items.append(Item(
|
|
210
|
+
id=f"claude:{conv.get('uuid') or i}",
|
|
211
|
+
title=(conv.get("name") or "").strip() or "Untitled",
|
|
212
|
+
created=created, source="claude", kind="chat",
|
|
213
|
+
text="\n".join(t for _, t in mine),
|
|
214
|
+
parts=[t or created for t, _ in mine], size=total, msgs=[t for _, t in mine],
|
|
215
|
+
))
|
|
216
|
+
return items
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --------------------------------------------------------------------------- #
|
|
220
|
+
# notes: Obsidian, Notion and plain folders
|
|
221
|
+
# --------------------------------------------------------------------------- #
|
|
222
|
+
def _frontmatter(text: str) -> tuple[dict, str]:
|
|
223
|
+
if not text.startswith("---"):
|
|
224
|
+
return {}, text
|
|
225
|
+
end = text.find("\n---", 3)
|
|
226
|
+
if end < 0:
|
|
227
|
+
return {}, text
|
|
228
|
+
meta: dict[str, Any] = {}
|
|
229
|
+
key = None
|
|
230
|
+
for line in text[3:end].splitlines():
|
|
231
|
+
if key and re.match(r"^\s*-\s+\S", line):
|
|
232
|
+
if not isinstance(meta.get(key), list):
|
|
233
|
+
meta[key] = []
|
|
234
|
+
meta[key].append(line.split("-", 1)[1].strip().strip("\"'"))
|
|
235
|
+
elif ":" in line and not line[:1].isspace():
|
|
236
|
+
key, _, val = line.partition(":")
|
|
237
|
+
key, val = key.strip().lower(), val.strip()
|
|
238
|
+
if val.startswith("[") and val.endswith("]"):
|
|
239
|
+
meta[key] = [v.strip().strip("\"'") for v in val[1:-1].split(",") if v.strip()]
|
|
240
|
+
else:
|
|
241
|
+
meta[key] = val.strip("\"'")
|
|
242
|
+
return meta, text[end + 4:]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def clean_markdown(body: str) -> tuple[str, list[str], list[str]]:
|
|
246
|
+
"""Return (plain text, linked titles, tags) for a Markdown note."""
|
|
247
|
+
body = re.sub(r"```.*?```", " ", body, flags=re.S)
|
|
248
|
+
links = re.findall(r"(?<!!)\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]", body)
|
|
249
|
+
links += re.findall(r"\[([^\]]+)\]\([^)\s]*[0-9a-f]{32}\.md\)", body) # Notion page links
|
|
250
|
+
body = re.sub(r"!\[\[[^\]]*\]\]|!\[[^\]]*\]\([^)]*\)", " ", body) # embeds and images
|
|
251
|
+
body = re.sub(r"\[\[[^\]|]*\|([^\]]+)\]\]", r"\1", body)
|
|
252
|
+
body = re.sub(r"\[\[([^\]]+)\]\]", r"\1", body)
|
|
253
|
+
body = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", body)
|
|
254
|
+
body = re.sub(r"https?://\S+|<[^>\n]+>", " ", body)
|
|
255
|
+
tags = re.findall(r"(?<![\w/&])#([^\W\d_][\w/-]*)", body)
|
|
256
|
+
body = re.sub(r"(?m)^\s*(#{1,6}|[-*+>]|\d+[.)])\s+", "", body)
|
|
257
|
+
body = re.sub(r"[*_~`|]{1,3}|(?<![\w/&])#(?=[^\W\d_])", " ", body)
|
|
258
|
+
text = re.sub(r"\s+", " ", body).strip()
|
|
259
|
+
return text, [l.strip() for l in links if l.strip()], tags
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _file_date(path: Path) -> datetime:
|
|
263
|
+
st = path.stat()
|
|
264
|
+
stamps = [st.st_mtime]
|
|
265
|
+
birth = getattr(st, "st_birthtime", None) or (st.st_ctime if os.name == "nt" else None)
|
|
266
|
+
if birth:
|
|
267
|
+
stamps.append(birth)
|
|
268
|
+
return datetime.fromtimestamp(min(stamps), tz=timezone.utc)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _read(path: Path) -> str:
|
|
272
|
+
with open(path, "rb") as fh:
|
|
273
|
+
return fh.read(MAX_NOTE_BYTES).decode("utf-8", errors="ignore")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_DATE_KEYS = ("created", "date created", "created time", "created at", "created_at", "creado",
|
|
277
|
+
"fecha de creación", "date", "fecha", "last edited time", "last edited", "updated")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _meta_date(meta: dict) -> datetime | None:
|
|
281
|
+
for key in _DATE_KEYS:
|
|
282
|
+
if key in meta:
|
|
283
|
+
found = parse_date(meta[key])
|
|
284
|
+
if found:
|
|
285
|
+
return found
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# Notes Brainplug wrote itself (--vault) are a copy of things already read, so they are
|
|
290
|
+
# skipped; the memory folder and "About me" are read as the source "memory".
|
|
291
|
+
_GENERATED = re.compile(r"(?:^|/)Brainplug/(?:Items/|Topics/|Brainplug\.md$|Excluded\.md$|Topic names\.md$)")
|
|
292
|
+
_MEMORY = re.compile(r"(?:^|/)Brainplug/(?:Memory/|About me\.md$)")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def parse_note(path: Path, rel: Path, source: str) -> Item | None:
|
|
296
|
+
meta, body = _frontmatter(_read(path))
|
|
297
|
+
if source == "memory":
|
|
298
|
+
if str(meta.get("archived", "")).lower() == "true":
|
|
299
|
+
return None # a memory you (or an assistant) put away
|
|
300
|
+
if "brainplug: fill this in" in body and not re.search(r"(?m)^- [^:\n]+:[ \t]*\S", body):
|
|
301
|
+
return None # "About me" is still the empty template
|
|
302
|
+
text, links, tags = clean_markdown(body)
|
|
303
|
+
if len(text.split()) < 3:
|
|
304
|
+
return None
|
|
305
|
+
created = _meta_date(meta)
|
|
306
|
+
if created is None:
|
|
307
|
+
m = re.match(r"(\d{4})-(\d{2})-(\d{2})", path.stem) # daily notes
|
|
308
|
+
created = parse_date(m.group(0)) if m else None
|
|
309
|
+
extra = meta.get("tags") or []
|
|
310
|
+
extra = extra if isinstance(extra, list) else re.split(r"[,\s]+", str(extra))
|
|
311
|
+
return Item(
|
|
312
|
+
id=f"{source}:{rel.as_posix()}", title=path.stem, created=created or _file_date(path),
|
|
313
|
+
source=source, kind="note", text=text, links=links,
|
|
314
|
+
tags=sorted({t.lstrip("#") for t in [*tags, *extra] if t}),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def parse_notion_page(path: Path, rel: Path) -> Item | None:
|
|
319
|
+
lines = _read(path).splitlines()
|
|
320
|
+
title = NOTION_ID.sub("", path.stem)
|
|
321
|
+
i = 0
|
|
322
|
+
if lines and lines[0].startswith("# "):
|
|
323
|
+
title = lines[0][2:].strip() or title
|
|
324
|
+
i = 1
|
|
325
|
+
while i < len(lines) and not lines[i].strip():
|
|
326
|
+
i += 1
|
|
327
|
+
props: dict[str, str] = {} # database pages start with "Property: value" lines
|
|
328
|
+
while i < len(lines) and re.match(r"^[^\W\d_][^:\n]{0,40}:\s+\S", lines[i]):
|
|
329
|
+
key, _, val = lines[i].partition(":")
|
|
330
|
+
props[key.strip().lower()] = val.strip()
|
|
331
|
+
i += 1
|
|
332
|
+
text, links, tags = clean_markdown("\n".join(lines[i:]))
|
|
333
|
+
if len(text.split()) < 3:
|
|
334
|
+
return None
|
|
335
|
+
created = _meta_date(props)
|
|
336
|
+
for key in ("tags", "etiquetas", "tag"):
|
|
337
|
+
tags += [t.strip() for t in props.get(key, "").split(",") if t.strip()]
|
|
338
|
+
return Item(
|
|
339
|
+
id=f"notion:{rel.as_posix()}", title=title, created=created or _file_date(path),
|
|
340
|
+
source="notion", kind="note", text=text, links=links, tags=sorted(set(tags)),
|
|
341
|
+
dated=created is not None,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --------------------------------------------------------------------------- #
|
|
346
|
+
# Gmail (Google Takeout .mbox)
|
|
347
|
+
# --------------------------------------------------------------------------- #
|
|
348
|
+
def _mbox_iter(path: Path, want_body: Callable[[bytes], bool]) -> Iterator[tuple[bytes, bytes, bool]]:
|
|
349
|
+
"""Stream an mbox file as (headers, body, kept). The body is only read into
|
|
350
|
+
memory when ``want_body(headers)`` is true, so huge mailboxes stay cheap."""
|
|
351
|
+
headers: list[bytes] = []
|
|
352
|
+
body: list[bytes] = []
|
|
353
|
+
started = in_headers = keep = False
|
|
354
|
+
prev_blank = True
|
|
355
|
+
with open(path, "rb") as fh:
|
|
356
|
+
for line in fh:
|
|
357
|
+
blank = line in (b"\n", b"\r\n")
|
|
358
|
+
if prev_blank and line.startswith(b"From "):
|
|
359
|
+
if started:
|
|
360
|
+
yield b"".join(headers), b"".join(body), keep
|
|
361
|
+
headers, body, started, in_headers, keep = [], [], True, True, False
|
|
362
|
+
elif started and in_headers:
|
|
363
|
+
if blank:
|
|
364
|
+
in_headers = False
|
|
365
|
+
keep = want_body(b"".join(headers))
|
|
366
|
+
else:
|
|
367
|
+
headers.append(line)
|
|
368
|
+
elif started and keep:
|
|
369
|
+
body.append(line)
|
|
370
|
+
prev_blank = blank
|
|
371
|
+
if started:
|
|
372
|
+
yield b"".join(headers), b"".join(body), keep
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _addresses(msg, name: str) -> list[str]:
|
|
376
|
+
try:
|
|
377
|
+
return [a.lower() for _, a in getaddresses([str(msg.get(name, ""))]) if a]
|
|
378
|
+
except Exception: # malformed header
|
|
379
|
+
return []
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _headers(raw: bytes):
|
|
383
|
+
try:
|
|
384
|
+
return email.message_from_bytes(raw, policy=policy.default)
|
|
385
|
+
except Exception:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
_REPLY = re.compile(
|
|
390
|
+
r"^(?:On|El|Le)\b[^\n]{0,200}\n?[^\n]{0,120}(?:wrote|escribió|a écrit)\s?:\s*$"
|
|
391
|
+
r"|^-{2,}\s*(?:Original Message|Mensaje original|Forwarded message|Mensaje reenviado)\s*-{2,}\s*$"
|
|
392
|
+
r"|^(?:From|De):\s.+\n(?:Sent|Enviado|Date|Fecha)(?: el)?:\s"
|
|
393
|
+
r"|^(?:Sent from my|Enviado desde mi)\s.+$"
|
|
394
|
+
r"|^-- ?$",
|
|
395
|
+
re.M | re.I,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def strip_quoted(text: str) -> str:
|
|
400
|
+
"""Keep only what the sender typed: drop quoted replies and signatures."""
|
|
401
|
+
text = text.replace("\r\n", "\n")
|
|
402
|
+
m = _REPLY.search(text)
|
|
403
|
+
if m:
|
|
404
|
+
text = text[: m.start()]
|
|
405
|
+
lines = [l for l in text.split("\n") if not l.lstrip().startswith(">")]
|
|
406
|
+
return re.sub(r"\s+", " ", re.sub(r"https?://\S+", " ", "\n".join(lines))).strip()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _html_to_text(markup: str) -> str:
|
|
410
|
+
markup = re.sub(r"(?is)<(script|style|blockquote)\b.*?</\1>", " ", markup)
|
|
411
|
+
markup = re.sub(r"(?i)<br\s*/?>|</p>|</div>", "\n", markup)
|
|
412
|
+
return html.unescape(re.sub(r"<[^>]+>", " ", markup))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def load_gmail(path: Path, me: Iterable[str] | None = None,
|
|
416
|
+
notes: list[str] | None = None) -> list[Item]:
|
|
417
|
+
"""Read the mail *you sent* from a Takeout mbox, grouped by thread."""
|
|
418
|
+
mine = {a.lower() for a in (me or [])}
|
|
419
|
+
if not mine:
|
|
420
|
+
# Your own address is the one mail is delivered to (or, in a Sent-only
|
|
421
|
+
# mailbox, the one everything comes from).
|
|
422
|
+
seen: Counter = Counter()
|
|
423
|
+
for raw, _, _ in _mbox_iter(path, lambda h: False):
|
|
424
|
+
msg = _headers(raw)
|
|
425
|
+
if msg is not None:
|
|
426
|
+
seen.update(_addresses(msg, "From") + _addresses(msg, "Delivered-To"))
|
|
427
|
+
if not seen:
|
|
428
|
+
return []
|
|
429
|
+
ranked = seen.most_common(2)
|
|
430
|
+
address, count = ranked[0]
|
|
431
|
+
mine = {address}
|
|
432
|
+
reasons = []
|
|
433
|
+
if count * 2 < sum(seen.values()):
|
|
434
|
+
reasons.append("the address appears in fewer than half of the counted addresses")
|
|
435
|
+
# A runner-up within 20% of the leader is too close to trust silently.
|
|
436
|
+
if len(ranked) > 1 and ranked[1][1] * 5 >= count * 4:
|
|
437
|
+
reasons.append("two addresses have similar counts")
|
|
438
|
+
unsure = f"; unsure: {'; '.join(reasons)}" if reasons else ""
|
|
439
|
+
remark = f"Gmail: kept the mail sent from {address} (detected{unsure}; to change it, give your address in “Who you are” or with --me)."
|
|
440
|
+
if notes is not None and remark not in notes:
|
|
441
|
+
notes.append(remark)
|
|
442
|
+
|
|
443
|
+
def sent_by_me(raw: bytes) -> bool:
|
|
444
|
+
msg = _headers(raw)
|
|
445
|
+
return msg is not None and any(a in mine for a in _addresses(msg, "From"))
|
|
446
|
+
|
|
447
|
+
threads: dict[str, dict] = {}
|
|
448
|
+
for raw, body, kept in _mbox_iter(path, sent_by_me):
|
|
449
|
+
if not kept:
|
|
450
|
+
continue
|
|
451
|
+
try:
|
|
452
|
+
msg = email.message_from_bytes(raw + b"\n" + body, policy=policy.default)
|
|
453
|
+
part = msg.get_body(preferencelist=("plain", "html"))
|
|
454
|
+
text = part.get_content() if part is not None else ""
|
|
455
|
+
if part is not None and part.get_content_type() == "text/html":
|
|
456
|
+
text = _html_to_text(text)
|
|
457
|
+
when = _aware(parsedate_to_datetime(str(msg["Date"])))
|
|
458
|
+
subject = str(msg.get("Subject", "") or "")
|
|
459
|
+
except Exception:
|
|
460
|
+
continue
|
|
461
|
+
text = strip_quoted(str(text))[:MAX_NOTE_BYTES]
|
|
462
|
+
if not text:
|
|
463
|
+
continue
|
|
464
|
+
subject = re.sub(r"^\s*(?:(?:re|fwd?|rv|fw|tr)\s*:\s*)+", "", subject, flags=re.I).strip()
|
|
465
|
+
key = str(msg.get("X-GM-THRID") or "").strip() or subject.lower()
|
|
466
|
+
thread = threads.setdefault(key, {"subject": subject, "parts": []})
|
|
467
|
+
thread["parts"].append((when, text))
|
|
468
|
+
|
|
469
|
+
items = []
|
|
470
|
+
for key, thread in threads.items():
|
|
471
|
+
parts = sorted(thread["parts"], key=lambda p: p[0])
|
|
472
|
+
text = "\n".join(t for _, t in parts)
|
|
473
|
+
if len(text.split()) < 3:
|
|
474
|
+
continue
|
|
475
|
+
items.append(Item(
|
|
476
|
+
id=f"gmail:{key}", title=thread["subject"] or "(no subject)", created=parts[0][0],
|
|
477
|
+
source="gmail", kind="message", text=text, parts=[w for w, _ in parts], size=len(parts),
|
|
478
|
+
))
|
|
479
|
+
return items
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# --------------------------------------------------------------------------- #
|
|
483
|
+
# Gemini (Google Takeout > My Activity > Gemini Apps)
|
|
484
|
+
# --------------------------------------------------------------------------- #
|
|
485
|
+
_GEMINI_PREFIX = re.compile(
|
|
486
|
+
r"^(?:Prompted|Said|Asked|You said|Has preguntado|Preguntaste|Has dicho|Dijiste|Le has dicho|"
|
|
487
|
+
r"Le has preguntado|Solicitaste|Has pedido)[:\s ]+", re.I)
|
|
488
|
+
_GEMINI_SKIP = re.compile(r"^(?:Used|Gave feedback|Selected|Created|Usaste|Has usado|Has utilizado|"
|
|
489
|
+
r"Enviaste comentarios|Has enviado comentarios)\b", re.I)
|
|
490
|
+
GEMINI_GAP = 30 * 60 # seconds without a prompt before a new chat starts
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _is_gemini(entry: Any) -> bool:
|
|
494
|
+
if not isinstance(entry, dict) or "time" not in entry or "title" not in entry:
|
|
495
|
+
return False
|
|
496
|
+
tag = " ".join([str(entry.get("header", "")), *map(str, entry.get("products") or [])]).lower()
|
|
497
|
+
return "gemini" in tag or "bard" in tag
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _gemini_items(prompts: list[tuple[datetime, str, str]]) -> list[Item]:
|
|
501
|
+
"""Takeout lists single prompts, so chats are rebuilt: by the conversation id
|
|
502
|
+
when the entry has one, otherwise by time (a new chat after a 30 minute gap)."""
|
|
503
|
+
prompts.sort(key=lambda p: p[0])
|
|
504
|
+
groups: dict[tuple, list] = {}
|
|
505
|
+
last, session = None, 0
|
|
506
|
+
for when, text, conv in prompts:
|
|
507
|
+
if conv:
|
|
508
|
+
key = (conv,)
|
|
509
|
+
else:
|
|
510
|
+
if last is None or (when - last).total_seconds() > GEMINI_GAP:
|
|
511
|
+
session += 1
|
|
512
|
+
last, key = when, (session, "by time")
|
|
513
|
+
groups.setdefault(key, []).append((when, text))
|
|
514
|
+
items = []
|
|
515
|
+
for key, turns in groups.items():
|
|
516
|
+
title = " ".join(turns[0][1].split())
|
|
517
|
+
items.append(Item(
|
|
518
|
+
id=f"gemini:{key[0]}" if len(key) == 1 else f"gemini:{turns[0][0]:%Y%m%dT%H%M%S}",
|
|
519
|
+
title=title[:70] + ("…" if len(title) > 70 else ""), created=turns[0][0],
|
|
520
|
+
source="gemini", kind="chat", text="\n".join(t for _, t in turns),
|
|
521
|
+
parts=[w for w, _ in turns], size=2 * len(turns), msgs=[t for _, t in turns],
|
|
522
|
+
))
|
|
523
|
+
return items
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def parse_gemini(data: list[dict]) -> list[Item]:
|
|
527
|
+
prompts = []
|
|
528
|
+
for entry in data:
|
|
529
|
+
if not _is_gemini(entry):
|
|
530
|
+
continue
|
|
531
|
+
when = parse_date(entry.get("time"))
|
|
532
|
+
text = ""
|
|
533
|
+
for detail in entry.get("details") or []: # older exports: {"name": "Request", "value": …}
|
|
534
|
+
if isinstance(detail, dict) and str(detail.get("name", "")).lower() in ("request", "prompt", "solicitud"):
|
|
535
|
+
text = str(detail.get("value") or "")
|
|
536
|
+
if not text:
|
|
537
|
+
title = html.unescape(str(entry.get("title") or "")).strip()
|
|
538
|
+
if _GEMINI_SKIP.match(title):
|
|
539
|
+
continue
|
|
540
|
+
text = _GEMINI_PREFIX.sub("", title)
|
|
541
|
+
text = text.strip()
|
|
542
|
+
if when is None or not text:
|
|
543
|
+
continue
|
|
544
|
+
conv = re.search(r"/app/(?:c/)?([0-9a-zA-Z_-]{6,})", str(entry.get("titleUrl") or ""))
|
|
545
|
+
prompts.append((when, text, conv.group(1) if conv else ""))
|
|
546
|
+
return _gemini_items(prompts)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def parse_gemini_html(markup: str) -> list[Item]:
|
|
550
|
+
"""The default Takeout format is HTML. Best effort: JSON is the reliable one."""
|
|
551
|
+
prompts = []
|
|
552
|
+
for cell in markup.split('<div class="outer-cell')[1:]:
|
|
553
|
+
if not re.search(r"Gemini|Bard", cell[:600]):
|
|
554
|
+
continue
|
|
555
|
+
m = re.search(r'content-cell[^"]*mdl-typography--body-1">(.*?)<br\s*/?>(.*?)<br', cell, re.S)
|
|
556
|
+
if not m:
|
|
557
|
+
continue
|
|
558
|
+
title = html.unescape(re.sub(r"<[^>]+>", " ", m.group(1))).replace(" ", " ").strip()
|
|
559
|
+
when = parse_date(html.unescape(re.sub(r"<[^>]+>", " ", m.group(2))).replace(" ", " "))
|
|
560
|
+
if when is None or _GEMINI_SKIP.match(title):
|
|
561
|
+
continue
|
|
562
|
+
text = _GEMINI_PREFIX.sub("", title).strip()
|
|
563
|
+
if text:
|
|
564
|
+
prompts.append((when, text, ""))
|
|
565
|
+
return _gemini_items(prompts)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# --------------------------------------------------------------------------- #
|
|
569
|
+
# coding agents: Claude Code and Codex sessions (what you typed, not the agent's work)
|
|
570
|
+
# --------------------------------------------------------------------------- #
|
|
571
|
+
# Text the tools put on your side of a session: environment blocks, instructions, command echoes.
|
|
572
|
+
_INJECTED = re.compile(
|
|
573
|
+
r"^\s*(?:<(?:environment_context|user_instructions|user_shell_command|turn_aborted|system-reminder|task-notification"
|
|
574
|
+
r"|user-prompt-submit-hook|command-[a-z]+|local-command-[a-z]+|bash-[a-z]+)\b|# AGENTS\.md instructions"
|
|
575
|
+
r"|Caveat: The messages below|\[Request interrupted)", re.I)
|
|
576
|
+
# Keys and tokens people paste into a coding session by mistake.
|
|
577
|
+
SECRET = re.compile(
|
|
578
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"
|
|
579
|
+
r"|\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|AKIA[0-9A-Z]{16}"
|
|
580
|
+
r"|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _typed(text: Any) -> str:
|
|
584
|
+
"""What you typed to a coding agent, with pasted keys masked; "" for text the tool added."""
|
|
585
|
+
text = str(text or "").strip()
|
|
586
|
+
return "" if not text or _INJECTED.match(text) else SECRET.sub("[secret]", text)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _text_blocks(content: Any) -> str:
|
|
590
|
+
if isinstance(content, str):
|
|
591
|
+
return content
|
|
592
|
+
return "\n".join(b.get("text", "") for b in content or []
|
|
593
|
+
if isinstance(b, dict) and b.get("type") in ("text", "input_text"))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def parse_session(path: Path) -> Item | None:
|
|
597
|
+
"""One Claude Code or Codex session log (JSON Lines), read in one pass."""
|
|
598
|
+
kind, sid, cwd, title, start = "", "", "", "", None
|
|
599
|
+
typed: list[tuple[datetime, str]] = []
|
|
600
|
+
queued: list[tuple[datetime, str]] = []
|
|
601
|
+
events: list[tuple[datetime, str]] = []
|
|
602
|
+
answers = 0
|
|
603
|
+
with open(path, encoding="utf-8", errors="ignore") as fh:
|
|
604
|
+
for raw in fh:
|
|
605
|
+
try:
|
|
606
|
+
obj = json.loads(raw)
|
|
607
|
+
except ValueError:
|
|
608
|
+
continue
|
|
609
|
+
if not isinstance(obj, dict):
|
|
610
|
+
continue
|
|
611
|
+
when = parse_date(obj.get("timestamp")) or start
|
|
612
|
+
if "sessionId" in obj or obj.get("type") == "summary": # Claude Code
|
|
613
|
+
kind = kind or ("claudecode" if "sessionId" in obj else "")
|
|
614
|
+
sid, cwd = obj.get("sessionId") or sid, obj.get("cwd") or cwd
|
|
615
|
+
t = obj.get("type")
|
|
616
|
+
if t == "summary":
|
|
617
|
+
title = title or str(obj.get("summary") or "")
|
|
618
|
+
elif t == "user" and not obj.get("isSidechain") and not obj.get("isMeta"):
|
|
619
|
+
text = _typed(_text_blocks((obj.get("message") or {}).get("content")))
|
|
620
|
+
if text and when:
|
|
621
|
+
typed.append((when, text))
|
|
622
|
+
elif t == "queue-operation" and obj.get("operation") == "enqueue":
|
|
623
|
+
text = _typed(_text_blocks(obj.get("content"))) # typed while the agent was working
|
|
624
|
+
if text and when:
|
|
625
|
+
queued.append((when, text))
|
|
626
|
+
elif t == "attachment" and (obj.get("attachment") or {}).get("type") == "queued_command":
|
|
627
|
+
sent = obj["attachment"] # a message delivered while it worked, e.g. with a screenshot
|
|
628
|
+
if sent.get("humanTurn") is True or (sent.get("origin") or {}).get("kind") == "human":
|
|
629
|
+
text = _typed(_text_blocks(sent.get("prompt")))
|
|
630
|
+
if text and when:
|
|
631
|
+
queued.append((when, text))
|
|
632
|
+
elif t == "assistant" and not obj.get("isSidechain"):
|
|
633
|
+
answers += bool(_text_blocks((obj.get("message") or {}).get("content")).strip())
|
|
634
|
+
continue
|
|
635
|
+
p = obj["payload"] if isinstance(obj.get("payload"), dict) else obj # Codex, new and old logs
|
|
636
|
+
if obj.get("type") == "session_meta" or ("instructions" in obj and "id" in obj):
|
|
637
|
+
kind, sid, cwd = "codex", str(p.get("id") or sid), str(p.get("cwd") or cwd)
|
|
638
|
+
start = parse_date(p.get("timestamp")) or start
|
|
639
|
+
continue
|
|
640
|
+
if p.get("type") == "message" and p.get("role") == "user":
|
|
641
|
+
text = _typed(_text_blocks(p.get("content")))
|
|
642
|
+
if text and (when or start):
|
|
643
|
+
typed.append((when or start, text))
|
|
644
|
+
elif p.get("type") == "message" and p.get("role") == "assistant":
|
|
645
|
+
answers += 1
|
|
646
|
+
elif p.get("type") == "user_message":
|
|
647
|
+
text = _typed(p.get("message"))
|
|
648
|
+
if text and (when or start):
|
|
649
|
+
events.append((when or start, text))
|
|
650
|
+
if not kind:
|
|
651
|
+
return None
|
|
652
|
+
if kind == "codex" and not typed:
|
|
653
|
+
typed = events # older logs only record what you typed as events
|
|
654
|
+
left, seen = Counter(_squash(t) for _, t in typed), set()
|
|
655
|
+
for when, text in queued: # a message typed while the agent worked is logged more than once
|
|
656
|
+
key = _squash(text)
|
|
657
|
+
if key in seen:
|
|
658
|
+
continue
|
|
659
|
+
seen.add(key)
|
|
660
|
+
if left[key]:
|
|
661
|
+
left[key] -= 1
|
|
662
|
+
else:
|
|
663
|
+
typed.append((when, text))
|
|
664
|
+
mine = sorted(typed, key=lambda m: m[0])
|
|
665
|
+
if not mine:
|
|
666
|
+
return None
|
|
667
|
+
project = re.split(r"[\\/]", cwd.rstrip("\\/"))[-1] if cwd else ""
|
|
668
|
+
first = _squash(title or mine[0][1])
|
|
669
|
+
name = first[:70] + ("…" if len(first) > 70 else "")
|
|
670
|
+
return Item(
|
|
671
|
+
id=f"{kind}:{sid or path.stem}", title=f"{project} · {name}" if project else name, created=mine[0][0],
|
|
672
|
+
source=kind, kind="chat", text="\n".join(t for _, t in mine), parts=[w for w, _ in mine],
|
|
673
|
+
msgs=[t for _, t in mine], size=len(mine) + answers,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _squash(text: str) -> str:
|
|
678
|
+
return " ".join(str(text).split())
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def session_folders() -> list[Path]:
|
|
682
|
+
"""Where Claude Code and Codex keep their session logs on this computer (those that exist)."""
|
|
683
|
+
claude = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude").expanduser() / "projects"
|
|
684
|
+
codex = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser() / "sessions"
|
|
685
|
+
return [p for p in (claude, codex) if p.is_dir()]
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# --------------------------------------------------------------------------- #
|
|
689
|
+
# live capture (the Brainplug browser extension)
|
|
690
|
+
# --------------------------------------------------------------------------- #
|
|
691
|
+
def parse_capture(data: dict) -> list[Item]:
|
|
692
|
+
groups: dict[tuple[str, str], list] = {}
|
|
693
|
+
for row in data.get("items") or []:
|
|
694
|
+
if not isinstance(row, dict):
|
|
695
|
+
continue
|
|
696
|
+
text = str(row.get("text") or "").strip()
|
|
697
|
+
when = _from_epoch((row.get("ts") or 0) / 1000) if isinstance(row.get("ts"), (int, float)) else parse_date(row.get("ts"))
|
|
698
|
+
site = re.sub(r"[^a-z0-9]", "", str(row.get("site") or "other").lower()) or "other"
|
|
699
|
+
if not text or when is None:
|
|
700
|
+
continue
|
|
701
|
+
conv = str(row.get("conv") or when.strftime("%Y%m%d"))
|
|
702
|
+
groups.setdefault((site, conv), []).append((when, text, str(row.get("title") or "")))
|
|
703
|
+
items = []
|
|
704
|
+
for (site, conv), turns in groups.items():
|
|
705
|
+
turns.sort(key=lambda t: t[0])
|
|
706
|
+
title = next((t for _, _, t in reversed(turns) if t.strip()), "") or " ".join(turns[0][1].split())[:70]
|
|
707
|
+
items.append(Item(
|
|
708
|
+
id=f"{site}:{conv}", title=title.strip(), created=turns[0][0], source=site, kind="chat",
|
|
709
|
+
text="\n".join(t for _, t, _ in turns), parts=[w for w, _, _ in turns], size=2 * len(turns),
|
|
710
|
+
msgs=[t for _, t, _ in turns],
|
|
711
|
+
))
|
|
712
|
+
return items
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def parse_json(data: Any) -> list[Item]:
|
|
716
|
+
"""Any JSON Brainplug understands: ChatGPT, Claude, Gemini or the extension's capture file."""
|
|
717
|
+
if isinstance(data, dict) and data.get("format") == "brainplug-capture":
|
|
718
|
+
return parse_capture(data)
|
|
719
|
+
if isinstance(data, list) and data:
|
|
720
|
+
sample = next((c for c in data if isinstance(c, dict)), {})
|
|
721
|
+
if "mapping" in sample:
|
|
722
|
+
return parse_chatgpt(data)
|
|
723
|
+
if "chat_messages" in sample:
|
|
724
|
+
return parse_claude(data)
|
|
725
|
+
if any(_is_gemini(e) for e in data[:50]):
|
|
726
|
+
return parse_gemini(data)
|
|
727
|
+
raise ValueError("Unknown JSON: expected conversations.json from ChatGPT or Claude, "
|
|
728
|
+
"MyActivity.json from Gemini (Google Takeout) or a Brainplug capture file.")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# --------------------------------------------------------------------------- #
|
|
732
|
+
# WhatsApp ("Export chat" > without media)
|
|
733
|
+
# --------------------------------------------------------------------------- #
|
|
734
|
+
_WA_LINE = re.compile(
|
|
735
|
+
r"^\[?(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4}),? (\d{1,2}):(\d{2})(?::\d{2})?(?: ?([ap])\.? ?m\.?)?\]?"
|
|
736
|
+
r"(?: -)? (.*)$", re.I)
|
|
737
|
+
_WA_CLEAN = re.compile("[-]")
|
|
738
|
+
_WA_NOISE = re.compile(
|
|
739
|
+
r"^<[^>]*>$|\b(?:omitted|omitido|omitida|omitidos)\b|end-to-end|extremo a extremo|"
|
|
740
|
+
r"\((?:file attached|archivo adjunto)\)|^(?:This message was deleted|You deleted this message|"
|
|
741
|
+
r"Eliminaste este mensaje|Se eliminó este mensaje|Missed (?:voice|video) call|Llamada perdida)", re.I)
|
|
742
|
+
_WA_NAME = re.compile(r"(?:WhatsApp Chat (?:with|-)|Chat de WhatsApp con|Conversación de WhatsApp con)\s+(.+)$", re.I)
|
|
743
|
+
WA_MIN_WORDS = 12
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
@dataclass
|
|
747
|
+
class _WhatsChat:
|
|
748
|
+
name: str # the other person or the group, when the file name says it
|
|
749
|
+
key: str
|
|
750
|
+
rows: list[tuple[datetime, str, str]] # when, sender, text
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def looks_like_whatsapp(text: str) -> bool:
|
|
754
|
+
lines = [_WA_CLEAN.sub("", l).replace(" ", " ").replace(" ", " ") for l in text[:4000].splitlines()]
|
|
755
|
+
return sum(bool(_WA_LINE.match(l)) for l in lines[:25]) >= 3
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def read_whatsapp(text: str, name: str, key: str) -> _WhatsChat:
|
|
759
|
+
raw: list[list] = []
|
|
760
|
+
for line in text.splitlines():
|
|
761
|
+
line = _WA_CLEAN.sub("", line).replace("\u00a0", " ").replace("\u202f", " ")
|
|
762
|
+
m = _WA_LINE.match(line)
|
|
763
|
+
if m:
|
|
764
|
+
raw.append(list(m.groups()))
|
|
765
|
+
elif raw:
|
|
766
|
+
raw[-1][6] += "\n" + line # a message that continues on the next line
|
|
767
|
+
# 03/04 is ambiguous: look at the whole file to tell day/month order
|
|
768
|
+
day_first = not any(int(r[1]) > 12 for r in raw) or any(int(r[0]) > 12 for r in raw)
|
|
769
|
+
rows = []
|
|
770
|
+
for a, b, y, hh, mm, ap, rest in raw:
|
|
771
|
+
sender, sep, body = rest.partition(": ")
|
|
772
|
+
if not sep:
|
|
773
|
+
continue # system line ("X added Y", "Messages are encrypted…")
|
|
774
|
+
d, mo = (int(a), int(b)) if day_first else (int(b), int(a))
|
|
775
|
+
year, hour = int(y) + (2000 if len(y) <= 2 else 0), int(hh) % 24
|
|
776
|
+
if ap:
|
|
777
|
+
hour = hour % 12 + (12 if ap.lower() == "p" else 0)
|
|
778
|
+
try:
|
|
779
|
+
when = datetime(year, mo, d, hour, int(mm)).astimezone() # phone time = local time
|
|
780
|
+
except (ValueError, OSError):
|
|
781
|
+
continue
|
|
782
|
+
body = re.sub(r"<[^>]*(?:edited|editó)[^>]*>", "", body).strip()
|
|
783
|
+
if body and not _WA_NOISE.search(body):
|
|
784
|
+
rows.append((when, sender.strip(), body))
|
|
785
|
+
return _WhatsChat(name=name, key=key, rows=rows)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def whatsapp_me(chats: list[_WhatsChat], names: set[str]) -> set[str]:
|
|
789
|
+
"""Who is "you" in these chats? Your own name, lower-cased; empty if unknown."""
|
|
790
|
+
if names:
|
|
791
|
+
return names
|
|
792
|
+
seen: Counter = Counter()
|
|
793
|
+
for chat in chats:
|
|
794
|
+
seen.update({s.lower() for _, s, _ in chat.rows})
|
|
795
|
+
if len(chats) >= 2 and seen: # you are the one who is in every chat
|
|
796
|
+
name, n = seen.most_common(1)[0]
|
|
797
|
+
if n == len(chats) and sum(1 for c in seen.values() if c == n) == 1:
|
|
798
|
+
return {name}
|
|
799
|
+
found = set()
|
|
800
|
+
for chat in chats: # a 1:1 chat is named after the other person
|
|
801
|
+
senders = {s.lower() for _, s, _ in chat.rows}
|
|
802
|
+
other = chat.name.lower()
|
|
803
|
+
if len(senders) == 2 and other in senders:
|
|
804
|
+
found |= senders - {other}
|
|
805
|
+
return found if len(found) == 1 else set()
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def whatsapp_items(chats: list[_WhatsChat], me: set[str]) -> list[Item]:
|
|
809
|
+
"""One item per chat and day, holding only the messages you sent."""
|
|
810
|
+
items = []
|
|
811
|
+
for chat in chats:
|
|
812
|
+
days: dict[str, dict] = {}
|
|
813
|
+
for when, sender, body in chat.rows:
|
|
814
|
+
day = days.setdefault(when.strftime("%Y-%m-%d"), {"mine": [], "total": 0})
|
|
815
|
+
day["total"] += 1
|
|
816
|
+
if sender.lower() in me:
|
|
817
|
+
day["mine"].append((when, re.sub(r"https?://\S+", " ", body)))
|
|
818
|
+
for date, day in days.items():
|
|
819
|
+
text = "\n".join(t for _, t in day["mine"]).strip()
|
|
820
|
+
if len(text.split()) < WA_MIN_WORDS:
|
|
821
|
+
continue
|
|
822
|
+
items.append(Item(
|
|
823
|
+
id=f"whatsapp:{chat.key}:{date}", title=chat.name or "WhatsApp", created=day["mine"][0][0],
|
|
824
|
+
source="whatsapp", kind="message", text=text[:MAX_NOTE_BYTES],
|
|
825
|
+
parts=[w for w, _ in day["mine"]], size=day["total"],
|
|
826
|
+
))
|
|
827
|
+
return items
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# --------------------------------------------------------------------------- #
|
|
831
|
+
# documents: Word and PDF
|
|
832
|
+
# --------------------------------------------------------------------------- #
|
|
833
|
+
def parse_docx(path: Path, rel: Path) -> Item | None:
|
|
834
|
+
try:
|
|
835
|
+
with zipfile.ZipFile(path) as zf:
|
|
836
|
+
xml = zf.read("word/document.xml").decode("utf-8", "ignore")
|
|
837
|
+
core = zf.read("docProps/core.xml").decode("utf-8", "ignore") if "docProps/core.xml" in zf.namelist() else ""
|
|
838
|
+
except (zipfile.BadZipFile, KeyError, OSError):
|
|
839
|
+
return None
|
|
840
|
+
paragraphs = []
|
|
841
|
+
for para in re.findall(r"<w:p[ >].*?</w:p>", xml, flags=re.S):
|
|
842
|
+
para = re.sub(r"<w:(?:tab|br|cr)\b[^>]*/>", " ", para)
|
|
843
|
+
text = html.unescape("".join(re.findall(r"<w:t(?:\s[^>]*)?>([^<]*)</w:t>", para))).strip()
|
|
844
|
+
if text:
|
|
845
|
+
paragraphs.append(text)
|
|
846
|
+
text = re.sub(r"\s+", " ", re.sub(r"https?://\S+", " ", " ".join(paragraphs))).strip()
|
|
847
|
+
if len(text.split()) < 3:
|
|
848
|
+
return None
|
|
849
|
+
title = re.search(r"<dc:title>([^<]+)</dc:title>", core)
|
|
850
|
+
created = re.search(r"<dcterms:created[^>]*>([^<]+)</dcterms:created>", core)
|
|
851
|
+
return Item(
|
|
852
|
+
id=f"word:{rel.as_posix()}", title=(html.unescape(title.group(1)).strip() if title else "") or path.stem,
|
|
853
|
+
created=(parse_date(created.group(1)) if created else None) or _file_date(path),
|
|
854
|
+
source="word", kind="note", text=text[:MAX_NOTE_BYTES],
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def parse_pdf(path: Path, rel: Path) -> Item | None:
|
|
859
|
+
try:
|
|
860
|
+
from pypdf import PdfReader
|
|
861
|
+
except ImportError as err: # pragma: no cover
|
|
862
|
+
raise ValueError("Reading PDF files needs pypdf: pip install pypdf") from err
|
|
863
|
+
try:
|
|
864
|
+
reader = PdfReader(str(path))
|
|
865
|
+
if reader.is_encrypted and not reader.decrypt(""):
|
|
866
|
+
return None
|
|
867
|
+
pages = [page.extract_text() or "" for page in reader.pages[:300]]
|
|
868
|
+
meta = reader.metadata or {}
|
|
869
|
+
title = str(getattr(meta, "title", None) or "").strip()
|
|
870
|
+
created = getattr(meta, "creation_date", None)
|
|
871
|
+
except Exception: # damaged or exotic PDF: skip it, keep the rest
|
|
872
|
+
return None
|
|
873
|
+
text = re.sub(r"\s+", " ", re.sub(r"https?://\S+", " ", " ".join(pages))).strip()
|
|
874
|
+
if len(text.split()) < 3:
|
|
875
|
+
return None # scanned pages have no text layer
|
|
876
|
+
return Item(
|
|
877
|
+
id=f"pdf:{rel.as_posix()}", title=title or path.stem,
|
|
878
|
+
created=_aware(created) if isinstance(created, datetime) else _file_date(path),
|
|
879
|
+
source="pdf", kind="note", text=text[:MAX_NOTE_BYTES],
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# --------------------------------------------------------------------------- #
|
|
884
|
+
# entry point: files, folders and ZIPs
|
|
885
|
+
# --------------------------------------------------------------------------- #
|
|
886
|
+
_ZIP_KEEP = NOTE_SUFFIXES | {".mbox", ".zip", ".docx", ".pdf", ".json", ".html"}
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _wanted_json(rel: str) -> bool:
|
|
890
|
+
"""Inside folders and ZIPs only JSON files that are clearly exports are read."""
|
|
891
|
+
low = rel.lower()
|
|
892
|
+
name = low.rsplit("/", 1)[-1]
|
|
893
|
+
return (name == "conversations.json" or "brainplug" in name
|
|
894
|
+
or (("gemini" in low or "bard" in low) and "activ" in name))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class _Loader:
|
|
898
|
+
def __init__(self, me: Iterable[str] | None, pdf: bool, notes: list[str] | None):
|
|
899
|
+
me = [m.strip() for m in (me or []) if m and m.strip()]
|
|
900
|
+
self.emails = [m for m in me if "@" in m]
|
|
901
|
+
self.names = {m.lower() for m in me if "@" not in m}
|
|
902
|
+
self.pdf = pdf
|
|
903
|
+
self.notes = notes if notes is not None else []
|
|
904
|
+
self.chats: list[_WhatsChat] = []
|
|
905
|
+
|
|
906
|
+
def json(self, path: Path, strict: bool) -> list[Item]:
|
|
907
|
+
try:
|
|
908
|
+
with open(path, encoding="utf-8") as fh:
|
|
909
|
+
return parse_json(json.load(fh))
|
|
910
|
+
except (ValueError, UnicodeDecodeError):
|
|
911
|
+
if strict:
|
|
912
|
+
raise
|
|
913
|
+
return []
|
|
914
|
+
|
|
915
|
+
def zip(self, path: Path) -> list[Item]:
|
|
916
|
+
depth = getattr(self, "_zip_depth", 0)
|
|
917
|
+
if depth == 0:
|
|
918
|
+
self._zip_entries = 0
|
|
919
|
+
self._zip_bytes = 0
|
|
920
|
+
|
|
921
|
+
def skip(name: str, why: str) -> None:
|
|
922
|
+
self.notes.append(f"ZIP: skipped {name} ({why}).")
|
|
923
|
+
|
|
924
|
+
if depth >= MAX_ZIP_DEPTH:
|
|
925
|
+
skip(path.name, "nesting depth limit")
|
|
926
|
+
return []
|
|
927
|
+
self._zip_depth = depth + 1
|
|
928
|
+
try:
|
|
929
|
+
# ZipFile eagerly allocates the central directory. Inspect it with bounded
|
|
930
|
+
# reads first; count actual records rather than trusting the EOCD count.
|
|
931
|
+
with path.open("rb") as fh:
|
|
932
|
+
end = zipfile._EndRecData(fh)
|
|
933
|
+
if not end:
|
|
934
|
+
raise zipfile.BadZipFile("Not a ZIP file")
|
|
935
|
+
size = end[zipfile._ECD_SIZE]
|
|
936
|
+
if size > MAX_ZIP_DIRECTORY_BYTES:
|
|
937
|
+
skip(path.name, "central directory byte limit")
|
|
938
|
+
return []
|
|
939
|
+
start = end[zipfile._ECD_LOCATION] - size
|
|
940
|
+
fh.seek(max(0, start))
|
|
941
|
+
if fh.read(4) != b"PK\x01\x02" and end[0] == b"PK\x06\x06":
|
|
942
|
+
start -= 76 # older Python versions leave the ZIP64 trailer here
|
|
943
|
+
fh.seek(max(0, start))
|
|
944
|
+
examined = 0
|
|
945
|
+
while examined < size:
|
|
946
|
+
if self._zip_entries >= MAX_ZIP_ENTRIES:
|
|
947
|
+
skip(path.name, "entry count limit")
|
|
948
|
+
return []
|
|
949
|
+
head = fh.read(46)
|
|
950
|
+
if len(head) != 46 or head[:4] != b"PK\x01\x02":
|
|
951
|
+
raise zipfile.BadZipFile("Damaged central directory")
|
|
952
|
+
self._zip_entries += 1
|
|
953
|
+
extra = sum(int.from_bytes(head[i:i + 2], "little") for i in (28, 30, 32))
|
|
954
|
+
fh.seek(extra, 1)
|
|
955
|
+
examined += 46 + extra
|
|
956
|
+
with zipfile.ZipFile(path) as zf, tempfile.TemporaryDirectory(prefix="brainplug_") as tmp:
|
|
957
|
+
root = Path(tmp)
|
|
958
|
+
for info in zf.infolist():
|
|
959
|
+
suffix = Path(info.filename).suffix.lower()
|
|
960
|
+
if info.is_dir() or suffix not in _ZIP_KEEP:
|
|
961
|
+
continue
|
|
962
|
+
low = info.filename.lower()
|
|
963
|
+
if (suffix == ".json" and not _wanted_json(low)) or \
|
|
964
|
+
(suffix == ".html" and not ("gemini" in low and "activ" in low)):
|
|
965
|
+
continue
|
|
966
|
+
why = None
|
|
967
|
+
if suffix == ".zip" and self._zip_depth >= MAX_ZIP_DEPTH:
|
|
968
|
+
why = "nesting depth limit"
|
|
969
|
+
elif info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
|
|
970
|
+
# Older Python ZipExtFile versions inflate BZIP2/LZMA in
|
|
971
|
+
# unbounded calls, even when read() requests a small chunk.
|
|
972
|
+
why = "unsupported compression for bounded extraction"
|
|
973
|
+
elif info.file_size > ZIP_RATIO_MIN_BYTES and info.file_size > MAX_ZIP_RATIO * info.compress_size:
|
|
974
|
+
why = "compression ratio limit"
|
|
975
|
+
elif info.file_size > MAX_ZIP_BYTES - self._zip_bytes:
|
|
976
|
+
why = "extracted byte budget"
|
|
977
|
+
if why:
|
|
978
|
+
skip(info.filename, why)
|
|
979
|
+
continue
|
|
980
|
+
# Match extract()'s path sanitization without its unbounded copy.
|
|
981
|
+
name = info.filename.replace("/", os.path.sep)
|
|
982
|
+
if os.path.altsep:
|
|
983
|
+
name = name.replace(os.path.altsep, os.path.sep)
|
|
984
|
+
parts = [p for p in os.path.splitdrive(name)[1].split(os.path.sep) if p not in ("", ".", "..")]
|
|
985
|
+
if os.path.sep == "\\":
|
|
986
|
+
parts = [zipfile.ZipFile._sanitize_windows_name(p, "\\") for p in parts]
|
|
987
|
+
target = root.joinpath(*parts)
|
|
988
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
989
|
+
written = 0
|
|
990
|
+
try:
|
|
991
|
+
# ZipExtFile otherwise silently truncates at file_size. Allow
|
|
992
|
+
# one extra byte so even a forged CRC for a prefix is rejected.
|
|
993
|
+
probe = copy.copy(info)
|
|
994
|
+
probe.file_size = min(info.file_size, MAX_ZIP_BYTES - self._zip_bytes) + 1
|
|
995
|
+
with zf.open(probe) as src, target.open("wb") as dst:
|
|
996
|
+
while chunk := src.read(ZIP_COPY_CHUNK):
|
|
997
|
+
if written + len(chunk) > info.file_size:
|
|
998
|
+
raise ValueError("output exceeds declared size")
|
|
999
|
+
if len(chunk) > MAX_ZIP_BYTES - self._zip_bytes:
|
|
1000
|
+
raise ValueError("extracted byte budget")
|
|
1001
|
+
self._zip_bytes += len(chunk)
|
|
1002
|
+
written += len(chunk)
|
|
1003
|
+
dst.write(chunk)
|
|
1004
|
+
if written != info.file_size:
|
|
1005
|
+
raise ValueError("output differs from declared size")
|
|
1006
|
+
except (ValueError, RuntimeError, NotImplementedError, zipfile.BadZipFile, zlib.error) as exc:
|
|
1007
|
+
target.unlink(missing_ok=True)
|
|
1008
|
+
skip(info.filename, f"invalid ZIP data or declared size: {exc}")
|
|
1009
|
+
continue
|
|
1010
|
+
try:
|
|
1011
|
+
stamp = datetime(*info.date_time).timestamp()
|
|
1012
|
+
os.utime(target, (stamp, stamp))
|
|
1013
|
+
except (ValueError, OSError):
|
|
1014
|
+
pass
|
|
1015
|
+
is_vault = any("/.obsidian/" in "/" + i.filename for i in zf.infolist())
|
|
1016
|
+
return self.folder(root, is_vault, hint=path.stem)
|
|
1017
|
+
finally:
|
|
1018
|
+
self._zip_depth = depth
|
|
1019
|
+
|
|
1020
|
+
def folder(self, root: Path, is_vault: bool = False, hint: str = "", skip: Iterable[Path] = ()) -> list[Item]:
|
|
1021
|
+
is_vault = is_vault or (root / ".obsidian").exists() or any(root.glob("*/.obsidian"))
|
|
1022
|
+
inner = [s.relative_to(root.resolve()).parts for s in skip] # folders also given on their own
|
|
1023
|
+
items: list[Item] = []
|
|
1024
|
+
for path in sorted(root.rglob("*")):
|
|
1025
|
+
rel = path.relative_to(root)
|
|
1026
|
+
if any(p.startswith(".") or p in SKIP_DIRS for p in rel.parts) or not path.is_file():
|
|
1027
|
+
continue
|
|
1028
|
+
if any(rel.parts[:len(parts)] == parts for parts in inner):
|
|
1029
|
+
continue
|
|
1030
|
+
items += self.file(path, rel, "obsidian" if is_vault else "notes", inside=True, hint=hint)
|
|
1031
|
+
return items
|
|
1032
|
+
|
|
1033
|
+
def file(self, path: Path, rel: Path, note_source: str = "notes", inside: bool = False,
|
|
1034
|
+
hint: str = "") -> list[Item]:
|
|
1035
|
+
suffix = path.suffix.lower()
|
|
1036
|
+
if suffix == ".json":
|
|
1037
|
+
return self.json(path, strict=not inside) if (not inside or _wanted_json(rel.as_posix())) else []
|
|
1038
|
+
if suffix == ".html":
|
|
1039
|
+
low = rel.as_posix().lower()
|
|
1040
|
+
return parse_gemini_html(_read_all(path)) if (not inside or ("gemini" in low and "activ" in low)) else []
|
|
1041
|
+
if suffix == ".mbox":
|
|
1042
|
+
return load_gmail(path, self.emails, self.notes)
|
|
1043
|
+
if suffix == ".jsonl":
|
|
1044
|
+
session = parse_session(path)
|
|
1045
|
+
return [session] if session else []
|
|
1046
|
+
if suffix == ".zip":
|
|
1047
|
+
return self.zip(path)
|
|
1048
|
+
if suffix == ".docx":
|
|
1049
|
+
doc = None if path.name.startswith("~$") else parse_docx(path, rel)
|
|
1050
|
+
return [doc] if doc else []
|
|
1051
|
+
if suffix == ".pdf":
|
|
1052
|
+
if inside and not self.pdf:
|
|
1053
|
+
return [] # PDFs in a folder are usually other people's writing
|
|
1054
|
+
doc = parse_pdf(path, rel)
|
|
1055
|
+
return [doc] if doc else []
|
|
1056
|
+
if suffix in NOTE_SUFFIXES:
|
|
1057
|
+
if suffix == ".txt":
|
|
1058
|
+
text = _read_all(path)
|
|
1059
|
+
if looks_like_whatsapp(text):
|
|
1060
|
+
m = _WA_NAME.search(path.stem) or _WA_NAME.search(hint)
|
|
1061
|
+
name = (m.group(1).strip() if m else "") or (hint if path.stem == "_chat" else path.stem)
|
|
1062
|
+
self.chats.append(read_whatsapp(text, name, key=name or rel.as_posix()))
|
|
1063
|
+
return []
|
|
1064
|
+
where = rel.as_posix()
|
|
1065
|
+
if _GENERATED.search(where):
|
|
1066
|
+
return []
|
|
1067
|
+
if suffix != ".txt" and NOTION_ID.search(path.stem):
|
|
1068
|
+
note = parse_notion_page(path, rel)
|
|
1069
|
+
else:
|
|
1070
|
+
note = parse_note(path, rel, "memory" if _MEMORY.search(where) else note_source)
|
|
1071
|
+
return [note] if note else []
|
|
1072
|
+
return []
|
|
1073
|
+
|
|
1074
|
+
def whatsapp(self) -> list[Item]:
|
|
1075
|
+
if not self.chats:
|
|
1076
|
+
return []
|
|
1077
|
+
me = whatsapp_me(self.chats, self.names)
|
|
1078
|
+
if not me:
|
|
1079
|
+
senders = sorted({s for c in self.chats for _, s, _ in c.rows})[:6]
|
|
1080
|
+
self.notes.append(
|
|
1081
|
+
"WhatsApp: unsure: could not tell which sender is you, so those chats were skipped. "
|
|
1082
|
+
f'Give your name as it appears in the chat in “Who you are” or with --me (seen: {", ".join(senders)}).')
|
|
1083
|
+
return []
|
|
1084
|
+
if not self.names:
|
|
1085
|
+
sender = next((s for c in self.chats for _, s, _ in c.rows if s.lower() in me), sorted(me)[0])
|
|
1086
|
+
evidence = f"{len(self.chats)} chats" if len(self.chats) != 1 else "the file name"
|
|
1087
|
+
self.notes.append(
|
|
1088
|
+
f"WhatsApp: kept the messages written by {sender} (detected from {evidence}; "
|
|
1089
|
+
'to change it, give your name in “Who you are” or with --me).')
|
|
1090
|
+
return whatsapp_items(self.chats, me)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _read_all(path: Path) -> str:
|
|
1094
|
+
with open(path, "rb") as fh:
|
|
1095
|
+
return fh.read(60_000_000).decode("utf-8", errors="ignore")
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def load(paths: Iterable[str | Path], me: Iterable[str] | None = None, pdf: bool = False,
|
|
1099
|
+
notes: list[str] | None = None) -> list[Item]:
|
|
1100
|
+
"""Load any mix of exports, folders and ZIPs. Oldest item first.
|
|
1101
|
+
|
|
1102
|
+
``me`` your email addresses (Gmail) and your name as shown in WhatsApp.
|
|
1103
|
+
``pdf`` also read the PDFs found inside folders and ZIPs.
|
|
1104
|
+
``notes`` a list that receives human-readable remarks (skipped inputs…).
|
|
1105
|
+
"""
|
|
1106
|
+
paths = list(paths)
|
|
1107
|
+
loader = _Loader(me, pdf, notes)
|
|
1108
|
+
found: list[Item] = []
|
|
1109
|
+
dirs = [Path(p).expanduser().resolve() for p in paths if Path(p).expanduser().is_dir()]
|
|
1110
|
+
for raw in paths:
|
|
1111
|
+
path = Path(raw).expanduser()
|
|
1112
|
+
if not path.exists():
|
|
1113
|
+
raise FileNotFoundError(f"Not found: {path}")
|
|
1114
|
+
if path.is_dir(): # a folder given on its own is read once, from its own root
|
|
1115
|
+
nested = [d for d in dirs if d != path.resolve() and d.is_relative_to(path.resolve())]
|
|
1116
|
+
found += loader.folder(path, skip=nested)
|
|
1117
|
+
else:
|
|
1118
|
+
found += loader.file(path, Path(path.name))
|
|
1119
|
+
found += loader.whatsapp()
|
|
1120
|
+
items: dict[str, Item] = {}
|
|
1121
|
+
for item in found:
|
|
1122
|
+
old = items.get(item.id)
|
|
1123
|
+
if old is None:
|
|
1124
|
+
items[item.id] = item
|
|
1125
|
+
elif old.text == item.text:
|
|
1126
|
+
continue # the same thing reached twice, e.g. a folder given twice
|
|
1127
|
+
elif old.msgs and item.msgs:
|
|
1128
|
+
items[item.id] = merge_chats(old, item) # an export and a later live capture of one chat
|
|
1129
|
+
elif item.kind == "note":
|
|
1130
|
+
n = 2 # a different note at the same place in another folder: keep both
|
|
1131
|
+
while f"{item.id}#{n}" in items:
|
|
1132
|
+
n += 1
|
|
1133
|
+
item.id = f"{item.id}#{n}"
|
|
1134
|
+
items[item.id] = item
|
|
1135
|
+
elif item.words > old.words:
|
|
1136
|
+
items[item.id] = item # an older and a newer export of the same thread
|
|
1137
|
+
left_out, left_out_sources = _exclusions(paths)
|
|
1138
|
+
return sorted((i for i in items.values() if i.id not in left_out and i.source not in left_out_sources),
|
|
1139
|
+
key=lambda i: i.created)
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def topic_names_in(paths: Iterable) -> list[tuple[str, list[str]]]:
|
|
1143
|
+
"""Topic names saved in a vault you pass (Brainplug/Topic names.md)."""
|
|
1144
|
+
from .memory import topic_names # here: memory does not depend on this module
|
|
1145
|
+
out: list[tuple[str, list[str]]] = []
|
|
1146
|
+
for raw in paths:
|
|
1147
|
+
root = Path(raw).expanduser()
|
|
1148
|
+
if root.is_dir():
|
|
1149
|
+
for note in [*root.glob("Brainplug/Topic names.md"), *root.glob("*/Brainplug/Topic names.md")]:
|
|
1150
|
+
out += topic_names(note.parent.parent)
|
|
1151
|
+
return out
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _exclusions(paths: list) -> tuple[set[str], set[str]]:
|
|
1155
|
+
"""What the person asked to leave out (Brainplug/Excluded.md in a vault you pass)."""
|
|
1156
|
+
from .memory import exclusions # here: memory does not depend on this module
|
|
1157
|
+
ids: set[str] = set()
|
|
1158
|
+
names: set[str] = set()
|
|
1159
|
+
for raw in paths:
|
|
1160
|
+
root = Path(raw).expanduser()
|
|
1161
|
+
if root.is_dir():
|
|
1162
|
+
for note in [*root.glob("Brainplug/Excluded.md"), *root.glob("*/Brainplug/Excluded.md")]:
|
|
1163
|
+
vault = note.parent.parent
|
|
1164
|
+
more_ids, more_names = exclusions(vault)
|
|
1165
|
+
ids |= more_ids
|
|
1166
|
+
names |= more_names
|
|
1167
|
+
if vault != root: # the vault is a folder inside what you passed: its notes' ids say so
|
|
1168
|
+
ids |= {f"{s}:{vault.name}/{rest}" for s, _, rest in (i.partition(":") for i in more_ids)}
|
|
1169
|
+
return ids, names
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _same(text: str) -> str:
|
|
1173
|
+
return " ".join(text.split())
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def merge_chats(a: Item, b: Item) -> Item:
|
|
1177
|
+
"""One conversation found in two places (an export and the extension's capture made
|
|
1178
|
+
after it): keep every message once. A message both have is counted once per copy."""
|
|
1179
|
+
base, other = (a, b) if len(a.msgs) >= len(b.msgs) else (b, a)
|
|
1180
|
+
unmatched = Counter(_same(m) for m in base.msgs)
|
|
1181
|
+
extra = []
|
|
1182
|
+
for when, text in zip(other.parts, other.msgs):
|
|
1183
|
+
key = _same(text)
|
|
1184
|
+
if unmatched[key]:
|
|
1185
|
+
unmatched[key] -= 1
|
|
1186
|
+
else:
|
|
1187
|
+
extra.append((when, text))
|
|
1188
|
+
if not extra:
|
|
1189
|
+
return base
|
|
1190
|
+
rows = sorted([*zip(base.parts, base.msgs), *extra], key=lambda r: r[0])
|
|
1191
|
+
return replace(base, title=base.title if base.title not in ("", "Untitled") else other.title,
|
|
1192
|
+
created=min(base.created, other.created), text="\n".join(t for _, t in rows),
|
|
1193
|
+
parts=[w for w, _ in rows], msgs=[t for _, t in rows], size=base.size + 2 * len(extra))
|