dctracker 1.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.
- dct/__init__.py +6 -0
- dct/cli.py +1659 -0
- dct/config.py +150 -0
- dct/core/__init__.py +0 -0
- dct/core/analytics.py +382 -0
- dct/core/changelog.py +112 -0
- dct/core/checkpoints.py +205 -0
- dct/core/decisions.py +238 -0
- dct/core/engine.py +32 -0
- dct/core/handoff.py +151 -0
- dct/core/ids.py +25 -0
- dct/core/items.py +382 -0
- dct/core/plans/__init__.py +6 -0
- dct/core/plans/classify.py +94 -0
- dct/core/plans/crud.py +680 -0
- dct/core/plans/ingest.py +408 -0
- dct/core/plans/parser.py +312 -0
- dct/core/projects.py +298 -0
- dct/core/sprint.py +315 -0
- dct/core/sprints.py +601 -0
- dct/core/version.py +116 -0
- dct/db.py +558 -0
- dct/export.py +107 -0
- dct/hooks/check-changelog-on-stop.sh +49 -0
- dct/hooks/check-changelog.sh +143 -0
- dct/hooks/dct-plan-autoscan.sh +54 -0
- dct/hooks/dct-task-mirror.sh +62 -0
- dct/server.py +1812 -0
- dct/setup.py +258 -0
- dct/skills/clog/SKILL.md +73 -0
- dct/skills/commit/SKILL.md +153 -0
- dct/skills/dct-import/SKILL.md +329 -0
- dct/skills/dct-init/SKILL.md +289 -0
- dct/skills/handoff/SKILL.md +136 -0
- dct/skills/pickup/SKILL.md +115 -0
- dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
- dct/skills/plan-status/SKILL.md +79 -0
- dct/skills/release/SKILL.md +281 -0
- dct/skills/track/SKILL.md +121 -0
- dct/skills/track-list/SKILL.md +134 -0
- dct/skills/track-resolve/SKILL.md +53 -0
- dct/skills/track-update/SKILL.md +109 -0
- dct/web/__init__.py +1 -0
- dct/web/app.py +83 -0
- dct/web/blueprints/__init__.py +5 -0
- dct/web/blueprints/analytics.py +34 -0
- dct/web/blueprints/changelog.py +40 -0
- dct/web/blueprints/decisions.py +23 -0
- dct/web/blueprints/events.py +69 -0
- dct/web/blueprints/handoffs.py +26 -0
- dct/web/blueprints/home.py +15 -0
- dct/web/blueprints/items.py +128 -0
- dct/web/blueprints/plans.py +53 -0
- dct/web/blueprints/projects.py +23 -0
- dct/web/blueprints/sprints.py +71 -0
- dct/web/changes.py +77 -0
- dct/web/serializers.py +109 -0
- dct/web/static/app.css +609 -0
- dct/web/static/app.js +62 -0
- dct/web/static/vendor/SOURCES.txt +10 -0
- dct/web/static/vendor/htmx.min.js +1 -0
- dct/web/static/vendor/uPlot.iife.min.js +2 -0
- dct/web/static/vendor/uPlot.min.css +1 -0
- dct/web/templates/analytics.html +63 -0
- dct/web/templates/base.html +106 -0
- dct/web/templates/changelog/list.html +23 -0
- dct/web/templates/decisions/list.html +22 -0
- dct/web/templates/handoffs/list.html +45 -0
- dct/web/templates/home.html +20 -0
- dct/web/templates/item_detail.html +91 -0
- dct/web/templates/items_list.html +44 -0
- dct/web/templates/partials/_bars.html +15 -0
- dct/web/templates/partials/_checkpoint_steps.html +22 -0
- dct/web/templates/partials/_items_table.html +23 -0
- dct/web/templates/partials/_plan_section.html +24 -0
- dct/web/templates/partials/_project_card.html +24 -0
- dct/web/templates/plans/detail.html +16 -0
- dct/web/templates/plans/list.html +15 -0
- dct/web/templates/project_home.html +51 -0
- dct/web/templates/sprints/detail.html +37 -0
- dct/web/templates/sprints/list.html +35 -0
- dctracker-1.0.0.dist-info/METADATA +357 -0
- dctracker-1.0.0.dist-info/RECORD +87 -0
- dctracker-1.0.0.dist-info/WHEEL +5 -0
- dctracker-1.0.0.dist-info/entry_points.txt +2 -0
- dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
- dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/core/plans/ingest.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Ingest pipeline — glue from markdown file to DB state.
|
|
2
|
+
|
|
3
|
+
Flow per spec §9.1.3:
|
|
4
|
+
1. Read + parse (parser.parse_plan).
|
|
5
|
+
2. Ensure every parsed element has a ULID; fill missing parents.
|
|
6
|
+
3. If any ULID was generated → write file atomically.
|
|
7
|
+
4. Compute sha256 → source_hash.
|
|
8
|
+
5. first-import (no plans row, or imported_at=NULL) vs re-sync.
|
|
9
|
+
6. Upsert plans + sections + checkpoints (parent FK resolution via ulid→id map).
|
|
10
|
+
7. On first import only: respect MD checkbox `[x]` as `status='done'`.
|
|
11
|
+
8. Optional: resolve uncertain sections via `claude --print`.
|
|
12
|
+
9. Stamp imported_at on first import; always update source_hash.
|
|
13
|
+
|
|
14
|
+
Deliberately keeps no global state — all intermediate maps are local.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from dct.config import load_config
|
|
26
|
+
from dct.core.ids import new_ulid
|
|
27
|
+
from dct.core.plans import classify as classify_mod
|
|
28
|
+
from dct.core.plans.crud import (
|
|
29
|
+
add_plan_checkpoint,
|
|
30
|
+
add_plan_section,
|
|
31
|
+
create_plan,
|
|
32
|
+
get_plan,
|
|
33
|
+
get_plan_by_ulid,
|
|
34
|
+
get_plan_checkpoint_by_ulid,
|
|
35
|
+
get_plan_section_by_ulid,
|
|
36
|
+
set_plan_imported,
|
|
37
|
+
update_plan,
|
|
38
|
+
update_plan_section,
|
|
39
|
+
)
|
|
40
|
+
from dct.core.plans.parser import ParseResult, parse_plan, slugify
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class IngestReport:
|
|
45
|
+
plan_id: int
|
|
46
|
+
plan_ulid: str
|
|
47
|
+
is_first_import: bool
|
|
48
|
+
source_hash: str
|
|
49
|
+
sections_created: int = 0
|
|
50
|
+
sections_updated: int = 0
|
|
51
|
+
checkpoints_created: int = 0
|
|
52
|
+
uncertain_resolved: int = 0
|
|
53
|
+
uncertain_remaining: int = 0
|
|
54
|
+
ulids_injected: bool = False
|
|
55
|
+
warnings: list[str] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def as_dict(self) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"plan_id": self.plan_id,
|
|
60
|
+
"plan_ulid": self.plan_ulid,
|
|
61
|
+
"is_first_import": self.is_first_import,
|
|
62
|
+
"source_hash": self.source_hash,
|
|
63
|
+
"sections_created": self.sections_created,
|
|
64
|
+
"sections_updated": self.sections_updated,
|
|
65
|
+
"checkpoints_created": self.checkpoints_created,
|
|
66
|
+
"uncertain_resolved": self.uncertain_resolved,
|
|
67
|
+
"uncertain_remaining": self.uncertain_remaining,
|
|
68
|
+
"ulids_injected": self.ulids_injected,
|
|
69
|
+
"warnings": self.warnings,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── ULID + parent fill ────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def ensure_ulids_and_parents(result: ParseResult) -> bool:
|
|
76
|
+
"""Generate missing ULIDs on result in-place; fill missing parents via nesting.
|
|
77
|
+
|
|
78
|
+
Returns True iff any ULID was newly generated (caller must write-back).
|
|
79
|
+
"""
|
|
80
|
+
changed = False
|
|
81
|
+
if result.plan_ulid is None:
|
|
82
|
+
result.plan_ulid = new_ulid()
|
|
83
|
+
changed = True
|
|
84
|
+
|
|
85
|
+
# Section ULIDs first, so parent lookups resolve.
|
|
86
|
+
for s in result.sections:
|
|
87
|
+
if s.ulid is None:
|
|
88
|
+
s.ulid = new_ulid()
|
|
89
|
+
changed = True
|
|
90
|
+
|
|
91
|
+
# Parent lookups via depth-stack walk in file order.
|
|
92
|
+
stack: list[tuple[int, str]] = [] # (depth, ulid)
|
|
93
|
+
for s in result.sections:
|
|
94
|
+
while stack and stack[-1][0] >= s.depth:
|
|
95
|
+
stack.pop()
|
|
96
|
+
if s.parent_ulid is None and stack:
|
|
97
|
+
s.parent_ulid = stack[-1][1]
|
|
98
|
+
changed = True
|
|
99
|
+
stack.append((s.depth, s.ulid)) # type: ignore[arg-type]
|
|
100
|
+
|
|
101
|
+
# Checkpoint ULIDs + parent section ULID from the nearest preceding heading.
|
|
102
|
+
sections_in_order = sorted(result.sections, key=lambda s: s.heading_line)
|
|
103
|
+
for cp in result.checkpoints:
|
|
104
|
+
if cp.ulid is None:
|
|
105
|
+
cp.ulid = new_ulid()
|
|
106
|
+
changed = True
|
|
107
|
+
if cp.parent_section_ulid is None:
|
|
108
|
+
preceding = [s for s in sections_in_order if 0 < s.heading_line < cp.checkbox_line]
|
|
109
|
+
if preceding:
|
|
110
|
+
cp.parent_section_ulid = preceding[-1].ulid
|
|
111
|
+
changed = True
|
|
112
|
+
|
|
113
|
+
return changed
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Write-back ────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
CHECKBOX_PATTERN = re.compile(r"^(?P<prefix>\s*-\s+\[[ xX]\]\s+)(?P<rest>.*)$")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def write_back_anchors(content: str, result: ParseResult) -> str:
|
|
122
|
+
"""Return `content` augmented with missing ULID HTML anchors.
|
|
123
|
+
|
|
124
|
+
Inserts at these points:
|
|
125
|
+
- Plan anchor at top of file if `result.plan_anchor_line is None`.
|
|
126
|
+
- Section anchor on line AFTER heading_line when `anchor_line is None`.
|
|
127
|
+
- Checkpoint anchor inline (between `[ ]` marker and text) on each checkbox
|
|
128
|
+
line missing an existing cp anchor.
|
|
129
|
+
|
|
130
|
+
Idempotent: lines that already carry the anchor are left untouched.
|
|
131
|
+
Preserves trailing newline if the source had one.
|
|
132
|
+
"""
|
|
133
|
+
ends_with_newline = content.endswith("\n")
|
|
134
|
+
lines = content.splitlines()
|
|
135
|
+
|
|
136
|
+
# --- Checkpoint anchors: replace lines in-place (no line count change). ---
|
|
137
|
+
for cp in result.checkpoints:
|
|
138
|
+
if cp.ulid is None or cp.checkbox_line <= 0 or cp.checkbox_line > len(lines):
|
|
139
|
+
continue
|
|
140
|
+
idx = cp.checkbox_line - 1
|
|
141
|
+
original = lines[idx]
|
|
142
|
+
if "<!-- dct:cp:" in original:
|
|
143
|
+
continue # already has an anchor
|
|
144
|
+
m = CHECKBOX_PATTERN.match(original)
|
|
145
|
+
if not m:
|
|
146
|
+
continue
|
|
147
|
+
parent_part = f" parent={cp.parent_section_ulid}" if cp.parent_section_ulid else ""
|
|
148
|
+
lines[idx] = (
|
|
149
|
+
f"{m.group('prefix')}<!-- dct:cp:{cp.ulid}{parent_part} --> "
|
|
150
|
+
f"{m.group('rest').lstrip()}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# --- Section anchors: INSERT new line after heading_line (descending, so
|
|
154
|
+
# earlier insertions don't shift later line numbers). Collect first.
|
|
155
|
+
insertions: list[tuple[int, str]] = [] # (insert_at_0based, line)
|
|
156
|
+
for s in result.sections:
|
|
157
|
+
if s.ulid is None or s.anchor_line is not None:
|
|
158
|
+
continue
|
|
159
|
+
parent_part = f" parent={s.parent_ulid}" if s.parent_ulid else ""
|
|
160
|
+
line = f"<!-- dct:section:{s.ulid}{parent_part} -->"
|
|
161
|
+
# heading_line is 1-based → insert AFTER it → 0-based index = heading_line
|
|
162
|
+
insertions.append((s.heading_line, line))
|
|
163
|
+
|
|
164
|
+
# --- Plan anchor at top of file if missing. ---
|
|
165
|
+
if result.plan_anchor_line is None and result.plan_ulid:
|
|
166
|
+
insertions.append((0, f"<!-- dct:plan:{result.plan_ulid} -->"))
|
|
167
|
+
|
|
168
|
+
# Apply insertions in DESCENDING order of target position.
|
|
169
|
+
insertions.sort(key=lambda x: x[0], reverse=True)
|
|
170
|
+
for at_idx, new_line in insertions:
|
|
171
|
+
lines.insert(at_idx, new_line)
|
|
172
|
+
|
|
173
|
+
output = "\n".join(lines)
|
|
174
|
+
if ends_with_newline and not output.endswith("\n"):
|
|
175
|
+
output += "\n"
|
|
176
|
+
return output
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def write_file_atomic(path: Path, content: str) -> None:
|
|
180
|
+
"""Atomic write: temp file in same dir, then os.replace."""
|
|
181
|
+
tmp = path.with_suffix(path.suffix + ".dct-tmp")
|
|
182
|
+
tmp.write_text(content, encoding="utf-8")
|
|
183
|
+
os.replace(tmp, path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Kind inference from path ──────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
def infer_kind_from_path(path: Path) -> str:
|
|
189
|
+
"""Map a filesystem path to a plan `kind`.
|
|
190
|
+
|
|
191
|
+
Order matters — specific dirs first, then filename-based heuristics, then
|
|
192
|
+
a conservative default of 'plan'.
|
|
193
|
+
"""
|
|
194
|
+
parts = [p.lower() for p in path.parts]
|
|
195
|
+
name = path.name.lower()
|
|
196
|
+
|
|
197
|
+
if "adr" in parts or name.startswith("adr-"):
|
|
198
|
+
return "adr"
|
|
199
|
+
if "specs" in parts or name.endswith("-design.md") or "design-spec" in name:
|
|
200
|
+
return "spec"
|
|
201
|
+
if "plans" in parts:
|
|
202
|
+
return "plan"
|
|
203
|
+
if "retros" in parts or name.startswith("retro-") or "retrospective" in name:
|
|
204
|
+
return "retro"
|
|
205
|
+
if "roadmap" in name:
|
|
206
|
+
return "roadmap"
|
|
207
|
+
if name in {"todos.md", "future.md"} or "backlog" in name:
|
|
208
|
+
return "backlog"
|
|
209
|
+
return "plan"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── Main ingest function ─────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def ingest_file(
|
|
215
|
+
engine,
|
|
216
|
+
file_path: str | Path,
|
|
217
|
+
project_slug: str,
|
|
218
|
+
auto_resolve_uncertain: bool | None = None,
|
|
219
|
+
) -> dict:
|
|
220
|
+
"""Ingest a markdown plan file into dct.
|
|
221
|
+
|
|
222
|
+
`auto_resolve_uncertain`: when True, any section classified as 'uncertain'
|
|
223
|
+
is sent through `classify_uncertain()` (`claude --print`). None = read
|
|
224
|
+
the default from `~/.claude/dct.toml [plans].auto_resolve_uncertain`
|
|
225
|
+
(default true).
|
|
226
|
+
"""
|
|
227
|
+
path = Path(file_path).resolve()
|
|
228
|
+
if not path.is_file():
|
|
229
|
+
raise FileNotFoundError(f"Plan file not found: {path}")
|
|
230
|
+
|
|
231
|
+
if auto_resolve_uncertain is None:
|
|
232
|
+
auto_resolve_uncertain = load_config().plans.auto_resolve_uncertain
|
|
233
|
+
|
|
234
|
+
content = path.read_text(encoding="utf-8")
|
|
235
|
+
result = parse_plan(content)
|
|
236
|
+
|
|
237
|
+
ulids_injected = ensure_ulids_and_parents(result)
|
|
238
|
+
if ulids_injected:
|
|
239
|
+
content = write_back_anchors(content, result)
|
|
240
|
+
write_file_atomic(path, content)
|
|
241
|
+
# `plan_anchor_missing` was a legitimate complaint about the file
|
|
242
|
+
# state at parse time, but we just fixed it by injecting the anchor.
|
|
243
|
+
# Keep the report honest — drop that warning so callers don't see a
|
|
244
|
+
# false "missing" after we've visibly written the anchor to disk.
|
|
245
|
+
result.warnings = [w for w in result.warnings if w != "plan_anchor_missing"]
|
|
246
|
+
|
|
247
|
+
source_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
248
|
+
|
|
249
|
+
# Look up existing plan by ULID (stable across rename / move).
|
|
250
|
+
assert result.plan_ulid is not None # ensure_ulids guarantees this
|
|
251
|
+
existing = get_plan_by_ulid(engine, result.plan_ulid)
|
|
252
|
+
is_first_import = existing is None or existing.get("imported_at") is None
|
|
253
|
+
|
|
254
|
+
# Derive plan title + slug on first creation.
|
|
255
|
+
if existing is None:
|
|
256
|
+
title = _infer_title(result, path)
|
|
257
|
+
slug = _pick_unique_slug(engine, project_slug, slugify(path.stem))
|
|
258
|
+
kind = infer_kind_from_path(path)
|
|
259
|
+
plan = create_plan(
|
|
260
|
+
engine, project_slug, slug=slug, title=title, kind=kind,
|
|
261
|
+
source_file=str(path), source_hash=source_hash, ulid=result.plan_ulid,
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
update_plan(engine, existing["id"], source_hash=source_hash)
|
|
265
|
+
plan = get_plan(engine, existing["id"]) or existing
|
|
266
|
+
|
|
267
|
+
report = IngestReport(
|
|
268
|
+
plan_id=plan["id"], plan_ulid=result.plan_ulid,
|
|
269
|
+
is_first_import=is_first_import, source_hash=source_hash,
|
|
270
|
+
ulids_injected=ulids_injected, warnings=list(result.warnings),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# ── Sections ────────────────────────────────────────────────────────────
|
|
274
|
+
section_ulid_to_id: dict[str, int] = {}
|
|
275
|
+
for s in result.sections:
|
|
276
|
+
assert s.ulid is not None
|
|
277
|
+
|
|
278
|
+
# Resolve uncertain if requested.
|
|
279
|
+
section_type = s.section_type
|
|
280
|
+
confidence = s.section_type_confidence
|
|
281
|
+
needs_review = section_type == "uncertain"
|
|
282
|
+
if needs_review and auto_resolve_uncertain:
|
|
283
|
+
resolved = classify_mod.classify_uncertain(
|
|
284
|
+
s.heading,
|
|
285
|
+
parent_heading=_find_parent_heading(s, result.sections),
|
|
286
|
+
preview="",
|
|
287
|
+
)
|
|
288
|
+
if resolved:
|
|
289
|
+
section_type = resolved
|
|
290
|
+
confidence = "high"
|
|
291
|
+
needs_review = False
|
|
292
|
+
report.uncertain_resolved += 1
|
|
293
|
+
else:
|
|
294
|
+
report.uncertain_remaining += 1
|
|
295
|
+
elif needs_review:
|
|
296
|
+
report.uncertain_remaining += 1
|
|
297
|
+
|
|
298
|
+
parent_id = (
|
|
299
|
+
section_ulid_to_id.get(s.parent_ulid) if s.parent_ulid else None
|
|
300
|
+
)
|
|
301
|
+
existing_section = get_plan_section_by_ulid(engine, s.ulid)
|
|
302
|
+
if existing_section is None:
|
|
303
|
+
new_section = add_plan_section(
|
|
304
|
+
engine, plan["id"], s.heading, s.depth,
|
|
305
|
+
section_type=section_type,
|
|
306
|
+
section_type_confidence=confidence,
|
|
307
|
+
needs_llm_review=needs_review,
|
|
308
|
+
ulid=s.ulid,
|
|
309
|
+
parent_section_id=parent_id,
|
|
310
|
+
sort_order=s.sort_order,
|
|
311
|
+
)
|
|
312
|
+
section_ulid_to_id[s.ulid] = new_section["id"]
|
|
313
|
+
report.sections_created += 1
|
|
314
|
+
else:
|
|
315
|
+
updates: dict = {}
|
|
316
|
+
if existing_section["heading"] != s.heading:
|
|
317
|
+
updates["heading"] = s.heading
|
|
318
|
+
if existing_section["section_type"] != section_type:
|
|
319
|
+
# Sticky resolved: a row that was previously classified (e.g.
|
|
320
|
+
# by LLM to 'reference', or by a user via /plan-resolve-uncertain)
|
|
321
|
+
# must NOT be downgraded back to 'uncertain' on a later rescan
|
|
322
|
+
# where auto_resolve_uncertain is off or the LLM happens to
|
|
323
|
+
# fail. The parser always re-infers from heading text alone,
|
|
324
|
+
# so its 'uncertain' verdict is weaker than any resolved DB
|
|
325
|
+
# state. Only overwrite when the new classification is itself
|
|
326
|
+
# non-uncertain (i.e. parser found a better match, or the LLM
|
|
327
|
+
# resolved on this run).
|
|
328
|
+
if section_type == "uncertain" and existing_section["section_type"] != "uncertain":
|
|
329
|
+
pass # keep the resolved DB state
|
|
330
|
+
else:
|
|
331
|
+
updates["section_type"] = section_type
|
|
332
|
+
updates["section_type_confidence"] = confidence
|
|
333
|
+
updates["needs_llm_review"] = needs_review
|
|
334
|
+
if existing_section["sort_order"] != s.sort_order:
|
|
335
|
+
updates["sort_order"] = s.sort_order
|
|
336
|
+
if updates:
|
|
337
|
+
update_plan_section(engine, existing_section["id"], **updates)
|
|
338
|
+
report.sections_updated += 1
|
|
339
|
+
section_ulid_to_id[s.ulid] = existing_section["id"]
|
|
340
|
+
|
|
341
|
+
# ── Checkpoints ─────────────────────────────────────────────────────────
|
|
342
|
+
for cp in result.checkpoints:
|
|
343
|
+
assert cp.ulid is not None
|
|
344
|
+
if cp.parent_section_ulid is None:
|
|
345
|
+
continue # orphan checkpoint — warning already in result.warnings
|
|
346
|
+
section_id = section_ulid_to_id.get(cp.parent_section_ulid)
|
|
347
|
+
if section_id is None:
|
|
348
|
+
continue # parent not tracked (shouldn't happen after upsert)
|
|
349
|
+
existing_cp = get_plan_checkpoint_by_ulid(engine, cp.ulid)
|
|
350
|
+
if existing_cp is None:
|
|
351
|
+
initial_status = "pending"
|
|
352
|
+
if is_first_import and cp.initial_state == "done":
|
|
353
|
+
initial_status = "done"
|
|
354
|
+
add_plan_checkpoint(
|
|
355
|
+
engine, section_id, cp.text,
|
|
356
|
+
status=initial_status, ulid=cp.ulid,
|
|
357
|
+
sort_order=cp.sort_order,
|
|
358
|
+
)
|
|
359
|
+
report.checkpoints_created += 1
|
|
360
|
+
# Re-sync path: state is DB-only, MD brackets ignored. We DO NOT
|
|
361
|
+
# update cp.text on re-sync; treating text edit as a new checkpoint
|
|
362
|
+
# would lose state. Future work: add a `dct plan cp rename` command.
|
|
363
|
+
|
|
364
|
+
# ── Finalize ────────────────────────────────────────────────────────────
|
|
365
|
+
if is_first_import:
|
|
366
|
+
set_plan_imported(engine, plan["id"], source_hash=source_hash)
|
|
367
|
+
|
|
368
|
+
return report.as_dict()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ── Small helpers ────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
def _infer_title(result: ParseResult, path: Path) -> str:
|
|
374
|
+
"""Prefer the first H1 heading; fall back to the file stem."""
|
|
375
|
+
for s in result.sections:
|
|
376
|
+
if s.depth == 1:
|
|
377
|
+
return s.heading
|
|
378
|
+
return path.stem.replace("-", " ").replace("_", " ").title()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _pick_unique_slug(engine, project_slug: str, base_slug: str) -> str:
|
|
382
|
+
"""Find a slug not already taken in this project.
|
|
383
|
+
|
|
384
|
+
Appends `-2`, `-3`, … if needed. Caller is responsible for the race with
|
|
385
|
+
concurrent ingest (unlikely — importer is typically single-invocation).
|
|
386
|
+
"""
|
|
387
|
+
from dct.core.plans.crud import get_plan_by_slug
|
|
388
|
+
if not get_plan_by_slug(engine, project_slug, base_slug):
|
|
389
|
+
return base_slug
|
|
390
|
+
i = 2
|
|
391
|
+
while True:
|
|
392
|
+
candidate = f"{base_slug}-{i}"
|
|
393
|
+
if not get_plan_by_slug(engine, project_slug, candidate):
|
|
394
|
+
return candidate
|
|
395
|
+
i += 1
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _find_parent_heading(
|
|
399
|
+
section, sections: list
|
|
400
|
+
) -> str | None:
|
|
401
|
+
"""Given a ParsedSection, return the heading text of its structural parent."""
|
|
402
|
+
parent_ulid = section.parent_ulid
|
|
403
|
+
if not parent_ulid:
|
|
404
|
+
return None
|
|
405
|
+
for s in sections:
|
|
406
|
+
if s.ulid == parent_ulid:
|
|
407
|
+
return s.heading
|
|
408
|
+
return None
|