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