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/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))