返回 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(
452 self,
453 url: str,
454 markdown_path: Path,
455 ) -> None:
456 route = build_conversion_command(
457 url,
458 markdown_path,
459 forced_type="web",
460 )
461 self._run_tool(route.command)
462
463 def _is_valid_imported_url_markdown(self, markdown_path: Path) -> bool:
464 """Return whether web_to_md produced a usable Markdown source."""
465 if not markdown_path.is_file():
466 return False
467 content = markdown_path.read_text(encoding="utf-8", errors="replace")
468 if "[Failed URLs]:" in content:
469 return False
470 return bool(content.strip())
471
472 def _archive_url_record(self, sources_dir: Path, url: str) -> Path:
473 file_path = self._ensure_unique_path(sources_dir / f"{derive_url_basename(url)}.url.txt")
474 file_path.write_text(
475 f"URL: {url}\nImported: {datetime.now().isoformat(timespec='seconds')}\n",
476 encoding="utf-8",
477 )
478 return file_path
479
480 def _normalize_text_source(self, source_path: Path, sources_dir: Path) -> Path:
481 target = self._ensure_unique_path(sources_dir / f"{source_path.stem}.md")
482 content = source_path.read_text(encoding="utf-8", errors="replace")
483 target.write_text(content, encoding="utf-8")
484 return target
485
486 def _canonicalize_markdown_content(self, content: str) -> str:
487 canonical = content.replace("\r\n", "\n")
488 canonical = re.sub(r"(?m)^(\s*Crawled:\s+).*$", r"\1__IGNORED__", canonical)
489 canonical = re.sub(r"(?m)^(\s*Imported:\s+).*$", r"\1__IGNORED__", canonical)
490 canonical = re.sub(r"([^\s\]()/]+_files)/", "__ASSET_DIR__/", canonical)
491 return canonical.strip()
492
493 def _find_equivalent_markdown(self, source_path: Path, sources_dir: Path) -> Path | None:
494 source_content = source_path.read_text(encoding="utf-8", errors="replace")
495 canonical_source = self._canonicalize_markdown_content(source_content)
496
497 for existing in sorted(sources_dir.iterdir()):
498 if existing.suffix.lower() not in {".md", ".markdown"}:
499 continue
500 try:
501 if existing.resolve() == source_path.resolve():
502 continue
503 except FileNotFoundError:
504 pass
505
506 existing_content = existing.read_text(encoding="utf-8", errors="replace")
507 if self._canonicalize_markdown_content(existing_content) == canonical_source:
508 return existing
509
510 return None
511
512 def _companion_asset_dir(self, source_path: Path) -> Path | None:
513 candidate = source_path.with_name(f"{source_path.stem}_files")
514 if candidate.exists() and candidate.is_dir():
515 return candidate
516 return None
517
518 def _rewrite_markdown_asset_refs(
519 self,
520 markdown_path: Path,
521 original_asset_dirname: str,
522 imported_asset_dirname: str,
523 ) -> None:
524 if original_asset_dirname == imported_asset_dirname:
525 return
526
527 content = markdown_path.read_text(encoding="utf-8", errors="replace")
528 updated = content.replace(f"{original_asset_dirname}/", f"{imported_asset_dirname}/")
529 if updated != content:
530 markdown_path.write_text(updated, encoding="utf-8")
531
532 def _merge_image_manifest(self, source_items: list[dict], destination_manifest: Path) -> None:
533 """Merge per-source manifest items into the project-level manifest, keyed by filename."""
534 _validate_image_manifest(source_items, destination_manifest)
535 existing_data = _read_existing_image_manifest(destination_manifest)
536
537 new_by_filename: dict[str, dict] = {}
538 new_order: list[str] = []
539 for item in source_items:
540 filename = item.get("filename")
541 if not isinstance(filename, str):
542 continue
543 normalized_filename = filename.casefold()
544 if normalized_filename not in new_by_filename:
545 new_order.append(normalized_filename)
546 new_by_filename[normalized_filename] = item
547
548 merged: list[dict] = []
549 seen: set[str] = set()
550 for item in existing_data:
551 if not isinstance(item, dict):
552 continue
553 filename = item.get("filename")
554 if not isinstance(filename, str):
555 continue
556 normalized_filename = filename.casefold()
557 if normalized_filename in new_by_filename:
558 merged.append(new_by_filename[normalized_filename])
559 else:
560 merged.append(item)
561 seen.add(normalized_filename)
562
563 for normalized_filename in new_order:
564 if normalized_filename not in seen:
565 merged.append(new_by_filename[normalized_filename])
566
567 _validate_image_manifest(merged, destination_manifest)
568 _write_json_atomic(destination_manifest, merged)
569
570 @staticmethod
571 def _namespace_from_asset_dir(asset_dir: Path) -> str:
572 """Derive a per-source namespace from a `<stem>_files` companion directory name."""
573 name = asset_dir.name
574 suffix = "_files"
575 return name[:-len(suffix)] if name.endswith(suffix) else name
576
577 def _image_destination_name(
578 self,
579 images_dir: Path,
580 source_file: Path,
581 namespace: str,
582 existing_manifest: dict[str, dict],
583 occupied_names: set[str],
584 ) -> str:
585 """Return a short unique image filename for the runtime image pool."""
586 candidate = images_dir / source_file.name
587 if candidate.name.casefold() not in occupied_names:
588 return source_file.name
589 try:
590 meta = existing_manifest.get(candidate.name, {})
591 if (
592 meta.get("source_namespace") == namespace
593 and candidate.is_file()
594 and filecmp.cmp(source_file, candidate, shallow=False)
595 ):
596 return candidate.name
597 except OSError:
598 pass
599
600 stem = source_file.stem
601 suffix = source_file.suffix
602 counter = 2
603 while True:
604 candidate = images_dir / f"{stem}_{counter}{suffix}"
605 if candidate.name.casefold() not in occupied_names:
606 return candidate.name
607 try:
608 meta = existing_manifest.get(candidate.name, {})
609 if (
610 meta.get("source_namespace") == namespace
611 and candidate.is_file()
612 and filecmp.cmp(source_file, candidate, shallow=False)
613 ):
614 return candidate.name
615 except OSError:
616 pass
617 counter += 1
618
619 def _propagate_image_assets(self, asset_dir: Path, project_dir: Path) -> None:
620 """Copy converter-generated image assets and manifest into project images/.
621
622 Filenames are preserved when possible because source Markdown commonly
623 uses short names that are meaningful in context. Only real collisions
624 receive a compact numeric suffix.
625 """
626 manifest_path = asset_dir / "image_manifest.json"
627 if not manifest_path.is_file():
628 return
629
630 try:
631 source_payload = json.loads(manifest_path.read_text(encoding="utf-8"))
632 except (OSError, json.JSONDecodeError) as exc:
633 print(f"[WARN] Cannot read image manifest {manifest_path}: {exc}")
634 return
635 try:
636 source_data = _validate_image_manifest(source_payload, manifest_path)
637 except RuntimeError as exc:
638 print(f"[WARN] {exc}")
639 return
640
641 images_dir = project_dir / "images"
642 namespace = self._namespace_from_asset_dir(asset_dir)
643 destination_manifest = images_dir / "image_manifest.json"
644 existing_data = _read_existing_image_manifest(destination_manifest)
645 images_dir.mkdir(parents=True, exist_ok=True)
646
647 existing_manifest = {
648 item["filename"]: item
649 for item in existing_data
650 }
651 occupied_names = {
652 path.name.casefold()
653 for path in images_dir.iterdir()
654 if path.is_file()
655 }
656 rename_map: dict[str, str] = {}
657
658 copied_count = 0
659 for source_file in sorted(asset_dir.iterdir()):
660 if not source_file.is_file():
661 continue
662 if source_file.suffix.lower() not in IMAGE_ASSET_SUFFIXES:
663 continue
664 new_name = self._image_destination_name(
665 images_dir,
666 source_file,
667 namespace,
668 existing_manifest,
669 occupied_names,
670 )
671 destination = images_dir / new_name
672 if source_file.resolve() != destination.resolve():
673 shutil.copy2(source_file, destination)
674 occupied_names.add(new_name.casefold())
675 rename_map[source_file.name] = new_name
676 copied_count += 1
677
678 rebased_items: list[dict] = []
679 for item in source_data:
680 if not isinstance(item, dict):
681 continue
682 original = item.get("filename")
683 if not isinstance(original, str):
684 continue
685 new_item = dict(item)
686 new_item["filename"] = rename_map.get(original, original)
687 new_item["source_namespace"] = namespace
688 rebased_items.append(new_item)
689
690 self._merge_image_manifest(rebased_items, images_dir / "image_manifest.json")
691 print(
692 f"Propagated {copied_count} image asset(s) + manifest "
693 f"from {asset_dir} → images/ (namespace: {namespace})"
694 )
695
696 def _propagate_companion_image_assets(self, markdown_path: Path, project_dir: Path) -> None:
697 asset_dir = markdown_path.with_name(f"{markdown_path.stem}_files")
698 if asset_dir.is_dir():
699 self._propagate_image_assets(asset_dir, project_dir)
700
701 def _import_markdown_with_assets(
702 self,
703 source_path: Path,
704 sources_dir: Path,
705 move: bool,
706 ) -> tuple[Path, Path | None, str | None]:
707 archived_markdown = self._copy_or_move_file(
708 source_path,
709 sources_dir / source_path.name,
710 move=move,
711 )
712
713 profile_src = source_path.with_name(f"{source_path.stem}.conversion_profile.json")
714 if profile_src.is_file():
715 self._copy_or_move_file(
716 profile_src,
717 sources_dir / f"{archived_markdown.stem}.conversion_profile.json",
718 move=move,
719 )
720
721 asset_dir = self._companion_asset_dir(source_path)
722 if asset_dir is None:
723 return archived_markdown, None, None
724
725 imported_asset_dir = self._copy_or_move_tree(
726 asset_dir,
727 sources_dir / f"{archived_markdown.stem}_files",
728 move=move,
729 )
730 self._rewrite_markdown_asset_refs(
731 archived_markdown,
732 original_asset_dirname=asset_dir.name,
733 imported_asset_dirname=imported_asset_dir.name,
734 )
735
736 note = None
737 if archived_markdown.stem != source_path.stem:
738 note = (
739 f"{source_path}: renamed imported markdown to {archived_markdown.name} "
740 f"and rewrote asset references to {imported_asset_dir.name}/"
741 )
742 return archived_markdown, imported_asset_dir, note
743
744 def import_sources(
745 self,
746 project_path: str,
747 source_items: list[str],
748 move: bool = False,
749 copy: bool = False,
750 ) -> dict[str, list[str]]:
751 if move and copy:
752 raise ValueError("--move and --copy are mutually exclusive")
753 project_dir = Path(project_path)
754 if not project_dir.exists() or not project_dir.is_dir():
755 raise FileNotFoundError(f"Project directory not found: {project_dir}")
756 if not source_items:
757 raise ValueError("At least one source path or URL is required")
758
759 sources_dir = self._source_dir(project_dir)
760 summary: dict[str, list[str]] = {
761 "archived": [],
762 "url_records": [],
763 "markdown": [],
764 "assets": [],
765 "images": [],
766 "analysis": [],
767 "notes": [],
768 "skipped": [],
769 }
770
771 expanded_items: list[str] = []
772 supplied_dirs: list[Path] = []
773 for item in source_items:
774 if is_url(item):
775 expanded_items.append(item)
776 continue
777 item_path = Path(item)
778 if item_path.is_dir():
779 supplied_dirs.append(item_path)
780 directory_files = sorted(
781 path for path in item_path.iterdir() if path.is_file()
782 )
783 if directory_files:
784 expanded_items.extend(str(path) for path in directory_files)
785 summary["notes"].append(
786 f"{item}: expanded directory into {len(directory_files)} file(s)"
787 )
788 else:
789 summary["skipped"].append(f"{item}: directory contains no files")
790 continue
791 expanded_items.append(item)
792
793 explicit_markdown_stems = {
794 Path(item).stem
795 for item in expanded_items
796 if not is_url(item)
797 and Path(item).exists()
798 and Path(item).is_file()
799 and Path(item).suffix.lower() in {".md", ".markdown"}
800 }
801
802 for item in expanded_items:
803 if is_url(item):
804 markdown_path = self._ensure_unique_path(
805 sources_dir / f"{derive_url_basename(item)}.md"
806 )
807 try:
808 self._import_url(item, markdown_path)
809 except Exception as exc: # pragma: no cover - summary path
810 archived = self._archive_url_record(sources_dir, item)
811 summary["url_records"].append(str(archived))
812 summary["skipped"].append(f"{item}: {exc}")
813 continue
814
815 if not self._is_valid_imported_url_markdown(markdown_path):
816 markdown_path.unlink(missing_ok=True)
817 archived = self._archive_url_record(sources_dir, item)
818 summary["url_records"].append(str(archived))
819 summary["skipped"].append(f"{item}: URL conversion produced no usable Markdown")
820 continue
821
822 summary["markdown"].append(str(markdown_path))
823 self._propagate_companion_image_assets(markdown_path, project_dir)
824 continue
825
826 source_path = Path(item)
827 if not source_path.exists():
828 summary["skipped"].append(f"{item}: path not found")
829 continue
830 if source_path.is_dir():
831 summary["skipped"].append(f"{item}: directories are not supported")
832 continue
833
834 inside_projects = is_within_path(source_path, PROJECTS_ROOT)
835 if copy:
836 effective_move = False
837 elif inside_projects:
838 effective_move = True
839 else:
840 effective_move = False
841 if move and not inside_projects:
842 print(
843 f"note: {source_path} is outside {PROJECTS_ROOT}; copied "
844 f"(not moved). Only sources under projects/ may be moved.",
845 file=sys.stderr,
846 )
847 elif inside_projects and not move and not copy:
848 print(
849 f"note: {source_path} is under projects/; moved into the target "
850 f"project. Pass --copy to preserve it.",
851 file=sys.stderr,
852 )
853 suffix = source_path.suffix.lower()
854
855 if suffix in {".md", ".markdown"}:
856 duplicate_markdown = self._find_equivalent_markdown(source_path, sources_dir)
857 if duplicate_markdown is not None:
858 summary["markdown"].append(str(duplicate_markdown))
859 self._propagate_companion_image_assets(duplicate_markdown, project_dir)
860 summary["notes"].append(
861 f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}"
862 )
863 continue
864
865 archived_markdown, asset_dir, note = self._import_markdown_with_assets(
866 source_path,
867 sources_dir,
868 move=effective_move,
869 )
870 summary["archived"].append(str(archived_markdown))
871 summary["markdown"].append(str(archived_markdown))
872 if asset_dir is not None:
873 summary["assets"].append(str(asset_dir))
874 self._propagate_image_assets(asset_dir, project_dir)
875 if note:
876 summary["notes"].append(note)
877 continue
878
879 archived_path = self._copy_or_move_file(
880 source_path,
881 sources_dir / source_path.name,
882 move=effective_move,
883 )
884 summary["archived"].append(str(archived_path))
885
886 if suffix in BITMAP_IMAGE_SUFFIXES:
887 images_dir = project_dir / "images"
888 images_dir.mkdir(parents=True, exist_ok=True)
889 image_path = self._ensure_unique_path(images_dir / archived_path.name)
890 shutil.copy2(archived_path, image_path)
891 summary["images"].append(str(image_path))
892 if image_path.name != archived_path.name:
893 summary["notes"].append(
894 f"{item}: copied runtime image as {image_path.name} "
895 "to avoid a filename collision"
896 )
897 elif suffix in PDF_SUFFIXES:
898 canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
899 if archived_path.stem in explicit_markdown_stems:
900 summary["notes"].append(
901 f"{item}: skipped PDF auto-conversion because a same-stem Markdown source was provided"
902 )
903 continue
904 if canonical_markdown_path.exists():
905 summary["markdown"].append(str(canonical_markdown_path))
906 self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
907 summary["notes"].append(
908 f"{item}: skipped PDF auto-conversion because {canonical_markdown_path.name} already exists"
909 )
910 continue
911 markdown_path = canonical_markdown_path
912 try:
913 self._import_pdf(archived_path, markdown_path)
914 summary["markdown"].append(str(markdown_path))
915 self._propagate_companion_image_assets(markdown_path, project_dir)
916 except Exception as exc: # pragma: no cover - summary path
917 summary["skipped"].append(f"{item}: PDF conversion failed ({exc})")
918 elif suffix in PRESENTATION_SUFFIXES:
919 canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
920 try:
921 intake_dir = self._import_pptx_intake(archived_path, project_dir)
922 intake_str = str(intake_dir)
923 if intake_str not in summary["analysis"]:
924 summary["analysis"].append(intake_str)
925 except Exception as exc: # pragma: no cover - summary path
926 summary["notes"].append(f"{item}: PPTX intake analysis failed ({exc})")
927 if archived_path.stem in explicit_markdown_stems:
928 summary["notes"].append(
929 f"{item}: skipped presentation auto-conversion because a same-stem Markdown source was provided"
930 )
931 continue
932 if canonical_markdown_path.exists():
933 summary["markdown"].append(str(canonical_markdown_path))
934 self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
935 summary["notes"].append(
936 f"{item}: skipped presentation auto-conversion because {canonical_markdown_path.name} already exists"
937 )
938 continue
939 markdown_path = canonical_markdown_path
940 try:
941 self._import_presentation(archived_path, markdown_path)
942 summary["markdown"].append(str(markdown_path))
943 self._propagate_companion_image_assets(markdown_path, project_dir)
944 except Exception as exc: # pragma: no cover - summary path
945 summary["skipped"].append(f"{item}: presentation conversion failed ({exc})")
946 elif suffix in EXCEL_SUFFIXES:
947 canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
948 if archived_path.stem in explicit_markdown_stems:
949 summary["notes"].append(
950 f"{item}: skipped Excel auto-conversion because a same-stem Markdown source was provided"
951 )
952 continue
953 if canonical_markdown_path.exists():
954 summary["markdown"].append(str(canonical_markdown_path))
955 self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
956 summary["notes"].append(
957 f"{item}: skipped Excel auto-conversion because {canonical_markdown_path.name} already exists"
958 )
959 continue
960 markdown_path = canonical_markdown_path
961 try:
962 self._import_excel(archived_path, markdown_path)
963 summary["markdown"].append(str(markdown_path))
964 self._propagate_companion_image_assets(markdown_path, project_dir)
965 except Exception as exc: # pragma: no cover - summary path
966 summary["skipped"].append(f"{item}: Excel conversion failed ({exc})")
967 elif suffix in LEGACY_EXCEL_SUFFIXES:
968 summary["notes"].append(
969 f"{item}: archived only; legacy .xls is not converted automatically. "
970 "Resave as .xlsx to generate Markdown."
971 )
972 elif suffix in TABLE_TEXT_SUFFIXES:
973 summary["notes"].append(
974 f"{item}: archived as a plain-text table source; no Markdown conversion needed"
975 )
976 elif suffix in DOC_SUFFIXES:
977 canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
978 if archived_path.stem in explicit_markdown_stems:
979 summary["notes"].append(
980 f"{item}: skipped document auto-conversion because a same-stem Markdown source was provided"
981 )
982 continue
983 if canonical_markdown_path.exists():
984 summary["markdown"].append(str(canonical_markdown_path))
985 self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
986 summary["notes"].append(
987 f"{item}: skipped document auto-conversion because {canonical_markdown_path.name} already exists"
988 )
989 continue
990 markdown_path = canonical_markdown_path
991 try:
992 self._import_doc(archived_path, markdown_path)
993 summary["markdown"].append(str(markdown_path))
994 self._propagate_companion_image_assets(markdown_path, project_dir)
995 except Exception as exc: # pragma: no cover - summary path
996 summary["skipped"].append(f"{item}: document conversion failed ({exc})")
997 elif suffix == ".txt":
998 markdown_path = self._normalize_text_source(archived_path, sources_dir)
999 summary["markdown"].append(str(markdown_path))
1000 else:
1001 summary["notes"].append(f"{item}: archived only, no automatic conversion")
1002
1003 # Cleanup: only a projects-local source directory may be removed after
1004 # its files move into the target project. Every other location is copied
1005 # and remains untouched, even when the caller passes --move.
1006 for directory in supplied_dirs:
1007 if copy or not is_within_path(directory, PROJECTS_ROOT):
1008 continue
1009 if directory.is_dir() and not any(directory.iterdir()):
1010 try:
1011 directory.rmdir()
1012 except OSError:
1013 continue
1014 summary["notes"].append(
1015 f"{directory}: removed empty source directory after import"
1016 )
1017
1018 return summary
1019
1020 def validate_project(self, project_path: str) -> tuple[bool, list[str], list[str]]:
1021 project_path_obj = Path(project_path)
1022 _, errors, warnings = validate_project_structure(
1023 str(project_path_obj),
1024 validate_communication=False,
1025 )
1026
1027 if project_path_obj.exists() and project_path_obj.is_dir():
1028 project_info = get_project_info_common(str(project_path_obj))
1029 artifact_errors, artifact_warnings = validate_project_artifacts(
1030 project_path_obj,
1031 project_info,
1032 )
1033 errors.extend(artifact_errors)
1034 warnings.extend(artifact_warnings)
1035
1036 if project_path_obj.exists() and project_path_obj.is_dir():
1037 info = get_project_info_common(str(project_path_obj))
1038 if info.get("svg_files"):
1039 svg_files = [project_path_obj / "svg_output" / name for name in info["svg_files"]]
1040 expected_format = info.get("format")
1041 if expected_format == "unknown":
1042 expected_format = None
1043 warnings.extend(validate_svg_viewbox(svg_files, expected_format))
1044
1045 return not errors, list(dict.fromkeys(errors)), warnings
1046
1047 def get_project_info(self, project_path: str) -> dict[str, object]:
1048 shared = get_project_info_common(project_path)
1049 return {
1050 "name": shared.get("name", Path(project_path).name),
1051 "path": shared.get("path", str(project_path)),
1052 "exists": shared.get("exists", False),
1053 "svg_count": shared.get("svg_count", 0),
1054 "has_spec": shared.get("has_spec", False),
1055 "has_source": shared.get("has_source", False),
1056 "source_count": shared.get("source_count", 0),
1057 "canvas_format": shared.get("format_name", "Unknown"),
1058 "create_date": shared.get("date_formatted", "Unknown"),
1059 }
1060
1061
1062 def build_parser() -> argparse.ArgumentParser:
1063 """Build the command-line parser."""
1064 parser = argparse.ArgumentParser(
1065 description="PPT Master project management helpers.",
1066 formatter_class=argparse.RawDescriptionHelpFormatter,
1067 epilog="""Examples:
1068 python3 scripts/project_manager.py init demo --format ppt169
1069 python3 scripts/project_manager.py import-sources projects/demo file.md
1070 python3 scripts/project_manager.py scaffold-spec projects/demo_ppt169_20260718
1071 python3 scripts/project_manager.py scaffold-lock projects/demo_ppt169_20260718
1072 python3 scripts/project_manager.py validate projects/demo
1073 python3 scripts/project_manager.py info projects/demo
1074 python3 scripts/project_manager.py page-context projects/demo P07 --record-usage
1075 python3 scripts/project_manager.py page-context-report projects/demo
1076 """,
1077 )
1078 subparsers = parser.add_subparsers(dest="command", required=True)
1079
1080 init = subparsers.add_parser("init", help="Create a project directory")
1081 init.add_argument("project_name", help="Project name")
1082 init.add_argument("--format", default="ppt169", help="Canvas format (default: ppt169)")
1083 init.add_argument("--dir", default=None, help="Base directory for the project")
1084 init.add_argument(
1085 "--quick-generate",
1086 action="store_true",
1087 help=(
1088 "Create svg_output plus the validation workflow audit log and "
1089 "omit README.md"
1090 ),
1091 )
1092
1093 import_sources = subparsers.add_parser(
1094 "import-sources",
1095 help="Import source files or URLs into a project",
1096 )
1097 import_sources.add_argument("project_path", help="Project directory")
1098 import_sources.add_argument("sources", nargs="+", help="Source files, directories, or URLs")
1099 mode = import_sources.add_mutually_exclusive_group()
1100 mode.add_argument(
1101 "--move",
1102 action="store_true",
1103 help="Move local sources under projects/; sources elsewhere are copied",
1104 )
1105 mode.add_argument("--copy", action="store_true", help="Copy local source files")
1106
1107 scaffold_spec = subparsers.add_parser(
1108 "scaffold-spec",
1109 help="Create design_spec.md from the versioned scaffold",
1110 )
1111 scaffold_spec.add_argument("project_path", help="Project directory")
1112
1113 scaffold_lock = subparsers.add_parser(
1114 "scaffold-lock",
1115 help="Create spec_lock.md from the versioned scaffold",
1116 )
1117 scaffold_lock.add_argument("project_path", help="Project directory")
1118
1119 validate = subparsers.add_parser("validate", help="Validate a project directory")
1120 validate.add_argument("project_path", help="Project directory")
1121
1122 info = subparsers.add_parser("info", help="Print project metadata")
1123 info.add_argument("project_path", help="Project directory")
1124
1125 page_context = subparsers.add_parser(
1126 "page-context",
1127 help="Print one deterministic per-page execution view",
1128 )
1129 page_context.add_argument("project_path", help="Project directory")
1130 page_context.add_argument("page", help="Positive page key such as P07")
1131 page_context.add_argument(
1132 "--bundle",
1133 action="store_true",
1134 help="Deprecated compatibility flag; output remains compact",
1135 )
1136 page_context.add_argument(
1137 "--pretty",
1138 action="store_true",
1139 help="Pretty-print the page-context JSON payload",
1140 )
1141 page_context.add_argument(
1142 "--record-usage",
1143 action="store_true",
1144 help="Write compact-output token telemetry under analysis/page-context/",
1145 )
1146
1147 page_context_report = subparsers.add_parser(
1148 "page-context-report",
1149 help="Summarize fresh per-page context telemetry",
1150 )
1151 page_context_report.add_argument("project_path", help="Project directory")
1152 return parser
1153
1154
1155 def main(argv: list[str] | None = None) -> int:
1156 """Run the CLI entry point."""
1157 require_skill_integrity()
1158 parser = build_parser()
1159 args = parser.parse_args(argv)
1160 manager = ProjectManager()
1161
1162 try:
1163 if args.command == "init":
1164 project_path = manager.init_project(
1165 args.project_name,
1166 args.format,
1167 base_dir=args.dir,
1168 quick_generate=args.quick_generate,
1169 )
1170 print(f"[OK] Project initialized: {project_path}")
1171 print("Next:")
1172 if args.quick_generate:
1173 print("1. Generate SVG files into svg_output/")
1174 print("2. Run the Quick Generate final checker and exporter")
1175 profile = "quick"
1176 else:
1177 print("1. Put source files into sources/ (or use import-sources)")
1178 print("2. Save your design spec to the project root")
1179 print("3. Generate SVG files into svg_output/")
1180 profile = "default"
1181 try:
1182 append_note(
1183 project_path,
1184 f"Project initialized: profile={profile}; "
1185 f"canvas={args.format}; path={project_path}",
1186 )
1187 except OSError as exc:
1188 print(
1189 f"[WARN] Workflow audit unavailable: {exc}",
1190 file=sys.stderr,
1191 )
1192 return 0
1193
1194 if args.command == "import-sources":
1195 summary = manager.import_sources(
1196 args.project_path,
1197 args.sources,
1198 move=args.move,
1199 copy=args.copy,
1200 )
1201 import_complete = _has_usable_import(summary)
1202 if import_complete:
1203 print(f"[OK] Imported sources into: {args.project_path}")
1204 else:
1205 print(
1206 f"[ERROR] No usable sources imported into: {args.project_path}",
1207 file=sys.stderr,
1208 )
1209 if summary["archived"]:
1210 print("\nArchived originals:")
1211 for item in summary["archived"]:
1212 print(f" - {item}")
1213 if summary["url_records"]:
1214 print("\nArchived URL records:")
1215 for item in summary["url_records"]:
1216 print(f" - {item}")
1217 if summary["markdown"]:
1218 print("\nNormalized markdown:")
1219 for item in summary["markdown"]:
1220 print(f" - {item}")
1221 if summary["assets"]:
1222 print("\nImported asset directories:")
1223 for item in summary["assets"]:
1224 print(f" - {item}")
1225 if summary["images"]:
1226 print("\nRuntime image copies:")
1227 for item in summary["images"]:
1228 print(f" - {item}")
1229 if summary["analysis"]:
1230 print("\nAnalysis artifacts:")
1231 for item in summary["analysis"]:
1232 print(f" - {item}")
1233 if summary["notes"]:
1234 print("\nNotes:")
1235 for item in summary["notes"]:
1236 print(f" - {item}")
1237 if summary["skipped"]:
1238 print("\nSkipped:")
1239 for item in summary["skipped"]:
1240 print(f" - {item}")
1241 return 0 if import_complete else 1
1242
1243 if args.command == "scaffold-spec":
1244 artifact_path = manager.scaffold_artifact(args.project_path, "design_spec")
1245 print(f"[OK] Design spec scaffold created: {artifact_path}")
1246 return 0
1247
1248 if args.command == "scaffold-lock":
1249 artifact_path = manager.scaffold_artifact(args.project_path, "spec_lock")
1250 print(f"[OK] Execution lock scaffold created: {artifact_path}")
1251 return 0
1252
1253 if args.command == "validate":
1254 project_path = args.project_path
1255 is_valid, errors, warnings = manager.validate_project(project_path)
1256
1257 print(f"\nProject validation: {project_path}")
1258 print("=" * 60)
1259
1260 if errors:
1261 print("\n[ERROR]")
1262 for error in errors:
1263 print(f" - {error}")
1264
1265 if warnings:
1266 print("\n[WARN]")
1267 for warning in warnings:
1268 print(f" - {warning}")
1269
1270 if is_valid and not warnings:
1271 print("\n[OK] Project structure is complete.")
1272 elif is_valid:
1273 print("\n[OK] Project structure is valid, with warnings.")
1274 else:
1275 print("\n[ERROR] Project structure is invalid.")
1276 return 1
1277 return 0
1278
1279 if args.command == "info":
1280 project_path = args.project_path
1281 info = manager.get_project_info(project_path)
1282
1283 print(f"\nProject info: {info['name']}")
1284 print("=" * 60)
1285 print(f"Path: {info['path']}")
1286 print(f"Exists: {'Yes' if info['exists'] else 'No'}")
1287 print(f"SVG files: {info['svg_count']}")
1288 print(f"Design spec: {'Yes' if info['has_spec'] else 'No'}")
1289 print(f"Source materials: {'Yes' if info['has_source'] else 'No'}")
1290 print(f"Source count: {info['source_count']}")
1291 print(f"Canvas format: {info['canvas_format']}")
1292 print(f"Created: {info['create_date']}")
1293 return 0
1294
1295 if args.command == "page-context":
1296 result = build_page_context(args.project_path, args.page)
1297 output, measured_reads = render_page_context(
1298 result,
1299 bundle=args.bundle,
1300 pretty=args.pretty,
1301 )
1302 if args.record_usage:
1303 _usage_path, token_status = record_page_context_usage(
1304 result,
1305 output,
1306 measured_reads,
1307 )
1308 if token_status != "exact":
1309 print(
1310 "[WARN] tiktoken/o200k_base unavailable; recorded bytes "
1311 "and hashes without token counts",
1312 file=sys.stderr,
1313 )
1314 print(output, end="")
1315 return 0
1316
1317 if args.command == "page-context-report":
1318 report = page_context_usage_report(args.project_path)
1319 print(json.dumps(report, ensure_ascii=False, indent=2))
1320 return 0
1321
1322 parser.error(f"Unknown command: {args.command}")
1323 except Exception as exc:
1324 print(f"[ERROR] {exc}")
1325 return 1
1326
1326 lines PYTHON