secondbrain-py 0.2.1__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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/ingest/gmail.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""Gmail ingester — shells out to the `gws` CLI.
|
|
2
|
+
|
|
3
|
+
Uses the real ``gws gmail users messages list/get`` surface. Each operation
|
|
4
|
+
accepts a JSON-encoded ``--params`` blob that mirrors the underlying Gmail
|
|
5
|
+
REST API. Message bodies returned by the ``get`` call are base64url-encoded
|
|
6
|
+
and may live either on ``payload.body.data`` or inside ``payload.parts[]``
|
|
7
|
+
(multi-part messages) — this module normalises both shapes into plain text.
|
|
8
|
+
"""
|
|
9
|
+
import base64
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from email.utils import parsedate_to_datetime
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from brain.config import BOILERPLATE_PATTERNS
|
|
21
|
+
|
|
22
|
+
# Re-using the shared Re/Fwd-prefix helper from vault.slug rather than
|
|
23
|
+
# duplicating the regex — it's marked private (leading underscore) but the
|
|
24
|
+
# strip rule is identical between the URL slug (``gmail_slug``) and the
|
|
25
|
+
# thread-doc title, and a divergent local copy would invite drift. Single
|
|
26
|
+
# source of truth wins over the soft visibility convention here.
|
|
27
|
+
from brain.vault.slug import _strip_re_fwd_prefixes
|
|
28
|
+
|
|
29
|
+
from . import ExtractedDoc
|
|
30
|
+
|
|
31
|
+
# Compile boilerplate patterns once at import time. Per-pattern ``(?s)``
|
|
32
|
+
# inline flags opt individual entries into DOTALL; see ``BOILERPLATE_PATTERNS``
|
|
33
|
+
# in ``brain.config`` for the rationale.
|
|
34
|
+
_BOILERPLATE_REGEXES: tuple[re.Pattern[str], ...] = tuple(
|
|
35
|
+
re.compile(p, re.MULTILINE | re.IGNORECASE) for p in BOILERPLATE_PATTERNS
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Run-collapse threshold: only collapse runs of consecutive identical lines
|
|
39
|
+
# longer than this (in characters). Short repeated lines (signoffs, blank
|
|
40
|
+
# separators, "ok") stay untouched.
|
|
41
|
+
_COLLAPSE_MIN_LINE_LEN = 40
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GmailError(RuntimeError):
|
|
45
|
+
"""Raised when the `gws` CLI is missing or returns a non-zero exit."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Runner = Callable[[list[str]], str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_query(
|
|
52
|
+
*,
|
|
53
|
+
query: str | None,
|
|
54
|
+
label: str | None,
|
|
55
|
+
since: str | None,
|
|
56
|
+
until: str | None,
|
|
57
|
+
from_addr: str | None,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Compose the Gmail search ``q`` string from the CLI scope flags.
|
|
60
|
+
|
|
61
|
+
Drafts are now **included** by default (wave Q1-A, 2026-05-11). The
|
|
62
|
+
per-message extractor (:func:`to_extracted_doc`) and per-thread
|
|
63
|
+
extractor (:func:`to_extracted_thread`) stamp
|
|
64
|
+
``metadata["_is_draft"] = True`` on all-draft documents so the ingest
|
|
65
|
+
pipeline can set ``documents.draft = TRUE``. The P1.6 wiki quarantine
|
|
66
|
+
(``contentIndex.ts:397``) hides those rows from the Quartz build;
|
|
67
|
+
``brain search`` / ``brain show`` still surface them so "what was I
|
|
68
|
+
going to email person-x about?" is answerable.
|
|
69
|
+
"""
|
|
70
|
+
parts: list[str] = []
|
|
71
|
+
if query:
|
|
72
|
+
parts.append(query)
|
|
73
|
+
if label:
|
|
74
|
+
parts.append(f"label:{label}")
|
|
75
|
+
if from_addr:
|
|
76
|
+
parts.append(f"from:{from_addr}")
|
|
77
|
+
if since:
|
|
78
|
+
parts.append(f"after:{since}")
|
|
79
|
+
if until:
|
|
80
|
+
parts.append(f"before:{until}")
|
|
81
|
+
return " ".join(parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def list_messages(
|
|
85
|
+
*,
|
|
86
|
+
query: str | None = None,
|
|
87
|
+
label: str | None = None,
|
|
88
|
+
since: str | None = None,
|
|
89
|
+
until: str | None = None,
|
|
90
|
+
from_addr: str | None = None,
|
|
91
|
+
max_results: int = 50,
|
|
92
|
+
runner: Runner | None = None,
|
|
93
|
+
) -> list[dict[str, Any]]:
|
|
94
|
+
"""Return a list of message stubs: ``[{"id": ..., "threadId": ...}, ...]``.
|
|
95
|
+
|
|
96
|
+
Calls ``gws gmail users messages list --params <json> --format json`` and
|
|
97
|
+
returns the ``messages`` array. The Gmail API omits the ``messages`` key
|
|
98
|
+
entirely when there are zero matches, so we coalesce to an empty list.
|
|
99
|
+
"""
|
|
100
|
+
q = _build_query(
|
|
101
|
+
query=query, label=label, since=since, until=until, from_addr=from_addr
|
|
102
|
+
)
|
|
103
|
+
params: dict[str, Any] = {"userId": "me", "maxResults": max_results}
|
|
104
|
+
if q:
|
|
105
|
+
params["q"] = q
|
|
106
|
+
out = _run(
|
|
107
|
+
[
|
|
108
|
+
"gws", "gmail", "users", "messages", "list",
|
|
109
|
+
"--params", json.dumps(params),
|
|
110
|
+
"--format", "json",
|
|
111
|
+
],
|
|
112
|
+
runner,
|
|
113
|
+
)
|
|
114
|
+
parsed = json.loads(out) if out.strip() else {}
|
|
115
|
+
return list(parsed.get("messages") or [])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_message(message_id: str, *, runner: Runner | None = None) -> dict[str, Any]:
|
|
119
|
+
"""Return the full Gmail Message resource for ``message_id``.
|
|
120
|
+
|
|
121
|
+
Calls ``gws gmail users messages get --params <json> --format json`` with
|
|
122
|
+
``format=full`` so the response includes headers and the message body
|
|
123
|
+
payload (possibly multipart).
|
|
124
|
+
"""
|
|
125
|
+
params = {"userId": "me", "id": message_id, "format": "full"}
|
|
126
|
+
out = _run(
|
|
127
|
+
[
|
|
128
|
+
"gws", "gmail", "users", "messages", "get",
|
|
129
|
+
"--params", json.dumps(params),
|
|
130
|
+
"--format", "json",
|
|
131
|
+
],
|
|
132
|
+
runner,
|
|
133
|
+
)
|
|
134
|
+
parsed: dict[str, Any] = json.loads(out)
|
|
135
|
+
return parsed
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _headers_to_dict(headers: list[dict[str, str]]) -> dict[str, str]:
|
|
139
|
+
"""Flatten a Gmail ``headers`` list to a ``{name.lower(): value}`` dict."""
|
|
140
|
+
return {h.get("name", "").lower(): h.get("value", "") for h in headers or []}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _decode_body_data(data: str) -> str:
|
|
144
|
+
"""Decode a Gmail API ``body.data`` base64url string into UTF-8 text."""
|
|
145
|
+
if not data:
|
|
146
|
+
return ""
|
|
147
|
+
padded = data + "=" * (-len(data) % 4)
|
|
148
|
+
raw = base64.urlsafe_b64decode(padded)
|
|
149
|
+
return raw.decode("utf-8", errors="replace")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _strip_html(text: str) -> str:
|
|
153
|
+
"""Naive HTML→text fallback: drop tags and collapse whitespace."""
|
|
154
|
+
text = re.sub(r"<style.*?</style>", "", text, flags=re.IGNORECASE | re.DOTALL)
|
|
155
|
+
text = re.sub(r"<script.*?</script>", "", text, flags=re.IGNORECASE | re.DOTALL)
|
|
156
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
157
|
+
text = re.sub(r"\s+", " ", text)
|
|
158
|
+
return text.strip()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _extract_body(payload: dict[str, Any]) -> str:
|
|
162
|
+
"""Pull plain text out of a Gmail payload, recursing into parts.
|
|
163
|
+
|
|
164
|
+
Strategy: prefer ``text/plain`` parts (aggregated), fall back to
|
|
165
|
+
``text/html`` (aggregated and HTML-stripped), and if neither is present,
|
|
166
|
+
use whatever data is on the root payload's ``body``.
|
|
167
|
+
"""
|
|
168
|
+
plain_chunks: list[str] = []
|
|
169
|
+
html_chunks: list[str] = []
|
|
170
|
+
|
|
171
|
+
def visit(node: dict[str, Any]) -> None:
|
|
172
|
+
mime = (node.get("mimeType") or "").lower()
|
|
173
|
+
body = node.get("body") or {}
|
|
174
|
+
data = body.get("data") or ""
|
|
175
|
+
if mime == "text/plain" and data:
|
|
176
|
+
plain_chunks.append(_decode_body_data(data))
|
|
177
|
+
elif mime == "text/html" and data:
|
|
178
|
+
html_chunks.append(_decode_body_data(data))
|
|
179
|
+
for child in node.get("parts") or []:
|
|
180
|
+
visit(child)
|
|
181
|
+
|
|
182
|
+
visit(payload)
|
|
183
|
+
if plain_chunks:
|
|
184
|
+
return "\n\n".join(plain_chunks)
|
|
185
|
+
if html_chunks:
|
|
186
|
+
return _strip_html("\n\n".join(html_chunks))
|
|
187
|
+
root_data = (payload.get("body") or {}).get("data") or ""
|
|
188
|
+
return _decode_body_data(root_data) if root_data else ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _collapse_repeated_long_lines(body: str) -> str:
|
|
192
|
+
"""Collapse runs of ≥2 consecutive identical lines longer than the threshold.
|
|
193
|
+
|
|
194
|
+
Operates on the line view of ``body`` (split on ``\\n`` so empty trailing
|
|
195
|
+
lines from a trailing newline are preserved). Only lines whose stripped
|
|
196
|
+
length exceeds :data:`_COLLAPSE_MIN_LINE_LEN` are eligible — short
|
|
197
|
+
repetitions (sign-offs, blank lines, single-word pleasantries) are left
|
|
198
|
+
alone.
|
|
199
|
+
"""
|
|
200
|
+
if not body:
|
|
201
|
+
return body
|
|
202
|
+
lines = body.split("\n")
|
|
203
|
+
out: list[str] = []
|
|
204
|
+
i = 0
|
|
205
|
+
while i < len(lines):
|
|
206
|
+
line = lines[i]
|
|
207
|
+
# Look for a run of identical lines starting at i.
|
|
208
|
+
j = i + 1
|
|
209
|
+
while j < len(lines) and lines[j] == line:
|
|
210
|
+
j += 1
|
|
211
|
+
run_length = j - i
|
|
212
|
+
if run_length >= 2 and len(line.strip()) > _COLLAPSE_MIN_LINE_LEN:
|
|
213
|
+
out.append(line)
|
|
214
|
+
else:
|
|
215
|
+
out.extend(lines[i:j])
|
|
216
|
+
i = j
|
|
217
|
+
return "\n".join(out)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def strip_boilerplate(body: str) -> str:
|
|
221
|
+
"""Remove repeated email signatures / disclaimers / mobile-app footers.
|
|
222
|
+
|
|
223
|
+
1. Collapse runs of identical lines (>40 chars) to one occurrence.
|
|
224
|
+
2. Strip patterns from ``BOILERPLATE_PATTERNS`` (config.py).
|
|
225
|
+
Quoted-reply markers below the most recent message are left alone for
|
|
226
|
+
single-message ingest; thread assembly (Phase 2) will revisit.
|
|
227
|
+
"""
|
|
228
|
+
if not body:
|
|
229
|
+
return body
|
|
230
|
+
body = _collapse_repeated_long_lines(body)
|
|
231
|
+
for regex in _BOILERPLATE_REGEXES:
|
|
232
|
+
body = regex.sub("", body)
|
|
233
|
+
return body.strip()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_date_header_to_iso_utc(raw: str | None) -> str | None:
|
|
237
|
+
"""Parse an RFC 2822 ``Date:`` header into an ISO-8601 UTC string.
|
|
238
|
+
|
|
239
|
+
Returns ``None`` when ``raw`` is missing/empty or unparseable so the
|
|
240
|
+
caller can omit the field rather than crash the ingest. Naive datetimes
|
|
241
|
+
(rare in real Gmail headers) are treated as UTC.
|
|
242
|
+
"""
|
|
243
|
+
if not raw or not raw.strip():
|
|
244
|
+
return None
|
|
245
|
+
try:
|
|
246
|
+
parsed = parsedate_to_datetime(raw)
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
return None
|
|
249
|
+
if parsed is None:
|
|
250
|
+
return None
|
|
251
|
+
if parsed.tzinfo is None:
|
|
252
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
253
|
+
return parsed.astimezone(UTC).isoformat()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_draft(msg: dict[str, Any]) -> bool:
|
|
257
|
+
"""Return True when the Gmail Message resource carries the ``DRAFT`` label.
|
|
258
|
+
|
|
259
|
+
Gmail's draft state is communicated via ``labelIds`` on the Message
|
|
260
|
+
resource. Drafts are unsent — the user typed them but never sent —
|
|
261
|
+
and ingesting them pollutes the searchable corpus. Ingest paths use
|
|
262
|
+
this helper to short-circuit on drafts before doing any work.
|
|
263
|
+
"""
|
|
264
|
+
label_ids = msg.get("labelIds") or []
|
|
265
|
+
return "DRAFT" in label_ids
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def to_extracted_doc(msg: dict[str, Any]) -> ExtractedDoc:
|
|
269
|
+
"""Build an :class:`ExtractedDoc` from a Gmail ``users.messages.get`` response.
|
|
270
|
+
|
|
271
|
+
Draft messages (``labelIds`` containing ``DRAFT``) are now included
|
|
272
|
+
rather than rejected. ``metadata["_is_draft"]`` (leading underscore
|
|
273
|
+
signals a derived/internal key, mirroring ``_participant_keys``) is
|
|
274
|
+
set to ``True`` so the ingest pipeline can stamp
|
|
275
|
+
``documents.draft = TRUE`` and the P1.6 wiki quarantine hides the
|
|
276
|
+
doc from Quartz while ``brain search`` / ``brain show`` still
|
|
277
|
+
surface it.
|
|
278
|
+
"""
|
|
279
|
+
payload = msg.get("payload") or {}
|
|
280
|
+
headers = _headers_to_dict(payload.get("headers") or [])
|
|
281
|
+
title = headers.get("subject") or "(no subject)"
|
|
282
|
+
body = strip_boilerplate(_extract_body(payload).strip())
|
|
283
|
+
metadata: dict[str, Any] = {
|
|
284
|
+
"from": headers.get("from"),
|
|
285
|
+
"to": headers.get("to"),
|
|
286
|
+
"date": headers.get("date"),
|
|
287
|
+
"message_id": msg.get("id"),
|
|
288
|
+
"thread_id": msg.get("threadId"),
|
|
289
|
+
"label_ids": msg.get("labelIds") or [],
|
|
290
|
+
"_is_draft": _is_draft(msg),
|
|
291
|
+
}
|
|
292
|
+
# New typed-column feeders (P1.3). Each key is omitted when the source
|
|
293
|
+
# header is absent so downstream column-promotion stays NULL rather than
|
|
294
|
+
# storing empty strings.
|
|
295
|
+
rfc_id = headers.get("message-id")
|
|
296
|
+
if rfc_id:
|
|
297
|
+
metadata["rfc_message_id"] = rfc_id
|
|
298
|
+
in_reply_to = headers.get("in-reply-to")
|
|
299
|
+
if in_reply_to:
|
|
300
|
+
metadata["in_reply_to"] = in_reply_to
|
|
301
|
+
sent_at = _parse_date_header_to_iso_utc(headers.get("date"))
|
|
302
|
+
if sent_at is not None:
|
|
303
|
+
metadata["sent_at"] = sent_at
|
|
304
|
+
return ExtractedDoc(
|
|
305
|
+
title=title,
|
|
306
|
+
content=body,
|
|
307
|
+
content_type="email",
|
|
308
|
+
source_path=None,
|
|
309
|
+
metadata=metadata,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# Thread assembly (P2.1)
|
|
315
|
+
#
|
|
316
|
+
# A Gmail thread is N messages sharing the same ``threadId``. P2.1 collapses
|
|
317
|
+
# them into one ``ExtractedDoc`` so downstream search / vault export operates
|
|
318
|
+
# on a single conversational unit instead of N near-duplicate per-message
|
|
319
|
+
# rows. The function is pure: no DB, no I/O, no logging at INFO.
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
# Cap the assembled-thread title at 200 chars (after Re/Fwd strip) so a
|
|
323
|
+
# pathological subject line — e.g. an auto-mailer that crams a multi-line
|
|
324
|
+
# log into the subject — doesn't blow out the ``documents.title`` column or
|
|
325
|
+
# the wiki UI. Truncation is whole-word-aware; the URL slug uses a separate
|
|
326
|
+
# 64-char cap inside ``vault.slug.gmail_slug``.
|
|
327
|
+
_THREAD_TITLE_MAX = 200
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _message_sort_key(msg: dict[str, Any]) -> int:
|
|
331
|
+
"""Return a milliseconds-since-epoch sort key for a Gmail message.
|
|
332
|
+
|
|
333
|
+
Prefer ``internalDate`` (Gmail's canonical receive timestamp, expressed
|
|
334
|
+
as a string of ms-since-epoch). When ``internalDate`` is missing or
|
|
335
|
+
unparseable, fall back to ``Date:`` header parsed via the standard
|
|
336
|
+
library. Returns ``0`` when neither is parseable — the call site keeps
|
|
337
|
+
such messages but their order is undefined; the spec only requires
|
|
338
|
+
defensive handling, not stable tie-breaking.
|
|
339
|
+
"""
|
|
340
|
+
raw = msg.get("internalDate")
|
|
341
|
+
if isinstance(raw, int):
|
|
342
|
+
return raw
|
|
343
|
+
if isinstance(raw, str) and raw.strip():
|
|
344
|
+
try:
|
|
345
|
+
return int(raw)
|
|
346
|
+
except ValueError:
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
payload = msg.get("payload") or {}
|
|
350
|
+
headers = _headers_to_dict(payload.get("headers") or [])
|
|
351
|
+
date_header = headers.get("date")
|
|
352
|
+
if date_header:
|
|
353
|
+
try:
|
|
354
|
+
parsed = parsedate_to_datetime(date_header)
|
|
355
|
+
except (TypeError, ValueError):
|
|
356
|
+
parsed = None
|
|
357
|
+
if parsed is not None:
|
|
358
|
+
if parsed.tzinfo is None:
|
|
359
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
360
|
+
return int(parsed.astimezone(UTC).timestamp() * 1000)
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _truncate_title_to_word(title: str, *, limit: int = _THREAD_TITLE_MAX) -> str:
|
|
365
|
+
"""Truncate ``title`` to ``limit`` chars at a word boundary, append ``…``.
|
|
366
|
+
|
|
367
|
+
Returns the input unchanged when ``len(title) <= limit``. Otherwise:
|
|
368
|
+
|
|
369
|
+
1. Take the first ``limit`` characters.
|
|
370
|
+
2. Cut back to the last space within that window so we don't slice
|
|
371
|
+
through a word. If no space exists (single very long token), keep
|
|
372
|
+
the hard slice.
|
|
373
|
+
3. ``rstrip`` trailing whitespace and append the U+2026 ellipsis.
|
|
374
|
+
|
|
375
|
+
Final length is at most ``limit + 1`` (one ellipsis char appended).
|
|
376
|
+
"""
|
|
377
|
+
if len(title) <= limit:
|
|
378
|
+
return title
|
|
379
|
+
chunk = title[:limit]
|
|
380
|
+
last_space = chunk.rfind(" ")
|
|
381
|
+
if last_space > 0:
|
|
382
|
+
chunk = chunk[:last_space]
|
|
383
|
+
return f"{chunk.rstrip()}…"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _split_addresses(value: str | None) -> list[str]:
|
|
387
|
+
"""Split a comma-separated To/Cc header value into individual entries.
|
|
388
|
+
|
|
389
|
+
Simple comma split — does NOT handle the rare RFC 5322 quoted-pair case
|
|
390
|
+
(``"Last, First" <addr>``). Test fixtures and live Gmail traffic stay
|
|
391
|
+
unquoted in practice; revisit with ``email.utils.getaddresses`` only if
|
|
392
|
+
we hit a real-world false-split. Empty / whitespace-only entries are
|
|
393
|
+
dropped.
|
|
394
|
+
"""
|
|
395
|
+
if not value:
|
|
396
|
+
return []
|
|
397
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _format_thread_section(msg: dict[str, Any], *, collapsed: bool) -> str:
|
|
401
|
+
"""Render one message as a Markdown section for the assembled thread body.
|
|
402
|
+
|
|
403
|
+
The most recent message uses a plain ``## YYYY-MM-DD HH:MM — <from>``
|
|
404
|
+
H2 (always expanded). Older messages wrap in ``<details><summary>``
|
|
405
|
+
so they collapse by default — both Markdown processors and Quartz
|
|
406
|
+
pass HTML through, and ``<details>`` is supported natively by every
|
|
407
|
+
modern browser.
|
|
408
|
+
|
|
409
|
+
Date format is ``YYYY-MM-DD HH:MM`` in UTC. The ``from`` value is the
|
|
410
|
+
raw header (``"Name <email>"``) for fidelity. Each message body passes
|
|
411
|
+
through :func:`strip_boilerplate` first.
|
|
412
|
+
"""
|
|
413
|
+
payload = msg.get("payload") or {}
|
|
414
|
+
headers = _headers_to_dict(payload.get("headers") or [])
|
|
415
|
+
raw_from = headers.get("from") or "(unknown sender)"
|
|
416
|
+
|
|
417
|
+
iso_utc = _parse_date_header_to_iso_utc(headers.get("date"))
|
|
418
|
+
if iso_utc is not None:
|
|
419
|
+
date_label = datetime.fromisoformat(iso_utc).astimezone(UTC).strftime(
|
|
420
|
+
"%Y-%m-%d %H:%M"
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
date_label = "(unknown date)"
|
|
424
|
+
|
|
425
|
+
body = strip_boilerplate(_extract_body(payload).strip())
|
|
426
|
+
heading = f"{date_label} — {raw_from}"
|
|
427
|
+
|
|
428
|
+
if collapsed:
|
|
429
|
+
# P4.4 fix: HTML-escape the summary content. Gmail headers
|
|
430
|
+
# routinely carry the From in `Name <email@addr>` form, and
|
|
431
|
+
# markdown processors (Quartz / CommonMark) treat raw HTML
|
|
432
|
+
# blocks like ``<details>...</details>`` as opaque pass-
|
|
433
|
+
# through. The browser then parses the inner ``<summary>Name
|
|
434
|
+
# <email@addr></summary>`` and treats ``<email@addr>`` as an
|
|
435
|
+
# unknown HTML tag — silently stripping it from the rendered
|
|
436
|
+
# text. Escaping ``<`` / ``>`` / ``&`` keeps the address
|
|
437
|
+
# visible in the rendered summary AND keeps the email
|
|
438
|
+
# substring available to the P4.4 "Show only my replies"
|
|
439
|
+
# JS filter (which reads ``summary.textContent`` and matches
|
|
440
|
+
# against ``window.BRAIN_USER_EMAIL``). Without the escape,
|
|
441
|
+
# the user's own historical replies inside ``<details>``
|
|
442
|
+
# mismatch the filter and stay visible when the toggle is on.
|
|
443
|
+
# Apply ``quote=False`` so single/double quotes pass through
|
|
444
|
+
# — the surrounding HTML uses no quote-delimited attributes
|
|
445
|
+
# on ``<summary>``.
|
|
446
|
+
escaped_heading = html.escape(heading, quote=False)
|
|
447
|
+
# Blank lines around the body are required for markdown processors
|
|
448
|
+
# (and Quartz) to render the inner content as markdown rather than
|
|
449
|
+
# a single HTML block.
|
|
450
|
+
return (
|
|
451
|
+
f"<details>\n"
|
|
452
|
+
f"<summary>{escaped_heading}</summary>\n"
|
|
453
|
+
f"\n"
|
|
454
|
+
f"{body}\n"
|
|
455
|
+
f"\n"
|
|
456
|
+
f"</details>"
|
|
457
|
+
)
|
|
458
|
+
return f"## {heading}\n\n{body}"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def to_extracted_thread(messages: list[dict[str, Any]]) -> ExtractedDoc:
|
|
462
|
+
"""Group N gmail messages from the same thread into one ExtractedDoc.
|
|
463
|
+
|
|
464
|
+
Pure function — no DB writes, no file I/O, no logging at INFO level.
|
|
465
|
+
Messages are sorted ascending by ``internalDate`` (defensive fallback to
|
|
466
|
+
``Date:`` header) and assembled into a single Markdown document, one
|
|
467
|
+
H2 per message. The most recent message renders as a plain H2; older
|
|
468
|
+
ones wrap in ``<details><summary>`` so they collapse by default.
|
|
469
|
+
|
|
470
|
+
Title is the FIRST message's subject after stripping leading
|
|
471
|
+
``Re:`` / ``Fwd:`` / ``Fw:`` prefixes (case-insensitive, repeated).
|
|
472
|
+
Empty subjects fall back to ``"(no subject)"``. Subjects longer than
|
|
473
|
+
200 chars are word-boundary truncated with a trailing ``…``.
|
|
474
|
+
|
|
475
|
+
Metadata aggregation — first-vs-latest is asymmetric on purpose:
|
|
476
|
+
|
|
477
|
+
- ``thread_id`` is the FIRST message's ``threadId`` (stable across the
|
|
478
|
+
whole thread; first-vs-last is a no-op in practice but the spec
|
|
479
|
+
pins ``first`` so re-ingestion is deterministic if a future Gmail
|
|
480
|
+
bug ever splits a thread mid-conversation).
|
|
481
|
+
- ``rfc_message_id``, ``in_reply_to``, ``from``, ``to``, ``date``,
|
|
482
|
+
``sent_at`` come from the LATEST message — the thread doc tracks
|
|
483
|
+
the most recent reply so an in-flight conversation surfaces with
|
|
484
|
+
its newest state.
|
|
485
|
+
- ``participants`` is the union of From + To + Cc across ALL
|
|
486
|
+
messages, deduped case-insensitively, sorted case-insensitively for
|
|
487
|
+
stability.
|
|
488
|
+
- ``label_ids`` is the sorted union of ``labelIds`` across all
|
|
489
|
+
messages — a thread inherits IMPORTANT / STARRED if any reply
|
|
490
|
+
carries it.
|
|
491
|
+
- ``message_count`` is N.
|
|
492
|
+
|
|
493
|
+
Tags are not produced here — the per-message extractor doesn't tag
|
|
494
|
+
either. Phase 2.4's destructive collapse will preserve tags by
|
|
495
|
+
unioning across the source per-message rows; new threaded ingests
|
|
496
|
+
pick tags up via ``brain tag <id> +foo`` post-ingest.
|
|
497
|
+
|
|
498
|
+
Draft handling is asymmetric on purpose:
|
|
499
|
+
|
|
500
|
+
- **All-draft thread**: every message carries the ``DRAFT`` label →
|
|
501
|
+
the full thread is assembled intact and
|
|
502
|
+
``metadata["_is_draft"] = True`` is set. The ingest pipeline stamps
|
|
503
|
+
``documents.draft = TRUE``; the P1.6 wiki quarantine hides the doc
|
|
504
|
+
from Quartz while ``brain search`` can still find it ("what was I
|
|
505
|
+
going to email person-x about?").
|
|
506
|
+
- **Mixed thread**: at least one sent message → drafts are dropped
|
|
507
|
+
from the rendered body (body, participants, label_ids, message_count
|
|
508
|
+
all reflect the post-filter sent set) and
|
|
509
|
+
``metadata["_is_draft"] = False`` is set. This prevents a WIP
|
|
510
|
+
unsent reply from appearing as the visible H2 in a published thread.
|
|
511
|
+
- **Empty input**: raises ``ValueError`` (programmer error — callers
|
|
512
|
+
must supply at least one message).
|
|
513
|
+
|
|
514
|
+
Raises:
|
|
515
|
+
ValueError: ``messages`` is empty.
|
|
516
|
+
"""
|
|
517
|
+
if not messages:
|
|
518
|
+
raise ValueError("to_extracted_thread requires at least one message")
|
|
519
|
+
|
|
520
|
+
all_drafts = all(_is_draft(m) for m in messages)
|
|
521
|
+
if all_drafts:
|
|
522
|
+
# All-draft thread: assemble the full list; the wiki quarantine
|
|
523
|
+
# hides the resulting doc; brain search still surfaces it.
|
|
524
|
+
sorted_msgs = sorted(messages, key=_message_sort_key)
|
|
525
|
+
else:
|
|
526
|
+
# Mixed or fully-sent thread: drop drafts from the rendered body
|
|
527
|
+
# so a WIP unsent reply doesn't appear as the visible H2.
|
|
528
|
+
sorted_msgs = sorted(
|
|
529
|
+
[m for m in messages if not _is_draft(m)], key=_message_sort_key
|
|
530
|
+
)
|
|
531
|
+
first = sorted_msgs[0]
|
|
532
|
+
latest = sorted_msgs[-1]
|
|
533
|
+
|
|
534
|
+
first_headers = _headers_to_dict(
|
|
535
|
+
(first.get("payload") or {}).get("headers") or []
|
|
536
|
+
)
|
|
537
|
+
latest_headers = _headers_to_dict(
|
|
538
|
+
(latest.get("payload") or {}).get("headers") or []
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
raw_subject = first_headers.get("subject") or ""
|
|
542
|
+
stripped_subject = _strip_re_fwd_prefixes(raw_subject).strip()
|
|
543
|
+
title = stripped_subject or "(no subject)"
|
|
544
|
+
title = _truncate_title_to_word(title)
|
|
545
|
+
|
|
546
|
+
last_idx = len(sorted_msgs) - 1
|
|
547
|
+
sections = [
|
|
548
|
+
_format_thread_section(msg, collapsed=(idx != last_idx))
|
|
549
|
+
for idx, msg in enumerate(sorted_msgs)
|
|
550
|
+
]
|
|
551
|
+
body = "\n\n".join(sections)
|
|
552
|
+
|
|
553
|
+
# Participants: From + To + Cc across every message, deduped case-
|
|
554
|
+
# insensitively (first-seen form wins so a "Alice <a@x.com>" sighting
|
|
555
|
+
# is preferred over a later "ALICE <a@x.com>"), then sorted case-
|
|
556
|
+
# insensitively for stability across re-ingests.
|
|
557
|
+
seen: dict[str, str] = {}
|
|
558
|
+
for msg in sorted_msgs:
|
|
559
|
+
headers = _headers_to_dict((msg.get("payload") or {}).get("headers") or [])
|
|
560
|
+
candidates: list[str] = []
|
|
561
|
+
from_hdr = headers.get("from")
|
|
562
|
+
if from_hdr:
|
|
563
|
+
candidates.append(from_hdr)
|
|
564
|
+
candidates.extend(_split_addresses(headers.get("to")))
|
|
565
|
+
candidates.extend(_split_addresses(headers.get("cc")))
|
|
566
|
+
for addr in candidates:
|
|
567
|
+
key = addr.casefold()
|
|
568
|
+
if key not in seen:
|
|
569
|
+
seen[key] = addr
|
|
570
|
+
participants = sorted(seen.values(), key=str.casefold)
|
|
571
|
+
|
|
572
|
+
label_ids: set[str] = set()
|
|
573
|
+
for msg in sorted_msgs:
|
|
574
|
+
label_ids.update(msg.get("labelIds") or [])
|
|
575
|
+
|
|
576
|
+
metadata: dict[str, Any] = {
|
|
577
|
+
"thread_id": first.get("threadId"),
|
|
578
|
+
"from": latest_headers.get("from"),
|
|
579
|
+
"to": latest_headers.get("to"),
|
|
580
|
+
"date": latest_headers.get("date"),
|
|
581
|
+
"label_ids": sorted(label_ids),
|
|
582
|
+
"participants": participants,
|
|
583
|
+
"message_count": len(sorted_msgs),
|
|
584
|
+
# Internal flag for the ingest pipeline: True when every message
|
|
585
|
+
# in the original (unfiltered) input was a DRAFT. The pipeline
|
|
586
|
+
# uses this to stamp documents.draft = TRUE and route the doc
|
|
587
|
+
# through the P1.6 wiki quarantine. Leading underscore mirrors
|
|
588
|
+
# the ``_participant_keys`` convention for derived fields.
|
|
589
|
+
"_is_draft": all_drafts,
|
|
590
|
+
}
|
|
591
|
+
rfc_id = latest_headers.get("message-id")
|
|
592
|
+
if rfc_id:
|
|
593
|
+
metadata["rfc_message_id"] = rfc_id
|
|
594
|
+
in_reply_to = latest_headers.get("in-reply-to")
|
|
595
|
+
if in_reply_to:
|
|
596
|
+
metadata["in_reply_to"] = in_reply_to
|
|
597
|
+
sent_at = _parse_date_header_to_iso_utc(latest_headers.get("date"))
|
|
598
|
+
if sent_at is not None:
|
|
599
|
+
metadata["sent_at"] = sent_at
|
|
600
|
+
|
|
601
|
+
return ExtractedDoc(
|
|
602
|
+
title=title,
|
|
603
|
+
content=body,
|
|
604
|
+
content_type="email_thread",
|
|
605
|
+
source_path=None,
|
|
606
|
+
metadata=metadata,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _run(cmd: list[str], runner: Runner | None = None) -> str:
|
|
611
|
+
"""Execute ``cmd`` and return stdout; delegate to ``runner`` when supplied (tests)."""
|
|
612
|
+
if runner is not None:
|
|
613
|
+
return runner(cmd)
|
|
614
|
+
if not shutil.which(cmd[0]): # pragma: no cover - requires missing gws on PATH
|
|
615
|
+
raise GmailError(f"`{cmd[0]}` CLI not found on PATH")
|
|
616
|
+
proc = subprocess.run( # pragma: no cover - exercised only against the real gws CLI
|
|
617
|
+
cmd, capture_output=True, text=True, check=False
|
|
618
|
+
)
|
|
619
|
+
if proc.returncode != 0: # pragma: no cover - requires real gws failure
|
|
620
|
+
raise GmailError(f"{' '.join(cmd)} failed: {proc.stderr.strip()}")
|
|
621
|
+
return proc.stdout # pragma: no cover - requires real gws success
|
brain/ingest/markdown.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Markdown extractor — preserves source syntax, surfaces heading list as metadata."""
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from . import ExtractedDoc
|
|
6
|
+
|
|
7
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_markdown(path: Path) -> ExtractedDoc:
|
|
11
|
+
"""Extract an :class:`ExtractedDoc` from a Markdown file on disk.
|
|
12
|
+
|
|
13
|
+
The body is preserved verbatim — code fences (``` ``` ```), heading
|
|
14
|
+
markers (``#``), bullet markers (``-``/``*``/``+``), inline emphasis,
|
|
15
|
+
and link URLs all survive into ``ExtractedDoc.content``. Earlier
|
|
16
|
+
versions of this extractor ran a flatten-to-plaintext pass before
|
|
17
|
+
storing, which silently erased code blocks, headings, and list
|
|
18
|
+
structure from the corpus — callers that round-trip through
|
|
19
|
+
``brain vault export`` lost rendering fidelity for any markdown that
|
|
20
|
+
wasn't pure prose.
|
|
21
|
+
|
|
22
|
+
Headings are still surfaced separately in ``metadata["headings"]`` for
|
|
23
|
+
callers that want a structured outline (search snippet builders,
|
|
24
|
+
etc.); the title is the first heading found, falling back to the
|
|
25
|
+
file's stem.
|
|
26
|
+
"""
|
|
27
|
+
raw = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
28
|
+
headings = [m.group(2).strip() for m in _HEADING_RE.finditer(raw)]
|
|
29
|
+
title = headings[0] if headings else Path(path).stem
|
|
30
|
+
|
|
31
|
+
return ExtractedDoc(
|
|
32
|
+
title=title,
|
|
33
|
+
content=raw.strip(),
|
|
34
|
+
content_type="markdown",
|
|
35
|
+
source_path=str(Path(path).resolve()),
|
|
36
|
+
metadata={"headings": headings},
|
|
37
|
+
)
|