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,215 @@
|
|
|
1
|
+
"""Edit classifier: classify a vault file edit as trivial or non-trivial.
|
|
2
|
+
|
|
3
|
+
A **trivial** edit is one whose canonical structural fingerprint did NOT
|
|
4
|
+
change — only body prose / whitespace / HTML comments / ignored frontmatter
|
|
5
|
+
fields were touched. A **non-trivial** edit requires a full Quartz build
|
|
6
|
+
(fingerprint changed, slug unknown, manifest missing / unreadable, source
|
|
7
|
+
path diverged from manifest record, etc.).
|
|
8
|
+
|
|
9
|
+
Used by the watcher (T6) to route edits through the fast path or the full
|
|
10
|
+
build path.
|
|
11
|
+
|
|
12
|
+
Public surface:
|
|
13
|
+
classify_edit(*, fastpath_dir, source_path, vault_root) → ClassificationResult
|
|
14
|
+
|
|
15
|
+
Slug helpers live in ``brain.wiki.slug``; import them from there.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from brain.wiki.fastpath_manifest import (
|
|
24
|
+
ManifestError,
|
|
25
|
+
compute_fingerprint,
|
|
26
|
+
read_manifest,
|
|
27
|
+
)
|
|
28
|
+
from brain.wiki.slug import slugify_source_path
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Public types
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EditClassification(Enum):
|
|
36
|
+
"""Result of classify_edit."""
|
|
37
|
+
|
|
38
|
+
TRIVIAL = "trivial"
|
|
39
|
+
NON_TRIVIAL = "non-trivial"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class ClassificationResult:
|
|
44
|
+
"""Full result of a single-file edit classification."""
|
|
45
|
+
|
|
46
|
+
classification: EditClassification
|
|
47
|
+
reason: str # human-readable, for logs / telemetry
|
|
48
|
+
slug: str | None # None only if slug could not be determined
|
|
49
|
+
old_fingerprint: str | None # None if manifest missing / slug unknown
|
|
50
|
+
new_fingerprint: str | None # None if file unreadable / compute failed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Public classifier
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def classify_edit(
|
|
59
|
+
*,
|
|
60
|
+
fastpath_dir: Path,
|
|
61
|
+
source_path: Path,
|
|
62
|
+
vault_root: Path,
|
|
63
|
+
) -> ClassificationResult:
|
|
64
|
+
"""Classify a single vault file edit as trivial or non-trivial.
|
|
65
|
+
|
|
66
|
+
A trivial edit is one where the canonical structural fingerprint is
|
|
67
|
+
unchanged — only prose / whitespace / ignored frontmatter fields were
|
|
68
|
+
modified. A non-trivial edit (or any error condition) falls back to a
|
|
69
|
+
full Quartz build.
|
|
70
|
+
|
|
71
|
+
Algorithm:
|
|
72
|
+
1. Validate ``source_path`` is inside ``vault_root`` (raises ValueError
|
|
73
|
+
on programmer error — T6 must pre-validate event paths).
|
|
74
|
+
2. Compute slug from ``source_path`` relative to ``vault_root``.
|
|
75
|
+
3. Check ``source_path`` still exists (delete/move → non-trivial).
|
|
76
|
+
4. Read manifest from ``fastpath_dir``; catch ManifestError → non-trivial.
|
|
77
|
+
5. Look up slug in manifest; missing → non-trivial.
|
|
78
|
+
5a. **Rename guard:** compare current vault-relative path against
|
|
79
|
+
``entry.source_path`` from the manifest. If they differ, the slug
|
|
80
|
+
collision is spurious (e.g. "a b.md" and "a-b.md" both slugify to
|
|
81
|
+
``a-b``); force non-trivial so the full build reconciles the rename.
|
|
82
|
+
6. Read file bytes; OS error → non-trivial.
|
|
83
|
+
7. After step 5a verifies the current vault-relative path equals
|
|
84
|
+
``entry.source_path``, compute the new fingerprint using the manifest's
|
|
85
|
+
recorded ``source_path`` / ``output_path`` (sanity guard against
|
|
86
|
+
drift between the manifest writer's and reader's path conventions).
|
|
87
|
+
8. Compare fingerprints; equal → trivial, unequal → non-trivial.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
fastpath_dir: Path to ``<vault>/.quartz/.cache/fastpath/``.
|
|
91
|
+
source_path: Absolute path to the edited file.
|
|
92
|
+
vault_root: Absolute path to the vault root directory.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
:class:`ClassificationResult` with all fields populated.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ValueError: If ``source_path`` is not inside ``vault_root``.
|
|
99
|
+
"""
|
|
100
|
+
# Step 1 — validate source_path inside vault_root (programmer-error guard).
|
|
101
|
+
try:
|
|
102
|
+
source_path.relative_to(vault_root)
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"source_path {source_path!r} is not inside vault_root {vault_root!r}"
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
# Step 2 — compute slug.
|
|
109
|
+
slug = slugify_source_path(source_path, vault_root)
|
|
110
|
+
|
|
111
|
+
# Step 3 — source file must exist (delete / move → non-trivial).
|
|
112
|
+
if not source_path.exists():
|
|
113
|
+
return ClassificationResult(
|
|
114
|
+
classification=EditClassification.NON_TRIVIAL,
|
|
115
|
+
reason="source file missing",
|
|
116
|
+
slug=slug,
|
|
117
|
+
old_fingerprint=None,
|
|
118
|
+
new_fingerprint=None,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Step 4 — read manifest; any ManifestError → non-trivial.
|
|
122
|
+
try:
|
|
123
|
+
manifest = read_manifest(fastpath_dir)
|
|
124
|
+
except ManifestError as exc:
|
|
125
|
+
raw = str(exc)
|
|
126
|
+
# Produce a user-friendly reason while preserving the error detail.
|
|
127
|
+
if "cannot read manifest" in raw:
|
|
128
|
+
reason = "manifest missing — first build pending or cleared"
|
|
129
|
+
elif "malformed manifest JSON" in raw:
|
|
130
|
+
reason = f"manifest unreadable: {exc}"
|
|
131
|
+
elif "version" in raw:
|
|
132
|
+
reason = raw # already contains version numbers from read_manifest
|
|
133
|
+
else:
|
|
134
|
+
reason = f"manifest error: {exc}"
|
|
135
|
+
return ClassificationResult(
|
|
136
|
+
classification=EditClassification.NON_TRIVIAL,
|
|
137
|
+
reason=reason,
|
|
138
|
+
slug=slug,
|
|
139
|
+
old_fingerprint=None,
|
|
140
|
+
new_fingerprint=None,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Step 5 — look up slug entry.
|
|
144
|
+
entry = manifest.slugs.get(slug)
|
|
145
|
+
if entry is None:
|
|
146
|
+
return ClassificationResult(
|
|
147
|
+
classification=EditClassification.NON_TRIVIAL,
|
|
148
|
+
reason=f"slug not in manifest: {slug}",
|
|
149
|
+
slug=slug,
|
|
150
|
+
old_fingerprint=None,
|
|
151
|
+
new_fingerprint=None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Step 5a — rename guard: verify current path matches manifest's record.
|
|
155
|
+
# Two distinct filenames can produce the same slug (e.g. "a b.md" and
|
|
156
|
+
# "a-b.md" both → "a-b"). If the paths differ the slug match is spurious;
|
|
157
|
+
# force full build so the rename is properly handled.
|
|
158
|
+
current_source_path = source_path.relative_to(vault_root).as_posix()
|
|
159
|
+
if entry.source_path != current_source_path:
|
|
160
|
+
return ClassificationResult(
|
|
161
|
+
classification=EditClassification.NON_TRIVIAL,
|
|
162
|
+
reason=(
|
|
163
|
+
f"source path changed: manifest={entry.source_path!r} "
|
|
164
|
+
f"current={current_source_path!r}"
|
|
165
|
+
),
|
|
166
|
+
slug=slug,
|
|
167
|
+
old_fingerprint=entry.fingerprint,
|
|
168
|
+
new_fingerprint=None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Step 6 — read current file bytes.
|
|
172
|
+
try:
|
|
173
|
+
source_bytes = source_path.read_bytes()
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
return ClassificationResult(
|
|
176
|
+
classification=EditClassification.NON_TRIVIAL,
|
|
177
|
+
reason=f"source unreadable: {exc}",
|
|
178
|
+
slug=slug,
|
|
179
|
+
old_fingerprint=entry.fingerprint,
|
|
180
|
+
new_fingerprint=None,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Step 7 — compute new fingerprint using manifest's recorded paths.
|
|
184
|
+
try:
|
|
185
|
+
new_fp = compute_fingerprint(
|
|
186
|
+
source_bytes=source_bytes,
|
|
187
|
+
slug=slug,
|
|
188
|
+
source_path=entry.source_path,
|
|
189
|
+
output_path=entry.output_path,
|
|
190
|
+
)
|
|
191
|
+
except ManifestError as exc:
|
|
192
|
+
return ClassificationResult(
|
|
193
|
+
classification=EditClassification.NON_TRIVIAL,
|
|
194
|
+
reason=f"fingerprint computation failed: {exc}",
|
|
195
|
+
slug=slug,
|
|
196
|
+
old_fingerprint=entry.fingerprint,
|
|
197
|
+
new_fingerprint=None,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Step 8 — compare fingerprints.
|
|
201
|
+
if new_fp == entry.fingerprint:
|
|
202
|
+
return ClassificationResult(
|
|
203
|
+
classification=EditClassification.TRIVIAL,
|
|
204
|
+
reason="fingerprint unchanged",
|
|
205
|
+
slug=slug,
|
|
206
|
+
old_fingerprint=entry.fingerprint,
|
|
207
|
+
new_fingerprint=new_fp,
|
|
208
|
+
)
|
|
209
|
+
return ClassificationResult(
|
|
210
|
+
classification=EditClassification.NON_TRIVIAL,
|
|
211
|
+
reason="fingerprint changed",
|
|
212
|
+
slug=slug,
|
|
213
|
+
old_fingerprint=entry.fingerprint,
|
|
214
|
+
new_fingerprint=new_fp,
|
|
215
|
+
)
|
brain/wiki/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Wiki-pipeline exceptions."""
|
|
2
|
+
from brain.errors import BrainError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BrainWikiError(BrainError):
|
|
6
|
+
"""Base class for wiki-pipeline failures (build, swap, watch)."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BrainWikiBuildError(BrainWikiError):
|
|
10
|
+
"""The Quartz build subprocess exited non-zero, timed out, or the workspace is broken."""
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Fast-path manifest: per-slug structural fingerprint + manifest I/O.
|
|
2
|
+
|
|
3
|
+
Python counterpart to ``brain/quartz_overrides/quartz/util/fastpath_manifest.ts``.
|
|
4
|
+
Both implementations MUST produce byte-identical canonical blobs for the same
|
|
5
|
+
input — verified by ``tests/wiki/test_fastpath_fingerprint_parity.py``.
|
|
6
|
+
|
|
7
|
+
Canonical-blob format: ``docs/specs/2026-05-09-fastpath-fingerprint.md``.
|
|
8
|
+
|
|
9
|
+
Design constraints:
|
|
10
|
+
- Pure Python; no Node/subprocess.
|
|
11
|
+
- Reads manifest.json written by the TS full-build hook.
|
|
12
|
+
- Computes fingerprint at classifier time (T3) for the current file on disk.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import struct
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import date, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from brain.errors import BrainError
|
|
28
|
+
from brain.vault.frontmatter import parse_frontmatter
|
|
29
|
+
|
|
30
|
+
from ._github_slugger import Slugger
|
|
31
|
+
|
|
32
|
+
FINGERPRINT_VERSION: int = 1
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Structural frontmatter fields — canonical key order (spec §Frontmatter blob).
|
|
36
|
+
# Changes to this list must bump FINGERPRINT_VERSION.
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
_STRUCTURAL_FIELD_ORDER: tuple[str, ...] = (
|
|
40
|
+
"title", "draft", "publish", "tags", "aliases", "permalink", "slug",
|
|
41
|
+
"lang", "cssclasses", "socialImage", "enableToc", "comments", "kind",
|
|
42
|
+
"description", "socialDescription", "date", "created", "modified",
|
|
43
|
+
"updated", "published",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_STRUCTURAL_FIELDS: frozenset[str] = frozenset(_STRUCTURAL_FIELD_ORDER)
|
|
47
|
+
|
|
48
|
+
# Array fields: JSON value is a sorted string array (or null if absent).
|
|
49
|
+
_ARRAY_FIELDS: frozenset[str] = frozenset(("tags", "aliases", "cssclasses"))
|
|
50
|
+
|
|
51
|
+
# Boolean fields: JSON value is bool (or null if absent).
|
|
52
|
+
_BOOL_FIELDS: frozenset[str] = frozenset(("draft", "publish", "enableToc", "comments"))
|
|
53
|
+
|
|
54
|
+
# Date fields: JSON value is iso8601 string (or null if absent).
|
|
55
|
+
_DATE_FIELDS: frozenset[str] = frozenset(
|
|
56
|
+
("date", "created", "modified", "updated", "published")
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Ignored frontmatter fields (Appendix A) — changes do NOT affect rendering.
|
|
61
|
+
# Changes to this list must bump FINGERPRINT_VERSION.
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
_IGNORED_FIELDS: frozenset[str] = frozenset({
|
|
65
|
+
# Identity / dedup metadata
|
|
66
|
+
"id", "external_id", "content_type", "source", "source_path",
|
|
67
|
+
"source_template_id", "source_template_name", "vault_path", "canonical", "hash",
|
|
68
|
+
# Ingest provenance
|
|
69
|
+
"ai_assisted", "autogenerated", "last_reviewed", "collected_by", "owner",
|
|
70
|
+
"organization_id", "organization_name",
|
|
71
|
+
# Brain workflow tracking
|
|
72
|
+
"autopilot_deferred_actions", "autopilot_digest", "autopilot_inventory",
|
|
73
|
+
"autopilot_subscription_renewal", "autopilot_sweep_compliance",
|
|
74
|
+
"autopilot_sweep_equipment", "autopilot_sweep_escalation",
|
|
75
|
+
"autopilot_sweep_payments", "autopilot_sweep_payroll",
|
|
76
|
+
"autopilot_sweep_proposals", "autopilot_sweep_routes",
|
|
77
|
+
"autopilot_sweep_seasonal", "autopilot_sweep_timesheets",
|
|
78
|
+
"autopilot_sweep_workload",
|
|
79
|
+
"billcom", "billcom_auto_sync", "check_grace_period", "hits",
|
|
80
|
+
"hours_saved_by_workflow", "hours_saved_today", "hours_saved_totals",
|
|
81
|
+
"komply_iq_sweep_deficiency", "komply_iq_sweep_invoice_delivery",
|
|
82
|
+
"komply_iq_sweep_invoice_generation", "komply_iq_sweep_payment_reminders",
|
|
83
|
+
"komply_iq_sweep_proposal_conversion", "komply_iq_sweep_quality_assurance",
|
|
84
|
+
"komply_iq_sweep_regulatory_filing",
|
|
85
|
+
"mandate", "proposal_expiration", "proposal_follow_up_reminders",
|
|
86
|
+
"quota", "report_usage", "reset_ai_quotas", "sidebar_position", "status",
|
|
87
|
+
"trial_expiration_check",
|
|
88
|
+
# Krisp/Slack/Gmail metadata
|
|
89
|
+
"participants", "duration_min", "thread_id", "channel", "attendees",
|
|
90
|
+
"to", "from", "subject", "schedule",
|
|
91
|
+
# Free-form metadata not rendered
|
|
92
|
+
"context", "keywords", "notes", "predecessor", "principle", "purview",
|
|
93
|
+
"render", "seo", "sweep", "where", "with", "wrote",
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Regex patterns for body parsing
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
# Non-transclusion wikilinks: [[target]] or [[target|alias]].
|
|
101
|
+
# Negative lookbehind for ! excludes transclusions.
|
|
102
|
+
# Group 1 = raw target text (including #anchor if present).
|
|
103
|
+
_WIKILINK_RE: re.Pattern[str] = re.compile(r"(?<!!)\[\[([^\[\]|]+?)(?:\|[^\[\]]*)?\]\]")
|
|
104
|
+
|
|
105
|
+
# Transclusions: ![[target]] including anchors (#Heading, #^block).
|
|
106
|
+
# Group 1 = raw target text.
|
|
107
|
+
_TRANSCLUSION_RE: re.Pattern[str] = re.compile(r"!\[\[([^\[\]]+?)\]\]")
|
|
108
|
+
|
|
109
|
+
# Block-ref IDs DEFINED in body: ^blockid at end of line.
|
|
110
|
+
# Negative lookbehind for [ and # to avoid matching inside wikilinks.
|
|
111
|
+
_BLOCKREF_DEF_RE: re.Pattern[str] = re.compile(
|
|
112
|
+
r"(?<![\[#])\^([A-Za-z0-9][A-Za-z0-9-]*)(?=\s*$)", re.MULTILINE
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# ATX headings: # text (with optional trailing ##).
|
|
116
|
+
_HEADING_RE: re.Pattern[str] = re.compile(
|
|
117
|
+
r"^#{1,6}[ \t]+(.+?)(?:[ \t]+#+)?$", re.MULTILINE
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Inline body tags: #tagname preceded by whitespace or start of line.
|
|
121
|
+
# Excludes headings (which also start with #) because headings have space AFTER #.
|
|
122
|
+
# This matches Obsidian-style inline tags.
|
|
123
|
+
_INLINE_TAG_RE: re.Pattern[str] = re.compile(
|
|
124
|
+
r"(?:^|(?<=\s))#([A-Za-zÀ-ɏͰ-Ͽ一-龥_]"
|
|
125
|
+
r"[^\s#,;!@$%^&*()\[\]{}'\"<>?/\\|`]*)",
|
|
126
|
+
re.MULTILINE,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Public exceptions + types
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ManifestError(BrainError):
|
|
136
|
+
"""Raised on manifest read/parse failures or version mismatches."""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class SlugEntry:
|
|
141
|
+
"""Per-slug record stored in the manifest."""
|
|
142
|
+
|
|
143
|
+
fingerprint: str
|
|
144
|
+
output_path: str
|
|
145
|
+
source_path: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class Manifest:
|
|
150
|
+
"""Deserialised fastpath ``manifest.json``."""
|
|
151
|
+
|
|
152
|
+
version: int
|
|
153
|
+
parent_build_id: str
|
|
154
|
+
built_at_ms: int
|
|
155
|
+
slugs: dict[str, SlugEntry]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Public API
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_manifest(dir: Path) -> Manifest:
|
|
164
|
+
"""Read + parse ``manifest.json`` from ``dir``.
|
|
165
|
+
|
|
166
|
+
Raises :class:`ManifestError` on missing file, JSON parse error, or
|
|
167
|
+
``version`` mismatch.
|
|
168
|
+
"""
|
|
169
|
+
path = dir / "manifest.json"
|
|
170
|
+
try:
|
|
171
|
+
raw = path.read_text(encoding="utf-8")
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
raise ManifestError(f"cannot read manifest at {path}: {exc}") from exc
|
|
174
|
+
try:
|
|
175
|
+
data: Any = json.loads(raw)
|
|
176
|
+
except json.JSONDecodeError as exc:
|
|
177
|
+
raise ManifestError(f"malformed manifest JSON at {path}: {exc}") from exc
|
|
178
|
+
if not isinstance(data, dict):
|
|
179
|
+
raise ManifestError(
|
|
180
|
+
f"manifest root must be a JSON object, got {type(data).__name__}"
|
|
181
|
+
)
|
|
182
|
+
version = data.get("version")
|
|
183
|
+
if version != FINGERPRINT_VERSION:
|
|
184
|
+
raise ManifestError(
|
|
185
|
+
f"manifest version {version!r} != current FINGERPRINT_VERSION "
|
|
186
|
+
f"{FINGERPRINT_VERSION}"
|
|
187
|
+
)
|
|
188
|
+
try:
|
|
189
|
+
slugs: dict[str, SlugEntry] = {
|
|
190
|
+
k: SlugEntry(
|
|
191
|
+
fingerprint=str(v["fingerprint"]),
|
|
192
|
+
output_path=str(v["output_path"]),
|
|
193
|
+
source_path=str(v["source_path"]),
|
|
194
|
+
)
|
|
195
|
+
for k, v in data.get("slugs", {}).items()
|
|
196
|
+
}
|
|
197
|
+
except (KeyError, TypeError) as exc:
|
|
198
|
+
raise ManifestError(f"malformed manifest slugs at {path}: {exc}") from exc
|
|
199
|
+
return Manifest(
|
|
200
|
+
version=int(data["version"]),
|
|
201
|
+
parent_build_id=str(data.get("parent_build_id", "")),
|
|
202
|
+
built_at_ms=int(data.get("built_at_ms", 0)),
|
|
203
|
+
slugs=slugs,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def compute_fingerprint(
|
|
208
|
+
*,
|
|
209
|
+
source_bytes: bytes,
|
|
210
|
+
slug: str,
|
|
211
|
+
source_path: str,
|
|
212
|
+
output_path: str,
|
|
213
|
+
) -> str:
|
|
214
|
+
"""Compute the structural fingerprint for a vault file.
|
|
215
|
+
|
|
216
|
+
Decodes ``source_bytes`` (UTF-8), parses YAML frontmatter, validates all
|
|
217
|
+
keys against the structural and ignored lists, extracts wikilinks /
|
|
218
|
+
transclusions / block-refs / heading anchors from the body, builds the
|
|
219
|
+
canonical blob per the fingerprint spec, and returns sha256 hex.
|
|
220
|
+
|
|
221
|
+
``slug`` and ``output_path`` are caller-supplied because the classifier
|
|
222
|
+
(T3) reads them directly from the manifest entry it is validating — the
|
|
223
|
+
manifest already stores both values from the full build, so the classifier
|
|
224
|
+
never needs to re-implement Quartz's ``slugifyFilePath()`` or
|
|
225
|
+
output-path derivation.
|
|
226
|
+
|
|
227
|
+
Raises :class:`ManifestError` on:
|
|
228
|
+
- ``source_bytes`` not valid UTF-8
|
|
229
|
+
- YAML frontmatter parse error
|
|
230
|
+
- Unknown frontmatter field (not in structural or Appendix A ignored list)
|
|
231
|
+
- Any other unrecoverable input issue
|
|
232
|
+
"""
|
|
233
|
+
blob = _compute_canonical_blob(
|
|
234
|
+
source_bytes=source_bytes,
|
|
235
|
+
slug=slug,
|
|
236
|
+
source_path=source_path,
|
|
237
|
+
output_path=output_path,
|
|
238
|
+
)
|
|
239
|
+
return hashlib.sha256(blob).hexdigest()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def compute_fingerprint_with_blob(
|
|
243
|
+
*,
|
|
244
|
+
source_bytes: bytes,
|
|
245
|
+
slug: str,
|
|
246
|
+
source_path: str,
|
|
247
|
+
output_path: str,
|
|
248
|
+
) -> tuple[str, bytes]:
|
|
249
|
+
"""Like :func:`compute_fingerprint` but also returns the raw canonical blob.
|
|
250
|
+
|
|
251
|
+
Intended for debugging parity failures — the blob hex lets maintainers
|
|
252
|
+
diff TS and Python byte-by-byte to identify which section diverges.
|
|
253
|
+
"""
|
|
254
|
+
blob = _compute_canonical_blob(
|
|
255
|
+
source_bytes=source_bytes,
|
|
256
|
+
slug=slug,
|
|
257
|
+
source_path=source_path,
|
|
258
|
+
output_path=output_path,
|
|
259
|
+
)
|
|
260
|
+
return hashlib.sha256(blob).hexdigest(), blob
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Private helpers — shared computation core
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _compute_canonical_blob(
|
|
269
|
+
*,
|
|
270
|
+
source_bytes: bytes,
|
|
271
|
+
slug: str,
|
|
272
|
+
source_path: str,
|
|
273
|
+
output_path: str,
|
|
274
|
+
) -> bytes:
|
|
275
|
+
"""Parse ``source_bytes``, validate frontmatter, build and return the blob.
|
|
276
|
+
|
|
277
|
+
Shared by :func:`compute_fingerprint` and :func:`compute_fingerprint_with_blob`.
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
source = source_bytes.decode("utf-8")
|
|
281
|
+
except UnicodeDecodeError as exc:
|
|
282
|
+
raise ManifestError(f"source bytes are not valid UTF-8: {exc}") from exc
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
fields, body = parse_frontmatter(source)
|
|
286
|
+
except (yaml.YAMLError, ValueError) as exc:
|
|
287
|
+
raise ManifestError(f"YAML frontmatter parse error: {exc}") from exc
|
|
288
|
+
|
|
289
|
+
# Validate all frontmatter keys — unknown keys force non-trivial.
|
|
290
|
+
for key in fields:
|
|
291
|
+
if key not in _STRUCTURAL_FIELDS and key not in _IGNORED_FIELDS:
|
|
292
|
+
raise ManifestError(
|
|
293
|
+
f"unknown frontmatter field {key!r} — not in structural list or "
|
|
294
|
+
"Appendix A ignored list; add to spec and bump FINGERPRINT_VERSION"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# SECTION_FRONTMATTER: structural fields + YAML-only tags.
|
|
298
|
+
fm_json = _build_frontmatter_json(fields)
|
|
299
|
+
|
|
300
|
+
# SECTION_TAGS: merged YAML tags + inline body tags, sorted, deduped.
|
|
301
|
+
yaml_tags = _get_str_list(fields.get("tags"))
|
|
302
|
+
inline_tags = _extract_inline_tags(body)
|
|
303
|
+
merged_tags = sorted(set(yaml_tags) | set(inline_tags))
|
|
304
|
+
tags_str = "\n".join(merged_tags)
|
|
305
|
+
|
|
306
|
+
# SECTION_WIKILINKS
|
|
307
|
+
wikilinks_str = "\n".join(sorted(set(_extract_wikilinks(body))))
|
|
308
|
+
|
|
309
|
+
# SECTION_TRANSCLUSIONS
|
|
310
|
+
transclusions_str = "\n".join(sorted(set(_extract_transclusions(body))))
|
|
311
|
+
|
|
312
|
+
# SECTION_BLOCK_REFS
|
|
313
|
+
block_refs_str = "\n".join(sorted(set(_extract_block_refs(body))))
|
|
314
|
+
|
|
315
|
+
# SECTION_HEADING_ANCHORS (document order — NOT sorted)
|
|
316
|
+
headings_str = "\n".join(_extract_heading_anchors(body))
|
|
317
|
+
|
|
318
|
+
return _build_canonical_blob(
|
|
319
|
+
slug=slug,
|
|
320
|
+
source_path=source_path,
|
|
321
|
+
output_path=output_path,
|
|
322
|
+
frontmatter_json=fm_json,
|
|
323
|
+
tags_str=tags_str,
|
|
324
|
+
wikilinks_str=wikilinks_str,
|
|
325
|
+
transclusions_str=transclusions_str,
|
|
326
|
+
block_refs_str=block_refs_str,
|
|
327
|
+
headings_str=headings_str,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
# Private helpers — canonical blob encoding
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _u32be(n: int) -> bytes:
|
|
337
|
+
return struct.pack(">I", n)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _encode_section(s: str) -> bytes:
|
|
341
|
+
encoded = s.encode("utf-8")
|
|
342
|
+
return _u32be(len(encoded)) + encoded
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _build_canonical_blob(
|
|
346
|
+
*,
|
|
347
|
+
slug: str,
|
|
348
|
+
source_path: str,
|
|
349
|
+
output_path: str,
|
|
350
|
+
frontmatter_json: str,
|
|
351
|
+
tags_str: str,
|
|
352
|
+
wikilinks_str: str,
|
|
353
|
+
transclusions_str: str,
|
|
354
|
+
block_refs_str: str,
|
|
355
|
+
headings_str: str,
|
|
356
|
+
) -> bytes:
|
|
357
|
+
"""Assemble the 10-section canonical blob per the fingerprint spec."""
|
|
358
|
+
return b"".join([
|
|
359
|
+
_u32be(FINGERPRINT_VERSION), # SECTION_VERSION: just u32be, no length prefix
|
|
360
|
+
_encode_section(slug), # SECTION_SLUG
|
|
361
|
+
_encode_section(source_path), # SECTION_SOURCE_PATH
|
|
362
|
+
_encode_section(output_path), # SECTION_OUTPUT_PATH
|
|
363
|
+
_encode_section(frontmatter_json), # SECTION_FRONTMATTER
|
|
364
|
+
_encode_section(tags_str), # SECTION_TAGS
|
|
365
|
+
_encode_section(wikilinks_str), # SECTION_WIKILINKS
|
|
366
|
+
_encode_section(transclusions_str), # SECTION_TRANSCLUSIONS
|
|
367
|
+
_encode_section(block_refs_str), # SECTION_BLOCK_REFS
|
|
368
|
+
_encode_section(headings_str), # SECTION_HEADING_ANCHORS
|
|
369
|
+
])
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Private helpers — frontmatter JSON blob
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _normalize_date_val(val: Any) -> str | None:
|
|
378
|
+
"""Serialize a YAML date/datetime/string to ISO 8601, or None.
|
|
379
|
+
|
|
380
|
+
Midnight ``datetime.datetime`` objects are truncated to date-only to match
|
|
381
|
+
TS ``_normalizeDateVal`` behaviour: js-yaml parses bare YAML date scalars
|
|
382
|
+
(e.g. ``date: 2024-03-15``) as UTC-midnight ``Date`` objects, which the TS
|
|
383
|
+
side normalises back to a ``"YYYY-MM-DD"`` string. Python pyyaml produces
|
|
384
|
+
``datetime.date`` for bare dates (already date-only) and
|
|
385
|
+
``datetime.datetime`` for bare date-times; truncating the midnight case
|
|
386
|
+
keeps both sides byte-identical.
|
|
387
|
+
|
|
388
|
+
Examples::
|
|
389
|
+
|
|
390
|
+
datetime.date(2024, 3, 15) → "2024-03-15"
|
|
391
|
+
datetime.datetime(2024, 3, 15, 0, 0, 0) → "2024-03-15" # midnight truncated
|
|
392
|
+
datetime.datetime(2024, 3, 15, 12, 0, 0)→ "2024-03-15T12:00:00"
|
|
393
|
+
"""
|
|
394
|
+
if val is None:
|
|
395
|
+
return None
|
|
396
|
+
if isinstance(val, datetime):
|
|
397
|
+
# Truncate midnight datetimes to date-only, matching TS _normalizeDateVal.
|
|
398
|
+
if (
|
|
399
|
+
val.hour == 0
|
|
400
|
+
and val.minute == 0
|
|
401
|
+
and val.second == 0
|
|
402
|
+
and val.microsecond == 0
|
|
403
|
+
):
|
|
404
|
+
return val.date().isoformat()
|
|
405
|
+
return val.isoformat()
|
|
406
|
+
if isinstance(val, date):
|
|
407
|
+
return val.isoformat()
|
|
408
|
+
if isinstance(val, str):
|
|
409
|
+
return val
|
|
410
|
+
return str(val)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _get_str_list(val: Any) -> list[str]:
|
|
414
|
+
"""Coerce a YAML value to a list of strings."""
|
|
415
|
+
if val is None:
|
|
416
|
+
return []
|
|
417
|
+
if isinstance(val, list):
|
|
418
|
+
return [str(x) for x in val if x is not None]
|
|
419
|
+
if isinstance(val, str):
|
|
420
|
+
return [val]
|
|
421
|
+
return []
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _build_frontmatter_json(fields: dict[str, Any]) -> str:
|
|
425
|
+
"""Build deterministic structural frontmatter JSON (no whitespace, exact key order).
|
|
426
|
+
|
|
427
|
+
Encodes YAML-only tags (not merged with inline body tags) per the spec.
|
|
428
|
+
"""
|
|
429
|
+
obj: dict[str, Any] = {}
|
|
430
|
+
for key in _STRUCTURAL_FIELD_ORDER:
|
|
431
|
+
if key in _ARRAY_FIELDS:
|
|
432
|
+
if key not in fields or fields[key] is None:
|
|
433
|
+
obj[key] = None
|
|
434
|
+
else:
|
|
435
|
+
obj[key] = sorted(_get_str_list(fields[key]))
|
|
436
|
+
elif key in _BOOL_FIELDS:
|
|
437
|
+
val = fields.get(key)
|
|
438
|
+
obj[key] = bool(val) if val is not None else None
|
|
439
|
+
elif key in _DATE_FIELDS:
|
|
440
|
+
obj[key] = _normalize_date_val(fields.get(key))
|
|
441
|
+
else:
|
|
442
|
+
val = fields.get(key)
|
|
443
|
+
obj[key] = str(val) if val is not None else None
|
|
444
|
+
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# Private helpers — body parsing
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _extract_inline_tags(body: str) -> list[str]:
|
|
453
|
+
"""Extract inline ``#tags`` from body text (Obsidian-style)."""
|
|
454
|
+
return [m.group(1) for m in _INLINE_TAG_RE.finditer(body)]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _extract_wikilinks(body: str) -> list[str]:
|
|
458
|
+
"""Extract non-transclusion wikilink targets (raw text, including anchors)."""
|
|
459
|
+
return [m.group(1).strip() for m in _WIKILINK_RE.finditer(body) if m.group(1).strip()]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _extract_transclusions(body: str) -> list[str]:
|
|
463
|
+
"""Extract transclusion targets (``![[...]]``, including anchors)."""
|
|
464
|
+
return [m.group(1).strip() for m in _TRANSCLUSION_RE.finditer(body) if m.group(1).strip()]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _extract_block_refs(body: str) -> list[str]:
|
|
468
|
+
"""Extract block-ref IDs defined in body (``^blockid`` at end of line)."""
|
|
469
|
+
return [m.group(1) for m in _BLOCKREF_DEF_RE.finditer(body)]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _extract_heading_anchors(body: str) -> list[str]:
|
|
473
|
+
"""Return heading anchors in document order using github-slugger semantics."""
|
|
474
|
+
slugger = Slugger()
|
|
475
|
+
return [slugger.slug(m.group(1).strip()) for m in _HEADING_RE.finditer(body)]
|