返回 ppt-master
1 #!/usr/bin/env python3
2 """PPT Master project-management CLI implementation.
3
4 Usage:
5 python3 scripts/project_manager.py init <project_name> [--format ppt169] [--dir <path>] [--quick-generate]
6 python3 scripts/project_manager.py import-sources <project_path> <source1> [<source2> ...] [--move | --copy]
7 python3 scripts/project_manager.py scaffold-spec <project_path>
8 python3 scripts/project_manager.py scaffold-lock <project_path>
9 python3 scripts/project_manager.py validate <project_path>
10 python3 scripts/project_manager.py info <project_path>
11 python3 scripts/project_manager.py page-context <project_path> P07 [--record-usage]
12 python3 scripts/project_manager.py page-context-report <project_path>
13
14 Examples:
15 python3 scripts/project_manager.py init demo --format ppt169
16 python3 scripts/project_manager.py validate projects/demo
17
18 Dependencies:
19 Standard library plus local PPT Master project and source-conversion modules.
20 """
21
22 from __future__ import annotations
23
24 import argparse
25 import filecmp
26 import json
27 import os
28 import re
29 import shutil
30 import subprocess
31 import sys
32 import tempfile
33 from datetime import datetime
34 from pathlib import Path
35 from urllib.parse import urlparse
36
37 from .page_context import (
38 build_page_context,
39 page_context_usage_report,
40 record_page_context_usage,
41 render_page_context,
42 )
43 from .paths import (
44 PROJECTS_ROOT,
45 REPO_ROOT,
46 SCRIPTS_DIR,
47 SOURCE_TO_MD_DIR,
48 )
49 from .project_specs import scaffold_project_artifact, validate_project_artifacts
50
51 if str(SCRIPTS_DIR) not in sys.path:
52 sys.path.insert(0, str(SCRIPTS_DIR))
53
54 from attribution_guard import require_skill_integrity # noqa: E402
55 from workflow_log import append_note # noqa: E402
56
57 try:
58 from project_utils import (
59 CANVAS_FORMATS,
60 get_project_info as get_project_info_common,
61 normalize_canvas_format,
62 validate_project_structure,
63 validate_svg_viewbox,
64 )
65 except ImportError:
66 tools_dir = SCRIPTS_DIR
67 if str(tools_dir) not in sys.path:
68 sys.path.insert(0, str(tools_dir))
69 from project_utils import ( # type: ignore
70 CANVAS_FORMATS,
71 get_project_info as get_project_info_common,
72 normalize_canvas_format,
73 validate_project_structure,
74 validate_svg_viewbox,
75 )
76
77 TOOLS_DIR = SCRIPTS_DIR
78 SOURCE_TO_MD_TOOLS_DIR = SOURCE_TO_MD_DIR
79 if str(SOURCE_TO_MD_TOOLS_DIR) not in sys.path:
80 sys.path.insert(0, str(SOURCE_TO_MD_TOOLS_DIR))
81
82 from _dispatcher import ( # noqa: E402
83 DOC_SUFFIXES,
84 EXCEL_SUFFIXES,
85 LEGACY_EXCEL_SUFFIXES,
86 PDF_SUFFIXES,
87 PRESENTATION_SUFFIXES,
88 build_conversion_command,
89 )
90
91 SOURCE_DIRNAME = "sources"
92 TEXT_SOURCE_SUFFIXES = {".md", ".markdown", ".txt"}
93 TABLE_TEXT_SUFFIXES = {".csv", ".tsv"}
94 BITMAP_IMAGE_SUFFIXES = {
95 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif",
96 }
97 IMAGE_ASSET_SUFFIXES = BITMAP_IMAGE_SUFFIXES | {
98 ".emf", ".wmf", ".svg",
99 }
100
101
102 def _validate_image_manifest(
103 payload: object,
104 path: Path,
105 ) -> list[dict]:
106 """Require a safe, case-insensitively unique image manifest payload."""
107 if not isinstance(payload, list):
108 raise RuntimeError(
109 f"Image manifest must be a JSON array: {path}"
110 )
111
112 seen_filenames: dict[str, str] = {}
113 for index, item in enumerate(payload):
114 if not isinstance(item, dict):
115 raise RuntimeError(
116 f"Existing image manifest item {index} must be an object: {path}"
117 )
118 filename = item.get("filename")
119 if (
120 not isinstance(filename, str)
121 or not filename.strip()
122 or filename in {".", ".."}
123 or "/" in filename
124 or "\\" in filename
125 or ":" in filename
126 or Path(filename).is_absolute()
127 or Path(filename).name != filename
128 ):
129 raise RuntimeError(
130 f"Image manifest item {index} has no safe bare filename: {path}"
131 )
132 normalized_filename = filename.casefold()
133 if normalized_filename in seen_filenames:
134 raise RuntimeError(
135 f"Image manifest filename {filename!r} conflicts with "
136 f"{seen_filenames[normalized_filename]!r} (case-insensitive): {path}"
137 )
138 seen_filenames[normalized_filename] = filename
139 return payload
140
141
142 def _read_existing_image_manifest(path: Path) -> list[dict]:
143 """Load an existing project image manifest or fail closed on corruption."""
144 if not path.exists():
145 return []
146 if not path.is_file():
147 raise RuntimeError(f"Existing image manifest is not a regular file: {path}")
148 try:
149 payload = json.loads(path.read_text(encoding="utf-8"))
150 except (OSError, json.JSONDecodeError) as exc:
151 raise RuntimeError(
152 f"Existing image manifest is unreadable: {path} ({exc}); "
153 "repair or restore it before importing more assets"
154 ) from exc
155 return _validate_image_manifest(payload, path)
156
157
158 def _write_json_atomic(path: Path, payload: object) -> None:
159 """Write JSON through a same-directory temporary file and atomic rename."""
160 fd, temp_name = tempfile.mkstemp(
161 prefix=f"{path.stem}.",
162 suffix=".tmp",
163 dir=str(path.parent),
164 )
165 try:
166 with os.fdopen(fd, "w", encoding="utf-8") as handle:
167 json.dump(payload, handle, ensure_ascii=False, indent=2)
168 handle.write("\n")
169 os.replace(temp_name, path)
170 except Exception:
171 try:
172 os.unlink(temp_name)
173 except OSError:
174 pass
175 raise
176
177
178 def is_url(value: str) -> bool:
179 """Return whether a string looks like an HTTP(S) URL."""
180 parsed = urlparse(value)
181 return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
182
183
184 def sanitize_name(value: str) -> str:
185 """Sanitize a user-facing name into a filesystem-safe token."""
186 safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in value.strip())
187 safe = safe.strip("._")
188 while "__" in safe:
189 safe = safe.replace("__", "_")
190 return safe[:120] or "source"
191
192
193 def derive_url_basename(url: str) -> str:
194 """Derive a stable base filename from a URL."""
195 parsed = urlparse(url)
196 parts = [sanitize_name(parsed.netloc)]
197 if parsed.path and parsed.path != "/":
198 path_part = sanitize_name(parsed.path.strip("/").replace("/", "_"))
199 if path_part:
200 parts.append(path_part)
201 return "_".join(part for part in parts if part) or "web_source"
202
203
204 def is_within_path(path: Path, parent: Path) -> bool:
205 """Return whether `path` resolves inside `parent`."""
206 try:
207 path.resolve().relative_to(parent.resolve())
208 return True
209 except ValueError:
210 return False
211
212
213 def _has_usable_import(summary: dict[str, list[str]]) -> bool:
214 """Return whether import-sources produced at least one usable source artifact."""
215 return any(
216 summary.get(key)
217 for key in ("archived", "markdown", "assets", "images", "analysis")
218 )
219
220
221 class ProjectManager:
222 """Create, inspect, validate, and populate project folders."""
223
224 CANVAS_FORMATS = CANVAS_FORMATS
225
226 def __init__(self, base_dir: str | Path | None = None) -> None:
227 self.base_dir = Path(base_dir) if base_dir is not None else Path.cwd() / "projects"
228
229 def scaffold_artifact(self, project_path: str, artifact: str) -> str:
230 """Delegate deterministic Markdown scaffold rendering."""
231 return scaffold_project_artifact(Path(project_path), artifact)
232
233 def init_project(
234 self,
235 project_name: str,
236 canvas_format: str = "ppt169",
237 base_dir: str | None = None,
238 *,
239 quick_generate: bool = False,
240 ) -> str:
241 base_path = Path(base_dir) if base_dir else self.base_dir
242
243 if (
244 not project_name
245 or project_name in {".", ".."}
246 or Path(project_name).is_absolute()
247 or "/" in project_name
248 or "\\" in project_name
249 ):
250 raise ValueError(
251 "Project name must be a single, non-absolute path component"
252 )
253
254 normalized_format = normalize_canvas_format(canvas_format)
255 if normalized_format not in self.CANVAS_FORMATS:
256 available = ", ".join(sorted(self.CANVAS_FORMATS.keys()))
257 raise ValueError(
258 f"Unsupported canvas format: {canvas_format} "
259 f"(available: {available}; common alias: xhs -> xiaohongshu)"
260 )
261
262 date_str = datetime.now().strftime("%Y%m%d")
263 # A name already carrying a `_<format>_<YYYYMMDD>` suffix (e.g. a full
264 # project dir name pasted back into init) is used as-is — re-appending
265 # would produce `name_ppt169_20260101_ppt169_20260102`.
266 if re.search(rf"_{re.escape(normalized_format)}_\d{{8}}$", project_name):
267 project_dir_name = project_name
268 else:
269 project_dir_name = f"{project_name}_{normalized_format}_{date_str}"
270 project_path = base_path / project_dir_name
271
272 if not is_within_path(project_path, base_path):
273 raise ValueError(
274 f"Project directory must stay within the base directory: {base_path}"
275 )
276 if project_path.exists():
277 raise FileExistsError(f"Project directory already exists: {project_path}")
278
279 project_dirs = (
280 ("svg_output",)
281 if quick_generate
282 else (
283 "svg_output",
284 "svg_final",
285 "images",
286 "icons",
287 "notes",
288 "templates",
289 "live_preview",
290 SOURCE_DIRNAME,
291 "analysis",
292 "validation",
293 "exports",
294 )
295 )
296 for rel_path in project_dirs:
297 (project_path / rel_path).mkdir(parents=True, exist_ok=True)
298
299 canvas_info = self.CANVAS_FORMATS[normalized_format]
300 if not quick_generate:
301 readme_path = project_path / "README.md"
302 readme_path.write_text(
303 (
304 f"# {project_name}\n\n"
305 f"- Canvas format: {normalized_format}\n"
306 f"- Created: {date_str}\n\n"
307 "## Directories\n\n"
308 "- `svg_output/`: raw SVG output\n"
309 "- `svg_final/`: self-contained SVG visual preview; may be inserted manually as an SVG image, but PowerPoint Convert to Shape is unsupported\n"
310 "- `images/`: runtime image pool; converter assets keep their original short filenames when possible\n"
311 "- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n"
312 "- `notes/`: speaker notes\n"
313 "- `templates/`: project templates\n"
314 "- `live_preview/`: browser preview runtime files and history (lock.json, server.log, edits.jsonl, annotations.jsonl)\n"
315 "- `sources/`: source materials and normalized markdown\n"
316 "- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n"
317 "- `validation/`: cold workflow audit log, SVG quality reports, and PPTX postflight audit reports\n"
318 "- `exports/`: final native DrawingML pptx deliverables only (timestamped); `_native_charts_tables.pptx` name with `--native-charts-and-tables`, `_narrated.pptx` name when narration audio is embedded\n"
319 "- `backup/<timestamp>/`: svg_output/ archive (always written in default-flow mode; safe to delete old timestamps)\n"
320 ),
321 encoding="utf-8",
322 )
323
324 print(f"Project created: {project_path}")
325 print(f"Canvas: {canvas_info['name']} ({canvas_info['dimensions']})")
326 return str(project_path)
327
328 def _source_dir(self, project_path: Path) -> Path:
329 sources_dir = project_path / SOURCE_DIRNAME
330 sources_dir.mkdir(parents=True, exist_ok=True)
331 return sources_dir
332
333 def _analysis_dir(self, project_path: Path) -> Path:
334 analysis_dir = project_path / "analysis"
335 analysis_dir.mkdir(parents=True, exist_ok=True)
336 return analysis_dir
337
338 def _ensure_unique_path(self, path: Path) -> Path:
339 if not path.exists():
340 return path
341
342 suffix = path.suffix
343 stem = path.stem
344 counter = 2
345 while True:
346 candidate = path.with_name(f"{stem}_{counter}{suffix}")
347 if not candidate.exists():
348 return candidate
349 counter += 1
350
351 def _copy_or_move_file(self, source: Path, destination: Path, move: bool) -> Path:
352 try:
353 if source.resolve() == destination.resolve():
354 return destination
355 except FileNotFoundError:
356 pass
357
358 destination = self._ensure_unique_path(destination)
359 if move:
360 shutil.move(str(source), str(destination))
361 else:
362 shutil.copy2(source, destination)
363 return destination
364
365 def _copy_or_move_tree(self, source: Path, destination: Path, move: bool) -> Path:
366 try:
367 if source.resolve() == destination.resolve():
368 return destination
369 except FileNotFoundError:
370 pass
371
372 destination = self._ensure_unique_path(destination)
373 if move:
374 shutil.move(str(source), str(destination))
375 else:
376 shutil.copytree(source, destination)
377 return destination
378
379 def _run_tool(self, args: list[str]) -> None:
380 child_env = os.environ.copy()
381 child_env["PYTHONUTF8"] = "1"
382 child_env["PYTHONIOENCODING"] = "utf-8:replace"
383 try:
384 result = subprocess.run(
385 args,
386 cwd=REPO_ROOT,
387 check=True,
388 capture_output=True,
389 text=True,
390 encoding="utf-8",
391 errors="replace",
392 env=child_env,
393 )
394 except FileNotFoundError as exc:
395 raise RuntimeError(f"Missing executable: {args[0]}") from exc
396 except subprocess.CalledProcessError as exc:
397 details = (exc.stderr or exc.stdout or "").strip()
398 raise RuntimeError(details or "tool execution failed") from exc
399
400 if result.stdout.strip():
401 print(result.stdout.strip())
402
403 def _import_pdf(self, pdf_path: Path, markdown_path: Path) -> None:
404 route = build_conversion_command(
405 str(pdf_path),
406 markdown_path,
407 forced_type="pdf",
408 )
409 self._run_tool(route.command)
410
411 def _import_doc(self, doc_path: Path, markdown_path: Path) -> None:
412 route = build_conversion_command(
413 str(doc_path),
414 markdown_path,
415 forced_type="doc",
416 )
417 self._run_tool(route.command)
418
419 def _import_presentation(self, presentation_path: Path, markdown_path: Path) -> None:
420 route = build_conversion_command(
421 str(presentation_path),
422 markdown_path,
423 forced_type="pptx",
424 )
425 self._run_tool(route.command)
426
427 def _import_pptx_intake(self, presentation_path: Path, project_dir: Path) -> Path:
428 # Multi-deck intake: each PPTX writes its own `<stem>.identity.json` /
429 # `<stem>.slide_library.json` and is merged into the single multi-deck
430 # index `analysis/source_profile.json` (one entry per source deck).
431 analysis_dir = self._analysis_dir(project_dir)
432 self._run_tool(
433 [
434 sys.executable,
435 str(TOOLS_DIR / "pptx_intake.py"),
436 str(presentation_path),
437 "-o",
438 str(analysis_dir),
439 ]
440 )
441 return analysis_dir
442
443 def _import_excel(self, excel_path: Path, markdown_path: Path) -> None:
444 route = build_conversion_command(
445 str(excel_path),
446 markdown_path,
447 forced_type="excel",
448 )
449 self._run_tool(route.command)
450
451 def _import_url(self, url: str, markdown_path: Path) -> None:
452 route = build_conversion_command(
453 url,
454 markdown_path,
455 forced_type="web",
456 )
457 self._run_tool(route.command)
458
459 def _is_valid_imported_url_markdown(self, markdown_path: Path) -> bool:
460 """Return whether web_to_md produced a usable Markdown source."""
461 if not markdown_path.is_file():
462 return False
463 content = markdown_path.read_text(encoding="utf-8", errors="replace")
464 if "[Failed URLs]:" in content:
465 return False
466 return bool(content.strip())
467
468 def _archive_url_record(self, sources_dir: Path, url: str) -> Path:
469 file_path = self._ensure_unique_path(sources_dir / f"{derive_url_basename(url)}.url.txt")
470 file_path.write_text(
471 f"URL: {url}\nImported: {datetime.now().isoformat(timespec='seconds')}\n",
472 encoding="utf-8",
473 )
474 return file_path
475
476 def _normalize_text_source(self, source_path: Path, sources_dir: Path) -> Path:
477 target = self._ensure_unique_path(sources_dir / f"{source_path.stem}.md")
478 content = source_path.read_text(encoding="utf-8", errors="replace")
479 target.write_text(content, encoding="utf-8")
480 return target
481
482 def _canonicalize_markdown_content(self, content: str) -> str:
483 canonical = content.replace("\r\n", "\n")
484 canonical = re.sub(r"(?m)^(\s*Crawled:\s+).*$", r"\1__IGNORED__", canonical)
485 canonical = re.sub(r"(?m)^(\s*Imported:\s+).*$", r"\1__IGNORED__", canonical)
486 canonical = re.sub(r"([^\s\]()/]+_files)/", "__ASSET_DIR__/", canonical)
487 return canonical.strip()
488
489 def _find_equivalent_markdown(self, source_path: Path, sources_dir: Path) -> Path | None:
490 source_content = source_path.read_text(encoding="utf-8", errors="replace")
491 canonical_source = self._canonicalize_markdown_content(source_content)
492
493 for existing in sorted(sources_dir.iterdir()):
494 if existing.suffix.lower() not in {".md", ".markdown"}:
495 continue
496 try:
497 if existing.resolve() == source_path.resolve():
498 continue
499 except FileNotFoundError:
500 pass
501
502 existing_content = existing.read_text(encoding="utf-8", errors="replace")
503 if self._canonicalize_markdown_content(existing_content) == canonical_source:
504 return existing
505
506 return None
507
508 def _companion_asset_dir(self, source_path: Path) -> Path | None:
509 candidate = source_path.with_name(f"{source_path.stem}_files")
510 if candidate.exists() and candidate.is_dir():
511 return candidate
512 return None
513
514 def _rewrite_markdown_asset_refs(
515 self,
516 markdown_path: Path,
517 original_asset_dirname: str,
518 imported_asset_dirname: str,
519 ) -> None:
520 if original_asset_dirname == imported_asset_dirname:
521 return
522
523 content = markdown_path.read_text(encoding="utf-8", errors="replace")
524 updated = content.replace(f"{original_asset_dirname}/", f"{imported_asset_dirname}/")
525 if updated != content:
526 markdown_path.write_text(updated, encoding="utf-8")
527
528 def _merge_image_manifest(self, source_items: list[dict], destination_manifest: Path) -> None:
529 """Merge per-source manifest items into the project-level manifest, keyed by filename."""
530 _validate_image_manifest(source_items, destination_manifest)
531 existing_data = _read_existing_image_manifest(destination_manifest)
532
533 new_by_filename: dict[str, dict] = {}
534 new_order: list[str] = []
535 for item in source_items:
536 filename = item.get("filename")
537 if not isinstance(filename, str):
538 continue
539 normalized_filename = filename.casefold()
540 if normalized_filename not in new_by_filename:
541 new_order.append(normalized_filename)
542 new_by_filename[normalized_filename] = item
543
544 merged: list[dict] = []
545 seen: set[str] = set()
546 for item in existing_data:
547 if not isinstance(item, dict):
548 continue
549 filename = item.get("filename")
550 if not isinstance(filename, str):
551 continue
552 normalized_filename = filename.casefold()
553 if normalized_filename in new_by_filename:
554 merged.append(new_by_filename[normalized_filename])
555 else:
556 merged.append(item)
557 seen.add(normalized_filename)
558
559 for normalized_filename in new_order:
560 if normalized_filename not in seen:
561 merged.append(new_by_filename[normalized_filename])
562
563 _validate_image_manifest(merged, destination_manifest)
564 _write_json_atomic(destination_manifest, merged)
565
566 @staticmethod
567 def _namespace_from_asset_dir(asset_dir: Path) -> str:
568 """Derive a per-source namespace from a `<stem>_files` companion directory name."""
569 name = asset_dir.name
570 suffix = "_files"
571 return name[:-len(suffix)] if name.endswith(suffix) else name
572
573 def _image_destination_name(
574 self,
575 images_dir: Path,
576 source_file: Path,
577 namespace: str,
578 existing_manifest: dict[str, dict],
579 occupied_names: set[str],
580 ) -> str:
581 """Return a short unique image filename for the runtime image pool."""
582 candidate = images_dir / source_file.name
583 if candidate.name.casefold() not in occupied_names:
584 return source_file.name
585 try:
586 meta = existing_manifest.get(candidate.name, {})
587 if (
588 meta.get("source_namespace") == namespace
589 and candidate.is_file()
590 and filecmp.cmp(source_file, candidate, shallow=False)
591 ):
592 return candidate.name
593 except OSError:
594 pass
595
596 stem = source_file.stem
597 suffix = source_file.suffix
598 counter = 2
599 while True:
600 candidate = images_dir / f"{stem}_{counter}{suffix}"
601 if candidate.name.casefold() not in occupied_names:
602 return candidate.name
603 try:
604 meta = existing_manifest.get(candidate.name, {})
605 if (
606 meta.get("source_namespace") == namespace
607 and candidate.is_file()
608 and filecmp.cmp(source_file, candidate, shallow=False)
609 ):
610 return candidate.name
611 except OSError:
612 pass
613 counter += 1
614
615 def _propagate_image_assets(self, asset_dir: Path, project_dir: Path) -> None:
616 """Copy converter-generated image assets and manifest into project images/.
617
618 Filenames are preserved when possible because source Markdown commonly
619 uses short names that are meaningful in context. Only real collisions
620 receive a compact numeric suffix.
621 """
622 manifest_path = asset_dir / "image_manifest.json"
623 if not manifest_path.is_file():
624 return
625
626 try:
627 source_payload = json.loads(manifest_path.read_text(encoding="utf-8"))
628 except (OSError, json.JSONDecodeError) as exc:
629 print(f"[WARN] Cannot read image manifest {manifest_path}: {exc}")
630 return
631 try:
632 source_data = _validate_image_manifest(source_payload, manifest_path)
633 except RuntimeError as exc:
634 print(f"[WARN] {exc}")
635 return
636
637 images_dir = project_dir / "images"
638 namespace = self._namespace_from_asset_dir(asset_dir)
639 destination_manifest = images_dir / "image_manifest.json"
640 existing_data = _read_existing_image_manifest(destination_manifest)
641 images_dir.mkdir(parents=True, exist_ok=True)
642
643 existing_manifest = {
644 item["filename"]: item
645 for item in existing_data
646 }
647 occupied_names = {
648 path.name.casefold()
649 for path in images_dir.iterdir()
650 if path.is_file()
651 }
652 rename_map: dict[str, str] = {}
653
654 copied_count = 0
655 for source_file in sorted(asset_dir.iterdir()):
656 if not source_file.is_file():
657 continue
658 if source_file.suffix.lower() not in IMAGE_ASSET_SUFFIXES:
659 continue
660 new_name = self._image_destination_name(
661 images_dir,
662 source_file,
663 namespace,
664 existing_manifest,
665 occupied_names,
666 )
667 destination = images_dir / new_name
668 if source_file.resolve() != destination.resolve():
669 shutil.copy2(source_file, destination)
670 occupied_names.add(new_name.casefold())
671 rename_map[source_file.name] = new_name
672 copied_count += 1
673
674 rebased_items: list[dict] = []
675 for item in source_data:
676 if not isinstance(item, dict):
677 continue
678 original = item.get("filename")
679 if not isinstance(original, str):
680 continue
681 new_item = dict(item)
682 new_item["filename"] = rename_map.get(original, original)
683 new_item["source_namespace"] = namespace
684 rebased_items.append(new_item)
685
686 self._merge_image_manifest(rebased_items, images_dir / "image_manifest.json")
687 print(
688 f"Propagated {copied_count} image asset(s) + manifest "
689 f"from {asset_dir} → images/ (namespace: {namespace})"
690 )
691
692 def _propagate_companion_image_assets(self, markdown_path: Path, project_dir: Path) -> None:
693 asset_dir = markdown_path.with_name(f"{markdown_path.stem}_files")
694 if asset_dir.is_dir():
695 self._propagate_image_assets(asset_dir, project_dir)
696
697 def _import_markdown_with_assets(
698 self,
699 source_path: Path,
700 sources_dir: Path,
701 move: bool,
702 ) -> tuple[Path, Path | None, str | None]:
703 archived_markdown = self._copy_or_move_file(
704 source_path,
705 sources_dir / source_path.name,
706 move=move,
707 )
708
709 profile_src = source_path.with_name(f"{source_path.stem}.conversion_profile.json")
710 if profile_src.is_file():
711 self._copy_or_move_file(
712 profile_src,
713 sources_dir / f"{archived_markdown.stem}.conversion_profile.json",
714 move=move,
715 )
716
717 asset_dir = self._companion_asset_dir(source_path)
718 if asset_dir is None:
719 return archived_markdown, None, None
720
721 imported_asset_dir = self._copy_or_move_tree(
722 asset_dir,
723 sources_dir / f"{archived_markdown.stem}_files",
724 move=move,
725 )
726 self._rewrite_markdown_asset_refs(
727 archived_markdown,
728 original_asset_dirname=asset_dir.name,
729 imported_asset_dirname=imported_asset_dir.name,
730 )
731
732 note = None
733 if archived_markdown.stem != source_path.stem:
734 note = (
735 f"{source_path}: renamed imported markdown to {archived_markdown.name} "
736 f"and rewrote asset references to {imported_asset_dir.name}/"
737 )
738 return archived_markdown, imported_asset_dir, note
739
740 def import_sources(
741 self,
742 project_path: str,
743 source_items: list[str],
744 move: bool = False,
745 copy: bool = False,
746 ) -> dict[str, list[str]]:
747 if move and copy:
748 raise ValueError("--move and --copy are mutually exclusive")
749 project_dir = Path(project_path)
750 if not project_dir.exists() or not project_dir.is_dir():
751 raise FileNotFoundError(f"Project directory not found: {project_dir}")
752 if not source_items:
753 raise ValueError("At least one source path or URL is required")
754
755 sources_dir = self._source_dir(project_dir)
756 summary: dict[str, list[str]] = {
757 "archived": [],
758 "url_records": [],
759 "markdown": [],
760 "assets": [],
761 "images": [],
762 "analysis": [],
763 "notes": [],
764 "skipped": [],
765 }
766
767 expanded_items: list[str] = []
768 supplied_dirs: list[Path] = []
769 for item in source_items:
770 if is_url(item):
771 expanded_items.append(item)
772 continue
773 item_path = Path(item)
774 if item_path.is_dir():
775 supplied_dirs.append(item_path)
776 directory_files = sorted(
777 path for path in item_path.iterdir() if path.is_file()
778 )
779 if directory_files:
780 expanded_items.extend(str(path) for path in directory_files)
781 summary["notes"].append(
782 f"{item}: expanded directory into {len(directory_files)} file(s)"
783 )
784 else:
785 summary["skipped"].append(f"{item}: directory contains no files")
786 continue
787 expanded_items.append(item)
788
789 explicit_markdown_stems = {
790 Path(item).stem
791 for item in expanded_items
792 if not is_url(item)
793 and Path(item).exists()
794 and Path(item).is_file()
795 and Path(item).suffix.lower() in {".md", ".markdown"}
796 }
797
798 for item in expanded_items:
799 if is_url(item):
800 markdown_path = self._ensure_unique_path(
801 sources_dir / f"{derive_url_basename(item)}.md"
802 )
803 try:
804 self._import_url(item, markdown_path)
805 except Exception as exc: # pragma: no cover - summary path
806 archived = self._archive_url_record(sources_dir, item)
807 summary["url_records"].append(str(archived))
808 summary["skipped"].append(f"{item}: {exc}")
809 continue
810
811 if not self._is_valid_imported_url_markdown(markdown_path):
812 markdown_path.unlink(missing_ok=True)
813 archived = self._archive_url_record(sources_dir, item)
814 summary["url_records"].append(str(archived))
815 summary["skipped"].append(f"{item}: URL conversion produced no usable Markdown")
816 continue
817
818 summary["markdown"].append(str(markdown_path))
819 self._propagate_companion_image_assets(markdown_path, project_dir)
820 continue
821
822 source_path = Path(item)
823 if not source_path.exists():
824 summary["skipped"].append(f"{item}: path not found")
825 continue
826 if source_path.is_dir():
827 summary["skipped"].append(f"{item}: directories are not supported")
828 continue
829
830 inside_projects = is_within_path(source_path, PROJECTS_ROOT)
831 if copy:
832 effective_move = False
833 elif inside_projects:
834 effective_move = True
835 else:
836 effective_move = False
837 if move and not inside_projects:
838 print(
839 f"note: {source_path} is outside {PROJECTS_ROOT}; copied "
840 f"(not moved). Only sources under projects/ may be moved.",
841 file=sys.stderr,
842 )
843 elif inside_projects and not move and not copy:
844 print(
845 f"note: {source_path} is under projects/; moved into the target "
846 f"project. Pass --copy to preserve it.",
847 file=sys.stderr,
848 )
849 suffix = source_path.suffix.lower()
850
851 if suffix in {".md", ".markdown"}:
852 duplicate_markdown = self._find_equivalent_markdown(source_path, sources_dir)
853 if duplicate_markdown is not None:
854 summary["markdown"].append(str(duplicate_markdown))
855 self._propagate_companion_image_assets(duplicate_markdown, project_dir)
856 summary["notes"].append(
857 f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}"
858 )
859 continue
860
861 archived_markdown, asset_dir, note = self._import_markdown_with_assets(
862 source_path,
863 sources_dir,
864 move=effective_move,
865 )
866 summary["archived"].append(str(archived_markdown))
867 summary["markdown"].append(str(archived_markdown))
868 if asset_dir is not None:
869 summary["assets"].append(str(asset_dir))
870 self._propagate_image_assets(asset_dir, project_dir)
871 if note:
872 summary["notes"].append(note)
873 continue
874
875 archived_path = self._copy_or_move_file(
876 source_path,
877 sources_dir / source_path.name,
878 move=effective_move,
879 )
880 summary["archived"].append(str(archived_path))
881
882 if suffix in BITMAP_IMAGE_SUFFIXES:
883 images_dir = project_dir / "images"
884 images_dir.mkdir(parents=True, exist_ok=True)
885 image_path = self._ensure_unique_path(images_dir / archived_path.name)
886 shutil.copy2(archived_path, image_path)
887 summary["images"].append(str(image_path))
888 if image_path.name != archived_path.name:
889 summary["notes"].append(
890 f"{item}: copied runtime image as {image_path.name} "
891 "to avoid a filename collision"
892 )
893 elif suffix in PDF_SUFFIXES:
894 canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
895 if archived_path.stem in explicit_markdown_stems:
896 summary["notes"].append(
897 f"{item}: skipped PDF auto-conversion because a same-stem Markdown source was provided"
898 )
899 continue
900 if canonical_markdown_path.exists():
901 summary["markdown"].append(str(canonical_markdown_path))
902 self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
903 summary["notes"].append(
904 f"{item}: skipped PDF auto-conversion because {canonical_markdown_path.name} already exists"
905 )
906 continue
907 markdown_path = canonical_markdown_path
908 try:
909 self._import_pdf(archived_path, markdown_path)
910 summary["markdown"].append(str(markdown_path))
911 self._propagate_companion_image_assets(markdown_path, project_dir)
912 except Exception as exc: # pragma: no cover - summary path
913 summary["skipped"].append(f"{item}: PDF conversion failed ({exc})")
914 elif suffix in PRESENTATION_SUFFIXES:
915 canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
916 try:
917 intake_dir = self._import_pptx_intake(archived_path, project_dir)
918 intake_str = str(intake_dir)
919 if intake_str not in summary["analysis"]:
920 summary["analysis"].append(intake_str)
921 except Exception as exc: # pragma: no cover - summary path
922 summary["notes"].append(f"{item}: PPTX intake analysis failed ({exc})")
923 if archived_path.stem in explicit_markdown_stems:
924 summary["notes"].append(
925 f"{item}: skipped presentation auto-conversion because a same-stem Markdown source was provided"
926 )
927 continue
928 if canonical_markdown_path.exists():
929 summary["markdown"].append(str(canonical_markdown_path))
930 self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
931 summary["notes"].append(
932 f"{item}: skipped presentation auto-conversion because {canonical_markdown_path.name} already exists"
933 )
934 continue
935 markdown_path = canonical_markdown_path
936 try:
937 self._import_presentation(archived_path, markdown_path)
938 summary["markdown"].append(str(markdown_path))
939 self._propagate_companion_image_assets(markdown_path, project_dir)
940 except Exception as exc: # pragma: no cover - summary path
941 summary["skipped"].append(f"{item}: presentation conversion failed ({exc})")
942 elif suffix in EXCEL_SUFFIXES:
943 canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
944 if archived_path.stem in explicit_markdown_stems:
945 summary["notes"].append(
946 f"{item}: skipped Excel auto-conversion because a same-stem Markdown source was provided"
947 )
948 continue
949 if canonical_markdown_path.exists():
950 summary["markdown"].append(str(canonical_markdown_path))
951 self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
952 summary["notes"].append(
953 f"{item}: skipped Excel auto-conversion because {canonical_markdown_path.name} already exists"
954 )
955 continue
956 markdown_path = canonical_markdown_path
957 try:
958 self._import_excel(archived_path, markdown_path)
959 summary["markdown"].append(str(markdown_path))
960 self._propagate_companion_image_assets(markdown_path, project_dir)
961 except Exception as exc: # pragma: no cover - summary path
962 summary["skipped"].append(f"{item}: Excel conversion failed ({exc})")
963 elif suffix in LEGACY_EXCEL_SUFFIXES:
964 summary["notes"].append(
965 f"{item}: archived only; legacy .xls is not converted automatically. "
966 "Resave as .xlsx to generate Markdown."
967 )
968 elif suffix in TABLE_TEXT_SUFFIXES:
969 summary["notes"].append(
970 f"{item}: archived as a plain-text table source; no Markdown conversion needed"
971 )
972 elif suffix in DOC_SUFFIXES:
973 canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
974 if archived_path.stem in explicit_markdown_stems:
975 summary["notes"].append(
976 f"{item}: skipped document auto-conversion because a same-stem Markdown source was provided"
977 )
978 continue
979 if canonical_markdown_path.exists():
980 summary["markdown"].append(str(canonical_markdown_path))
981 self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
982 summary["notes"].append(
983 f"{item}: skipped document auto-conversion because {canonical_markdown_path.name} already exists"
984 )
985 continue
986 markdown_path = canonical_markdown_path
987 try:
988 self._import_doc(archived_path, markdown_path)
989 summary["markdown"].append(str(markdown_path))
990 self._propagate_companion_image_assets(markdown_path, project_dir)
991 except Exception as exc: # pragma: no cover - summary path
992 summary["skipped"].append(f"{item}: document conversion failed ({exc})")
993 elif suffix == ".txt":
994 markdown_path = self._normalize_text_source(archived_path, sources_dir)
995 summary["markdown"].append(str(markdown_path))
996 else:
997 summary["notes"].append(f"{item}: archived only, no automatic conversion")
998
999 # Cleanup: only a projects-local source directory may be removed after
1000 # its files move into the target project. Every other location is copied
1001 # and remains untouched, even when the caller passes --move.
1002 for directory in supplied_dirs:
1003 if copy or not is_within_path(directory, PROJECTS_ROOT):
1004 continue
1005 if directory.is_dir() and not any(directory.iterdir()):
1006 try:
1007 directory.rmdir()
1008 except OSError:
1009 continue
1010 summary["notes"].append(
1011 f"{directory}: removed empty source directory after import"
1012 )
1013
1014 return summary
1015
1016 def validate_project(self, project_path: str) -> tuple[bool, list[str], list[str]]:
1017 project_path_obj = Path(project_path)
1018 _, errors, warnings = validate_project_structure(
1019 str(project_path_obj),
1020 validate_communication=False,
1021 )
1022
1023 if project_path_obj.exists() and project_path_obj.is_dir():
1024 project_info = get_project_info_common(str(project_path_obj))
1025 artifact_errors, artifact_warnings = validate_project_artifacts(
1026 project_path_obj,
1027 project_info,
1028 )
1029 errors.extend(artifact_errors)
1030 warnings.extend(artifact_warnings)
1031
1032 if project_path_obj.exists() and project_path_obj.is_dir():
1033 info = get_project_info_common(str(project_path_obj))
1034 if info.get("svg_files"):
1035 svg_files = [project_path_obj / "svg_output" / name for name in info["svg_files"]]
1036 expected_format = info.get("format")
1037 if expected_format == "unknown":
1038 expected_format = None
1039 warnings.extend(validate_svg_viewbox(svg_files, expected_format))
1040
1041 return not errors, list(dict.fromkeys(errors)), warnings
1042
1043 def get_project_info(self, project_path: str) -> dict[str, object]:
1044 shared = get_project_info_common(project_path)
1045 return {
1046 "name": shared.get("name", Path(project_path).name),
1047 "path": shared.get("path", str(project_path)),
1048 "exists": shared.get("exists", False),
1049 "svg_count": shared.get("svg_count", 0),
1050 "has_spec": shared.get("has_spec", False),
1051 "has_source": shared.get("has_source", False),
1052 "source_count": shared.get("source_count", 0),
1053 "canvas_format": shared.get("format_name", "Unknown"),
1054 "create_date": shared.get("date_formatted", "Unknown"),
1055 }
1056
1057
1058 def build_parser() -> argparse.ArgumentParser:
1059 """Build the command-line parser."""
1060 parser = argparse.ArgumentParser(
1061 description="PPT Master project management helpers.",
1062 formatter_class=argparse.RawDescriptionHelpFormatter,
1063 epilog="""Examples:
1064 python3 scripts/project_manager.py init demo --format ppt169
1065 python3 scripts/project_manager.py import-sources projects/demo file.md
1066 python3 scripts/project_manager.py scaffold-spec projects/demo_ppt169_20260718
1067 python3 scripts/project_manager.py scaffold-lock projects/demo_ppt169_20260718
1068 python3 scripts/project_manager.py validate projects/demo
1069 python3 scripts/project_manager.py info projects/demo
1070 python3 scripts/project_manager.py page-context projects/demo P07 --record-usage
1071 python3 scripts/project_manager.py page-context-report projects/demo
1072 """,
1073 )
1074 subparsers = parser.add_subparsers(dest="command", required=True)
1075
1076 init = subparsers.add_parser("init", help="Create a project directory")
1077 init.add_argument("project_name", help="Project name")
1078 init.add_argument("--format", default="ppt169", help="Canvas format (default: ppt169)")
1079 init.add_argument("--dir", default=None, help="Base directory for the project")
1080 init.add_argument(
1081 "--quick-generate",
1082 action="store_true",
1083 help=(
1084 "Create svg_output plus the validation workflow audit log and "
1085 "omit README.md"
1086 ),
1087 )
1088
1089 import_sources = subparsers.add_parser(
1090 "import-sources",
1091 help="Import source files or URLs into a project",
1092 )
1093 import_sources.add_argument("project_path", help="Project directory")
1094 import_sources.add_argument("sources", nargs="+", help="Source files, directories, or URLs")
1095 mode = import_sources.add_mutually_exclusive_group()
1096 mode.add_argument(
1097 "--move",
1098 action="store_true",
1099 help="Move local sources under projects/; sources elsewhere are copied",
1100 )
1101 mode.add_argument("--copy", action="store_true", help="Copy local source files")
1102
1103 scaffold_spec = subparsers.add_parser(
1104 "scaffold-spec",
1105 help="Create design_spec.md from the versioned scaffold",
1106 )
1107 scaffold_spec.add_argument("project_path", help="Project directory")
1108
1109 scaffold_lock = subparsers.add_parser(
1110 "scaffold-lock",
1111 help="Create spec_lock.md from the versioned scaffold",
1112 )
1113 scaffold_lock.add_argument("project_path", help="Project directory")
1114
1115 validate = subparsers.add_parser("validate", help="Validate a project directory")
1116 validate.add_argument("project_path", help="Project directory")
1117
1118 info = subparsers.add_parser("info", help="Print project metadata")
1119 info.add_argument("project_path", help="Project directory")
1120
1121 page_context = subparsers.add_parser(
1122 "page-context",
1123 help="Print one deterministic per-page execution view",
1124 )
1125 page_context.add_argument("project_path", help="Project directory")
1126 page_context.add_argument("page", help="Positive page key such as P07")
1127 page_context.add_argument(
1128 "--bundle",
1129 action="store_true",
1130 help="Deprecated compatibility flag; output remains compact",
1131 )
1132 page_context.add_argument(
1133 "--pretty",
1134 action="store_true",
1135 help="Pretty-print the page-context JSON payload",
1136 )
1137 page_context.add_argument(
1138 "--record-usage",
1139 action="store_true",
1140 help="Write compact-output token telemetry under analysis/page-context/",
1141 )
1142
1143 page_context_report = subparsers.add_parser(
1144 "page-context-report",
1145 help="Summarize fresh per-page context telemetry",
1146 )
1147 page_context_report.add_argument("project_path", help="Project directory")
1148 return parser
1149
1150
1151 def main(argv: list[str] | None = None) -> int:
1152 """Run the CLI entry point."""
1153 require_skill_integrity()
1154 parser = build_parser()
1155 args = parser.parse_args(argv)
1156 manager = ProjectManager()
1157
1158 try:
1159 if args.command == "init":
1160 project_path = manager.init_project(
1161 args.project_name,
1162 args.format,
1163 base_dir=args.dir,
1164 quick_generate=args.quick_generate,
1165 )
1166 print(f"[OK] Project initialized: {project_path}")
1167 print("Next:")
1168 if args.quick_generate:
1169 print("1. Generate SVG files into svg_output/")
1170 print("2. Run the Quick Generate final checker and exporter")
1171 profile = "quick"
1172 else:
1173 print("1. Put source files into sources/ (or use import-sources)")
1174 print("2. Save your design spec to the project root")
1175 print("3. Generate SVG files into svg_output/")
1176 profile = "default"
1177 try:
1178 append_note(
1179 project_path,
1180 f"Project initialized: profile={profile}; "
1181 f"canvas={args.format}; path={project_path}",
1182 )
1183 except OSError as exc:
1184 print(
1185 f"[WARN] Workflow audit unavailable: {exc}",
1186 file=sys.stderr,
1187 )
1188 return 0
1189
1190 if args.command == "import-sources":
1191 summary = manager.import_sources(
1192 args.project_path,
1193 args.sources,
1194 move=args.move,
1195 copy=args.copy,
1196 )
1197 has_usable_import = _has_usable_import(summary)
1198 if has_usable_import:
1199 print(f"[OK] Imported sources into: {args.project_path}")
1200 else:
1201 print(
1202 f"[ERROR] No usable sources imported into: {args.project_path}",
1203 file=sys.stderr,
1204 )
1205 if summary["archived"]:
1206 print("\nArchived originals:")
1207 for item in summary["archived"]:
1208 print(f" - {item}")
1209 if summary["url_records"]:
1210 print("\nArchived URL records:")
1211 for item in summary["url_records"]:
1212 print(f" - {item}")
1213 if summary["markdown"]:
1214 print("\nNormalized markdown:")
1215 for item in summary["markdown"]:
1216 print(f" - {item}")
1217 if summary["assets"]:
1218 print("\nImported asset directories:")
1219 for item in summary["assets"]:
1220 print(f" - {item}")
1221 if summary["images"]:
1222 print("\nRuntime image copies:")
1223 for item in summary["images"]:
1224 print(f" - {item}")
1225 if summary["analysis"]:
1226 print("\nAnalysis artifacts:")
1227 for item in summary["analysis"]:
1228 print(f" - {item}")
1229 if summary["notes"]:
1230 print("\nNotes:")
1231 for item in summary["notes"]:
1232 print(f" - {item}")
1233 if summary["skipped"]:
1234 print("\nSkipped:")
1235 for item in summary["skipped"]:
1236 print(f" - {item}")
1237 return 0 if has_usable_import else 1
1238
1239 if args.command == "scaffold-spec":
1240 artifact_path = manager.scaffold_artifact(args.project_path, "design_spec")
1241 print(f"[OK] Design spec scaffold created: {artifact_path}")
1242 return 0
1243
1244 if args.command == "scaffold-lock":
1245 artifact_path = manager.scaffold_artifact(args.project_path, "spec_lock")
1246 print(f"[OK] Execution lock scaffold created: {artifact_path}")
1247 return 0
1248
1249 if args.command == "validate":
1250 project_path = args.project_path
1251 is_valid, errors, warnings = manager.validate_project(project_path)
1252
1253 print(f"\nProject validation: {project_path}")
1254 print("=" * 60)
1255
1256 if errors:
1257 print("\n[ERROR]")
1258 for error in errors:
1259 print(f" - {error}")
1260
1261 if warnings:
1262 print("\n[WARN]")
1263 for warning in warnings:
1264 print(f" - {warning}")
1265
1266 if is_valid and not warnings:
1267 print("\n[OK] Project structure is complete.")
1268 elif is_valid:
1269 print("\n[OK] Project structure is valid, with warnings.")
1270 else:
1271 print("\n[ERROR] Project structure is invalid.")
1272 return 1
1273 return 0
1274
1275 if args.command == "info":
1276 project_path = args.project_path
1277 info = manager.get_project_info(project_path)
1278
1279 print(f"\nProject info: {info['name']}")
1280 print("=" * 60)
1281 print(f"Path: {info['path']}")
1282 print(f"Exists: {'Yes' if info['exists'] else 'No'}")
1283 print(f"SVG files: {info['svg_count']}")
1284 print(f"Design spec: {'Yes' if info['has_spec'] else 'No'}")
1285 print(f"Source materials: {'Yes' if info['has_source'] else 'No'}")
1286 print(f"Source count: {info['source_count']}")
1287 print(f"Canvas format: {info['canvas_format']}")
1288 print(f"Created: {info['create_date']}")
1289 return 0
1290
1291 if args.command == "page-context":
1292 result = build_page_context(args.project_path, args.page)
1293 output, measured_reads = render_page_context(
1294 result,
1295 bundle=args.bundle,
1296 pretty=args.pretty,
1297 )
1298 if args.record_usage:
1299 _usage_path, token_status = record_page_context_usage(
1300 result,
1301 output,
1302 measured_reads,
1303 )
1304 if token_status != "exact":
1305 print(
1306 "[WARN] tiktoken/o200k_base unavailable; recorded bytes "
1307 "and hashes without token counts",
1308 file=sys.stderr,
1309 )
1310 print(output, end="")
1311 return 0
1312
1313 if args.command == "page-context-report":
1314 report = page_context_usage_report(args.project_path)
1315 print(json.dumps(report, ensure_ascii=False, indent=2))
1316 return 0
1317
1318 parser.error(f"Unknown command: {args.command}")
1319 except Exception as exc:
1320 print(f"[ERROR] {exc}")
1321 return 1
1322
1322 lines PYTHON