secondbrain-py 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/vault/rename.py ADDED
@@ -0,0 +1,591 @@
1
+ """Vault note rename: title rewrite + reference refactor + atomic apply.
2
+
3
+ A rename is a multi-file destructive operation — the renamed note's frontmatter
4
+ gets a new ``title`` and (usually) the file moves to a new slug-based path,
5
+ and every other note in the vault that references the old title via
6
+ ``[[old-title]]`` (or ``[[old-title|alias]]``, ``[[old-title#heading]]``,
7
+ ``![[old-title]]``) is rewritten to point at the new title.
8
+
9
+ The module is split into two halves so the CLI can offer ``--dry-run``:
10
+
11
+ - :func:`plan_rename` reads the DB + walks the vault, returns a fully
12
+ populated :class:`RenameOp` describing every change the apply phase
13
+ would make. It writes nothing to disk and nothing to the DB.
14
+ - :func:`apply_rename` snapshots every file it's about to touch into a
15
+ tempdir, then performs the writes. On any exception the snapshots are
16
+ copied back, leaving the vault in its pre-call state. The temp dir's
17
+ path is logged on a hard failure so the user can recover manually.
18
+
19
+ Reference matching is delegated to
20
+ :func:`brain.vault.links.iter_wiki_links_with_spans` — that's the same parser
21
+ the sync engine uses, so code-fence / inline-code / escaped-bracket skipping
22
+ behaves identically in both code paths. Don't reach for a regex of your own
23
+ here; the parser already does the right thing.
24
+ """
25
+ import logging
26
+ import shutil
27
+ import tempfile
28
+ from collections.abc import Iterable
29
+ from dataclasses import dataclass, field
30
+ from datetime import UTC, datetime
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import psycopg
35
+ import yaml
36
+
37
+ from ..ingest import Embedder
38
+ from .frontmatter import dump_frontmatter, parse_frontmatter
39
+ from .links import iter_wiki_links_with_spans
40
+ from .slug import slugify
41
+ from .sync import SyncReport, sync_one_file
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class RenameError(Exception):
47
+ """Rename cannot proceed (collision, doc not found, etc.).
48
+
49
+ The CLI catches this and surfaces ``str(e)`` as the user-facing diagnostic;
50
+ the rename never partially applies when this is raised from
51
+ :func:`plan_rename`.
52
+ """
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ReferenceMatch:
57
+ """One ``[[old-title]]`` (or variant) found in another vault file.
58
+
59
+ ``span`` is the byte range in ``file_path``'s text that the link
60
+ occupies. ``new_text`` is what it'll become after the rename — the
61
+ rewriter just splices ``new_text`` over the ``[span]`` slice.
62
+
63
+ ``line_no`` is 1-indexed and surfaced for human-readable diagnostics
64
+ (``--dry-run`` output, error messages); the rewriter doesn't use it.
65
+ """
66
+
67
+ file_path: Path
68
+ line_no: int
69
+ old_text: str
70
+ new_text: str
71
+ span: tuple[int, int]
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class RenameOp:
76
+ """The fully resolved plan for a single ``brain note rename``.
77
+
78
+ Constructed by :func:`plan_rename`; consumed by :func:`apply_rename`.
79
+ Frozen so callers can pass it around (e.g. between dry-run print and
80
+ real apply) without worrying about mutation.
81
+
82
+ ``references`` is grouped per-file in document order — when a single
83
+ file has three matches, all three appear consecutively. The apply
84
+ phase relies on this ordering to splice safely (writes back-to-front
85
+ so earlier spans stay valid).
86
+ """
87
+
88
+ document_id: str
89
+ old_title: str
90
+ new_title: str
91
+ old_path: Path
92
+ new_path: Path
93
+ references: tuple[ReferenceMatch, ...]
94
+
95
+
96
+ @dataclass
97
+ class RenameReport:
98
+ """Outcome of :func:`apply_rename`.
99
+
100
+ Counters mirror what the CLI prints back to the user; ``sync_report``
101
+ carries the post-rename single-file sync results (the renamed file's
102
+ DB row + chunks + links are re-indexed in place).
103
+ """
104
+
105
+ file_renamed: bool = False
106
+ files_rewritten: int = 0
107
+ references_rewritten: int = 0
108
+ sync_report: SyncReport | None = None
109
+ errors: list[str] = field(default_factory=list)
110
+
111
+
112
+ def plan_rename(
113
+ conn: psycopg.Connection[Any],
114
+ *,
115
+ vault_path: Path,
116
+ document_id: str,
117
+ new_title: str,
118
+ ) -> RenameOp:
119
+ """Build a :class:`RenameOp` describing the rename.
120
+
121
+ Reads:
122
+ - The doc row (must exist, must be ``kind='vault'``, must have a
123
+ ``vault_path``).
124
+ - Every ``.md`` file in the vault, scanned for ``[[old-title]]``
125
+ references via the wiki-link parser (so code fences / inline code /
126
+ escaped brackets are skipped automatically).
127
+
128
+ Errors:
129
+ - :class:`RenameError` if the doc isn't found, isn't vault-tier, or
130
+ has no ``vault_path`` on disk.
131
+ - :class:`RenameError` on slug collision — the new path would overwrite
132
+ another file that already exists.
133
+ - :class:`RenameError` if the new title is empty / whitespace.
134
+ """
135
+ if not new_title.strip():
136
+ raise RenameError("new title must not be empty")
137
+ new_title = new_title.strip()
138
+
139
+ row = conn.execute(
140
+ "SELECT title, vault_path, kind FROM documents WHERE id = %s",
141
+ (document_id,),
142
+ ).fetchone()
143
+ if row is None:
144
+ raise RenameError(f"document not found: {document_id}")
145
+ old_title, vault_path_value, kind = row
146
+ if kind != "vault":
147
+ raise RenameError(
148
+ f"document is {kind!r}, not 'vault' — only vault-tier docs can be renamed"
149
+ )
150
+ if not vault_path_value:
151
+ raise RenameError(
152
+ "document has no vault_path on disk; run `brain vault sync` first"
153
+ )
154
+
155
+ old_relative = Path(vault_path_value)
156
+ old_abs = vault_path / old_relative
157
+ if not old_abs.is_file():
158
+ raise RenameError(
159
+ f"vault file is missing on disk: {vault_path_value} "
160
+ "(run `brain vault sync --prune` to clean up the DB row)"
161
+ )
162
+
163
+ new_slug = slugify(new_title)
164
+ new_relative = old_relative.with_name(f"{new_slug}.md")
165
+ new_abs = vault_path / new_relative
166
+
167
+ # Slug collision: the new path collides with a different existing file.
168
+ # Same-file (no-op rename) is allowed — the title may change without the
169
+ # filename slug changing.
170
+ if new_abs.exists() and new_abs.resolve() != old_abs.resolve():
171
+ raise RenameError(
172
+ f"target path already exists: {new_relative.as_posix()} — "
173
+ "pick a more distinctive title"
174
+ )
175
+
176
+ # ``old_relative`` (e.g. ``Path("target.md")``) gives the rename
177
+ # scanner the path stem it needs to match path-form references like
178
+ # ``[[target|Target]]`` produced by the post-sync wiki-link rewriter
179
+ # (see ``brain.vault.link_rewrite``). Without this, references in
180
+ # path form would be missed and the rename would leave broken links
181
+ # behind.
182
+ old_path_stem = old_relative.with_suffix("").as_posix()
183
+ references = collect_references(
184
+ vault_path,
185
+ old_targets=[(old_title, old_path_stem)],
186
+ new_title=new_title,
187
+ )
188
+
189
+ return RenameOp(
190
+ document_id=document_id,
191
+ old_title=old_title,
192
+ new_title=new_title,
193
+ old_path=old_abs,
194
+ new_path=new_abs,
195
+ references=tuple(references),
196
+ )
197
+
198
+
199
+ def apply_rename(
200
+ conn: psycopg.Connection[Any],
201
+ *,
202
+ embedder: Embedder,
203
+ vault_path: Path,
204
+ op: RenameOp,
205
+ ) -> RenameReport:
206
+ """Apply ``op``: rewrite references, update frontmatter, move the file.
207
+
208
+ Atomicity contract:
209
+
210
+ 1. Snapshot every file we're about to write into a fresh tempdir
211
+ (``brain-rename-<uuid>/``).
212
+ 2. Perform every write under a try/except.
213
+ 3. On any exception, copy snapshots back to their original paths and
214
+ re-raise the original error after logging the snapshot path so the
215
+ user can recover manually if the restore itself failed.
216
+ 4. On success, the snapshot dir is deleted.
217
+
218
+ The renamed file's DB row + chunks + links are re-indexed via
219
+ :func:`sync_one_file` after the disk writes commit. If the target
220
+ file references survive the rename (e.g. ``[[person-x|original display]]``
221
+ becomes ``[[person-x Q1|original display]]``) the sync will pick them up
222
+ on the next full vault pass; this helper deliberately doesn't walk
223
+ the entire vault.
224
+ """
225
+ report = RenameReport()
226
+ files_to_rewrite = _group_by_file(op.references)
227
+ snapshot_targets: dict[Path, bytes] = {}
228
+
229
+ # Always snapshot the source file (we update its frontmatter + may move it)
230
+ # plus every file with rewritten references. Reading bytes (not text)
231
+ # preserves any oddball encoding the user has — restore is byte-exact.
232
+ snapshot_targets[op.old_path] = op.old_path.read_bytes()
233
+ for path in files_to_rewrite:
234
+ if path == op.old_path:
235
+ continue
236
+ snapshot_targets[path] = path.read_bytes()
237
+
238
+ backup_dir = Path(tempfile.mkdtemp(prefix="brain-rename-"))
239
+ try:
240
+ for path, data in snapshot_targets.items():
241
+ backup_path = _backup_path_for(backup_dir, vault_path, path)
242
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
243
+ backup_path.write_bytes(data)
244
+
245
+ # Step 1: rewrite references in OTHER files first. We do this before
246
+ # touching the source file so a failure here leaves the source intact
247
+ # for restore.
248
+ for path, matches in files_to_rewrite.items():
249
+ if path == op.old_path:
250
+ # Source-file references handled below alongside the
251
+ # frontmatter rewrite — keeps the source's writes coherent.
252
+ continue
253
+ new_text = apply_matches_to_text(
254
+ path.read_text(encoding="utf-8"),
255
+ matches,
256
+ )
257
+ path.write_text(new_text, encoding="utf-8")
258
+ report.files_rewritten += 1
259
+ report.references_rewritten += len(matches)
260
+
261
+ # Step 2: rewrite the source file. Two changes happen here:
262
+ # a. Any in-body references to the doc's own title are rewritten
263
+ # (rare but possible — a note that links to itself by title).
264
+ # b. Frontmatter ``title`` is updated and ``updated`` timestamped.
265
+ text = op.old_path.read_text(encoding="utf-8")
266
+ source_matches = files_to_rewrite.get(op.old_path, [])
267
+ if source_matches:
268
+ text = apply_matches_to_text(text, source_matches)
269
+ report.references_rewritten += len(source_matches)
270
+ text = _rewrite_source_frontmatter(text, new_title=op.new_title)
271
+
272
+ # Step 3: write the source. If the target path differs (slug change),
273
+ # write the new file then unlink the old. Same path → in-place write.
274
+ if op.new_path.resolve() != op.old_path.resolve():
275
+ op.new_path.parent.mkdir(parents=True, exist_ok=True)
276
+ op.new_path.write_text(text, encoding="utf-8")
277
+ op.old_path.unlink()
278
+ report.file_renamed = True
279
+ else:
280
+ op.old_path.write_text(text, encoding="utf-8")
281
+
282
+ # Source counted in files_rewritten only when something actually
283
+ # changed (matches present OR title changed — title change always
284
+ # changes frontmatter, so always count when we got here).
285
+ report.files_rewritten += 1
286
+ except Exception as exc:
287
+ # Restore from snapshots — best effort. If restore itself fails we
288
+ # log loudly so the user can recover from the backup_dir manually.
289
+ logger.error(
290
+ "vault rename: writes failed (%s); restoring from snapshot at %s",
291
+ exc,
292
+ backup_dir,
293
+ )
294
+ _restore_from_backup(backup_dir, vault_path, snapshot_targets.keys())
295
+ # If the source was moved before the failure, the new path may also
296
+ # exist — tear it down so the restore lands cleanly.
297
+ if (
298
+ op.new_path.resolve() != op.old_path.resolve()
299
+ and op.new_path.exists()
300
+ ):
301
+ try:
302
+ op.new_path.unlink()
303
+ except OSError as cleanup_exc:
304
+ logger.error(
305
+ "vault rename: failed to remove partially-written %s: %s "
306
+ "(snapshot at %s)",
307
+ op.new_path,
308
+ cleanup_exc,
309
+ backup_dir,
310
+ )
311
+ raise
312
+ else:
313
+ # Success — clean up the snapshot dir.
314
+ shutil.rmtree(backup_dir, ignore_errors=True)
315
+
316
+ # Re-index the renamed file. We pass the new path so sync sees the new
317
+ # frontmatter title and re-resolves any links targeted at it.
318
+ report.sync_report = sync_one_file(
319
+ conn,
320
+ embedder=embedder,
321
+ vault_path=vault_path,
322
+ file_path=op.new_path,
323
+ )
324
+ return report
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Public helpers (also used by ``scripts/collapse_gmail_threads.py``).
329
+ # ---------------------------------------------------------------------------
330
+
331
+
332
+ def collect_references(
333
+ vault_path: Path,
334
+ *,
335
+ old_targets: list[tuple[str, str]],
336
+ new_title: str,
337
+ ) -> list[ReferenceMatch]:
338
+ """Walk the vault, returning every ``[[old-title]]`` / ``[[old-path]]`` reference.
339
+
340
+ ``old_targets`` is a list of ``(old_title, old_path_stem)`` pairs. A wiki-link
341
+ matches when its target equals one of the ``old_title`` values
342
+ (case-insensitive) OR equals one of the ``old_path_stem`` values
343
+ (case-sensitive POSIX path). Each match's rewrite uses the matched pair's
344
+ ``old_title`` so the synthetic-display drop in :func:`_rewrite_link_text`
345
+ fires correctly even when several old targets share one new title (the
346
+ Gmail-thread collapse use case in
347
+ :mod:`scripts.collapse_gmail_threads`).
348
+
349
+ The walk includes the source file itself (a note may reference its own
350
+ title) but excludes ``_templates/`` and ``_attachments/`` — those don't
351
+ belong in the link graph and rewriting their content would mutate
352
+ user-owned scaffolding. ``_ingested/`` IS walked because users may
353
+ legitimately reference an ingested artifact's title from a vault note,
354
+ but Phase 2's sync gives ingested-tier files their own DB rows so any
355
+ references inside them are part of the live graph.
356
+
357
+ Title comparison is case-insensitive, matching the resolver's title
358
+ resolution rule (``LOWER(title) = LOWER(?)``). Path comparison is
359
+ case-sensitive (vault paths are POSIX strings), matching
360
+ :func:`brain.vault.resolver._resolve_by_vault_path` so references
361
+ rewritten by :func:`brain.vault.link_rewrite.rewrite_wiki_links` into
362
+ ``[[<vault-root-relative-path>|<display>]]`` form are still caught
363
+ by this scan.
364
+ """
365
+ matches: list[ReferenceMatch] = []
366
+ # Build (lower_title, path_stem) lookup tuples once. Single-element
367
+ # ``old_targets`` is the rename use case; multi-element is the
368
+ # gmail-thread collapse use case (one new merged thread doc replaces
369
+ # N per-message docs).
370
+ targets: list[tuple[str, str, str]] = [
371
+ (old_title.lower(), old_path_stem, old_title)
372
+ for old_title, old_path_stem in old_targets
373
+ ]
374
+ for path in sorted(vault_path.rglob("*.md")):
375
+ if not path.is_file():
376
+ continue
377
+ try:
378
+ relative = path.relative_to(vault_path)
379
+ except ValueError:
380
+ continue
381
+ first = relative.parts[0] if relative.parts else ""
382
+ if first in {"_templates", "_attachments"}:
383
+ continue
384
+ try:
385
+ text = path.read_text(encoding="utf-8")
386
+ except OSError:
387
+ continue
388
+ # The wiki-link parser receives the body, but the rewrite must
389
+ # land at the right offset in the WHOLE file (frontmatter included).
390
+ # Parse frontmatter to compute its byte length, then run the link
391
+ # scanner over the body and shift offsets back into whole-file
392
+ # coordinates. This skips ``[[X]]`` written inside the YAML header
393
+ # (which is intentional: the resolver doesn't follow those either).
394
+ try:
395
+ _, body = parse_frontmatter(text)
396
+ except (ValueError, yaml.YAMLError):
397
+ # Malformed frontmatter — sync would skip this file too. Don't
398
+ # rewrite it; we'd be guessing where the body starts.
399
+ continue
400
+ body_offset = len(text) - len(body)
401
+ for parsed, start, end in iter_wiki_links_with_spans(body):
402
+ if parsed.target_type != "title":
403
+ continue
404
+ target = parsed.target_value
405
+ target_lower = target.lower()
406
+ matched_old_title: str | None = None
407
+ for lower_title, path_stem, original_title in targets:
408
+ if target_lower == lower_title or target == path_stem:
409
+ matched_old_title = original_title
410
+ break
411
+ if matched_old_title is None:
412
+ continue
413
+ old_text = body[start:end]
414
+ new_text = _rewrite_link_text(
415
+ old_text,
416
+ new_title=new_title,
417
+ embed=parsed.kind == "embed",
418
+ old_title=matched_old_title,
419
+ )
420
+ absolute_start = body_offset + start
421
+ absolute_end = body_offset + end
422
+ line_no = text.count("\n", 0, absolute_start) + 1
423
+ matches.append(
424
+ ReferenceMatch(
425
+ file_path=path,
426
+ line_no=line_no,
427
+ old_text=old_text,
428
+ new_text=new_text,
429
+ span=(absolute_start, absolute_end),
430
+ )
431
+ )
432
+ return matches
433
+
434
+
435
+ def _rewrite_link_text(
436
+ old_text: str,
437
+ *,
438
+ new_title: str,
439
+ embed: bool,
440
+ old_title: str | None = None,
441
+ ) -> str:
442
+ """Rewrite a single ``[[...]]`` (or ``![[...]]``) match's title segment.
443
+
444
+ Preserves:
445
+ - The embed marker (``!``) if present.
446
+ - Pipe alias (``|display``) — only the title part is replaced. EXCEPT:
447
+ when ``display`` equals ``old_title`` (case-insensitive), the alias
448
+ is treated as the synthetic display added by
449
+ :func:`brain.vault.link_rewrite.rewrite_wiki_links` for the
450
+ bare-title case (``[[Old]]`` → ``[[<path>|Old]]``) and is dropped
451
+ so the rename's output reads ``[[New]]`` rather than
452
+ ``[[New|Old]]``. A user-chosen alias (``[[Old|something else]]``)
453
+ is preserved verbatim.
454
+ - Heading anchor (``#heading``) — preserved as-is on the new title.
455
+
456
+ Examples:
457
+ - ``[[Old]]`` → ``[[New]]``
458
+ - ``[[Old|alias]]`` → ``[[New|alias]]``
459
+ - ``[[Old|Old]]`` → ``[[New]]`` (synthetic-display drop)
460
+ - ``[[old-path|Old]]`` → ``[[New]]`` (synthetic-display drop after
461
+ path-form match)
462
+ - ``[[Old#heading]]`` → ``[[New#heading]]``
463
+ - ``![[Old]]`` → ``![[New]]``
464
+ """
465
+ prefix = "![[" if embed else "[["
466
+ assert old_text.startswith(prefix), f"unexpected match shape: {old_text!r}"
467
+ inner = old_text[len(prefix) : -2] # strip prefix + ``]]``
468
+ target_part, sep, display = inner.partition("|")
469
+ _, hash_sep, heading = target_part.partition("#")
470
+ # The title is the first segment up to ``#`` or ``|`` — discard whatever
471
+ # whitespace surrounded it and substitute. The user's spacing inside
472
+ # ``[[ Title ]]`` is normalized away on rewrite (the resolver was
473
+ # ignoring it anyway); the gain in readability outweighs the loss of
474
+ # that obscure stylistic choice.
475
+ rebuilt_target = new_title
476
+ if hash_sep:
477
+ rebuilt_target = f"{rebuilt_target}#{heading}"
478
+ # Drop the display when it equals the OLD title — this is the
479
+ # synthetic display the post-sync link rewriter inserts for
480
+ # bare-title references ``[[Old]]`` → ``[[<path>|Old]]``. The user
481
+ # never typed it, so the rename should erase it rather than carry
482
+ # the stale title forward.
483
+ if (
484
+ sep
485
+ and old_title is not None
486
+ and display.strip().lower() == old_title.strip().lower()
487
+ ):
488
+ sep = ""
489
+ display = ""
490
+ rebuilt_inner = (
491
+ f"{rebuilt_target}|{display}" if sep else rebuilt_target
492
+ )
493
+ return f"{prefix}{rebuilt_inner}]]"
494
+
495
+
496
+ def _group_by_file(
497
+ references: tuple[ReferenceMatch, ...],
498
+ ) -> dict[Path, list[ReferenceMatch]]:
499
+ """Bucket matches by their file path, preserving in-file order.
500
+
501
+ The apply phase rewrites back-to-front per file; preserving the natural
502
+ order returned by :func:`collect_references` (which is document order
503
+ by virtue of the parser's deterministic walk) is what makes that
504
+ correct without an explicit sort.
505
+ """
506
+ grouped: dict[Path, list[ReferenceMatch]] = {}
507
+ for ref in references:
508
+ grouped.setdefault(ref.file_path, []).append(ref)
509
+ return grouped
510
+
511
+
512
+ def apply_matches_to_text(text: str, matches: list[ReferenceMatch]) -> str:
513
+ """Splice ``new_text`` over each match's span in ``text``.
514
+
515
+ Writes back-to-front so each splice doesn't invalidate earlier offsets.
516
+ The matches list is required to be non-overlapping (the parser
517
+ guarantees this by construction). Public so
518
+ :mod:`scripts.collapse_gmail_threads` can reuse the splice logic when
519
+ rewriting per-message gmail references onto the merged thread doc.
520
+ """
521
+ if not matches:
522
+ return text
523
+ # Sort descending by start so we splice the tail of the file first.
524
+ ordered = sorted(matches, key=lambda m: m.span[0], reverse=True)
525
+ out = text
526
+ for match in ordered:
527
+ start, end = match.span
528
+ out = out[:start] + match.new_text + out[end:]
529
+ return out
530
+
531
+
532
+ def _rewrite_source_frontmatter(text: str, *, new_title: str) -> str:
533
+ """Update the ``title`` field (and ``updated:`` timestamp) in-place.
534
+
535
+ If the file has no parseable frontmatter we add a fresh one — but the
536
+ plan-phase guard (file must come from a synced vault doc) means the
537
+ file always has frontmatter in practice. The defensive branch keeps
538
+ apply_rename safe if a user mutates the file out from under us between
539
+ plan and apply.
540
+ """
541
+ try:
542
+ fields, body = parse_frontmatter(text)
543
+ except (ValueError, yaml.YAMLError) as e:
544
+ # Don't try to rewrite a malformed header; the apply contract is
545
+ # "leave the file in a runnable state". Surface the failure up.
546
+ raise RenameError(
547
+ "frontmatter is malformed — fix manually before renaming"
548
+ ) from e
549
+ fields = dict(fields)
550
+ fields["title"] = new_title
551
+ fields["updated"] = datetime.now(UTC).isoformat()
552
+ return dump_frontmatter(fields, body)
553
+
554
+
555
+ def _backup_path_for(
556
+ backup_dir: Path, vault_path: Path, target: Path
557
+ ) -> Path:
558
+ """Map a vault file to its slot inside the snapshot dir.
559
+
560
+ Uses the relative path so the backup tree mirrors the vault layout —
561
+ aids manual recovery if restore itself fails.
562
+ """
563
+ relative = target.resolve().relative_to(vault_path.resolve())
564
+ return backup_dir / relative
565
+
566
+
567
+ def _restore_from_backup(
568
+ backup_dir: Path, vault_path: Path, paths: Iterable[Path]
569
+ ) -> None:
570
+ """Best-effort restore of every snapshotted file.
571
+
572
+ ``paths`` is the iterable of original (vault-side) paths the snapshot
573
+ dir holds. Each file is restored from its mirrored snapshot location.
574
+ Failures during restore are logged but don't re-raise — the caller's
575
+ original exception is what matters; restore-failure information goes
576
+ via the snapshot dir path in the log line.
577
+ """
578
+ for path in paths:
579
+ backup_path = _backup_path_for(backup_dir, vault_path, path)
580
+ if not backup_path.is_file():
581
+ continue
582
+ try:
583
+ path.parent.mkdir(parents=True, exist_ok=True)
584
+ path.write_bytes(backup_path.read_bytes())
585
+ except OSError as e:
586
+ logger.error(
587
+ "vault rename: failed to restore %s from %s: %s",
588
+ path,
589
+ backup_path,
590
+ e,
591
+ )