fusion-cli 1.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.
- fusion/__init__.py +3 -0
- fusion/_skills/fusion-analyst/SKILL.md +50 -0
- fusion/_skills/fusion-analyst/references/assess.md +12 -0
- fusion/_skills/fusion-analyst/references/compare.md +12 -0
- fusion/_skills/fusion-analyst/references/export.md +18 -0
- fusion/_skills/fusion-analyst/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-analyst/references/report.md +13 -0
- fusion/_skills/fusion-analyst/scripts/export.py +64 -0
- fusion/_skills/fusion-intake/SKILL.md +128 -0
- fusion/_skills/fusion-intake/references/convert.md +104 -0
- fusion/_skills/fusion-intake/references/delivery.md +107 -0
- fusion/_skills/fusion-intake/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-intake/references/gate.md +107 -0
- fusion/_skills/fusion-intake/scripts/convert.py +836 -0
- fusion/_skills/fusion-intake/scripts/gate.py +267 -0
- fusion/_skills/fusion-librarian/SKILL.md +64 -0
- fusion/_skills/fusion-librarian/references/archive.md +21 -0
- fusion/_skills/fusion-librarian/references/create.md +14 -0
- fusion/_skills/fusion-librarian/references/cross-reference.md +45 -0
- fusion/_skills/fusion-librarian/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-librarian/references/promote.md +24 -0
- fusion/_skills/fusion-librarian/references/query.md +17 -0
- fusion/_skills/fusion-librarian/references/reflect.md +47 -0
- fusion/_skills/fusion-librarian/references/restructure.md +20 -0
- fusion/_skills/fusion-librarian/references/tag.md +13 -0
- fusion/_skills/fusion-librarian/scripts/link-repair.py +371 -0
- fusion/_skills/fusion-planner/SKILL.md +62 -0
- fusion/_skills/fusion-planner/references/close.md +12 -0
- fusion/_skills/fusion-planner/references/create-activity.md +38 -0
- fusion/_skills/fusion-planner/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-planner/references/horizon.md +20 -0
- fusion/bucket.py +77 -0
- fusion/checker.py +248 -0
- fusion/cli.py +406 -0
- fusion/document.py +155 -0
- fusion/hub.py +78 -0
- fusion/indexer.py +75 -0
- fusion/ledger.py +106 -0
- fusion/manifest.py +33 -0
- fusion/scaffold.py +120 -0
- fusion/setup.py +300 -0
- fusion/views.py +111 -0
- fusion_cli-1.1.0.dist-info/METADATA +67 -0
- fusion_cli-1.1.0.dist-info/RECORD +46 -0
- fusion_cli-1.1.0.dist-info/WHEEL +4 -0
- fusion_cli-1.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "openpyxl>=3.1.0",
|
|
6
|
+
# "PyYAML>=6.0",
|
|
7
|
+
# "pymupdf>=1.24.0",
|
|
8
|
+
# ]
|
|
9
|
+
# ///
|
|
10
|
+
"""fusion-intake Stage-1 engine: admit / prepare / link / batch / cleanup.
|
|
11
|
+
|
|
12
|
+
Deterministic, no LLM. `admit` is the ONLY writer of sources/MANIFEST.md in
|
|
13
|
+
the whole Fusion reference implementation. `prepare` routes by format:
|
|
14
|
+
extractive files (xlsx/csv) become conformant documents directly; everything
|
|
15
|
+
else gets a work dir under workbench/.intake/ with page text + images +
|
|
16
|
+
manifest.json for the Stage-2 agent (references/convert.md). The page-
|
|
17
|
+
coverage invariant — every page recorded, low-text pages flagged
|
|
18
|
+
needs_vision — makes silent-empty output structurally impossible. `batch`
|
|
19
|
+
runs a validated admit+link op-list in one process for delivery-scale
|
|
20
|
+
intake (references/delivery.md) — it reuses `admit`/`link`, never a second
|
|
21
|
+
writer.
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import csv as csv_mod
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import posixpath
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import uuid
|
|
33
|
+
import zipfile
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
from email import policy
|
|
36
|
+
from email.parser import BytesParser
|
|
37
|
+
from html.parser import HTMLParser
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import yaml
|
|
41
|
+
|
|
42
|
+
AURORAS = ("commitments", "focus", "ops", "collab", "life", "explore",
|
|
43
|
+
"archive", "library")
|
|
44
|
+
|
|
45
|
+
EXTRACTIVE_EXTS = {".xlsx", ".csv"}
|
|
46
|
+
LIBREOFFICE_EXTS = {".docx", ".pptx", ".doc", ".odt", ".rtf", ".key",
|
|
47
|
+
".pages", ".ppt", ".xls", ".html", ".htm"}
|
|
48
|
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
49
|
+
MAIL_EXTS = {".eml"}
|
|
50
|
+
TEXT_EXTS = {".md", ".txt", ".json", ".yaml", ".yml"}
|
|
51
|
+
SUPPORTED_EXTS = (EXTRACTIVE_EXTS | LIBREOFFICE_EXTS | IMAGE_EXTS
|
|
52
|
+
| MAIL_EXTS | TEXT_EXTS | {".pdf"})
|
|
53
|
+
|
|
54
|
+
# Containers are DELIVERY VEHICLES, not originals: `unpack` extracts them
|
|
55
|
+
# into inbox/ and discards the zip — the members become the originals.
|
|
56
|
+
# They must never join SUPPORTED_EXTS; admit keeps refusing them.
|
|
57
|
+
CONTAINER_EXTS = {".zip", ".athena"}
|
|
58
|
+
|
|
59
|
+
# Zones a `link` doc path may point into — inbox/sources/workbench never
|
|
60
|
+
# hold finished documents, only originals and ephemeral staging.
|
|
61
|
+
DOC_ZONES = {"library", "activities", "output"}
|
|
62
|
+
|
|
63
|
+
TEXT_COVERAGE_MIN_CHARS = 100 # below this a page is scanned/figure
|
|
64
|
+
RENDER_DPI = 150
|
|
65
|
+
EXCEL_ERRORS = {"#REF!", "#N/A", "#VALUE!", "#DIV/0!", "#NAME?", "#NULL!",
|
|
66
|
+
"#NUM!"}
|
|
67
|
+
|
|
68
|
+
TODAY = datetime.now().strftime("%Y-%m-%d")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class IntakeError(Exception):
|
|
72
|
+
"""Strict-writer refusal — named loudly, never silently skipped."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── shared helpers ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def sha256_of(path: Path) -> str:
|
|
78
|
+
h = hashlib.sha256()
|
|
79
|
+
with open(path, "rb") as fh:
|
|
80
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
81
|
+
h.update(chunk)
|
|
82
|
+
return h.hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def slugify(name: str) -> str:
|
|
86
|
+
"""SPEC §4 filename: lowercase, hyphen-separated, stem <=60 chars."""
|
|
87
|
+
stem = re.sub(r"\.(xlsx|docx|pptx|csv|pdf|eml|md|txt|png|jpe?g|webp|gif|html?|json|ya?ml)$",
|
|
88
|
+
"", name, flags=re.IGNORECASE)
|
|
89
|
+
stem = stem.lower()
|
|
90
|
+
stem = re.sub(r"[^a-z0-9]+", "-", stem).strip("-")
|
|
91
|
+
stem = re.sub(r"-+", "-", stem)
|
|
92
|
+
return stem[:60].rstrip("-") or "document"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cell_to_string(cell) -> str:
|
|
96
|
+
if isinstance(cell, str) and cell.strip() in EXCEL_ERRORS:
|
|
97
|
+
return ""
|
|
98
|
+
if cell is None:
|
|
99
|
+
return ""
|
|
100
|
+
if isinstance(cell, bool):
|
|
101
|
+
return str(cell)
|
|
102
|
+
if isinstance(cell, float):
|
|
103
|
+
if cell.is_integer():
|
|
104
|
+
return str(int(cell))
|
|
105
|
+
return repr(cell) # shortest round-trippable form — verbatim, never rounded
|
|
106
|
+
if isinstance(cell, int):
|
|
107
|
+
return str(cell)
|
|
108
|
+
if isinstance(cell, datetime):
|
|
109
|
+
return cell.strftime("%Y-%m-%d")
|
|
110
|
+
text = str(cell).replace("|", "\\|").replace("\n", "<br>")
|
|
111
|
+
return text.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _row_empty(row) -> bool:
|
|
115
|
+
return all(cell_to_string(c) == "" for c in row)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def prune_empty_columns(rows):
|
|
119
|
+
if not rows:
|
|
120
|
+
return rows
|
|
121
|
+
width = max(len(r) for r in rows)
|
|
122
|
+
padded = [list(r) + [None] * (width - len(r)) for r in rows]
|
|
123
|
+
keep = [i for i in range(width)
|
|
124
|
+
if any(cell_to_string(r[i]) != "" for r in padded)]
|
|
125
|
+
return [[r[i] for i in keep] for r in padded] if keep else []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def rows_to_table(rows) -> str:
|
|
129
|
+
"""One markdown table. Every row, every column — no caps, no sampling."""
|
|
130
|
+
rows = [r for r in rows if not _row_empty(r)]
|
|
131
|
+
rows = prune_empty_columns(rows)
|
|
132
|
+
if not rows or not rows[0]:
|
|
133
|
+
return "*No data*"
|
|
134
|
+
string_rows = [[cell_to_string(c) for c in r] for r in rows]
|
|
135
|
+
lines = ["| " + " | ".join(string_rows[0]) + " |",
|
|
136
|
+
"| " + " | ".join(["---"] * len(rows[0])) + " |"]
|
|
137
|
+
lines += ["| " + " | ".join(r) + " |" for r in string_rows[1:]]
|
|
138
|
+
return "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render_document(fm: dict, summary: str, body: str) -> str:
|
|
142
|
+
front = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True,
|
|
143
|
+
width=2**31 - 1)
|
|
144
|
+
return f"---\n{front}---\n\n## Summary\n\n{summary}\n\n---\n\n{body}\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── MANIFEST (single writer lives here) ──────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def _manifest_path(root: Path) -> Path:
|
|
150
|
+
return root / "sources" / "MANIFEST.md"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def manifest_append(root: Path, rel: str, actor: str, sha: str) -> None:
|
|
154
|
+
path = _manifest_path(root)
|
|
155
|
+
if not path.is_file():
|
|
156
|
+
path.write_text("# Manifest\n\n| file | added | by | sha256 | library |\n"
|
|
157
|
+
"|---|---|---|---|---|\n", encoding="utf-8", newline="\n")
|
|
158
|
+
text = path.read_text(encoding="utf-8")
|
|
159
|
+
if not text.endswith("\n"):
|
|
160
|
+
text += "\n"
|
|
161
|
+
text += f"| {rel} | {TODAY} | {actor} | {sha} | — |\n"
|
|
162
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def manifest_link(root: Path, rel: str, doc: str) -> None:
|
|
166
|
+
path = _manifest_path(root)
|
|
167
|
+
if not path.is_file():
|
|
168
|
+
raise IntakeError(f"no manifest at {path}")
|
|
169
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
170
|
+
hit = False
|
|
171
|
+
for i, line in enumerate(lines):
|
|
172
|
+
if not line.strip().startswith("|"):
|
|
173
|
+
continue
|
|
174
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
175
|
+
if len(cells) == 5 and cells[0] == rel:
|
|
176
|
+
cells[4] = doc if cells[4] in ("—", "") else f"{cells[4]}, {doc}"
|
|
177
|
+
lines[i] = "| " + " | ".join(cells) + " |"
|
|
178
|
+
hit = True
|
|
179
|
+
break
|
|
180
|
+
if not hit:
|
|
181
|
+
raise IntakeError(f"source not in manifest: {rel}")
|
|
182
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _manifest_rels(root: Path) -> set:
|
|
186
|
+
"""The `file` column of every real row — used by `batch` to validate
|
|
187
|
+
(category, basename) uniqueness against what's already registered."""
|
|
188
|
+
path = _manifest_path(root)
|
|
189
|
+
if not path.is_file():
|
|
190
|
+
return set()
|
|
191
|
+
rels = set()
|
|
192
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
193
|
+
if not line.strip().startswith("|"):
|
|
194
|
+
continue
|
|
195
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
196
|
+
if len(cells) == 5 and cells[0] not in ("file", "---"):
|
|
197
|
+
rels.add(cells[0])
|
|
198
|
+
return rels
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── admit ────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def admit(root: Path, inbox_rel: str, category: str, actor: str) -> dict:
|
|
204
|
+
root = Path(root)
|
|
205
|
+
# Resolve the inbox root once and require the target to stay under it —
|
|
206
|
+
# inbox_rel is untrusted (may carry ../ traversal) and a LEXICAL
|
|
207
|
+
# relative_to() check downstream would accept a resolved parent that has
|
|
208
|
+
# already escaped. Check this BEFORE any other validation, including
|
|
209
|
+
# is_file() (mirrors unpack()'s pattern, commit 8851fd3) — admit is the
|
|
210
|
+
# only writer of sources/MANIFEST.md and every caller (batch included)
|
|
211
|
+
# goes through this one function, so the guard lives here once.
|
|
212
|
+
inbox_root = (root / "inbox").resolve()
|
|
213
|
+
src = root / "inbox" / inbox_rel
|
|
214
|
+
src_resolved = src.resolve()
|
|
215
|
+
if not src_resolved.is_relative_to(inbox_root):
|
|
216
|
+
raise IntakeError(f"inbox path escapes inbox/: {inbox_rel!r}")
|
|
217
|
+
if not src.is_file():
|
|
218
|
+
raise IntakeError(f"not in inbox/: {inbox_rel}")
|
|
219
|
+
if src.suffix.lower() not in SUPPORTED_EXTS:
|
|
220
|
+
raise IntakeError(
|
|
221
|
+
f"unsupported format: {src.suffix.lower()} — the gate refuses "
|
|
222
|
+
"what it cannot preserve; the file stays in inbox/")
|
|
223
|
+
if not actor or any(c.isspace() for c in actor):
|
|
224
|
+
raise IntakeError(f"actor must be a single token: {actor!r}")
|
|
225
|
+
category = category.strip().strip("/")
|
|
226
|
+
if not category:
|
|
227
|
+
raise IntakeError("category required")
|
|
228
|
+
if any(c in src.name for c in "|\n\r") or any(c in category for c in "|\n\r"):
|
|
229
|
+
raise IntakeError(
|
|
230
|
+
f"'|' and newlines break the manifest grammar: {src.name!r} — "
|
|
231
|
+
"rename the incoming file before admitting it")
|
|
232
|
+
dest = root / "sources" / category / src.name
|
|
233
|
+
if dest.exists():
|
|
234
|
+
raise IntakeError(
|
|
235
|
+
f"sources/ is immutable and {category}/{src.name} already "
|
|
236
|
+
"exists — rename the incoming file before admitting it")
|
|
237
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
shutil.move(str(src), str(dest))
|
|
239
|
+
sha = sha256_of(dest)
|
|
240
|
+
rel = f"{category}/{src.name}"
|
|
241
|
+
manifest_append(root, rel, actor, sha)
|
|
242
|
+
return {"source": rel, "sha256": sha, "manifest_row": True}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ── unpack: containers are delivery vehicles, not originals ─────────────
|
|
246
|
+
|
|
247
|
+
def unpack(root: Path, inbox_rel: str) -> dict:
|
|
248
|
+
"""Extract a container (.zip / .athena) sitting in inbox/ into a sibling
|
|
249
|
+
folder — the members become the originals, the container is discarded.
|
|
250
|
+
Does NOT write the ledger: the operator signs a `noted` entry via
|
|
251
|
+
`fusion log` (single-writer)."""
|
|
252
|
+
root = Path(root)
|
|
253
|
+
# Resolve the inbox root once and require every path we touch to stay
|
|
254
|
+
# under it — inbox_rel is untrusted (may carry ../ traversal) and a
|
|
255
|
+
# LEXICAL relative_to() check downstream would accept a resolved parent
|
|
256
|
+
# that has already escaped. Check this BEFORE any other validation.
|
|
257
|
+
inbox_root = (root / "inbox").resolve()
|
|
258
|
+
src = root / "inbox" / inbox_rel
|
|
259
|
+
src_resolved = src.resolve()
|
|
260
|
+
if not src_resolved.is_relative_to(inbox_root):
|
|
261
|
+
raise IntakeError(
|
|
262
|
+
f"container path escapes inbox/: {inbox_rel!r}")
|
|
263
|
+
if not src.is_file():
|
|
264
|
+
raise IntakeError(f"not in inbox/: {inbox_rel}")
|
|
265
|
+
if src.suffix.lower() not in CONTAINER_EXTS:
|
|
266
|
+
raise IntakeError(
|
|
267
|
+
f"not a container: {src.suffix.lower()} — unpack only handles "
|
|
268
|
+
f"{sorted(CONTAINER_EXTS)}")
|
|
269
|
+
if not zipfile.is_zipfile(src):
|
|
270
|
+
raise IntakeError(f"not a readable zip: {inbox_rel}")
|
|
271
|
+
|
|
272
|
+
stem = src_resolved.stem
|
|
273
|
+
dest = src_resolved.parent / stem # sibling of the container — nested
|
|
274
|
+
# vehicles keep their folder context, not
|
|
275
|
+
# inbox root — computed from the RESOLVED
|
|
276
|
+
# src so a traversal-crafted parent can't
|
|
277
|
+
# smuggle a dest outside inbox/ either.
|
|
278
|
+
if not dest.is_relative_to(inbox_root):
|
|
279
|
+
raise IntakeError(
|
|
280
|
+
f"container path escapes inbox/: {inbox_rel!r}")
|
|
281
|
+
dest_rel = str(dest.relative_to(inbox_root.parent))
|
|
282
|
+
if dest.exists():
|
|
283
|
+
raise IntakeError(
|
|
284
|
+
f"destination already exists: {dest_rel} — refusing to "
|
|
285
|
+
"merge a container into existing content")
|
|
286
|
+
|
|
287
|
+
with zipfile.ZipFile(src) as zf:
|
|
288
|
+
members = []
|
|
289
|
+
for info in zf.infolist():
|
|
290
|
+
if info.is_dir():
|
|
291
|
+
continue
|
|
292
|
+
name = info.filename
|
|
293
|
+
if name.startswith("__MACOSX/") or Path(name).name.startswith("._"):
|
|
294
|
+
continue
|
|
295
|
+
members.append(info)
|
|
296
|
+
|
|
297
|
+
# Validate every destination BEFORE writing anything — a hostile
|
|
298
|
+
# member fails the whole unpack, not just itself.
|
|
299
|
+
dest_resolved = dest.resolve()
|
|
300
|
+
targets = []
|
|
301
|
+
for info in members:
|
|
302
|
+
target = (dest / info.filename).resolve()
|
|
303
|
+
if not target.is_relative_to(dest_resolved):
|
|
304
|
+
raise IntakeError(
|
|
305
|
+
f"zip-slip attempt in {inbox_rel}: hostile member "
|
|
306
|
+
f"{info.filename!r} escapes {dest_rel}/ — unpack refused")
|
|
307
|
+
targets.append((info, target))
|
|
308
|
+
|
|
309
|
+
dest.mkdir(parents=True)
|
|
310
|
+
try:
|
|
311
|
+
for info, target in targets:
|
|
312
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
with zf.open(info) as fh, open(target, "wb") as out:
|
|
314
|
+
shutil.copyfileobj(fh, out)
|
|
315
|
+
except Exception:
|
|
316
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
317
|
+
raise
|
|
318
|
+
|
|
319
|
+
src.unlink()
|
|
320
|
+
return {"unpacked": len(targets), "dir": dest_rel}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ── prepare: routing + engines ───────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
def probe_pdf_text_layer(pdf_path: Path):
|
|
326
|
+
import fitz
|
|
327
|
+
records = []
|
|
328
|
+
doc = fitz.open(str(pdf_path))
|
|
329
|
+
try:
|
|
330
|
+
for i, page in enumerate(doc, 1):
|
|
331
|
+
text = page.get_text().strip()
|
|
332
|
+
records.append({"page": i, "text": text, "text_chars": len(text),
|
|
333
|
+
"needs_vision": len(text) < TEXT_COVERAGE_MIN_CHARS})
|
|
334
|
+
finally:
|
|
335
|
+
doc.close()
|
|
336
|
+
return records
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def render_pdf_pages(pdf_path: Path, out_dir: Path, pages=None):
|
|
340
|
+
import fitz
|
|
341
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
written = []
|
|
343
|
+
doc = fitz.open(str(pdf_path))
|
|
344
|
+
try:
|
|
345
|
+
targets = (list(range(1, doc.page_count + 1))
|
|
346
|
+
if pages is None else sorted(pages))
|
|
347
|
+
for n in targets:
|
|
348
|
+
if 1 <= n <= doc.page_count:
|
|
349
|
+
pix = doc.load_page(n - 1).get_pixmap(dpi=RENDER_DPI)
|
|
350
|
+
img = out_dir / f"page-{n:03d}.png"
|
|
351
|
+
pix.save(str(img))
|
|
352
|
+
written.append(img)
|
|
353
|
+
finally:
|
|
354
|
+
doc.close()
|
|
355
|
+
return written
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def require_soffice() -> str:
|
|
359
|
+
exe = shutil.which("soffice") or shutil.which("libreoffice")
|
|
360
|
+
if not exe:
|
|
361
|
+
raise IntakeError(
|
|
362
|
+
"LibreOffice not found: 'soffice' must be on PATH for "
|
|
363
|
+
"docx/pptx/legacy office formats (declared in SKILL.md "
|
|
364
|
+
"compatibility). Install it or add it to PATH. No fallback.")
|
|
365
|
+
return exe
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def soffice_to_pdf(src: Path, out_dir: Path) -> Path:
|
|
369
|
+
exe = require_soffice()
|
|
370
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
proc = subprocess.run(
|
|
372
|
+
[exe, "--headless", "--convert-to", "pdf", "--outdir",
|
|
373
|
+
str(out_dir), str(src)],
|
|
374
|
+
capture_output=True, text=True, timeout=300)
|
|
375
|
+
pdf = out_dir / (src.stem + ".pdf")
|
|
376
|
+
if proc.returncode != 0 or not pdf.exists():
|
|
377
|
+
raise IntakeError(
|
|
378
|
+
f"soffice failed on {src.name} (rc={proc.returncode}): "
|
|
379
|
+
f"{proc.stderr.strip()[:400]}")
|
|
380
|
+
return pdf
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class _HTMLText(HTMLParser):
|
|
384
|
+
"""Text of an HTML mail body: entities decoded (convert_charrefs),
|
|
385
|
+
script/style dropped whole, block tags become line breaks."""
|
|
386
|
+
_SKIP = {"script", "style"}
|
|
387
|
+
_BREAK = {"p", "br", "div", "tr", "li", "table",
|
|
388
|
+
"h1", "h2", "h3", "h4", "h5", "h6"}
|
|
389
|
+
|
|
390
|
+
def __init__(self):
|
|
391
|
+
super().__init__(convert_charrefs=True)
|
|
392
|
+
self._chunks: list = []
|
|
393
|
+
self._skip_depth = 0
|
|
394
|
+
|
|
395
|
+
def handle_starttag(self, tag, attrs):
|
|
396
|
+
if tag in self._SKIP:
|
|
397
|
+
self._skip_depth += 1
|
|
398
|
+
elif tag in self._BREAK:
|
|
399
|
+
self._chunks.append("\n")
|
|
400
|
+
|
|
401
|
+
def handle_endtag(self, tag):
|
|
402
|
+
if tag in self._SKIP and self._skip_depth:
|
|
403
|
+
self._skip_depth -= 1
|
|
404
|
+
elif tag in self._BREAK:
|
|
405
|
+
self._chunks.append("\n")
|
|
406
|
+
|
|
407
|
+
def handle_data(self, data):
|
|
408
|
+
if not self._skip_depth:
|
|
409
|
+
self._chunks.append(data)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def html_to_text(html: str) -> str:
|
|
413
|
+
parser = _HTMLText()
|
|
414
|
+
parser.feed(html)
|
|
415
|
+
parser.close()
|
|
416
|
+
lines = [re.sub(r"[ \t]+", " ", ln).strip()
|
|
417
|
+
for ln in "".join(parser._chunks).splitlines()]
|
|
418
|
+
return "\n".join(ln for ln in lines if ln)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def eml_to_text(path: Path, work_dir: Path):
|
|
422
|
+
msg = BytesParser(policy=policy.default).parse(open(path, "rb"))
|
|
423
|
+
lines = [f"{h}: {msg[h]}" for h in ("From", "To", "Date", "Subject")
|
|
424
|
+
if msg[h]]
|
|
425
|
+
body = msg.get_body(preferencelist=("plain", "html"))
|
|
426
|
+
content = body.get_content() if body else ""
|
|
427
|
+
if body and body.get_content_subtype() == "html":
|
|
428
|
+
content = html_to_text(content)
|
|
429
|
+
attachments = []
|
|
430
|
+
seen_lower = set()
|
|
431
|
+
for part in msg.iter_attachments():
|
|
432
|
+
name = Path(part.get_filename() or "attachment.bin").name
|
|
433
|
+
if name in {"", ".", ".."}:
|
|
434
|
+
name = "attachment.bin"
|
|
435
|
+
stem, dot, ext = name.partition(".")
|
|
436
|
+
n = 2
|
|
437
|
+
while name.lower() in seen_lower:
|
|
438
|
+
name = f"{stem}-{n}{dot}{ext}"
|
|
439
|
+
n += 1
|
|
440
|
+
(work_dir / name).write_bytes(part.get_payload(decode=True) or b"")
|
|
441
|
+
attachments.append(name)
|
|
442
|
+
seen_lower.add(name.lower())
|
|
443
|
+
return "\n".join(lines) + "\n\n" + content, attachments
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _route(ext: str) -> str:
|
|
447
|
+
ext = ext.lower()
|
|
448
|
+
if ext in EXTRACTIVE_EXTS:
|
|
449
|
+
return "extractive"
|
|
450
|
+
if ext in LIBREOFFICE_EXTS:
|
|
451
|
+
return "libreoffice"
|
|
452
|
+
if ext == ".pdf":
|
|
453
|
+
return "pdf"
|
|
454
|
+
if ext in IMAGE_EXTS:
|
|
455
|
+
return "image"
|
|
456
|
+
if ext in MAIL_EXTS:
|
|
457
|
+
return "mail"
|
|
458
|
+
if ext in TEXT_EXTS:
|
|
459
|
+
return "text"
|
|
460
|
+
raise IntakeError(f"unsupported format: {ext} — the gate refuses "
|
|
461
|
+
"what it cannot preserve")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _sheet_matrix(ws):
|
|
465
|
+
"""All cell values with merged ranges unfolded — every cell a merge
|
|
466
|
+
spans carries the anchor's value, so pruning never eats merged data."""
|
|
467
|
+
rows = [list(r) for r in ws.iter_rows(values_only=True)]
|
|
468
|
+
for rng in ws.merged_cells.ranges:
|
|
469
|
+
if rng.min_row - 1 >= len(rows):
|
|
470
|
+
continue
|
|
471
|
+
anchor_row = rows[rng.min_row - 1]
|
|
472
|
+
anchor = (anchor_row[rng.min_col - 1]
|
|
473
|
+
if rng.min_col - 1 < len(anchor_row) else None)
|
|
474
|
+
for r in range(rng.min_row, min(rng.max_row, len(rows)) + 1):
|
|
475
|
+
row = rows[r - 1]
|
|
476
|
+
for c in range(rng.min_col, min(rng.max_col, len(row)) + 1):
|
|
477
|
+
row[c - 1] = anchor
|
|
478
|
+
return rows
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _xlsx_body(path: Path):
|
|
482
|
+
import openpyxl
|
|
483
|
+
wb = openpyxl.load_workbook(path, data_only=True)
|
|
484
|
+
sections, sheets, rows_total = [], 0, 0
|
|
485
|
+
for name in wb.sheetnames:
|
|
486
|
+
rows = _sheet_matrix(wb[name])
|
|
487
|
+
live = [r for r in rows if not _row_empty(r)]
|
|
488
|
+
if live:
|
|
489
|
+
sheets += 1
|
|
490
|
+
rows_total += max(len(live) - 1, 0)
|
|
491
|
+
sections.append(f"## {name}\n\n{rows_to_table(rows)}")
|
|
492
|
+
return "\n\n".join(sections) or "*No data*", sheets, rows_total
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _csv_body(path: Path):
|
|
496
|
+
with open(path, "r", encoding="utf-8-sig", newline="") as fh:
|
|
497
|
+
rows = list(csv_mod.reader(fh))
|
|
498
|
+
return rows_to_table(rows), 1, max(len(rows) - 1, 0)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _work_dir(root: Path) -> Path:
|
|
502
|
+
d = root / "workbench" / ".intake" / uuid.uuid4().hex[:12]
|
|
503
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
504
|
+
return d
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _read_frontmatter(path: Path) -> dict:
|
|
508
|
+
"""Tolerant frontmatter reader: {} if the leading --- block is absent
|
|
509
|
+
or unparsable — reconcile must never choke on a hand-edited document."""
|
|
510
|
+
text = path.read_text(encoding="utf-8")
|
|
511
|
+
if not text.startswith("---\n"):
|
|
512
|
+
return {}
|
|
513
|
+
parts = text.split("---\n", 2)
|
|
514
|
+
if len(parts) < 3:
|
|
515
|
+
return {}
|
|
516
|
+
try:
|
|
517
|
+
fm = yaml.safe_load(parts[1])
|
|
518
|
+
except yaml.YAMLError:
|
|
519
|
+
return {}
|
|
520
|
+
return fm if isinstance(fm, dict) else {}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _merge_reconcile_fm(existing_fm: dict, seed: dict) -> dict:
|
|
524
|
+
"""The librarian curated the existing document — its values win.
|
|
525
|
+
Existing keys come first (unknown keys preserved verbatim, including
|
|
526
|
+
the original `created:`); title/type/aurora are filled from the seed
|
|
527
|
+
only where the existing doc lacks them; `source:` always repoints to
|
|
528
|
+
the new original; `updated:` is always bumped to today."""
|
|
529
|
+
merged = dict(existing_fm)
|
|
530
|
+
for key in ("title", "type", "aurora"):
|
|
531
|
+
if not merged.get(key):
|
|
532
|
+
merged[key] = seed[key]
|
|
533
|
+
if "created" not in merged:
|
|
534
|
+
merged["created"] = seed["created"]
|
|
535
|
+
merged["source"] = seed["source"]
|
|
536
|
+
merged["updated"] = TODAY
|
|
537
|
+
return merged
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def prepare(root: Path, source_rel: str, dest: str | None = None,
|
|
541
|
+
slug: str | None = None, doc_type: str | None = None,
|
|
542
|
+
aurora: str = "library", reconcile: bool = False) -> dict:
|
|
543
|
+
root = Path(root)
|
|
544
|
+
src = root / "sources" / source_rel
|
|
545
|
+
if not src.is_file():
|
|
546
|
+
raise IntakeError(f"not in sources/: {source_rel}")
|
|
547
|
+
if aurora not in AURORAS:
|
|
548
|
+
raise IntakeError(f"aurora must be one of the eight, got: {aurora!r}")
|
|
549
|
+
category = Path(source_rel).parts[0] if "/" in source_rel else "reference"
|
|
550
|
+
doc_type = doc_type or category.rstrip("s")
|
|
551
|
+
dest = (dest or f"library/{category}").strip("/")
|
|
552
|
+
slug = slug or slugify(src.name)
|
|
553
|
+
out_rel = f"{dest}/{slug}.md"
|
|
554
|
+
seed = {"title": src.stem, "type": doc_type, "aurora": aurora,
|
|
555
|
+
"source": f"sources/{source_rel}", "created": TODAY}
|
|
556
|
+
path = _route(src.suffix)
|
|
557
|
+
|
|
558
|
+
if path == "extractive":
|
|
559
|
+
if src.suffix.lower() == ".xlsx":
|
|
560
|
+
body, sheets, nrows = _xlsx_body(src)
|
|
561
|
+
else:
|
|
562
|
+
body, sheets, nrows = _csv_body(src)
|
|
563
|
+
summary = (f"Tabular data converted from {src.name}: "
|
|
564
|
+
f"{sheets} sheet(s), {nrows} data row(s).")
|
|
565
|
+
out = root / out_rel
|
|
566
|
+
if out.exists() and not reconcile:
|
|
567
|
+
raise IntakeError(
|
|
568
|
+
f"document exists: {out_rel} — pass --reconcile for a "
|
|
569
|
+
"confirmed update, or choose --slug")
|
|
570
|
+
if reconcile and out.exists():
|
|
571
|
+
seed = _merge_reconcile_fm(_read_frontmatter(out), seed)
|
|
572
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
573
|
+
out.write_text(render_document(seed, summary, body), encoding="utf-8",
|
|
574
|
+
newline="\n")
|
|
575
|
+
return {"path": "extractive", "done": True, "source": source_rel,
|
|
576
|
+
"output_file": out_rel, "front_matter_seed": seed,
|
|
577
|
+
"reconcile": reconcile}
|
|
578
|
+
|
|
579
|
+
# vision / structured paths get a work dir + manifest for Stage 2
|
|
580
|
+
out = root / out_rel
|
|
581
|
+
if out.exists() and not reconcile:
|
|
582
|
+
raise IntakeError(
|
|
583
|
+
f"document exists: {out_rel} — pass --reconcile for a "
|
|
584
|
+
"confirmed update, or choose --slug")
|
|
585
|
+
if reconcile and out.exists():
|
|
586
|
+
seed = _merge_reconcile_fm(_read_frontmatter(out), seed)
|
|
587
|
+
|
|
588
|
+
work = _work_dir(root)
|
|
589
|
+
record = {"path": path, "done": False, "source": source_rel,
|
|
590
|
+
"run_dir": str(work.relative_to(root)),
|
|
591
|
+
"output_file": out_rel, "front_matter_seed": seed,
|
|
592
|
+
"reconcile": reconcile,
|
|
593
|
+
"pages": [], "images": [], "attachments": [],
|
|
594
|
+
"intermediate_pdf": None}
|
|
595
|
+
|
|
596
|
+
if path == "libreoffice":
|
|
597
|
+
pdf = soffice_to_pdf(src, work)
|
|
598
|
+
record["intermediate_pdf"] = str(pdf.relative_to(root))
|
|
599
|
+
record["pages"] = probe_pdf_text_layer(pdf)
|
|
600
|
+
imgs = render_pdf_pages(pdf, work) # ALL pages
|
|
601
|
+
record["images"] = [str(p.relative_to(root)) for p in imgs]
|
|
602
|
+
elif path == "pdf":
|
|
603
|
+
probe = probe_pdf_text_layer(src)
|
|
604
|
+
record["pages"] = probe
|
|
605
|
+
if all(p["needs_vision"] for p in probe):
|
|
606
|
+
record["path"] = "pdf_scanned"
|
|
607
|
+
imgs = render_pdf_pages(src, work) # ALL pages
|
|
608
|
+
else:
|
|
609
|
+
record["path"] = "pdf_text"
|
|
610
|
+
imgs = render_pdf_pages(
|
|
611
|
+
src, work, pages=[p["page"] for p in probe if p["needs_vision"]])
|
|
612
|
+
record["images"] = [str(p.relative_to(root)) for p in imgs]
|
|
613
|
+
elif path == "image":
|
|
614
|
+
copy = work / src.name
|
|
615
|
+
shutil.copy2(src, copy)
|
|
616
|
+
record["pages"] = [{"page": 1, "text": "", "text_chars": 0,
|
|
617
|
+
"needs_vision": True}]
|
|
618
|
+
record["images"] = [str(copy.relative_to(root))]
|
|
619
|
+
elif path == "mail":
|
|
620
|
+
text, attachments = eml_to_text(src, work)
|
|
621
|
+
record["pages"] = [{"page": 1, "text": text,
|
|
622
|
+
"text_chars": len(text), "needs_vision": False}]
|
|
623
|
+
record["attachments"] = attachments
|
|
624
|
+
else: # text passthrough
|
|
625
|
+
text = src.read_text(encoding="utf-8", errors="replace")
|
|
626
|
+
record["pages"] = [{"page": 1, "text": text,
|
|
627
|
+
"text_chars": len(text), "needs_vision": False}]
|
|
628
|
+
|
|
629
|
+
record["page_count"] = len(record["pages"])
|
|
630
|
+
manifest = work / "manifest.json"
|
|
631
|
+
manifest.write_text(json.dumps(record, indent=2), encoding="utf-8",
|
|
632
|
+
newline="\n")
|
|
633
|
+
record["manifest"] = str(manifest.relative_to(root))
|
|
634
|
+
return record
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def link(root: Path, source_rel: str, doc_rel: str) -> dict:
|
|
638
|
+
manifest_link(Path(root), source_rel, doc_rel)
|
|
639
|
+
return {"source": source_rel, "library": doc_rel}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ── batch: one op-list, validated whole, then moved ──────────────────────
|
|
643
|
+
#
|
|
644
|
+
# Schema (references/delivery.md):
|
|
645
|
+
# {"admits": [{"file": "<inbox-rel>", "category": "<cat>"}],
|
|
646
|
+
# "links": [{"source": "<sources-rel>", "doc": "<zone-rel-doc>"}]}
|
|
647
|
+
#
|
|
648
|
+
# Semantics — two validation passes, never partial:
|
|
649
|
+
# 1. VALIDATE every admit and link syntactically/structurally BEFORE any
|
|
650
|
+
# filesystem or MANIFEST mutation (the gate's rule, batch-scale): a
|
|
651
|
+
# bad op anywhere in the list means nothing moves, nothing is
|
|
652
|
+
# appended — not even the good ops ahead of it.
|
|
653
|
+
# 2. EXECUTE all admits (reusing `admit()`, one MANIFEST row per file).
|
|
654
|
+
# 3. VALIDATE that every link's doc exists on disk NOW — admits already
|
|
655
|
+
# landed (the mechanical half doesn't roll back), but if any doc is
|
|
656
|
+
# missing NO link is written, not even the ones whose doc is fine.
|
|
657
|
+
# 4. EXECUTE all links (reusing `link()`).
|
|
658
|
+
|
|
659
|
+
def batch(root: Path, ops: dict, actor: str) -> dict:
|
|
660
|
+
root = Path(root)
|
|
661
|
+
if not actor or any(c.isspace() for c in actor):
|
|
662
|
+
raise IntakeError(f"actor must be a single token: {actor!r}")
|
|
663
|
+
|
|
664
|
+
admits = ops.get("admits", [])
|
|
665
|
+
links = ops.get("links", [])
|
|
666
|
+
|
|
667
|
+
# ── phase 1: validate every op, nothing touched yet ──────────────────
|
|
668
|
+
existing_rels = _manifest_rels(root)
|
|
669
|
+
batch_rels = set()
|
|
670
|
+
seen_files = set()
|
|
671
|
+
for i, op in enumerate(admits):
|
|
672
|
+
file = (op.get("file") or "").strip()
|
|
673
|
+
category = (op.get("category") or "").strip().strip("/")
|
|
674
|
+
if not file:
|
|
675
|
+
raise IntakeError(f"admits[{i}]: file required")
|
|
676
|
+
if not category:
|
|
677
|
+
raise IntakeError(f"admits[{i}]: category required")
|
|
678
|
+
if file in seen_files:
|
|
679
|
+
raise IntakeError(
|
|
680
|
+
f"admits[{i}]: {file!r} is admitted twice in this batch — "
|
|
681
|
+
"the first admit would move it before the second runs")
|
|
682
|
+
seen_files.add(file)
|
|
683
|
+
src = root / "inbox" / file
|
|
684
|
+
if not src.is_file():
|
|
685
|
+
raise IntakeError(f"admits[{i}]: not in inbox/: {file}")
|
|
686
|
+
if src.suffix.lower() not in SUPPORTED_EXTS:
|
|
687
|
+
raise IntakeError(
|
|
688
|
+
f"admits[{i}]: unsupported format: {src.suffix.lower()} — "
|
|
689
|
+
"the gate refuses what it cannot preserve; the file stays "
|
|
690
|
+
"in inbox/")
|
|
691
|
+
if any(c in src.name for c in "|\n\r") or any(c in category for c in "|\n\r"):
|
|
692
|
+
raise IntakeError(
|
|
693
|
+
f"admits[{i}]: '|' and newlines break the manifest "
|
|
694
|
+
f"grammar: {src.name!r}")
|
|
695
|
+
rel = f"{category}/{src.name}"
|
|
696
|
+
# A hand-placed file in sources/ with no MANIFEST row is a collision
|
|
697
|
+
# too — checking existing_rels alone lets a mixed batch admit past
|
|
698
|
+
# it, then fail mid-batch at admit()'s own dest.exists() with no
|
|
699
|
+
# rollback, breaking the all-or-nothing promise (references/delivery.md).
|
|
700
|
+
if rel in existing_rels or (root / "sources" / rel).exists():
|
|
701
|
+
raise IntakeError(
|
|
702
|
+
f"admits[{i}]: sources/ is immutable and {rel} already "
|
|
703
|
+
"exists — rename the incoming file before admitting it")
|
|
704
|
+
if rel in batch_rels:
|
|
705
|
+
raise IntakeError(
|
|
706
|
+
f"admits[{i}]: duplicate (category, basename) within this "
|
|
707
|
+
f"batch: {rel}")
|
|
708
|
+
batch_rels.add(rel)
|
|
709
|
+
|
|
710
|
+
for i, op in enumerate(links):
|
|
711
|
+
source = (op.get("source") or "").strip()
|
|
712
|
+
doc = (op.get("doc") or "").strip()
|
|
713
|
+
if not source:
|
|
714
|
+
raise IntakeError(f"links[{i}]: source required")
|
|
715
|
+
if not doc:
|
|
716
|
+
raise IntakeError(f"links[{i}]: doc required")
|
|
717
|
+
# Normalize before judging the zone — a LEXICAL split on the raw
|
|
718
|
+
# string ("library/../inbox/evil.md".split("/",1)[0] == "library")
|
|
719
|
+
# passes a path that actually resolves outside every zone. Collapse
|
|
720
|
+
# ".." segments first, then require: no leftover ".." (it climbed
|
|
721
|
+
# above the zone root), not absolute, first segment in DOC_ZONES.
|
|
722
|
+
norm_doc = posixpath.normpath(doc)
|
|
723
|
+
doc_parts = norm_doc.split("/")
|
|
724
|
+
if (posixpath.isabs(norm_doc) or doc_parts[0] == ".."
|
|
725
|
+
or doc_parts[0] not in DOC_ZONES):
|
|
726
|
+
raise IntakeError(
|
|
727
|
+
f"links[{i}]: doc must be zone-relative under "
|
|
728
|
+
f"{sorted(DOC_ZONES)}: {doc!r}")
|
|
729
|
+
op["doc"] = norm_doc # store/compare the NORMALIZED path — later
|
|
730
|
+
# phases (doc-exists check, link()) read
|
|
731
|
+
# this same op dict, never the raw input
|
|
732
|
+
if source not in existing_rels and source not in batch_rels:
|
|
733
|
+
raise IntakeError(
|
|
734
|
+
f"links[{i}]: link source will not exist: {source!r} — "
|
|
735
|
+
"not already registered in sources/ nor admitted by this "
|
|
736
|
+
"batch's own admits")
|
|
737
|
+
|
|
738
|
+
# ── phase 2: execute admits ───────────────────────────────────────────
|
|
739
|
+
admitted = [admit(root, op["file"], op["category"], actor) for op in admits]
|
|
740
|
+
|
|
741
|
+
# ── phase 3: validate every link's doc exists NOW, before any link ───
|
|
742
|
+
for i, op in enumerate(links):
|
|
743
|
+
doc = op["doc"].strip()
|
|
744
|
+
if not (root / doc).is_file():
|
|
745
|
+
raise IntakeError(
|
|
746
|
+
f"links[{i}]: doc does not exist: {doc!r} — write it "
|
|
747
|
+
"(Stage 2 conversion) before linking; no link in this "
|
|
748
|
+
"batch was written")
|
|
749
|
+
|
|
750
|
+
# ── phase 4: execute links ────────────────────────────────────────────
|
|
751
|
+
for op in links:
|
|
752
|
+
link(root, op["source"].strip(), op["doc"].strip())
|
|
753
|
+
|
|
754
|
+
return {"admitted": len(admitted), "linked": len(links)}
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def cleanup(run_dir: Path) -> None:
|
|
758
|
+
run_dir = Path(run_dir).resolve()
|
|
759
|
+
parts = run_dir.parts
|
|
760
|
+
if ".intake" not in parts or "workbench" not in parts:
|
|
761
|
+
raise IntakeError(f"refusing to delete outside workbench/.intake: {run_dir}")
|
|
762
|
+
if run_dir.exists():
|
|
763
|
+
shutil.rmtree(run_dir)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ── CLI ──────────────────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
def main(argv=None) -> int:
|
|
769
|
+
ap = argparse.ArgumentParser(description="fusion-intake Stage-1 engine")
|
|
770
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
771
|
+
|
|
772
|
+
p = sub.add_parser("admit", help="inbox -> sources + MANIFEST row")
|
|
773
|
+
p.add_argument("--bucket", required=True)
|
|
774
|
+
p.add_argument("--file", required=True, help="path relative to inbox/")
|
|
775
|
+
p.add_argument("--category", required=True)
|
|
776
|
+
p.add_argument("--actor", required=True)
|
|
777
|
+
|
|
778
|
+
p = sub.add_parser("unpack", help="container -> inbox folder "
|
|
779
|
+
"(vehicle, not an original)")
|
|
780
|
+
p.add_argument("--bucket", required=True)
|
|
781
|
+
p.add_argument("--file", required=True, help="path relative to inbox/")
|
|
782
|
+
|
|
783
|
+
p = sub.add_parser("prepare", help="route + convert / stage for vision")
|
|
784
|
+
p.add_argument("--bucket", required=True)
|
|
785
|
+
p.add_argument("--source", required=True, help="path relative to sources/")
|
|
786
|
+
p.add_argument("--dest", help="zone-relative output dir "
|
|
787
|
+
"(default library/<category>)")
|
|
788
|
+
p.add_argument("--slug")
|
|
789
|
+
p.add_argument("--type", dest="doc_type")
|
|
790
|
+
p.add_argument("--aurora", default="library")
|
|
791
|
+
p.add_argument("--reconcile", action="store_true",
|
|
792
|
+
help="confirmed update: reconcile the existing document "
|
|
793
|
+
"in place instead of refusing the slug collision")
|
|
794
|
+
|
|
795
|
+
p = sub.add_parser("link", help="set the MANIFEST library column")
|
|
796
|
+
p.add_argument("--bucket", required=True)
|
|
797
|
+
p.add_argument("--source", required=True)
|
|
798
|
+
p.add_argument("--doc", required=True)
|
|
799
|
+
|
|
800
|
+
p = sub.add_parser("cleanup", help="delete one work dir")
|
|
801
|
+
p.add_argument("--run-dir", required=True)
|
|
802
|
+
|
|
803
|
+
p = sub.add_parser("batch", help="validated multi-op admit+link in one "
|
|
804
|
+
"process (references/delivery.md)")
|
|
805
|
+
p.add_argument("--bucket", required=True)
|
|
806
|
+
p.add_argument("--ops", required=True,
|
|
807
|
+
help="path to a JSON op-list: {admits: [...], links: [...]}")
|
|
808
|
+
p.add_argument("--actor", default="claude")
|
|
809
|
+
|
|
810
|
+
args = ap.parse_args(argv)
|
|
811
|
+
try:
|
|
812
|
+
if args.cmd == "admit":
|
|
813
|
+
out = admit(Path(args.bucket), args.file, args.category, args.actor)
|
|
814
|
+
elif args.cmd == "unpack":
|
|
815
|
+
out = unpack(Path(args.bucket), args.file)
|
|
816
|
+
elif args.cmd == "prepare":
|
|
817
|
+
out = prepare(Path(args.bucket), args.source, dest=args.dest,
|
|
818
|
+
slug=args.slug, doc_type=args.doc_type,
|
|
819
|
+
aurora=args.aurora, reconcile=args.reconcile)
|
|
820
|
+
elif args.cmd == "link":
|
|
821
|
+
out = link(Path(args.bucket), args.source, args.doc)
|
|
822
|
+
elif args.cmd == "batch":
|
|
823
|
+
ops = json.loads(Path(args.ops).read_text(encoding="utf-8"))
|
|
824
|
+
out = batch(Path(args.bucket), ops, args.actor)
|
|
825
|
+
else:
|
|
826
|
+
cleanup(Path(args.run_dir))
|
|
827
|
+
out = {"cleaned": args.run_dir}
|
|
828
|
+
except IntakeError as exc:
|
|
829
|
+
print(f"intake: {exc}", file=sys.stderr)
|
|
830
|
+
return 1
|
|
831
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
832
|
+
return 0
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
if __name__ == "__main__":
|
|
836
|
+
raise SystemExit(main())
|