secondbrain-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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 `<script>alert(1)</script>` 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, "&")
|
|
469
|
+
.replace(/</g, "<")
|
|
470
|
+
.replace(/>/g, ">")
|
|
471
|
+
.replace(/"/g, """)
|
|
472
|
+
.replace(/'/g, "'")
|
|
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
|
+
})
|