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/todo.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Parser for ``- [ ]`` / ``- [x]`` Markdown action-item lines.
|
|
2
|
+
|
|
3
|
+
Standalone so it can be unit-tested without a DB. The CLI calls
|
|
4
|
+
:func:`parse_action_items` on each ``krisp_action_items`` doc body fetched
|
|
5
|
+
in one SELECT (see :mod:`brain.cli` `todo` command).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
import psycopg
|
|
16
|
+
|
|
17
|
+
# Match leading whitespace, ``-`` or ``*``, whitespace, ``[ ]`` or ``[x]``
|
|
18
|
+
# (case-insensitive), whitespace, rest-of-line. Anchored with ``^`` +
|
|
19
|
+
# ``re.MULTILINE`` so we walk every body line in one pass.
|
|
20
|
+
_TASK_RE = re.compile(
|
|
21
|
+
r"^\s*[-*]\s*\[([ xX])\]\s*(.+?)\s*$",
|
|
22
|
+
re.MULTILINE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ActionItem:
|
|
28
|
+
"""One parsed action-item line.
|
|
29
|
+
|
|
30
|
+
``state`` is ``"open"`` for ``[ ]`` and ``"done"`` for ``[x]`` (case-
|
|
31
|
+
insensitive). ``text`` is the rest-of-line content after the checkbox,
|
|
32
|
+
stripped of trailing whitespace.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
state: Literal["open", "done"]
|
|
36
|
+
text: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_action_items(body: str) -> list[ActionItem]:
|
|
40
|
+
"""Return one :class:`ActionItem` per recognized task line.
|
|
41
|
+
|
|
42
|
+
Lines without the ``[ ]`` / ``[x]`` pattern are ignored — comments,
|
|
43
|
+
headers, the trailing ``Parent meeting:`` wikilink. The parser is
|
|
44
|
+
Markdown-aware enough to handle leading whitespace + ``-`` / ``*``
|
|
45
|
+
bullets but does NOT support nested checkboxes (a future wave can add
|
|
46
|
+
nesting if the action-items shape grows). Pure function; the CLI feeds
|
|
47
|
+
in pre-fetched bodies.
|
|
48
|
+
"""
|
|
49
|
+
out: list[ActionItem] = []
|
|
50
|
+
for m in _TASK_RE.finditer(body):
|
|
51
|
+
ch = m.group(1)
|
|
52
|
+
state: Literal["open", "done"] = "done" if ch.lower() == "x" else "open"
|
|
53
|
+
out.append(ActionItem(state=state, text=m.group(2)))
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class TodoRow:
|
|
59
|
+
"""One flat row returned by :func:`iter_action_item_docs`.
|
|
60
|
+
|
|
61
|
+
Carries the parent document context (id, title, ingested_at) alongside
|
|
62
|
+
the parsed action item so the CLI ``brain todo`` view can render a flat
|
|
63
|
+
table without re-joining at print time.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
document_id: str
|
|
67
|
+
document_title: str
|
|
68
|
+
ingested_at: datetime | None
|
|
69
|
+
state: Literal["open", "done"]
|
|
70
|
+
text: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def iter_action_item_docs(
|
|
74
|
+
conn: psycopg.Connection[Any],
|
|
75
|
+
*,
|
|
76
|
+
source_kind: str | None = None,
|
|
77
|
+
since_days: int | None = None,
|
|
78
|
+
include_closed: bool = False,
|
|
79
|
+
) -> Iterator[TodoRow]:
|
|
80
|
+
"""Yield one :class:`TodoRow` per parsed item across the corpus.
|
|
81
|
+
|
|
82
|
+
Walks ``documents WHERE content_type='krisp_action_items'`` (optionally
|
|
83
|
+
filtered by source / recency), parses each body line by line, and
|
|
84
|
+
yields the parsed items in document-recency order. The caller decides
|
|
85
|
+
whether to render a table or emit JSON.
|
|
86
|
+
|
|
87
|
+
Filters:
|
|
88
|
+
|
|
89
|
+
- ``source_kind`` — restricts to docs whose joined ``sources.kind``
|
|
90
|
+
matches (today only ``"krisp"`` is meaningful, but the parameter
|
|
91
|
+
stays open for future source kinds emitting the same content_type).
|
|
92
|
+
- ``since_days`` — restricts to docs ingested in the last N days.
|
|
93
|
+
- ``include_closed`` — when False (default), drop ``[x]`` items from
|
|
94
|
+
the stream; when True, both states are returned.
|
|
95
|
+
"""
|
|
96
|
+
sql = (
|
|
97
|
+
"SELECT d.id::text, d.title, d.content, d.ingested_at, s.kind "
|
|
98
|
+
"FROM documents d LEFT JOIN sources s ON s.id = d.source_id "
|
|
99
|
+
"WHERE d.content_type = 'krisp_action_items'"
|
|
100
|
+
)
|
|
101
|
+
params: list[Any] = []
|
|
102
|
+
if source_kind is not None:
|
|
103
|
+
sql += " AND s.kind = %s"
|
|
104
|
+
params.append(source_kind)
|
|
105
|
+
if since_days is not None:
|
|
106
|
+
sql += " AND d.ingested_at >= NOW() - (%s * INTERVAL '1 day')"
|
|
107
|
+
params.append(since_days)
|
|
108
|
+
sql += " ORDER BY d.ingested_at DESC"
|
|
109
|
+
rows = conn.execute(sql, params).fetchall()
|
|
110
|
+
for r in rows:
|
|
111
|
+
doc_id = str(r[0])
|
|
112
|
+
title = str(r[1])
|
|
113
|
+
body = str(r[2] or "")
|
|
114
|
+
ingested_at = r[3]
|
|
115
|
+
for item in parse_action_items(body):
|
|
116
|
+
if item.state == "done" and not include_closed:
|
|
117
|
+
continue
|
|
118
|
+
yield TodoRow(
|
|
119
|
+
document_id=doc_id,
|
|
120
|
+
document_title=title,
|
|
121
|
+
ingested_at=ingested_at,
|
|
122
|
+
state=item.state,
|
|
123
|
+
text=item.text,
|
|
124
|
+
)
|
brain/uninstall.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""brain uninstall — removes launchd plists, $BRAIN_HOME runtime state, and optionally the vault.
|
|
2
|
+
|
|
3
|
+
Does NOT remove the pipx installation itself; a CLI cannot safely uninstall
|
|
4
|
+
its own running process. After running this command, the user should manually
|
|
5
|
+
run `pipx uninstall secondbrain-py`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from ._compose import compose_cmd
|
|
17
|
+
from .config import DEFAULT_VAULT_PATH, _brain_home_root
|
|
18
|
+
|
|
19
|
+
# Files / dirs inside $BRAIN_HOME that are safe to remove even when --remove-db
|
|
20
|
+
# is NOT set (i.e. everything except the data/ subtree).
|
|
21
|
+
_BRAIN_HOME_SAFE_REMOVES = (
|
|
22
|
+
".env",
|
|
23
|
+
".shims",
|
|
24
|
+
"Caddyfile",
|
|
25
|
+
"docker-compose.yml",
|
|
26
|
+
"bin",
|
|
27
|
+
"logs",
|
|
28
|
+
"build",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _step(label: str) -> None:
|
|
33
|
+
typer.echo(f" {label}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ok(label: str) -> None:
|
|
37
|
+
typer.secho(f" [ok] {label}", fg="green")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _skipped(label: str) -> None:
|
|
41
|
+
typer.echo(f" [skipped] {label}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_uninstall(
|
|
45
|
+
*,
|
|
46
|
+
yes: bool = False,
|
|
47
|
+
remove_db: bool = False,
|
|
48
|
+
remove_vault: bool = False,
|
|
49
|
+
brain_home: Path | None = None,
|
|
50
|
+
vault_path: Path | None = None,
|
|
51
|
+
_launchd_uninstall: object | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Core uninstall logic — separated from the Typer command for testability.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
yes:
|
|
58
|
+
Skip the interactive confirmation prompt.
|
|
59
|
+
remove_db:
|
|
60
|
+
Also remove ``$BRAIN_HOME/data/postgres/`` (requires extra typed
|
|
61
|
+
confirmation regardless of ``yes``).
|
|
62
|
+
remove_vault:
|
|
63
|
+
Also remove the vault directory.
|
|
64
|
+
brain_home:
|
|
65
|
+
Override the resolved ``$BRAIN_HOME`` path (for tests).
|
|
66
|
+
vault_path:
|
|
67
|
+
Override the resolved ``$BRAIN_VAULT_PATH`` path (for tests).
|
|
68
|
+
_launchd_uninstall:
|
|
69
|
+
Dependency-injected callable that replaces ``brain.bin.launchd.uninstall_main``
|
|
70
|
+
(for tests that don't want real launchctl calls).
|
|
71
|
+
"""
|
|
72
|
+
home = brain_home if brain_home is not None else _brain_home_root()
|
|
73
|
+
vault = vault_path if vault_path is not None else DEFAULT_VAULT_PATH
|
|
74
|
+
|
|
75
|
+
# -----------------------------------------------------------------------
|
|
76
|
+
# 1. Print summary of what will be removed.
|
|
77
|
+
# -----------------------------------------------------------------------
|
|
78
|
+
typer.echo("")
|
|
79
|
+
typer.echo("🧠 brain uninstall")
|
|
80
|
+
typer.echo("")
|
|
81
|
+
typer.echo("The following will be removed:")
|
|
82
|
+
typer.echo(" • launchd plists (macOS only)")
|
|
83
|
+
typer.echo(" • Docker compose containers / networks (brain project)")
|
|
84
|
+
for name in _BRAIN_HOME_SAFE_REMOVES:
|
|
85
|
+
p = home / name
|
|
86
|
+
if p.exists():
|
|
87
|
+
typer.echo(f" • {p}")
|
|
88
|
+
if remove_db:
|
|
89
|
+
typer.echo(f" • {home / 'data' / 'postgres'} ← ALL DATABASE DATA")
|
|
90
|
+
if remove_vault:
|
|
91
|
+
typer.echo(f" • {vault} ← YOUR NOTES")
|
|
92
|
+
typer.echo("")
|
|
93
|
+
|
|
94
|
+
# -----------------------------------------------------------------------
|
|
95
|
+
# 2. Interactive confirmation (unless --yes).
|
|
96
|
+
# -----------------------------------------------------------------------
|
|
97
|
+
if not yes:
|
|
98
|
+
typer.confirm("Proceed?", default=False, abort=True)
|
|
99
|
+
|
|
100
|
+
# -----------------------------------------------------------------------
|
|
101
|
+
# 3. Extra typed confirmation for --remove-db (NEVER bypassed by --yes).
|
|
102
|
+
# -----------------------------------------------------------------------
|
|
103
|
+
if remove_db:
|
|
104
|
+
typer.echo("")
|
|
105
|
+
typer.echo("WARNING: --remove-db will permanently delete all ingested documents.")
|
|
106
|
+
answer = typer.prompt('Type "yes, delete my data" to confirm')
|
|
107
|
+
if answer != "yes, delete my data":
|
|
108
|
+
typer.secho("Aborted — database data was NOT removed.", fg="yellow")
|
|
109
|
+
raise typer.Abort()
|
|
110
|
+
|
|
111
|
+
# -----------------------------------------------------------------------
|
|
112
|
+
# 4. Uninstall launchd (macOS only).
|
|
113
|
+
# -----------------------------------------------------------------------
|
|
114
|
+
if sys.platform == "darwin":
|
|
115
|
+
_step("Uninstalling launchd plists …")
|
|
116
|
+
try:
|
|
117
|
+
if _launchd_uninstall is not None:
|
|
118
|
+
_launchd_uninstall() # type: ignore[operator]
|
|
119
|
+
else:
|
|
120
|
+
from .bin.launchd import uninstall_main
|
|
121
|
+
uninstall_main()
|
|
122
|
+
_ok("launchd plists removed")
|
|
123
|
+
except Exception as exc: # noqa: BLE001
|
|
124
|
+
typer.secho(f" [warn] launchd uninstall: {exc}", fg="yellow")
|
|
125
|
+
else:
|
|
126
|
+
_skipped("launchd (not macOS)")
|
|
127
|
+
|
|
128
|
+
# -----------------------------------------------------------------------
|
|
129
|
+
# 5. Stop Docker compose (fail-silent — user may have stopped it already).
|
|
130
|
+
# -----------------------------------------------------------------------
|
|
131
|
+
_step("Stopping Docker compose …")
|
|
132
|
+
try:
|
|
133
|
+
subprocess.run(
|
|
134
|
+
compose_cmd("down", brain_home=home),
|
|
135
|
+
check=False,
|
|
136
|
+
capture_output=True,
|
|
137
|
+
timeout=30,
|
|
138
|
+
)
|
|
139
|
+
_ok("docker compose down")
|
|
140
|
+
except Exception as exc: # noqa: BLE001
|
|
141
|
+
typer.secho(f" [warn] docker compose down: {exc}", fg="yellow")
|
|
142
|
+
|
|
143
|
+
# -----------------------------------------------------------------------
|
|
144
|
+
# 6. Remove safe $BRAIN_HOME entries.
|
|
145
|
+
# -----------------------------------------------------------------------
|
|
146
|
+
_step(f"Removing $BRAIN_HOME runtime files from {home} …")
|
|
147
|
+
for name in _BRAIN_HOME_SAFE_REMOVES:
|
|
148
|
+
p = home / name
|
|
149
|
+
if p.is_dir():
|
|
150
|
+
shutil.rmtree(p, ignore_errors=True)
|
|
151
|
+
_ok(f"removed dir {p}")
|
|
152
|
+
elif p.is_file():
|
|
153
|
+
p.unlink(missing_ok=True)
|
|
154
|
+
_ok(f"removed file {p}")
|
|
155
|
+
|
|
156
|
+
# -----------------------------------------------------------------------
|
|
157
|
+
# 7. Optionally remove the database data directory.
|
|
158
|
+
# -----------------------------------------------------------------------
|
|
159
|
+
if remove_db:
|
|
160
|
+
pg_dir = home / "data" / "postgres"
|
|
161
|
+
if pg_dir.exists():
|
|
162
|
+
shutil.rmtree(pg_dir)
|
|
163
|
+
_ok(f"removed database {pg_dir}")
|
|
164
|
+
else:
|
|
165
|
+
_skipped(f"data/postgres not found at {pg_dir}")
|
|
166
|
+
|
|
167
|
+
# -----------------------------------------------------------------------
|
|
168
|
+
# 8. Optionally remove the vault.
|
|
169
|
+
# -----------------------------------------------------------------------
|
|
170
|
+
if remove_vault:
|
|
171
|
+
if vault.exists():
|
|
172
|
+
shutil.rmtree(vault)
|
|
173
|
+
_ok(f"removed vault {vault}")
|
|
174
|
+
else:
|
|
175
|
+
_skipped(f"vault not found at {vault}")
|
|
176
|
+
else:
|
|
177
|
+
_skipped(f"vault kept at {vault} (pass --remove-vault to delete)")
|
|
178
|
+
|
|
179
|
+
# -----------------------------------------------------------------------
|
|
180
|
+
# 9. Final hint.
|
|
181
|
+
# -----------------------------------------------------------------------
|
|
182
|
+
typer.echo("")
|
|
183
|
+
typer.secho("Done. To complete removal, run:", fg="cyan")
|
|
184
|
+
typer.secho(" pipx uninstall secondbrain-py", bold=True)
|
|
185
|
+
typer.echo("")
|
brain/vault/__init__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Vault model — `.md` files on disk are the source of truth for vault notes.
|
|
2
|
+
|
|
3
|
+
Phase 1 ships :func:`init_vault` (one-shot directory + template scaffold) and
|
|
4
|
+
:func:`export_vault` (DB → vault dump). Later phases add sync, link parsing,
|
|
5
|
+
authoring commands, and a Quartz renderer on top of the same module layout.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .link_rewrite import rewrite_vault_links, rewrite_wiki_links
|
|
11
|
+
from .templates import (
|
|
12
|
+
CAPTURE_README,
|
|
13
|
+
DAILY_TEMPLATE,
|
|
14
|
+
INGESTED_README,
|
|
15
|
+
NOTE_TEMPLATE,
|
|
16
|
+
VAULT_README,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CAPTURE_README",
|
|
21
|
+
"DAILY_TEMPLATE",
|
|
22
|
+
"INGESTED_README",
|
|
23
|
+
"NOTE_TEMPLATE",
|
|
24
|
+
"VAULT_README",
|
|
25
|
+
"VAULT_SUBDIRS",
|
|
26
|
+
"VAULT_TEMPLATE_FILES",
|
|
27
|
+
"VaultInitSummary",
|
|
28
|
+
"create_vault_note",
|
|
29
|
+
"init_vault",
|
|
30
|
+
"rewrite_vault_links",
|
|
31
|
+
"rewrite_wiki_links",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Subdirectories created (or ensured) by ``brain vault init``. Order is
|
|
35
|
+
# preserved in the summary so output stays deterministic across runs.
|
|
36
|
+
VAULT_SUBDIRS: tuple[str, ...] = (
|
|
37
|
+
"_templates",
|
|
38
|
+
"_attachments",
|
|
39
|
+
"_ingested",
|
|
40
|
+
"_ingested/krisp",
|
|
41
|
+
"_ingested/slack",
|
|
42
|
+
"_ingested/gmail",
|
|
43
|
+
"_ingested/manual",
|
|
44
|
+
"capture",
|
|
45
|
+
"daily",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# (relative path, content) pairs written if the file does not already exist.
|
|
49
|
+
# Tuple-of-tuples (not dict) so iteration order is part of the contract.
|
|
50
|
+
VAULT_TEMPLATE_FILES: tuple[tuple[str, str], ...] = (
|
|
51
|
+
("_templates/daily.md", DAILY_TEMPLATE),
|
|
52
|
+
("_templates/note.md", NOTE_TEMPLATE),
|
|
53
|
+
("_ingested/README.md", INGESTED_README),
|
|
54
|
+
("capture/README.md", CAPTURE_README),
|
|
55
|
+
("README.md", VAULT_README),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class VaultInitSummary:
|
|
61
|
+
"""What ``init_vault`` actually did, for the CLI to print.
|
|
62
|
+
|
|
63
|
+
``created_dirs`` lists subdirs that didn't exist before this call;
|
|
64
|
+
``existing_dirs`` lists ones that did. ``written_files`` and
|
|
65
|
+
``preserved_files`` are the analogous lists for templates: a template
|
|
66
|
+
is only ever written when absent (we never overwrite user edits).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
vault_path: Path
|
|
70
|
+
created_dirs: list[str] = field(default_factory=list)
|
|
71
|
+
existing_dirs: list[str] = field(default_factory=list)
|
|
72
|
+
written_files: list[str] = field(default_factory=list)
|
|
73
|
+
preserved_files: list[str] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def init_vault(vault_path: Path) -> VaultInitSummary:
|
|
77
|
+
"""Create / ensure the vault folder structure and default templates.
|
|
78
|
+
|
|
79
|
+
Idempotent — safe to re-run. Never overwrites existing files; user edits
|
|
80
|
+
to ``_templates/*.md`` or ``README.md`` survive every subsequent
|
|
81
|
+
``brain vault init`` call.
|
|
82
|
+
|
|
83
|
+
Creates the vault root if absent. Creates each subdir under
|
|
84
|
+
:data:`VAULT_SUBDIRS` with ``mkdir -p`` semantics (no error if present).
|
|
85
|
+
Writes each template in :data:`VAULT_TEMPLATE_FILES` only if its target
|
|
86
|
+
path does not yet exist.
|
|
87
|
+
"""
|
|
88
|
+
summary = VaultInitSummary(vault_path=vault_path)
|
|
89
|
+
vault_path.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
for relative in VAULT_SUBDIRS:
|
|
92
|
+
target = vault_path / relative
|
|
93
|
+
if target.is_dir():
|
|
94
|
+
summary.existing_dirs.append(relative)
|
|
95
|
+
else:
|
|
96
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
summary.created_dirs.append(relative)
|
|
98
|
+
|
|
99
|
+
for relative, content in VAULT_TEMPLATE_FILES:
|
|
100
|
+
target = vault_path / relative
|
|
101
|
+
if target.exists():
|
|
102
|
+
summary.preserved_files.append(relative)
|
|
103
|
+
else:
|
|
104
|
+
target.write_text(content, encoding="utf-8")
|
|
105
|
+
summary.written_files.append(relative)
|
|
106
|
+
|
|
107
|
+
return summary
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Imported at the BOTTOM on purpose: ``note_builder`` pulls in (lazily) the
|
|
111
|
+
# ingest pipeline, and ``brain.ingest`` imports ``brain.vault.export``, which
|
|
112
|
+
# does ``from . import init_vault``. Re-exporting here only after ``init_vault``
|
|
113
|
+
# is defined keeps that re-entrant import resolvable instead of hitting a
|
|
114
|
+
# partially-initialized package. See note_builder's module docstring.
|
|
115
|
+
from .note_builder import create_vault_note # noqa: E402
|
brain/vault/_atomic.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Atomic file-write helper shared by every body-rewriter under ``vault/``."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
7
|
+
"""Write ``text`` to ``path`` atomically (sibling tempfile + ``os.replace``).
|
|
8
|
+
|
|
9
|
+
The temp file is a sibling of ``path`` (same parent directory) so the
|
|
10
|
+
rename never crosses a filesystem boundary — ``os.replace`` is atomic
|
|
11
|
+
on POSIX in that case (it's ``rename(2)`` underneath). A crash between
|
|
12
|
+
the write and the rename leaves the original file intact.
|
|
13
|
+
|
|
14
|
+
On any failure during the write or rename, the temp file is removed so
|
|
15
|
+
we don't leave stray ``*.tmp`` siblings cluttering the vault. Tempfile
|
|
16
|
+
cleanup uses ``unlink(missing_ok=True)`` because the failure may have
|
|
17
|
+
happened before the file was created.
|
|
18
|
+
"""
|
|
19
|
+
tmp = path.with_name(f"{path.name}.tmp")
|
|
20
|
+
try:
|
|
21
|
+
tmp.write_text(text, encoding="utf-8")
|
|
22
|
+
os.replace(tmp, path)
|
|
23
|
+
except BaseException:
|
|
24
|
+
tmp.unlink(missing_ok=True)
|
|
25
|
+
raise
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Auto-generate ``<vault>/daily/index.md`` from the dailies on disk.
|
|
2
|
+
|
|
3
|
+
Phase 4.1 of the Wiki UX Overhaul. The home note's "Daily notes" door
|
|
4
|
+
points at ``daily/`` — Quartz resolves a folder reference to
|
|
5
|
+
``daily/index`` if and only if a markdown file exists at that path.
|
|
6
|
+
Without :func:`regenerate_daily_index`, the door is a 404.
|
|
7
|
+
|
|
8
|
+
Contract:
|
|
9
|
+
|
|
10
|
+
* Walks ``<vault>/daily/<YYYY>/<YYYY-MM-DD>.md`` recursively (any depth
|
|
11
|
+
is tolerated, but the brain CLI only writes year-folder children) and
|
|
12
|
+
collects every ``.md`` file whose stem matches ``YYYY-MM-DD``.
|
|
13
|
+
``index.md`` itself is excluded so a re-run of the regen doesn't
|
|
14
|
+
feed itself.
|
|
15
|
+
* Sorted reverse-chronological by filename. The filename is the
|
|
16
|
+
canonical truth (matches the slug Quartz emits, matches what the
|
|
17
|
+
user expects to see) — frontmatter ``date`` would be a second
|
|
18
|
+
truth-source that could drift.
|
|
19
|
+
* Body = ``- [[YYYY-MM-DD]]`` per line, newest first. Wiki-link form
|
|
20
|
+
uses the bare date stem so Quartz's ``markdownLinkResolution:
|
|
21
|
+
shortest`` resolves to the right path under ``daily/<YYYY>/``
|
|
22
|
+
without the regen having to know about year folders.
|
|
23
|
+
* Frontmatter: ``id`` and ``created`` are preserved across re-runs
|
|
24
|
+
(read from the existing file when present); ``updated`` is bumped
|
|
25
|
+
only when the body changed; ``autogenerated: true`` flags the
|
|
26
|
+
file as a brain-managed artifact users shouldn't hand-edit.
|
|
27
|
+
* Idempotent: when the body matches what's already on disk, the file
|
|
28
|
+
is left untouched (no ``updated`` churn, no atomic write). A test
|
|
29
|
+
asserts byte-identical output across two consecutive regens.
|
|
30
|
+
* Empty case: when no daily notes exist, the function does NOT write
|
|
31
|
+
``index.md``. Combined with the home-page hide-if-empty
|
|
32
|
+
transformer this gives a coherent "no dailies → no door" UX. A
|
|
33
|
+
pre-existing ``index.md`` is left in place — deleting it would
|
|
34
|
+
surprise users who hand-curated the file before running the regen.
|
|
35
|
+
|
|
36
|
+
The atomic write uses :func:`brain.vault._atomic.atomic_write_text`
|
|
37
|
+
so a crash mid-write can never leave a half-written index visible to
|
|
38
|
+
Quartz's watcher.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import re
|
|
43
|
+
import uuid
|
|
44
|
+
from datetime import UTC, datetime
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
import yaml
|
|
48
|
+
|
|
49
|
+
from ._atomic import atomic_write_text
|
|
50
|
+
from .frontmatter import dump_frontmatter, parse_frontmatter
|
|
51
|
+
from .paths import strip_md_extension
|
|
52
|
+
|
|
53
|
+
# Filename stem of a daily note: ``YYYY-MM-DD``. Anchored on both ends
|
|
54
|
+
# so a stray ``2026-04-29-rough-thoughts.md`` (a manually-named note
|
|
55
|
+
# under ``daily/``) doesn't get hoisted into the index.
|
|
56
|
+
_DAILY_STEM_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
57
|
+
|
|
58
|
+
_INDEX_FILENAME = "index.md"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _list_daily_notes(daily_dir: Path) -> list[Path]:
|
|
62
|
+
"""Return every ``YYYY-MM-DD.md`` daily note under ``daily_dir``.
|
|
63
|
+
|
|
64
|
+
Recursive — the brain CLI nests daily notes under
|
|
65
|
+
``daily/<YYYY>/`` but a user who's flattened the structure
|
|
66
|
+
(or never year-folded their pre-2026 notes) is also supported. The
|
|
67
|
+
filename ``index.md`` is excluded so a stale auto-generated index
|
|
68
|
+
can't recursively feed itself.
|
|
69
|
+
|
|
70
|
+
Returns the empty list when ``daily_dir`` doesn't exist (the brain
|
|
71
|
+
vault scaffolds it via ``init_vault`` but a user pointing at an
|
|
72
|
+
unmanaged folder might not have one yet).
|
|
73
|
+
"""
|
|
74
|
+
if not daily_dir.is_dir():
|
|
75
|
+
return []
|
|
76
|
+
out: list[Path] = []
|
|
77
|
+
for path in daily_dir.rglob("*.md"):
|
|
78
|
+
if path.name == _INDEX_FILENAME:
|
|
79
|
+
continue
|
|
80
|
+
if not path.is_file():
|
|
81
|
+
continue
|
|
82
|
+
if _DAILY_STEM_RE.match(path.stem):
|
|
83
|
+
out.append(path)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_index_body(daily_paths: list[Path], *, vault_path: Path) -> str:
|
|
88
|
+
"""Return the body text for ``daily/index.md`` from ``daily_paths``.
|
|
89
|
+
|
|
90
|
+
Reverse-chronological by filename stem (``YYYY-MM-DD`` lex-sorts
|
|
91
|
+
correctly as date order). Each line is a path-form-aliased wiki
|
|
92
|
+
link — ``[[daily/<year>/<stem>|<stem>]]`` — matching the canonical
|
|
93
|
+
shape that :func:`brain.vault.link_rewrite.rewrite_wiki_links`
|
|
94
|
+
produces for resolved vault links.
|
|
95
|
+
|
|
96
|
+
Why path-form, not bare stem? Earlier prototype used
|
|
97
|
+
``[[<stem>]]`` and relied on Quartz's ``markdownLinkResolution:
|
|
98
|
+
shortest`` to resolve the date-shaped target. That worked at the
|
|
99
|
+
Quartz level, but the post-write ``brain vault sync`` ran the link
|
|
100
|
+
rewriter over the body and replaced every bare stem with the path-
|
|
101
|
+
form alias. The next ``brain daily`` invocation then saw a body
|
|
102
|
+
mismatch (regen wanted bare stem, disk had path form), triggered
|
|
103
|
+
a rewrite, and bumped ``updated:`` — losing idempotency and
|
|
104
|
+
sending the watcher into a rebuild loop on every invocation.
|
|
105
|
+
Emitting the path-form here makes the regen output already
|
|
106
|
+
canonical, so the sync step is a no-op and consecutive runs
|
|
107
|
+
leave the file byte-identical.
|
|
108
|
+
|
|
109
|
+
The body opens with a ``# Daily notes`` heading and a one-line
|
|
110
|
+
preamble — Quartz uses the heading as the page title and the
|
|
111
|
+
preamble as the breadcrumb-friendly first paragraph. A trailing
|
|
112
|
+
newline closes the file in the conventional Unix way.
|
|
113
|
+
"""
|
|
114
|
+
# Sort by stem desc — but keep the resolved path alongside so the
|
|
115
|
+
# path-form alias can be emitted without a second filesystem walk.
|
|
116
|
+
rels: list[tuple[str, str]] = []
|
|
117
|
+
for p in daily_paths:
|
|
118
|
+
try:
|
|
119
|
+
rel = p.resolve().relative_to(vault_path.resolve())
|
|
120
|
+
except ValueError:
|
|
121
|
+
# Defensive: ``rglob`` shouldn't surface a path outside
|
|
122
|
+
# ``daily_dir``, but a symlinked vault could in theory.
|
|
123
|
+
# Skip rather than crash.
|
|
124
|
+
continue
|
|
125
|
+
# POSIX form so the wiki link round-trips on Windows too. Strip
|
|
126
|
+
# ``.md`` to match what the link rewriter emits — shared helper
|
|
127
|
+
# in :mod:`brain.vault.paths` so the homepage / people / daily
|
|
128
|
+
# renderers cannot drift on the canonical link-target shape.
|
|
129
|
+
rels.append((p.stem, strip_md_extension(rel.as_posix())))
|
|
130
|
+
rels.sort(key=lambda t: t[0], reverse=True)
|
|
131
|
+
|
|
132
|
+
lines = [
|
|
133
|
+
"# Daily notes",
|
|
134
|
+
"",
|
|
135
|
+
(
|
|
136
|
+
"All daily notes, newest first. "
|
|
137
|
+
"This page is auto-generated — run `brain daily` to refresh it."
|
|
138
|
+
),
|
|
139
|
+
"",
|
|
140
|
+
]
|
|
141
|
+
for stem, posix in rels:
|
|
142
|
+
lines.append(f"- [[{posix}|{stem}]]")
|
|
143
|
+
lines.append("")
|
|
144
|
+
return "\n".join(lines)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _read_existing_metadata(target: Path) -> tuple[str | None, str | None, str | None]:
|
|
148
|
+
"""Return ``(id, created, body)`` from an existing index.md, or ``None``s.
|
|
149
|
+
|
|
150
|
+
Used to preserve ``id`` and ``created`` across re-runs (so the
|
|
151
|
+
auto-generated row keeps a stable DB identity even when the body
|
|
152
|
+
changes), and to short-circuit the rewrite when the body hasn't
|
|
153
|
+
changed (so the on-disk file stays byte-identical and the watcher
|
|
154
|
+
doesn't fire a needless rebuild).
|
|
155
|
+
|
|
156
|
+
Failure modes — missing file, malformed YAML, structurally invalid
|
|
157
|
+
frontmatter — return ``(None, None, None)`` so the caller treats
|
|
158
|
+
the situation as "regenerate from scratch." This is safe: a
|
|
159
|
+
corrupt index re-emerges with a fresh id; the user can always
|
|
160
|
+
rebind it via ``brain edit`` if they had hand-edits worth saving
|
|
161
|
+
(auto-generated files explicitly carry ``autogenerated: true`` to
|
|
162
|
+
discourage that workflow).
|
|
163
|
+
"""
|
|
164
|
+
if not target.is_file():
|
|
165
|
+
return (None, None, None)
|
|
166
|
+
try:
|
|
167
|
+
text = target.read_text(encoding="utf-8")
|
|
168
|
+
except OSError:
|
|
169
|
+
return (None, None, None)
|
|
170
|
+
try:
|
|
171
|
+
fields, body = parse_frontmatter(text)
|
|
172
|
+
except (ValueError, yaml.YAMLError):
|
|
173
|
+
return (None, None, None)
|
|
174
|
+
raw_id = fields.get("id")
|
|
175
|
+
raw_created = fields.get("created")
|
|
176
|
+
return (
|
|
177
|
+
raw_id if isinstance(raw_id, str) else None,
|
|
178
|
+
raw_created if isinstance(raw_created, str) else None,
|
|
179
|
+
body,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def regenerate_daily_index(vault_path: Path) -> bool:
|
|
184
|
+
"""Regenerate ``<vault>/daily/index.md`` from the dailies on disk.
|
|
185
|
+
|
|
186
|
+
Returns ``True`` if the file was written (or rewritten), ``False``
|
|
187
|
+
if the regen was a no-op (body unchanged, or no dailies to index).
|
|
188
|
+
|
|
189
|
+
Atomic — a crash mid-write leaves the previous ``index.md`` (if
|
|
190
|
+
any) intact. Idempotent — two consecutive calls against an
|
|
191
|
+
unchanged ``daily/`` produce a byte-identical file on disk.
|
|
192
|
+
|
|
193
|
+
Empty case: no daily notes ⇒ no write. A pre-existing
|
|
194
|
+
``index.md`` is left in place rather than deleted; the user's
|
|
195
|
+
hand-curated file (if any) survives. The home-page hide-if-empty
|
|
196
|
+
transformer is the layer that reflects "no dailies" in the UI.
|
|
197
|
+
"""
|
|
198
|
+
daily_dir = vault_path / "daily"
|
|
199
|
+
target = daily_dir / _INDEX_FILENAME
|
|
200
|
+
|
|
201
|
+
daily_paths = _list_daily_notes(daily_dir)
|
|
202
|
+
if not daily_paths:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
body = _build_index_body(daily_paths, vault_path=vault_path)
|
|
206
|
+
|
|
207
|
+
existing_id, existing_created, existing_body = _read_existing_metadata(target)
|
|
208
|
+
if existing_body is not None and existing_body == body:
|
|
209
|
+
# No content drift — skip rewrite to keep the file byte-identical
|
|
210
|
+
# and the watcher quiet.
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
now_iso = datetime.now(UTC).isoformat(timespec="seconds")
|
|
214
|
+
doc_id = existing_id if existing_id else str(uuid.uuid4())
|
|
215
|
+
created = existing_created if existing_created else now_iso
|
|
216
|
+
fields: dict[str, object] = {
|
|
217
|
+
"id": doc_id,
|
|
218
|
+
"title": "Daily notes",
|
|
219
|
+
"created": created,
|
|
220
|
+
"updated": now_iso,
|
|
221
|
+
"tags": ["daily", "index"],
|
|
222
|
+
"kind": "vault",
|
|
223
|
+
"content_type": "note",
|
|
224
|
+
"autogenerated": True,
|
|
225
|
+
}
|
|
226
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
atomic_write_text(target, dump_frontmatter(fields, body))
|
|
228
|
+
return True
|