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
sessionwiki/audit.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Pre-publish privacy audit for raw/, wiki/, and site/.
|
|
2
|
+
|
|
3
|
+
The converter redacts known secrets at ingest time, but generated pages
|
|
4
|
+
and tool output can still contain a username, a home path, or a token
|
|
5
|
+
that appeared after redaction. This command is a read-only second look:
|
|
6
|
+
|
|
7
|
+
sessionwiki audit
|
|
8
|
+
sessionwiki audit --path site --json
|
|
9
|
+
sessionwiki audit --fail-on-findings
|
|
10
|
+
|
|
11
|
+
It never edits files. Exit code is 1 only with ``--fail-on-findings``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterable, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
from sessionwiki import REPO_ROOT
|
|
23
|
+
from sessionwiki.config import resolve_config
|
|
24
|
+
from sessionwiki.convert import _DEFAULT_TOKEN_PATTERNS
|
|
25
|
+
|
|
26
|
+
DEFAULT_RELATIVE_PATHS = ("raw", "wiki", "site")
|
|
27
|
+
TEXT_EXTENSIONS = {
|
|
28
|
+
".md", ".txt", ".json", ".jsonl", ".html", ".htm", ".xml",
|
|
29
|
+
".yml", ".yaml", ".toml", ".css", ".js", ".log", ".csv",
|
|
30
|
+
}
|
|
31
|
+
SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".pytest_cache", ".ruff_cache"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _iter_files(paths: Sequence[Path]) -> Iterable[Path]:
|
|
35
|
+
for base in paths:
|
|
36
|
+
path = Path(base)
|
|
37
|
+
if path.is_file():
|
|
38
|
+
yield path
|
|
39
|
+
continue
|
|
40
|
+
if not path.is_dir():
|
|
41
|
+
continue
|
|
42
|
+
for child in path.rglob("*"):
|
|
43
|
+
if any(part in SKIP_DIRS for part in child.parts):
|
|
44
|
+
continue
|
|
45
|
+
if child.is_file() and child.suffix.lower() in TEXT_EXTENSIONS:
|
|
46
|
+
yield child
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _preview(line: str, start: int, end: int, limit: int = 180) -> str:
|
|
50
|
+
masked = line[:start] + "<MATCH>" + line[end:]
|
|
51
|
+
return " ".join(masked.split())[:limit]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _compile_user_patterns(patterns: Sequence[str]) -> list[re.Pattern[str]]:
|
|
55
|
+
compiled: list[re.Pattern[str]] = []
|
|
56
|
+
for raw in patterns:
|
|
57
|
+
try:
|
|
58
|
+
compiled.append(re.compile(raw))
|
|
59
|
+
except re.error:
|
|
60
|
+
continue
|
|
61
|
+
return compiled
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def audit_paths(
|
|
65
|
+
paths: Sequence[Path],
|
|
66
|
+
*,
|
|
67
|
+
username: Optional[str] = None,
|
|
68
|
+
extra_patterns: Sequence[str] = (),
|
|
69
|
+
max_findings: int = 200,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Scan text files and return findings. Does not modify anything."""
|
|
72
|
+
findings: list[dict[str, Any]] = []
|
|
73
|
+
files_scanned = 0
|
|
74
|
+
secret_patterns = [
|
|
75
|
+
(pattern, "secret") for pattern in _DEFAULT_TOKEN_PATTERNS
|
|
76
|
+
]
|
|
77
|
+
secret_patterns.extend(
|
|
78
|
+
(pattern, "pattern") for pattern in _compile_user_patterns(extra_patterns)
|
|
79
|
+
)
|
|
80
|
+
user_re = re.compile(re.escape(username), re.IGNORECASE) if username else None
|
|
81
|
+
|
|
82
|
+
for file_path in _iter_files(paths):
|
|
83
|
+
if len(findings) >= max_findings:
|
|
84
|
+
break
|
|
85
|
+
try:
|
|
86
|
+
raw = file_path.read_bytes()
|
|
87
|
+
except OSError:
|
|
88
|
+
continue
|
|
89
|
+
if b"\x00" in raw[:4096]:
|
|
90
|
+
continue
|
|
91
|
+
text = raw.decode("utf-8", errors="replace")
|
|
92
|
+
files_scanned += 1
|
|
93
|
+
for line_number, line in enumerate(text.splitlines(), 1):
|
|
94
|
+
if user_re is not None:
|
|
95
|
+
for match in user_re.finditer(line):
|
|
96
|
+
findings.append(
|
|
97
|
+
{
|
|
98
|
+
"path": str(file_path),
|
|
99
|
+
"line": line_number,
|
|
100
|
+
"kind": "username",
|
|
101
|
+
"preview": _preview(line, match.start(), match.end()),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
if len(findings) >= max_findings:
|
|
105
|
+
break
|
|
106
|
+
for pattern, kind in secret_patterns:
|
|
107
|
+
for match in pattern.finditer(line):
|
|
108
|
+
findings.append(
|
|
109
|
+
{
|
|
110
|
+
"path": str(file_path),
|
|
111
|
+
"line": line_number,
|
|
112
|
+
"kind": kind,
|
|
113
|
+
"preview": _preview(line, match.start(), match.end()),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
if len(findings) >= max_findings:
|
|
117
|
+
break
|
|
118
|
+
if len(findings) >= max_findings:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
counts: dict[str, int] = {}
|
|
122
|
+
for finding in findings:
|
|
123
|
+
counts[finding["kind"]] = counts.get(finding["kind"], 0) + 1
|
|
124
|
+
return {
|
|
125
|
+
"files_scanned": files_scanned,
|
|
126
|
+
"username": username or "",
|
|
127
|
+
"findings": findings,
|
|
128
|
+
"counts": counts,
|
|
129
|
+
"truncated": len(findings) >= max_findings,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_audit(args: Any) -> int:
|
|
134
|
+
"""Print the privacy report for the selected paths."""
|
|
135
|
+
configured = resolve_config()
|
|
136
|
+
redaction = configured.get("redaction") if isinstance(configured, dict) else None
|
|
137
|
+
redaction = redaction if isinstance(redaction, dict) else {}
|
|
138
|
+
username = (
|
|
139
|
+
getattr(args, "username", None)
|
|
140
|
+
or redaction.get("real_username")
|
|
141
|
+
or os.environ.get("USER")
|
|
142
|
+
or os.environ.get("USERNAME")
|
|
143
|
+
or ""
|
|
144
|
+
)
|
|
145
|
+
paths = getattr(args, "path", None)
|
|
146
|
+
if not paths:
|
|
147
|
+
paths = [REPO_ROOT / relative for relative in DEFAULT_RELATIVE_PATHS]
|
|
148
|
+
|
|
149
|
+
report = audit_paths(
|
|
150
|
+
paths,
|
|
151
|
+
username=username,
|
|
152
|
+
extra_patterns=redaction.get("extra_patterns") or [],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if getattr(args, "json", False):
|
|
156
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
157
|
+
else:
|
|
158
|
+
print(f"audit: scanned {report['files_scanned']} file(s)")
|
|
159
|
+
print(f"audit: {len(report['findings'])} finding(s)")
|
|
160
|
+
for kind, count in sorted(report["counts"].items()):
|
|
161
|
+
print(f" {kind}: {count}")
|
|
162
|
+
for finding in report["findings"][:50]:
|
|
163
|
+
print(
|
|
164
|
+
f" {finding['path']}:{finding['line']} "
|
|
165
|
+
f"[{finding['kind']}] {finding['preview']}"
|
|
166
|
+
)
|
|
167
|
+
if report["truncated"]:
|
|
168
|
+
print(" … findings truncated")
|
|
169
|
+
if report["findings"]:
|
|
170
|
+
print("audit: review before publishing raw/, wiki/, or site/")
|
|
171
|
+
|
|
172
|
+
if report["findings"] and getattr(args, "fail_on_findings", False):
|
|
173
|
+
return 1
|
|
174
|
+
return 0
|
sessionwiki/backlinks.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Backlink injection: every wiki page that's linked-to gets a managed
|
|
2
|
+
``## Referenced by`` section listing its referrers (#328).
|
|
3
|
+
|
|
4
|
+
Today a source page links out to ``[[Pratiyush]]`` but the
|
|
5
|
+
``entities/Pratiyush.md`` page doesn't know about it — 95% of pages
|
|
6
|
+
end up as graph orphans (575 / 596 sources with zero inbound links).
|
|
7
|
+
|
|
8
|
+
This module rebuilds the reverse-reference index
|
|
9
|
+
(:func:`sessionwiki.references.build_index`) and injects a managed
|
|
10
|
+
section into each target page bounded by HTML-comment sentinels so
|
|
11
|
+
rerun is idempotent:
|
|
12
|
+
|
|
13
|
+
<!-- BACKLINKS:START -->
|
|
14
|
+
## Referenced by
|
|
15
|
+
|
|
16
|
+
- [[session-slug-1]] — Session: title 1 (2026-04-19)
|
|
17
|
+
- [[session-slug-2]] — Session: title 2 (2026-04-18)
|
|
18
|
+
- …
|
|
19
|
+
<!-- BACKLINKS:END -->
|
|
20
|
+
|
|
21
|
+
Characteristics:
|
|
22
|
+
|
|
23
|
+
* Stdlib-only.
|
|
24
|
+
* Safe on every page — we only write inside the sentinels; everything
|
|
25
|
+
else is preserved verbatim.
|
|
26
|
+
* Skips pages that are already inside a ``wiki/archive/`` subtree.
|
|
27
|
+
* Caps each list at 50 entries so model entity pages don't balloon
|
|
28
|
+
(the full list stays reachable via the graph + ``sessionwiki references
|
|
29
|
+
<slug>``).
|
|
30
|
+
* Sorts by date descending when the referrer has a ``date:`` field,
|
|
31
|
+
otherwise alphabetically by title.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Optional
|
|
40
|
+
|
|
41
|
+
from sessionwiki import REPO_ROOT
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_START = "<!-- BACKLINKS:START -->"
|
|
45
|
+
_END = "<!-- BACKLINKS:END -->"
|
|
46
|
+
_BACKLINK_BLOCK_RE = re.compile(
|
|
47
|
+
re.escape(_START) + r".*?" + re.escape(_END) + r"\s*",
|
|
48
|
+
re.DOTALL,
|
|
49
|
+
)
|
|
50
|
+
DEFAULT_MAX_ENTRIES = 50
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class BacklinkEntry:
|
|
55
|
+
"""One row in a ``## Referenced by`` list."""
|
|
56
|
+
|
|
57
|
+
slug: str
|
|
58
|
+
title: str
|
|
59
|
+
date: str # may be empty
|
|
60
|
+
|
|
61
|
+
def sort_key(self) -> tuple[str, str]:
|
|
62
|
+
# Newest-first when date is ISO-8601; alphabetical by title otherwise.
|
|
63
|
+
return (self.date or "", self.title.lower())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
67
|
+
m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
|
|
68
|
+
if not m:
|
|
69
|
+
return {}, text
|
|
70
|
+
fm: dict[str, str] = {}
|
|
71
|
+
for line in m.group(1).splitlines():
|
|
72
|
+
mm = re.match(r"^([a-zA-Z_][\w-]*):\s*(.*)$", line)
|
|
73
|
+
if mm:
|
|
74
|
+
fm[mm.group(1)] = mm.group(2).strip().strip("'\"")
|
|
75
|
+
return fm, m.group(2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _collect_pages(wiki_dir: Path) -> dict[str, dict[str, Any]]:
|
|
79
|
+
"""Gather every wiki page into ``{slug: {path, meta, body}}``.
|
|
80
|
+
|
|
81
|
+
Skips ``wiki/archive/`` (deprecated) and ``_context.md`` stubs —
|
|
82
|
+
neither wants a backlinks block.
|
|
83
|
+
"""
|
|
84
|
+
out: dict[str, dict[str, Any]] = {}
|
|
85
|
+
if not wiki_dir.is_dir():
|
|
86
|
+
return out
|
|
87
|
+
for p in sorted(wiki_dir.rglob("*.md")):
|
|
88
|
+
if "archive" in p.parts:
|
|
89
|
+
continue
|
|
90
|
+
if p.name == "_context.md":
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
text = p.read_text(encoding="utf-8")
|
|
94
|
+
except OSError:
|
|
95
|
+
continue
|
|
96
|
+
meta, body = _parse_frontmatter(text)
|
|
97
|
+
out[p.stem] = {"path": p, "meta": meta, "body": body, "text": text}
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Match `[[wikilink]]` or `[[wikilink|display]]` targets.
|
|
102
|
+
_WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_reverse_index(pages: dict[str, dict[str, Any]]) -> dict[str, list[BacklinkEntry]]:
|
|
106
|
+
"""Return ``{target_slug: [BacklinkEntry(referrer)]}``.
|
|
107
|
+
|
|
108
|
+
An entry is produced for every ``[[Target]]`` occurrence in any
|
|
109
|
+
page's body (even if the Target doesn't exist — the graph's
|
|
110
|
+
broken-link checker handles that separately). Self-references are
|
|
111
|
+
skipped (a page never appears in its own backlinks list).
|
|
112
|
+
"""
|
|
113
|
+
rev: dict[str, list[BacklinkEntry]] = {}
|
|
114
|
+
for slug, page in pages.items():
|
|
115
|
+
body = page["body"]
|
|
116
|
+
for target in set(_WIKILINK_RE.findall(body)):
|
|
117
|
+
t = target.split("#")[0].strip()
|
|
118
|
+
if not t or t == slug:
|
|
119
|
+
continue
|
|
120
|
+
rev.setdefault(t, []).append(
|
|
121
|
+
BacklinkEntry(
|
|
122
|
+
slug=slug,
|
|
123
|
+
title=page["meta"].get("title", slug),
|
|
124
|
+
date=page["meta"].get("date", ""),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return rev
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _render_block(entries: list[BacklinkEntry], *, max_entries: int) -> str:
|
|
131
|
+
"""Render the sentinel-bounded ``## Referenced by`` block."""
|
|
132
|
+
ordered = sorted(entries, key=lambda e: e.sort_key(), reverse=True)
|
|
133
|
+
truncated = len(ordered) > max_entries
|
|
134
|
+
ordered = ordered[:max_entries]
|
|
135
|
+
lines = [_START, "", "## Referenced by", ""]
|
|
136
|
+
for e in ordered:
|
|
137
|
+
suffix = f" — {e.title}" if e.title and e.title != e.slug else ""
|
|
138
|
+
if e.date:
|
|
139
|
+
suffix = f"{suffix} ({e.date})" if suffix else f" ({e.date})"
|
|
140
|
+
lines.append(f"- [[{e.slug}]]{suffix}")
|
|
141
|
+
if truncated:
|
|
142
|
+
lines.append(f"")
|
|
143
|
+
lines.append(
|
|
144
|
+
f"*…and {len(entries) - max_entries} more referrer(s) — "
|
|
145
|
+
f"run `sessionwiki references <slug>` for the full list.*"
|
|
146
|
+
)
|
|
147
|
+
lines.append("")
|
|
148
|
+
lines.append(_END)
|
|
149
|
+
return "\n".join(lines) + "\n"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def inject_block(text: str, block: str) -> str:
|
|
153
|
+
"""Replace or append the backlinks block in ``text``.
|
|
154
|
+
|
|
155
|
+
Finds the existing sentinel-bounded section and swaps it; appends
|
|
156
|
+
to the end of the file with a leading blank line when the sentinels
|
|
157
|
+
aren't present. Idempotent.
|
|
158
|
+
"""
|
|
159
|
+
stripped = text.rstrip()
|
|
160
|
+
if _START in text and _END in text:
|
|
161
|
+
new_text = _BACKLINK_BLOCK_RE.sub(block, text, count=1)
|
|
162
|
+
# Normalise trailing newlines.
|
|
163
|
+
if not new_text.endswith("\n"):
|
|
164
|
+
new_text += "\n"
|
|
165
|
+
return new_text
|
|
166
|
+
return stripped + "\n\n" + block
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def remove_block(text: str) -> str:
|
|
170
|
+
"""Strip any existing backlinks block (used by ``--prune``)."""
|
|
171
|
+
if _START not in text:
|
|
172
|
+
return text
|
|
173
|
+
return _BACKLINK_BLOCK_RE.sub("", text).rstrip() + "\n"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def inject_all(
|
|
177
|
+
wiki_dir: Optional[Path] = None,
|
|
178
|
+
*,
|
|
179
|
+
max_entries: int = DEFAULT_MAX_ENTRIES,
|
|
180
|
+
dry_run: bool = False,
|
|
181
|
+
) -> dict[str, int]:
|
|
182
|
+
"""Walk ``wiki_dir`` and inject backlinks into every target page.
|
|
183
|
+
|
|
184
|
+
Returns ``{slug: num_backlinks}`` for every page that got an
|
|
185
|
+
(added, updated, or unchanged) block.
|
|
186
|
+
"""
|
|
187
|
+
root = wiki_dir or (REPO_ROOT / "wiki")
|
|
188
|
+
pages = _collect_pages(root)
|
|
189
|
+
rev = build_reverse_index(pages)
|
|
190
|
+
results: dict[str, int] = {}
|
|
191
|
+
for target_slug, entries in rev.items():
|
|
192
|
+
target = pages.get(target_slug)
|
|
193
|
+
if not target:
|
|
194
|
+
# Broken wikilink — skip.
|
|
195
|
+
continue
|
|
196
|
+
block = _render_block(entries, max_entries=max_entries)
|
|
197
|
+
new_text = inject_block(target["text"], block)
|
|
198
|
+
if new_text != target["text"] and not dry_run:
|
|
199
|
+
target["path"].write_text(new_text, encoding="utf-8")
|
|
200
|
+
results[target_slug] = len(entries)
|
|
201
|
+
return results
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def prune_all(
|
|
205
|
+
wiki_dir: Optional[Path] = None, *, dry_run: bool = False
|
|
206
|
+
) -> int:
|
|
207
|
+
"""Strip backlink blocks from every page. Returns count touched."""
|
|
208
|
+
root = wiki_dir or (REPO_ROOT / "wiki")
|
|
209
|
+
count = 0
|
|
210
|
+
for p in root.rglob("*.md"):
|
|
211
|
+
if "archive" in p.parts:
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
text = p.read_text(encoding="utf-8")
|
|
215
|
+
except OSError:
|
|
216
|
+
continue
|
|
217
|
+
if _START not in text:
|
|
218
|
+
continue
|
|
219
|
+
new_text = remove_block(text)
|
|
220
|
+
if new_text != text:
|
|
221
|
+
count += 1
|
|
222
|
+
if not dry_run:
|
|
223
|
+
p.write_text(new_text, encoding="utf-8")
|
|
224
|
+
return count
|