secondbrain-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Server-side render of the home-page "Recently captured" rail (P4.7).
|
|
2
|
+
|
|
3
|
+
Phase 4.7 of the Wiki UX Overhaul. Surfaces the 12 most-recently-ingested
|
|
4
|
+
documents on the home page so the user lands on something fresh after every
|
|
5
|
+
ingest cycle (Krisp call dump, Slack thread pull, Gmail batch, …) instead of
|
|
6
|
+
the static "Doors / Topic clusters" copy.
|
|
7
|
+
|
|
8
|
+
Two write surfaces, both fed from the same DB query:
|
|
9
|
+
|
|
10
|
+
- ``<vault>/_partials/recent.md`` — the rendered bullet list, written
|
|
11
|
+
byte-stable for inspection / debugging. The Quartz workspace ignores the
|
|
12
|
+
``_partials/`` directory (see ``ignorePatterns`` in ``quartz.config.ts``)
|
|
13
|
+
so this file never becomes a public page.
|
|
14
|
+
- ``<vault>/index.md`` — the live home note. Its body has a fenced region
|
|
15
|
+
marked by ``<!-- BRAIN_RECENT_START -->`` / ``<!-- BRAIN_RECENT_END -->``;
|
|
16
|
+
this module rewrites the content between markers in-place. Mirrors the
|
|
17
|
+
Phase D derived-edges fence pattern (``brain.vault.derived_links.fence``)
|
|
18
|
+
exactly: stable markers, atomic write, idempotent on byte-identical input.
|
|
19
|
+
|
|
20
|
+
Why two files? The partial is the source-of-truth artifact (a future tooling
|
|
21
|
+
layer — e.g. a "What's new" RSS feed — can read it without parsing index.md).
|
|
22
|
+
The fence in index.md is what readers actually see in the rendered wiki. The
|
|
23
|
+
public function :func:`refresh_homepage` writes both in lockstep so they never
|
|
24
|
+
disagree.
|
|
25
|
+
|
|
26
|
+
Failure modes are silent-but-loud: a missing fence in index.md logs a
|
|
27
|
+
warning and skips the rewrite (don't auto-insert — the user might be
|
|
28
|
+
deliberately omitting the rail); a DB error inside :func:`refresh_homepage`
|
|
29
|
+
logs an error and returns ``(False, False)`` so the surrounding build never
|
|
30
|
+
fails *because* the rail couldn't be regenerated. The build is the customer;
|
|
31
|
+
the rail is a courtesy.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import datetime
|
|
36
|
+
import logging
|
|
37
|
+
from collections.abc import Sequence
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
import psycopg
|
|
43
|
+
|
|
44
|
+
from ..config import Config
|
|
45
|
+
from ..db import connect
|
|
46
|
+
from ..vault._atomic import atomic_write_text
|
|
47
|
+
from ..vault.frontmatter import dump_frontmatter, parse_frontmatter
|
|
48
|
+
from ..vault.paths import safe_wikilink_alias, strip_md_extension
|
|
49
|
+
|
|
50
|
+
# Stable HTML-comment markers — universal CommonMark passthroughs that
|
|
51
|
+
# Obsidian, Quartz, and GFM all leave alone. Public so tests + future
|
|
52
|
+
# render surfaces can detect the fence without re-importing internal regex
|
|
53
|
+
# state. Mirrors :data:`brain.vault.derived_links.fence.FENCE_START_MARKER`
|
|
54
|
+
# pattern but in its own namespace so tweaking one surface doesn't tug the
|
|
55
|
+
# other.
|
|
56
|
+
FENCE_START_MARKER: str = "<!-- BRAIN_RECENT_START -->"
|
|
57
|
+
FENCE_END_MARKER: str = "<!-- BRAIN_RECENT_END -->"
|
|
58
|
+
|
|
59
|
+
# Fixed window. The plan says "12 newest"; we don't make this configurable
|
|
60
|
+
# because the visual rail is laid out for exactly this count and adding a
|
|
61
|
+
# knob invites drift between the partial, the fence, and any future RSS
|
|
62
|
+
# consumer.
|
|
63
|
+
RECENT_LIMIT: int = 12
|
|
64
|
+
|
|
65
|
+
# Source-icon vocabulary mirrors `brain/quartz_overrides/quartz/util/sourceIcons.ts`
|
|
66
|
+
# (the SOURCE_ICONS map there is the canonical client-side copy). Keeping a
|
|
67
|
+
# server-side duplicate here is intentional: the partial is rendered by Python
|
|
68
|
+
# *before* Quartz is invoked, so we can't import the .ts module. The two
|
|
69
|
+
# copies must stay aligned — a future change to the client glyph table needs
|
|
70
|
+
# this dict bumped too. Tests in ``test_brain_recent_homepage.py`` pin every
|
|
71
|
+
# key/value pair to lock that contract.
|
|
72
|
+
_SOURCE_ICONS: dict[str, str] = {
|
|
73
|
+
"gmail": "📧",
|
|
74
|
+
"krisp": "🎙️",
|
|
75
|
+
"slack": "💬",
|
|
76
|
+
"manual": "✍️",
|
|
77
|
+
"vault": "🌱",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Unknown source kinds fall back to the generic "vault" glyph so a row never
|
|
81
|
+
# renders icon-less. Same default as ``sourceIconFor`` on the client.
|
|
82
|
+
_DEFAULT_SOURCE_ICON: str = _SOURCE_ICONS["vault"]
|
|
83
|
+
|
|
84
|
+
_logger = logging.getLogger(__name__)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class RecentDoc:
|
|
89
|
+
"""One row of the recent-rail query, projected for rendering.
|
|
90
|
+
|
|
91
|
+
``source_kind`` may be ``None`` for vault-tier docs (no ``sources`` row);
|
|
92
|
+
callers map ``None`` → ``"vault"`` before icon lookup.
|
|
93
|
+
|
|
94
|
+
``vault_path`` is guaranteed non-empty by the SQL filter — the renderer
|
|
95
|
+
relies on it to build the wiki-link target. Rows without a vault_path
|
|
96
|
+
are excluded at the SQL layer (they aren't browseable yet).
|
|
97
|
+
|
|
98
|
+
``display_date`` carries the doc's CONTENT/EVENT date, not its raw ingest
|
|
99
|
+
time: the SQL projects ``COALESCE(d.doc_date, d.ingested_at)`` so a Krisp
|
|
100
|
+
meeting held last week but ingested today ranks (and renders) by the
|
|
101
|
+
meeting date, not the processing timestamp. ``documents.doc_date`` is the
|
|
102
|
+
generated ``COALESCE(sent_at, ingested_at)`` column (migration 021); the
|
|
103
|
+
outer ``COALESCE`` guards corpora predating that migration. Both the
|
|
104
|
+
ordering and the rendered ``data-date`` span read this field, so the rail
|
|
105
|
+
stops mis-ranking on ``ingested_at`` (which the pipeline bulk-bumps when it
|
|
106
|
+
regenerates derived pages).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
title: str
|
|
110
|
+
source_kind: str | None
|
|
111
|
+
display_date: datetime.datetime
|
|
112
|
+
vault_path: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Public API
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def refresh_homepage(cfg: Config) -> tuple[bool, bool]:
|
|
121
|
+
"""Refresh the partial + the fence in lockstep using ``cfg``'s DB + vault.
|
|
122
|
+
|
|
123
|
+
Convenience entry point used by the build pipeline (CLI in
|
|
124
|
+
``brain.wiki.build_swap.main`` and any caller that already has a
|
|
125
|
+
:class:`Config` in hand). Opens a short-lived DB connection, runs the
|
|
126
|
+
recent-docs query once, and writes both surfaces from the same row set
|
|
127
|
+
so the partial and the fence can never drift.
|
|
128
|
+
|
|
129
|
+
Returns ``(partial_changed, fence_changed)`` — each ``True`` if the
|
|
130
|
+
corresponding file was written (content drifted), ``False`` if the
|
|
131
|
+
write was a no-op (byte-identical input) or skipped (no fence in
|
|
132
|
+
index.md, no docs to render). Tests assert specific tuples here to
|
|
133
|
+
prove idempotency.
|
|
134
|
+
|
|
135
|
+
Failure handling is intentionally permissive: any
|
|
136
|
+
:class:`psycopg.Error` (DB unreachable, schema drift, …) is logged at
|
|
137
|
+
``WARNING`` and swallowed — the rail is a nice-to-have, not a build
|
|
138
|
+
gate. A failed refresh leaves both files untouched; the next
|
|
139
|
+
successful build retries.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
with connect(cfg.database_url) as conn:
|
|
143
|
+
docs = _fetch_recent_docs(conn, limit=RECENT_LIMIT)
|
|
144
|
+
except psycopg.Error as exc:
|
|
145
|
+
_logger.warning(
|
|
146
|
+
"wiki recent rail: DB query failed (%s) — skipping refresh", exc
|
|
147
|
+
)
|
|
148
|
+
return (False, False)
|
|
149
|
+
|
|
150
|
+
partial_changed = regenerate_recent_partial(cfg.vault_path, docs=docs)
|
|
151
|
+
fence_changed = regenerate_recent_fence(cfg.vault_path, docs=docs)
|
|
152
|
+
return (partial_changed, fence_changed)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def regenerate_recent_partial(
|
|
156
|
+
vault_path: Path, *, docs: Sequence[RecentDoc]
|
|
157
|
+
) -> bool:
|
|
158
|
+
"""Write the rendered bullet list to ``<vault>/_partials/recent.md``.
|
|
159
|
+
|
|
160
|
+
Returns ``True`` iff the file was actually written (content drifted
|
|
161
|
+
from what's already on disk). A no-op return preserves the file's
|
|
162
|
+
mtime so the watcher doesn't fire a needless rebuild.
|
|
163
|
+
|
|
164
|
+
The partial directory is created on demand (``mkdir -p``) — first
|
|
165
|
+
call on a fresh vault doesn't require a separate scaffold step.
|
|
166
|
+
|
|
167
|
+
The atomic write goes through
|
|
168
|
+
:func:`brain.vault._atomic.atomic_write_text` so a crash mid-write
|
|
169
|
+
can never leave a half-written partial visible to consumers.
|
|
170
|
+
"""
|
|
171
|
+
rendered = _render_bullets(docs)
|
|
172
|
+
target = vault_path / "_partials" / "recent.md"
|
|
173
|
+
|
|
174
|
+
# Idempotency: read-then-compare before write. A re-run with the same
|
|
175
|
+
# DB state must produce a byte-identical file — that's the property the
|
|
176
|
+
# `Phase 4.1 daily index` test pattern proved valuable; we mirror it.
|
|
177
|
+
if target.is_file():
|
|
178
|
+
try:
|
|
179
|
+
existing = target.read_text(encoding="utf-8")
|
|
180
|
+
except OSError:
|
|
181
|
+
existing = None
|
|
182
|
+
if existing == rendered:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
atomic_write_text(target, rendered)
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def regenerate_recent_fence(
|
|
191
|
+
vault_path: Path, *, docs: Sequence[RecentDoc]
|
|
192
|
+
) -> bool:
|
|
193
|
+
"""Replace the recent-rail fence in ``<vault>/index.md`` in place.
|
|
194
|
+
|
|
195
|
+
Looks for the ``<!-- BRAIN_RECENT_START -->`` / ``<!-- BRAIN_RECENT_END -->``
|
|
196
|
+
markers in the home note's body. If found, the content between them is
|
|
197
|
+
swapped for the freshly-rendered bullet list (preserving the markers
|
|
198
|
+
themselves). If the markers are missing, logs a warning and returns
|
|
199
|
+
``False`` — the renderer never auto-inserts the fence because the user
|
|
200
|
+
might have deliberately removed it from the home note.
|
|
201
|
+
|
|
202
|
+
Returns ``True`` iff the file was actually rewritten. A re-run with
|
|
203
|
+
the same DB state and the same on-disk fence content returns ``False``
|
|
204
|
+
without touching the file (preserves mtime, keeps the watcher quiet).
|
|
205
|
+
|
|
206
|
+
The home note retains its existing frontmatter (id / created /
|
|
207
|
+
title / tags / …) — only the body changes. ``updated`` is left
|
|
208
|
+
intact so the home note doesn't bump on every rail refresh; the
|
|
209
|
+
fence is a low-signal change and we don't want it polluting the
|
|
210
|
+
"modified" timestamp Quartz surfaces in the page header.
|
|
211
|
+
|
|
212
|
+
Atomic — sibling tempfile + ``os.replace`` via
|
|
213
|
+
:func:`atomic_write_text`. A crash mid-write leaves the previous
|
|
214
|
+
home note intact.
|
|
215
|
+
"""
|
|
216
|
+
target = vault_path / "index.md"
|
|
217
|
+
if not target.is_file():
|
|
218
|
+
_logger.warning(
|
|
219
|
+
"wiki recent rail: home note %s does not exist — skipping fence",
|
|
220
|
+
target,
|
|
221
|
+
)
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
text = target.read_text(encoding="utf-8")
|
|
225
|
+
try:
|
|
226
|
+
frontmatter, body = parse_frontmatter(text)
|
|
227
|
+
except Exception as exc: # noqa: BLE001 — parse_frontmatter raises broadly
|
|
228
|
+
_logger.warning(
|
|
229
|
+
"wiki recent rail: malformed frontmatter in %s (%s) — skipping",
|
|
230
|
+
target, exc,
|
|
231
|
+
)
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
new_body = _replace_fence(body, _render_bullets(docs))
|
|
235
|
+
if new_body is None:
|
|
236
|
+
_logger.warning(
|
|
237
|
+
"wiki recent rail: %s missing %s/%s markers — add them by hand to "
|
|
238
|
+
"enable the recent rail (skipping)",
|
|
239
|
+
target, FENCE_START_MARKER, FENCE_END_MARKER,
|
|
240
|
+
)
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
if new_body == body:
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
new_text = dump_frontmatter(frontmatter, new_body)
|
|
247
|
+
atomic_write_text(target, new_text)
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Internals — query + rendering helpers.
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _fetch_recent_docs(
|
|
257
|
+
conn: psycopg.Connection[Any], *, limit: int
|
|
258
|
+
) -> list[RecentDoc]:
|
|
259
|
+
"""Return the ``limit`` most-recent docs (by event date) eligible for the rail.
|
|
260
|
+
|
|
261
|
+
Filters applied at the SQL layer:
|
|
262
|
+
|
|
263
|
+
- ``draft = FALSE`` — drafts are quarantined from every public surface
|
|
264
|
+
(P1.6). The recent rail must not surface them either.
|
|
265
|
+
- ``vault_path IS NOT NULL`` — without a vault path the row isn't
|
|
266
|
+
browseable from the wiki, so emitting a wiki-link would 404.
|
|
267
|
+
- ``ingested_at IS NOT NULL`` — defensive; the column is ``NOT NULL``
|
|
268
|
+
in 001_init.sql but the predicate guards a future schema relax.
|
|
269
|
+
- ``vault_path <> 'index.md'`` — the home note itself. Without this the
|
|
270
|
+
rail would list itself (the rail lives inside index.md), and because the
|
|
271
|
+
pipeline re-stamps the home note's ``ingested_at`` on every derived-page
|
|
272
|
+
regeneration it would otherwise sit permanently at the top.
|
|
273
|
+
- ``vault_path NOT LIKE 'people/%%'`` — the People-Hub auto-page namespace.
|
|
274
|
+
``brain.wiki.build_people.emit_people_pages`` writes EVERY page it emits
|
|
275
|
+
under ``<vault>/people/`` (the per-person ``people/<slug>.md`` roster
|
|
276
|
+
pages + ``people/index.md``); those are machine-generated derived pages,
|
|
277
|
+
re-stamped ``ingested_at = now()`` on each People-Hub regeneration, so
|
|
278
|
+
after any Krisp/Slack batch they would swamp the rail. Path-based (not a
|
|
279
|
+
jsonb ``?`` lookup) so it never collides with psycopg ``%s`` placeholders;
|
|
280
|
+
the literal ``%`` is doubled because psycopg treats the SQL string as a
|
|
281
|
+
format template.
|
|
282
|
+
|
|
283
|
+
Ranking + display both use ``COALESCE(d.doc_date, d.ingested_at)`` (the
|
|
284
|
+
doc's content/event date — see :class:`RecentDoc`), NOT raw ``ingested_at``,
|
|
285
|
+
so a meeting held last week but ingested today ranks by the meeting date.
|
|
286
|
+
Sort is that expression ``DESC``. LEFT JOIN against ``sources`` so
|
|
287
|
+
vault-tier docs (no ``sources`` row) come back with ``source_kind=NULL``
|
|
288
|
+
rather than being silently dropped.
|
|
289
|
+
|
|
290
|
+
Read-only — never INSERT/UPDATE/DELETE. Safe to call from any
|
|
291
|
+
autocommit-or-not context.
|
|
292
|
+
"""
|
|
293
|
+
rows = conn.execute(
|
|
294
|
+
"""
|
|
295
|
+
SELECT d.title, s.kind, COALESCE(d.doc_date, d.ingested_at) AS display_date,
|
|
296
|
+
d.vault_path
|
|
297
|
+
FROM documents d
|
|
298
|
+
LEFT JOIN sources s ON s.id = d.source_id
|
|
299
|
+
WHERE d.draft = FALSE
|
|
300
|
+
AND d.vault_path IS NOT NULL
|
|
301
|
+
AND d.ingested_at IS NOT NULL
|
|
302
|
+
AND d.vault_path <> 'index.md'
|
|
303
|
+
AND d.vault_path NOT LIKE 'people/%%'
|
|
304
|
+
ORDER BY COALESCE(d.doc_date, d.ingested_at) DESC
|
|
305
|
+
LIMIT %s
|
|
306
|
+
""",
|
|
307
|
+
(limit,),
|
|
308
|
+
).fetchall()
|
|
309
|
+
return [
|
|
310
|
+
RecentDoc(
|
|
311
|
+
title=str(title),
|
|
312
|
+
source_kind=str(kind) if kind is not None else None,
|
|
313
|
+
display_date=display_date,
|
|
314
|
+
vault_path=str(vault_path),
|
|
315
|
+
)
|
|
316
|
+
for (title, kind, display_date, vault_path) in rows
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _render_bullets(docs: Sequence[RecentDoc]) -> str:
|
|
321
|
+
"""Render the recent-rail markdown body (bullets, no fence markers).
|
|
322
|
+
|
|
323
|
+
Empty corpus → a single italic placeholder line so the rail has *some*
|
|
324
|
+
visible content rather than collapsing to a blank gap. The placeholder
|
|
325
|
+
uses ``*…*`` (markdown italic) which renders as muted text in the
|
|
326
|
+
Linear-style theme — gentler than a hard "(no docs)" string.
|
|
327
|
+
|
|
328
|
+
Each non-empty line is shaped:
|
|
329
|
+
|
|
330
|
+
``- {icon} [[<vault_path-without-md>|<safe_title>]] · <span ...>{abs}</span>``
|
|
331
|
+
|
|
332
|
+
where the trailing token is a machine-readable relative-date span:
|
|
333
|
+
|
|
334
|
+
``<span class="brain-rel-date" data-date="YYYY-MM-DD">{absolute}</span>``
|
|
335
|
+
|
|
336
|
+
and:
|
|
337
|
+
|
|
338
|
+
- ``{icon}`` comes from :data:`_SOURCE_ICONS` (with the ``"vault"``
|
|
339
|
+
fallback for unknown kinds).
|
|
340
|
+
- ``<vault_path-without-md>`` is the doc's ``vault_path`` with a
|
|
341
|
+
trailing ``.md`` stripped — matches the canonical wiki-link target
|
|
342
|
+
shape the link rewriter emits, so a future ``brain vault sync`` pass
|
|
343
|
+
over the home note doesn't churn this body.
|
|
344
|
+
- ``<safe_title>`` strips wiki-link-breaking ``[`` / ``]`` from the
|
|
345
|
+
alias slot. Quartz's wiki-link regex defines aliases as
|
|
346
|
+
``[^\\[\\]\\#]``; bracketed prefixes like ``Re: [External] Re: …``
|
|
347
|
+
from Gmail subjects would otherwise emit raw text. Same trick the
|
|
348
|
+
derived-edges fence uses.
|
|
349
|
+
- ``{cal_date}`` is :func:`_recent_calendar_date` of ``display_date`` —
|
|
350
|
+
a plain ``YYYY-MM-DD`` calendar date (NOT a full ISO timestamp), the
|
|
351
|
+
machine-readable source of truth the client script
|
|
352
|
+
(``/static/relativeDate.js``) reads to recompute the relative text
|
|
353
|
+
("today" / "3d ago" / …) live on every page load. ``display_date`` is
|
|
354
|
+
the doc's content/event date (``COALESCE(doc_date, ingested_at)``), so a
|
|
355
|
+
meeting held last week but ingested today renders "1w ago", not "today".
|
|
356
|
+
Emitting a plain calendar date (parsed client-side as a LOCAL naive
|
|
357
|
+
date) avoids the off-by-one timezone drift that a full ISO timestamp
|
|
358
|
+
caused for UTC-midnight date-only docs (Krisp meetings, ``--date``
|
|
359
|
+
ingests). Baking a relative string here would decay: a doc 3 days old at
|
|
360
|
+
build time still reads "3d ago" weeks later because the home note isn't
|
|
361
|
+
re-rendered daily. Emitting the calendar date + recomputing client-side
|
|
362
|
+
keeps the rail honest.
|
|
363
|
+
- ``{absolute}`` is :func:`_format_absolute_date` of the computed
|
|
364
|
+
calendar date — a NON-decaying fallback (e.g. ``"Jun 11"``) shown
|
|
365
|
+
verbatim if the client script never runs (JS disabled, parse failure).
|
|
366
|
+
Both the ``data-date`` attribute and the absolute fallback are
|
|
367
|
+
machine-generated (no user content), so neither needs HTML-escaping.
|
|
368
|
+
|
|
369
|
+
Trailing newline so the body always ends Unix-cleanly.
|
|
370
|
+
"""
|
|
371
|
+
if not docs:
|
|
372
|
+
return "*No documents ingested yet — try `brain ingest <file>`.*\n"
|
|
373
|
+
|
|
374
|
+
lines: list[str] = []
|
|
375
|
+
for doc in docs:
|
|
376
|
+
icon = _SOURCE_ICONS.get(doc.source_kind or "vault", _DEFAULT_SOURCE_ICON)
|
|
377
|
+
target = strip_md_extension(doc.vault_path)
|
|
378
|
+
alias = safe_wikilink_alias(doc.title)
|
|
379
|
+
cal_date = _recent_calendar_date(doc.display_date)
|
|
380
|
+
# ``date.isoformat()`` yields ``YYYY-MM-DD`` — a plain calendar date,
|
|
381
|
+
# no time component, no tz offset. The client parses it as a local
|
|
382
|
+
# naive date, so no timezone shift occurs on either side.
|
|
383
|
+
iso = cal_date.isoformat()
|
|
384
|
+
absolute = _format_absolute_date(cal_date)
|
|
385
|
+
span = f'<span class="brain-rel-date" data-date="{iso}">{absolute}</span>'
|
|
386
|
+
lines.append(f"- {icon} [[{target}|{alias}]] · {span}")
|
|
387
|
+
return "\n".join(lines) + "\n"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _replace_fence(body: str, new_inner: str) -> str | None:
|
|
391
|
+
"""Return ``body`` with the fence's inner content swapped for ``new_inner``.
|
|
392
|
+
|
|
393
|
+
Mirrors the contract of
|
|
394
|
+
:func:`brain.vault.derived_links.fence.extract_fence` but reduced to a
|
|
395
|
+
single-shot replace: we don't need the partial-extract API surface
|
|
396
|
+
here (the partial file is the inspection surface; the fence is purely
|
|
397
|
+
in-place).
|
|
398
|
+
|
|
399
|
+
Behavior:
|
|
400
|
+
|
|
401
|
+
- Both markers present and well-ordered (START before END) → return
|
|
402
|
+
``<prefix><START>\\n<new_inner><END><suffix>`` (note the END marker
|
|
403
|
+
is appended directly after the inner content; ``new_inner`` already
|
|
404
|
+
ends with ``\\n`` so the END line lands on its own line).
|
|
405
|
+
- Markers missing or inverted (END before START) → return ``None``.
|
|
406
|
+
The caller logs a warning and skips the rewrite.
|
|
407
|
+
- Multiple START or END markers — only the first START and the
|
|
408
|
+
first END after it are anchors. Strays stay in the surrounding
|
|
409
|
+
body as text; corruption recovery is then a re-render away.
|
|
410
|
+
|
|
411
|
+
Idempotency is the caller's job (compare returned body to input).
|
|
412
|
+
"""
|
|
413
|
+
start_idx = body.find(FENCE_START_MARKER)
|
|
414
|
+
if start_idx == -1:
|
|
415
|
+
return None
|
|
416
|
+
end_search_from = start_idx + len(FENCE_START_MARKER)
|
|
417
|
+
end_idx = body.find(FENCE_END_MARKER, end_search_from)
|
|
418
|
+
if end_idx == -1:
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
prefix = body[: start_idx + len(FENCE_START_MARKER)]
|
|
422
|
+
suffix = body[end_idx:]
|
|
423
|
+
# ``new_inner`` always ends with ``\n`` (per :func:`_render_bullets`),
|
|
424
|
+
# so the END marker lands on its own line. The start marker is
|
|
425
|
+
# followed by exactly one newline so the bullets begin on the next
|
|
426
|
+
# row, matching how the user would hand-author the fence.
|
|
427
|
+
return f"{prefix}\n{new_inner}{suffix}"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _format_relative_date(
|
|
431
|
+
when: datetime.datetime, *, today: datetime.date
|
|
432
|
+
) -> str:
|
|
433
|
+
"""Render ``when`` as a coarse human-friendly relative date.
|
|
434
|
+
|
|
435
|
+
PARITY REFERENCE — NOT dead code. As of the live-relative-date change
|
|
436
|
+
the renderer no longer calls this: :func:`_render_bullets` emits an
|
|
437
|
+
absolute date + a machine-readable ``data-date`` span, and the browser
|
|
438
|
+
(``quartz/static/relativeDate.js``) recomputes the relative bucket on
|
|
439
|
+
every page load. This function is now exercised only by the parity
|
|
440
|
+
tests in ``tests/test_brain_recent_homepage.py`` and MUST stay in
|
|
441
|
+
lock-step with ``relativeDate.js``'s bucket logic — keep it as the
|
|
442
|
+
canonical Python reference; do NOT delete it as "unused."
|
|
443
|
+
|
|
444
|
+
Buckets, in order:
|
|
445
|
+
|
|
446
|
+
- same calendar day → ``"today"``
|
|
447
|
+
- 1–6 days ago → ``"1d ago"`` … ``"6d ago"``
|
|
448
|
+
- 1–4 weeks ago → ``"1w ago"`` … ``"4w ago"``
|
|
449
|
+
- 5+ weeks ago → ``"Apr 27"`` style (locale-independent ``%b %-d``)
|
|
450
|
+
|
|
451
|
+
Calendar-day comparison (not 24h windows) so a doc ingested at 23:59
|
|
452
|
+
yesterday and another at 00:01 today both render as expected — naive
|
|
453
|
+
elapsed-seconds bucketing would mis-classify the boundary.
|
|
454
|
+
|
|
455
|
+
Future dates (clock skew, manual ``ingested_at`` overrides) bucket as
|
|
456
|
+
``"today"`` — a recent rail with a pretend-future bullet shouldn't
|
|
457
|
+
surface ``"-3d ago"``.
|
|
458
|
+
|
|
459
|
+
The calendar-day delta is computed from :func:`_recent_calendar_date`
|
|
460
|
+
(NOT a plain local projection) so this parity reference applies the same
|
|
461
|
+
UTC-midnight-vs-local rule the rendered span carries — keeping it in
|
|
462
|
+
lock-step with what ``relativeDate.js`` recomputes from the emitted
|
|
463
|
+
``data-date``.
|
|
464
|
+
"""
|
|
465
|
+
when_date = _recent_calendar_date(when)
|
|
466
|
+
delta = (today - when_date).days
|
|
467
|
+
if delta <= 0:
|
|
468
|
+
return "today"
|
|
469
|
+
if delta < 7:
|
|
470
|
+
return f"{delta}d ago"
|
|
471
|
+
if delta < 35:
|
|
472
|
+
return f"{delta // 7}w ago"
|
|
473
|
+
# 35+ days ago → the same absolute "Mon D" form the recent-rail span
|
|
474
|
+
# falls back to. Delegated to :func:`_format_absolute_date` so the two
|
|
475
|
+
# surfaces (this >= 35-day branch and the client-side span fallback)
|
|
476
|
+
# stay byte-identical without a second copy of the portable day-build.
|
|
477
|
+
return _format_absolute_date(when_date)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _format_absolute_date(cal_date: datetime.date) -> str:
|
|
481
|
+
"""Render an already-computed calendar date as a portable ``"Mon D"``.
|
|
482
|
+
|
|
483
|
+
Example: ``date(2026, 6, 11)`` → ``"Jun 11"`` (no leading zero on the
|
|
484
|
+
day). This is the NON-decaying fallback baked into the recent-rail span's
|
|
485
|
+
text content — the client script overwrites it with a live relative
|
|
486
|
+
string, but if JS never runs (disabled, parse failure) the absolute date
|
|
487
|
+
is what the reader sees.
|
|
488
|
+
|
|
489
|
+
``%-d`` is GNU/BSD-specific (no leading zero); on Windows the right
|
|
490
|
+
spelling is ``%#d``. Both are absent from POSIX. Build the day ourselves
|
|
491
|
+
from ``.day`` (an ``int``) to stay portable across hosts.
|
|
492
|
+
|
|
493
|
+
Takes the calendar ``date`` directly (computed by
|
|
494
|
+
:func:`_recent_calendar_date`) so the absolute fallback and the relative
|
|
495
|
+
text agree on which calendar day a doc lands on — no second projection,
|
|
496
|
+
no chance of drift.
|
|
497
|
+
"""
|
|
498
|
+
month = cal_date.strftime("%b")
|
|
499
|
+
return f"{month} {cal_date.day}"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _recent_calendar_date(when: datetime.datetime) -> datetime.date:
|
|
503
|
+
"""Project a doc's ``display_date`` onto the calendar date to display.
|
|
504
|
+
|
|
505
|
+
Two kinds of timestamp flow through the recent rail and they want
|
|
506
|
+
different projections:
|
|
507
|
+
|
|
508
|
+
- DATE-ONLY content — Krisp meeting dates and ``--date`` ingests are
|
|
509
|
+
stored as UTC-midnight date-only values (e.g.
|
|
510
|
+
``2026-06-11T00:00:00+00:00``). These carry no real time-of-day; the
|
|
511
|
+
intent is purely "June 11". Projecting them to LOCAL time shifts the
|
|
512
|
+
date back a day in any negative-offset zone (UTC−7 → "Jun 10"), the
|
|
513
|
+
off-by-one bug this fixes. For these, use the **UTC** calendar date
|
|
514
|
+
verbatim — no local shift.
|
|
515
|
+
- REAL timestamps — Gmail ``sent_at`` and note ``ingested_at`` are true
|
|
516
|
+
wall-clock instants. A doc sent at 23:30 UTC genuinely belongs to the
|
|
517
|
+
reader's local "tomorrow" in a positive-offset zone, so these keep the
|
|
518
|
+
LOCAL projection (``astimezone().date()``), the original behavior.
|
|
519
|
+
|
|
520
|
+
The heuristic — "exactly 00:00:00.000000 in UTC ⇒ date-only" — is safe
|
|
521
|
+
because real timestamps essentially never land on exact UTC midnight:
|
|
522
|
+
``ingested_at`` is a microsecond-resolution clock and Gmail send-times
|
|
523
|
+
carry seconds. The worst-case misfire is a 1-day display difference on an
|
|
524
|
+
astronomically rare exact-UTC-midnight email; the upside is correct dates
|
|
525
|
+
for every Krisp call and ``--date`` ingest.
|
|
526
|
+
|
|
527
|
+
Naive (tz-less) datetimes are treated as already-local and projected via
|
|
528
|
+
``.date()`` directly — they predate the TIMESTAMPTZ era and the UTC
|
|
529
|
+
heuristic can't apply without a tzinfo.
|
|
530
|
+
"""
|
|
531
|
+
if when.tzinfo is None:
|
|
532
|
+
return when.date()
|
|
533
|
+
as_utc = when.astimezone(datetime.UTC)
|
|
534
|
+
if as_utc.hour == 0 and as_utc.minute == 0 and as_utc.second == 0 and (
|
|
535
|
+
as_utc.microsecond == 0
|
|
536
|
+
):
|
|
537
|
+
# Date-only content: trust the UTC calendar date, no local shift.
|
|
538
|
+
return as_utc.date()
|
|
539
|
+
# Real timestamp: project onto the reader's local calendar date.
|
|
540
|
+
return when.astimezone().date()
|
|
541
|
+
|