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
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
|
+
)
|