campfire-cli 0.1.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.
- campfire_cli/__init__.py +3 -0
- campfire_cli/alembic/__init__.py +1 -0
- campfire_cli/alembic/env.py +37 -0
- campfire_cli/alembic/script.py.mako +22 -0
- campfire_cli/alembic/versions/001_initial_schema.py +81 -0
- campfire_cli/alembic/versions/002_workspace_project_registry.py +47 -0
- campfire_cli/alembic/versions/003_unified_workspace_state.py +113 -0
- campfire_cli/alembic/versions/004_restructure_workspace_domain.py +23 -0
- campfire_cli/alembic/versions/005_decision_channel.py +71 -0
- campfire_cli/alembic/versions/006_workspace_topology_index.py +61 -0
- campfire_cli/alembic/versions/007_workspace_adoption.py +38 -0
- campfire_cli/alembic/versions/__init__.py +1 -0
- campfire_cli/app/SPEC.md +12 -0
- campfire_cli/app/__init__.py +1 -0
- campfire_cli/app/base/SPEC.md +3 -0
- campfire_cli/app/base/__init__.py +1 -0
- campfire_cli/app/base/cli/base_cli.py +37 -0
- campfire_cli/app/base/repository/base_repository.py +17 -0
- campfire_cli/app/base/schema/base_schema.py +18 -0
- campfire_cli/app/base/service/base_service.py +129 -0
- campfire_cli/app/decision/SPEC.md +16 -0
- campfire_cli/app/decision/__init__.py +1 -0
- campfire_cli/app/decision/cli/decision_cli.py +112 -0
- campfire_cli/app/decision/repository/decision_repository.py +199 -0
- campfire_cli/app/decision/schema/decision_schema.py +56 -0
- campfire_cli/app/decision/service/decision_projection_service.py +151 -0
- campfire_cli/app/decision/service/decision_service.py +124 -0
- campfire_cli/app/document/SPEC.md +51 -0
- campfire_cli/app/document/__init__.py +1 -0
- campfire_cli/app/document/cli/__init__.py +1 -0
- campfire_cli/app/document/cli/document_cli.py +63 -0
- campfire_cli/app/document/cli/profile_cli.py +65 -0
- campfire_cli/app/document/cli/type_cli.py +49 -0
- campfire_cli/app/document/repository/__init__.py +1 -0
- campfire_cli/app/document/repository/document_profile_repository.py +17 -0
- campfire_cli/app/document/repository/document_type_repository.py +17 -0
- campfire_cli/app/document/service/__init__.py +1 -0
- campfire_cli/app/document/service/document_profile_service.py +66 -0
- campfire_cli/app/document/service/document_rule_service.py +316 -0
- campfire_cli/app/document/service/document_scanner.py +38 -0
- campfire_cli/app/document/service/document_service.py +139 -0
- campfire_cli/app/document/service/document_type_service.py +28 -0
- campfire_cli/app/document/service/frontmatter_apply.py +98 -0
- campfire_cli/app/document/service/frontmatter_formatter.py +104 -0
- campfire_cli/app/document/service/frontmatter_plan.py +75 -0
- campfire_cli/app/document/service/profile_registry.py +166 -0
- campfire_cli/app/document/service/type_apply.py +185 -0
- campfire_cli/app/document/service/type_plan.py +80 -0
- campfire_cli/app/maintenance/SPEC.md +22 -0
- campfire_cli/app/maintenance/__init__.py +1 -0
- campfire_cli/app/maintenance/cli/maintenance_cli.py +103 -0
- campfire_cli/app/maintenance/repository/maintenance_repository.py +115 -0
- campfire_cli/app/maintenance/schema/maintenance_schema.py +109 -0
- campfire_cli/app/maintenance/service/archive_service.py +255 -0
- campfire_cli/app/maintenance/service/link_service.py +91 -0
- campfire_cli/app/maintenance/service/maintenance_protocol.py +25 -0
- campfire_cli/app/maintenance/service/maintenance_service.py +461 -0
- campfire_cli/app/maintenance/service/moc_service.py +310 -0
- campfire_cli/app/maintenance/service/plan_service.py +411 -0
- campfire_cli/app/skill/SPEC.md +3 -0
- campfire_cli/app/skill/__init__.py +1 -0
- campfire_cli/app/skill/cli/skill_cli.py +44 -0
- campfire_cli/app/skill/repository/skill_repository.py +33 -0
- campfire_cli/app/skill/schema/skill_schema.py +18 -0
- campfire_cli/app/skill/service/skill_service.py +146 -0
- campfire_cli/app/workspace/SPEC.md +25 -0
- campfire_cli/app/workspace/__init__.py +1 -0
- campfire_cli/app/workspace/cli/__init__.py +1 -0
- campfire_cli/app/workspace/cli/adoption_cli.py +83 -0
- campfire_cli/app/workspace/cli/config_cli.py +30 -0
- campfire_cli/app/workspace/cli/domain_cli.py +101 -0
- campfire_cli/app/workspace/cli/project_cli.py +135 -0
- campfire_cli/app/workspace/cli/restructure_cli.py +133 -0
- campfire_cli/app/workspace/cli/space_cli.py +65 -0
- campfire_cli/app/workspace/cli/workspace_cli.py +145 -0
- campfire_cli/app/workspace/repository/__init__.py +1 -0
- campfire_cli/app/workspace/repository/adoption_repository.py +55 -0
- campfire_cli/app/workspace/repository/manifest_repository.py +38 -0
- campfire_cli/app/workspace/repository/restructure_repository.py +93 -0
- campfire_cli/app/workspace/repository/workspace_repository.py +125 -0
- campfire_cli/app/workspace/schema/__init__.py +1 -0
- campfire_cli/app/workspace/schema/adoption_schema.py +47 -0
- campfire_cli/app/workspace/schema/restructure_schema.py +64 -0
- campfire_cli/app/workspace/schema/workspace_schema.py +225 -0
- campfire_cli/app/workspace/service/__init__.py +1 -0
- campfire_cli/app/workspace/service/adoption_protocol.py +11 -0
- campfire_cli/app/workspace/service/adoption_service.py +364 -0
- campfire_cli/app/workspace/service/config_service.py +189 -0
- campfire_cli/app/workspace/service/domain_restructure_service.py +316 -0
- campfire_cli/app/workspace/service/project_service.py +389 -0
- campfire_cli/app/workspace/service/restructure_protocol.py +19 -0
- campfire_cli/app/workspace/service/restructure_service.py +392 -0
- campfire_cli/app/workspace/service/restructure_verifier.py +83 -0
- campfire_cli/app/workspace/service/structure_service.py +494 -0
- campfire_cli/app/workspace/service/workspace_protocol.py +24 -0
- campfire_cli/app/workspace/service/workspace_service.py +304 -0
- campfire_cli/common/SPEC.md +12 -0
- campfire_cli/common/__init__.py +1 -0
- campfire_cli/common/database/__init__.py +4 -0
- campfire_cli/common/database/migrations.py +18 -0
- campfire_cli/common/database/models.py +215 -0
- campfire_cli/common/database/session.py +30 -0
- campfire_cli/common/documents/__init__.py +1 -0
- campfire_cli/common/documents/document_types.py +119 -0
- campfire_cli/common/documents/frontmatter_schema.py +42 -0
- campfire_cli/common/documents/markdown.py +66 -0
- campfire_cli/common/exceptions/__init__.py +7 -0
- campfire_cli/common/exceptions/base.py +19 -0
- campfire_cli/common/filesystem/__init__.py +6 -0
- campfire_cli/common/filesystem/atomic.py +30 -0
- campfire_cli/common/filesystem/locking.py +55 -0
- campfire_cli/common/governance/__init__.py +13 -0
- campfire_cli/common/governance/issues.py +38 -0
- campfire_cli/common/governance/locking.py +31 -0
- campfire_cli/common/governance/snapshots.py +26 -0
- campfire_cli/common/hashing.py +10 -0
- campfire_cli/common/reports/__init__.py +1 -0
- campfire_cli/common/reports/json_report.py +8 -0
- campfire_cli/common/reports/markdown_report.py +29 -0
- campfire_cli/config/SPEC.md +5 -0
- campfire_cli/config/defaults.py +64 -0
- campfire_cli/config/settings.py +50 -0
- campfire_cli/container.py +130 -0
- campfire_cli/main.py +133 -0
- campfire_cli/resources/__init__.py +1 -0
- campfire_cli/resources/bases/Agent/346/211/247/350/241/214/345/267/245/344/275/234/345/217/260.base +35 -0
- campfire_cli/resources/bases/SPEC.md +3 -0
- campfire_cli/resources/bases//344/273/273/345/212/241/345/267/245/344/275/234/345/217/260.base +49 -0
- campfire_cli/resources/bases//345/206/263/347/255/226/345/267/245/344/275/234/345/217/260.base +40 -0
- campfire_cli/resources/bases//345/276/205/345/275/222/346/241/243/346/226/207/346/241/243.base +28 -0
- campfire_cli/resources/bases//351/241/271/347/233/256/346/226/207/346/241/243.base +35 -0
- campfire_cli/resources/defaults/__init__.py +1 -0
- campfire_cli/resources/defaults/config.yml +458 -0
- campfire_cli/resources/skills/SPEC.md +3 -0
- campfire_cli/resources/skills/campfire-context-bootstrap/SKILL.md +62 -0
- campfire_cli/resources/skills/campfire-context-bootstrap/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-conversation-router/SKILL.md +53 -0
- campfire_cli/resources/skills/campfire-conversation-router/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/inbox-handoff.md +19 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/learning-intake.md +31 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/task-intake.md +28 -0
- campfire_cli/resources/skills/campfire-document-capture/SKILL.md +109 -0
- campfire_cli/resources/skills/campfire-inbox-triage/SKILL.md +23 -0
- campfire_cli/resources/skills/campfire-weekly-report-writing/SKILL.md +21 -0
- campfire_cli/resources/skills/campfire-weekly-report-writing/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-workspace-adoption/SKILL.md +42 -0
- campfire_cli/resources/skills/campfire-workspace-maintenance/SKILL.md +74 -0
- campfire_cli/resources/skills/campfire-workspace-maintenance/references//344/273/273/345/212/241/346/255/243/346/226/207/347/273/223/346/236/204.md +23 -0
- campfire_cli/resources/skills/campfire-workspace-restructure/SKILL.md +52 -0
- campfire_cli-0.1.0.dist-info/METADATA +141 -0
- campfire_cli-0.1.0.dist-info/RECORD +154 -0
- campfire_cli-0.1.0.dist-info/WHEEL +4 -0
- campfire_cli-0.1.0.dist-info/entry_points.txt +2 -0
- campfire_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from campfire_cli.app.workspace.schema.workspace_schema import WorkspaceConfigCheckResult
|
|
6
|
+
from campfire_cli.config.defaults import config_section
|
|
7
|
+
from campfire_cli.config.settings import WorkspaceSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkspaceConfigService:
|
|
11
|
+
"""Validate packaged product defaults and one Workspace's effective contracts."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, settings: WorkspaceSettings) -> None:
|
|
14
|
+
self.settings = settings
|
|
15
|
+
|
|
16
|
+
def check(self) -> WorkspaceConfigCheckResult:
|
|
17
|
+
issues: list[dict[str, Any]] = []
|
|
18
|
+
checked = ["config.yml"]
|
|
19
|
+
self._check_workspace_template(issues)
|
|
20
|
+
self._check_policy_lists(issues)
|
|
21
|
+
self._check_document_types(issues)
|
|
22
|
+
self._check_profiles(issues)
|
|
23
|
+
self._check_named_list("skills", self.settings.skills, "managed_skills", issues)
|
|
24
|
+
self._check_named_list("bases", self.settings.bases, "managed_bases", issues)
|
|
25
|
+
for field in (
|
|
26
|
+
"inbox",
|
|
27
|
+
"space_marker",
|
|
28
|
+
"domain_marker",
|
|
29
|
+
"related_limit",
|
|
30
|
+
"related_min_score",
|
|
31
|
+
"cross_domain_related_limit",
|
|
32
|
+
"cross_domain_min_score",
|
|
33
|
+
):
|
|
34
|
+
if not self.settings.governance.get(field):
|
|
35
|
+
self._issue(issues, "governance", field, "required-value-missing")
|
|
36
|
+
return WorkspaceConfigCheckResult(
|
|
37
|
+
status="ok" if not issues else "issues-found", checked=checked, issues=issues
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _check_workspace_template(self, issues: list[dict[str, Any]]) -> None:
|
|
41
|
+
config = config_section("workspace")
|
|
42
|
+
directories = config.get("system_directories", [])
|
|
43
|
+
spaces = config.get("spaces", [])
|
|
44
|
+
self._unique("workspace", "system_directories", directories, issues)
|
|
45
|
+
ids = [item.get("id") for item in spaces if isinstance(item, dict)]
|
|
46
|
+
paths = [item.get("path") for item in spaces if isinstance(item, dict)]
|
|
47
|
+
self._unique("workspace", "spaces.id", ids, issues)
|
|
48
|
+
self._unique("workspace", "spaces.path", paths, issues)
|
|
49
|
+
system_roots = {str(item).split("/", 1)[0] for item in directories}
|
|
50
|
+
for index, item in enumerate(spaces):
|
|
51
|
+
if not isinstance(item, dict):
|
|
52
|
+
self._issue(issues, "workspace", f"spaces.{index}", "object-required")
|
|
53
|
+
continue
|
|
54
|
+
for field in ("id", "name", "path", "type"):
|
|
55
|
+
if not item.get(field):
|
|
56
|
+
self._issue(
|
|
57
|
+
issues,
|
|
58
|
+
"workspace",
|
|
59
|
+
f"spaces.{index}.{field}",
|
|
60
|
+
"required-value-missing",
|
|
61
|
+
)
|
|
62
|
+
if item.get("path") in system_roots:
|
|
63
|
+
self._issue(
|
|
64
|
+
issues,
|
|
65
|
+
"workspace",
|
|
66
|
+
f"spaces.{index}.path",
|
|
67
|
+
"system-space-path-conflict",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _check_policy_lists(self, issues: list[dict[str, Any]]) -> None:
|
|
71
|
+
for name, fields in (
|
|
72
|
+
("project", ("statuses",)),
|
|
73
|
+
("archive", ("reasons", "reasons_requiring_successor")),
|
|
74
|
+
("issues", ("warning_codes",)),
|
|
75
|
+
):
|
|
76
|
+
config = config_section(name)
|
|
77
|
+
for field in fields:
|
|
78
|
+
self._unique(name, field, config.get(field), issues)
|
|
79
|
+
archive = config_section("archive")
|
|
80
|
+
unknown = set(archive.get("reasons_requiring_successor", [])) - set(
|
|
81
|
+
archive.get("reasons", [])
|
|
82
|
+
)
|
|
83
|
+
for value in sorted(unknown):
|
|
84
|
+
self._issue(
|
|
85
|
+
issues,
|
|
86
|
+
"archive",
|
|
87
|
+
"reasons_requiring_successor",
|
|
88
|
+
"unknown-reference",
|
|
89
|
+
value,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _check_document_types(self, issues: list[dict[str, Any]]) -> None:
|
|
93
|
+
config = self.settings.document_types
|
|
94
|
+
types = config.get("types")
|
|
95
|
+
if not isinstance(types, dict) or not types:
|
|
96
|
+
self._issue(issues, "document_types", "types", "mapping-required")
|
|
97
|
+
return
|
|
98
|
+
prefixes = [item.get("prefix") for item in types.values() if isinstance(item, dict)]
|
|
99
|
+
self._unique("document_types", "types.prefix", prefixes, issues)
|
|
100
|
+
for name, item in types.items():
|
|
101
|
+
if not isinstance(item, dict) or not item.get("prefix") or not item.get("label"):
|
|
102
|
+
self._issue(issues, "document_types", f"types.{name}", "invalid-type")
|
|
103
|
+
for profile, names in config.get("profiles", {}).items():
|
|
104
|
+
for name in names:
|
|
105
|
+
if name not in types:
|
|
106
|
+
self._issue(
|
|
107
|
+
issues,
|
|
108
|
+
"document_types",
|
|
109
|
+
f"profiles.{profile}",
|
|
110
|
+
"unknown-reference",
|
|
111
|
+
name,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _check_profiles(self, issues: list[dict[str, Any]]) -> None:
|
|
115
|
+
config = self.settings.frontmatter_schema
|
|
116
|
+
profiles = config.get("profiles")
|
|
117
|
+
if not isinstance(profiles, dict) or "base" not in profiles:
|
|
118
|
+
self._issue(issues, "frontmatter_schema", "profiles.base", "mapping-required")
|
|
119
|
+
return
|
|
120
|
+
for name, profile in profiles.items():
|
|
121
|
+
if not isinstance(profile, dict):
|
|
122
|
+
self._issue(
|
|
123
|
+
issues, "frontmatter_schema", f"profiles.{name}", "invalid-profile"
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
parent = profile.get("extends")
|
|
127
|
+
if parent and (parent != "base" or name == "base"):
|
|
128
|
+
self._issue(
|
|
129
|
+
issues,
|
|
130
|
+
"frontmatter_schema",
|
|
131
|
+
f"profiles.{name}.extends",
|
|
132
|
+
"invalid-profile-inheritance",
|
|
133
|
+
parent,
|
|
134
|
+
)
|
|
135
|
+
self._unique(
|
|
136
|
+
"frontmatter_schema",
|
|
137
|
+
f"profiles.{name}.field_order",
|
|
138
|
+
profile.get("field_order", []),
|
|
139
|
+
issues,
|
|
140
|
+
)
|
|
141
|
+
resolver = config.get("resolver", {})
|
|
142
|
+
referenced = [resolver.get("fallback")]
|
|
143
|
+
referenced.extend(resolver.get("profile_by_type", {}).values())
|
|
144
|
+
referenced.extend(resolver.get("profile_by_governance", {}).values())
|
|
145
|
+
for name in referenced:
|
|
146
|
+
if name not in profiles:
|
|
147
|
+
self._issue(
|
|
148
|
+
issues,
|
|
149
|
+
"frontmatter_schema",
|
|
150
|
+
"resolver",
|
|
151
|
+
"unknown-reference",
|
|
152
|
+
name,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _check_named_list(
|
|
156
|
+
self,
|
|
157
|
+
filename: str,
|
|
158
|
+
config: dict[str, Any],
|
|
159
|
+
field: str,
|
|
160
|
+
issues: list[dict[str, Any]],
|
|
161
|
+
) -> None:
|
|
162
|
+
self._unique(filename, field, config.get(field), issues)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def _unique(
|
|
166
|
+
cls,
|
|
167
|
+
filename: str,
|
|
168
|
+
field: str,
|
|
169
|
+
values: Any,
|
|
170
|
+
issues: list[dict[str, Any]],
|
|
171
|
+
) -> None:
|
|
172
|
+
if not isinstance(values, list) or any(
|
|
173
|
+
not isinstance(item, str) or not item for item in values
|
|
174
|
+
):
|
|
175
|
+
cls._issue(issues, filename, field, "non-empty-string-list-required")
|
|
176
|
+
return
|
|
177
|
+
duplicates = sorted({item for item in values if values.count(item) > 1})
|
|
178
|
+
for value in duplicates:
|
|
179
|
+
cls._issue(issues, filename, field, "duplicate-value", value)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _issue(
|
|
183
|
+
issues: list[dict[str, Any]],
|
|
184
|
+
path: str,
|
|
185
|
+
field: str,
|
|
186
|
+
code: str,
|
|
187
|
+
actual: Any = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
issues.append({"code": code, "path": path, "field": field, "actual": actual})
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from campfire_cli.app.maintenance.service.maintenance_service import MaintenanceService
|
|
8
|
+
from campfire_cli.app.workspace.repository.manifest_repository import (
|
|
9
|
+
WorkspaceManifestRepository,
|
|
10
|
+
)
|
|
11
|
+
from campfire_cli.app.workspace.schema.restructure_schema import DomainRestructureResult
|
|
12
|
+
from campfire_cli.app.workspace.schema.workspace_schema import Domain, ManifestProject
|
|
13
|
+
from campfire_cli.app.workspace.service.structure_service import (
|
|
14
|
+
DOMAIN_MARKER,
|
|
15
|
+
ID_RE,
|
|
16
|
+
DomainService,
|
|
17
|
+
parse_marker,
|
|
18
|
+
reserved_directories,
|
|
19
|
+
)
|
|
20
|
+
from campfire_cli.app.workspace.service.workspace_protocol import WorkspaceRepositoryProtocol
|
|
21
|
+
from campfire_cli.common.documents.markdown import parse_document, render_document
|
|
22
|
+
from campfire_cli.common.exceptions import ConfigurationError
|
|
23
|
+
from campfire_cli.common.filesystem import atomic_write, safe_path, workspace_write_lock
|
|
24
|
+
from campfire_cli.config.settings import WorkspaceSettings
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DomainRestructureService:
|
|
28
|
+
"""Plan and execute structural changes to one declared Domain."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
settings: WorkspaceSettings,
|
|
33
|
+
workspace_repository: WorkspaceRepositoryProtocol,
|
|
34
|
+
maintenance: MaintenanceService,
|
|
35
|
+
manifest_repository: WorkspaceManifestRepository | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._settings = settings
|
|
38
|
+
self._workspaces = workspace_repository
|
|
39
|
+
self._maintenance = maintenance
|
|
40
|
+
self._manifests = manifest_repository or WorkspaceManifestRepository()
|
|
41
|
+
self._domains = DomainService(settings.vault_root, settings.state_root)
|
|
42
|
+
|
|
43
|
+
def rename(
|
|
44
|
+
self,
|
|
45
|
+
domain_id: str,
|
|
46
|
+
name: str,
|
|
47
|
+
*,
|
|
48
|
+
rename_directory: bool = False,
|
|
49
|
+
target_path: str | None = None,
|
|
50
|
+
project_name: str | None = None,
|
|
51
|
+
confirm: bool = False,
|
|
52
|
+
) -> DomainRestructureResult:
|
|
53
|
+
domain = self._domain(domain_id)
|
|
54
|
+
new_name = name.strip()
|
|
55
|
+
if not new_name:
|
|
56
|
+
raise ConfigurationError("Domain name 不能为空")
|
|
57
|
+
if target_path and rename_directory:
|
|
58
|
+
raise ConfigurationError("--target-path 与 --rename-directory 只能使用一个")
|
|
59
|
+
target = domain.path
|
|
60
|
+
if target_path:
|
|
61
|
+
target = self._validate_target(target_path, domain)
|
|
62
|
+
elif rename_directory:
|
|
63
|
+
target = domain.path.with_name(self._directory_name(domain.path.name, new_name))
|
|
64
|
+
return self._change(
|
|
65
|
+
domain,
|
|
66
|
+
target=target,
|
|
67
|
+
name=new_name,
|
|
68
|
+
new_id=domain.id,
|
|
69
|
+
parent_domain=domain.parent_domain,
|
|
70
|
+
project_name=project_name,
|
|
71
|
+
confirm=confirm,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def move(
|
|
75
|
+
self,
|
|
76
|
+
domain_id: str,
|
|
77
|
+
target_path: str,
|
|
78
|
+
*,
|
|
79
|
+
parent_domain: str | None = None,
|
|
80
|
+
confirm: bool = False,
|
|
81
|
+
) -> DomainRestructureResult:
|
|
82
|
+
domain = self._domain(domain_id)
|
|
83
|
+
target = self._validate_target(target_path, domain)
|
|
84
|
+
if parent_domain:
|
|
85
|
+
parent = self._domain(parent_domain)
|
|
86
|
+
if parent.path not in target.parents:
|
|
87
|
+
raise ConfigurationError("目标路径必须位于指定父 Domain 下")
|
|
88
|
+
if parent.governance != domain.governance:
|
|
89
|
+
raise ConfigurationError("移动后的子 Domain 必须继承父 Domain governance")
|
|
90
|
+
return self._change(
|
|
91
|
+
domain,
|
|
92
|
+
target=target,
|
|
93
|
+
name=domain.name,
|
|
94
|
+
new_id=domain.id,
|
|
95
|
+
parent_domain=parent_domain,
|
|
96
|
+
project_name=None,
|
|
97
|
+
confirm=confirm,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def rekey(
|
|
101
|
+
self, domain_id: str, new_id: str, *, confirm: bool = False
|
|
102
|
+
) -> DomainRestructureResult:
|
|
103
|
+
if not ID_RE.fullmatch(new_id):
|
|
104
|
+
raise ConfigurationError("Domain id 只能使用小写字母、数字和连字符")
|
|
105
|
+
domain = self._domain(domain_id)
|
|
106
|
+
if any(item.id == new_id for item in self._domains.discover()[0]):
|
|
107
|
+
raise ConfigurationError(f"Domain id 已存在:{new_id}")
|
|
108
|
+
return self._change(
|
|
109
|
+
domain,
|
|
110
|
+
target=domain.path,
|
|
111
|
+
name=domain.name,
|
|
112
|
+
new_id=new_id,
|
|
113
|
+
parent_domain=domain.parent_domain,
|
|
114
|
+
project_name=None,
|
|
115
|
+
confirm=confirm,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _change(
|
|
119
|
+
self,
|
|
120
|
+
domain: Domain,
|
|
121
|
+
*,
|
|
122
|
+
target: Path,
|
|
123
|
+
name: str,
|
|
124
|
+
new_id: str,
|
|
125
|
+
parent_domain: str | None,
|
|
126
|
+
project_name: str | None,
|
|
127
|
+
confirm: bool,
|
|
128
|
+
) -> DomainRestructureResult:
|
|
129
|
+
old_relative = domain.path.relative_to(self._settings.vault_root).as_posix()
|
|
130
|
+
new_relative = target.relative_to(self._settings.vault_root).as_posix()
|
|
131
|
+
projects = [
|
|
132
|
+
project
|
|
133
|
+
for project in self._workspaces.list_projects(self._settings.workspace_id)
|
|
134
|
+
if project.document_domain == old_relative
|
|
135
|
+
]
|
|
136
|
+
operations: list[dict[str, str]] = []
|
|
137
|
+
if target != domain.path:
|
|
138
|
+
operations.append(
|
|
139
|
+
{"action": "move-domain", "source": old_relative, "path": new_relative}
|
|
140
|
+
)
|
|
141
|
+
if name != domain.name:
|
|
142
|
+
operations.append({"action": "rename-domain", "source": domain.name, "path": name})
|
|
143
|
+
if new_id != domain.id:
|
|
144
|
+
operations.append({"action": "rekey-domain", "source": domain.id, "path": new_id})
|
|
145
|
+
if parent_domain != domain.parent_domain:
|
|
146
|
+
operations.append(
|
|
147
|
+
{
|
|
148
|
+
"action": "update-parent-domain",
|
|
149
|
+
"source": domain.parent_domain or "none",
|
|
150
|
+
"path": parent_domain or "none",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
for project in projects:
|
|
154
|
+
operations.append({"action": "update-project", "path": project.id})
|
|
155
|
+
if not confirm:
|
|
156
|
+
return self._result(domain, new_id, name, new_relative, operations, projects, False)
|
|
157
|
+
if target != domain.path and target.exists():
|
|
158
|
+
raise ConfigurationError(f"目标路径已存在:{new_relative}")
|
|
159
|
+
with workspace_write_lock(self._settings.state_root):
|
|
160
|
+
current = self._domain(domain.id)
|
|
161
|
+
if current.path != domain.path or current.name != domain.name:
|
|
162
|
+
raise ConfigurationError("Domain 在预览后发生变化,请重新执行")
|
|
163
|
+
if target != domain.path:
|
|
164
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
domain.path.rename(target)
|
|
166
|
+
marker = target / DOMAIN_MARKER
|
|
167
|
+
self._update_marker(marker, name=name, domain_id=new_id, parent_domain=parent_domain)
|
|
168
|
+
if new_id != domain.id:
|
|
169
|
+
self._rewrite_child_parent_ids(target, domain.id, new_id)
|
|
170
|
+
self._rewrite_domain_id_references(domain.id, new_id)
|
|
171
|
+
self._rewrite_path_references(old_relative, new_relative)
|
|
172
|
+
for project in projects:
|
|
173
|
+
updated = project.model_copy(
|
|
174
|
+
update={
|
|
175
|
+
"document_domain": new_relative,
|
|
176
|
+
"name": project_name.strip() if project_name else project.name,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
self._workspaces.save_project(updated)
|
|
180
|
+
self._sync_manifest()
|
|
181
|
+
sync = self._maintenance.sync(scope=new_relative)
|
|
182
|
+
index = self._maintenance.check(summary=True)
|
|
183
|
+
operations.extend(
|
|
184
|
+
{str(key): str(value) for key, value in operation.items()}
|
|
185
|
+
for operation in sync.operations
|
|
186
|
+
)
|
|
187
|
+
domain_check = self._domains.check()
|
|
188
|
+
affected_prefix = f"{new_relative}/"
|
|
189
|
+
issues = [
|
|
190
|
+
issue
|
|
191
|
+
for issue in domain_check.issues
|
|
192
|
+
if str(issue.get("path", "")).startswith(affected_prefix)
|
|
193
|
+
or issue.get("path") == new_relative
|
|
194
|
+
]
|
|
195
|
+
issues.extend(issue.model_dump(mode="json") for issue in sync.issues)
|
|
196
|
+
issues.extend(issue.model_dump(mode="json") for issue in index.issues)
|
|
197
|
+
result = self._result(domain, new_id, name, new_relative, operations, projects, True)
|
|
198
|
+
if issues:
|
|
199
|
+
result.status = "needs-review"
|
|
200
|
+
result.issues = issues
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
def _domain(self, domain_id: str) -> Domain:
|
|
204
|
+
matches = [item for item in self._domains.discover()[0] if item.id == domain_id]
|
|
205
|
+
if len(matches) != 1:
|
|
206
|
+
raise ConfigurationError(f"Domain 不存在或不唯一:{domain_id}")
|
|
207
|
+
return matches[0]
|
|
208
|
+
|
|
209
|
+
def _validate_target(self, value: str, domain: Domain) -> Path:
|
|
210
|
+
target = safe_path(self._settings.vault_root, value)
|
|
211
|
+
spaces = self._domains.spaces.discover()[0]
|
|
212
|
+
owners = [
|
|
213
|
+
space for space in spaces if (self._settings.vault_root / space.path) in target.parents
|
|
214
|
+
]
|
|
215
|
+
if len(owners) != 1:
|
|
216
|
+
raise ConfigurationError("Domain 目标路径必须位于唯一一个已声明 Space 下")
|
|
217
|
+
relative_to_space = target.relative_to(self._settings.vault_root / owners[0].path)
|
|
218
|
+
if any(
|
|
219
|
+
part in reserved_directories() or part.startswith(".")
|
|
220
|
+
for part in relative_to_space.parts
|
|
221
|
+
):
|
|
222
|
+
raise ConfigurationError("Domain 目标路径不能使用保留目录")
|
|
223
|
+
if target != domain.path and target.exists():
|
|
224
|
+
raise ConfigurationError(f"目标路径已存在:{value}")
|
|
225
|
+
return target
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _directory_name(current: str, name: str) -> str:
|
|
229
|
+
if re.fullmatch(r"【[^】]+】文档中心", current):
|
|
230
|
+
return f"【{name}】文档中心"
|
|
231
|
+
return name
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _update_marker(
|
|
235
|
+
marker: Path, *, name: str, domain_id: str, parent_domain: str | None
|
|
236
|
+
) -> None:
|
|
237
|
+
parsed = parse_document(marker.read_text(encoding="utf-8"))
|
|
238
|
+
frontmatter = dict(parsed.frontmatter)
|
|
239
|
+
frontmatter["name"] = name
|
|
240
|
+
frontmatter["domain_id"] = domain_id
|
|
241
|
+
if parent_domain:
|
|
242
|
+
frontmatter["parent_domain"] = parent_domain
|
|
243
|
+
else:
|
|
244
|
+
frontmatter.pop("parent_domain", None)
|
|
245
|
+
body = re.sub(r"^# .+$", f"# {name}", parsed.body, count=1, flags=re.MULTILINE)
|
|
246
|
+
atomic_write(marker, render_document(frontmatter, body, list(frontmatter)))
|
|
247
|
+
|
|
248
|
+
def _rewrite_child_parent_ids(self, root: Path, old_id: str, new_id: str) -> None:
|
|
249
|
+
for marker in root.rglob(DOMAIN_MARKER):
|
|
250
|
+
if (
|
|
251
|
+
marker == root / DOMAIN_MARKER
|
|
252
|
+
or parse_marker(marker).get("parent_domain") != old_id
|
|
253
|
+
):
|
|
254
|
+
continue
|
|
255
|
+
parsed = parse_document(marker.read_text(encoding="utf-8"))
|
|
256
|
+
frontmatter = dict(parsed.frontmatter)
|
|
257
|
+
frontmatter["parent_domain"] = new_id
|
|
258
|
+
atomic_write(marker, render_document(frontmatter, parsed.body, list(frontmatter)))
|
|
259
|
+
|
|
260
|
+
def _rewrite_domain_id_references(self, old_id: str, new_id: str) -> None:
|
|
261
|
+
pattern = re.compile(
|
|
262
|
+
rf"(?m)^(\s*(?:domain|domain_id|parent_domain):\s*)(['\"]?){re.escape(old_id)}\2\s*$"
|
|
263
|
+
)
|
|
264
|
+
for path in self._settings.vault_root.rglob("*.md"):
|
|
265
|
+
if ".git" in path.relative_to(self._settings.vault_root).parts:
|
|
266
|
+
continue
|
|
267
|
+
text = path.read_text(encoding="utf-8")
|
|
268
|
+
updated = pattern.sub(rf"\g<1>\g<2>{new_id}\g<2>", text)
|
|
269
|
+
if updated != text:
|
|
270
|
+
atomic_write(path, updated)
|
|
271
|
+
|
|
272
|
+
def _rewrite_path_references(self, old: str, new: str) -> None:
|
|
273
|
+
if old == new:
|
|
274
|
+
return
|
|
275
|
+
ignored = {".git", ".obsidian"}
|
|
276
|
+
for path in self._settings.vault_root.rglob("*"):
|
|
277
|
+
if not path.is_file() or path.suffix.lower() not in {".md", ".canvas", ".base"}:
|
|
278
|
+
continue
|
|
279
|
+
if any(part in ignored for part in path.relative_to(self._settings.vault_root).parts):
|
|
280
|
+
continue
|
|
281
|
+
text = path.read_text(encoding="utf-8")
|
|
282
|
+
updated = text.replace(old, new).replace(quote(old), quote(new))
|
|
283
|
+
if updated != text:
|
|
284
|
+
atomic_write(path, updated)
|
|
285
|
+
|
|
286
|
+
def _sync_manifest(self) -> None:
|
|
287
|
+
manifest = self._manifests.load(self._settings.vault_root)
|
|
288
|
+
if manifest is None:
|
|
289
|
+
raise ConfigurationError("Workspace 缺少 .campfire.yaml")
|
|
290
|
+
manifest.projects = [
|
|
291
|
+
ManifestProject.model_validate(
|
|
292
|
+
project.model_dump(exclude={"workspace_id", "local_path"})
|
|
293
|
+
)
|
|
294
|
+
for project in self._workspaces.list_projects(self._settings.workspace_id)
|
|
295
|
+
]
|
|
296
|
+
self._manifests.save(self._settings.vault_root, manifest)
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _result(
|
|
300
|
+
domain: Domain,
|
|
301
|
+
domain_id: str,
|
|
302
|
+
name: str,
|
|
303
|
+
path: str,
|
|
304
|
+
operations: list[dict[str, str]],
|
|
305
|
+
projects: list,
|
|
306
|
+
written: bool,
|
|
307
|
+
) -> DomainRestructureResult:
|
|
308
|
+
return DomainRestructureResult(
|
|
309
|
+
status="applied" if written else "planned",
|
|
310
|
+
domain_id=domain_id,
|
|
311
|
+
name=name,
|
|
312
|
+
path=path,
|
|
313
|
+
operations=operations,
|
|
314
|
+
affected_projects=[project.id for project in projects],
|
|
315
|
+
write_performed=written,
|
|
316
|
+
)
|