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,388 @@
|
|
|
1
|
+
"""Byte-deterministic exporters for :class:`brain.vault.graph.GraphData`.
|
|
2
|
+
|
|
3
|
+
Three target formats:
|
|
4
|
+
|
|
5
|
+
- JSON — stable shape with ``sort_keys=True``; ``json.loads`` round-trips.
|
|
6
|
+
- Graphviz DOT — single ``digraph G {}``; pipe to ``dot -Tsvg`` for an SVG.
|
|
7
|
+
- Mermaid — ``graph TD`` (top-down); paste into a Mermaid renderer.
|
|
8
|
+
|
|
9
|
+
Determinism contract: every output is a pure function of the
|
|
10
|
+
:class:`GraphData` input. No timestamps, no random sorts, no environment
|
|
11
|
+
reads. Tests pin exact byte strings; any change to a formatter will
|
|
12
|
+
break them — that's the point.
|
|
13
|
+
|
|
14
|
+
Derived-edge tier styling (per spec §10 Q3):
|
|
15
|
+
|
|
16
|
+
- Wiki-link edges keep the existing solid black look — they are the
|
|
17
|
+
user's authoritative thinking surface.
|
|
18
|
+
- ``derived``/``shared_thread`` (R1, weight 1.0) — strongest derived
|
|
19
|
+
signal, rendered bold (Mermaid ``==>``, DOT ``style=bold``).
|
|
20
|
+
- ``derived``/``same_day_participant`` (R3, weight 0.7) — medium
|
|
21
|
+
confidence, plain solid arrow but in gray (Mermaid ``-->`` with a
|
|
22
|
+
``linkStyle`` directive, DOT ``color=gray``).
|
|
23
|
+
- ``derived``/``shared_participant`` (R2, weight 0.4) — noisiest tier,
|
|
24
|
+
visually subordinated as dotted/light gray (Mermaid ``-.->``, DOT
|
|
25
|
+
``style=dotted, color="#cccccc"``).
|
|
26
|
+
|
|
27
|
+
JSON output passes ``rule`` / ``weight`` / ``evidence`` straight through
|
|
28
|
+
on every edge regardless of tier; wiki edges carry ``null`` for all three
|
|
29
|
+
to keep the schema stable.
|
|
30
|
+
"""
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .graph import GraphData, GraphEdge, GraphNode
|
|
36
|
+
|
|
37
|
+
# Long titles get truncated in DOT/Mermaid labels for readability — graphs
|
|
38
|
+
# with even moderately verbose titles produce unreadable SVG otherwise.
|
|
39
|
+
# JSON keeps the full title (machine-readable; renderers can wrap).
|
|
40
|
+
_LABEL_TRUNCATE_AT = 60
|
|
41
|
+
_TRUNCATION_SUFFIX = "…" # `…`
|
|
42
|
+
|
|
43
|
+
# Mermaid node ids must be alphanumeric or underscore. We strip hyphens
|
|
44
|
+
# from the UUID prefix; if that produces a collision we add a stable hash
|
|
45
|
+
# suffix (4 hex chars) computed from the full document_id.
|
|
46
|
+
_MERMAID_ID_PREFIX_LEN = 8
|
|
47
|
+
_MERMAID_HASH_SUFFIX_LEN = 4
|
|
48
|
+
|
|
49
|
+
# Vault-tier nodes get the "primary" color; ingested-tier nodes get a
|
|
50
|
+
# muted gray so the user's authored notes pop visually.
|
|
51
|
+
_DOT_FILL_VAULT = "lightblue"
|
|
52
|
+
_DOT_FILL_INGESTED = "lightgray"
|
|
53
|
+
# Mermaid uses class definitions; the body of these is byte-stable.
|
|
54
|
+
_MERMAID_CLASS_VAULT = (
|
|
55
|
+
"classDef vault fill:#cfe5f5,stroke:#3a7ab8,color:#000;"
|
|
56
|
+
)
|
|
57
|
+
_MERMAID_CLASS_INGESTED = (
|
|
58
|
+
"classDef ingested fill:#dddddd,stroke:#888,color:#000;"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Mermaid arrow shape per derived rule. R1 (`shared_thread`) and R2
|
|
62
|
+
# (`shared_participant`) use Mermaid's built-in bold/dotted arrows; R3
|
|
63
|
+
# (`same_day_participant`) renders as a plain arrow whose color is
|
|
64
|
+
# overridden via a `linkStyle` directive emitted after the edge block.
|
|
65
|
+
_MERMAID_DERIVED_ARROWS: dict[str, str] = {
|
|
66
|
+
"shared_thread": "==>", # R1 — bold built-in
|
|
67
|
+
"shared_participant": "-.->", # R2 — dotted built-in
|
|
68
|
+
"same_day_participant": "-->", # R3 — plain arrow, gray via linkStyle
|
|
69
|
+
}
|
|
70
|
+
# Rules that need a per-edge `linkStyle` line to override the default
|
|
71
|
+
# stroke. Currently only R3 — R1 and R2 carry their styling in the arrow
|
|
72
|
+
# shape itself.
|
|
73
|
+
_MERMAID_LINKSTYLE: dict[str, str] = {
|
|
74
|
+
"same_day_participant": "stroke:gray", # R3
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# DOT per-edge attributes for derived rules. Stored as a tuple of raw
|
|
78
|
+
# `key=value` fragments that are appended to the per-edge attribute list
|
|
79
|
+
# alongside the existing `label="..."`. Color values that aren't bare
|
|
80
|
+
# Graphviz keywords (e.g. hex codes) are quoted so the parser doesn't
|
|
81
|
+
# choke; bare keywords (`bold`, `solid`, `gray`, `black`, `dotted`) stay
|
|
82
|
+
# unquoted to match the spec table verbatim.
|
|
83
|
+
_DOT_DERIVED_ATTRS: dict[str, tuple[str, ...]] = {
|
|
84
|
+
"shared_thread": ("style=bold", "color=black"), # R1
|
|
85
|
+
"same_day_participant": ("style=solid", "color=gray"), # R3
|
|
86
|
+
"shared_participant": ("style=dotted", 'color="#cccccc"'), # R2
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def to_json(graph: GraphData) -> str:
|
|
91
|
+
"""Stable JSON: ``{"nodes": [...], "edges": [...]}``.
|
|
92
|
+
|
|
93
|
+
Nodes are sorted by ``(title.lower(), id)`` — case-insensitive title
|
|
94
|
+
primary, id tiebreaker. Edges are sorted by
|
|
95
|
+
``(src, dst, link_text, link_kind)`` so edges between the same pair
|
|
96
|
+
of nodes still order consistently (a note with multiple ``[[refs]]``
|
|
97
|
+
to the same target produces multiple deterministic rows).
|
|
98
|
+
|
|
99
|
+
``json.dumps`` is called with ``sort_keys=True`` so the per-object
|
|
100
|
+
key order is also stable. ``ensure_ascii=False`` keeps non-ASCII
|
|
101
|
+
titles readable instead of escaping them as ``\\uXXXX`` sequences;
|
|
102
|
+
determinism is preserved because the input was already a Python
|
|
103
|
+
string.
|
|
104
|
+
|
|
105
|
+
Every edge carries ``rule``, ``weight``, ``evidence`` keys so the
|
|
106
|
+
JSON shape is uniform across wiki and derived edges. Wiki edges have
|
|
107
|
+
``null`` for all three; derived edges carry the metadata-rule
|
|
108
|
+
provenance pulled straight from :class:`GraphEdge`.
|
|
109
|
+
"""
|
|
110
|
+
payload: dict[str, list[dict[str, Any]]] = {
|
|
111
|
+
"nodes": [
|
|
112
|
+
{
|
|
113
|
+
"id": n.document_id,
|
|
114
|
+
"title": n.title,
|
|
115
|
+
"kind": n.kind,
|
|
116
|
+
}
|
|
117
|
+
for n in sorted(
|
|
118
|
+
graph.nodes,
|
|
119
|
+
key=lambda n: (n.title.lower(), n.document_id),
|
|
120
|
+
)
|
|
121
|
+
],
|
|
122
|
+
"edges": [
|
|
123
|
+
{
|
|
124
|
+
"src": e.src_document_id,
|
|
125
|
+
"dst": e.dst_document_id,
|
|
126
|
+
"kind": e.link_kind,
|
|
127
|
+
"text": e.link_text,
|
|
128
|
+
"display": e.display_text,
|
|
129
|
+
"rule": e.rule,
|
|
130
|
+
"weight": e.weight,
|
|
131
|
+
"evidence": e.evidence,
|
|
132
|
+
}
|
|
133
|
+
for e in sorted(
|
|
134
|
+
graph.edges,
|
|
135
|
+
key=lambda e: (
|
|
136
|
+
e.src_document_id,
|
|
137
|
+
e.dst_document_id,
|
|
138
|
+
e.link_text,
|
|
139
|
+
e.link_kind,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
],
|
|
143
|
+
}
|
|
144
|
+
return json.dumps(payload, sort_keys=True, ensure_ascii=False, indent=2)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def to_dot(graph: GraphData) -> str:
|
|
148
|
+
"""Graphviz DOT (digraph). Pipe to ``dot -Tsvg`` for visualization.
|
|
149
|
+
|
|
150
|
+
Empty graph returns a valid (but empty-bodied) digraph — tools like
|
|
151
|
+
``dot`` accept it without warning.
|
|
152
|
+
|
|
153
|
+
Conventions:
|
|
154
|
+
|
|
155
|
+
- Node ids = first 8 chars of the document UUID with hyphens
|
|
156
|
+
stripped; collision-safe via a 4-hex hash suffix.
|
|
157
|
+
- Vault-tier nodes get ``fillcolor=lightblue``; ingested-tier nodes
|
|
158
|
+
get ``fillcolor=lightgray``.
|
|
159
|
+
- Embed edges (``link_kind='embed'``) get ``style=dashed``.
|
|
160
|
+
- Derived edges pick up tier-specific ``style`` + ``color`` from
|
|
161
|
+
:data:`_DOT_DERIVED_ATTRS` (see module docstring).
|
|
162
|
+
- Labels: title truncated to 60 chars; all backslashes / quotes /
|
|
163
|
+
newlines escaped per Graphviz quoted-string rules.
|
|
164
|
+
"""
|
|
165
|
+
sorted_nodes = sorted(
|
|
166
|
+
graph.nodes, key=lambda n: (n.title.lower(), n.document_id)
|
|
167
|
+
)
|
|
168
|
+
sorted_edges = sorted(
|
|
169
|
+
graph.edges,
|
|
170
|
+
key=lambda e: (
|
|
171
|
+
e.src_document_id,
|
|
172
|
+
e.dst_document_id,
|
|
173
|
+
e.link_text,
|
|
174
|
+
e.link_kind,
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
lines = [
|
|
179
|
+
"digraph G {",
|
|
180
|
+
" rankdir=LR;",
|
|
181
|
+
" node [shape=box, style=\"rounded,filled\"];",
|
|
182
|
+
]
|
|
183
|
+
if not sorted_nodes and not sorted_edges:
|
|
184
|
+
lines.append("}")
|
|
185
|
+
return "\n".join(lines) + "\n"
|
|
186
|
+
|
|
187
|
+
id_map = _build_id_map(sorted_nodes)
|
|
188
|
+
for n in sorted_nodes:
|
|
189
|
+
node_id = id_map[n.document_id]
|
|
190
|
+
label = _dot_escape(_truncate_label(n.title))
|
|
191
|
+
fill = _DOT_FILL_VAULT if n.kind == "vault" else _DOT_FILL_INGESTED
|
|
192
|
+
lines.append(
|
|
193
|
+
f' {node_id} [label="{label}", fillcolor="{fill}"];'
|
|
194
|
+
)
|
|
195
|
+
for e in sorted_edges:
|
|
196
|
+
src = id_map.get(e.src_document_id)
|
|
197
|
+
dst = id_map.get(e.dst_document_id)
|
|
198
|
+
if src is None or dst is None:
|
|
199
|
+
# Shouldn't happen — graph_data filters edges to nodes in the
|
|
200
|
+
# same snapshot. Skip rather than emit a dangling edge.
|
|
201
|
+
continue # pragma: no cover
|
|
202
|
+
label = _dot_escape(_truncate_label(_edge_label(e)))
|
|
203
|
+
attrs = [f'label="{label}"']
|
|
204
|
+
if e.link_kind == "embed":
|
|
205
|
+
attrs.append('style="dashed"')
|
|
206
|
+
elif e.link_kind == "derived" and e.rule in _DOT_DERIVED_ATTRS:
|
|
207
|
+
attrs.extend(_DOT_DERIVED_ATTRS[e.rule])
|
|
208
|
+
lines.append(f" {src} -> {dst} [{', '.join(attrs)}];")
|
|
209
|
+
lines.append("}")
|
|
210
|
+
return "\n".join(lines) + "\n"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def to_mermaid(graph: GraphData) -> str:
|
|
214
|
+
"""Mermaid ``graph TD`` (top-down). Paste into any Mermaid renderer.
|
|
215
|
+
|
|
216
|
+
Empty graph emits ``graph TD\\n`` — valid Mermaid syntax with no
|
|
217
|
+
nodes or edges (the renderer shows an empty canvas).
|
|
218
|
+
|
|
219
|
+
Conventions match :func:`to_dot`:
|
|
220
|
+
|
|
221
|
+
- Node ids: alphanumeric + underscore (Mermaid syntax requirement),
|
|
222
|
+
built from the UUID prefix + collision hash.
|
|
223
|
+
- Vault-tier nodes get class ``vault``; ingested-tier get
|
|
224
|
+
``ingested``. Class definitions are emitted at the top so the
|
|
225
|
+
output is one self-contained string.
|
|
226
|
+
- Embed edges use ``-.->`` (dashed). Wiki edges use ``-->``.
|
|
227
|
+
- Derived edges pick their arrow shape from
|
|
228
|
+
:data:`_MERMAID_DERIVED_ARROWS` (R1 ``==>``, R2 ``-.->``, R3
|
|
229
|
+
``-->``). Rules listed in :data:`_MERMAID_LINKSTYLE` (R3) emit a
|
|
230
|
+
trailing ``linkStyle <index> stroke:gray;`` directive targeting
|
|
231
|
+
the edge by its 0-indexed position in the rendered output.
|
|
232
|
+
- Labels: title truncated to 60 chars, with Mermaid-special chars
|
|
233
|
+
escaped (``"`` → ``#quot;`` per Mermaid's HTML-entity convention).
|
|
234
|
+
"""
|
|
235
|
+
sorted_nodes = sorted(
|
|
236
|
+
graph.nodes, key=lambda n: (n.title.lower(), n.document_id)
|
|
237
|
+
)
|
|
238
|
+
sorted_edges = sorted(
|
|
239
|
+
graph.edges,
|
|
240
|
+
key=lambda e: (
|
|
241
|
+
e.src_document_id,
|
|
242
|
+
e.dst_document_id,
|
|
243
|
+
e.link_text,
|
|
244
|
+
e.link_kind,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if not sorted_nodes and not sorted_edges:
|
|
249
|
+
return "graph TD\n"
|
|
250
|
+
|
|
251
|
+
lines = ["graph TD"]
|
|
252
|
+
# Class definitions kept out of the empty-graph branch — Mermaid
|
|
253
|
+
# rejects standalone class definitions without nodes in some
|
|
254
|
+
# renderer versions.
|
|
255
|
+
lines.append(f" {_MERMAID_CLASS_VAULT}")
|
|
256
|
+
lines.append(f" {_MERMAID_CLASS_INGESTED}")
|
|
257
|
+
|
|
258
|
+
id_map = _build_id_map(sorted_nodes)
|
|
259
|
+
for n in sorted_nodes:
|
|
260
|
+
node_id = id_map[n.document_id]
|
|
261
|
+
label = _mermaid_escape(_truncate_label(n.title))
|
|
262
|
+
# ``id["label"]`` is Mermaid's "rectangle with text" form — works
|
|
263
|
+
# for vault and ingested alike since we differentiate by class.
|
|
264
|
+
lines.append(f' {node_id}["{label}"]')
|
|
265
|
+
cls = "vault" if n.kind == "vault" else "ingested"
|
|
266
|
+
lines.append(f" class {node_id} {cls};")
|
|
267
|
+
# `linkStyle` directives must reference the 0-indexed position of an
|
|
268
|
+
# edge in the rendered Mermaid output. We track the index as we emit
|
|
269
|
+
# edges and accumulate directives in a sidecar list, then append them
|
|
270
|
+
# after the edge block — keeping every edge line contiguous (some
|
|
271
|
+
# Mermaid renderers stumble on linkStyle interleaved with edges).
|
|
272
|
+
linkstyle_lines: list[str] = []
|
|
273
|
+
edge_index = 0
|
|
274
|
+
for e in sorted_edges:
|
|
275
|
+
src = id_map.get(e.src_document_id)
|
|
276
|
+
dst = id_map.get(e.dst_document_id)
|
|
277
|
+
if src is None or dst is None:
|
|
278
|
+
continue # pragma: no cover
|
|
279
|
+
arrow = _mermaid_arrow_for(e)
|
|
280
|
+
label = _mermaid_escape(_truncate_label(_edge_label(e)))
|
|
281
|
+
if label:
|
|
282
|
+
lines.append(f' {src} {arrow}|"{label}"| {dst}')
|
|
283
|
+
else:
|
|
284
|
+
lines.append(f" {src} {arrow} {dst}")
|
|
285
|
+
if e.link_kind == "derived" and e.rule in _MERMAID_LINKSTYLE:
|
|
286
|
+
linkstyle_lines.append(
|
|
287
|
+
f" linkStyle {edge_index} {_MERMAID_LINKSTYLE[e.rule]};"
|
|
288
|
+
)
|
|
289
|
+
edge_index += 1
|
|
290
|
+
lines.extend(linkstyle_lines)
|
|
291
|
+
return "\n".join(lines) + "\n"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# Helpers — kept private (formatter-internal). Stable across releases as long
|
|
296
|
+
# as the byte-for-byte output of the public functions is stable.
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _build_id_map(nodes: list[GraphNode]) -> dict[str, str]:
|
|
301
|
+
"""Map document_id → safe id string used by DOT/Mermaid.
|
|
302
|
+
|
|
303
|
+
Strategy: first 8 chars of the UUID with hyphens stripped (UUIDs use
|
|
304
|
+
only hex + hyphens, so the result is always alphanumeric). On
|
|
305
|
+
collision (same 8-hex prefix shared by two docs) append a 4-char
|
|
306
|
+
hash of the full id. Mermaid requires the prefix to be a letter or
|
|
307
|
+
underscore — UUIDs with leading digits are common, so we always
|
|
308
|
+
prepend ``n_``.
|
|
309
|
+
"""
|
|
310
|
+
out: dict[str, str] = {}
|
|
311
|
+
used: dict[str, str] = {} # safe_id → document_id
|
|
312
|
+
for node in nodes:
|
|
313
|
+
candidate = "n_" + node.document_id.replace("-", "")[:_MERMAID_ID_PREFIX_LEN]
|
|
314
|
+
if candidate in used and used[candidate] != node.document_id:
|
|
315
|
+
suffix = hashlib.sha256(
|
|
316
|
+
node.document_id.encode("utf-8")
|
|
317
|
+
).hexdigest()[:_MERMAID_HASH_SUFFIX_LEN]
|
|
318
|
+
candidate = f"{candidate}_{suffix}"
|
|
319
|
+
used[candidate] = node.document_id
|
|
320
|
+
out[node.document_id] = candidate
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _truncate_label(text: str) -> str:
|
|
325
|
+
"""Shorten ``text`` to :data:`_LABEL_TRUNCATE_AT` chars with an ellipsis."""
|
|
326
|
+
if len(text) <= _LABEL_TRUNCATE_AT:
|
|
327
|
+
return text
|
|
328
|
+
return text[: _LABEL_TRUNCATE_AT - 1] + _TRUNCATION_SUFFIX
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _mermaid_arrow_for(edge: GraphEdge) -> str:
|
|
332
|
+
"""Pick the Mermaid arrow shape for an edge.
|
|
333
|
+
|
|
334
|
+
Embed edges keep the existing dashed arrow. Derived edges use the
|
|
335
|
+
per-rule arrow from :data:`_MERMAID_DERIVED_ARROWS`. Wiki edges and
|
|
336
|
+
any unknown derived rule fall through to the plain ``-->`` arrow —
|
|
337
|
+
that way an unrecognized rule still renders as a valid Mermaid
|
|
338
|
+
edge instead of crashing the formatter.
|
|
339
|
+
"""
|
|
340
|
+
if edge.link_kind == "embed":
|
|
341
|
+
return "-.->"
|
|
342
|
+
if edge.link_kind == "derived" and edge.rule in _MERMAID_DERIVED_ARROWS:
|
|
343
|
+
return _MERMAID_DERIVED_ARROWS[edge.rule]
|
|
344
|
+
return "-->"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _edge_label(edge: GraphEdge) -> str:
|
|
348
|
+
"""Pick the human label for an edge.
|
|
349
|
+
|
|
350
|
+
Display text wins when set (``[[X|person-x]]`` shows "person-x"); otherwise
|
|
351
|
+
the raw ``link_text`` ("[[person-x conversation]]"). Renderers usually
|
|
352
|
+
want the same thing the user typed — ambiguity ("did they mean
|
|
353
|
+
display or raw?") is resolved here once instead of every consumer.
|
|
354
|
+
"""
|
|
355
|
+
return edge.display_text or edge.link_text
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _dot_escape(text: str) -> str:
|
|
359
|
+
"""Escape ``text`` for inclusion in a DOT quoted string.
|
|
360
|
+
|
|
361
|
+
Per Graphviz: backslash escapes itself, double-quote, and newline.
|
|
362
|
+
Other characters pass through. Length-preserving except for the
|
|
363
|
+
inserted backslashes.
|
|
364
|
+
"""
|
|
365
|
+
return (
|
|
366
|
+
text.replace("\\", "\\\\")
|
|
367
|
+
.replace('"', '\\"')
|
|
368
|
+
.replace("\n", "\\n")
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _mermaid_escape(text: str) -> str:
|
|
373
|
+
"""Escape ``text`` for inclusion inside a double-quoted Mermaid label.
|
|
374
|
+
|
|
375
|
+
Mermaid uses HTML entities for special chars inside ``"..."``:
|
|
376
|
+
|
|
377
|
+
- ``"`` → ``#quot;``
|
|
378
|
+
- ``#`` → ``#35;`` (so a literal ``#`` doesn't become an entity)
|
|
379
|
+
|
|
380
|
+
Newlines are converted to ``<br/>`` (Mermaid's only multi-line
|
|
381
|
+
convention inside labels). Backslashes are left alone — Mermaid
|
|
382
|
+
doesn't process them as escapes inside quoted labels.
|
|
383
|
+
"""
|
|
384
|
+
return (
|
|
385
|
+
text.replace("#", "#35;")
|
|
386
|
+
.replace('"', "#quot;")
|
|
387
|
+
.replace("\n", "<br/>")
|
|
388
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Rewrite vault-tier wiki-links to vault-root-relative path form.
|
|
2
|
+
|
|
3
|
+
Quartz's ``ObsidianFlavoredMarkdown`` plugin treats the inner text of a
|
|
4
|
+
``[[X]]`` marker as a literal filepath/slug — it does no frontmatter
|
|
5
|
+
lookup. So a vault note that contains ``[[company-mc <> COMPANY_REDACTED - Recap]]``
|
|
6
|
+
emits ``href="./company-mc-<>-COMPANY_REDACTED---Recap"`` and 404s in the rendered
|
|
7
|
+
site, even though the brain DB resolved the target to
|
|
8
|
+
``_ingested/gmail/Mon, 20 Ap-19dacef6-re-company-mc-company-ko-recap.md``.
|
|
9
|
+
|
|
10
|
+
This module fixes that by rewriting every resolvable ``[[X]]`` in a
|
|
11
|
+
vault-tier file to ``[[<vault-root-relative-path>|<display>]]`` form
|
|
12
|
+
during ``brain vault sync``. The path is the target document's
|
|
13
|
+
``vault_path`` (sans ``.md``); the display preserves whatever the user
|
|
14
|
+
wrote (title, alias, or the resolved title for the ``[[brain:<id>]]``
|
|
15
|
+
no-display case).
|
|
16
|
+
|
|
17
|
+
Mirrors the precedent set by
|
|
18
|
+
:func:`brain.vault.derived_links.fence.rewrite_derived_fences` — same
|
|
19
|
+
in-place body rewriting during sync, same atomic-write pattern, same
|
|
20
|
+
"counter-on-write" reporting via :class:`brain.vault.sync.SyncReport`.
|
|
21
|
+
|
|
22
|
+
Public API:
|
|
23
|
+
|
|
24
|
+
- :func:`rewrite_wiki_links` — pure: take a body string + connection,
|
|
25
|
+
return ``(rewritten_body, replacements_made)``. No filesystem access.
|
|
26
|
+
- :func:`rewrite_vault_links` — I/O wrapper: read a file, rewrite its
|
|
27
|
+
body, write it back atomically when changed. Returns whether the file
|
|
28
|
+
was rewritten.
|
|
29
|
+
"""
|
|
30
|
+
import logging
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import psycopg
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
from ._atomic import atomic_write_text
|
|
38
|
+
from .frontmatter import dump_frontmatter, parse_frontmatter
|
|
39
|
+
from .links import ParsedLink, iter_wiki_links_with_spans
|
|
40
|
+
from .paths import strip_md_extension
|
|
41
|
+
from .resolver import resolve_link
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rewrite_wiki_links(
|
|
47
|
+
body: str,
|
|
48
|
+
*,
|
|
49
|
+
document_id: str,
|
|
50
|
+
conn: psycopg.Connection[Any],
|
|
51
|
+
) -> tuple[str, int]:
|
|
52
|
+
"""Rewrite resolvable ``[[…]]`` markers in ``body`` to path-form inner text.
|
|
53
|
+
|
|
54
|
+
For every wiki-link or embed:
|
|
55
|
+
|
|
56
|
+
1. Parse via :func:`brain.vault.links.iter_wiki_links_with_spans` so
|
|
57
|
+
we get exact byte spans (length-preserving over fenced code +
|
|
58
|
+
inline code, which the parser already masks).
|
|
59
|
+
2. Resolve via :func:`brain.vault.resolver.resolve_link`, excluding
|
|
60
|
+
``document_id`` from candidates so a self-link is left alone.
|
|
61
|
+
3. If the target has a ``vault_path``, build the new inner as
|
|
62
|
+
``<path><#heading?>|<display>``:
|
|
63
|
+
|
|
64
|
+
- ``<path>`` is the target's ``vault_path`` with the trailing
|
|
65
|
+
``.md`` stripped (POSIX form).
|
|
66
|
+
- ``<heading>`` is preserved from the original link if present.
|
|
67
|
+
- ``<display>`` is the original ``parsed.display_text`` if set,
|
|
68
|
+
else the original ``parsed.target_value`` (which preserves the
|
|
69
|
+
user's chosen label — title text, source-external string, etc.).
|
|
70
|
+
The one exception: ``[[brain:<id>]]`` with no display reads the
|
|
71
|
+
resolved target's ``documents.title`` so the rendered site shows
|
|
72
|
+
a human-friendly label instead of a UUID prefix.
|
|
73
|
+
|
|
74
|
+
4. Compare the candidate inner to the link's existing inner; if
|
|
75
|
+
byte-equal, no rewrite (idempotent fast path — the second sync
|
|
76
|
+
pass over an already-rewritten body produces zero replacements).
|
|
77
|
+
5. Replace each affected span in reverse order so earlier indices
|
|
78
|
+
stay valid as later substitutions change the body length.
|
|
79
|
+
|
|
80
|
+
Unresolved links, links whose target has ``vault_path IS NULL``, and
|
|
81
|
+
self-links are left untouched. The rewriter is contractually
|
|
82
|
+
non-destructive: it only ever writes path-form for links the resolver
|
|
83
|
+
confirmed.
|
|
84
|
+
|
|
85
|
+
Returns a ``(new_body, replacements)`` tuple. ``replacements`` is the
|
|
86
|
+
number of spans rewritten — a body with three resolvable links and
|
|
87
|
+
one already-in-path-form yields ``replacements == 2``.
|
|
88
|
+
"""
|
|
89
|
+
spans = iter_wiki_links_with_spans(body)
|
|
90
|
+
if not spans:
|
|
91
|
+
return body, 0
|
|
92
|
+
|
|
93
|
+
# Walk the spans once, collecting ``(start, end, new_raw)`` tuples for
|
|
94
|
+
# each rewrite we want to apply. We iterate forward (document order)
|
|
95
|
+
# for deterministic logging, then apply substitutions in reverse to
|
|
96
|
+
# preserve byte offsets.
|
|
97
|
+
edits: list[tuple[int, int, str]] = []
|
|
98
|
+
for parsed, start, end in spans:
|
|
99
|
+
try:
|
|
100
|
+
target = resolve_link(conn, parsed, exclude_doc_id=document_id)
|
|
101
|
+
except psycopg.Error as e:
|
|
102
|
+
logger.warning(
|
|
103
|
+
"link rewrite: resolver failed on %s — leaving link alone: %s",
|
|
104
|
+
parsed.raw,
|
|
105
|
+
e,
|
|
106
|
+
)
|
|
107
|
+
continue
|
|
108
|
+
if target is None:
|
|
109
|
+
continue
|
|
110
|
+
# Per-link SELECT (not batched): personal-corpus scale (≤10K docs);
|
|
111
|
+
# batching would require a second pass over edits and add complexity
|
|
112
|
+
# for negligible gain.
|
|
113
|
+
target_row = conn.execute(
|
|
114
|
+
"SELECT vault_path, title FROM documents WHERE id = %s",
|
|
115
|
+
(target.document_id,),
|
|
116
|
+
).fetchone()
|
|
117
|
+
if target_row is None:
|
|
118
|
+
# Race: target was resolved a moment ago but vanished. Leave
|
|
119
|
+
# the link untouched; the user (or a follow-up sync) will see
|
|
120
|
+
# the unresolved row in the DB and surface the issue.
|
|
121
|
+
continue
|
|
122
|
+
target_vault_path, target_title = target_row
|
|
123
|
+
if not target_vault_path:
|
|
124
|
+
logger.debug(
|
|
125
|
+
"link rewrite: target %s has no vault_path — leaving %s alone",
|
|
126
|
+
target.document_id[:8],
|
|
127
|
+
parsed.raw,
|
|
128
|
+
)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
path_no_ext = strip_md_extension(str(target_vault_path))
|
|
132
|
+
display = _choose_display(parsed, resolved_title=str(target_title))
|
|
133
|
+
new_inner = _build_inner(
|
|
134
|
+
path_no_ext, heading=parsed.heading, display=display
|
|
135
|
+
)
|
|
136
|
+
new_raw = ("![[" if parsed.kind == "embed" else "[[") + new_inner + "]]"
|
|
137
|
+
|
|
138
|
+
if new_raw == parsed.raw:
|
|
139
|
+
# Already in canonical path form — idempotent fast path.
|
|
140
|
+
continue
|
|
141
|
+
edits.append((start, end, new_raw))
|
|
142
|
+
|
|
143
|
+
if not edits:
|
|
144
|
+
return body, 0
|
|
145
|
+
|
|
146
|
+
new_body = body
|
|
147
|
+
for start, end, new_raw in reversed(edits):
|
|
148
|
+
new_body = new_body[:start] + new_raw + new_body[end:]
|
|
149
|
+
return new_body, len(edits)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def rewrite_vault_links(
|
|
153
|
+
file_path: Path,
|
|
154
|
+
*,
|
|
155
|
+
document_id: str,
|
|
156
|
+
conn: psycopg.Connection[Any],
|
|
157
|
+
) -> bool:
|
|
158
|
+
"""Read ``file_path``, rewrite its wiki-links, write back atomically.
|
|
159
|
+
|
|
160
|
+
Returns ``True`` iff the file's bytes changed (i.e. at least one link
|
|
161
|
+
was rewritten and the new body differs). On any I/O or YAML error the
|
|
162
|
+
function logs a warning and returns ``False`` — the rewrite is a
|
|
163
|
+
best-effort polish step; a failure here MUST NOT fail the sync run.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
text = file_path.read_text(encoding="utf-8")
|
|
167
|
+
except OSError as e:
|
|
168
|
+
logger.warning(
|
|
169
|
+
"link rewrite: could not read %s: %s — skipping", file_path, e
|
|
170
|
+
)
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
frontmatter, body = parse_frontmatter(text)
|
|
174
|
+
except (ValueError, yaml.YAMLError) as e:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"link rewrite: malformed frontmatter in %s: %s — skipping",
|
|
177
|
+
file_path,
|
|
178
|
+
e,
|
|
179
|
+
)
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
new_body, replacements = rewrite_wiki_links(
|
|
184
|
+
body, document_id=document_id, conn=conn
|
|
185
|
+
)
|
|
186
|
+
except psycopg.Error as e:
|
|
187
|
+
# Defense in depth — ``rewrite_wiki_links`` already swallows
|
|
188
|
+
# per-link resolver errors; this branch catches a connection-level
|
|
189
|
+
# problem that escaped the inner handler. Log and move on.
|
|
190
|
+
logger.warning(
|
|
191
|
+
"link rewrite: DB error rewriting %s — skipping: %s",
|
|
192
|
+
file_path,
|
|
193
|
+
e,
|
|
194
|
+
)
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
if replacements == 0 or new_body == body:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
new_text = dump_frontmatter(frontmatter, new_body)
|
|
201
|
+
try:
|
|
202
|
+
atomic_write_text(file_path, new_text)
|
|
203
|
+
except OSError as e:
|
|
204
|
+
logger.warning(
|
|
205
|
+
"link rewrite: could not write %s: %s — skipping", file_path, e
|
|
206
|
+
)
|
|
207
|
+
return False
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _choose_display(parsed: ParsedLink, *, resolved_title: str) -> str:
|
|
212
|
+
"""Pick the display label for the rewritten link.
|
|
213
|
+
|
|
214
|
+
Precedence:
|
|
215
|
+
|
|
216
|
+
1. Original ``parsed.display_text`` if the user wrote one
|
|
217
|
+
(``[[X|alias]]`` or ``[[brain:<id>|alias]]``).
|
|
218
|
+
2. For ``[[brain:<id>]]`` (no display), use the resolved target's
|
|
219
|
+
``documents.title`` — the user clearly wanted a human label, not a
|
|
220
|
+
raw UUID prefix.
|
|
221
|
+
3. Otherwise fall back to ``parsed.target_value`` (the original
|
|
222
|
+
title text, source-external string, etc.) so what the user typed
|
|
223
|
+
remains the visible label.
|
|
224
|
+
"""
|
|
225
|
+
if parsed.display_text:
|
|
226
|
+
return str(parsed.display_text)
|
|
227
|
+
if parsed.target_type == "doc-id":
|
|
228
|
+
return resolved_title
|
|
229
|
+
return str(parsed.target_value)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_inner(path: str, *, heading: str | None, display: str) -> str:
|
|
233
|
+
"""Concatenate the rewritten link inner: ``<path>[#heading]|<display>``."""
|
|
234
|
+
head = f"#{heading}" if heading else ""
|
|
235
|
+
return f"{path}{head}|{display}"
|