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
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Brain wiki — email-thread reading mode runtime (P4.4 of the Wiki UX
|
|
2
|
+
// Overhaul).
|
|
3
|
+
//
|
|
4
|
+
// This file is a TEMPLATE. It is installed at
|
|
5
|
+
// `<vault>/.quartz/quartz/static/emailThread.js` by `brain vault render
|
|
6
|
+
// --overlay`, and is copied verbatim into every build's `static/` dir
|
|
7
|
+
// by Quartz's stock `Plugin.Static()` emitter (no extra wiring needed —
|
|
8
|
+
// Quartz already mirrors `quartz/static/` 1:1 into the build output).
|
|
9
|
+
// The script is referenced from page `<head>` by the brain
|
|
10
|
+
// `Plugin.EmailThreadReader` transformer's `externalResources()` hook
|
|
11
|
+
// (see `emailThread.ts` for the inject side).
|
|
12
|
+
//
|
|
13
|
+
// Tested against Quartz v4.5.x (April 2026). Plain vanilla JS — no
|
|
14
|
+
// transpile, no bundler — so it survives any future Quartz refactor of
|
|
15
|
+
// its plugin pipeline. The only contracts are (a) Quartz keeps copying
|
|
16
|
+
// `quartz/static/*` into `<build>/static/`, and (b) `brain.ingest.gmail
|
|
17
|
+
// .to_extracted_thread` keeps emitting `## YYYY-MM-DD HH:MM — <from>`
|
|
18
|
+
// for the latest message and `<details><summary>YYYY-MM-DD HH:MM —
|
|
19
|
+
// <from></summary>...` for older messages. If the markdown assembly
|
|
20
|
+
// shape changes, update `parseFromAddress` and the section walker
|
|
21
|
+
// together.
|
|
22
|
+
//
|
|
23
|
+
// What this script does, and why: an email_thread page (markdown body
|
|
24
|
+
// produced by `to_extracted_thread`) renders as a leading `<h2>` (the
|
|
25
|
+
// most recent message — always visible) followed by zero or more
|
|
26
|
+
// `<details>` elements (older messages, collapsed by default). The
|
|
27
|
+
// script enhances that shape with two affordances:
|
|
28
|
+
//
|
|
29
|
+
// 1. Annotates every message section with `data-brain-thread-from`
|
|
30
|
+
// (the From address parsed from the heading) and
|
|
31
|
+
// `data-brain-is-mine` ("true" iff the From contains the user's
|
|
32
|
+
// email — read from `window.BRAIN_USER_EMAIL`, baked in at build
|
|
33
|
+
// time by the EmailThreadReader transformer).
|
|
34
|
+
// 2. Renders a "Show only my replies" toggle button at the top of
|
|
35
|
+
// `<article>`. State persists in localStorage under
|
|
36
|
+
// `brain.email.repliesOnly` as a JSON boolean. When ON, the body
|
|
37
|
+
// gets a `brain-replies-only` class which `_email_thread.scss`
|
|
38
|
+
// uses to hide every `[data-brain-is-mine="false"]` section.
|
|
39
|
+
//
|
|
40
|
+
// Detection: only runs on email-thread pages. The detection heuristic
|
|
41
|
+
// is `/_ingested/gmail/` in the pathname AND the article contains
|
|
42
|
+
// either a `<details>` element or an `<h2>` whose text matches the
|
|
43
|
+
// "YYYY-MM-DD HH:MM — <from>" shape. Two-condition gate so a non-thread
|
|
44
|
+
// page that happens to live under `_ingested/gmail/` (unlikely, but
|
|
45
|
+
// defensive) doesn't get a stray button. Symmetric with how the
|
|
46
|
+
// explorer toggle (P4.2) gates itself.
|
|
47
|
+
//
|
|
48
|
+
// Idempotent: the wrapping logic and button render check for an
|
|
49
|
+
// existing wiring marker on `<article>` and skip if already done. Re-
|
|
50
|
+
// runs on Quartz SPA `nav` events so navigation between thread pages
|
|
51
|
+
// re-wires the new article.
|
|
52
|
+
//
|
|
53
|
+
// Browser support: targets evergreen Chrome / Firefox / Safari. Uses
|
|
54
|
+
// `document.querySelectorAll`, `dataset`, `classList`, and
|
|
55
|
+
// `addEventListener` — all baseline since 2017.
|
|
56
|
+
;(function () {
|
|
57
|
+
// brain: localStorage key for the "Show only my replies" filter
|
|
58
|
+
// state. Mirrors the `brain.<feature>.<setting>` namespace from
|
|
59
|
+
// search.inline.ts (`brain.search.activeSources`) and explorer
|
|
60
|
+
// .inline.ts (`brain.explorer.showIngested`) so brain prefs cluster
|
|
61
|
+
// cleanly under one prefix in devtools.
|
|
62
|
+
var REPLIES_ONLY_KEY = "brain.email.repliesOnly"
|
|
63
|
+
|
|
64
|
+
// brain: data attributes the SCSS reads. Centralised here so a
|
|
65
|
+
// future rename is a one-line change in JS + a search-replace in
|
|
66
|
+
// _email_thread.scss.
|
|
67
|
+
var FROM_ATTR = "data-brain-thread-from"
|
|
68
|
+
var IS_MINE_ATTR = "data-brain-is-mine"
|
|
69
|
+
var WIRED_ATTR = "data-brain-thread-wired"
|
|
70
|
+
|
|
71
|
+
// brain: body class flipped by the toggle. SCSS keys off this to
|
|
72
|
+
// hide non-user messages.
|
|
73
|
+
var REPLIES_ONLY_CLASS = "brain-replies-only"
|
|
74
|
+
|
|
75
|
+
// brain: section class added to wrapped latest-message + each
|
|
76
|
+
// <details>. Gives the SCSS a single hook regardless of whether the
|
|
77
|
+
// section is the leading H2 (wrapped at runtime) or a markdown
|
|
78
|
+
// <details>.
|
|
79
|
+
var SECTION_CLASS = "brain-thread-message"
|
|
80
|
+
var LATEST_CLASS = "brain-thread-latest"
|
|
81
|
+
|
|
82
|
+
// brain: button class. Matches the spec verbatim — the static test
|
|
83
|
+
// pins this literal so a future rename trips it.
|
|
84
|
+
var TOGGLE_CLASS = "brain-email-replies-only-toggle"
|
|
85
|
+
|
|
86
|
+
// brain: button labels. Factored out so the static test can pin
|
|
87
|
+
// them and a future i18n pass has a single seam to retarget.
|
|
88
|
+
var LABELS = {
|
|
89
|
+
show: "Show only my replies",
|
|
90
|
+
showingMine: "Show all messages",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// brain: heading separator between the date and the From address,
|
|
94
|
+
// emitted by `to_extracted_thread`'s `_format_thread_section`. Pinned
|
|
95
|
+
// here as a constant rather than an inline literal so a future
|
|
96
|
+
// markdown-shape change has a single seam.
|
|
97
|
+
var FROM_SEPARATOR = " — "
|
|
98
|
+
|
|
99
|
+
// brain: extract the `From` address from a heading text like
|
|
100
|
+
// `"2026-04-28 14:00 — Alice <alice@example.com>"`. Returns the
|
|
101
|
+
// substring after the first ` — ` (em-dash padded with single
|
|
102
|
+
// spaces — the exact form `to_extracted_thread` writes), or empty
|
|
103
|
+
// string when the separator is missing. We don't try to crack the
|
|
104
|
+
// address into name/local/domain — substring containment against
|
|
105
|
+
// `BRAIN_USER_EMAIL` is enough to decide ownership and tolerates
|
|
106
|
+
// both `Name <addr>` and bare-address forms.
|
|
107
|
+
function parseFromAddress(text) {
|
|
108
|
+
if (typeof text !== "string") return ""
|
|
109
|
+
var idx = text.indexOf(FROM_SEPARATOR)
|
|
110
|
+
if (idx < 0) return ""
|
|
111
|
+
return text.slice(idx + FROM_SEPARATOR.length).trim()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// brain: heuristic match for "this heading text looks like a
|
|
115
|
+
// per-message thread heading". Used for the email-thread page
|
|
116
|
+
// gate so a stray H2 (`## Conclusion`) on an unrelated page can't
|
|
117
|
+
// be mistaken for a thread message. The H2 must contain ` — ` AND
|
|
118
|
+
// start with a YYYY-MM-DD date pattern.
|
|
119
|
+
var THREAD_HEADING_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}\s+—\s+/
|
|
120
|
+
|
|
121
|
+
// brain: case-insensitive substring match between the parsed `from`
|
|
122
|
+
// and the user's email. We match on the email (not display name)
|
|
123
|
+
// because Gmail headers are stable on the address and noisy on the
|
|
124
|
+
// name (people change display names per client). Empty user email
|
|
125
|
+
// → no match (button still renders; toggling hides everything,
|
|
126
|
+
// which is the right "you forgot to set BRAIN_USER_EMAIL" cue).
|
|
127
|
+
function fromMatchesUser(from, userEmail) {
|
|
128
|
+
if (!from || !userEmail) return false
|
|
129
|
+
return from.toLowerCase().indexOf(userEmail.toLowerCase()) !== -1
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// brain: read the persisted toggle state. Default (no key, missing
|
|
133
|
+
// localStorage, parse failure, non-boolean) is `false` so a fresh
|
|
134
|
+
// visit shows every message — discoverable affordance over hidden-
|
|
135
|
+
// by-default. Try/catch covers Safari private mode which can throw
|
|
136
|
+
// on `localStorage.getItem`.
|
|
137
|
+
function loadRepliesOnly() {
|
|
138
|
+
if (typeof localStorage === "undefined") return false
|
|
139
|
+
try {
|
|
140
|
+
var raw = localStorage.getItem(REPLIES_ONLY_KEY)
|
|
141
|
+
if (raw === null) return false
|
|
142
|
+
var parsed = JSON.parse(raw)
|
|
143
|
+
return typeof parsed === "boolean" ? parsed : false
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// brain: persist the toggle state. Errors are swallowed —
|
|
150
|
+
// localStorage may throw `QuotaExceededError` in private mode, and
|
|
151
|
+
// a failed write should not tear down the article. Worst case the
|
|
152
|
+
// user re-flips the toggle next session.
|
|
153
|
+
function persistRepliesOnly(value) {
|
|
154
|
+
if (typeof localStorage === "undefined") return
|
|
155
|
+
try {
|
|
156
|
+
localStorage.setItem(REPLIES_ONLY_KEY, JSON.stringify(value))
|
|
157
|
+
} catch (err) {
|
|
158
|
+
// intentional: see docstring
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// brain: get the user's email from the `<script>` global the build-
|
|
163
|
+
// time transformer injected. Tolerates a missing global (script
|
|
164
|
+
// injection failed, dev-only build skipped the transformer, etc.) —
|
|
165
|
+
// returns "" rather than throwing.
|
|
166
|
+
function getUserEmail() {
|
|
167
|
+
var raw =
|
|
168
|
+
typeof window !== "undefined" && typeof window.BRAIN_USER_EMAIL === "string"
|
|
169
|
+
? window.BRAIN_USER_EMAIL
|
|
170
|
+
: ""
|
|
171
|
+
return raw.trim()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// brain: detect an email-thread page. Two-part gate:
|
|
175
|
+
// 1. URL contains `/_ingested/gmail/` (the brain mirror tier
|
|
176
|
+
// where thread pages live).
|
|
177
|
+
// 2. The article DOM has either a `<details>` element or an
|
|
178
|
+
// `<h2>` whose text matches the per-message heading shape.
|
|
179
|
+
// Both checks must pass — keeps the toggle off non-thread pages
|
|
180
|
+
// even if a future feature mounts a stray gmail-mirror page.
|
|
181
|
+
function isThreadPage(article) {
|
|
182
|
+
if (!article) return false
|
|
183
|
+
if (window.location.pathname.indexOf("/_ingested/gmail/") === -1) {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
if (article.querySelector("details")) return true
|
|
187
|
+
var h2s = article.querySelectorAll("h2")
|
|
188
|
+
for (var i = 0; i < h2s.length; i++) {
|
|
189
|
+
if (THREAD_HEADING_RE.test(h2s[i].textContent || "")) return true
|
|
190
|
+
}
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// brain: wrap the leading H2-message (and its trailing siblings up
|
|
195
|
+
// to the first `<details>` / end of article) in a synthetic
|
|
196
|
+
// `<section>` so the SCSS / filter has the same shape regardless of
|
|
197
|
+
// whether the section is markdown-emitted `<details>` or the latest
|
|
198
|
+
// plain H2. The wrapper carries the same data attributes so the
|
|
199
|
+
// toggle can hide it via the `[data-brain-is-mine]` selector.
|
|
200
|
+
//
|
|
201
|
+
// Rationale: `to_extracted_thread` renders the latest message as
|
|
202
|
+
// `## H2 + body` (test-pinned in `tests/test_gmail_thread.py`). We
|
|
203
|
+
// can't change that without churning the test contract; wrapping
|
|
204
|
+
// client-side gets us a uniform DOM shape with zero ingest-side
|
|
205
|
+
// change. Idempotent — checks `WIRED_ATTR` on `<article>` first.
|
|
206
|
+
function wrapLatestMessage(article, userEmail) {
|
|
207
|
+
var children = Array.prototype.slice.call(article.children)
|
|
208
|
+
var startIdx = -1
|
|
209
|
+
for (var i = 0; i < children.length; i++) {
|
|
210
|
+
var child = children[i]
|
|
211
|
+
if (
|
|
212
|
+
child.tagName === "H2" &&
|
|
213
|
+
THREAD_HEADING_RE.test(child.textContent || "")
|
|
214
|
+
) {
|
|
215
|
+
startIdx = i
|
|
216
|
+
break
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (startIdx === -1) return // nothing to wrap
|
|
220
|
+
|
|
221
|
+
var endIdx = children.length
|
|
222
|
+
for (var j = startIdx + 1; j < children.length; j++) {
|
|
223
|
+
var node = children[j]
|
|
224
|
+
if (
|
|
225
|
+
node.tagName === "DETAILS" ||
|
|
226
|
+
// P4.4 — the runtime button itself is also injected at the top
|
|
227
|
+
// of <article>; don't sweep it into the latest-message section.
|
|
228
|
+
(node.classList && node.classList.contains(TOGGLE_CLASS))
|
|
229
|
+
) {
|
|
230
|
+
endIdx = j
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
var section = document.createElement("section")
|
|
236
|
+
section.className = SECTION_CLASS + " " + LATEST_CLASS
|
|
237
|
+
var fromText = parseFromAddress(children[startIdx].textContent || "")
|
|
238
|
+
section.setAttribute(FROM_ATTR, fromText)
|
|
239
|
+
section.setAttribute(
|
|
240
|
+
IS_MINE_ATTR,
|
|
241
|
+
String(fromMatchesUser(fromText, userEmail)),
|
|
242
|
+
)
|
|
243
|
+
children[startIdx].parentNode.insertBefore(section, children[startIdx])
|
|
244
|
+
for (var k = startIdx; k < endIdx; k++) {
|
|
245
|
+
section.appendChild(children[k])
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// brain: stamp `data-brain-thread-from` + `data-brain-is-mine` on
|
|
250
|
+
// each `<details>` inside the article. The summary's text carries
|
|
251
|
+
// the same `YYYY-MM-DD HH:MM — <from>` heading we parse from H2s.
|
|
252
|
+
// Adds the section class for SCSS parity with the wrapped latest.
|
|
253
|
+
function annotateDetails(article, userEmail) {
|
|
254
|
+
var detailsList = article.querySelectorAll("details")
|
|
255
|
+
for (var i = 0; i < detailsList.length; i++) {
|
|
256
|
+
var d = detailsList[i]
|
|
257
|
+
if (d.hasAttribute(FROM_ATTR)) continue // idempotent
|
|
258
|
+
d.classList.add(SECTION_CLASS)
|
|
259
|
+
var summary = d.querySelector("summary")
|
|
260
|
+
var fromText = summary ? parseFromAddress(summary.textContent || "") : ""
|
|
261
|
+
d.setAttribute(FROM_ATTR, fromText)
|
|
262
|
+
d.setAttribute(
|
|
263
|
+
IS_MINE_ATTR,
|
|
264
|
+
String(fromMatchesUser(fromText, userEmail)),
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// brain: build + insert the toggle button at the top of <article>.
|
|
270
|
+
// Idempotent — bails if a button is already present (covers SPA
|
|
271
|
+
// re-init on the same article element). The button's
|
|
272
|
+
// `aria-pressed` mirrors the body class state for assistive tech.
|
|
273
|
+
function renderToggle(article) {
|
|
274
|
+
if (article.querySelector("." + TOGGLE_CLASS)) return
|
|
275
|
+
var pressed = loadRepliesOnly()
|
|
276
|
+
|
|
277
|
+
var button = document.createElement("button")
|
|
278
|
+
button.type = "button"
|
|
279
|
+
button.className = TOGGLE_CLASS
|
|
280
|
+
button.setAttribute("aria-pressed", String(pressed))
|
|
281
|
+
button.textContent = pressed ? LABELS.showingMine : LABELS.show
|
|
282
|
+
|
|
283
|
+
document.body.classList.toggle(REPLIES_ONLY_CLASS, pressed)
|
|
284
|
+
|
|
285
|
+
button.addEventListener("click", function () {
|
|
286
|
+
var next = !document.body.classList.contains(REPLIES_ONLY_CLASS)
|
|
287
|
+
document.body.classList.toggle(REPLIES_ONLY_CLASS, next)
|
|
288
|
+
persistRepliesOnly(next)
|
|
289
|
+
button.setAttribute("aria-pressed", String(next))
|
|
290
|
+
button.textContent = next ? LABELS.showingMine : LABELS.show
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
article.insertBefore(button, article.firstChild)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// brain: top-level entry. Locates `<article>`, gates on
|
|
297
|
+
// `isThreadPage`, then runs annotate + wrap + render. Idempotent at
|
|
298
|
+
// article granularity (`WIRED_ATTR`).
|
|
299
|
+
function init() {
|
|
300
|
+
var article = document.querySelector("article")
|
|
301
|
+
if (!article) return
|
|
302
|
+
if (!isThreadPage(article)) {
|
|
303
|
+
// Not a thread page — but still flip the body class so a stale
|
|
304
|
+
// `brain-replies-only` from a prior thread page doesn't bleed
|
|
305
|
+
// into a non-thread navigation.
|
|
306
|
+
document.body.classList.remove(REPLIES_ONLY_CLASS)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (article.getAttribute(WIRED_ATTR) === "true") {
|
|
310
|
+
// Re-runs of init() on the same article (SPA back/forward) just
|
|
311
|
+
// re-sync the body class with persisted state — no DOM rewrite.
|
|
312
|
+
document.body.classList.toggle(REPLIES_ONLY_CLASS, loadRepliesOnly())
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
var userEmail = getUserEmail()
|
|
317
|
+
annotateDetails(article, userEmail)
|
|
318
|
+
wrapLatestMessage(article, userEmail)
|
|
319
|
+
renderToggle(article)
|
|
320
|
+
article.setAttribute(WIRED_ATTR, "true")
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (document.readyState === "loading") {
|
|
324
|
+
document.addEventListener("DOMContentLoaded", init)
|
|
325
|
+
} else {
|
|
326
|
+
init()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// brain: re-run on Quartz SPA navigation. `enableSPA: true` in
|
|
330
|
+
// `quartz.config.ts` makes Quartz dispatch a `nav` event on
|
|
331
|
+
// `document` after each in-page route change — same hook the
|
|
332
|
+
// linkSourceTag.js script uses.
|
|
333
|
+
document.addEventListener("nav", init)
|
|
334
|
+
})()
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Brain wiki redesign — link source-tagger client script (Lane B).
|
|
2
|
+
//
|
|
3
|
+
// This file is a TEMPLATE. It is installed at
|
|
4
|
+
// `<vault>/.quartz/quartz/static/linkSourceTag.js` by `brain vault
|
|
5
|
+
// render --overlay`, and is copied verbatim into every build's
|
|
6
|
+
// `static/` dir by Quartz's stock `Plugin.Static()` emitter (no extra
|
|
7
|
+
// wiring needed — Quartz already mirrors `quartz/static/` 1:1 into
|
|
8
|
+
// the build output). The script is referenced from page `<head>` by
|
|
9
|
+
// the brain `Plugin.LinkSourceTag` transformer's `externalResources()`
|
|
10
|
+
// hook (see `linkSourceTag.ts` for the inject side).
|
|
11
|
+
//
|
|
12
|
+
// Tested against Quartz v4.5.x (April 2026). Plain vanilla JS — no
|
|
13
|
+
// transpile, no bundler — so it survives any future Quartz refactor
|
|
14
|
+
// of its plugin pipeline. The only contracts are (a) Quartz keeps
|
|
15
|
+
// copying `quartz/static/*` into `<build>/static/`, and (b) the
|
|
16
|
+
// brain redesign keeps the `data-brain-link-kind="ingested"` CSS
|
|
17
|
+
// attribute selector pattern.
|
|
18
|
+
//
|
|
19
|
+
// What this script does, and why: the `linkKindMark.ts` transformer
|
|
20
|
+
// stamps `data-brain-link-kind="ingested"` on every `<a>` whose URL
|
|
21
|
+
// starts with `_ingested/`, but it can't determine WHICH source
|
|
22
|
+
// (krisp/slack/gmail/manual) the target came from without a
|
|
23
|
+
// document-level lookup that's expensive at build time. The source
|
|
24
|
+
// is encoded in the URL path itself (`_ingested/<source>/<slug>`),
|
|
25
|
+
// so this 25-line client script extracts it once on `DOMContentLoaded`
|
|
26
|
+
// and stamps `data-brain-source="<source>"` on each ingested link.
|
|
27
|
+
// `_links.scss` reads that attribute via `&[data-brain-source="krisp"]`
|
|
28
|
+
// rules to pick the source-tinted left-rail color from the
|
|
29
|
+
// `--brain-source-*` token palette in `_tokens.scss`.
|
|
30
|
+
//
|
|
31
|
+
// Idempotent: if `data-brain-source` is already set (e.g. SPA
|
|
32
|
+
// navigation re-runs us, or a future build-time emitter pre-stamps
|
|
33
|
+
// it), we skip — first writer wins. Quartz's enableSPA mode
|
|
34
|
+
// dispatches `nav` events on each route change; the listener at the
|
|
35
|
+
// bottom re-runs the tagger so links rendered into the new page get
|
|
36
|
+
// tagged too.
|
|
37
|
+
//
|
|
38
|
+
// Browser support: targets evergreen Chrome / Firefox / Safari. Uses
|
|
39
|
+
// `document.querySelectorAll`, `forEach`, and `addEventListener` —
|
|
40
|
+
// all baseline since 2017.
|
|
41
|
+
;(function () {
|
|
42
|
+
// brain: the four sources `brain ingest --source <name>` accepts.
|
|
43
|
+
// Anything else falls back to "manual" via the `--brain-source-manual`
|
|
44
|
+
// CSS variable default in `_links.scss`. Keeping the allowlist
|
|
45
|
+
// explicit means we don't accidentally stamp `data-brain-source="."`
|
|
46
|
+
// on a malformed URL.
|
|
47
|
+
var KNOWN_SOURCES = ["krisp", "slack", "gmail", "manual"]
|
|
48
|
+
|
|
49
|
+
// brain: the data-attribute the SCSS reads. Mirrors the constant in
|
|
50
|
+
// `_links.scss`. If you rename one, rename both.
|
|
51
|
+
var SOURCE_ATTR = "data-brain-source"
|
|
52
|
+
|
|
53
|
+
// brain: extract the source segment from a `_ingested/<source>/...`
|
|
54
|
+
// URL. Handles three forms the build may emit:
|
|
55
|
+
// * `_ingested/krisp/...` (relative)
|
|
56
|
+
// * `/_ingested/krisp/...` (absolute)
|
|
57
|
+
// * `./_ingested/krisp/...` (explicit relative)
|
|
58
|
+
// Returns the source string (lowercased, validated against the
|
|
59
|
+
// allowlist) or `null` if extraction fails.
|
|
60
|
+
function sourceFromHref(href) {
|
|
61
|
+
if (typeof href !== "string") return null
|
|
62
|
+
// Strip query strings + fragments before splitting on `/`.
|
|
63
|
+
var clean = href.split("?")[0].split("#")[0]
|
|
64
|
+
var match = clean.match(/(?:^|\/|\.\/)_ingested\/([^/]+)/)
|
|
65
|
+
if (match === null) return null
|
|
66
|
+
var source = match[1].toLowerCase()
|
|
67
|
+
if (KNOWN_SOURCES.indexOf(source) === -1) return null
|
|
68
|
+
return source
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// brain: tag every ingested-link `<a>` in the document. Selector
|
|
72
|
+
// covers both the relative and absolute href forms; the prefix
|
|
73
|
+
// check below also covers the explicit `./` form. Idempotent —
|
|
74
|
+
// skips elements that already carry `data-brain-source`.
|
|
75
|
+
function tagAll() {
|
|
76
|
+
var nodes = document.querySelectorAll(
|
|
77
|
+
'a[href^="_ingested/"], a[href*="/_ingested/"], a[href^="./_ingested/"]',
|
|
78
|
+
)
|
|
79
|
+
nodes.forEach(function (a) {
|
|
80
|
+
if (a.hasAttribute(SOURCE_ATTR)) return
|
|
81
|
+
var source = sourceFromHref(a.getAttribute("href"))
|
|
82
|
+
if (source === null) return
|
|
83
|
+
a.setAttribute(SOURCE_ATTR, source)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// brain: run on first DOM ready. `DOMContentLoaded` fires before
|
|
88
|
+
// first paint of below-fold content, so any flash-of-unstyled-link
|
|
89
|
+
// is bounded to above-fold links — acceptable per the redesign
|
|
90
|
+
// plan's risk note.
|
|
91
|
+
if (document.readyState === "loading") {
|
|
92
|
+
document.addEventListener("DOMContentLoaded", tagAll)
|
|
93
|
+
} else {
|
|
94
|
+
tagAll()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// brain: re-run on Quartz SPA navigation. `enableSPA: true` in
|
|
98
|
+
// `quartz.config.ts` makes Quartz dispatch a `nav` event on
|
|
99
|
+
// `document` after each in-page route change (see
|
|
100
|
+
// `quartz/components/scripts/spa.inline.ts` upstream). Listening for
|
|
101
|
+
// it keeps source tagging consistent across the whole session
|
|
102
|
+
// without a full page reload.
|
|
103
|
+
document.addEventListener("nav", tagAll)
|
|
104
|
+
})()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Brain wiki — live relative-date client script (home-page Recent rail).
|
|
2
|
+
//
|
|
3
|
+
// This file is a TEMPLATE. It is installed at
|
|
4
|
+
// `<vault>/.quartz/quartz/static/relativeDate.js` by `brain vault
|
|
5
|
+
// render --overlay`, and is copied verbatim into every build's
|
|
6
|
+
// `static/` dir by Quartz's stock `Plugin.Static()` emitter (no extra
|
|
7
|
+
// wiring needed — Quartz already mirrors `quartz/static/` 1:1 into
|
|
8
|
+
// the build output). The script is referenced from page `<head>` by
|
|
9
|
+
// the brain `Plugin.RelativeDate` transformer's `externalResources()`
|
|
10
|
+
// hook (see `relativeDate.ts` for the inject side).
|
|
11
|
+
//
|
|
12
|
+
// Tested against Quartz v4.5.x (April 2026). Plain vanilla JS — no
|
|
13
|
+
// transpile, no bundler — so it survives any future Quartz refactor
|
|
14
|
+
// of its plugin pipeline. The only contracts are (a) Quartz keeps
|
|
15
|
+
// copying `quartz/static/*` into `<build>/static/`, and (b) the
|
|
16
|
+
// recent-rail markup keeps emitting `<span class="brain-rel-date"
|
|
17
|
+
// data-date="YYYY-MM-DD">…</span>` (see
|
|
18
|
+
// `brain.wiki.build_homepage._render_bullets`). The `data-date` is a plain
|
|
19
|
+
// CALENDAR date (`YYYY-MM-DD`, no time, no offset) — NOT a full ISO 8601
|
|
20
|
+
// timestamp. It is parsed here as a LOCAL naive date (no UTC parse), so a
|
|
21
|
+
// Krisp meeting stored at UTC-midnight renders on its real calendar day in
|
|
22
|
+
// every timezone instead of shifting back a day.
|
|
23
|
+
//
|
|
24
|
+
// What this script does, and why: the recent rail in `index.md` used to
|
|
25
|
+
// bake a relative string ("today", "3d ago") at BUILD time. That string
|
|
26
|
+
// decays — the home note isn't re-rendered daily, so a doc ingested 3
|
|
27
|
+
// days before a build still reads "3d ago" weeks later. The server now
|
|
28
|
+
// emits the ABSOLUTE date as `data-date` (the machine-readable source of
|
|
29
|
+
// truth) and a non-decaying "Jun 10"-style fallback as the span's text.
|
|
30
|
+
// This script recomputes the relative bucket client-side on every page
|
|
31
|
+
// load + SPA navigation, so the rail is always honest relative to the
|
|
32
|
+
// reader's current local date.
|
|
33
|
+
//
|
|
34
|
+
// BUCKET PARITY: this MUST mirror
|
|
35
|
+
// `brain.wiki.build_homepage._format_relative_date` exactly — same
|
|
36
|
+
// calendar-day delta (LOCAL time; compare local-midnight dates, not 24h
|
|
37
|
+
// windows) and the same four buckets:
|
|
38
|
+
// * delta <= 0 (today or future) → "today"
|
|
39
|
+
// * 1..6 → "Nd ago"
|
|
40
|
+
// * 7..34 → "Nw ago" (delta // 7)
|
|
41
|
+
// * >= 35 → "Mon D" (e.g. "Jun 10")
|
|
42
|
+
// If you change a bucket here, change it there too (and the parity test
|
|
43
|
+
// in `tests/test_brain_recent_homepage.py`).
|
|
44
|
+
//
|
|
45
|
+
// Idempotent + safe: a missing or unparseable `data-date` leaves the
|
|
46
|
+
// element's existing text (the absolute fallback) untouched, so the
|
|
47
|
+
// reader never sees a blank or "Invalid Date" cell. Re-runs on the
|
|
48
|
+
// Quartz `nav` SPA event so spans rendered into a new route get the live
|
|
49
|
+
// text too.
|
|
50
|
+
//
|
|
51
|
+
// Browser support: targets evergreen Chrome / Firefox / Safari. Uses
|
|
52
|
+
// `document.querySelectorAll`, `forEach`, `addEventListener`, and the
|
|
53
|
+
// `Date` API — all baseline since 2017.
|
|
54
|
+
;(function () {
|
|
55
|
+
// brain: English month abbreviations, index 0 = January. Hard-coded
|
|
56
|
+
// (NOT `toLocaleString`) so the rendered "Mon D" fallback matches
|
|
57
|
+
// Python's `strftime("%b")` English output regardless of the browser
|
|
58
|
+
// locale — avoids env drift between the server-baked fallback and the
|
|
59
|
+
// client-recomputed >= 35-day bucket.
|
|
60
|
+
var MONTHS = [
|
|
61
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
62
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
// brain: project a Date onto local midnight so the delta is a calendar-
|
|
66
|
+
// day count (matching Python's local-midnight calendar-day delta; the
|
|
67
|
+
// UTC-vs-local date decision is made server-side in
|
|
68
|
+
// `_recent_calendar_date`, then emitted as the `YYYY-MM-DD` `data-date`
|
|
69
|
+
// this script parses as a local naive date), not a raw elapsed-hours
|
|
70
|
+
// count. Without this, a doc ingested at 23:59 yesterday vs one at 00:01
|
|
71
|
+
// today would mis-bucket near the boundary.
|
|
72
|
+
function localMidnight(d) {
|
|
73
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// brain: compute the relative-date text for a `YYYY-MM-DD` `data-date`.
|
|
77
|
+
// Parses STRICTLY as a calendar date — split the parts and build a LOCAL
|
|
78
|
+
// midnight `Date` (`new Date(y, m-1, d)`), NEVER `new Date(iso)` (which
|
|
79
|
+
// would parse a bare `YYYY-MM-DD` as UTC and shift the day back in
|
|
80
|
+
// negative-offset zones — the off-by-one bug). Returns `null` when the
|
|
81
|
+
// value is missing or doesn't match the calendar-date shape (empty,
|
|
82
|
+
// garbage, or an old full-ISO timestamp) so the caller leaves the absolute
|
|
83
|
+
// fallback in place. Mirrors the bucket order in `_format_relative_date`.
|
|
84
|
+
function relativeText(iso) {
|
|
85
|
+
if (typeof iso !== "string" || iso.length === 0) return null
|
|
86
|
+
var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)
|
|
87
|
+
if (m === null) return null
|
|
88
|
+
var when = new Date(
|
|
89
|
+
parseInt(m[1], 10),
|
|
90
|
+
parseInt(m[2], 10) - 1,
|
|
91
|
+
parseInt(m[3], 10),
|
|
92
|
+
)
|
|
93
|
+
if (isNaN(when.getTime())) return null
|
|
94
|
+
|
|
95
|
+
var nowMid = localMidnight(new Date())
|
|
96
|
+
var whenMid = localMidnight(when)
|
|
97
|
+
// Whole-day delta. `getTime()` is ms since epoch; dividing the local-
|
|
98
|
+
// midnight difference by a day yields an integer calendar-day count.
|
|
99
|
+
// Round to absorb any DST-induced sub-millisecond drift.
|
|
100
|
+
var deltaDays = Math.round(
|
|
101
|
+
(nowMid.getTime() - whenMid.getTime()) / 86400000,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if (deltaDays <= 0) return "today"
|
|
105
|
+
if (deltaDays < 7) return deltaDays + "d ago"
|
|
106
|
+
if (deltaDays < 35) return Math.floor(deltaDays / 7) + "w ago"
|
|
107
|
+
// >= 35 days → absolute "Mon D" (no leading zero on the day), matching
|
|
108
|
+
// the server's `_format_absolute_date`.
|
|
109
|
+
return MONTHS[whenMid.getMonth()] + " " + whenMid.getDate()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// brain: rewrite every recent-rail span's text from its `data-date`.
|
|
113
|
+
// Idempotent — recomputing from `data-date` (never from the existing
|
|
114
|
+
// text) means repeated runs converge on the same value, and a bad
|
|
115
|
+
// `data-date` is a no-op (the absolute fallback text stays put).
|
|
116
|
+
function updateAll() {
|
|
117
|
+
var nodes = document.querySelectorAll(".brain-rel-date[data-date]")
|
|
118
|
+
nodes.forEach(function (el) {
|
|
119
|
+
var text = relativeText(el.getAttribute("data-date"))
|
|
120
|
+
if (text === null) return
|
|
121
|
+
el.textContent = text
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// brain: run on first DOM ready. `DOMContentLoaded` fires before first
|
|
126
|
+
// paint of below-fold content, so any flash of the absolute fallback is
|
|
127
|
+
// bounded to above-fold spans — acceptable, and the fallback is a real
|
|
128
|
+
// date either way.
|
|
129
|
+
if (document.readyState === "loading") {
|
|
130
|
+
document.addEventListener("DOMContentLoaded", updateAll)
|
|
131
|
+
} else {
|
|
132
|
+
updateAll()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// brain: re-run on Quartz SPA navigation. `enableSPA: true` in
|
|
136
|
+
// `quartz.config.ts` makes Quartz dispatch a `nav` event on `document`
|
|
137
|
+
// after each in-page route change (see
|
|
138
|
+
// `quartz/components/scripts/spa.inline.ts` upstream). Listening for it
|
|
139
|
+
// keeps the relative dates live across the whole session without a full
|
|
140
|
+
// page reload.
|
|
141
|
+
document.addEventListener("nav", updateAll)
|
|
142
|
+
})()
|