sessionwiki 2.0.0__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.
- sessionwiki/__init__.py +74 -0
- sessionwiki/__main__.py +6 -0
- sessionwiki/_frontmatter.py +118 -0
- sessionwiki/_system_pages.py +44 -0
- sessionwiki/adapter_config.py +124 -0
- sessionwiki/adapters/__init__.py +134 -0
- sessionwiki/adapters/base.py +191 -0
- sessionwiki/adapters/claude_code.py +77 -0
- sessionwiki/adapters/codex_cli.py +226 -0
- sessionwiki/adapters/contrib/__init__.py +1 -0
- sessionwiki/adapters/contrib/chatgpt.py +252 -0
- sessionwiki/adapters/contrib/copilot_chat.py +121 -0
- sessionwiki/adapters/contrib/copilot_cli.py +115 -0
- sessionwiki/adapters/contrib/cursor.py +100 -0
- sessionwiki/adapters/contrib/gemini_cli.py +74 -0
- sessionwiki/adapters/contrib/obsidian.py +121 -0
- sessionwiki/adapters/opencode.py +169 -0
- sessionwiki/adapters/status.py +58 -0
- sessionwiki/adapters/zcode.py +45 -0
- sessionwiki/audit.py +174 -0
- sessionwiki/backlinks.py +224 -0
- sessionwiki/build.py +2786 -0
- sessionwiki/cache.py +401 -0
- sessionwiki/candidates.py +309 -0
- sessionwiki/categories.py +158 -0
- sessionwiki/changelog_timeline.py +422 -0
- sessionwiki/cli.py +1687 -0
- sessionwiki/compare.py +404 -0
- sessionwiki/completion.py +191 -0
- sessionwiki/confidence.py +179 -0
- sessionwiki/config.py +74 -0
- sessionwiki/config_schedule.py +77 -0
- sessionwiki/context_md.py +182 -0
- sessionwiki/convert.py +1746 -0
- sessionwiki/docs_pages.py +725 -0
- sessionwiki/doctor.py +113 -0
- sessionwiki/exporters.py +680 -0
- sessionwiki/freshness.py +153 -0
- sessionwiki/graph.py +1058 -0
- sessionwiki/graphify_bridge.py +509 -0
- sessionwiki/ingest_queue.py +101 -0
- sessionwiki/lifecycle.py +167 -0
- sessionwiki/link_checker.py +148 -0
- sessionwiki/lint/__init__.py +170 -0
- sessionwiki/lint/rules/__init__.py +69 -0
- sessionwiki/lint/rules/_helpers.py +119 -0
- sessionwiki/lint/rules/claim_verification.py +44 -0
- sessionwiki/lint/rules/content_freshness.py +41 -0
- sessionwiki/lint/rules/contradiction_detection.py +45 -0
- sessionwiki/lint/rules/duplicate_detection.py +164 -0
- sessionwiki/lint/rules/entity_consistency.py +29 -0
- sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
- sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
- sessionwiki/lint/rules/frontmatter_validity.py +71 -0
- sessionwiki/lint/rules/index_sync.py +68 -0
- sessionwiki/lint/rules/link_integrity.py +34 -0
- sessionwiki/lint/rules/orphan_detection.py +43 -0
- sessionwiki/lint/rules/stale_candidates.py +59 -0
- sessionwiki/lint/rules/stale_reference_detection.py +42 -0
- sessionwiki/lint/rules/summary_accuracy.py +36 -0
- sessionwiki/lint/rules/tags_topics_convention.py +58 -0
- sessionwiki/lint/rules/tools_consistency.py +65 -0
- sessionwiki/lock.py +126 -0
- sessionwiki/log_reader.py +129 -0
- sessionwiki/manifest.py +132 -0
- sessionwiki/mcp/__init__.py +29 -0
- sessionwiki/mcp/__main__.py +5 -0
- sessionwiki/mcp/server.py +1061 -0
- sessionwiki/models_page.py +291 -0
- sessionwiki/obsidian_output.py +208 -0
- sessionwiki/opencode/__init__.py +1 -0
- sessionwiki/opencode/autosync.py +102 -0
- sessionwiki/opencode/export.py +419 -0
- sessionwiki/opencode/install.py +152 -0
- sessionwiki/pipeline.py +150 -0
- sessionwiki/project_topics.py +213 -0
- sessionwiki/py.typed +0 -0
- sessionwiki/quarantine.py +273 -0
- sessionwiki/queue.py +29 -0
- sessionwiki/references.py +224 -0
- sessionwiki/render/__init__.py +20 -0
- sessionwiki/render/css.py +988 -0
- sessionwiki/render/docs_css.py +463 -0
- sessionwiki/render/js.py +1337 -0
- sessionwiki/schema.py +270 -0
- sessionwiki/search_facets.py +150 -0
- sessionwiki/search_tree.py +184 -0
- sessionwiki/serve.py +91 -0
- sessionwiki/session_export.py +333 -0
- sessionwiki/skill_installer.py +112 -0
- sessionwiki/sync/__init__.py +13 -0
- sessionwiki/sync/status.py +120 -0
- sessionwiki/synth/__init__.py +14 -0
- sessionwiki/synth/agent_delegate.py +327 -0
- sessionwiki/synth/base.py +125 -0
- sessionwiki/synth/cli_helpers.py +98 -0
- sessionwiki/synth/estimate.py +146 -0
- sessionwiki/synth/ollama.py +379 -0
- sessionwiki/synth/openai_compat.py +325 -0
- sessionwiki/synth/pipeline.py +1489 -0
- sessionwiki/tag_utils.py +62 -0
- sessionwiki/tags.py +359 -0
- sessionwiki/vault.py +347 -0
- sessionwiki/viz_heatmap.py +305 -0
- sessionwiki/viz_tokens.py +429 -0
- sessionwiki/viz_tools.py +241 -0
- sessionwiki/watch.py +154 -0
- sessionwiki/zcode/__init__.py +43 -0
- sessionwiki/zcode/autosync.py +150 -0
- sessionwiki/zcode/export.py +568 -0
- sessionwiki/zcode/install.py +386 -0
- sessionwiki-2.0.0.dist-info/METADATA +303 -0
- sessionwiki-2.0.0.dist-info/RECORD +118 -0
- sessionwiki-2.0.0.dist-info/WHEEL +5 -0
- sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
- sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
- sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
- sessionwiki-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Candidate approval workflow (v1.1.0 · #51).
|
|
2
|
+
|
|
3
|
+
New entity/concept pages created by `/wiki-ingest` land in
|
|
4
|
+
``wiki/candidates/`` first with ``status: candidate`` frontmatter.
|
|
5
|
+
A human then runs `/wiki-candidates` to promote, merge, or discard each
|
|
6
|
+
one. Promoted pages move into ``wiki/entities/`` or ``wiki/concepts/``.
|
|
7
|
+
Discarded candidates are archived under ``wiki/archive/candidates/``
|
|
8
|
+
for audit.
|
|
9
|
+
|
|
10
|
+
Rationale: hallucinated entities ("CompanyX" that doesn't exist) should
|
|
11
|
+
not land in the trusted wiki layer without human review.
|
|
12
|
+
|
|
13
|
+
Public API:
|
|
14
|
+
- ``list_candidates(wiki_dir)`` → list of Candidate dicts
|
|
15
|
+
- ``promote(slug, wiki_dir, dest)`` → move candidate into trusted area
|
|
16
|
+
- ``merge(slug, wiki_dir, into_slug)`` → fold candidate into an existing page
|
|
17
|
+
- ``discard(slug, wiki_dir, reason)`` → move to archive/
|
|
18
|
+
- ``stale_candidates(wiki_dir, threshold_days=30)`` → list pages flagged stale
|
|
19
|
+
- ``is_candidate(page_path)`` → bool
|
|
20
|
+
|
|
21
|
+
Design choices:
|
|
22
|
+
- Separate ``candidates/`` mirror tree (vs status field only) so the
|
|
23
|
+
build step can cleanly exclude them from the public site by default.
|
|
24
|
+
- ``## Connections`` links from candidates stay as-is when promoted;
|
|
25
|
+
callers run `sessionwiki lint` afterward to catch any stale pointers.
|
|
26
|
+
- Discard is non-destructive: pages move to ``wiki/archive/candidates/``
|
|
27
|
+
with a timestamped reason file so you can recover them later.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
import shutil
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Optional, TypedDict
|
|
37
|
+
|
|
38
|
+
# ─── constants ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
CANDIDATES_DIR_NAME = "candidates"
|
|
41
|
+
ARCHIVE_DIR_NAME = "archive"
|
|
42
|
+
ARCHIVED_CANDIDATES_SUBDIR = "candidates"
|
|
43
|
+
|
|
44
|
+
# Subfolders mirrored under wiki/candidates/
|
|
45
|
+
MIRRORED_SUBDIRS = ["entities", "concepts", "sources", "syntheses"]
|
|
46
|
+
|
|
47
|
+
# Default staleness threshold (days)
|
|
48
|
+
DEFAULT_STALE_DAYS = 30
|
|
49
|
+
|
|
50
|
+
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ─── types ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
class Candidate(TypedDict):
|
|
56
|
+
"""Info about one candidate page waiting for review."""
|
|
57
|
+
|
|
58
|
+
slug: str # bare filename stem (e.g. "NewEntity")
|
|
59
|
+
rel_path: str # path relative to wiki/ (e.g. "candidates/entities/NewEntity.md")
|
|
60
|
+
abs_path: Path # absolute path to the file
|
|
61
|
+
kind: str # "entities" | "concepts" | "sources" | "syntheses"
|
|
62
|
+
title: str # frontmatter title
|
|
63
|
+
created: Optional[str] # frontmatter created/last_updated date (YYYY-MM-DD)
|
|
64
|
+
age_days: int # days since `created`
|
|
65
|
+
body_preview: str # first 200 chars of body
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ─── helpers ───────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
71
|
+
"""Return (meta_dict, body)."""
|
|
72
|
+
m = FRONTMATTER_RE.match(text)
|
|
73
|
+
if not m:
|
|
74
|
+
return {}, text
|
|
75
|
+
out: dict[str, str] = {}
|
|
76
|
+
for line in m.group(1).splitlines():
|
|
77
|
+
if ":" not in line:
|
|
78
|
+
continue
|
|
79
|
+
k, _, v = line.partition(":")
|
|
80
|
+
out[k.strip()] = v.strip().strip('"')
|
|
81
|
+
return out, m.group(2)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _age_days(date_str: Optional[str], *, now: Optional[datetime] = None) -> int:
|
|
85
|
+
"""Compute days between ``date_str`` (YYYY-MM-DD) and now."""
|
|
86
|
+
if not date_str:
|
|
87
|
+
return 0
|
|
88
|
+
try:
|
|
89
|
+
dt = datetime.fromisoformat(date_str)
|
|
90
|
+
if dt.tzinfo is None:
|
|
91
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
92
|
+
except (ValueError, TypeError):
|
|
93
|
+
return 0
|
|
94
|
+
ref = now or datetime.now(timezone.utc)
|
|
95
|
+
return max(0, (ref - dt).days)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_candidate(page_path: Path) -> bool:
|
|
99
|
+
"""True if the path is inside wiki/candidates/ subtree."""
|
|
100
|
+
parts = page_path.parts
|
|
101
|
+
return CANDIDATES_DIR_NAME in parts
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def candidates_dir(wiki_dir: Path) -> Path:
|
|
105
|
+
"""Return wiki/candidates/ (creates parent if needed)."""
|
|
106
|
+
return wiki_dir / CANDIDATES_DIR_NAME
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def archive_dir(wiki_dir: Path) -> Path:
|
|
110
|
+
"""Return wiki/archive/candidates/."""
|
|
111
|
+
return wiki_dir / ARCHIVE_DIR_NAME / ARCHIVED_CANDIDATES_SUBDIR
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ─── public API ────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def list_candidates(
|
|
118
|
+
wiki_dir: Path,
|
|
119
|
+
*,
|
|
120
|
+
now: Optional[datetime] = None,
|
|
121
|
+
) -> list[Candidate]:
|
|
122
|
+
"""Walk wiki/candidates/ and return one entry per pending page."""
|
|
123
|
+
root = candidates_dir(wiki_dir)
|
|
124
|
+
if not root.is_dir():
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
out: list[Candidate] = []
|
|
128
|
+
for sub in MIRRORED_SUBDIRS:
|
|
129
|
+
sub_dir = root / sub
|
|
130
|
+
if not sub_dir.is_dir():
|
|
131
|
+
continue
|
|
132
|
+
for path in sorted(sub_dir.glob("*.md")):
|
|
133
|
+
if path.name == "_context.md":
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
text = path.read_text(encoding="utf-8")
|
|
137
|
+
except OSError:
|
|
138
|
+
continue
|
|
139
|
+
meta, body = _parse_frontmatter(text)
|
|
140
|
+
created = meta.get("last_updated") or meta.get("date")
|
|
141
|
+
out.append({
|
|
142
|
+
"slug": path.stem,
|
|
143
|
+
"rel_path": str(path.relative_to(wiki_dir)),
|
|
144
|
+
"abs_path": path,
|
|
145
|
+
"kind": sub,
|
|
146
|
+
"title": meta.get("title", path.stem),
|
|
147
|
+
"created": created,
|
|
148
|
+
"age_days": _age_days(created, now=now),
|
|
149
|
+
"body_preview": body.strip()[:200],
|
|
150
|
+
})
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def promote(
|
|
155
|
+
slug: str,
|
|
156
|
+
wiki_dir: Path,
|
|
157
|
+
*,
|
|
158
|
+
kind: Optional[str] = None,
|
|
159
|
+
) -> Path:
|
|
160
|
+
"""Move ``wiki/candidates/<kind>/<slug>.md`` → ``wiki/<kind>/<slug>.md``.
|
|
161
|
+
|
|
162
|
+
If ``kind`` is omitted, infers from where the candidate lives. Rewrites
|
|
163
|
+
the frontmatter ``status:`` from ``candidate`` → ``reviewed`` so the
|
|
164
|
+
lifecycle rule picks it up.
|
|
165
|
+
|
|
166
|
+
Returns the new (promoted) path. Raises FileNotFoundError if the
|
|
167
|
+
candidate does not exist.
|
|
168
|
+
"""
|
|
169
|
+
candidate = _find_candidate(slug, wiki_dir, kind)
|
|
170
|
+
inferred_kind = candidate.parent.name
|
|
171
|
+
target_dir = wiki_dir / inferred_kind
|
|
172
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
target = target_dir / candidate.name
|
|
174
|
+
|
|
175
|
+
text = candidate.read_text(encoding="utf-8")
|
|
176
|
+
text = _rewrite_status(text, old="candidate", new="reviewed")
|
|
177
|
+
target.write_text(text, encoding="utf-8")
|
|
178
|
+
candidate.unlink()
|
|
179
|
+
return target
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def merge(
|
|
183
|
+
slug: str,
|
|
184
|
+
wiki_dir: Path,
|
|
185
|
+
*,
|
|
186
|
+
into_slug: str,
|
|
187
|
+
kind: Optional[str] = None,
|
|
188
|
+
) -> Path:
|
|
189
|
+
"""Append the candidate's body under a ``## Candidate merge — <date>``
|
|
190
|
+
heading into the existing wiki page ``<into_slug>.md``, then discard
|
|
191
|
+
the candidate.
|
|
192
|
+
|
|
193
|
+
Returns the path of the target page. Raises FileNotFoundError if either
|
|
194
|
+
page is missing.
|
|
195
|
+
"""
|
|
196
|
+
candidate = _find_candidate(slug, wiki_dir, kind)
|
|
197
|
+
inferred_kind = candidate.parent.name
|
|
198
|
+
target = wiki_dir / inferred_kind / f"{into_slug}.md"
|
|
199
|
+
if not target.is_file():
|
|
200
|
+
raise FileNotFoundError(
|
|
201
|
+
f"merge target not found: {target} (candidate={candidate})"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
candidate_text = candidate.read_text(encoding="utf-8")
|
|
205
|
+
_, candidate_body = _parse_frontmatter(candidate_text)
|
|
206
|
+
|
|
207
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
208
|
+
appended = (
|
|
209
|
+
target.read_text(encoding="utf-8").rstrip() +
|
|
210
|
+
f"\n\n## Candidate merge — {today}\n\n" +
|
|
211
|
+
f"Merged from `{candidate.relative_to(wiki_dir)}`:\n\n" +
|
|
212
|
+
candidate_body.strip() + "\n"
|
|
213
|
+
)
|
|
214
|
+
target.write_text(appended, encoding="utf-8")
|
|
215
|
+
|
|
216
|
+
# Discard candidate by moving it to archive with a merge-reason file
|
|
217
|
+
_archive_candidate(candidate, wiki_dir, reason=f"merged into {into_slug}")
|
|
218
|
+
return target
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def discard(
|
|
222
|
+
slug: str,
|
|
223
|
+
wiki_dir: Path,
|
|
224
|
+
*,
|
|
225
|
+
reason: str = "",
|
|
226
|
+
kind: Optional[str] = None,
|
|
227
|
+
) -> Path:
|
|
228
|
+
"""Move the candidate to ``wiki/archive/candidates/<timestamp>/<slug>.md``
|
|
229
|
+
with an adjacent ``<slug>.reason.txt`` capturing why.
|
|
230
|
+
|
|
231
|
+
Returns the archived path.
|
|
232
|
+
"""
|
|
233
|
+
candidate = _find_candidate(slug, wiki_dir, kind)
|
|
234
|
+
return _archive_candidate(candidate, wiki_dir, reason=reason)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def stale_candidates(
|
|
238
|
+
wiki_dir: Path,
|
|
239
|
+
*,
|
|
240
|
+
threshold_days: int = DEFAULT_STALE_DAYS,
|
|
241
|
+
now: Optional[datetime] = None,
|
|
242
|
+
) -> list[Candidate]:
|
|
243
|
+
"""Return candidates older than ``threshold_days``."""
|
|
244
|
+
return [
|
|
245
|
+
c for c in list_candidates(wiki_dir, now=now)
|
|
246
|
+
if c["age_days"] >= threshold_days
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ─── internals ─────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _find_candidate(
|
|
254
|
+
slug: str,
|
|
255
|
+
wiki_dir: Path,
|
|
256
|
+
kind: Optional[str],
|
|
257
|
+
) -> Path:
|
|
258
|
+
"""Locate ``<slug>.md`` under wiki/candidates/, optionally filtered by kind."""
|
|
259
|
+
root = candidates_dir(wiki_dir)
|
|
260
|
+
subs = [kind] if kind else MIRRORED_SUBDIRS
|
|
261
|
+
for sub in subs:
|
|
262
|
+
path = root / sub / f"{slug}.md"
|
|
263
|
+
if path.is_file():
|
|
264
|
+
return path
|
|
265
|
+
raise FileNotFoundError(
|
|
266
|
+
f"candidate not found: {slug!r} under {root}"
|
|
267
|
+
+ (f" (kind={kind})" if kind else "")
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _rewrite_status(text: str, *, old: str, new: str) -> str:
|
|
272
|
+
"""Replace ``status: <old>`` with ``status: <new>`` in frontmatter."""
|
|
273
|
+
pattern = re.compile(
|
|
274
|
+
rf"^(status:\s*){re.escape(old)}(\s*)$",
|
|
275
|
+
re.MULTILINE,
|
|
276
|
+
)
|
|
277
|
+
if pattern.search(text):
|
|
278
|
+
return pattern.sub(rf"\g<1>{new}\g<2>", text)
|
|
279
|
+
# Add status line to frontmatter if missing
|
|
280
|
+
m = FRONTMATTER_RE.match(text)
|
|
281
|
+
if m:
|
|
282
|
+
new_fm = m.group(1) + f"\nstatus: {new}"
|
|
283
|
+
return f"---\n{new_fm}\n---\n{m.group(2)}"
|
|
284
|
+
return text
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _archive_candidate(
|
|
288
|
+
candidate: Path,
|
|
289
|
+
wiki_dir: Path,
|
|
290
|
+
*,
|
|
291
|
+
reason: str,
|
|
292
|
+
) -> Path:
|
|
293
|
+
"""Move candidate into archive with reason file."""
|
|
294
|
+
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
|
|
295
|
+
dest_dir = archive_dir(wiki_dir) / stamp
|
|
296
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
|
|
298
|
+
dest = dest_dir / candidate.name
|
|
299
|
+
shutil.move(str(candidate), str(dest))
|
|
300
|
+
|
|
301
|
+
if reason:
|
|
302
|
+
reason_file = dest.with_suffix(".reason.txt")
|
|
303
|
+
reason_file.write_text(
|
|
304
|
+
f"Discarded at: {datetime.now(timezone.utc).isoformat()}\n"
|
|
305
|
+
f"Reason: {reason}\n"
|
|
306
|
+
f"Original path: candidates/{candidate.parent.name}/{candidate.name}\n",
|
|
307
|
+
encoding="utf-8",
|
|
308
|
+
)
|
|
309
|
+
return dest
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Category page generator (v1.0 · #154).
|
|
2
|
+
|
|
3
|
+
Generates per-tag index pages (`wiki/categories/<tag>.md`) from the
|
|
4
|
+
`tags:` field in wiki page frontmatter.
|
|
5
|
+
|
|
6
|
+
Two output modes:
|
|
7
|
+
|
|
8
|
+
- **Dataview** (``generate_dataview_categories``) — pages contain a
|
|
9
|
+
live Dataview query that Obsidian renders dynamically.
|
|
10
|
+
- **Static** (``generate_static_categories``) — pages contain a
|
|
11
|
+
pre-rendered markdown list for use in the static HTML site.
|
|
12
|
+
|
|
13
|
+
Both modes work from the same page scan; the user picks which mode
|
|
14
|
+
fits their workflow (Obsidian users → Dataview; GitHub Pages users →
|
|
15
|
+
static).
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
from sessionwiki.categories import scan_tags, generate_static_categories
|
|
20
|
+
pages = load_pages()
|
|
21
|
+
counts = scan_tags(pages)
|
|
22
|
+
generate_static_categories(pages, out_dir, min_count=2)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
# Shared tag parser + NOISE_TAGS live in sessionwiki.tag_utils so
|
|
32
|
+
# sessionwiki/search_facets.py uses the same implementation.
|
|
33
|
+
from sessionwiki.tag_utils import NOISE_TAGS, parse_tags_field as _parse_tags, scan_tags
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ─── Dataview output ──────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def dataview_page(tag: str) -> str:
|
|
39
|
+
"""Render the Dataview query page body for a tag."""
|
|
40
|
+
return f"""---
|
|
41
|
+
title: "Category: {tag}"
|
|
42
|
+
type: navigation
|
|
43
|
+
tag: {tag}
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
# Category: {tag}
|
|
47
|
+
|
|
48
|
+
Pages tagged with `{tag}`.
|
|
49
|
+
|
|
50
|
+
```dataview
|
|
51
|
+
TABLE type, confidence, last_updated
|
|
52
|
+
FROM "sources" OR "entities" OR "concepts" OR "syntheses"
|
|
53
|
+
WHERE contains(tags, "{tag}")
|
|
54
|
+
SORT last_updated DESC
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Connections
|
|
58
|
+
|
|
59
|
+
- [[index]] — full catalog
|
|
60
|
+
- [[dashboard]] — live overview
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ─── Static output ────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def _page_slug(rel: str) -> str:
|
|
67
|
+
return rel.rsplit("/", 1)[-1].removesuffix(".md")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _page_title(pages: dict[str, dict[str, Any]], rel: str) -> str:
|
|
71
|
+
return pages[rel]["meta"].get("title", _page_slug(rel))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def static_page(
|
|
75
|
+
tag: str,
|
|
76
|
+
page_rels: list[str],
|
|
77
|
+
pages: dict[str, dict[str, Any]],
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Render a static category page body for GitHub Pages."""
|
|
80
|
+
lines = [
|
|
81
|
+
"---",
|
|
82
|
+
f'title: "Category: {tag}"',
|
|
83
|
+
"type: navigation",
|
|
84
|
+
f"tag: {tag}",
|
|
85
|
+
"---",
|
|
86
|
+
"",
|
|
87
|
+
f"# Category: {tag}",
|
|
88
|
+
"",
|
|
89
|
+
f"{len(page_rels)} pages tagged with `{tag}`.",
|
|
90
|
+
"",
|
|
91
|
+
]
|
|
92
|
+
# Group by top-level folder
|
|
93
|
+
by_folder: dict[str, list[str]] = {}
|
|
94
|
+
for rel in page_rels:
|
|
95
|
+
folder = rel.split("/", 1)[0] if "/" in rel else "root"
|
|
96
|
+
by_folder.setdefault(folder, []).append(rel)
|
|
97
|
+
|
|
98
|
+
for folder in sorted(by_folder):
|
|
99
|
+
lines.append(f"## {folder}")
|
|
100
|
+
lines.append("")
|
|
101
|
+
for rel in by_folder[folder]:
|
|
102
|
+
slug = _page_slug(rel)
|
|
103
|
+
title = _page_title(pages, rel)
|
|
104
|
+
lines.append(f"- [[{slug}|{title}]]")
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
lines.extend([
|
|
108
|
+
"## Connections",
|
|
109
|
+
"",
|
|
110
|
+
"- [[index]] — full catalog",
|
|
111
|
+
"- [[dashboard]] — live overview",
|
|
112
|
+
"",
|
|
113
|
+
])
|
|
114
|
+
return "\n".join(lines)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ─── Generators ──────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def generate_dataview_categories(
|
|
120
|
+
tags: dict[str, list[str]],
|
|
121
|
+
out_dir: Path,
|
|
122
|
+
*,
|
|
123
|
+
min_count: int = 2,
|
|
124
|
+
) -> list[Path]:
|
|
125
|
+
"""Write Dataview category pages for every tag with >= ``min_count`` pages.
|
|
126
|
+
|
|
127
|
+
Returns the list of written paths.
|
|
128
|
+
"""
|
|
129
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
written: list[Path] = []
|
|
131
|
+
for tag, page_rels in sorted(tags.items()):
|
|
132
|
+
if len(page_rels) < min_count:
|
|
133
|
+
continue
|
|
134
|
+
slug = re.sub(r"[^a-z0-9_-]+", "-", tag)
|
|
135
|
+
path = out_dir / f"{slug}.md"
|
|
136
|
+
path.write_text(dataview_page(tag), encoding="utf-8")
|
|
137
|
+
written.append(path)
|
|
138
|
+
return written
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def generate_static_categories(
|
|
142
|
+
pages: dict[str, dict[str, Any]],
|
|
143
|
+
out_dir: Path,
|
|
144
|
+
*,
|
|
145
|
+
min_count: int = 2,
|
|
146
|
+
) -> list[Path]:
|
|
147
|
+
"""Write static category pages (no Dataview) for every tag."""
|
|
148
|
+
tags = scan_tags(pages)
|
|
149
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
written: list[Path] = []
|
|
151
|
+
for tag, page_rels in sorted(tags.items()):
|
|
152
|
+
if len(page_rels) < min_count:
|
|
153
|
+
continue
|
|
154
|
+
slug = re.sub(r"[^a-z0-9_-]+", "-", tag)
|
|
155
|
+
path = out_dir / f"{slug}.md"
|
|
156
|
+
path.write_text(static_page(tag, page_rels, pages), encoding="utf-8")
|
|
157
|
+
written.append(path)
|
|
158
|
+
return written
|