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.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,768 @@
1
+ // Brain explorer inline script — overlay-replacement of Quartz's stock
2
+ // `quartz/components/scripts/explorer.inline.ts`.
3
+ //
4
+ // This file is installed at
5
+ // `<vault>/.quartz/quartz/components/scripts/explorer.inline.ts` by
6
+ // `brain vault render --overlay` (`src/brain/vault/quartz_overlay.py`),
7
+ // 1:1 file overwrite. The stock `Explorer.tsx` imports
8
+ // `./scripts/explorer.inline`, so this override is the canonical seam
9
+ // for changing explorer client-side behaviour without forking the
10
+ // component itself.
11
+ //
12
+ // brain delta vs upstream (Quartz v4.5.x, April 2026):
13
+ // * `countFiles(node)` — recursive count of leaf (non-folder)
14
+ // descendants under a folder node.
15
+ // * `createFolderNode` — appends a `<span class="folder-count">N</span>`
16
+ // immediately after the folder title (whether title is a button or
17
+ // anchor, depending on `folderClickBehavior`). Style lives in
18
+ // `quartz_overrides/quartz/styles/brain/_sidebar.scss`.
19
+ // * P4.2 (Wiki UX Overhaul, item 7) — "Show ingested" toggle button
20
+ // above the explorer tree. State persists in `localStorage` under
21
+ // `brain.explorer.showIngested` as a JSON boolean. Default = `false`
22
+ // (OFF, hides the `_ingested/` top-level folder + descendants from
23
+ // the rendered tree, which also drops them from `.folder-count`
24
+ // totals because the count is computed from the post-filter trie).
25
+ // Click flips state, re-renders the tree, and updates the button
26
+ // label. The injection is script-side (rather than SSR-side via an
27
+ // Explorer.tsx override) to keep the upstream component unforked —
28
+ // same pattern as the `.folder-count` badge above.
29
+ // * P4.3 (Wiki UX Overhaul, item 9) — per-source month grouping for
30
+ // `_ingested/krisp/` and `_ingested/gmail/`. When `createFolderNode`
31
+ // descends into either of those folders, the children are grouped
32
+ // by `YYYY-MM` parsed from the slug-segment date prefix (krisp +
33
+ // gmail slugs are uniformly `<YYYY-MM-DD>-<rest>.md` per the P1.4 /
34
+ // P1.5 slug contracts). Groups render newest month first; files
35
+ // within a month sort by day descending. Each file's link text is
36
+ // rewritten to `<MMM D> · <Title>` so the meeting / thread date is
37
+ // visible without expanding the doc. Group headers are SSR-free
38
+ // `<li class="brain-explorer-month-header">` rows — non-collapsible
39
+ // to keep the patch minimal (no synthetic folder-state to persist).
40
+ // Implemented as a `groupFn` extension to the existing tree builder:
41
+ // the `isMonthGroupedFolder()` predicate gates the branch so every
42
+ // other folder renders through the original child loop unchanged.
43
+ //
44
+ // Everything else is verbatim from upstream. When upgrading Quartz,
45
+ // diff this file against the new
46
+ // https://github.com/jackyzha0/quartz/blob/v4/quartz/components/scripts/explorer.inline.ts
47
+ // and re-apply the brain deltas above.
48
+
49
+ import { FileTrieNode } from "../../util/fileTrie"
50
+ import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
51
+ import { ContentDetails } from "../../plugins/emitters/contentIndex"
52
+
53
+ // brain (P4.2): localStorage key for the "Show ingested" toggle.
54
+ // Mirrors the `brain.search.activeSources` idiom from search.inline.ts —
55
+ // dotted prefix `brain.<feature>.<setting>` so future brain-prefixed
56
+ // preferences cluster cleanly under one namespace inspectable via
57
+ // devtools.
58
+ const SHOW_INGESTED_KEY = "brain.explorer.showIngested"
59
+
60
+ // brain (P4.2): top-level folder segment to hide when the toggle is
61
+ // OFF. Pinned as a constant (rather than inlined in the predicate) so
62
+ // a future brain rename of the ingested-mirror tree is a one-line
63
+ // change here. The value matches the path-form heuristic used by the
64
+ // contentIndex emitter (`slug.startsWith("_ingested/")`) and the
65
+ // `inferSource()` helper in search.inline.ts — keeping the literal in
66
+ // one place per file documents the cross-file coordination.
67
+ const INGESTED_FOLDER_SEGMENT = "_ingested"
68
+
69
+ // brain (P4.2): button-label strings, factored out so the static
70
+ // source-test can pin them and a future i18n pass has a single seam
71
+ // to retarget.
72
+ const SHOW_INGESTED_LABELS = {
73
+ show: "Show ingested",
74
+ hide: "Hide ingested",
75
+ } as const
76
+
77
+ type MaybeHTMLElement = HTMLElement | undefined
78
+
79
+ // brain (P4.2): read the persisted "Show ingested" preference. Default
80
+ // (no key, missing localStorage, parse failure, non-boolean) is
81
+ // `false` so a fresh visitor sees the curated vault tree without the
82
+ // ingested noise. Try/catch covers private-mode Safari which can
83
+ // throw on `localStorage.getItem`.
84
+ function loadShowIngested(): boolean {
85
+ if (typeof localStorage === "undefined") return false
86
+ try {
87
+ const raw = localStorage.getItem(SHOW_INGESTED_KEY)
88
+ if (raw === null) return false
89
+ const parsed = JSON.parse(raw)
90
+ return typeof parsed === "boolean" ? parsed : false
91
+ } catch {
92
+ return false
93
+ }
94
+ }
95
+
96
+ // brain (P4.2): persist the toggle state. Errors are swallowed —
97
+ // localStorage may throw (`QuotaExceededError` in private mode); a
98
+ // failed write should not tear down the explorer. Worst case the user
99
+ // flips the chip again next session.
100
+ function persistShowIngested(value: boolean): void {
101
+ if (typeof localStorage === "undefined") return
102
+ try {
103
+ localStorage.setItem(SHOW_INGESTED_KEY, JSON.stringify(value))
104
+ } catch {
105
+ // intentional: see docstring
106
+ }
107
+ }
108
+
109
+ interface ParsedOptions {
110
+ folderClickBehavior: "collapse" | "link"
111
+ folderDefaultState: "collapsed" | "open"
112
+ useSavedState: boolean
113
+ sortFn: (a: FileTrieNode, b: FileTrieNode) => number
114
+ filterFn: (node: FileTrieNode) => boolean
115
+ mapFn: (node: FileTrieNode) => void
116
+ order: "sort" | "filter" | "map"[]
117
+ }
118
+
119
+ type FolderState = {
120
+ path: string
121
+ collapsed: boolean
122
+ }
123
+
124
+ let currentExplorerState: Array<FolderState>
125
+ function toggleExplorer(this: HTMLElement) {
126
+ const nearestExplorer = this.closest(".explorer") as HTMLElement
127
+ if (!nearestExplorer) return
128
+ const explorerCollapsed = nearestExplorer.classList.toggle("collapsed")
129
+ nearestExplorer.setAttribute(
130
+ "aria-expanded",
131
+ nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
132
+ )
133
+
134
+ if (!explorerCollapsed) {
135
+ // Stop <html> from being scrollable when mobile explorer is open
136
+ document.documentElement.classList.add("mobile-no-scroll")
137
+ } else {
138
+ document.documentElement.classList.remove("mobile-no-scroll")
139
+ }
140
+ }
141
+
142
+ function toggleFolder(evt: MouseEvent) {
143
+ evt.stopPropagation()
144
+ const target = evt.target as MaybeHTMLElement
145
+ if (!target) return
146
+
147
+ // Check if target was svg icon or button
148
+ const isSvg = target.nodeName === "svg"
149
+
150
+ // corresponding <ul> element relative to clicked button/folder
151
+ const folderContainer = (
152
+ isSvg
153
+ ? // svg -> div.folder-container
154
+ target.parentElement
155
+ : // button.folder-button -> div -> div.folder-container
156
+ target.parentElement?.parentElement
157
+ ) as MaybeHTMLElement
158
+ if (!folderContainer) return
159
+ const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
160
+ if (!childFolderContainer) return
161
+
162
+ childFolderContainer.classList.toggle("open")
163
+
164
+ // Collapse folder container
165
+ const isCollapsed = !childFolderContainer.classList.contains("open")
166
+ setFolderState(childFolderContainer, isCollapsed)
167
+
168
+ const currentFolderState = currentExplorerState.find(
169
+ (item) => item.path === folderContainer.dataset.folderpath,
170
+ )
171
+ if (currentFolderState) {
172
+ currentFolderState.collapsed = isCollapsed
173
+ } else {
174
+ currentExplorerState.push({
175
+ path: folderContainer.dataset.folderpath as FullSlug,
176
+ collapsed: isCollapsed,
177
+ })
178
+ }
179
+
180
+ const stringifiedFileTree = JSON.stringify(currentExplorerState)
181
+ localStorage.setItem("fileTree", stringifiedFileTree)
182
+ }
183
+
184
+ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
185
+ const template = document.getElementById("template-file") as HTMLTemplateElement
186
+ const clone = template.content.cloneNode(true) as DocumentFragment
187
+ const li = clone.querySelector("li") as HTMLLIElement
188
+ const a = li.querySelector("a") as HTMLAnchorElement
189
+ a.href = resolveRelative(currentSlug, node.slug)
190
+ a.dataset.for = node.slug
191
+ a.textContent = node.displayName
192
+
193
+ if (currentSlug === node.slug) {
194
+ a.classList.add("active")
195
+ }
196
+
197
+ return li
198
+ }
199
+
200
+ // brain (P4.3): the simple-slug paths whose direct children are
201
+ // grouped by `YYYY-MM` parsed from the slug date prefix. Path-form
202
+ // (post-`simplifySlug`) so we match what the trie produces after
203
+ // stripping the synthetic `/index` suffix from folder nodes. Note
204
+ // the **trailing slash** — `simplifySlug("foo/bar/index")` returns
205
+ // `"foo/bar/"` (the `stripSlashes(s, true)` call inside `simplifySlug`
206
+ // only strips the *leading* slash; trailing is preserved). Verified
207
+ // by upstream `quartz/util/path.test.ts::simplifySlug` which asserts
208
+ // `simplifySlug("abc/index") === "abc/"`.
209
+ const MONTH_GROUPED_FOLDERS: ReadonlySet<string> = new Set([
210
+ "_ingested/krisp/",
211
+ "_ingested/gmail/",
212
+ ])
213
+
214
+ // brain (P4.3): slug-segment date-prefix matcher. The slug for ingested
215
+ // krisp / gmail docs is uniformly `<YYYY>-<MM>-<DD>-<external_id>-<slug>`
216
+ // per `_krisp_slug()` / `gmail_slug()`. The capture groups feed
217
+ // `parseSlugDate`; everything past the date prefix is irrelevant.
218
+ const SLUG_DATE_PREFIX_RE = /^(\d{4})-(\d{2})-(\d{2})/
219
+
220
+ // brain (P4.3): localized month names. Indexed 0..11 to match
221
+ // `Date.getMonth()` semantics so a future swap to `Intl.DateTimeFormat`
222
+ // is a single-line drop-in. Long names render in the group header
223
+ // ("April 2026"), short names render in the per-file date prefix
224
+ // ("Apr 15 · Title"). Pinned literals (rather than `Intl`) so the
225
+ // static source-test in `tests/test_explorer_groupfn.py` can anchor
226
+ // on them and so SSR-free rendering doesn't depend on the visitor's
227
+ // browser locale.
228
+ const MONTH_LABELS_LONG = [
229
+ "January", "February", "March", "April", "May", "June",
230
+ "July", "August", "September", "October", "November", "December",
231
+ ] as const
232
+
233
+ const MONTH_LABELS_SHORT = [
234
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
235
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
236
+ ] as const
237
+
238
+ // brain (P4.3): visual separator between the date prefix and the
239
+ // document title in the rewritten link text. Middle-dot reads as a
240
+ // soft cue without competing with the title's typography (the explorer
241
+ // already uses em-dash for folder counts).
242
+ const MONTH_DATE_SEPARATOR = " · "
243
+
244
+ interface ParsedSlugDate {
245
+ year: number
246
+ month: number // 1..12
247
+ day: number // 1..31
248
+ }
249
+
250
+ // brain (P4.3): parse `YYYY-MM-DD` prefix from a slug segment. Returns
251
+ // `null` when the slug doesn't carry a leading date (defensive — covers
252
+ // hand-dropped non-ingested files in `_ingested/krisp/` and any future
253
+ // slug-shape change). Bounds-check month/day so a malformed slug like
254
+ // `2026-13-99-...` doesn't yield a phantom group.
255
+ function parseSlugDate(slugSegment: string): ParsedSlugDate | null {
256
+ const match = SLUG_DATE_PREFIX_RE.exec(slugSegment)
257
+ if (!match) return null
258
+ const year = parseInt(match[1], 10)
259
+ const month = parseInt(match[2], 10)
260
+ const day = parseInt(match[3], 10)
261
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
262
+ return null
263
+ }
264
+ if (month < 1 || month > 12) return null
265
+ if (day < 1 || day > 31) return null
266
+ return { year, month, day }
267
+ }
268
+
269
+ // brain (P4.3): `YYYY-MM` group key. Stable lexicographic sort matches
270
+ // chronological sort because the year is fixed-width and zero-padded.
271
+ function monthKey(parsed: ParsedSlugDate): string {
272
+ return `${parsed.year}-${String(parsed.month).padStart(2, "0")}`
273
+ }
274
+
275
+ // brain (P4.3): human-readable group header label, e.g. `April 2026`.
276
+ function monthGroupLabel(parsed: ParsedSlugDate): string {
277
+ const name = MONTH_LABELS_LONG[parsed.month - 1] ?? ""
278
+ return `${name} ${parsed.year}`
279
+ }
280
+
281
+ // brain (P4.3): per-file short date label, e.g. `Apr 15`.
282
+ function monthDayShort(parsed: ParsedSlugDate): string {
283
+ const name = MONTH_LABELS_SHORT[parsed.month - 1] ?? ""
284
+ return `${name} ${parsed.day}`
285
+ }
286
+
287
+ // brain (P4.3): activation predicate. `simplifySlug` turns the trie's
288
+ // folder slug `_ingested/krisp/index` into the simple `_ingested/krisp`
289
+ // form pinned in `MONTH_GROUPED_FOLDERS`. Folders outside this set fall
290
+ // through to the original child-iteration loop in `createFolderNode`.
291
+ function isMonthGroupedFolder(folderPath: FullSlug): boolean {
292
+ return MONTH_GROUPED_FOLDERS.has(simplifySlug(folderPath))
293
+ }
294
+
295
+ // brain (P4.3): rewrite a file node's link text to `<MMM D> · <Title>`.
296
+ // Built on top of `createFileNode` so the href / `data-for` /
297
+ // `.active` wiring stays identical to every other file row — the only
298
+ // delta is the link's child structure. Two spans (`-date`, `-title`)
299
+ // give the SCSS partial separate hooks for typography (date is
300
+ // dimmed + tabular numerals; title is the regular ink color).
301
+ function createFileNodeWithDatePrefix(
302
+ currentSlug: FullSlug,
303
+ node: FileTrieNode,
304
+ parsed: ParsedSlugDate,
305
+ ): HTMLLIElement {
306
+ const li = createFileNode(currentSlug, node)
307
+ const a = li.querySelector("a") as HTMLAnchorElement | null
308
+ if (!a) return li
309
+
310
+ const title = node.displayName
311
+ // Replace the upstream textContent with two spans so the date can
312
+ // be styled independently of the title.
313
+ a.textContent = ""
314
+ const dateEl = document.createElement("span")
315
+ dateEl.className = "brain-explorer-month-date"
316
+ dateEl.textContent = monthDayShort(parsed)
317
+ a.appendChild(dateEl)
318
+ a.appendChild(document.createTextNode(MONTH_DATE_SEPARATOR))
319
+ const titleEl = document.createElement("span")
320
+ titleEl.className = "brain-explorer-month-title"
321
+ titleEl.textContent = title
322
+ a.appendChild(titleEl)
323
+
324
+ return li
325
+ }
326
+
327
+ // brain (P4.3): the `groupFn` extension itself. Splits the folder's
328
+ // children into month buckets, renders one header `<li>` per bucket
329
+ // (newest first) followed by the bucket's files (newest day first),
330
+ // and appends everything to `ul`. Behaviour notes:
331
+ //
332
+ // * Folder children (defensive — current corpus is flat) recurse
333
+ // through `createFolderNode` so they render exactly as they would
334
+ // outside the grouped path. They appear before any month buckets
335
+ // so they don't visually break a `April 2026` ↘ files run.
336
+ // * Files whose slug segment doesn't match `SLUG_DATE_PREFIX_RE`
337
+ // are appended at the end as undated rows, rendered through the
338
+ // plain `createFileNode` so they keep their normal link text.
339
+ // * Empty buckets are skipped — defensive; a bucket only exists
340
+ // because at least one item parsed into it.
341
+ //
342
+ // The function returns `void` and mutates `ul` in place — same shape
343
+ // as the original child loop in `createFolderNode`.
344
+ function buildMonthGroupedChildren(
345
+ currentSlug: FullSlug,
346
+ node: FileTrieNode,
347
+ ul: HTMLUListElement,
348
+ opts: ParsedOptions,
349
+ ): void {
350
+ interface Bucket {
351
+ parsed: ParsedSlugDate
352
+ items: Array<{ child: FileTrieNode; parsed: ParsedSlugDate }>
353
+ }
354
+
355
+ const buckets = new Map<string, Bucket>()
356
+ const undatedFiles: FileTrieNode[] = []
357
+ const folderChildren: FileTrieNode[] = []
358
+
359
+ for (const child of node.children) {
360
+ if (child.isFolder) {
361
+ folderChildren.push(child)
362
+ continue
363
+ }
364
+ const parsed = parseSlugDate(child.slugSegment)
365
+ if (parsed === null) {
366
+ undatedFiles.push(child)
367
+ continue
368
+ }
369
+ const key = monthKey(parsed)
370
+ let bucket = buckets.get(key)
371
+ if (bucket === undefined) {
372
+ bucket = { parsed: { ...parsed, day: 1 }, items: [] }
373
+ buckets.set(key, bucket)
374
+ }
375
+ bucket.items.push({ child, parsed })
376
+ }
377
+
378
+ // Render any straggler subfolders first so they don't appear
379
+ // sandwiched between month groups.
380
+ for (const child of folderChildren) {
381
+ ul.appendChild(createFolderNode(currentSlug, child, opts))
382
+ }
383
+
384
+ // Sort keys descending — `YYYY-MM` is fixed-width zero-padded so
385
+ // lexicographic compare === chronological compare.
386
+ const sortedKeys = [...buckets.keys()].sort().reverse()
387
+ for (const key of sortedKeys) {
388
+ const bucket = buckets.get(key)
389
+ if (!bucket || bucket.items.length === 0) continue
390
+ const headerLi = document.createElement("li")
391
+ headerLi.className = "brain-explorer-month-header"
392
+ headerLi.setAttribute("role", "presentation")
393
+ headerLi.dataset["monthKey"] = key
394
+ headerLi.textContent = monthGroupLabel(bucket.parsed)
395
+ ul.appendChild(headerLi)
396
+
397
+ // Day-descending within the month. Same-day collisions fall back
398
+ // to slug-segment alpha so the ordering is deterministic.
399
+ bucket.items.sort((a, b) => {
400
+ if (a.parsed.day !== b.parsed.day) return b.parsed.day - a.parsed.day
401
+ return a.child.slugSegment.localeCompare(b.child.slugSegment)
402
+ })
403
+ for (const item of bucket.items) {
404
+ ul.appendChild(createFileNodeWithDatePrefix(currentSlug, item.child, item.parsed))
405
+ }
406
+ }
407
+
408
+ // Appendix — undated files (defensive; not expected in current corpus).
409
+ for (const child of undatedFiles) {
410
+ ul.appendChild(createFileNode(currentSlug, child))
411
+ }
412
+ }
413
+
414
+ // brain delta: recursive leaf-count for folder nodes. A "leaf" is any
415
+ // node where `isFolder === false` — `FileTrieNode` is the post-filter
416
+ // trie (Quartz strips `tags/` via the default `filterFn`), so the
417
+ // count reflects what the user actually sees in the explorer rather
418
+ // than the raw contentIndex.
419
+ function countFiles(node: FileTrieNode): number {
420
+ let count = 0
421
+ for (const child of node.children) {
422
+ if (child.isFolder) {
423
+ count += countFiles(child)
424
+ } else {
425
+ count += 1
426
+ }
427
+ }
428
+ return count
429
+ }
430
+
431
+ function createFolderNode(
432
+ currentSlug: FullSlug,
433
+ node: FileTrieNode,
434
+ opts: ParsedOptions,
435
+ ): HTMLLIElement {
436
+ const template = document.getElementById("template-folder") as HTMLTemplateElement
437
+ const clone = template.content.cloneNode(true) as DocumentFragment
438
+ const li = clone.querySelector("li") as HTMLLIElement
439
+ const folderContainer = li.querySelector(".folder-container") as HTMLElement
440
+ const titleContainer = folderContainer.querySelector("div") as HTMLElement
441
+ const folderOuter = li.querySelector(".folder-outer") as HTMLElement
442
+ const ul = folderOuter.querySelector("ul") as HTMLUListElement
443
+
444
+ const folderPath = node.slug
445
+ folderContainer.dataset.folderpath = folderPath
446
+
447
+ if (currentSlug === folderPath) {
448
+ folderContainer.classList.add("active")
449
+ }
450
+
451
+ if (opts.folderClickBehavior === "link") {
452
+ // Replace button with link for link behavior
453
+ const button = titleContainer.querySelector(".folder-button") as HTMLElement
454
+ const a = document.createElement("a")
455
+ a.href = resolveRelative(currentSlug, folderPath)
456
+ a.dataset.for = folderPath
457
+ a.className = "folder-title"
458
+ a.textContent = node.displayName
459
+ button.replaceWith(a)
460
+ } else {
461
+ const span = titleContainer.querySelector(".folder-title") as HTMLElement
462
+ span.textContent = node.displayName
463
+ }
464
+
465
+ // brain delta: append a `.folder-count` badge after the folder
466
+ // title showing the recursive file count under this folder. We
467
+ // inject post-title (rather than into the template) so the upstream
468
+ // template stays unchanged — keeps Explorer.tsx unforked.
469
+ const fileCount = countFiles(node)
470
+ if (fileCount > 0) {
471
+ const titleEl = titleContainer.querySelector(".folder-title") as HTMLElement | null
472
+ if (titleEl) {
473
+ const countEl = document.createElement("span")
474
+ countEl.className = "folder-count"
475
+ countEl.textContent = String(fileCount)
476
+ countEl.setAttribute("aria-label", `${fileCount} notes`)
477
+ titleEl.insertAdjacentElement("afterend", countEl)
478
+ }
479
+ }
480
+
481
+ // if the saved state is collapsed or the default state is collapsed
482
+ const isCollapsed =
483
+ currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
484
+ opts.folderDefaultState === "collapsed"
485
+
486
+ // if this folder is a prefix of the current path we
487
+ // want to open it anyways
488
+ const simpleFolderPath = simplifySlug(folderPath)
489
+ const folderIsPrefixOfCurrentSlug =
490
+ simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
491
+
492
+ if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
493
+ folderOuter.classList.add("open")
494
+ }
495
+
496
+ // brain (P4.3): per-source month grouping for `_ingested/krisp/`
497
+ // and `_ingested/gmail/`. Every other folder renders through the
498
+ // original child loop unchanged.
499
+ if (isMonthGroupedFolder(folderPath)) {
500
+ buildMonthGroupedChildren(currentSlug, node, ul, opts)
501
+ } else {
502
+ for (const child of node.children) {
503
+ const childNode = child.isFolder
504
+ ? createFolderNode(currentSlug, child, opts)
505
+ : createFileNode(currentSlug, child)
506
+ ul.appendChild(childNode)
507
+ }
508
+ }
509
+
510
+ return li
511
+ }
512
+
513
+ // brain (P4.2): refresh the toggle button's visible state to match
514
+ // the current `showIngested` preference. Idempotent — safe to call
515
+ // multiple times. Updates label text, `aria-pressed`, and the
516
+ // `[data-active]` attribute that drives CSS state.
517
+ function refreshIngestedToggleState(button: HTMLButtonElement, showIngested: boolean): void {
518
+ button.textContent = showIngested ? SHOW_INGESTED_LABELS.hide : SHOW_INGESTED_LABELS.show
519
+ button.setAttribute("aria-pressed", showIngested ? "true" : "false")
520
+ button.dataset["active"] = showIngested ? "true" : "false"
521
+ }
522
+
523
+ // brain (P4.2): build the trie + render the explorer tree DOM. Split
524
+ // off the original `setupExplorer` body so we can call it twice — once
525
+ // at SPA-nav time (initial render) and once on toggle click (re-render
526
+ // without re-attaching listeners). All folder-button / folder-icon
527
+ // click handlers are wired ON every call because the new tree DOM is a
528
+ // fresh set of elements; the toggle button's own click handler stays
529
+ // wired across re-renders since it lives outside the explorer-ul.
530
+ function renderExplorerTree(
531
+ explorer: HTMLElement,
532
+ opts: ParsedOptions,
533
+ currentSlug: FullSlug,
534
+ data: Record<FullSlug, ContentDetails>,
535
+ ): void {
536
+ // Get folder state from local storage. Carries the user's per-
537
+ // folder collapse preferences across renders so toggling the
538
+ // ingested chip doesn't reset every other folder's open state.
539
+ const storageTree = localStorage.getItem("fileTree")
540
+ const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
541
+ const oldIndex = new Map<string, boolean>(
542
+ serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
543
+ )
544
+
545
+ const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
546
+ const trie = FileTrieNode.fromEntries(entries)
547
+
548
+ // Apply functions in order
549
+ for (const fn of opts.order) {
550
+ switch (fn) {
551
+ case "filter":
552
+ if (opts.filterFn) trie.filter(opts.filterFn)
553
+ break
554
+ case "map":
555
+ if (opts.mapFn) trie.map(opts.mapFn)
556
+ break
557
+ case "sort":
558
+ if (opts.sortFn) trie.sort(opts.sortFn)
559
+ break
560
+ }
561
+ }
562
+
563
+ // brain (P4.2): conditional `_ingested/` filter pass. Runs AFTER
564
+ // upstream's user filter so a vault that already excludes
565
+ // `_ingested/` via a custom filterFn doesn't see this filter as a
566
+ // no-op error — the trie is just unaffected. The countFiles helper
567
+ // operates on the post-filter trie, so the `.folder-count` badges
568
+ // automatically exclude ingested descendants when the toggle is OFF.
569
+ if (!loadShowIngested()) {
570
+ trie.filter((node) => node.slugSegment !== INGESTED_FOLDER_SEGMENT)
571
+ }
572
+
573
+ // Get folder paths for state management
574
+ const folderPaths = trie.getFolderPaths()
575
+ currentExplorerState = folderPaths.map((path) => {
576
+ const previousState = oldIndex.get(path)
577
+ return {
578
+ path,
579
+ collapsed:
580
+ previousState === undefined ? opts.folderDefaultState === "collapsed" : previousState,
581
+ }
582
+ })
583
+
584
+ const explorerUl = explorer.querySelector(".explorer-ul")
585
+ if (!explorerUl) return
586
+
587
+ // brain (P4.2): clear stale tree nodes from a previous render before
588
+ // appending the new ones. Required for the toggle re-render path —
589
+ // otherwise the second pass would double the visible tree. We
590
+ // preserve the `.overflow-end` sentinel `<li>` because the
591
+ // OverflowList script relies on it for the gradient-edge intersection
592
+ // observer (see `OverflowList.tsx`'s `overflowListAfterDOMLoaded`).
593
+ for (const child of Array.from(explorerUl.children)) {
594
+ if (!(child instanceof HTMLElement) || !child.classList.contains("overflow-end")) {
595
+ child.remove()
596
+ }
597
+ }
598
+
599
+ // Create and insert new content
600
+ const fragment = document.createDocumentFragment()
601
+ for (const child of trie.children) {
602
+ const node = child.isFolder
603
+ ? createFolderNode(currentSlug, child, opts)
604
+ : createFileNode(currentSlug, child)
605
+
606
+ fragment.appendChild(node)
607
+ }
608
+ explorerUl.insertBefore(fragment, explorerUl.firstChild)
609
+
610
+ // restore explorer scrollTop position if it exists
611
+ const scrollTop = sessionStorage.getItem("explorerScrollTop")
612
+ if (scrollTop) {
613
+ ;(explorerUl as HTMLElement).scrollTop = parseInt(scrollTop)
614
+ } else {
615
+ // try to scroll to the active element if it exists
616
+ const activeElement = explorerUl.querySelector(".active")
617
+ if (activeElement) {
618
+ activeElement.scrollIntoView({ behavior: "smooth" })
619
+ }
620
+ }
621
+
622
+ // Set up folder click handlers — re-attached on every render
623
+ // because the folder DOM is freshly built each time.
624
+ if (opts.folderClickBehavior === "collapse") {
625
+ const folderButtons = explorer.getElementsByClassName(
626
+ "folder-button",
627
+ ) as HTMLCollectionOf<HTMLElement>
628
+ for (const button of folderButtons) {
629
+ button.addEventListener("click", toggleFolder)
630
+ window.addCleanup(() => button.removeEventListener("click", toggleFolder))
631
+ }
632
+ }
633
+
634
+ const folderIcons = explorer.getElementsByClassName(
635
+ "folder-icon",
636
+ ) as HTMLCollectionOf<HTMLElement>
637
+ for (const icon of folderIcons) {
638
+ icon.addEventListener("click", toggleFolder)
639
+ window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
640
+ }
641
+ }
642
+
643
+ // brain (P4.2): inject the "Show ingested" toggle button into the
644
+ // explorer DOM. Idempotent — if the button already exists (from a
645
+ // prior SPA nav whose explorer DOM happens to be reused), refresh its
646
+ // visible state and skip re-injection. The button is inserted as the
647
+ // FIRST child of `.explorer-content` so it sits inside the
648
+ // collapsible region but above the explorer-ul.
649
+ function ensureIngestedToggle(
650
+ explorer: HTMLElement,
651
+ onClick: (button: HTMLButtonElement) => void,
652
+ ): HTMLButtonElement | null {
653
+ const content = explorer.querySelector(".explorer-content")
654
+ if (!(content instanceof HTMLElement)) return null
655
+
656
+ let button = explorer.querySelector(
657
+ ".brain-explorer-ingested-toggle",
658
+ ) as HTMLButtonElement | null
659
+ if (button === null) {
660
+ button = document.createElement("button")
661
+ button.type = "button"
662
+ button.className = "brain-explorer-ingested-toggle"
663
+ // Insert at the top of the collapsible explorer body so the
664
+ // toggle is visible whenever the explorer is open.
665
+ content.insertBefore(button, content.firstChild)
666
+ }
667
+
668
+ refreshIngestedToggleState(button, loadShowIngested())
669
+
670
+ // Wire the click handler on every call. `window.addCleanup` from
671
+ // the previous nav has already torn down the prior listener; the
672
+ // upstream pattern is to register the cleanup-and-rebind cycle on
673
+ // each nav, and we follow it.
674
+ const handler = (event: MouseEvent) => {
675
+ // Don't bubble into the desktop-explorer collapse handler — that
676
+ // handler closes the entire explorer when its title button is
677
+ // clicked, which would defeat the toggle.
678
+ event.stopPropagation()
679
+ onClick(button as HTMLButtonElement)
680
+ }
681
+ button.addEventListener("click", handler)
682
+ window.addCleanup(() => (button as HTMLButtonElement).removeEventListener("click", handler))
683
+ return button
684
+ }
685
+
686
+ async function setupExplorer(currentSlug: FullSlug) {
687
+ const allExplorers = document.querySelectorAll("div.explorer") as NodeListOf<HTMLElement>
688
+
689
+ for (const explorer of allExplorers) {
690
+ const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
691
+ const opts: ParsedOptions = {
692
+ folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
693
+ folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
694
+ useSavedState: explorer.dataset.savestate === "true",
695
+ order: dataFns.order || ["filter", "map", "sort"],
696
+ sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
697
+ filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
698
+ mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
699
+ }
700
+
701
+ const data = (await fetchData) as Record<FullSlug, ContentDetails>
702
+
703
+ // Initial render with current `showIngested` preference applied.
704
+ renderExplorerTree(explorer, opts, currentSlug, data)
705
+
706
+ // brain (P4.2): inject the toggle button + wire its click. The
707
+ // handler flips the persisted preference and triggers a re-render.
708
+ ensureIngestedToggle(explorer, (button) => {
709
+ const next = !loadShowIngested()
710
+ persistShowIngested(next)
711
+ refreshIngestedToggleState(button, next)
712
+ renderExplorerTree(explorer, opts, currentSlug, data)
713
+ })
714
+
715
+ // Set up explorer collapse handlers (top-level, mobile + desktop
716
+ // toggle buttons). Wired ONCE per nav since these DOM elements
717
+ // are SSR'd and persist across renders.
718
+ const explorerButtons = explorer.getElementsByClassName(
719
+ "explorer-toggle",
720
+ ) as HTMLCollectionOf<HTMLElement>
721
+ for (const button of explorerButtons) {
722
+ button.addEventListener("click", toggleExplorer)
723
+ window.addCleanup(() => button.removeEventListener("click", toggleExplorer))
724
+ }
725
+ }
726
+ }
727
+
728
+ document.addEventListener("prenav", async () => {
729
+ // save explorer scrollTop position
730
+ const explorer = document.querySelector(".explorer-ul")
731
+ if (!explorer) return
732
+ sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
733
+ })
734
+
735
+ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
736
+ const currentSlug = e.detail.url
737
+ await setupExplorer(currentSlug)
738
+
739
+ // if mobile hamburger is visible, collapse by default
740
+ for (const explorer of document.getElementsByClassName("explorer")) {
741
+ const mobileExplorer = explorer.querySelector(".mobile-explorer")
742
+ if (!mobileExplorer) return
743
+
744
+ if (mobileExplorer.checkVisibility()) {
745
+ explorer.classList.add("collapsed")
746
+ explorer.setAttribute("aria-expanded", "false")
747
+
748
+ // Allow <html> to be scrollable when mobile explorer is collapsed
749
+ document.documentElement.classList.remove("mobile-no-scroll")
750
+ }
751
+
752
+ mobileExplorer.classList.remove("hide-until-loaded")
753
+ }
754
+ })
755
+
756
+ window.addEventListener("resize", function () {
757
+ // Desktop explorer opens by default, and it stays open when the window is resized
758
+ // to mobile screen size. Applies `no-scroll` to <html> in this edge case.
759
+ const explorer = document.querySelector(".explorer")
760
+ if (explorer && !explorer.classList.contains("collapsed")) {
761
+ document.documentElement.classList.add("mobile-no-scroll")
762
+ return
763
+ }
764
+ })
765
+
766
+ function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
767
+ return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
768
+ }