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,165 @@
|
|
|
1
|
+
"""YAML frontmatter writer + reader for vault files."""
|
|
2
|
+
import hashlib
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from ..tags import normalize_tags
|
|
10
|
+
from ._atomic import atomic_write_text
|
|
11
|
+
from .derived_links.fence import strip_fence
|
|
12
|
+
|
|
13
|
+
_FENCE = "---"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dump_frontmatter(fields: dict[str, Any], body: str) -> str:
|
|
17
|
+
"""Serialize a vault file: YAML frontmatter + a single blank line + body.
|
|
18
|
+
|
|
19
|
+
Field ordering is preserved verbatim from ``fields`` (callers control the
|
|
20
|
+
canonical order; this is what makes export output stable across runs).
|
|
21
|
+
Output uses block style with Unicode preserved so titles like
|
|
22
|
+
``"person-x Q1 réview"`` round-trip cleanly.
|
|
23
|
+
|
|
24
|
+
The body is written verbatim with a single newline separator after the
|
|
25
|
+
closing ``---`` fence; callers are responsible for any trailing newline
|
|
26
|
+
on the body itself.
|
|
27
|
+
"""
|
|
28
|
+
yaml_body = yaml.safe_dump(
|
|
29
|
+
fields,
|
|
30
|
+
default_flow_style=False,
|
|
31
|
+
sort_keys=False,
|
|
32
|
+
allow_unicode=True,
|
|
33
|
+
)
|
|
34
|
+
return f"{_FENCE}\n{yaml_body}{_FENCE}\n\n{body}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
38
|
+
"""Split a vault file's text into ``(frontmatter_dict, body)``.
|
|
39
|
+
|
|
40
|
+
A vault file *must* begin with ``---``; if not, returns ``({}, text)`` and
|
|
41
|
+
treats the whole input as body. Inside the fences the YAML is parsed in
|
|
42
|
+
safe mode (no arbitrary Python object construction). Returns the parsed
|
|
43
|
+
YAML mapping (empty dict if YAML loads to ``None``) and everything after
|
|
44
|
+
the closing fence, with the leading blank line stripped if present.
|
|
45
|
+
|
|
46
|
+
Raises :class:`yaml.YAMLError` for malformed YAML so callers can decide
|
|
47
|
+
whether to skip + warn or abort the run.
|
|
48
|
+
"""
|
|
49
|
+
lines = text.splitlines(keepends=True)
|
|
50
|
+
if not lines or lines[0].rstrip("\r\n") != _FENCE:
|
|
51
|
+
return {}, text
|
|
52
|
+
# Find the closing ``---`` — second fence on its own line.
|
|
53
|
+
for idx in range(1, len(lines)):
|
|
54
|
+
if lines[idx].rstrip("\r\n") == _FENCE:
|
|
55
|
+
yaml_text = "".join(lines[1:idx])
|
|
56
|
+
body_text = "".join(lines[idx + 1 :])
|
|
57
|
+
# The writer adds an empty separator line after the closing fence;
|
|
58
|
+
# peel exactly one of those off so re-parses see the same body
|
|
59
|
+
# the caller wrote.
|
|
60
|
+
if body_text.startswith("\n"):
|
|
61
|
+
body_text = body_text[1:]
|
|
62
|
+
elif body_text.startswith("\r\n"):
|
|
63
|
+
body_text = body_text[2:]
|
|
64
|
+
parsed = yaml.safe_load(yaml_text) if yaml_text.strip() else {}
|
|
65
|
+
if parsed is None:
|
|
66
|
+
parsed = {}
|
|
67
|
+
if not isinstance(parsed, dict):
|
|
68
|
+
# Frontmatter is contractually a mapping; anything else (a
|
|
69
|
+
# bare list, scalar, etc.) is corrupt.
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"frontmatter must be a YAML mapping; "
|
|
72
|
+
f"got {type(parsed).__name__}"
|
|
73
|
+
)
|
|
74
|
+
return parsed, body_text
|
|
75
|
+
# No closing fence — treat the whole file as body.
|
|
76
|
+
return {}, text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def body_hash(text: str) -> str:
|
|
80
|
+
"""Return SHA-256 of the body of a vault file (frontmatter + fence stripped).
|
|
81
|
+
|
|
82
|
+
The hash is what populates ``documents.content_hash`` for vault-tier
|
|
83
|
+
rows. ``brain vault sync`` uses it to detect "real" content changes and
|
|
84
|
+
skip a re-embed when only the frontmatter shifted (e.g. tag tweaks,
|
|
85
|
+
``updated:`` timestamp bumps).
|
|
86
|
+
|
|
87
|
+
Normalizations before hashing — all chosen to preserve hash stability
|
|
88
|
+
across editors and platforms:
|
|
89
|
+
|
|
90
|
+
- The leading ``---\\n…\\n---\\n`` block (if present) is stripped via
|
|
91
|
+
:func:`parse_frontmatter`. Inputs with no frontmatter hash the entire
|
|
92
|
+
text.
|
|
93
|
+
- The auto-generated derived-edges fence (everything between
|
|
94
|
+
``<!-- BRAIN_DERIVED_START -->`` and ``<!-- BRAIN_DERIVED_END -->``)
|
|
95
|
+
is stripped via :func:`brain.vault.derived_links.fence.strip_fence`.
|
|
96
|
+
The fence is recomputed every relink from ``derived_links`` rows; its
|
|
97
|
+
content is therefore not authored body and must not influence the
|
|
98
|
+
hash, otherwise every relink would trigger a re-embed cascade.
|
|
99
|
+
- CRLF line endings collapse to LF so a Windows-saved copy and the same
|
|
100
|
+
content on macOS produce the same hash.
|
|
101
|
+
- Leading/trailing whitespace is stripped — the writer's trailing
|
|
102
|
+
newline is not part of the canonical content. (Editors that append a
|
|
103
|
+
trailing newline don't trigger spurious updates.)
|
|
104
|
+
|
|
105
|
+
The hash matches the value the export module would write into the same
|
|
106
|
+
document's ``content_hash`` column, which is what makes the
|
|
107
|
+
export → sync round-trip a no-op.
|
|
108
|
+
"""
|
|
109
|
+
# ``parse_frontmatter`` raises on malformed YAML — callers (the sync
|
|
110
|
+
# engine) catch that separately and record an error. For the hash itself
|
|
111
|
+
# we fall back to hashing the raw text when the file has no frontmatter
|
|
112
|
+
# at all, but propagate yaml errors upward.
|
|
113
|
+
_, body = parse_frontmatter(text)
|
|
114
|
+
fence_stripped = strip_fence(body)
|
|
115
|
+
normalized = fence_stripped.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
116
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def rewrite_tags(path: Path, new_tags: list[str]) -> bool:
|
|
120
|
+
"""Rewrite a vault file's frontmatter ``tags:`` field to ``new_tags``.
|
|
121
|
+
|
|
122
|
+
Returns ``True`` if the file changed, ``False`` if it was already in
|
|
123
|
+
sync — calling this with the same ``new_tags`` twice in a row is a
|
|
124
|
+
guaranteed no-op on the second call (no rewrite, no ``updated:`` bump,
|
|
125
|
+
no mtime change). That idempotency is what lets ``brain tag`` and
|
|
126
|
+
``brain backfill normalize-tags`` run repeatedly without churning the
|
|
127
|
+
vault's git history.
|
|
128
|
+
|
|
129
|
+
Behavior:
|
|
130
|
+
|
|
131
|
+
- ``new_tags`` is passed through :func:`brain.tags.normalize_tags`
|
|
132
|
+
before the comparison, so callers passing mixed-case or duplicate
|
|
133
|
+
input get the canonical form on disk and a re-call with the
|
|
134
|
+
already-canonical form remains idempotent.
|
|
135
|
+
- All other frontmatter keys and their order are preserved (the dict
|
|
136
|
+
is mutated in place; :func:`dump_frontmatter` uses ``sort_keys=False``).
|
|
137
|
+
- A missing ``tags:`` key and an empty ``tags: []`` list are both
|
|
138
|
+
treated as "set fresh" — passing a non-empty ``new_tags`` triggers
|
|
139
|
+
a write in either case.
|
|
140
|
+
- On any change, ``updated:`` is bumped to the current UTC timestamp
|
|
141
|
+
(mirrors :func:`brain.vault.rename._rewrite_source_frontmatter`).
|
|
142
|
+
The body is untouched, so ``body_hash`` is stable and the next
|
|
143
|
+
``brain vault sync`` skips re-embedding.
|
|
144
|
+
- The disk write goes through :func:`brain.vault._atomic.atomic_write_text`
|
|
145
|
+
so a partial write can never leave a half-rewritten frontmatter on
|
|
146
|
+
disk.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
FileNotFoundError: if ``path`` does not exist.
|
|
150
|
+
yaml.YAMLError: propagated from :func:`parse_frontmatter` if the
|
|
151
|
+
existing frontmatter is unparseable.
|
|
152
|
+
ValueError: propagated from :func:`parse_frontmatter` if the
|
|
153
|
+
frontmatter is structurally invalid (e.g. a YAML list at the
|
|
154
|
+
top level instead of a mapping).
|
|
155
|
+
"""
|
|
156
|
+
text = path.read_text(encoding="utf-8")
|
|
157
|
+
fields, body = parse_frontmatter(text)
|
|
158
|
+
current_tags = list(fields.get("tags") or [])
|
|
159
|
+
desired_tags = normalize_tags(new_tags)
|
|
160
|
+
if current_tags == desired_tags:
|
|
161
|
+
return False
|
|
162
|
+
fields["tags"] = desired_tags
|
|
163
|
+
fields["updated"] = datetime.now(UTC).isoformat()
|
|
164
|
+
atomic_write_text(path, dump_frontmatter(fields, body))
|
|
165
|
+
return True
|