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,1011 @@
1
+ // Brain Search runtime — Phase 3.2 of the Wiki UX Overhaul.
2
+ //
3
+ // This file is a TEMPLATE. It is installed at
4
+ // `<vault>/.quartz/quartz/components/scripts/search.inline.ts` by
5
+ // `brain vault render --overlay`. esbuild-loader inside the cloned
6
+ // Quartz workspace bundles it into a string that `Search.tsx` exports
7
+ // as `afterDOMLoaded`. The string is then injected at `</body>` time
8
+ // by Quartz's `renderPage.tsx`. The script does NOT compile or run
9
+ // from the brain repo itself.
10
+ //
11
+ // Brain customizations layered on top of stock Quartz's search
12
+ // runtime:
13
+ //
14
+ // 1. Source icon prefix on every result row — driven by
15
+ // `details.source` (or, when missing, a fallback path-form
16
+ // heuristic on the slug). Glyph table is read once at boot from
17
+ // the `data-brain-source-icons` JSON attribute on the chip rail
18
+ // so the component (`Search.tsx`) is the only place that hard-
19
+ // codes the table.
20
+ //
21
+ // 2. Chip filter row (krisp / slack / gmail / manual / vault). Click
22
+ // a chip to toggle its membership in the active set. The "All"
23
+ // chip resets to the full vocabulary. Active set persists in
24
+ // `localStorage` under `brain.search.activeSources` so the user's
25
+ // preferred slice survives SPA navigation and reload.
26
+ //
27
+ // 3. Lazy preview pane — when a result is selected (hover or arrow
28
+ // key), fetch `static/contentBodies/<slug>.json` (the per-slug
29
+ // body file P3.1's contentIndex emitter writes) and render the
30
+ // `content` field in the preview pane. On error (404, network
31
+ // failure) we fall back to `details.snippet ?? details.content`
32
+ // from the loaded index. Bodies are cached in a per-popover
33
+ // `Map<slug, string>` so re-selecting the same row doesn't
34
+ // refetch.
35
+ //
36
+ // 4. Date column on each row, formatted relative ("3d ago") for the
37
+ // past week and ISO ("2026-04-12") otherwise.
38
+ //
39
+ // Snippet highlighting reuses the upstream `<mark>`-equivalent
40
+ // technique — wrap matched terms in a `<span class="highlight">` so
41
+ // the existing `_search.scss` accent rules kick in. We keep the same
42
+ // `highlight()` helper as upstream (with the trim-window logic) so the
43
+ // snippet column is dense + recognisable.
44
+
45
+ import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
46
+ import { ContentDetails } from "../../plugins/emitters/contentIndex"
47
+ import { registerEscapeHandler, removeAllChildren } from "./util"
48
+ import { FullSlug, pathToRoot, resolveRelative } from "../../util/path"
49
+ import { SOURCE_ICONS, inferSource as sharedInferSource } from "../../util/sourceIcons"
50
+
51
+ interface Item {
52
+ id: number
53
+ slug: FullSlug
54
+ title: string
55
+ content: string
56
+ tags: string[]
57
+ source?: string
58
+ tier?: string
59
+ date?: number
60
+ [key: string]: unknown
61
+ }
62
+
63
+ // brain: minimal subset of `BrainContentDetails` we read off the
64
+ // loaded `contentIndex.json`. Avoids importing the brain emitter type
65
+ // (which carries server-side hast types we don't need at runtime).
66
+ interface BrainEntry {
67
+ title?: string
68
+ content?: string
69
+ snippet?: string
70
+ tags?: string[]
71
+ source?: string
72
+ tier?: string
73
+ date?: string | number
74
+ }
75
+
76
+ // brain: chip/persistence constants. Pinned at module scope so the
77
+ // active set survives `setupSearch` re-entry (per SPA `nav` event).
78
+ const ACTIVE_SOURCES_KEY = "brain.search.activeSources"
79
+ const CONTENT_BODIES_RELDIR = "static/contentBodies"
80
+
81
+ // brain (P3.6 fix-3): mirror of `SAFE_SLUG_RE` + `isSafeSlug` in
82
+ // `quartz_overrides/quartz/plugins/emitters/contentIndex.ts`. Duplicated
83
+ // here (rather than imported) because the inline script is bundled
84
+ // separately by esbuild-loader and can't take a runtime import from the
85
+ // server-side emitter module without dragging hast/vfile types into the
86
+ // browser bundle. Defense in depth: any slug we'd have rejected on the
87
+ // emitter side gets the same treatment on the fetch side, so a stale
88
+ // `contentIndex.json` from before the emitter guard rolled out can't
89
+ // poison the fetch URL. The allowlist composition is documented in
90
+ // the emitter's identical constant (it justifies every char against the
91
+ // real brain-vault slug shapes including `,` from email subject dates
92
+ // and `:` from `krisp:` prefixes); see contentIndex.ts for the
93
+ // rationale on what's in the set and what isn't.
94
+ const SAFE_SLUG_RE = /^[a-zA-Z0-9._/,:-]+$/
95
+
96
+ function isSafeSlug(slug: string): boolean {
97
+ if (!SAFE_SLUG_RE.test(slug)) return false
98
+ // Reject empty segments (leading slash, double slash) and `..`
99
+ // segments — the char allowlist alone permits both.
100
+ for (const segment of slug.split("/")) {
101
+ if (segment === "" || segment === "..") return false
102
+ }
103
+ return true
104
+ }
105
+
106
+ // brain (P3.6 fix-4): single-key parse-failure fallback. Previously this
107
+ // module carried a full duplicate of the source-icon table (mirroring
108
+ // the `SOURCE_ICONS` constant in Search.tsx). Both copies have now
109
+ // converged on `quartz_overrides/quartz/util/sourceIcons.ts` (P3.3); the
110
+ // SSR rail serializes the imported table into `data-brain-source-icons`
111
+ // and the inline script reads it back at boot via `readSourceIcons`. We
112
+ // keep one minimal fallback entry here for the "data attribute is
113
+ // absent or unparseable" branch — `vault` is the universal-default
114
+ // glyph the `sourceIcon` helper falls back to anyway, and a single key
115
+ // is enough to keep the chip rail rendering rather than throwing.
116
+ const FALLBACK_SOURCE_ICONS: Record<string, string> = { vault: SOURCE_ICONS["vault"] }
117
+
118
+ // brain (P3.6 fix-4): canonical default-active vocabulary, sourced from
119
+ // the shared `SOURCE_ICONS` keys so adding a new ingest source (e.g.
120
+ // `notion`) is a one-line change in `util/sourceIcons.ts` rather than a
121
+ // hunt across files. Preserves the original behaviour of "all known
122
+ // sources visible by default" without a duplicated string list.
123
+ const DEFAULT_SOURCE_VOCAB: ReadonlyArray<string> = Object.keys(SOURCE_ICONS)
124
+
125
+ // Can be expanded with things like "term" in the future
126
+ type SearchType = "basic" | "tags"
127
+ let searchType: SearchType = "basic"
128
+ let currentSearchTerm: string = ""
129
+
130
+ // brain: chip filter state at module scope so the user's selection
131
+ // survives SPA navigation. Default = full vocabulary (everything
132
+ // visible). On first load we hydrate from localStorage; subsequent
133
+ // chip clicks rewrite the same key.
134
+ const DEFAULT_ACTIVE_SOURCES = new Set<string>(DEFAULT_SOURCE_VOCAB)
135
+ let activeSources: Set<string> = loadActiveSources()
136
+
137
+ function loadActiveSources(): Set<string> {
138
+ if (typeof localStorage === "undefined") return new Set(DEFAULT_ACTIVE_SOURCES)
139
+ try {
140
+ const raw = localStorage.getItem(ACTIVE_SOURCES_KEY)
141
+ if (raw === null) return new Set(DEFAULT_ACTIVE_SOURCES)
142
+ const parsed = JSON.parse(raw)
143
+ if (!Array.isArray(parsed)) return new Set(DEFAULT_ACTIVE_SOURCES)
144
+ // brain: filter parsed values down to the known vocabulary so a
145
+ // stale localStorage from an older release (e.g. before a renamed
146
+ // source) doesn't wedge filtering into a permanently-empty state.
147
+ const filtered = parsed.filter((v) => typeof v === "string")
148
+ return new Set<string>(filtered)
149
+ } catch {
150
+ return new Set(DEFAULT_ACTIVE_SOURCES)
151
+ }
152
+ }
153
+
154
+ function persistActiveSources(): void {
155
+ if (typeof localStorage === "undefined") return
156
+ try {
157
+ localStorage.setItem(ACTIVE_SOURCES_KEY, JSON.stringify([...activeSources]))
158
+ } catch {
159
+ // brain: localStorage may throw in private-mode Safari; swallow so
160
+ // a quota exception doesn't tear down the search popover.
161
+ }
162
+ }
163
+
164
+ const encoder = (str: string): string[] => {
165
+ const tokens: string[] = []
166
+ let bufferStart = -1
167
+ let bufferEnd = -1
168
+ const lower = str.toLowerCase()
169
+
170
+ let i = 0
171
+ for (const char of lower) {
172
+ const code = char.codePointAt(0)!
173
+
174
+ const isCJK =
175
+ (code >= 0x3040 && code <= 0x309f) ||
176
+ (code >= 0x30a0 && code <= 0x30ff) ||
177
+ (code >= 0x4e00 && code <= 0x9fff) ||
178
+ (code >= 0xac00 && code <= 0xd7af) ||
179
+ (code >= 0x20000 && code <= 0x2a6df)
180
+
181
+ const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
182
+
183
+ if (isCJK) {
184
+ if (bufferStart !== -1) {
185
+ tokens.push(lower.slice(bufferStart, bufferEnd))
186
+ bufferStart = -1
187
+ }
188
+ tokens.push(char)
189
+ } else if (isWhitespace) {
190
+ if (bufferStart !== -1) {
191
+ tokens.push(lower.slice(bufferStart, bufferEnd))
192
+ bufferStart = -1
193
+ }
194
+ } else {
195
+ if (bufferStart === -1) bufferStart = i
196
+ bufferEnd = i + char.length
197
+ }
198
+
199
+ i += char.length
200
+ }
201
+
202
+ if (bufferStart !== -1) {
203
+ tokens.push(lower.slice(bufferStart))
204
+ }
205
+
206
+ return tokens
207
+ }
208
+
209
+ let index = new FlexSearch.Document<Item>({
210
+ encode: encoder,
211
+ document: {
212
+ id: "id",
213
+ tag: "tags",
214
+ index: [
215
+ {
216
+ field: "title",
217
+ tokenize: "forward",
218
+ },
219
+ {
220
+ field: "content",
221
+ tokenize: "forward",
222
+ },
223
+ {
224
+ field: "tags",
225
+ tokenize: "forward",
226
+ },
227
+ ],
228
+ },
229
+ })
230
+
231
+ const p = new DOMParser()
232
+ const contextWindowWords = 30
233
+ const numSearchResults = 8
234
+ const numTagResults = 5
235
+
236
+ const tokenizeTerm = (term: string) => {
237
+ const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
238
+ const tokenLen = tokens.length
239
+ if (tokenLen > 1) {
240
+ for (let i = 1; i < tokenLen; i++) {
241
+ tokens.push(tokens.slice(0, i + 1).join(" "))
242
+ }
243
+ }
244
+
245
+ return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first
246
+ }
247
+
248
+ // brain (P3.6 fix-2): regex-metachar escape. The `highlight()` helper
249
+ // takes raw user query tokens and previously fed them straight into
250
+ // `new RegExp(...)`. A token like `(.*)` would compile as a capturing
251
+ // group and either throw or match unintended substrings; a token
252
+ // containing `<` could escape its surrounding `<mark>` tags. Escaping
253
+ // every metachar before the regex compile is the standard fix.
254
+ function escapeRegExp(s: string): string {
255
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
256
+ }
257
+
258
+ // brain (P3.6 fix-2): the previous `highlight()` was an XSS hole. The
259
+ // upstream comment promised escaping but the implementation only fed
260
+ // raw tokens through `String.prototype.replace`, which preserved every
261
+ // HTML metachar verbatim. A title like `Re: <script>alert(1)</script>`
262
+ // would execute when `resultToHTML()` set `itemTile.innerHTML = ...`.
263
+ // The fix:
264
+ // 1. Escape the entire body text first via `escapeHtml()` so any HTML
265
+ // metachars in the snippet/title become safe entities.
266
+ // 2. Escape each query token via `escapeHtml(escapeRegExp(...))` so
267
+ // both the regex semantics AND the rendered `<mark>` body are safe.
268
+ // 3. Wrap matches in `<mark>` *after* escaping — the only HTML we
269
+ // intentionally produce, scoped to the literal six-char tag pair.
270
+ // This preserves the original "wrap matches in <mark>" UX without
271
+ // allowing user-supplied content to break out of the row markup.
272
+ function highlight(searchTerm: string, text: string, trim?: boolean) {
273
+ const tokenizedTerms = tokenizeTerm(searchTerm)
274
+ let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
275
+
276
+ let startIndex = 0
277
+ let endIndex = tokenizedText.length - 1
278
+ if (trim) {
279
+ const includesCheck = (tok: string) =>
280
+ tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
281
+ const occurrencesIndices = tokenizedText.map(includesCheck)
282
+
283
+ let bestSum = 0
284
+ let bestIndex = 0
285
+ for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
286
+ const window = occurrencesIndices.slice(i, i + contextWindowWords)
287
+ const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
288
+ if (windowSum >= bestSum) {
289
+ bestSum = windowSum
290
+ bestIndex = i
291
+ }
292
+ }
293
+
294
+ startIndex = Math.max(bestIndex - contextWindowWords, 0)
295
+ endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1)
296
+ tokenizedText = tokenizedText.slice(startIndex, endIndex)
297
+ }
298
+
299
+ const slice = tokenizedText
300
+ .map((tok) => {
301
+ // brain (P3.6 fix-2): escape the raw text BEFORE looking for
302
+ // matches. Every subsequent regex substitution operates on
303
+ // already-escaped text, so a title like `<script>alert(1)
304
+ // </script>` becomes `&lt;script&gt;alert(1)&lt;/script&gt;` and
305
+ // the popover renders it as visible literal text rather than
306
+ // executing it.
307
+ const escapedTok = escapeHtml(tok)
308
+ // see if this tok is prefixed by any search terms
309
+ for (const searchTok of tokenizedTerms) {
310
+ if (tok.toLowerCase().includes(searchTok.toLowerCase())) {
311
+ // Escape regex metachars in the token before compiling so a
312
+ // query like `(.*)` doesn't blow up `new RegExp()` or match
313
+ // unintended substrings; HTML-escape the regex source so the
314
+ // resulting RegExp matches the *escaped* version of the
315
+ // token in the already-escaped body text.
316
+ const regexSource = escapeHtml(escapeRegExp(searchTok.toLowerCase()))
317
+ const regex = new RegExp(regexSource, "gi")
318
+ // brain: emit `<mark>` so the brain accent CSS in
319
+ // `_search.scss` can paint per-result highlights without
320
+ // colliding with the upstream `.highlight` class (which is
321
+ // also reused by the preview pane's HTML highlighter). The
322
+ // `<mark>` tags are the only HTML we intentionally produce
323
+ // here — the wrapped match is itself escaped by step 1.
324
+ return escapedTok.replace(regex, `<mark>$&</mark>`)
325
+ }
326
+ }
327
+ return escapedTok
328
+ })
329
+ .join(" ")
330
+
331
+ return `${startIndex === 0 ? "" : "..."}${slice}${
332
+ endIndex === tokenizedText.length - 1 ? "" : "..."
333
+ }`
334
+ }
335
+
336
+ // brain: pull the source icon table out of the chip rail's data
337
+ // attribute. Falls back to the inline default when the attribute is
338
+ // missing (defensive — the SSR markup always sets it). Parsing
339
+ // failures fall back to the default rather than throwing so a stale
340
+ // rail from a previous build doesn't bork the search popover.
341
+ function readSourceIcons(rail: HTMLElement | null): Record<string, string> {
342
+ if (rail === null) return { ...FALLBACK_SOURCE_ICONS }
343
+ const attr = rail.dataset["brainSourceIcons"]
344
+ if (typeof attr !== "string" || attr.length === 0) {
345
+ return { ...FALLBACK_SOURCE_ICONS }
346
+ }
347
+ try {
348
+ const parsed = JSON.parse(attr) as unknown
349
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
350
+ return { ...FALLBACK_SOURCE_ICONS }
351
+ }
352
+ const out: Record<string, string> = {}
353
+ for (const [key, value] of Object.entries(parsed)) {
354
+ if (typeof value === "string") out[key] = value
355
+ }
356
+ return Object.keys(out).length > 0 ? out : { ...FALLBACK_SOURCE_ICONS }
357
+ } catch {
358
+ return { ...FALLBACK_SOURCE_ICONS }
359
+ }
360
+ }
361
+
362
+ // brain (P3.6 fix-4): re-export the shared `inferSource` helper from
363
+ // `util/sourceIcons.ts` under its prior local name so the rest of this
364
+ // file (and the static parity tests that grep for the symbol) keeps
365
+ // working without a sweep. The previous local copy duplicated the
366
+ // regex + fallback logic for no good reason — same heuristic, same
367
+ // branches.
368
+ const inferSource = sharedInferSource
369
+
370
+ function sourceIcon(icons: Record<string, string>, source: string): string {
371
+ return icons[source] ?? icons["vault"] ?? FALLBACK_SOURCE_ICONS["vault"] ?? SOURCE_ICONS["vault"]
372
+ }
373
+
374
+ // brain: format a contentIndex date stamp for the right-aligned column
375
+ // on each row. ISO strings, numeric epoch millis, and `undefined` are
376
+ // all tolerated (matching upstream `Date | undefined` plus the JSON
377
+ // round-trip that turns Date into a string). Past 7 days renders as
378
+ // "Nd ago" / "today"; older renders as "YYYY-MM-DD".
379
+ function formatRelativeDate(raw: unknown): string {
380
+ if (raw === null || raw === undefined) return ""
381
+ let ts: number
382
+ if (typeof raw === "number") {
383
+ if (!Number.isFinite(raw)) return ""
384
+ ts = raw
385
+ } else if (typeof raw === "string") {
386
+ const parsed = Date.parse(raw)
387
+ if (Number.isNaN(parsed)) return ""
388
+ ts = parsed
389
+ } else {
390
+ return ""
391
+ }
392
+ const now = Date.now()
393
+ const diffMs = now - ts
394
+ const day = 86_400_000
395
+ if (diffMs < 0) return new Date(ts).toISOString().slice(0, 10)
396
+ if (diffMs < day) return "today"
397
+ if (diffMs < 7 * day) {
398
+ const days = Math.floor(diffMs / day)
399
+ return `${days}d ago`
400
+ }
401
+ return new Date(ts).toISOString().slice(0, 10)
402
+ }
403
+
404
+ function highlightHTML(searchTerm: string, el: HTMLElement) {
405
+ const p = new DOMParser()
406
+ const tokenizedTerms = tokenizeTerm(searchTerm)
407
+ const html = p.parseFromString(el.innerHTML, "text/html")
408
+
409
+ const createHighlightSpan = (text: string) => {
410
+ const span = document.createElement("span")
411
+ span.className = "highlight"
412
+ span.textContent = text
413
+ return span
414
+ }
415
+
416
+ const highlightTextNodes = (node: Node, term: string) => {
417
+ if (node.nodeType === Node.TEXT_NODE) {
418
+ const nodeText = node.nodeValue ?? ""
419
+ const regex = new RegExp(term.toLowerCase(), "gi")
420
+ const matches = nodeText.match(regex)
421
+ if (!matches || matches.length === 0) return
422
+ const spanContainer = document.createElement("span")
423
+ let lastIndex = 0
424
+ for (const match of matches) {
425
+ const matchIndex = nodeText.indexOf(match, lastIndex)
426
+ spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
427
+ spanContainer.appendChild(createHighlightSpan(match))
428
+ lastIndex = matchIndex + match.length
429
+ }
430
+ spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
431
+ node.parentNode?.replaceChild(spanContainer, node)
432
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
433
+ if ((node as HTMLElement).classList.contains("highlight")) return
434
+ Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term))
435
+ }
436
+ }
437
+
438
+ for (const term of tokenizedTerms) {
439
+ highlightTextNodes(html.body, term)
440
+ }
441
+
442
+ return html.body
443
+ }
444
+
445
+ // brain: HTML-escape plain text so a result title containing `<` or
446
+ // `&` can't break out of the row markup. The upstream Search component
447
+ // builds rows with `innerHTML` so escaping is mandatory anywhere we
448
+ // embed user-supplied content (titles, snippets that bypass the
449
+ // `highlight()` mark-injection path).
450
+ //
451
+ // brain (P3.6 fix-2): protection inventory — every user-supplied string
452
+ // that flows into innerHTML is escaped:
453
+ // * `highlight()` — escapes body text + each token before
454
+ // wrapping matches in `<mark>`.
455
+ // * `highlightTags()` — escapes each `tag` before
456
+ // interpolating into `<p>#...</p>`.
457
+ // * `formatForDisplay` — escapes the title in the
458
+ // `searchType === "tags"` branch (the
459
+ // other branch flows through
460
+ // `highlight()`).
461
+ // * `resultToHTML` — escapes the icon + date label before
462
+ // embedding them into the row tile.
463
+ // Adding new innerHTML interpolations: pass user content through
464
+ // `escapeHtml()` first, OR through a helper that escapes internally
465
+ // (`highlight()` does). Anchor tests in `tests/test_quartz_search_static.py`.
466
+ function escapeHtml(s: string): string {
467
+ return s
468
+ .replace(/&/g, "&amp;")
469
+ .replace(/</g, "&lt;")
470
+ .replace(/>/g, "&gt;")
471
+ .replace(/"/g, "&quot;")
472
+ .replace(/'/g, "&#039;")
473
+ }
474
+
475
+ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) {
476
+ const container = searchElement.querySelector(".search-container") as HTMLElement
477
+ if (!container) return
478
+
479
+ const sidebar = container.closest(".sidebar") as HTMLElement | null
480
+
481
+ const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
482
+ if (!searchButton) return
483
+
484
+ const searchBar = searchElement.querySelector(".search-bar") as HTMLInputElement
485
+ if (!searchBar) return
486
+
487
+ const searchLayout = searchElement.querySelector(".search-layout") as HTMLElement
488
+ if (!searchLayout) return
489
+
490
+ const chipsRail = searchElement.querySelector(".brain-search-chips") as HTMLElement | null
491
+ const sourceIcons = readSourceIcons(chipsRail)
492
+
493
+ // brain: per-popover lazy-fetch cache. Keyed by slug, value is the
494
+ // resolved body string (or `null` for a known fetch failure so we
495
+ // don't re-hammer a 404). Lives at function scope rather than module
496
+ // scope because the cache should be reset between SPA nav events
497
+ // (a fresh `setupSearch` call gets a fresh map).
498
+ const fetchContentCache: Map<FullSlug, string | null> = new Map()
499
+
500
+ const idDataMap = Object.keys(data) as FullSlug[]
501
+ const appendLayout = (el: HTMLElement) => {
502
+ searchLayout.appendChild(el)
503
+ }
504
+
505
+ const enablePreview = searchLayout.dataset.preview === "true"
506
+ let preview: HTMLDivElement | undefined = undefined
507
+ let previewInner: HTMLDivElement | undefined = undefined
508
+ const results = document.createElement("div")
509
+ results.className = "results-container"
510
+ appendLayout(results)
511
+
512
+ if (enablePreview) {
513
+ preview = document.createElement("div")
514
+ preview.className = "preview-container brain-search-preview"
515
+ appendLayout(preview)
516
+ }
517
+
518
+ // brain: reflect the persisted active set onto the chip rail's
519
+ // `data-active` attributes. The "All" pseudo-chip is active iff the
520
+ // active set equals (or is a superset of) the full vocabulary —
521
+ // following the same UX rule the graph chips use.
522
+ function refreshChipState(): void {
523
+ if (chipsRail === null) return
524
+ const chips = chipsRail.querySelectorAll<HTMLButtonElement>(".brain-search-chip")
525
+ const knownValues = chips.length === 0 ? [] : Array.from(chips)
526
+ .map((b) => b.dataset["brainSource"])
527
+ .filter((v): v is string => typeof v === "string" && v !== "__all__")
528
+ const allActive = knownValues.length > 0 && knownValues.every((v) => activeSources.has(v))
529
+ chips.forEach((chip) => {
530
+ const value = chip.dataset["brainSource"]
531
+ if (value === "__all__") {
532
+ chip.dataset["active"] = allActive ? "true" : "false"
533
+ return
534
+ }
535
+ if (typeof value !== "string") return
536
+ chip.dataset["active"] = activeSources.has(value) ? "true" : "false"
537
+ })
538
+ }
539
+
540
+ function bindChipHandlers(): void {
541
+ if (chipsRail === null) return
542
+ const chips = chipsRail.querySelectorAll<HTMLButtonElement>(".brain-search-chip")
543
+ if (chips.length === 0) return
544
+ const allValues = Array.from(chips)
545
+ .map((b) => b.dataset["brainSource"])
546
+ .filter((v): v is string => typeof v === "string" && v !== "__all__")
547
+ chips.forEach((chip) => {
548
+ const value = chip.dataset["brainSource"]
549
+ const handler = () => {
550
+ if (value === "__all__") {
551
+ activeSources = new Set<string>(allValues)
552
+ } else if (typeof value === "string") {
553
+ if (activeSources.has(value)) {
554
+ activeSources.delete(value)
555
+ } else {
556
+ activeSources.add(value)
557
+ }
558
+ }
559
+ persistActiveSources()
560
+ refreshChipState()
561
+ // brain: re-run the current query through the filter so chip
562
+ // toggles reflect immediately. `onType` reads `searchBar.value`
563
+ // directly (no event arg), so we just call it.
564
+ void onType()
565
+ }
566
+ chip.addEventListener("click", handler)
567
+ window.addCleanup(() => chip.removeEventListener("click", handler))
568
+ })
569
+ }
570
+
571
+ refreshChipState()
572
+ bindChipHandlers()
573
+
574
+ function hideSearch() {
575
+ container.classList.remove("active")
576
+ searchBar.value = "" // clear the input when we dismiss the search
577
+ if (sidebar) sidebar.style.zIndex = ""
578
+ removeAllChildren(results)
579
+ if (preview) {
580
+ removeAllChildren(preview)
581
+ }
582
+ searchLayout.classList.remove("display-results")
583
+ searchType = "basic" // reset search type after closing
584
+ searchButton.focus()
585
+ }
586
+
587
+ function showSearch(searchTypeNew: SearchType) {
588
+ searchType = searchTypeNew
589
+ if (sidebar) sidebar.style.zIndex = "1"
590
+ container.classList.add("active")
591
+ searchBar.focus()
592
+ }
593
+
594
+ let currentHover: HTMLInputElement | null = null
595
+ async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
596
+ if (e.key.toLowerCase() === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
597
+ e.preventDefault()
598
+ const searchBarOpen = container.classList.contains("active")
599
+ searchBarOpen ? hideSearch() : showSearch("basic")
600
+ return
601
+ } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
602
+ // Hotkey to open tag search
603
+ e.preventDefault()
604
+ const searchBarOpen = container.classList.contains("active")
605
+ searchBarOpen ? hideSearch() : showSearch("tags")
606
+
607
+ // add "#" prefix for tag search
608
+ searchBar.value = "#"
609
+ return
610
+ }
611
+
612
+ if (currentHover) {
613
+ currentHover.classList.remove("focus")
614
+ }
615
+
616
+ // If search is active, then we will render the first result and display accordingly
617
+ if (!container.classList.contains("active")) return
618
+ if (e.key === "Enter" && !e.isComposing) {
619
+ // If result has focus, navigate to that one, otherwise pick first result
620
+ if (results.contains(document.activeElement)) {
621
+ const active = document.activeElement as HTMLInputElement
622
+ if (active.classList.contains("no-match")) return
623
+ await displayPreview(active)
624
+ active.click()
625
+ } else {
626
+ const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
627
+ if (!anchor || anchor.classList.contains("no-match")) return
628
+ await displayPreview(anchor)
629
+ anchor.click()
630
+ }
631
+ } else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
632
+ e.preventDefault()
633
+ if (results.contains(document.activeElement)) {
634
+ const currentResult = currentHover
635
+ ? currentHover
636
+ : (document.activeElement as HTMLInputElement | null)
637
+ const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
638
+ currentResult?.classList.remove("focus")
639
+ prevResult?.focus()
640
+ if (prevResult) currentHover = prevResult
641
+ await displayPreview(prevResult)
642
+ }
643
+ } else if (e.key === "ArrowDown" || e.key === "Tab") {
644
+ e.preventDefault()
645
+ if (document.activeElement === searchBar || currentHover !== null) {
646
+ const firstResult = currentHover
647
+ ? currentHover
648
+ : (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
649
+ const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
650
+ firstResult?.classList.remove("focus")
651
+ secondResult?.focus()
652
+ if (secondResult) currentHover = secondResult
653
+ await displayPreview(secondResult)
654
+ }
655
+ }
656
+ }
657
+
658
+ const formatForDisplay = (term: string, id: number): Item => {
659
+ const slug = idDataMap[id]
660
+ const entry = data[slug] as BrainEntry & ContentDetails
661
+ const rawSnippet =
662
+ typeof entry.snippet === "string" && entry.snippet.length > 0
663
+ ? entry.snippet
664
+ : (entry.content ?? "")
665
+ return {
666
+ id,
667
+ slug,
668
+ // brain (P3.6 fix-2): both branches must escape — `highlight()`
669
+ // already escapes its return value, but the tag-search branch
670
+ // bypasses it and previously interpolated the raw frontmatter
671
+ // title into innerHTML. Add an explicit `escapeHtml` so a title
672
+ // like `Re: <script>alert(1)</script>` renders as visible
673
+ // literal text in the popover instead of executing.
674
+ title:
675
+ searchType === "tags"
676
+ ? escapeHtml(entry.title ?? "")
677
+ : highlight(term, entry.title ?? ""),
678
+ content: highlight(term, rawSnippet, true),
679
+ tags: highlightTags(term.substring(1), entry.tags ?? []) as unknown as string[],
680
+ source: inferSource(slug as string, entry.source),
681
+ tier: entry.tier,
682
+ date:
683
+ typeof entry.date === "number"
684
+ ? entry.date
685
+ : typeof entry.date === "string"
686
+ ? Date.parse(entry.date) || undefined
687
+ : undefined,
688
+ }
689
+ }
690
+
691
+ function highlightTags(term: string, tags: string[]): string[] {
692
+ if (!tags || searchType !== "tags") {
693
+ return []
694
+ }
695
+
696
+ return tags
697
+ .map((tag) => {
698
+ if (tag.toLowerCase().includes(term.toLowerCase())) {
699
+ return `<li><p class="match-tag">#${escapeHtml(tag)}</p></li>`
700
+ } else {
701
+ return `<li><p>#${escapeHtml(tag)}</p></li>`
702
+ }
703
+ })
704
+ .slice(0, numTagResults)
705
+ }
706
+
707
+ function resolveUrl(slug: FullSlug): URL {
708
+ return new URL(resolveRelative(currentSlug, slug), location.toString())
709
+ }
710
+
711
+ // brain: build the per-row markup. Source icon → title → date →
712
+ // snippet, with the `<mark>` highlights from `highlight()` left
713
+ // intact (we innerHTML-set so the spans render as elements rather
714
+ // than literal text).
715
+ const resultToHTML = (item: Item): HTMLAnchorElement => {
716
+ const { slug, title, content, tags, source, date } = item
717
+ const htmlTags =
718
+ tags.length > 0 ? `<ul class="tags brain-search-tags">${tags.join("")}</ul>` : ``
719
+ const itemTile = document.createElement("a")
720
+ itemTile.classList.add("result-card", "brain-search-row")
721
+ itemTile.id = slug
722
+ itemTile.href = resolveUrl(slug).toString()
723
+ const icon = sourceIcon(sourceIcons, source ?? "vault")
724
+ const dateLabel = formatRelativeDate(date)
725
+ itemTile.dataset["brainSource"] = source ?? "vault"
726
+ itemTile.innerHTML = `
727
+ <span class="brain-search-icon" aria-hidden="true">${escapeHtml(icon)}</span>
728
+ <span class="brain-search-title">${title}</span>
729
+ <span class="brain-search-date">${escapeHtml(dateLabel)}</span>
730
+ ${htmlTags}
731
+ <div class="brain-search-snippet">${content}</div>
732
+ `
733
+ itemTile.addEventListener("click", (event) => {
734
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
735
+ hideSearch()
736
+ })
737
+
738
+ const handler = (event: MouseEvent) => {
739
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
740
+ hideSearch()
741
+ }
742
+
743
+ async function onMouseEnter(ev: MouseEvent) {
744
+ if (!ev.target) return
745
+ const target = ev.target as HTMLInputElement
746
+ await displayPreview(target)
747
+ }
748
+
749
+ itemTile.addEventListener("mouseenter", onMouseEnter)
750
+ window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
751
+ itemTile.addEventListener("click", handler)
752
+ window.addCleanup(() => itemTile.removeEventListener("click", handler))
753
+
754
+ return itemTile
755
+ }
756
+
757
+ async function displayResults(finalResults: Item[]) {
758
+ removeAllChildren(results)
759
+ if (finalResults.length === 0) {
760
+ results.innerHTML = `<a class="result-card no-match">
761
+ <h3>No results.</h3>
762
+ <p>Try another search term?</p>
763
+ </a>`
764
+ } else {
765
+ results.append(...finalResults.map(resultToHTML))
766
+ }
767
+
768
+ if (finalResults.length === 0 && preview) {
769
+ // no results, clear previous preview
770
+ removeAllChildren(preview)
771
+ } else {
772
+ // focus on first result, then also dispatch preview immediately
773
+ const firstChild = results.firstElementChild as HTMLElement
774
+ firstChild.classList.add("focus")
775
+ currentHover = firstChild as HTMLInputElement
776
+ await displayPreview(firstChild)
777
+ }
778
+ }
779
+
780
+ // brain: lazy-fetch the per-slug body file P3.1 emitted under
781
+ // `static/contentBodies/<slug>.json`. Resolved relative to the
782
+ // current page via `pathToRoot(currentSlug)` + the static path so
783
+ // the URL is correct for nested slugs (e.g. `_ingested/gmail/<id>`).
784
+ // On any failure we cache `null` and fall back to the snippet from
785
+ // the loaded contentIndex entry.
786
+ async function fetchBody(slug: FullSlug): Promise<string | null> {
787
+ if (fetchContentCache.has(slug)) {
788
+ return fetchContentCache.get(slug) ?? null
789
+ }
790
+ // brain (P3.6 fix-3): defense-in-depth slug guard. Slugs come from
791
+ // Quartz's trusted slugify, but a stale `contentIndex.json` from
792
+ // before the emitter's matching guard rolled out could still carry
793
+ // an unsafe slug. Reject anything outside the allowlist (or
794
+ // containing a `..` path segment) before building the fetch URL —
795
+ // the preview pane falls through to the snippet fallback (same
796
+ // shape as a 404).
797
+ if (!isSafeSlug(slug)) {
798
+ fetchContentCache.set(slug, null)
799
+ return null
800
+ }
801
+ const root = pathToRoot(currentSlug)
802
+ const url = `${root}/${CONTENT_BODIES_RELDIR}/${slug}.json`
803
+ try {
804
+ const res = await fetch(url)
805
+ if (!res.ok) {
806
+ fetchContentCache.set(slug, null)
807
+ return null
808
+ }
809
+ const payload = (await res.json()) as { content?: unknown }
810
+ const body = typeof payload.content === "string" ? payload.content : null
811
+ fetchContentCache.set(slug, body)
812
+ return body
813
+ } catch {
814
+ fetchContentCache.set(slug, null)
815
+ return null
816
+ }
817
+ }
818
+
819
+ async function displayPreview(el: HTMLElement | null) {
820
+ if (!searchLayout || !enablePreview || !el || !preview) return
821
+ const slug = el.id as FullSlug
822
+ const body = await fetchBody(slug)
823
+ let html: string
824
+ if (body !== null && body.length > 0) {
825
+ // brain: render the lazy-fetched full body into the preview
826
+ // pane. The body is a plain markdown-ish string (Quartz's
827
+ // `details.content` is the HTML-stripped body), so we run the
828
+ // same `highlight()` pass the snippet column uses to surface
829
+ // search matches in context.
830
+ const highlighted = highlight(currentSearchTerm, body, true)
831
+ html = `<div class="preview-inner brain-search-preview-body">${highlighted}</div>`
832
+ } else {
833
+ // brain: fallback path — when the lazy fetch fails (404 in dev,
834
+ // network error, truncated body), surface the snippet from the
835
+ // already-loaded index. Same shape so the preview pane never
836
+ // ends up empty just because the bodies dir wasn't deployed.
837
+ const entry = data[slug] as BrainEntry & ContentDetails
838
+ const snippet =
839
+ typeof entry?.snippet === "string" && entry.snippet.length > 0
840
+ ? entry.snippet
841
+ : entry?.content ?? ""
842
+ const highlighted = highlight(currentSearchTerm, snippet, true)
843
+ html = `<div class="preview-inner brain-search-preview-fallback">${highlighted}</div>`
844
+ }
845
+ previewInner = document.createElement("div")
846
+ previewInner.classList.add("preview-inner")
847
+ previewInner.innerHTML = html
848
+ preview.replaceChildren(previewInner)
849
+
850
+ // scroll to longest highlight (mark or .highlight span)
851
+ const marks = [...preview.querySelectorAll("mark, .highlight")].sort(
852
+ (a, b) => (b as HTMLElement).innerHTML.length - (a as HTMLElement).innerHTML.length,
853
+ )
854
+ ;(marks[0] as HTMLElement | undefined)?.scrollIntoView({ block: "start" })
855
+ }
856
+
857
+ // brain: source filter. Returns true when the entry's source (or
858
+ // path-form fallback) is in the active set. When the active set is
859
+ // empty (every chip toggled off) we treat it as a wildcard so the
860
+ // popover doesn't render permanently empty — same UX rule as the
861
+ // graph chips. Note: the SSR rail boots with every chip active, so
862
+ // the default state matches the wildcard semantics naturally.
863
+ function passesChipFilter(slug: string, source: string | undefined): boolean {
864
+ if (activeSources.size === 0) return true
865
+ return activeSources.has(inferSource(slug, source))
866
+ }
867
+
868
+ // brain (P3.6 fix-5): `onType` previously took an `InputEvent` so it
869
+ // could read `e.target.value`. The chip-toggle handler was forced to
870
+ // synthesise a fake event (`{target: searchBar} as unknown as
871
+ // InputEvent`) just to refire it. Refactor: the handler reads
872
+ // `searchBar.value` directly. Both call sites (the input listener and
873
+ // the chip toggle) now invoke `onType()` with no arguments — no cast,
874
+ // no synthetic event.
875
+ async function onType() {
876
+ if (!searchLayout || !index) return
877
+ currentSearchTerm = searchBar.value
878
+ searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
879
+ searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
880
+
881
+ let searchResults: DefaultDocumentSearchResults<Item>
882
+ if (searchType === "tags") {
883
+ currentSearchTerm = currentSearchTerm.substring(1).trim()
884
+ const separatorIndex = currentSearchTerm.indexOf(" ")
885
+ if (separatorIndex != -1) {
886
+ const tag = currentSearchTerm.substring(0, separatorIndex)
887
+ const query = currentSearchTerm.substring(separatorIndex + 1).trim()
888
+ searchResults = await index.searchAsync({
889
+ query: query,
890
+ limit: Math.max(numSearchResults, 10000),
891
+ index: ["title", "content"],
892
+ tag: { tags: tag },
893
+ })
894
+ for (let searchResult of searchResults) {
895
+ searchResult.result = searchResult.result.slice(0, numSearchResults)
896
+ }
897
+ searchType = "basic"
898
+ currentSearchTerm = query
899
+ } else {
900
+ searchResults = await index.searchAsync({
901
+ query: currentSearchTerm,
902
+ limit: numSearchResults,
903
+ index: ["tags"],
904
+ })
905
+ }
906
+ } else {
907
+ // brain: pull a wider window from flexsearch so the chip filter
908
+ // has room to keep `numSearchResults` after dropping non-matching
909
+ // sources. Without the wider window a strict chip selection
910
+ // (e.g. only "krisp" active) could ship 0 results even when a
911
+ // krisp doc matches the query — the slice above would have
912
+ // already trimmed it before the filter runs.
913
+ searchResults = await index.searchAsync({
914
+ query: currentSearchTerm,
915
+ limit: Math.max(numSearchResults * 5, 50),
916
+ index: ["title", "content"],
917
+ })
918
+ }
919
+
920
+ const getByField = (field: string): number[] => {
921
+ const results = searchResults.filter((x) => x.field === field)
922
+ return results.length === 0 ? [] : ([...results[0].result] as number[])
923
+ }
924
+
925
+ // order titles ahead of content
926
+ const allIds: Set<number> = new Set([
927
+ ...getByField("title"),
928
+ ...getByField("content"),
929
+ ...getByField("tags"),
930
+ ])
931
+ // brain: chip filter — drop ids whose entry's source isn't in the
932
+ // active set, then cap at the upstream-equivalent display budget.
933
+ const filteredIds: number[] = []
934
+ for (const id of allIds) {
935
+ const slug = idDataMap[id]
936
+ const entry = data[slug] as BrainEntry & ContentDetails
937
+ if (passesChipFilter(slug as string, entry?.source)) {
938
+ filteredIds.push(id)
939
+ }
940
+ if (filteredIds.length >= numSearchResults) break
941
+ }
942
+ const finalResults = filteredIds.map((id) => formatForDisplay(currentSearchTerm, id))
943
+ await displayResults(finalResults)
944
+ }
945
+
946
+ document.addEventListener("keydown", shortcutHandler)
947
+ window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
948
+ searchButton.addEventListener("click", () => showSearch("basic"))
949
+ window.addCleanup(() => searchButton.removeEventListener("click", () => showSearch("basic")))
950
+ // brain (P3.6 fix-5): wrap `onType` in an arrow so the InputEvent the
951
+ // browser passes here is harmlessly discarded. `onType` takes no
952
+ // args; binding it directly would still work (extra arg ignored at
953
+ // runtime) but the explicit wrapper makes the intent visible and
954
+ // matches the chip handler's `void onType()` shape.
955
+ const onTypeListener = (): void => {
956
+ void onType()
957
+ }
958
+ searchBar.addEventListener("input", onTypeListener)
959
+ window.addCleanup(() => searchBar.removeEventListener("input", onTypeListener))
960
+
961
+ registerEscapeHandler(container, hideSearch)
962
+ await fillDocument(data)
963
+ }
964
+
965
+ /**
966
+ * Fills flexsearch document with data.
967
+ */
968
+ let indexPopulated = false
969
+ async function fillDocument(data: ContentIndex) {
970
+ if (indexPopulated) return
971
+ let id = 0
972
+ const promises: Array<Promise<unknown>> = []
973
+ for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
974
+ const entry = fileData as BrainEntry & ContentDetails
975
+ // brain: feed the index with the snippet (P3.1 contract) when
976
+ // available, falling back to the legacy `content` field. Keeping
977
+ // both branches lets the search component cope with a stale
978
+ // `contentIndex.json` that pre-dates the slim transform.
979
+ const indexable =
980
+ typeof entry.snippet === "string" && entry.snippet.length > 0
981
+ ? entry.snippet
982
+ : entry.content ?? ""
983
+ promises.push(
984
+ index.addAsync(id++, {
985
+ id,
986
+ slug: slug as FullSlug,
987
+ title: entry.title ?? "",
988
+ content: indexable,
989
+ tags: entry.tags ?? [],
990
+ }),
991
+ )
992
+ }
993
+
994
+ await Promise.all(promises)
995
+ indexPopulated = true
996
+ }
997
+
998
+ // brain: hooked off Quartz's SPA `nav` event — fires on first page
999
+ // load + every SPA navigation. The fetch is shared via Quartz's
1000
+ // inline `fetchData` global (see commandPalette.inline.ts L88-94 for
1001
+ // the contract).
1002
+ declare const fetchData: Promise<ContentIndex>
1003
+
1004
+ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
1005
+ const currentSlug = e.detail.url
1006
+ const data = await fetchData
1007
+ const searchElement = document.getElementsByClassName("search")
1008
+ for (const element of searchElement) {
1009
+ await setupSearch(element, currentSlug, data)
1010
+ }
1011
+ })