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/sync.py
ADDED
|
@@ -0,0 +1,1513 @@
|
|
|
1
|
+
"""Vault → DB sync engine.
|
|
2
|
+
|
|
3
|
+
Walks a vault folder, reconciles every ``.md`` file against the
|
|
4
|
+
``documents`` table by frontmatter ``id``, materializes ``[[wiki-links]]``
|
|
5
|
+
into the ``links`` / ``unresolved_links`` tables, and (optionally) prunes
|
|
6
|
+
vault-tier rows whose files vanished.
|
|
7
|
+
|
|
8
|
+
**Tier-aware metadata contract:**
|
|
9
|
+
|
|
10
|
+
- For ``kind='vault'`` rows the file is authoritative — sync overwrites
|
|
11
|
+
``documents.metadata`` from the file's frontmatter on every pass. Aliases,
|
|
12
|
+
freeform keys, everything in the YAML header round-trips into JSONB.
|
|
13
|
+
- For ``kind='ingested'`` rows the **DB** is authoritative for metadata.
|
|
14
|
+
``_ingested/`` files are content/title/tags mirrors, not metadata mirrors.
|
|
15
|
+
Re-syncing an ingested-tier file does NOT overwrite ``documents.metadata``
|
|
16
|
+
(e.g. ``date``, ``duration_min``, source-specific fields populated during
|
|
17
|
+
the original ingest are preserved). New aliases written into an ingested
|
|
18
|
+
file ARE merged in — they're authored by the user — but everything else
|
|
19
|
+
the DB owns stays put.
|
|
20
|
+
|
|
21
|
+
**Atomicity:** each file's upsert runs in a single transaction. The disk
|
|
22
|
+
write that stamps an assigned ``id`` back into the file's frontmatter is
|
|
23
|
+
deferred until after the DB transaction commits, so a DB crash mid-sync
|
|
24
|
+
leaves the file untouched (the prior write-first-then-DB ordering could
|
|
25
|
+
leave a file id-stamped on disk with no DB row to back it).
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import uuid
|
|
33
|
+
from collections.abc import Iterator
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
import psycopg
|
|
39
|
+
import yaml
|
|
40
|
+
|
|
41
|
+
from ..ingest import Embedder, _chunk_search_metadata
|
|
42
|
+
from ..ingest.chunker import chunk_text
|
|
43
|
+
from ..ingest.sub_tokens import extract_sub_tokens
|
|
44
|
+
from ..queries import sync_chunk_search_metadata
|
|
45
|
+
from ..tags import normalize_tags
|
|
46
|
+
from .derived_links import DirectoryStore, rebuild_derived_for
|
|
47
|
+
from .derived_links.fence import rewrite_derived_fences, strip_fence
|
|
48
|
+
from .frontmatter import body_hash, dump_frontmatter, parse_frontmatter
|
|
49
|
+
from .link_rewrite import rewrite_vault_links
|
|
50
|
+
from .links import ParsedLink, parse_wiki_links
|
|
51
|
+
from .resolver import resolve_link, title_collisions
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from ..graph_rag.sync import GraphSyncer
|
|
55
|
+
|
|
56
|
+
# Top-level vault directories (or anything starting with ``_``) that the sync
|
|
57
|
+
# engine never treats as authored notes. ``_templates`` is editor scaffolding,
|
|
58
|
+
# ``_attachments`` is binaries (non-Markdown), and ``_ingested`` is the
|
|
59
|
+
# read-only mirror tier (treated specially below).
|
|
60
|
+
_TEMPLATE_DIR_NAME = "_templates"
|
|
61
|
+
_ATTACHMENTS_DIR_NAME = "_attachments"
|
|
62
|
+
_INGESTED_DIR_NAME = "_ingested"
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class SyncReport:
|
|
69
|
+
"""Aggregate counters returned from :func:`sync_vault`.
|
|
70
|
+
|
|
71
|
+
Each counter is incremented exactly once per file, except
|
|
72
|
+
``links_resolved`` / ``links_unresolved`` which count link insertions
|
|
73
|
+
(a file with three links contributes three to one or both of those).
|
|
74
|
+
|
|
75
|
+
``derived_links`` counts ``derived_links`` rows inserted by the
|
|
76
|
+
metadata-aware linker pass that runs at the end of every non-dry-run
|
|
77
|
+
sync (see :func:`brain.vault.derived_links.rebuild_derived_for`). The
|
|
78
|
+
counter stays ``0`` on dry-run and on syncs that touched no Gmail/Krisp
|
|
79
|
+
docs.
|
|
80
|
+
|
|
81
|
+
``errors`` lists files we couldn't sync along with a short human-readable
|
|
82
|
+
reason; the file is skipped (no DB write) and the run continues.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
created: int = 0
|
|
86
|
+
updated: int = 0
|
|
87
|
+
skipped: int = 0
|
|
88
|
+
deleted: int = 0
|
|
89
|
+
warned: int = 0
|
|
90
|
+
links_resolved: int = 0
|
|
91
|
+
links_unresolved: int = 0
|
|
92
|
+
id_assigned: int = 0
|
|
93
|
+
derived_links: int = 0
|
|
94
|
+
# Phase D — count of ``_ingested/`` files whose derived-edges fence was
|
|
95
|
+
# rewritten this pass. Driven by ``rebuild_derived_for``'s affected-ids
|
|
96
|
+
# set; stays at ``0`` on dry-run, on syncs with no affected ingested
|
|
97
|
+
# docs, and when the renderer skips every candidate (vault-tier,
|
|
98
|
+
# missing mirror file, etc.).
|
|
99
|
+
fences_written: int = 0
|
|
100
|
+
# Vault-tier files whose ``[[…]]`` markers were rewritten to
|
|
101
|
+
# vault-root-relative path form so Quartz can resolve them without a
|
|
102
|
+
# frontmatter lookup. Counted by :func:`brain.vault.link_rewrite.
|
|
103
|
+
# rewrite_vault_links`; stays at ``0`` on dry-run, on syncs that
|
|
104
|
+
# touched only ingested-tier files, when ``--no-link-rewrite`` is in
|
|
105
|
+
# effect, and when every link is already in canonical path form
|
|
106
|
+
# (idempotent re-sync).
|
|
107
|
+
links_rewritten: int = 0
|
|
108
|
+
# Fence-stripped body baseline the vault watcher caches for fence-only-write
|
|
109
|
+
# detection. Set only by the single-file path (:func:`_sync_one` via
|
|
110
|
+
# :func:`sync_one_file`) and reflects the content sync actually INDEXED —
|
|
111
|
+
# NOT a fresh disk read that could have picked up a racing user edit. The
|
|
112
|
+
# watcher prefers this over re-reading disk so an edit landing between
|
|
113
|
+
# sync's write and the cache refresh is never masked as a no-op. Stays
|
|
114
|
+
# ``None`` on dry-run, on documentation-skips, and on bulk ``sync_vault``
|
|
115
|
+
# aggregate reports (only the per-file watcher path consumes it).
|
|
116
|
+
body_baseline: str | None = None
|
|
117
|
+
errors: list[tuple[Path, str]] = field(default_factory=list)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class _WalkedFile:
|
|
122
|
+
"""Internal projection of one ``.md`` file the walker handed us."""
|
|
123
|
+
|
|
124
|
+
abs_path: Path
|
|
125
|
+
relative_posix: str # always POSIX-style (forward slashes)
|
|
126
|
+
classification: str # 'vault' or 'ingested' — derived from the path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def sync_vault(
|
|
130
|
+
conn: psycopg.Connection[Any],
|
|
131
|
+
*,
|
|
132
|
+
embedder: Embedder,
|
|
133
|
+
vault_path: Path,
|
|
134
|
+
prune: bool = False,
|
|
135
|
+
dry_run: bool = False,
|
|
136
|
+
link_rewrite: bool = True,
|
|
137
|
+
owner_participants: frozenset[str] = frozenset(),
|
|
138
|
+
graph_syncer: GraphSyncer | None = None,
|
|
139
|
+
) -> SyncReport:
|
|
140
|
+
"""Reconcile every ``.md`` file under ``vault_path`` into the DB.
|
|
141
|
+
|
|
142
|
+
Steps:
|
|
143
|
+
|
|
144
|
+
1. Walk the vault, collecting ``.md`` files (skipping ``_templates/``
|
|
145
|
+
and ``_attachments/``).
|
|
146
|
+
2. For each file: parse frontmatter, assign an ``id`` if missing
|
|
147
|
+
(writing back to disk), compute the body hash, then upsert the
|
|
148
|
+
``documents`` row + chunks. Re-parse links and materialize them.
|
|
149
|
+
3. After the walk, look at every vault-tier ``documents`` row whose
|
|
150
|
+
``vault_path`` was NOT seen and either prune (``prune=True``) or
|
|
151
|
+
warn.
|
|
152
|
+
4. Re-resolution pass: for every previously-unresolved link whose
|
|
153
|
+
``src_document_id`` was processed in this run, retry the lookup
|
|
154
|
+
so dangling refs that just got their target created turn into
|
|
155
|
+
resolved links.
|
|
156
|
+
5. Linker pass: rebuild ``derived_links`` for every touched doc so the
|
|
157
|
+
metadata-derived (R1 shared_thread / R2 shared_participant /
|
|
158
|
+
R3 same_day_participant) edges stay consistent with the latest
|
|
159
|
+
Gmail / Krisp metadata. The pass is scoped to ``seen_doc_ids`` and
|
|
160
|
+
the runner filters non-linkable (vault-tier) ids itself.
|
|
161
|
+
|
|
162
|
+
``embedder`` is an explicit dependency (Dependency Inversion): the sync
|
|
163
|
+
engine never reaches for a global. Tests pass a fake; production passes
|
|
164
|
+
whatever ``brain.embeddings.make_embedder`` returned.
|
|
165
|
+
|
|
166
|
+
With ``dry_run=True`` no writes happen — neither to the DB nor to disk
|
|
167
|
+
(no id assignment, no link materialization, no derived-links rebuild).
|
|
168
|
+
The returned report still counts the actions that *would* have been
|
|
169
|
+
taken for steps 1–3; ``derived_links`` stays at 0 because the linker is
|
|
170
|
+
skipped entirely on dry-run.
|
|
171
|
+
"""
|
|
172
|
+
report = SyncReport()
|
|
173
|
+
if not vault_path.is_dir():
|
|
174
|
+
report.errors.append((vault_path, "vault path does not exist or is not a directory"))
|
|
175
|
+
return report
|
|
176
|
+
|
|
177
|
+
walked: list[_WalkedFile] = list(_walk_vault(vault_path))
|
|
178
|
+
seen_doc_ids: set[str] = set()
|
|
179
|
+
seen_relative: set[str] = set()
|
|
180
|
+
|
|
181
|
+
for walked_file in walked:
|
|
182
|
+
seen_relative.add(walked_file.relative_posix)
|
|
183
|
+
try:
|
|
184
|
+
doc_id = _sync_one(
|
|
185
|
+
conn,
|
|
186
|
+
embedder=embedder,
|
|
187
|
+
vault_path=vault_path,
|
|
188
|
+
walked=walked_file,
|
|
189
|
+
report=report,
|
|
190
|
+
dry_run=dry_run,
|
|
191
|
+
)
|
|
192
|
+
except _SyncError as e:
|
|
193
|
+
report.errors.append((walked_file.abs_path, str(e)))
|
|
194
|
+
continue
|
|
195
|
+
if doc_id is not None:
|
|
196
|
+
seen_doc_ids.add(doc_id)
|
|
197
|
+
|
|
198
|
+
# Step 3: detect missing-on-disk vault-tier rows.
|
|
199
|
+
pruned_ids = _process_missing(
|
|
200
|
+
conn,
|
|
201
|
+
seen_relative=seen_relative,
|
|
202
|
+
report=report,
|
|
203
|
+
prune=prune,
|
|
204
|
+
dry_run=dry_run,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Step 4: resolution pass for unresolved links from just-processed docs.
|
|
208
|
+
if not dry_run and seen_doc_ids:
|
|
209
|
+
_retry_unresolved(conn, seen_doc_ids, report)
|
|
210
|
+
|
|
211
|
+
# Step 5: rebuild metadata-derived edges (R1/R2/R3) for the touched docs.
|
|
212
|
+
# Skipped on dry-run to honor the "no DB writes" contract; the linker
|
|
213
|
+
# opens its own transaction internally, so we call it AFTER the per-file
|
|
214
|
+
# transactions in ``_sync_one`` have committed (no nesting). Vault-tier
|
|
215
|
+
# docs in ``seen_doc_ids`` are silently filtered by the runner — only
|
|
216
|
+
# gmail / krisp rows produce edges. ``rebuild_derived_for`` already
|
|
217
|
+
# short-circuits on an empty set, so no extra guard here.
|
|
218
|
+
#
|
|
219
|
+
# Step 6 (Phase D): regenerate the fenced "Related" section in every
|
|
220
|
+
# affected ``_ingested/`` file so Quartz's graph view reflects the
|
|
221
|
+
# latest derived edges. The renderer is also a no-op on an empty
|
|
222
|
+
# affected-set, so it's safe to call unconditionally on the post-linker
|
|
223
|
+
# branch.
|
|
224
|
+
if not dry_run:
|
|
225
|
+
report.derived_links, affected_ids = rebuild_derived_for(
|
|
226
|
+
conn,
|
|
227
|
+
seen_doc_ids,
|
|
228
|
+
directory=DirectoryStore(conn),
|
|
229
|
+
owner_participants=owner_participants,
|
|
230
|
+
)
|
|
231
|
+
report.fences_written = rewrite_derived_fences(
|
|
232
|
+
conn, affected_ids, vault_path=vault_path
|
|
233
|
+
)
|
|
234
|
+
if link_rewrite:
|
|
235
|
+
report.links_rewritten = _rewrite_vault_tier_links(
|
|
236
|
+
conn,
|
|
237
|
+
vault_path=vault_path,
|
|
238
|
+
doc_ids=seen_doc_ids,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Step 7 (wave G1-c): people-aspect graph sync. Runs only on a real
|
|
242
|
+
# (non-dry-run) sync, AFTER the per-file transactions + linker pass have
|
|
243
|
+
# committed, reusing ``conn``. Reconcile every doc we touched (most are
|
|
244
|
+
# watermark-skips — vault-tier rows carry no participants, and ingested
|
|
245
|
+
# mirrors keep DB-authoritative metadata that sync doesn't change — so the
|
|
246
|
+
# graph only moves for the rare synced doc whose person set actually
|
|
247
|
+
# differs) and remove every pruned doc. Both calls are best-effort /
|
|
248
|
+
# never-raise and no-op when graph sync is disabled or AGE is absent.
|
|
249
|
+
if not dry_run and graph_syncer is not None:
|
|
250
|
+
for doc_id in sorted(seen_doc_ids):
|
|
251
|
+
graph_syncer.reconcile(conn, doc_id)
|
|
252
|
+
for doc_id in pruned_ids:
|
|
253
|
+
graph_syncer.remove(conn, doc_id)
|
|
254
|
+
|
|
255
|
+
return report
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def sync_one_file(
|
|
259
|
+
conn: psycopg.Connection[Any],
|
|
260
|
+
*,
|
|
261
|
+
embedder: Embedder,
|
|
262
|
+
vault_path: Path,
|
|
263
|
+
file_path: Path,
|
|
264
|
+
link_rewrite: bool = True,
|
|
265
|
+
owner_participants: frozenset[str] = frozenset(),
|
|
266
|
+
graph_syncer: GraphSyncer | None = None,
|
|
267
|
+
) -> SyncReport:
|
|
268
|
+
"""Sync exactly one ``.md`` file under ``vault_path``.
|
|
269
|
+
|
|
270
|
+
Used by the authoring commands (``brain note new``, ``brain daily``,
|
|
271
|
+
``brain edit`` for vault-tier docs) so we re-index just the file the user
|
|
272
|
+
touched instead of walking the entire vault. The contract matches
|
|
273
|
+
:func:`sync_vault` for the fields it touches:
|
|
274
|
+
|
|
275
|
+
- The file is classified the same way as the full walker (path under
|
|
276
|
+
``_ingested/`` → ``kind='ingested'``; anywhere else → ``kind='vault'``;
|
|
277
|
+
``_templates/`` and ``_attachments/`` are explicitly out of bounds —
|
|
278
|
+
passing one returns an error in ``report.errors``).
|
|
279
|
+
- Frontmatter id is auto-assigned + written back to disk if missing
|
|
280
|
+
(same recovery semantics as a full sync).
|
|
281
|
+
- Wiki-links from this file are re-materialized; targets that exist
|
|
282
|
+
anywhere in the DB resolve, others land in ``unresolved_links``.
|
|
283
|
+
- The link-retry pass runs scoped to this single document so a body
|
|
284
|
+
that just gained ``[[Foo]]`` (with Foo.md already in the DB from a
|
|
285
|
+
prior sync) ends up resolved.
|
|
286
|
+
- The metadata-derived linker pass also runs scoped to this single
|
|
287
|
+
document so any ``derived_links`` rows touching it stay current.
|
|
288
|
+
Vault-tier docs are filtered out by the runner — only Gmail / Krisp
|
|
289
|
+
ids contribute edges.
|
|
290
|
+
|
|
291
|
+
What's intentionally NOT re-checked: the rest of the vault. If another
|
|
292
|
+
file's ``[[<this title>]]`` was previously unresolved, a full
|
|
293
|
+
``brain vault sync`` is still required to convert that dangling ref —
|
|
294
|
+
otherwise we'd have to walk every file on every authoring action, which
|
|
295
|
+
defeats the point of the helper. The follow-up sync is cheap (body-hash
|
|
296
|
+
short-circuits every unchanged file).
|
|
297
|
+
|
|
298
|
+
``file_path`` may be absolute or relative to ``vault_path``; we
|
|
299
|
+
normalize before classifying. A path outside ``vault_path``, a non-file,
|
|
300
|
+
or a non-``.md`` file each surfaces in ``report.errors`` rather than
|
|
301
|
+
raising — the caller is the CLI and a clean error message beats a
|
|
302
|
+
traceback.
|
|
303
|
+
"""
|
|
304
|
+
report = SyncReport()
|
|
305
|
+
if not vault_path.is_dir():
|
|
306
|
+
report.errors.append(
|
|
307
|
+
(vault_path, "vault path does not exist or is not a directory")
|
|
308
|
+
)
|
|
309
|
+
return report
|
|
310
|
+
|
|
311
|
+
abs_path = (
|
|
312
|
+
file_path
|
|
313
|
+
if file_path.is_absolute()
|
|
314
|
+
else (vault_path / file_path)
|
|
315
|
+
).resolve()
|
|
316
|
+
try:
|
|
317
|
+
relative = abs_path.relative_to(vault_path.resolve())
|
|
318
|
+
except ValueError:
|
|
319
|
+
report.errors.append(
|
|
320
|
+
(abs_path, "file is not under the vault path")
|
|
321
|
+
)
|
|
322
|
+
return report
|
|
323
|
+
|
|
324
|
+
if abs_path.suffix.lower() != ".md":
|
|
325
|
+
report.errors.append((abs_path, "not a .md file"))
|
|
326
|
+
return report
|
|
327
|
+
if not abs_path.is_file():
|
|
328
|
+
report.errors.append((abs_path, "file does not exist"))
|
|
329
|
+
return report
|
|
330
|
+
|
|
331
|
+
parts = relative.parts
|
|
332
|
+
if not parts:
|
|
333
|
+
report.errors.append((abs_path, "empty relative path"))
|
|
334
|
+
return report
|
|
335
|
+
first = parts[0]
|
|
336
|
+
if first in {_TEMPLATE_DIR_NAME, _ATTACHMENTS_DIR_NAME}:
|
|
337
|
+
report.errors.append(
|
|
338
|
+
(abs_path, f"path is under {first}/ — not a syncable note")
|
|
339
|
+
)
|
|
340
|
+
return report
|
|
341
|
+
if _has_hidden_component(parts):
|
|
342
|
+
report.errors.append(
|
|
343
|
+
(abs_path, "path contains a hidden directory component (starts with '.')")
|
|
344
|
+
)
|
|
345
|
+
return report
|
|
346
|
+
|
|
347
|
+
classification = "ingested" if first == _INGESTED_DIR_NAME else "vault"
|
|
348
|
+
walked = _WalkedFile(
|
|
349
|
+
abs_path=abs_path,
|
|
350
|
+
relative_posix=relative.as_posix(),
|
|
351
|
+
classification=classification,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
doc_id = _sync_one(
|
|
356
|
+
conn,
|
|
357
|
+
embedder=embedder,
|
|
358
|
+
vault_path=vault_path,
|
|
359
|
+
walked=walked,
|
|
360
|
+
report=report,
|
|
361
|
+
dry_run=False,
|
|
362
|
+
)
|
|
363
|
+
except _SyncError as e:
|
|
364
|
+
report.errors.append((walked.abs_path, str(e)))
|
|
365
|
+
return report
|
|
366
|
+
|
|
367
|
+
if doc_id is not None:
|
|
368
|
+
_retry_unresolved(conn, {doc_id}, report)
|
|
369
|
+
# Mirror ``sync_vault``: rebuild metadata-derived edges scoped to the
|
|
370
|
+
# one doc we just processed. ``sync_one_file`` has no dry_run mode,
|
|
371
|
+
# so the linker always runs here. Non-linkable (vault-tier) doc ids
|
|
372
|
+
# are filtered by the runner.
|
|
373
|
+
#
|
|
374
|
+
# Phase D step 6: regenerate the fenced "Related" section in every
|
|
375
|
+
# ``_ingested/`` file the linker touched. ``rewrite_derived_fences``
|
|
376
|
+
# is also a no-op on an empty affected-set.
|
|
377
|
+
report.derived_links, affected_ids = rebuild_derived_for(
|
|
378
|
+
conn,
|
|
379
|
+
{doc_id},
|
|
380
|
+
directory=DirectoryStore(conn),
|
|
381
|
+
owner_participants=owner_participants,
|
|
382
|
+
)
|
|
383
|
+
report.fences_written = rewrite_derived_fences(
|
|
384
|
+
conn, affected_ids, vault_path=vault_path
|
|
385
|
+
)
|
|
386
|
+
# Vault-tier link rewrite — only runs for the one doc we just
|
|
387
|
+
# processed, and only when the path lives under a vault-tier
|
|
388
|
+
# directory (the rewriter is a no-op for ingested-tier mirrors).
|
|
389
|
+
if (
|
|
390
|
+
link_rewrite
|
|
391
|
+
and walked.classification == "vault"
|
|
392
|
+
and rewrite_vault_links(
|
|
393
|
+
walked.abs_path,
|
|
394
|
+
document_id=doc_id,
|
|
395
|
+
conn=conn,
|
|
396
|
+
)
|
|
397
|
+
):
|
|
398
|
+
report.links_rewritten += 1
|
|
399
|
+
|
|
400
|
+
# Wave G1-c: reconcile the single touched doc into the people graph
|
|
401
|
+
# (best-effort / never-raises; a no-op when graph sync is disabled or
|
|
402
|
+
# AGE is absent). Runs after the per-file + linker transactions
|
|
403
|
+
# committed, reusing ``conn``.
|
|
404
|
+
if graph_syncer is not None:
|
|
405
|
+
graph_syncer.reconcile(conn, doc_id)
|
|
406
|
+
|
|
407
|
+
return report
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# Internal helpers.
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class _SyncError(Exception):
|
|
416
|
+
"""Per-file sync failure; the run continues with the next file."""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _violation_constraint(exc: psycopg.errors.UniqueViolation) -> str:
|
|
420
|
+
"""Extract a human-readable constraint name from a ``UniqueViolation``.
|
|
421
|
+
|
|
422
|
+
Falls back to a sentinel when ``exc.diag.constraint_name`` is empty —
|
|
423
|
+
psycopg's diagnostic fields are documented as nullable, and we'd rather
|
|
424
|
+
surface a stable label in :class:`SyncReport.errors` than crash a second
|
|
425
|
+
time formatting the original error.
|
|
426
|
+
"""
|
|
427
|
+
name = getattr(exc.diag, "constraint_name", None)
|
|
428
|
+
return name or "<unknown>"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _rewrite_vault_tier_links(
|
|
432
|
+
conn: psycopg.Connection[Any],
|
|
433
|
+
*,
|
|
434
|
+
vault_path: Path,
|
|
435
|
+
doc_ids: set[str],
|
|
436
|
+
) -> int:
|
|
437
|
+
"""Rewrite ``[[…]]`` markers in every vault-tier file in ``doc_ids``.
|
|
438
|
+
|
|
439
|
+
Pulls each touched doc's ``kind`` + ``vault_path`` in one batch query
|
|
440
|
+
so we don't issue N round-trips on a full-vault sync, then dispatches
|
|
441
|
+
to :func:`brain.vault.link_rewrite.rewrite_vault_links` for each
|
|
442
|
+
vault-tier file that exists on disk. Ingested-tier docs are silently
|
|
443
|
+
filtered — wiki-link rewriting is contractually a vault-tier-only
|
|
444
|
+
operation (their ``[[…]]`` markers would all be inside the
|
|
445
|
+
auto-generated derived-edges fence, which is itself regenerated each
|
|
446
|
+
sync).
|
|
447
|
+
|
|
448
|
+
Empty input short-circuits to ``0`` — no DB round-trip, no FS scan.
|
|
449
|
+
Returns the count of files actually rewritten on disk; files that
|
|
450
|
+
were already in canonical path form (idempotent fast path) and files
|
|
451
|
+
skipped due to read/write errors do not contribute to the count.
|
|
452
|
+
"""
|
|
453
|
+
if not doc_ids:
|
|
454
|
+
return 0
|
|
455
|
+
rows = conn.execute(
|
|
456
|
+
"SELECT id::text, vault_path FROM documents "
|
|
457
|
+
"WHERE id = ANY(%s) AND kind = 'vault'",
|
|
458
|
+
(sorted(doc_ids),),
|
|
459
|
+
).fetchall()
|
|
460
|
+
rewritten = 0
|
|
461
|
+
for doc_id, vp in rows:
|
|
462
|
+
if not vp:
|
|
463
|
+
# Vault-tier rows are supposed to have a ``vault_path``; defensive
|
|
464
|
+
# skip for the corrupted-row case (no file to rewrite).
|
|
465
|
+
continue
|
|
466
|
+
target = vault_path / str(vp)
|
|
467
|
+
if not target.is_file():
|
|
468
|
+
continue
|
|
469
|
+
if rewrite_vault_links(
|
|
470
|
+
target,
|
|
471
|
+
document_id=str(doc_id),
|
|
472
|
+
conn=conn,
|
|
473
|
+
):
|
|
474
|
+
rewritten += 1
|
|
475
|
+
return rewritten
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _has_hidden_component(parts: tuple[str, ...]) -> bool:
|
|
479
|
+
"""Return True if any path component starts with ``.``.
|
|
480
|
+
|
|
481
|
+
Mirrors :func:`brain.vault.watch._filter_path`'s hidden-directory test
|
|
482
|
+
so the watcher and the one-shot sync agree on what's a non-syncable
|
|
483
|
+
path. ``.git/``, ``.quartz/`` (Quartz workspace), ``.obsidian/`` (Obsidian
|
|
484
|
+
config), VSCode's ``.vscode/``, etc. are all unwanted descents — those
|
|
485
|
+
trees contain ``.md`` files (issue templates, Quartz docs) that aren't
|
|
486
|
+
user notes and don't carry our frontmatter contract.
|
|
487
|
+
"""
|
|
488
|
+
return any(part.startswith(".") for part in parts)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _walk_vault(vault_path: Path) -> Iterator[_WalkedFile]:
|
|
492
|
+
"""Yield every Markdown file the sync should process.
|
|
493
|
+
|
|
494
|
+
Skips ``_templates/`` and ``_attachments/`` entirely, plus any path
|
|
495
|
+
whose components include a hidden directory (``.git/``, ``.quartz/``,
|
|
496
|
+
``.obsidian/``, …). Files under ``_ingested/<source>/...`` are still
|
|
497
|
+
yielded but classified as ``ingested`` so the caller treats them with
|
|
498
|
+
``kind='ingested'``. All other ``.md`` files are vault-tier.
|
|
499
|
+
|
|
500
|
+
Paths are yielded in deterministic (sorted) order so test assertions on
|
|
501
|
+
iteration order are stable.
|
|
502
|
+
"""
|
|
503
|
+
for path in sorted(vault_path.rglob("*.md")):
|
|
504
|
+
if not path.is_file():
|
|
505
|
+
continue
|
|
506
|
+
try:
|
|
507
|
+
relative = path.relative_to(vault_path)
|
|
508
|
+
except ValueError:
|
|
509
|
+
# ``rglob`` always produces children of ``vault_path``; the
|
|
510
|
+
# relative_to should never fail. Defensive: skip and move on.
|
|
511
|
+
continue
|
|
512
|
+
parts = relative.parts
|
|
513
|
+
if not parts:
|
|
514
|
+
continue
|
|
515
|
+
first = parts[0]
|
|
516
|
+
if first in {_TEMPLATE_DIR_NAME, _ATTACHMENTS_DIR_NAME}:
|
|
517
|
+
continue
|
|
518
|
+
if _has_hidden_component(parts):
|
|
519
|
+
# Hidden directories (``.git/``, ``.quartz/``, ``.obsidian/``, …)
|
|
520
|
+
# are tooling state, not authored notes. The watcher's
|
|
521
|
+
# ``_filter_path`` already skips these; the one-shot sync now
|
|
522
|
+
# matches that behavior so a vault containing a Quartz workspace
|
|
523
|
+
# or any other dotted tree doesn't pollute the DB.
|
|
524
|
+
continue
|
|
525
|
+
classification = "ingested" if first == _INGESTED_DIR_NAME else "vault"
|
|
526
|
+
yield _WalkedFile(
|
|
527
|
+
abs_path=path,
|
|
528
|
+
relative_posix=relative.as_posix(),
|
|
529
|
+
classification=classification,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _sync_one(
|
|
534
|
+
conn: psycopg.Connection[Any],
|
|
535
|
+
*,
|
|
536
|
+
embedder: Embedder,
|
|
537
|
+
vault_path: Path,
|
|
538
|
+
walked: _WalkedFile,
|
|
539
|
+
report: SyncReport,
|
|
540
|
+
dry_run: bool,
|
|
541
|
+
) -> str | None:
|
|
542
|
+
"""Sync a single file. Returns the document id (or None on dry-run / skip).
|
|
543
|
+
|
|
544
|
+
Wraps the per-file work in a single transaction (when not dry-run) so a
|
|
545
|
+
DB error mid-file leaves no half-written state — chunks, document row,
|
|
546
|
+
and links all commit together or not at all.
|
|
547
|
+
"""
|
|
548
|
+
try:
|
|
549
|
+
text = walked.abs_path.read_text(encoding="utf-8")
|
|
550
|
+
except OSError as e:
|
|
551
|
+
raise _SyncError(f"could not read file: {e}") from e
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
frontmatter, body = parse_frontmatter(text)
|
|
555
|
+
except (ValueError, yaml.YAMLError) as e:
|
|
556
|
+
raise _SyncError(f"malformed frontmatter: {e}") from e
|
|
557
|
+
|
|
558
|
+
# Files without any frontmatter block at all (no opening ``---``) are
|
|
559
|
+
# treated as tooling-managed documentation (vault README, ``_ingested/``
|
|
560
|
+
# README written by ``brain vault init``). They're intentional, not
|
|
561
|
+
# malformed, and don't belong in the search index — silently skip.
|
|
562
|
+
if not frontmatter and not text.lstrip().startswith("---"):
|
|
563
|
+
logger.debug(
|
|
564
|
+
"vault sync: %s has no frontmatter — skipping as documentation",
|
|
565
|
+
walked.relative_posix,
|
|
566
|
+
)
|
|
567
|
+
return None
|
|
568
|
+
|
|
569
|
+
# Validate / coerce the frontmatter mapping into a strongly typed view.
|
|
570
|
+
title = frontmatter.get("title")
|
|
571
|
+
if not isinstance(title, str) or not title.strip():
|
|
572
|
+
raise _SyncError("missing or empty 'title' in frontmatter")
|
|
573
|
+
|
|
574
|
+
raw_id = frontmatter.get("id")
|
|
575
|
+
needs_disk_write = False
|
|
576
|
+
if not isinstance(raw_id, str) or not raw_id:
|
|
577
|
+
report.id_assigned += 1
|
|
578
|
+
if dry_run:
|
|
579
|
+
# In dry-run we don't write to disk, but we still want to count
|
|
580
|
+
# the would-create + planned link materialization. A fresh UUID
|
|
581
|
+
# gives the rest of the function a stable key for the simulated
|
|
582
|
+
# documents row; the user doesn't see this synthetic id.
|
|
583
|
+
document_id = str(uuid.uuid4())
|
|
584
|
+
else:
|
|
585
|
+
# Recovery branch: a previous sync may have committed the DB row
|
|
586
|
+
# but crashed before stamping the id back to disk. If the file's
|
|
587
|
+
# vault_path matches an existing vault-tier row, reuse that id —
|
|
588
|
+
# otherwise we'd orphan the prior row (different id) and produce
|
|
589
|
+
# two DB rows for the same file across runs. Only matches by
|
|
590
|
+
# path; matching by title would risk false positives.
|
|
591
|
+
existing_by_path = conn.execute(
|
|
592
|
+
"SELECT id::text FROM documents "
|
|
593
|
+
"WHERE vault_path = %s AND kind = %s "
|
|
594
|
+
"LIMIT 1",
|
|
595
|
+
(walked.relative_posix, walked.classification),
|
|
596
|
+
).fetchone()
|
|
597
|
+
if existing_by_path is not None:
|
|
598
|
+
document_id = str(existing_by_path[0])
|
|
599
|
+
else:
|
|
600
|
+
document_id = str(uuid.uuid4())
|
|
601
|
+
# Defer the disk write until AFTER the DB upsert commits — a
|
|
602
|
+
# half-written file (id on disk, no DB row) was the prior bug.
|
|
603
|
+
frontmatter = dict(frontmatter)
|
|
604
|
+
frontmatter["id"] = document_id
|
|
605
|
+
needs_disk_write = True
|
|
606
|
+
else:
|
|
607
|
+
document_id = raw_id
|
|
608
|
+
|
|
609
|
+
# ``content_hash`` mirrors ``body_hash`` (frontmatter-stripped, normalized);
|
|
610
|
+
# this is what makes export → sync a no-op on unchanged content. We also
|
|
611
|
+
# compute the legacy "raw body" hash because Phase 1 export wrote rows
|
|
612
|
+
# with ``content_hash = sha256(doc.content)`` — to keep the round-trip a
|
|
613
|
+
# no-op (zero re-embeds) we treat *either* hash matching as "body
|
|
614
|
+
# unchanged" and silently migrate to ``body_hash`` form on the first run.
|
|
615
|
+
new_hash = body_hash(text)
|
|
616
|
+
legacy_hash = _legacy_body_hash(body)
|
|
617
|
+
parsed_tags = _coerce_tag_list(frontmatter.get("tags"))
|
|
618
|
+
tags = normalize_tags(parsed_tags)
|
|
619
|
+
# If the on-disk frontmatter has non-canonical tags (mixed case,
|
|
620
|
+
# underscores, duplicates), schedule a write-back so the file
|
|
621
|
+
# converges with what we're about to upsert into ``documents.tags``.
|
|
622
|
+
# This piggybacks on the existing ``needs_disk_write`` path that the
|
|
623
|
+
# missing-id branch already uses, so we still write the file at most
|
|
624
|
+
# once per sync. ``updated:`` is intentionally NOT bumped here — a tag
|
|
625
|
+
# normalization is a one-time canonicalization, not an authored edit;
|
|
626
|
+
# bumping ``updated:`` on every initial sync would churn git history.
|
|
627
|
+
if tags != parsed_tags:
|
|
628
|
+
frontmatter["tags"] = tags
|
|
629
|
+
needs_disk_write = True
|
|
630
|
+
aliases = _coerce_alias_list(frontmatter.get("aliases"))
|
|
631
|
+
content_type = (
|
|
632
|
+
frontmatter["content_type"]
|
|
633
|
+
if isinstance(frontmatter.get("content_type"), str)
|
|
634
|
+
else "note"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# Path classification: path wins over frontmatter when there's a conflict.
|
|
638
|
+
classification = walked.classification
|
|
639
|
+
declared_kind = frontmatter.get("kind")
|
|
640
|
+
if (
|
|
641
|
+
isinstance(declared_kind, str)
|
|
642
|
+
and declared_kind in {"vault", "ingested"}
|
|
643
|
+
and declared_kind != classification
|
|
644
|
+
):
|
|
645
|
+
logger.warning(
|
|
646
|
+
"vault sync: %s declares kind=%r but its path implies %r — using path",
|
|
647
|
+
walked.relative_posix,
|
|
648
|
+
declared_kind,
|
|
649
|
+
classification,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
if dry_run:
|
|
653
|
+
# Dry-run: we still count what *would* happen so users see the plan.
|
|
654
|
+
# We don't open a transaction or write to the DB.
|
|
655
|
+
existing = conn.execute(
|
|
656
|
+
"SELECT content_hash, title, tags, kind, vault_path, metadata "
|
|
657
|
+
"FROM documents WHERE id = %s",
|
|
658
|
+
(document_id,),
|
|
659
|
+
).fetchone()
|
|
660
|
+
normalized_body = _normalized_body(body)
|
|
661
|
+
if existing is None:
|
|
662
|
+
report.created += 1
|
|
663
|
+
else:
|
|
664
|
+
cur_hash, cur_title, cur_tags, _cur_kind, cur_vp, cur_meta = existing
|
|
665
|
+
cur_tags = list(cur_tags or [])
|
|
666
|
+
cur_meta = dict(cur_meta or {})
|
|
667
|
+
planned_metadata = _build_metadata(
|
|
668
|
+
frontmatter,
|
|
669
|
+
aliases,
|
|
670
|
+
tier=classification,
|
|
671
|
+
existing_metadata=cur_meta,
|
|
672
|
+
)
|
|
673
|
+
body_unchanged = cur_hash in (new_hash, legacy_hash)
|
|
674
|
+
metadata_visible_change = (
|
|
675
|
+
cur_title != title
|
|
676
|
+
or sorted(cur_tags) != sorted(tags)
|
|
677
|
+
or cur_vp != walked.relative_posix
|
|
678
|
+
or cur_meta != planned_metadata
|
|
679
|
+
)
|
|
680
|
+
if not body_unchanged or metadata_visible_change:
|
|
681
|
+
report.updated += 1
|
|
682
|
+
else:
|
|
683
|
+
report.skipped += 1
|
|
684
|
+
# Count links as if we materialized them — purely informational on dry-run.
|
|
685
|
+
for parsed in parse_wiki_links(normalized_body):
|
|
686
|
+
target = resolve_link(conn, parsed, exclude_doc_id=document_id)
|
|
687
|
+
if target is None:
|
|
688
|
+
report.links_unresolved += 1
|
|
689
|
+
else:
|
|
690
|
+
report.links_resolved += 1
|
|
691
|
+
return document_id
|
|
692
|
+
|
|
693
|
+
normalized_body = _normalized_body(body)
|
|
694
|
+
with conn.transaction():
|
|
695
|
+
existing = conn.execute(
|
|
696
|
+
"SELECT content_hash, title, tags, content_type, metadata, kind, vault_path "
|
|
697
|
+
"FROM documents WHERE id = %s",
|
|
698
|
+
(document_id,),
|
|
699
|
+
).fetchone()
|
|
700
|
+
if existing is None:
|
|
701
|
+
# Brand-new row: build metadata fresh (no DB row to merge against).
|
|
702
|
+
metadata = _build_metadata(
|
|
703
|
+
frontmatter, aliases, tier=classification, existing_metadata=None
|
|
704
|
+
)
|
|
705
|
+
try:
|
|
706
|
+
_insert_document(
|
|
707
|
+
conn,
|
|
708
|
+
embedder=embedder,
|
|
709
|
+
document_id=document_id,
|
|
710
|
+
title=title,
|
|
711
|
+
content=normalized_body,
|
|
712
|
+
content_hash=new_hash,
|
|
713
|
+
content_type=content_type,
|
|
714
|
+
tags=tags,
|
|
715
|
+
metadata=metadata,
|
|
716
|
+
kind=classification,
|
|
717
|
+
vault_path=walked.relative_posix,
|
|
718
|
+
source=_source_from_frontmatter(frontmatter, classification),
|
|
719
|
+
external_id=_external_id_from_frontmatter(
|
|
720
|
+
frontmatter, classification
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
except psycopg.errors.UniqueViolation as exc:
|
|
724
|
+
# Mirror drift: the file's vault_path or content_hash already
|
|
725
|
+
# belongs to another row. One bad file must not crash the
|
|
726
|
+
# whole sync — convert to ``_SyncError`` so the outer loop
|
|
727
|
+
# routes it into ``report.errors`` and continues.
|
|
728
|
+
raise _SyncError(
|
|
729
|
+
"unique constraint violation (mirror drift): "
|
|
730
|
+
f"{_violation_constraint(exc)}"
|
|
731
|
+
) from exc
|
|
732
|
+
report.created += 1
|
|
733
|
+
else:
|
|
734
|
+
(
|
|
735
|
+
cur_hash,
|
|
736
|
+
cur_title,
|
|
737
|
+
cur_tags,
|
|
738
|
+
cur_type,
|
|
739
|
+
cur_meta,
|
|
740
|
+
cur_kind,
|
|
741
|
+
cur_vault_path,
|
|
742
|
+
) = existing
|
|
743
|
+
cur_tags = list(cur_tags or [])
|
|
744
|
+
cur_meta = dict(cur_meta or {})
|
|
745
|
+
# Tier-aware metadata: ingested-tier preserves DB-owned fields,
|
|
746
|
+
# vault-tier rebuilds from frontmatter. See module docstring.
|
|
747
|
+
metadata = _build_metadata(
|
|
748
|
+
frontmatter,
|
|
749
|
+
aliases,
|
|
750
|
+
tier=classification,
|
|
751
|
+
existing_metadata=cur_meta,
|
|
752
|
+
)
|
|
753
|
+
# Body is unchanged iff cur_hash matches either the new normalized
|
|
754
|
+
# form or the legacy raw form; the latter handles freshly-exported
|
|
755
|
+
# files whose content_hash predates Phase 2's normalization.
|
|
756
|
+
body_unchanged = cur_hash in (new_hash, legacy_hash)
|
|
757
|
+
metadata_changed = cur_meta != metadata
|
|
758
|
+
tags_changed = sorted(cur_tags) != sorted(tags)
|
|
759
|
+
title_changed = cur_title != title
|
|
760
|
+
kind_changed = cur_kind != classification
|
|
761
|
+
vault_path_changed = cur_vault_path != walked.relative_posix
|
|
762
|
+
type_changed = cur_type != content_type
|
|
763
|
+
user_visible_change = (
|
|
764
|
+
metadata_changed
|
|
765
|
+
or tags_changed
|
|
766
|
+
or title_changed
|
|
767
|
+
or kind_changed
|
|
768
|
+
or vault_path_changed
|
|
769
|
+
or type_changed
|
|
770
|
+
)
|
|
771
|
+
if not body_unchanged or user_visible_change:
|
|
772
|
+
try:
|
|
773
|
+
_update_document(
|
|
774
|
+
conn,
|
|
775
|
+
embedder=embedder,
|
|
776
|
+
document_id=document_id,
|
|
777
|
+
title=title,
|
|
778
|
+
content=normalized_body,
|
|
779
|
+
content_hash=new_hash,
|
|
780
|
+
content_type=content_type,
|
|
781
|
+
tags=tags,
|
|
782
|
+
metadata=metadata,
|
|
783
|
+
kind=classification,
|
|
784
|
+
vault_path=walked.relative_posix,
|
|
785
|
+
body_changed=not body_unchanged,
|
|
786
|
+
)
|
|
787
|
+
except psycopg.errors.UniqueViolation as exc:
|
|
788
|
+
# Mirror drift on update: another row already owns the
|
|
789
|
+
# vault_path or content_hash this file is trying to
|
|
790
|
+
# claim. Convert to ``_SyncError`` so the run continues.
|
|
791
|
+
raise _SyncError(
|
|
792
|
+
"unique constraint violation (mirror drift): "
|
|
793
|
+
f"{_violation_constraint(exc)}"
|
|
794
|
+
) from exc
|
|
795
|
+
report.updated += 1
|
|
796
|
+
elif cur_hash != new_hash:
|
|
797
|
+
# Silent hash migration: body is byte-equivalent under the
|
|
798
|
+
# new normalization, but the stored hash is in the legacy
|
|
799
|
+
# (raw) form. Update the hash without re-embedding so the
|
|
800
|
+
# next run is a clean skip.
|
|
801
|
+
conn.execute(
|
|
802
|
+
"UPDATE documents SET content_hash = %s WHERE id = %s",
|
|
803
|
+
(new_hash, document_id),
|
|
804
|
+
)
|
|
805
|
+
report.skipped += 1
|
|
806
|
+
else:
|
|
807
|
+
report.skipped += 1
|
|
808
|
+
|
|
809
|
+
# Always re-materialize links — cheap, and ensures the link graph is
|
|
810
|
+
# consistent with the body even when we determined the doc itself
|
|
811
|
+
# was a no-op (e.g. a frontmatter-only edit removed a link).
|
|
812
|
+
_materialize_links(
|
|
813
|
+
conn,
|
|
814
|
+
document_id=document_id,
|
|
815
|
+
body=normalized_body,
|
|
816
|
+
title=title,
|
|
817
|
+
report=report,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
# DB transaction has now committed. Stamp the assigned id (and any
|
|
821
|
+
# normalized tags) back onto disk — if this fails, the DB row is intact
|
|
822
|
+
# and a follow-up sync will detect the orphaned-id-on-disk via the
|
|
823
|
+
# vault_path recovery branch above.
|
|
824
|
+
if needs_disk_write:
|
|
825
|
+
_write_frontmatter_back(
|
|
826
|
+
walked.abs_path,
|
|
827
|
+
frontmatter=frontmatter,
|
|
828
|
+
body=body,
|
|
829
|
+
original_text=text,
|
|
830
|
+
document_id=document_id,
|
|
831
|
+
relative_posix=walked.relative_posix,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# Record the fence-stripped body baseline the watcher should cache for
|
|
835
|
+
# fence-only-write detection: the content sync actually INDEXED, NOT a
|
|
836
|
+
# fresh disk read that could have picked up a racing user edit. When we
|
|
837
|
+
# wrote frontmatter back, reconstruct the intended on-disk text from the
|
|
838
|
+
# indexed body so the write-back's own follow-up event is recognized as a
|
|
839
|
+
# no-op; otherwise the file is untouched on disk and equals ``text``.
|
|
840
|
+
baseline_text = (
|
|
841
|
+
dump_frontmatter(frontmatter, body) if needs_disk_write else text
|
|
842
|
+
)
|
|
843
|
+
report.body_baseline = strip_fence(baseline_text)
|
|
844
|
+
|
|
845
|
+
return document_id
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _write_frontmatter_back(
|
|
849
|
+
abs_path: Path,
|
|
850
|
+
*,
|
|
851
|
+
frontmatter: dict[str, Any],
|
|
852
|
+
body: str,
|
|
853
|
+
original_text: str,
|
|
854
|
+
document_id: str,
|
|
855
|
+
relative_posix: str,
|
|
856
|
+
) -> None:
|
|
857
|
+
"""Stamp frontmatter (assigned id / canonical tags) back onto disk without
|
|
858
|
+
clobbering a concurrent user edit.
|
|
859
|
+
|
|
860
|
+
``_sync_one`` reads the file, then embeds — a multi-second call for a real
|
|
861
|
+
backend. If the user saves the file during that window, the naive
|
|
862
|
+
``write_text(dump_frontmatter(frontmatter, body))`` would overwrite their
|
|
863
|
+
new text with the stale ``body`` captured before the embed. Guard against
|
|
864
|
+
that: re-read the file; if its bytes changed since the initial read
|
|
865
|
+
(``original_text``), splice ONLY the frontmatter onto the freshly-read
|
|
866
|
+
body so the user's edit survives. If the fresh file can't be read or its
|
|
867
|
+
frontmatter no longer parses, skip the write-back with a WARN — a later
|
|
868
|
+
sync reconciles the missing id rather than risk clobbering the edit.
|
|
869
|
+
|
|
870
|
+
The DB row is one sync behind in this case (it was built from the body we
|
|
871
|
+
read before the embed); the next sync's body-hash check re-embeds the
|
|
872
|
+
freshly-saved content, so the two converge on the following pass.
|
|
873
|
+
"""
|
|
874
|
+
try:
|
|
875
|
+
current_text = abs_path.read_text(encoding="utf-8")
|
|
876
|
+
except OSError as e:
|
|
877
|
+
logger.error(
|
|
878
|
+
"vault sync: %s — DB row %s is committed but the file could not "
|
|
879
|
+
"be re-read for id write-back: %s. Next sync will detect the "
|
|
880
|
+
"missing id and rewrite the frontmatter.",
|
|
881
|
+
relative_posix,
|
|
882
|
+
document_id[:8],
|
|
883
|
+
e,
|
|
884
|
+
)
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
body_to_write = body
|
|
888
|
+
if current_text != original_text:
|
|
889
|
+
# A concurrent edit landed between the initial read and now.
|
|
890
|
+
try:
|
|
891
|
+
_fresh_frontmatter, fresh_body = parse_frontmatter(current_text)
|
|
892
|
+
except (ValueError, yaml.YAMLError):
|
|
893
|
+
logger.warning(
|
|
894
|
+
"vault sync: %s changed during sync and now has malformed "
|
|
895
|
+
"frontmatter — skipping id write-back to avoid clobbering the "
|
|
896
|
+
"concurrent edit; next sync will reconcile.",
|
|
897
|
+
relative_posix,
|
|
898
|
+
)
|
|
899
|
+
return
|
|
900
|
+
body_to_write = fresh_body
|
|
901
|
+
logger.info(
|
|
902
|
+
"vault sync: %s changed during sync — splicing frontmatter onto "
|
|
903
|
+
"the freshly-read body so the concurrent edit survives.",
|
|
904
|
+
relative_posix,
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
abs_path.write_text(
|
|
909
|
+
dump_frontmatter(frontmatter, body_to_write), encoding="utf-8"
|
|
910
|
+
)
|
|
911
|
+
except OSError as e:
|
|
912
|
+
logger.error(
|
|
913
|
+
"vault sync: %s — DB row %s is committed but failed to write id "
|
|
914
|
+
"back to disk: %s. Next sync will detect the missing id and "
|
|
915
|
+
"rewrite the frontmatter.",
|
|
916
|
+
relative_posix,
|
|
917
|
+
document_id[:8],
|
|
918
|
+
e,
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _normalized_body(body: str) -> str:
|
|
923
|
+
"""Return ``body`` with the fence + line-ending + trailing-whitespace normalization.
|
|
924
|
+
|
|
925
|
+
The DB stores the canonical form: LF line endings, no trailing whitespace,
|
|
926
|
+
no auto-generated derived-edges fence. Reading the value back and
|
|
927
|
+
comparing to a freshly-normalized disk read must produce identical
|
|
928
|
+
strings, so the export → sync round-trip stays a no-op even if the
|
|
929
|
+
file was saved with CRLF, has an extra trailing newline, or carries a
|
|
930
|
+
fenced "Related" section appended by the linker (Phase D).
|
|
931
|
+
|
|
932
|
+
The fence strip is what keeps ``documents.content`` clean — vector
|
|
933
|
+
search must not surface documents by their auto-generated "Related"
|
|
934
|
+
list, and the wiki-link parser (which runs over this same normalized
|
|
935
|
+
body in :func:`_materialize_links`) must not double-count fence-internal
|
|
936
|
+
references that already live in ``derived_links``.
|
|
937
|
+
"""
|
|
938
|
+
fence_stripped = strip_fence(body)
|
|
939
|
+
return fence_stripped.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _legacy_body_hash(body: str) -> str:
|
|
943
|
+
"""SHA-256 of ``body`` (fence stripped, otherwise unnormalized).
|
|
944
|
+
|
|
945
|
+
Phase 1 ingest computed ``documents.content_hash = sha256(doc.content)``
|
|
946
|
+
with no normalization, and Phase 1 export wrote files whose body bytes
|
|
947
|
+
matched ``doc.content`` exactly. This helper reproduces that legacy
|
|
948
|
+
digest so the sync engine can recognize "body unchanged under the
|
|
949
|
+
legacy hash" and skip a re-embed on the very first sync after a Phase
|
|
950
|
+
1 export — preserving the round-trip-no-op contract.
|
|
951
|
+
|
|
952
|
+
Phase D adds the fence strip: a Phase 1 file that has since been
|
|
953
|
+
rendered with a derived-edges fence still hashes equal to its original
|
|
954
|
+
content under the legacy form, so the linker's first relink doesn't
|
|
955
|
+
accidentally invalidate the legacy-hash short-circuit and trigger a
|
|
956
|
+
re-embed. The fence is contractually not authored body.
|
|
957
|
+
"""
|
|
958
|
+
return hashlib.sha256(strip_fence(body).encode("utf-8")).hexdigest()
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _build_metadata(
|
|
962
|
+
frontmatter: dict[str, Any],
|
|
963
|
+
aliases: list[str],
|
|
964
|
+
*,
|
|
965
|
+
tier: str,
|
|
966
|
+
existing_metadata: dict[str, Any] | None = None,
|
|
967
|
+
) -> dict[str, Any]:
|
|
968
|
+
"""Project the file's frontmatter into ``documents.metadata`` shape.
|
|
969
|
+
|
|
970
|
+
For vault-tier rows the file is authoritative: metadata is built fresh
|
|
971
|
+
from frontmatter (only fields that aren't first-class columns end up in
|
|
972
|
+
metadata: aliases, plus any user-supplied keys like ``priority``).
|
|
973
|
+
|
|
974
|
+
For ingested-tier rows the DB is authoritative for metadata: source
|
|
975
|
+
fields like ``date`` and ``duration_min`` are populated during the
|
|
976
|
+
original ingest and re-syncing the mirror file must not blow them
|
|
977
|
+
away. We merge the existing DB metadata with any new ``aliases`` from
|
|
978
|
+
the file (user-authored aliases on an ingested mirror are intentional)
|
|
979
|
+
but otherwise leave the DB metadata untouched.
|
|
980
|
+
|
|
981
|
+
Aliases live in ``metadata['aliases']`` per the spec; we never write an
|
|
982
|
+
empty list (keeps the JSONB tidy and matches how export omits the field).
|
|
983
|
+
"""
|
|
984
|
+
if tier == "ingested":
|
|
985
|
+
merged = dict(existing_metadata or {})
|
|
986
|
+
if aliases:
|
|
987
|
+
merged["aliases"] = aliases
|
|
988
|
+
elif "aliases" in merged and not merged["aliases"]:
|
|
989
|
+
# An empty aliases list in the DB is noise; drop it.
|
|
990
|
+
del merged["aliases"]
|
|
991
|
+
return merged
|
|
992
|
+
|
|
993
|
+
meta: dict[str, Any] = {}
|
|
994
|
+
reserved = {
|
|
995
|
+
"id",
|
|
996
|
+
"title",
|
|
997
|
+
"tags",
|
|
998
|
+
"aliases",
|
|
999
|
+
"kind",
|
|
1000
|
+
"content_type",
|
|
1001
|
+
"source",
|
|
1002
|
+
"external_id",
|
|
1003
|
+
"created",
|
|
1004
|
+
"updated",
|
|
1005
|
+
"vault_path",
|
|
1006
|
+
}
|
|
1007
|
+
for key, value in frontmatter.items():
|
|
1008
|
+
if key in reserved:
|
|
1009
|
+
continue
|
|
1010
|
+
meta[key] = value
|
|
1011
|
+
if aliases:
|
|
1012
|
+
meta["aliases"] = aliases
|
|
1013
|
+
return meta
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _coerce_tag_list(value: Any) -> list[str]:
|
|
1017
|
+
"""Coerce a frontmatter ``tags`` value into a clean ``list[str]``.
|
|
1018
|
+
|
|
1019
|
+
Accepts ``None`` / missing → ``[]``, a list → filter to string elements,
|
|
1020
|
+
a single string → ``[string]`` (some users write ``tags: career`` as a
|
|
1021
|
+
scalar). Anything else returns an empty list — the file is still
|
|
1022
|
+
syncable, the user just sees no tags.
|
|
1023
|
+
"""
|
|
1024
|
+
if value is None:
|
|
1025
|
+
return []
|
|
1026
|
+
if isinstance(value, str):
|
|
1027
|
+
return [value] if value.strip() else []
|
|
1028
|
+
if isinstance(value, list):
|
|
1029
|
+
return [t for t in value if isinstance(t, str) and t.strip()]
|
|
1030
|
+
return []
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _coerce_alias_list(value: Any) -> list[str]:
|
|
1034
|
+
"""Coerce a frontmatter ``aliases`` value into a clean ``list[str]``.
|
|
1035
|
+
|
|
1036
|
+
Mirrors :func:`_coerce_tag_list` — accepts a list of strings, a scalar
|
|
1037
|
+
string (rare but seen), or anything else → ``[]``.
|
|
1038
|
+
"""
|
|
1039
|
+
return _coerce_tag_list(value)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _source_from_frontmatter(
|
|
1043
|
+
frontmatter: dict[str, Any], tier: str
|
|
1044
|
+
) -> str | None:
|
|
1045
|
+
"""Extract the ``source`` field for ingested-tier files.
|
|
1046
|
+
|
|
1047
|
+
Vault-tier files don't have a source, so we return ``None`` regardless
|
|
1048
|
+
of what the YAML says. For ingested-tier we trust the file's
|
|
1049
|
+
``source:`` (krisp / slack / gmail / manual / ...). Returns ``None`` if
|
|
1050
|
+
the field is missing or not a string — the resulting row will have
|
|
1051
|
+
``source_id=NULL`` which still lets ``brain show`` and ``brain search``
|
|
1052
|
+
work; only ``[[<source>:<external_id>]]`` resolution would miss it,
|
|
1053
|
+
which is a soft degradation rather than a failure.
|
|
1054
|
+
"""
|
|
1055
|
+
if tier != "ingested":
|
|
1056
|
+
return None
|
|
1057
|
+
raw = frontmatter.get("source")
|
|
1058
|
+
if isinstance(raw, str) and raw.strip():
|
|
1059
|
+
return raw.strip()
|
|
1060
|
+
return None
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _external_id_from_frontmatter(
|
|
1064
|
+
frontmatter: dict[str, Any], tier: str
|
|
1065
|
+
) -> str | None:
|
|
1066
|
+
"""Extract the ``external_id`` field for ingested-tier files."""
|
|
1067
|
+
if tier != "ingested":
|
|
1068
|
+
return None
|
|
1069
|
+
raw = frontmatter.get("external_id")
|
|
1070
|
+
if isinstance(raw, str) and raw.strip():
|
|
1071
|
+
return raw.strip()
|
|
1072
|
+
return None
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _upsert_source_row(
|
|
1076
|
+
conn: psycopg.Connection[Any],
|
|
1077
|
+
*,
|
|
1078
|
+
kind: str,
|
|
1079
|
+
external_id: str | None,
|
|
1080
|
+
) -> str | None:
|
|
1081
|
+
"""Return an existing ``sources.id`` for ``(kind, external_id)``, or insert one.
|
|
1082
|
+
|
|
1083
|
+
Mirrors the ingest pipeline's ``_upsert_source`` helper but with no
|
|
1084
|
+
metadata payload — vault-authored ingested-tier files don't have
|
|
1085
|
+
upstream metadata to merge in. Returns ``None`` when ``external_id``
|
|
1086
|
+
is ``None`` (a sourceless row gets ``source_id=NULL``, same as a
|
|
1087
|
+
purely manual ingest).
|
|
1088
|
+
"""
|
|
1089
|
+
if external_id is None:
|
|
1090
|
+
return None
|
|
1091
|
+
row = conn.execute(
|
|
1092
|
+
"SELECT id::text FROM sources "
|
|
1093
|
+
"WHERE kind = %s AND external_id IS NOT DISTINCT FROM %s",
|
|
1094
|
+
(kind, external_id),
|
|
1095
|
+
).fetchone()
|
|
1096
|
+
if row is not None:
|
|
1097
|
+
return str(row[0])
|
|
1098
|
+
new = conn.execute(
|
|
1099
|
+
"INSERT INTO sources (kind, external_id, metadata) "
|
|
1100
|
+
"VALUES (%s, %s, '{}'::jsonb) RETURNING id::text",
|
|
1101
|
+
(kind, external_id),
|
|
1102
|
+
).fetchone()
|
|
1103
|
+
assert new is not None # RETURNING id always yields a row
|
|
1104
|
+
return str(new[0])
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _insert_document(
|
|
1108
|
+
conn: psycopg.Connection[Any],
|
|
1109
|
+
*,
|
|
1110
|
+
embedder: Embedder,
|
|
1111
|
+
document_id: str,
|
|
1112
|
+
title: str,
|
|
1113
|
+
content: str,
|
|
1114
|
+
content_hash: str,
|
|
1115
|
+
content_type: str,
|
|
1116
|
+
tags: list[str],
|
|
1117
|
+
metadata: dict[str, Any],
|
|
1118
|
+
kind: str,
|
|
1119
|
+
vault_path: str,
|
|
1120
|
+
source: str | None = None,
|
|
1121
|
+
external_id: str | None = None,
|
|
1122
|
+
) -> None:
|
|
1123
|
+
"""Insert a brand-new ``documents`` row + chunks.
|
|
1124
|
+
|
|
1125
|
+
Caller is responsible for the surrounding transaction — this helper
|
|
1126
|
+
runs INSERTs in the connection's current transaction.
|
|
1127
|
+
|
|
1128
|
+
For ingested-tier rows with both ``source`` and ``external_id``, we
|
|
1129
|
+
upsert a ``sources`` row first so ``[[<source>:<external_id>]]`` link
|
|
1130
|
+
resolution works against this row from day one. Vault-authored
|
|
1131
|
+
ingested-tier files (a user manually drops one into ``_ingested/``)
|
|
1132
|
+
therefore don't depend on a prior ``brain ingest`` having run.
|
|
1133
|
+
"""
|
|
1134
|
+
source_id: str | None = None
|
|
1135
|
+
if source is not None and external_id is not None:
|
|
1136
|
+
source_id = _upsert_source_row(conn, kind=source, external_id=external_id)
|
|
1137
|
+
conn.execute(
|
|
1138
|
+
"""
|
|
1139
|
+
INSERT INTO documents
|
|
1140
|
+
(id, title, content, content_hash, content_type, tags,
|
|
1141
|
+
metadata, kind, vault_path, source_id)
|
|
1142
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s)
|
|
1143
|
+
""",
|
|
1144
|
+
(
|
|
1145
|
+
document_id,
|
|
1146
|
+
title,
|
|
1147
|
+
content,
|
|
1148
|
+
content_hash,
|
|
1149
|
+
content_type,
|
|
1150
|
+
tags,
|
|
1151
|
+
json.dumps(metadata),
|
|
1152
|
+
kind,
|
|
1153
|
+
vault_path,
|
|
1154
|
+
source_id,
|
|
1155
|
+
),
|
|
1156
|
+
)
|
|
1157
|
+
_embed_and_insert_chunks(
|
|
1158
|
+
conn,
|
|
1159
|
+
embedder=embedder,
|
|
1160
|
+
document_id=document_id,
|
|
1161
|
+
content=content,
|
|
1162
|
+
title=title,
|
|
1163
|
+
tags=tags,
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _update_document(
|
|
1168
|
+
conn: psycopg.Connection[Any],
|
|
1169
|
+
*,
|
|
1170
|
+
embedder: Embedder,
|
|
1171
|
+
document_id: str,
|
|
1172
|
+
title: str,
|
|
1173
|
+
content: str,
|
|
1174
|
+
content_hash: str,
|
|
1175
|
+
content_type: str,
|
|
1176
|
+
tags: list[str],
|
|
1177
|
+
metadata: dict[str, Any],
|
|
1178
|
+
kind: str,
|
|
1179
|
+
vault_path: str,
|
|
1180
|
+
body_changed: bool,
|
|
1181
|
+
) -> None:
|
|
1182
|
+
"""Update an existing ``documents`` row + (when body changed) re-chunk."""
|
|
1183
|
+
conn.execute(
|
|
1184
|
+
"""
|
|
1185
|
+
UPDATE documents SET
|
|
1186
|
+
title = %s,
|
|
1187
|
+
content = %s,
|
|
1188
|
+
content_hash = %s,
|
|
1189
|
+
content_type = %s,
|
|
1190
|
+
tags = %s,
|
|
1191
|
+
metadata = %s::jsonb,
|
|
1192
|
+
kind = %s,
|
|
1193
|
+
vault_path = %s,
|
|
1194
|
+
ingested_at = NOW()
|
|
1195
|
+
WHERE id = %s
|
|
1196
|
+
""",
|
|
1197
|
+
(
|
|
1198
|
+
title,
|
|
1199
|
+
content,
|
|
1200
|
+
content_hash,
|
|
1201
|
+
content_type,
|
|
1202
|
+
tags,
|
|
1203
|
+
json.dumps(metadata),
|
|
1204
|
+
kind,
|
|
1205
|
+
vault_path,
|
|
1206
|
+
document_id,
|
|
1207
|
+
),
|
|
1208
|
+
)
|
|
1209
|
+
if body_changed:
|
|
1210
|
+
conn.execute("DELETE FROM chunks WHERE document_id = %s", (document_id,))
|
|
1211
|
+
_embed_and_insert_chunks(
|
|
1212
|
+
conn,
|
|
1213
|
+
embedder=embedder,
|
|
1214
|
+
document_id=document_id,
|
|
1215
|
+
content=content,
|
|
1216
|
+
title=title,
|
|
1217
|
+
tags=tags,
|
|
1218
|
+
)
|
|
1219
|
+
else:
|
|
1220
|
+
# Body unchanged but the parent doc's title / tags may have moved
|
|
1221
|
+
# (frontmatter-only edit). Migration 009 denormalizes both onto
|
|
1222
|
+
# every chunk for the weighted tsv, so propagate the change to the
|
|
1223
|
+
# existing chunks. The IS DISTINCT FROM guards inside the helper
|
|
1224
|
+
# make this a free no-op when neither column actually moved.
|
|
1225
|
+
sync_chunk_search_metadata(conn, document_id)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _embed_and_insert_chunks(
|
|
1229
|
+
conn: psycopg.Connection[Any],
|
|
1230
|
+
*,
|
|
1231
|
+
embedder: Embedder,
|
|
1232
|
+
document_id: str,
|
|
1233
|
+
content: str,
|
|
1234
|
+
title: str,
|
|
1235
|
+
tags: list[str],
|
|
1236
|
+
) -> None:
|
|
1237
|
+
"""Chunk + embed ``content`` and INSERT the resulting chunks.
|
|
1238
|
+
|
|
1239
|
+
Empty / whitespace-only bodies produce zero chunks; the document row
|
|
1240
|
+
still exists (so ``brain show`` works) but is unsearchable until the user
|
|
1241
|
+
adds content. Matches the existing ingest pipeline's behavior.
|
|
1242
|
+
|
|
1243
|
+
``title`` and ``tags`` populate the migration-009 denormalized
|
|
1244
|
+
``chunks.title_text`` / ``chunks.tags_text`` columns so the weighted
|
|
1245
|
+
FTS tsvector (title at weight A, tags at weight B) ranks vault-tier
|
|
1246
|
+
title/tag hits ahead of body hits — same contract as the ingest
|
|
1247
|
+
pipeline.
|
|
1248
|
+
"""
|
|
1249
|
+
chunks = chunk_text(content, count_tokens=embedder.count_tokens)
|
|
1250
|
+
if not chunks:
|
|
1251
|
+
return
|
|
1252
|
+
embeddings = embedder.embed([c.content for c in chunks], input_type="document")
|
|
1253
|
+
title_text, tags_text = _chunk_search_metadata(title, tags)
|
|
1254
|
+
for c, vec in zip(chunks, embeddings, strict=True):
|
|
1255
|
+
conn.execute(
|
|
1256
|
+
"INSERT INTO chunks (document_id, chunk_index, content, embedding, "
|
|
1257
|
+
"title_text, tags_text, search_extras) "
|
|
1258
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
1259
|
+
(
|
|
1260
|
+
document_id,
|
|
1261
|
+
c.index,
|
|
1262
|
+
c.content,
|
|
1263
|
+
vec,
|
|
1264
|
+
title_text,
|
|
1265
|
+
tags_text,
|
|
1266
|
+
extract_sub_tokens(c.content),
|
|
1267
|
+
),
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def _materialize_links(
|
|
1272
|
+
conn: psycopg.Connection[Any],
|
|
1273
|
+
*,
|
|
1274
|
+
document_id: str,
|
|
1275
|
+
body: str,
|
|
1276
|
+
title: str,
|
|
1277
|
+
report: SyncReport,
|
|
1278
|
+
) -> None:
|
|
1279
|
+
"""Drop + re-insert every link sourced from ``document_id``.
|
|
1280
|
+
|
|
1281
|
+
The drop-and-recreate pattern is simpler than diffing and runs in the
|
|
1282
|
+
same transaction as the document upsert, so a crash mid-link leaves the
|
|
1283
|
+
DB consistent. Each parsed link either lands in ``links`` (resolved) or
|
|
1284
|
+
``unresolved_links`` (dangling); the unique constraints on both tables
|
|
1285
|
+
deduplicate when a body has multiple identical ``[[X]]`` references.
|
|
1286
|
+
|
|
1287
|
+
``body`` is expected to already be fence-stripped — callers pass
|
|
1288
|
+
``_normalized_body(...)``, which runs :func:`strip_fence` first.
|
|
1289
|
+
Wiki-links inside the auto-generated derived-edges fence (Phase D)
|
|
1290
|
+
therefore do NOT land in ``links``; those edges live in
|
|
1291
|
+
``derived_links`` and would double-count if materialized here.
|
|
1292
|
+
"""
|
|
1293
|
+
conn.execute(
|
|
1294
|
+
"DELETE FROM links WHERE src_document_id = %s", (document_id,)
|
|
1295
|
+
)
|
|
1296
|
+
conn.execute(
|
|
1297
|
+
"DELETE FROM unresolved_links WHERE src_document_id = %s", (document_id,)
|
|
1298
|
+
)
|
|
1299
|
+
for parsed in parse_wiki_links(body):
|
|
1300
|
+
target = resolve_link(conn, parsed, exclude_doc_id=document_id)
|
|
1301
|
+
if target is None:
|
|
1302
|
+
_record_unresolved(conn, document_id, parsed, title=title)
|
|
1303
|
+
report.links_unresolved += 1
|
|
1304
|
+
else:
|
|
1305
|
+
_record_resolved(conn, document_id, parsed, target.document_id)
|
|
1306
|
+
report.links_resolved += 1
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _record_resolved(
|
|
1310
|
+
conn: psycopg.Connection[Any],
|
|
1311
|
+
src_document_id: str,
|
|
1312
|
+
parsed: ParsedLink,
|
|
1313
|
+
dst_document_id: str,
|
|
1314
|
+
) -> None:
|
|
1315
|
+
"""INSERT into ``links``; ON CONFLICT DO NOTHING for repeated text in body."""
|
|
1316
|
+
conn.execute(
|
|
1317
|
+
"""
|
|
1318
|
+
INSERT INTO links
|
|
1319
|
+
(src_document_id, dst_document_id, link_text, link_kind, display_text)
|
|
1320
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
1321
|
+
ON CONFLICT (src_document_id, dst_document_id, link_text, link_kind)
|
|
1322
|
+
DO NOTHING
|
|
1323
|
+
""",
|
|
1324
|
+
(
|
|
1325
|
+
src_document_id,
|
|
1326
|
+
dst_document_id,
|
|
1327
|
+
parsed.raw,
|
|
1328
|
+
parsed.kind,
|
|
1329
|
+
parsed.display_text,
|
|
1330
|
+
),
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _record_unresolved(
|
|
1335
|
+
conn: psycopg.Connection[Any],
|
|
1336
|
+
src_document_id: str,
|
|
1337
|
+
parsed: ParsedLink,
|
|
1338
|
+
*,
|
|
1339
|
+
title: str,
|
|
1340
|
+
) -> None:
|
|
1341
|
+
"""INSERT into ``unresolved_links`` with ON CONFLICT DO NOTHING.
|
|
1342
|
+
|
|
1343
|
+
We log a one-line warning when the resolution failed because of a title
|
|
1344
|
+
collision — that's the single case where the user can fix it by adding
|
|
1345
|
+
an alias, and the diagnostic is the single most useful breadcrumb the
|
|
1346
|
+
sync produces.
|
|
1347
|
+
"""
|
|
1348
|
+
if parsed.target_type == "title":
|
|
1349
|
+
collisions = title_collisions(
|
|
1350
|
+
conn, parsed.target_value, exclude_doc_id=src_document_id
|
|
1351
|
+
)
|
|
1352
|
+
if len(collisions) > 1:
|
|
1353
|
+
preview = ", ".join(c[:8] for c in collisions[:3])
|
|
1354
|
+
logger.warning(
|
|
1355
|
+
"vault sync: %r links to %r which matches %d documents (%s%s); "
|
|
1356
|
+
"use [[brain:<prefix>]] to disambiguate",
|
|
1357
|
+
title,
|
|
1358
|
+
parsed.target_value,
|
|
1359
|
+
len(collisions),
|
|
1360
|
+
preview,
|
|
1361
|
+
"…" if len(collisions) > 3 else "",
|
|
1362
|
+
)
|
|
1363
|
+
conn.execute(
|
|
1364
|
+
"""
|
|
1365
|
+
INSERT INTO unresolved_links
|
|
1366
|
+
(src_document_id, link_text, link_kind, display_text)
|
|
1367
|
+
VALUES (%s, %s, %s, %s)
|
|
1368
|
+
ON CONFLICT (src_document_id, link_text, link_kind) DO NOTHING
|
|
1369
|
+
""",
|
|
1370
|
+
(src_document_id, parsed.raw, parsed.kind, parsed.display_text),
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _process_missing(
|
|
1375
|
+
conn: psycopg.Connection[Any],
|
|
1376
|
+
*,
|
|
1377
|
+
seen_relative: set[str],
|
|
1378
|
+
report: SyncReport,
|
|
1379
|
+
prune: bool,
|
|
1380
|
+
dry_run: bool,
|
|
1381
|
+
) -> list[str]:
|
|
1382
|
+
"""Find vault-tier rows whose ``vault_path`` was NOT seen and prune/warn.
|
|
1383
|
+
|
|
1384
|
+
Dry-run reports the planned action via the report counters but never
|
|
1385
|
+
writes — and never logs a warning either, since dry-run is contractually
|
|
1386
|
+
"read-only" (no DB writes, no FS writes, no log noise the user wasn't
|
|
1387
|
+
asking for). The counters in the returned report still reflect what
|
|
1388
|
+
*would* happen.
|
|
1389
|
+
|
|
1390
|
+
Returns the list of document ids that were ACTUALLY deleted this pass
|
|
1391
|
+
(``prune`` and not ``dry_run``) so the caller can drop them from the
|
|
1392
|
+
people graph (wave G1-c). Empty on warn-only / dry-run.
|
|
1393
|
+
"""
|
|
1394
|
+
rows = conn.execute(
|
|
1395
|
+
"SELECT id::text, vault_path, title FROM documents "
|
|
1396
|
+
"WHERE kind = 'vault' AND vault_path IS NOT NULL"
|
|
1397
|
+
).fetchall()
|
|
1398
|
+
pruned_ids: list[str] = []
|
|
1399
|
+
for doc_id, vault_path_value, title in rows:
|
|
1400
|
+
if vault_path_value in seen_relative:
|
|
1401
|
+
continue
|
|
1402
|
+
if prune:
|
|
1403
|
+
if not dry_run:
|
|
1404
|
+
conn.execute("DELETE FROM documents WHERE id = %s", (doc_id,))
|
|
1405
|
+
pruned_ids.append(str(doc_id))
|
|
1406
|
+
report.deleted += 1
|
|
1407
|
+
else:
|
|
1408
|
+
if not dry_run:
|
|
1409
|
+
logger.warning(
|
|
1410
|
+
"vault sync: %r (%s) has vault_path=%r but no file on disk; "
|
|
1411
|
+
"use --prune to delete",
|
|
1412
|
+
title,
|
|
1413
|
+
str(doc_id)[:8],
|
|
1414
|
+
vault_path_value,
|
|
1415
|
+
)
|
|
1416
|
+
report.warned += 1
|
|
1417
|
+
return pruned_ids
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _retry_unresolved(
|
|
1421
|
+
conn: psycopg.Connection[Any],
|
|
1422
|
+
src_document_ids: set[str],
|
|
1423
|
+
report: SyncReport,
|
|
1424
|
+
) -> None:
|
|
1425
|
+
"""Try to resolve ``unresolved_links`` whose source was just processed.
|
|
1426
|
+
|
|
1427
|
+
Restricting the scan to ``src_document_id IN (just-processed-ids)``
|
|
1428
|
+
keeps it cheap on a large vault — we don't re-scan dangling refs from
|
|
1429
|
+
every doc, only the ones whose body might have just been changed (or
|
|
1430
|
+
whose target may have just been created when another file in this
|
|
1431
|
+
same run produced it).
|
|
1432
|
+
|
|
1433
|
+
Each newly-resolvable row moves from ``unresolved_links`` to ``links``
|
|
1434
|
+
in a single transaction (per row, to keep the transaction footprint
|
|
1435
|
+
small). The corresponding counters are adjusted so the final report
|
|
1436
|
+
reflects the post-pass state, not the per-file naive count.
|
|
1437
|
+
"""
|
|
1438
|
+
if not src_document_ids:
|
|
1439
|
+
return
|
|
1440
|
+
# Two-step: collect the candidates (read-only), then for each one try a
|
|
1441
|
+
# resolve + move. Doing the move inline while iterating the cursor would
|
|
1442
|
+
# invalidate the cursor; the candidate set is small (just-processed
|
|
1443
|
+
# documents) so materializing it is cheap.
|
|
1444
|
+
rows = conn.execute(
|
|
1445
|
+
"SELECT id::text, src_document_id::text, link_text, link_kind, display_text "
|
|
1446
|
+
"FROM unresolved_links WHERE src_document_id = ANY(%s)",
|
|
1447
|
+
(list(src_document_ids),),
|
|
1448
|
+
).fetchall()
|
|
1449
|
+
for row_id, src_id, link_text, link_kind, display_text in rows:
|
|
1450
|
+
parsed = _reparse_link_text(link_text, link_kind, display_text)
|
|
1451
|
+
if parsed is None:
|
|
1452
|
+
continue
|
|
1453
|
+
target = resolve_link(conn, parsed, exclude_doc_id=str(src_id))
|
|
1454
|
+
if target is None:
|
|
1455
|
+
continue
|
|
1456
|
+
with conn.transaction():
|
|
1457
|
+
conn.execute(
|
|
1458
|
+
"""
|
|
1459
|
+
INSERT INTO links
|
|
1460
|
+
(src_document_id, dst_document_id, link_text, link_kind, display_text)
|
|
1461
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
1462
|
+
ON CONFLICT (src_document_id, dst_document_id, link_text, link_kind)
|
|
1463
|
+
DO NOTHING
|
|
1464
|
+
""",
|
|
1465
|
+
(
|
|
1466
|
+
str(src_id),
|
|
1467
|
+
target.document_id,
|
|
1468
|
+
link_text,
|
|
1469
|
+
link_kind,
|
|
1470
|
+
display_text,
|
|
1471
|
+
),
|
|
1472
|
+
)
|
|
1473
|
+
conn.execute(
|
|
1474
|
+
"DELETE FROM unresolved_links WHERE id = %s", (row_id,)
|
|
1475
|
+
)
|
|
1476
|
+
report.links_resolved += 1
|
|
1477
|
+
report.links_unresolved -= 1
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _reparse_link_text(
|
|
1481
|
+
link_text: str, link_kind: str, display_text: str | None
|
|
1482
|
+
) -> ParsedLink | None:
|
|
1483
|
+
"""Re-parse a stored ``unresolved_links.link_text`` back into a ParsedLink.
|
|
1484
|
+
|
|
1485
|
+
The DB only stores the raw ``[[...]]`` form (we deliberately don't keep
|
|
1486
|
+
the parsed structure around — fewer columns to migrate later). To retry
|
|
1487
|
+
resolution we run the same parser over the stored text, which yields at
|
|
1488
|
+
most one ParsedLink (the entire text *is* one link). Returns ``None``
|
|
1489
|
+
for any malformed stored text — extremely unusual but the retry pass
|
|
1490
|
+
just skips it.
|
|
1491
|
+
"""
|
|
1492
|
+
parsed = parse_wiki_links(link_text)
|
|
1493
|
+
if not parsed:
|
|
1494
|
+
return None
|
|
1495
|
+
only = parsed[0]
|
|
1496
|
+
# The retry pass uses the stored ``display_text`` rather than the
|
|
1497
|
+
# re-parsed one — the writer already extracted aliases, so they should
|
|
1498
|
+
# always agree. A divergence (parser drift after an upgrade, a manual DB
|
|
1499
|
+
# edit, a migration) must NOT abort a foreground ``brain vault sync``, and
|
|
1500
|
+
# a bare ``assert`` here would (and is silently stripped under ``python
|
|
1501
|
+
# -O``, making the behavior inconsistent). Log it and continue: the caller
|
|
1502
|
+
# inserts the STORED ``display_text``, so no alias is dropped either way.
|
|
1503
|
+
if only.display_text is not None and only.display_text != display_text:
|
|
1504
|
+
logger.warning(
|
|
1505
|
+
"vault sync: unresolved link %r re-parsed display text %r differs "
|
|
1506
|
+
"from stored %r; using the stored display text and continuing",
|
|
1507
|
+
link_text,
|
|
1508
|
+
only.display_text,
|
|
1509
|
+
display_text,
|
|
1510
|
+
)
|
|
1511
|
+
if link_kind not in {"wiki", "embed"}:
|
|
1512
|
+
return None
|
|
1513
|
+
return only
|