codealmanac 0.1.0.dev0__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.
- codealmanac/__init__.py +13 -0
- codealmanac/app.py +175 -0
- codealmanac/cli/__init__.py +1 -0
- codealmanac/cli/dispatch/__init__.py +0 -0
- codealmanac/cli/dispatch/admin.py +124 -0
- codealmanac/cli/dispatch/config.py +50 -0
- codealmanac/cli/dispatch/root.py +328 -0
- codealmanac/cli/main.py +28 -0
- codealmanac/cli/parser/__init__.py +0 -0
- codealmanac/cli/parser/admin.py +81 -0
- codealmanac/cli/parser/lifecycle.py +57 -0
- codealmanac/cli/parser/root.py +19 -0
- codealmanac/cli/parser/wiki.py +87 -0
- codealmanac/cli/render/__init__.py +0 -0
- codealmanac/cli/render/admin.py +191 -0
- codealmanac/cli/render/root.py +290 -0
- codealmanac/core/__init__.py +1 -0
- codealmanac/core/errors.py +45 -0
- codealmanac/core/models.py +14 -0
- codealmanac/core/paths.py +25 -0
- codealmanac/core/slug.py +7 -0
- codealmanac/core/text.py +5 -0
- codealmanac/database/__init__.py +15 -0
- codealmanac/database/sqlite.py +54 -0
- codealmanac/integrations/__init__.py +1 -0
- codealmanac/integrations/automation/__init__.py +3 -0
- codealmanac/integrations/automation/scheduler/__init__.py +5 -0
- codealmanac/integrations/automation/scheduler/launchd.py +163 -0
- codealmanac/integrations/command.py +56 -0
- codealmanac/integrations/harnesses/__init__.py +7 -0
- codealmanac/integrations/harnesses/claude/__init__.py +1 -0
- codealmanac/integrations/harnesses/claude/adapter.py +217 -0
- codealmanac/integrations/harnesses/codex/__init__.py +3 -0
- codealmanac/integrations/harnesses/codex/adapter.py +221 -0
- codealmanac/integrations/harnesses/git_status.py +49 -0
- codealmanac/integrations/sources/__init__.py +29 -0
- codealmanac/integrations/sources/filesystem/__init__.py +5 -0
- codealmanac/integrations/sources/filesystem/adapter.py +685 -0
- codealmanac/integrations/sources/filesystem/selection.py +209 -0
- codealmanac/integrations/sources/git/__init__.py +3 -0
- codealmanac/integrations/sources/git/adapter.py +132 -0
- codealmanac/integrations/sources/github/__init__.py +3 -0
- codealmanac/integrations/sources/github/adapter.py +413 -0
- codealmanac/integrations/sources/runtime.py +22 -0
- codealmanac/integrations/sources/transcripts/__init__.py +33 -0
- codealmanac/integrations/sources/transcripts/claude.py +61 -0
- codealmanac/integrations/sources/transcripts/codex.py +69 -0
- codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
- codealmanac/integrations/sources/transcripts/runtime.py +387 -0
- codealmanac/integrations/sources/web/__init__.py +3 -0
- codealmanac/integrations/sources/web/adapter.py +303 -0
- codealmanac/integrations/updates/__init__.py +7 -0
- codealmanac/integrations/updates/package.py +85 -0
- codealmanac/integrations/workspaces/__init__.py +1 -0
- codealmanac/integrations/workspaces/git/__init__.py +3 -0
- codealmanac/integrations/workspaces/git/probe.py +128 -0
- codealmanac/manual/README.md +24 -0
- codealmanac/manual/__init__.py +19 -0
- codealmanac/manual/build.md +20 -0
- codealmanac/manual/evidence.md +23 -0
- codealmanac/manual/garden.md +20 -0
- codealmanac/manual/ingest.md +17 -0
- codealmanac/manual/library.py +84 -0
- codealmanac/manual/models.py +83 -0
- codealmanac/manual/pages.md +28 -0
- codealmanac/manual/requests.py +6 -0
- codealmanac/manual/sources.md +18 -0
- codealmanac/manual/style.md +19 -0
- codealmanac/prompts/__init__.py +5 -0
- codealmanac/prompts/base/notability.md +14 -0
- codealmanac/prompts/base/purpose.md +23 -0
- codealmanac/prompts/base/syntax.md +19 -0
- codealmanac/prompts/models.py +9 -0
- codealmanac/prompts/operations/garden.md +26 -0
- codealmanac/prompts/operations/ingest.md +18 -0
- codealmanac/prompts/renderer.py +24 -0
- codealmanac/prompts/requests.py +22 -0
- codealmanac/server/__init__.py +1 -0
- codealmanac/server/app.py +202 -0
- codealmanac/server/assets/__init__.py +1 -0
- codealmanac/server/assets/app.css +865 -0
- codealmanac/server/assets/app.js +3 -0
- codealmanac/server/assets/index.html +80 -0
- codealmanac/server/assets/viewer/api.js +30 -0
- codealmanac/server/assets/viewer/components.js +197 -0
- codealmanac/server/assets/viewer/main.js +126 -0
- codealmanac/server/assets/viewer/renderers.js +122 -0
- codealmanac/server/assets/viewer/routes.js +36 -0
- codealmanac/services/__init__.py +1 -0
- codealmanac/services/automation/__init__.py +3 -0
- codealmanac/services/automation/models.py +83 -0
- codealmanac/services/automation/ports.py +14 -0
- codealmanac/services/automation/requests.py +40 -0
- codealmanac/services/automation/service.py +294 -0
- codealmanac/services/config/__init__.py +17 -0
- codealmanac/services/config/models.py +61 -0
- codealmanac/services/config/requests.py +21 -0
- codealmanac/services/config/service.py +55 -0
- codealmanac/services/config/store.py +26 -0
- codealmanac/services/diagnostics/__init__.py +1 -0
- codealmanac/services/diagnostics/models.py +22 -0
- codealmanac/services/diagnostics/requests.py +8 -0
- codealmanac/services/diagnostics/service.py +283 -0
- codealmanac/services/harnesses/__init__.py +1 -0
- codealmanac/services/harnesses/models.py +104 -0
- codealmanac/services/harnesses/ports.py +18 -0
- codealmanac/services/harnesses/requests.py +19 -0
- codealmanac/services/harnesses/service.py +38 -0
- codealmanac/services/health/__init__.py +1 -0
- codealmanac/services/health/requests.py +8 -0
- codealmanac/services/health/service.py +20 -0
- codealmanac/services/index/__init__.py +1 -0
- codealmanac/services/index/models.py +135 -0
- codealmanac/services/index/requests.py +26 -0
- codealmanac/services/index/service.py +86 -0
- codealmanac/services/index/store.py +411 -0
- codealmanac/services/index/views.py +524 -0
- codealmanac/services/pages/__init__.py +1 -0
- codealmanac/services/pages/requests.py +17 -0
- codealmanac/services/pages/service.py +26 -0
- codealmanac/services/runs/__init__.py +1 -0
- codealmanac/services/runs/models.py +91 -0
- codealmanac/services/runs/requests.py +76 -0
- codealmanac/services/runs/service.py +86 -0
- codealmanac/services/runs/store.py +256 -0
- codealmanac/services/search/__init__.py +1 -0
- codealmanac/services/search/requests.py +23 -0
- codealmanac/services/search/service.py +31 -0
- codealmanac/services/sources/__init__.py +1 -0
- codealmanac/services/sources/models.py +126 -0
- codealmanac/services/sources/ports.py +30 -0
- codealmanac/services/sources/requests.py +76 -0
- codealmanac/services/sources/service.py +351 -0
- codealmanac/services/tagging/__init__.py +1 -0
- codealmanac/services/tagging/models.py +9 -0
- codealmanac/services/tagging/requests.py +35 -0
- codealmanac/services/tagging/service.py +43 -0
- codealmanac/services/topics/__init__.py +1 -0
- codealmanac/services/topics/models.py +36 -0
- codealmanac/services/topics/requests.py +115 -0
- codealmanac/services/topics/service.py +297 -0
- codealmanac/services/updates/__init__.py +4 -0
- codealmanac/services/updates/models.py +83 -0
- codealmanac/services/updates/ports.py +17 -0
- codealmanac/services/updates/requests.py +10 -0
- codealmanac/services/updates/service.py +113 -0
- codealmanac/services/viewer/__init__.py +1 -0
- codealmanac/services/viewer/models.py +80 -0
- codealmanac/services/viewer/renderer.py +89 -0
- codealmanac/services/viewer/requests.py +86 -0
- codealmanac/services/viewer/service.py +211 -0
- codealmanac/services/wiki/__init__.py +1 -0
- codealmanac/services/wiki/documents.py +83 -0
- codealmanac/services/wiki/frontmatter.py +94 -0
- codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
- codealmanac/services/wiki/models.py +69 -0
- codealmanac/services/wiki/paths.py +42 -0
- codealmanac/services/wiki/service.py +57 -0
- codealmanac/services/wiki/templates.py +73 -0
- codealmanac/services/wiki/topics.py +266 -0
- codealmanac/services/wiki/wikilinks.py +58 -0
- codealmanac/services/workspaces/__init__.py +1 -0
- codealmanac/services/workspaces/models.py +124 -0
- codealmanac/services/workspaces/ports.py +9 -0
- codealmanac/services/workspaces/requests.py +82 -0
- codealmanac/services/workspaces/roots.py +74 -0
- codealmanac/services/workspaces/service.py +303 -0
- codealmanac/services/workspaces/store.py +127 -0
- codealmanac/workflows/__init__.py +1 -0
- codealmanac/workflows/build/__init__.py +1 -0
- codealmanac/workflows/build/models.py +8 -0
- codealmanac/workflows/build/service.py +45 -0
- codealmanac/workflows/garden/__init__.py +3 -0
- codealmanac/workflows/garden/models.py +30 -0
- codealmanac/workflows/garden/requests.py +22 -0
- codealmanac/workflows/garden/service.py +239 -0
- codealmanac/workflows/ingest/__init__.py +1 -0
- codealmanac/workflows/ingest/models.py +26 -0
- codealmanac/workflows/ingest/requests.py +39 -0
- codealmanac/workflows/ingest/service.py +302 -0
- codealmanac/workflows/lifecycle.py +197 -0
- codealmanac/workflows/sync/__init__.py +3 -0
- codealmanac/workflows/sync/models.py +157 -0
- codealmanac/workflows/sync/requests.py +63 -0
- codealmanac/workflows/sync/service.py +651 -0
- codealmanac/workflows/sync/store.py +51 -0
- codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
- codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
- codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
- codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
- codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from codealmanac.services.index.models import (
|
|
2
|
+
HealthReport,
|
|
3
|
+
IndexRefreshResult,
|
|
4
|
+
IndexSummary,
|
|
5
|
+
PageView,
|
|
6
|
+
SearchPageResult,
|
|
7
|
+
TopicDetail,
|
|
8
|
+
TopicSummary,
|
|
9
|
+
)
|
|
10
|
+
from codealmanac.services.index.requests import ReindexRequest, SearchIndexRequest
|
|
11
|
+
from codealmanac.services.index.store import IndexStore
|
|
12
|
+
from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
|
|
13
|
+
from codealmanac.services.workspaces.service import WorkspacesService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IndexService:
|
|
17
|
+
def __init__(self, workspaces: WorkspacesService, store: IndexStore):
|
|
18
|
+
self.workspaces = workspaces
|
|
19
|
+
self.store = store
|
|
20
|
+
|
|
21
|
+
def ensure_fresh(self, workspace_id: str) -> IndexRefreshResult:
|
|
22
|
+
workspace = self.workspaces.get(workspace_id)
|
|
23
|
+
return self.store.refresh(workspace.almanac_path)
|
|
24
|
+
|
|
25
|
+
def reindex(self, request: ReindexRequest) -> IndexRefreshResult:
|
|
26
|
+
if request.wiki is None:
|
|
27
|
+
workspace = self.workspaces.resolve(request.cwd)
|
|
28
|
+
else:
|
|
29
|
+
workspace = self.workspaces.select(
|
|
30
|
+
SelectWorkspaceRequest(selector=request.wiki, base_path=request.cwd)
|
|
31
|
+
)
|
|
32
|
+
return self.store.rebuild(workspace.almanac_path)
|
|
33
|
+
|
|
34
|
+
def summary(self, workspace_id: str) -> IndexSummary:
|
|
35
|
+
workspace = self.workspaces.get(workspace_id)
|
|
36
|
+
refresh = self.ensure_fresh(workspace_id)
|
|
37
|
+
counts = self.store.counts(workspace.almanac_path)
|
|
38
|
+
return IndexSummary(
|
|
39
|
+
pages=counts.pages,
|
|
40
|
+
topics=counts.topics,
|
|
41
|
+
files_seen=refresh.files_seen,
|
|
42
|
+
files_skipped=refresh.files_skipped,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def search(
|
|
46
|
+
self,
|
|
47
|
+
workspace_id: str,
|
|
48
|
+
request: SearchIndexRequest,
|
|
49
|
+
) -> tuple[SearchPageResult, ...]:
|
|
50
|
+
self.ensure_fresh(workspace_id)
|
|
51
|
+
workspace = self.workspaces.get(workspace_id)
|
|
52
|
+
return self.store.search(workspace.almanac_path, request)
|
|
53
|
+
|
|
54
|
+
def get_page(self, workspace_id: str, slug: str) -> PageView | None:
|
|
55
|
+
self.ensure_fresh(workspace_id)
|
|
56
|
+
workspace = self.workspaces.get(workspace_id)
|
|
57
|
+
return self.store.get_page(workspace.almanac_path, slug)
|
|
58
|
+
|
|
59
|
+
def list_topics(self, workspace_id: str) -> tuple[TopicSummary, ...]:
|
|
60
|
+
self.ensure_fresh(workspace_id)
|
|
61
|
+
workspace = self.workspaces.get(workspace_id)
|
|
62
|
+
return self.store.list_topics(workspace.almanac_path)
|
|
63
|
+
|
|
64
|
+
def get_topic(
|
|
65
|
+
self,
|
|
66
|
+
workspace_id: str,
|
|
67
|
+
slug: str,
|
|
68
|
+
include_descendants: bool,
|
|
69
|
+
) -> TopicDetail | None:
|
|
70
|
+
self.ensure_fresh(workspace_id)
|
|
71
|
+
workspace = self.workspaces.get(workspace_id)
|
|
72
|
+
return self.store.get_topic(
|
|
73
|
+
workspace.almanac_path,
|
|
74
|
+
slug,
|
|
75
|
+
include_descendants,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def health_report(self, workspace_id: str) -> HealthReport:
|
|
79
|
+
self.ensure_fresh(workspace_id)
|
|
80
|
+
workspace = self.workspaces.get(workspace_id)
|
|
81
|
+
registered_wikis = {workspace.name for workspace in self.workspaces.list()}
|
|
82
|
+
return self.store.health_report(
|
|
83
|
+
workspace.almanac_path,
|
|
84
|
+
workspace.root_path,
|
|
85
|
+
registered_wikis,
|
|
86
|
+
)
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
from hashlib import sha256
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from codealmanac.core.errors import NotFoundError
|
|
7
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
8
|
+
from codealmanac.database import (
|
|
9
|
+
SQLiteConnection,
|
|
10
|
+
SQLiteMigration,
|
|
11
|
+
apply_migrations,
|
|
12
|
+
connect_sqlite,
|
|
13
|
+
)
|
|
14
|
+
from codealmanac.services.index.models import (
|
|
15
|
+
HealthReport,
|
|
16
|
+
IndexCounts,
|
|
17
|
+
IndexedPageFingerprint,
|
|
18
|
+
IndexRefreshResult,
|
|
19
|
+
IndexSourceSignature,
|
|
20
|
+
PageView,
|
|
21
|
+
SearchPageResult,
|
|
22
|
+
TopicDetail,
|
|
23
|
+
TopicSummary,
|
|
24
|
+
)
|
|
25
|
+
from codealmanac.services.index.requests import SearchIndexRequest
|
|
26
|
+
from codealmanac.services.index.views import (
|
|
27
|
+
build_health_report,
|
|
28
|
+
get_page_view,
|
|
29
|
+
get_topic_detail,
|
|
30
|
+
index_counts,
|
|
31
|
+
list_topic_summaries,
|
|
32
|
+
search_pages,
|
|
33
|
+
)
|
|
34
|
+
from codealmanac.services.wiki.documents import load_page_document
|
|
35
|
+
from codealmanac.services.wiki.models import PageDocument
|
|
36
|
+
from codealmanac.services.wiki.topics import TopicDefinition, load_topics_yaml
|
|
37
|
+
from codealmanac.services.workspaces.roots import is_initialized_almanac_root
|
|
38
|
+
|
|
39
|
+
SCHEMA_VERSION = 20260630
|
|
40
|
+
|
|
41
|
+
SCHEMA_DDL = """
|
|
42
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
43
|
+
slug TEXT PRIMARY KEY,
|
|
44
|
+
title TEXT,
|
|
45
|
+
summary TEXT,
|
|
46
|
+
file_path TEXT NOT NULL,
|
|
47
|
+
content_hash TEXT NOT NULL,
|
|
48
|
+
updated_at INTEGER NOT NULL,
|
|
49
|
+
archived_at INTEGER,
|
|
50
|
+
superseded_by TEXT,
|
|
51
|
+
body TEXT NOT NULL
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
55
|
+
slug TEXT PRIMARY KEY,
|
|
56
|
+
title TEXT,
|
|
57
|
+
description TEXT
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE TABLE IF NOT EXISTS page_topics (
|
|
61
|
+
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
62
|
+
topic_slug TEXT NOT NULL,
|
|
63
|
+
PRIMARY KEY (page_slug, topic_slug)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS topic_parents (
|
|
67
|
+
child_slug TEXT NOT NULL,
|
|
68
|
+
parent_slug TEXT NOT NULL,
|
|
69
|
+
PRIMARY KEY (child_slug, parent_slug),
|
|
70
|
+
CHECK (child_slug != parent_slug)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
74
|
+
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
75
|
+
path TEXT NOT NULL,
|
|
76
|
+
original_path TEXT NOT NULL,
|
|
77
|
+
is_dir INTEGER NOT NULL,
|
|
78
|
+
PRIMARY KEY (page_slug, path)
|
|
79
|
+
);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_file_refs_path ON file_refs(path);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS wikilinks (
|
|
83
|
+
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
84
|
+
target_slug TEXT NOT NULL,
|
|
85
|
+
PRIMARY KEY (source_slug, target_slug)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS cross_wiki_links (
|
|
89
|
+
source_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
|
|
90
|
+
target_wiki TEXT NOT NULL,
|
|
91
|
+
target_slug TEXT NOT NULL,
|
|
92
|
+
PRIMARY KEY (source_slug, target_wiki, target_slug)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_pages USING fts5(slug, title, content);
|
|
96
|
+
|
|
97
|
+
CREATE TABLE IF NOT EXISTS index_metadata (
|
|
98
|
+
key TEXT PRIMARY KEY,
|
|
99
|
+
value TEXT NOT NULL
|
|
100
|
+
);
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
DROP_DERIVED_INDEX_DDL = """
|
|
104
|
+
DROP TABLE IF EXISTS cross_wiki_links;
|
|
105
|
+
DROP TABLE IF EXISTS wikilinks;
|
|
106
|
+
DROP TABLE IF EXISTS file_refs;
|
|
107
|
+
DROP TABLE IF EXISTS page_topics;
|
|
108
|
+
DROP TABLE IF EXISTS topic_parents;
|
|
109
|
+
DROP TABLE IF EXISTS topics;
|
|
110
|
+
DROP TABLE IF EXISTS pages;
|
|
111
|
+
DROP TABLE IF EXISTS fts_pages;
|
|
112
|
+
DROP TABLE IF EXISTS index_metadata;
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
INDEX_MIGRATIONS = (
|
|
116
|
+
SQLiteMigration(
|
|
117
|
+
version=SCHEMA_VERSION,
|
|
118
|
+
sql=f"{DROP_DERIVED_INDEX_DDL}\n{SCHEMA_DDL}",
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
SOURCE_SIGNATURE_KEY = "source_signature"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class IndexStore:
|
|
126
|
+
def refresh(self, almanac_path: Path) -> IndexRefreshResult:
|
|
127
|
+
require_initialized_almanac_root(almanac_path)
|
|
128
|
+
sources = load_index_sources(almanac_path)
|
|
129
|
+
db_path = index_db_path(almanac_path)
|
|
130
|
+
with connect_index(db_path) as connection:
|
|
131
|
+
if stored_signature(connection) == sources.signature:
|
|
132
|
+
return IndexRefreshResult(
|
|
133
|
+
changed=0,
|
|
134
|
+
removed=0,
|
|
135
|
+
pages_indexed=len(sources.documents),
|
|
136
|
+
files_seen=sources.files_seen,
|
|
137
|
+
files_skipped=sources.files_skipped,
|
|
138
|
+
)
|
|
139
|
+
replace_documents(connection, sources)
|
|
140
|
+
return IndexRefreshResult(
|
|
141
|
+
changed=len(sources.documents),
|
|
142
|
+
removed=0,
|
|
143
|
+
pages_indexed=len(sources.documents),
|
|
144
|
+
files_seen=sources.files_seen,
|
|
145
|
+
files_skipped=sources.files_skipped,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def rebuild(self, almanac_path: Path) -> IndexRefreshResult:
|
|
149
|
+
require_initialized_almanac_root(almanac_path)
|
|
150
|
+
sources = load_index_sources(almanac_path)
|
|
151
|
+
db_path = index_db_path(almanac_path)
|
|
152
|
+
with connect_index(db_path) as connection:
|
|
153
|
+
replace_documents(connection, sources)
|
|
154
|
+
return IndexRefreshResult(
|
|
155
|
+
changed=len(sources.documents),
|
|
156
|
+
removed=0,
|
|
157
|
+
pages_indexed=len(sources.documents),
|
|
158
|
+
files_seen=sources.files_seen,
|
|
159
|
+
files_skipped=sources.files_skipped,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def search(
|
|
163
|
+
self,
|
|
164
|
+
almanac_path: Path,
|
|
165
|
+
request: SearchIndexRequest,
|
|
166
|
+
) -> tuple[SearchPageResult, ...]:
|
|
167
|
+
require_initialized_almanac_root(almanac_path)
|
|
168
|
+
with connect_index(index_db_path(almanac_path)) as connection:
|
|
169
|
+
return search_pages(connection, request)
|
|
170
|
+
|
|
171
|
+
def counts(self, almanac_path: Path) -> IndexCounts:
|
|
172
|
+
require_initialized_almanac_root(almanac_path)
|
|
173
|
+
with connect_index(index_db_path(almanac_path)) as connection:
|
|
174
|
+
return index_counts(connection)
|
|
175
|
+
|
|
176
|
+
def get_page(self, almanac_path: Path, slug: str) -> PageView | None:
|
|
177
|
+
require_initialized_almanac_root(almanac_path)
|
|
178
|
+
with connect_index(index_db_path(almanac_path)) as connection:
|
|
179
|
+
return get_page_view(connection, slug)
|
|
180
|
+
|
|
181
|
+
def list_topics(self, almanac_path: Path) -> tuple[TopicSummary, ...]:
|
|
182
|
+
require_initialized_almanac_root(almanac_path)
|
|
183
|
+
with connect_index(index_db_path(almanac_path)) as connection:
|
|
184
|
+
return list_topic_summaries(connection)
|
|
185
|
+
|
|
186
|
+
def get_topic(
|
|
187
|
+
self,
|
|
188
|
+
almanac_path: Path,
|
|
189
|
+
slug: str,
|
|
190
|
+
include_descendants: bool,
|
|
191
|
+
) -> TopicDetail | None:
|
|
192
|
+
require_initialized_almanac_root(almanac_path)
|
|
193
|
+
with connect_index(index_db_path(almanac_path)) as connection:
|
|
194
|
+
return get_topic_detail(connection, slug, include_descendants)
|
|
195
|
+
|
|
196
|
+
def health_report(
|
|
197
|
+
self,
|
|
198
|
+
almanac_path: Path,
|
|
199
|
+
repo_root: Path,
|
|
200
|
+
registered_wikis: set[str],
|
|
201
|
+
) -> HealthReport:
|
|
202
|
+
require_initialized_almanac_root(almanac_path)
|
|
203
|
+
with connect_index(index_db_path(almanac_path)) as connection:
|
|
204
|
+
return build_health_report(connection, repo_root, registered_wikis)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class LoadedIndexSources(CodeAlmanacModel):
|
|
208
|
+
documents: tuple[PageDocument, ...]
|
|
209
|
+
topics: tuple[TopicDefinition, ...]
|
|
210
|
+
files_seen: int
|
|
211
|
+
files_skipped: int
|
|
212
|
+
signature: IndexSourceSignature
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def index_db_path(almanac_path: Path) -> Path:
|
|
216
|
+
return almanac_path / "index.db"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def connect_index(path: Path) -> SQLiteConnection:
|
|
220
|
+
connection = connect_sqlite(path)
|
|
221
|
+
apply_migrations(connection, INDEX_MIGRATIONS)
|
|
222
|
+
return connection
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def require_initialized_almanac_root(almanac_path: Path) -> None:
|
|
226
|
+
if not is_initialized_almanac_root(almanac_path):
|
|
227
|
+
raise NotFoundError("Almanac root", str(almanac_path))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def load_documents(pages_path: Path) -> tuple[list[PageDocument], int, int]:
|
|
231
|
+
if not pages_path.is_dir():
|
|
232
|
+
return [], 0, 0
|
|
233
|
+
documents: list[PageDocument] = []
|
|
234
|
+
seen_slugs: set[str] = set()
|
|
235
|
+
files = sorted(pages_path.rglob("*.md"))
|
|
236
|
+
files_skipped = 0
|
|
237
|
+
for page_path in files:
|
|
238
|
+
document = load_page_document(page_path, pages_path)
|
|
239
|
+
if document is None or document.slug in seen_slugs:
|
|
240
|
+
files_skipped += 1
|
|
241
|
+
continue
|
|
242
|
+
seen_slugs.add(document.slug)
|
|
243
|
+
documents.append(document)
|
|
244
|
+
return documents, len(files), files_skipped
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_index_sources(almanac_path: Path) -> LoadedIndexSources:
|
|
248
|
+
documents, files_seen, files_skipped = load_documents(almanac_path / "pages")
|
|
249
|
+
topics = load_topics_yaml(almanac_path)
|
|
250
|
+
document_tuple = tuple(documents)
|
|
251
|
+
signature = IndexSourceSignature(
|
|
252
|
+
pages=tuple(
|
|
253
|
+
IndexedPageFingerprint(
|
|
254
|
+
slug=document.slug,
|
|
255
|
+
relative_path=document.relative_path,
|
|
256
|
+
content_hash=document.content_hash,
|
|
257
|
+
)
|
|
258
|
+
for document in document_tuple
|
|
259
|
+
),
|
|
260
|
+
topics_hash=file_hash(almanac_path / "topics.yaml"),
|
|
261
|
+
files_seen=files_seen,
|
|
262
|
+
files_skipped=files_skipped,
|
|
263
|
+
)
|
|
264
|
+
return LoadedIndexSources(
|
|
265
|
+
documents=document_tuple,
|
|
266
|
+
topics=topics,
|
|
267
|
+
files_seen=files_seen,
|
|
268
|
+
files_skipped=files_skipped,
|
|
269
|
+
signature=signature,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def file_hash(path: Path) -> str:
|
|
274
|
+
if not path.is_file():
|
|
275
|
+
return sha256(b"").hexdigest()
|
|
276
|
+
try:
|
|
277
|
+
return sha256(path.read_bytes()).hexdigest()
|
|
278
|
+
except OSError:
|
|
279
|
+
return sha256(b"").hexdigest()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def stored_signature(connection: SQLiteConnection) -> IndexSourceSignature | None:
|
|
283
|
+
row = connection.execute(
|
|
284
|
+
"SELECT value FROM index_metadata WHERE key = ?",
|
|
285
|
+
(SOURCE_SIGNATURE_KEY,),
|
|
286
|
+
).fetchone()
|
|
287
|
+
if row is None:
|
|
288
|
+
return None
|
|
289
|
+
try:
|
|
290
|
+
return IndexSourceSignature.model_validate_json(row["value"])
|
|
291
|
+
except (ValidationError, ValueError):
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def replace_documents(
|
|
296
|
+
connection: SQLiteConnection,
|
|
297
|
+
sources: LoadedIndexSources,
|
|
298
|
+
) -> None:
|
|
299
|
+
with connection:
|
|
300
|
+
connection.execute("DELETE FROM fts_pages")
|
|
301
|
+
connection.execute("DELETE FROM pages")
|
|
302
|
+
connection.execute("DELETE FROM topic_parents")
|
|
303
|
+
connection.execute("DELETE FROM topics")
|
|
304
|
+
for document in sources.documents:
|
|
305
|
+
insert_document(connection, document)
|
|
306
|
+
for topic in sources.topics:
|
|
307
|
+
insert_topic_definition(connection, topic)
|
|
308
|
+
connection.execute(
|
|
309
|
+
"""
|
|
310
|
+
INSERT INTO index_metadata (key, value)
|
|
311
|
+
VALUES (?, ?)
|
|
312
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
313
|
+
""",
|
|
314
|
+
(
|
|
315
|
+
SOURCE_SIGNATURE_KEY,
|
|
316
|
+
sources.signature.model_dump_json(),
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def insert_document(connection: SQLiteConnection, document: PageDocument) -> None:
|
|
322
|
+
connection.execute(
|
|
323
|
+
"""
|
|
324
|
+
INSERT INTO pages (
|
|
325
|
+
slug, title, summary, file_path, content_hash, updated_at,
|
|
326
|
+
archived_at, superseded_by, body
|
|
327
|
+
)
|
|
328
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
329
|
+
""",
|
|
330
|
+
(
|
|
331
|
+
document.slug,
|
|
332
|
+
document.title,
|
|
333
|
+
document.summary,
|
|
334
|
+
str(document.file_path),
|
|
335
|
+
document.content_hash,
|
|
336
|
+
document.updated_at,
|
|
337
|
+
document.archived_at,
|
|
338
|
+
document.superseded_by,
|
|
339
|
+
document.body,
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
connection.execute(
|
|
343
|
+
"INSERT INTO fts_pages (slug, title, content) VALUES (?, ?, ?)",
|
|
344
|
+
(document.slug, document.title, document.body),
|
|
345
|
+
)
|
|
346
|
+
for topic in document.topics:
|
|
347
|
+
if not topic:
|
|
348
|
+
continue
|
|
349
|
+
connection.execute(
|
|
350
|
+
"INSERT OR IGNORE INTO topics (slug, title) VALUES (?, ?)",
|
|
351
|
+
(topic, title_for_slug(topic)),
|
|
352
|
+
)
|
|
353
|
+
connection.execute(
|
|
354
|
+
"INSERT OR IGNORE INTO page_topics (page_slug, topic_slug) VALUES (?, ?)",
|
|
355
|
+
(document.slug, topic),
|
|
356
|
+
)
|
|
357
|
+
for ref in document.file_refs:
|
|
358
|
+
connection.execute(
|
|
359
|
+
"""
|
|
360
|
+
INSERT OR IGNORE INTO file_refs
|
|
361
|
+
(page_slug, path, original_path, is_dir)
|
|
362
|
+
VALUES (?, ?, ?, ?)
|
|
363
|
+
""",
|
|
364
|
+
(document.slug, ref.path, ref.original_path, int(ref.is_dir)),
|
|
365
|
+
)
|
|
366
|
+
for target in document.page_links:
|
|
367
|
+
connection.execute(
|
|
368
|
+
"INSERT OR IGNORE INTO wikilinks (source_slug, target_slug) VALUES (?, ?)",
|
|
369
|
+
(document.slug, target),
|
|
370
|
+
)
|
|
371
|
+
for wiki, target in document.cross_wiki_links:
|
|
372
|
+
connection.execute(
|
|
373
|
+
"""
|
|
374
|
+
INSERT OR IGNORE INTO cross_wiki_links
|
|
375
|
+
(source_slug, target_wiki, target_slug)
|
|
376
|
+
VALUES (?, ?, ?)
|
|
377
|
+
""",
|
|
378
|
+
(document.slug, wiki, target),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def insert_topic_definition(
|
|
383
|
+
connection: SQLiteConnection,
|
|
384
|
+
topic: TopicDefinition,
|
|
385
|
+
) -> None:
|
|
386
|
+
connection.execute(
|
|
387
|
+
"""
|
|
388
|
+
INSERT INTO topics (slug, title, description)
|
|
389
|
+
VALUES (?, ?, ?)
|
|
390
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
391
|
+
title = excluded.title,
|
|
392
|
+
description = excluded.description
|
|
393
|
+
""",
|
|
394
|
+
(topic.slug, topic.title or title_for_slug(topic.slug), topic.description),
|
|
395
|
+
)
|
|
396
|
+
for parent in topic.parents:
|
|
397
|
+
connection.execute(
|
|
398
|
+
"INSERT OR IGNORE INTO topics (slug, title) VALUES (?, ?)",
|
|
399
|
+
(parent, title_for_slug(parent)),
|
|
400
|
+
)
|
|
401
|
+
connection.execute(
|
|
402
|
+
"""
|
|
403
|
+
INSERT OR IGNORE INTO topic_parents (child_slug, parent_slug)
|
|
404
|
+
VALUES (?, ?)
|
|
405
|
+
""",
|
|
406
|
+
(topic.slug, parent),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def title_for_slug(slug: str) -> str:
|
|
411
|
+
return " ".join(word.capitalize() for word in slug.split("-") if word)
|