Files
wehub-resource-sync 8ef9db2513
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:56:49 +08:00

1016 lines
41 KiB
Python

#!/usr/bin/env python3
"""PPT Master project management helpers.
Usage:
python3 scripts/project_manager.py init <project_name> [--format ppt169] [--dir <path>]
python3 scripts/project_manager.py import-sources <project_path> <source1> [<source2> ...] [--move | --copy]
python3 scripts/project_manager.py validate <project_path>
python3 scripts/project_manager.py info <project_path>
"""
from __future__ import annotations
import argparse
import filecmp
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from console_encoding import configure_utf8_stdio
try:
from project_utils import (
CANVAS_FORMATS,
get_project_info as get_project_info_common,
normalize_canvas_format,
validate_project_structure,
validate_svg_viewbox,
)
except ImportError:
tools_dir = Path(__file__).resolve().parent
if str(tools_dir) not in sys.path:
sys.path.insert(0, str(tools_dir))
from project_utils import ( # type: ignore
CANVAS_FORMATS,
get_project_info as get_project_info_common,
normalize_canvas_format,
validate_project_structure,
validate_svg_viewbox,
)
TOOLS_DIR = Path(__file__).resolve().parent
SKILL_DIR = TOOLS_DIR.parent
REPO_ROOT = SKILL_DIR.parent.parent
SOURCE_TO_MD_TOOLS_DIR = TOOLS_DIR / "source_to_md"
if str(SOURCE_TO_MD_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(SOURCE_TO_MD_TOOLS_DIR))
from _dispatcher import ( # noqa: E402
DOC_SUFFIXES,
EXCEL_SUFFIXES,
LEGACY_EXCEL_SUFFIXES,
PDF_SUFFIXES,
PRESENTATION_SUFFIXES,
build_conversion_command,
)
SOURCE_DIRNAME = "sources"
TEXT_SOURCE_SUFFIXES = {".md", ".markdown", ".txt"}
TABLE_TEXT_SUFFIXES = {".csv", ".tsv"}
IMAGE_ASSET_SUFFIXES = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif",
".emf", ".wmf", ".svg",
}
configure_utf8_stdio()
def is_url(value: str) -> bool:
"""Return whether a string looks like an HTTP(S) URL."""
parsed = urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def sanitize_name(value: str) -> str:
"""Sanitize a user-facing name into a filesystem-safe token."""
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in value.strip())
safe = safe.strip("._")
while "__" in safe:
safe = safe.replace("__", "_")
return safe[:120] or "source"
def derive_url_basename(url: str) -> str:
"""Derive a stable base filename from a URL."""
parsed = urlparse(url)
parts = [sanitize_name(parsed.netloc)]
if parsed.path and parsed.path != "/":
path_part = sanitize_name(parsed.path.strip("/").replace("/", "_"))
if path_part:
parts.append(path_part)
return "_".join(part for part in parts if part) or "web_source"
def is_within_path(path: Path, parent: Path) -> bool:
"""Return whether `path` resolves inside `parent`."""
try:
path.resolve().relative_to(parent.resolve())
return True
except ValueError:
return False
class ProjectManager:
"""Create, inspect, validate, and populate project folders."""
CANVAS_FORMATS = CANVAS_FORMATS
def __init__(self, base_dir: str | Path | None = None) -> None:
self.base_dir = Path(base_dir) if base_dir is not None else Path.cwd() / "projects"
def init_project(
self,
project_name: str,
canvas_format: str = "ppt169",
base_dir: str | None = None,
) -> str:
base_path = Path(base_dir) if base_dir else self.base_dir
normalized_format = normalize_canvas_format(canvas_format)
if normalized_format not in self.CANVAS_FORMATS:
available = ", ".join(sorted(self.CANVAS_FORMATS.keys()))
raise ValueError(
f"Unsupported canvas format: {canvas_format} "
f"(available: {available}; common alias: xhs -> xiaohongshu)"
)
date_str = datetime.now().strftime("%Y%m%d")
# A name already carrying a `_<format>_<YYYYMMDD>` suffix (e.g. a full
# project dir name pasted back into init) is used as-is — re-appending
# would produce `name_ppt169_20260101_ppt169_20260102`.
if re.search(rf"_{re.escape(normalized_format)}_\d{{8}}$", project_name):
project_dir_name = project_name
else:
project_dir_name = f"{project_name}_{normalized_format}_{date_str}"
project_path = base_path / project_dir_name
if project_path.exists():
raise FileExistsError(f"Project directory already exists: {project_path}")
for rel_path in (
"svg_output",
"svg_final",
"images",
"icons",
"notes",
"templates",
"live_preview",
SOURCE_DIRNAME,
"analysis",
"exports",
):
(project_path / rel_path).mkdir(parents=True, exist_ok=True)
canvas_info = self.CANVAS_FORMATS[normalized_format]
readme_path = project_path / "README.md"
readme_path.write_text(
(
f"# {project_name}\n\n"
f"- Canvas format: {normalized_format}\n"
f"- Created: {date_str}\n\n"
"## Directories\n\n"
"- `svg_output/`: raw SVG output\n"
"- `svg_final/`: self-contained SVG visual preview; may be inserted manually as an SVG image, but PowerPoint Convert to Shape is unsupported\n"
"- `images/`: runtime image pool; converter assets keep their original short filenames when possible\n"
"- `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"
"- `notes/`: speaker notes\n"
"- `templates/`: project templates\n"
"- `live_preview/`: browser preview runtime files and history (lock.json, server.log, edits.jsonl, annotations.jsonl)\n"
"- `sources/`: source materials and normalized markdown\n"
"- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n"
"- `exports/`: native DrawingML pptx (timestamped); `_native_charts.pptx` name with `--native-objects`, `_narrated.pptx` name when narration audio is embedded\n"
"- `backup/<timestamp>/`: svg_output/ archive (always written in default-flow mode; safe to delete old timestamps)\n"
),
encoding="utf-8",
)
print(f"Project created: {project_path}")
print(f"Canvas: {canvas_info['name']} ({canvas_info['dimensions']})")
return str(project_path)
def _source_dir(self, project_path: Path) -> Path:
sources_dir = project_path / SOURCE_DIRNAME
sources_dir.mkdir(parents=True, exist_ok=True)
return sources_dir
def _analysis_dir(self, project_path: Path) -> Path:
analysis_dir = project_path / "analysis"
analysis_dir.mkdir(parents=True, exist_ok=True)
return analysis_dir
def _ensure_unique_path(self, path: Path) -> Path:
if not path.exists():
return path
suffix = path.suffix
stem = path.stem
counter = 2
while True:
candidate = path.with_name(f"{stem}_{counter}{suffix}")
if not candidate.exists():
return candidate
counter += 1
def _copy_or_move_file(self, source: Path, destination: Path, move: bool) -> Path:
try:
if source.resolve() == destination.resolve():
return destination
except FileNotFoundError:
pass
destination = self._ensure_unique_path(destination)
if move:
shutil.move(str(source), str(destination))
else:
shutil.copy2(source, destination)
return destination
def _copy_or_move_tree(self, source: Path, destination: Path, move: bool) -> Path:
try:
if source.resolve() == destination.resolve():
return destination
except FileNotFoundError:
pass
destination = self._ensure_unique_path(destination)
if move:
shutil.move(str(source), str(destination))
else:
shutil.copytree(source, destination)
return destination
def _run_tool(self, args: list[str]) -> None:
child_env = os.environ.copy()
child_env["PYTHONUTF8"] = "1"
child_env["PYTHONIOENCODING"] = "utf-8:replace"
try:
result = subprocess.run(
args,
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=child_env,
)
except FileNotFoundError as exc:
raise RuntimeError(f"Missing executable: {args[0]}") from exc
except subprocess.CalledProcessError as exc:
details = (exc.stderr or exc.stdout or "").strip()
raise RuntimeError(details or "tool execution failed") from exc
if result.stdout.strip():
print(result.stdout.strip())
def _import_pdf(self, pdf_path: Path, markdown_path: Path) -> None:
route = build_conversion_command(
str(pdf_path),
markdown_path,
forced_type="pdf",
)
self._run_tool(route.command)
def _import_doc(self, doc_path: Path, markdown_path: Path) -> None:
route = build_conversion_command(
str(doc_path),
markdown_path,
forced_type="doc",
)
self._run_tool(route.command)
def _import_presentation(self, presentation_path: Path, markdown_path: Path) -> None:
route = build_conversion_command(
str(presentation_path),
markdown_path,
forced_type="pptx",
)
self._run_tool(route.command)
def _import_pptx_intake(self, presentation_path: Path, project_dir: Path) -> Path:
# Multi-deck intake: each PPTX writes its own `<stem>.identity.json` /
# `<stem>.slide_library.json` and is merged into the single multi-deck
# index `analysis/source_profile.json` (one entry per source deck).
analysis_dir = self._analysis_dir(project_dir)
self._run_tool(
[
sys.executable,
str(TOOLS_DIR / "pptx_intake.py"),
str(presentation_path),
"-o",
str(analysis_dir),
]
)
return analysis_dir
def _import_excel(self, excel_path: Path, markdown_path: Path) -> None:
route = build_conversion_command(
str(excel_path),
markdown_path,
forced_type="excel",
)
self._run_tool(route.command)
def _import_url(self, url: str, markdown_path: Path) -> None:
route = build_conversion_command(
url,
markdown_path,
forced_type="web",
)
self._run_tool(route.command)
def _is_valid_imported_url_markdown(self, markdown_path: Path) -> bool:
"""Return whether web_to_md produced a usable Markdown source."""
if not markdown_path.is_file():
return False
content = markdown_path.read_text(encoding="utf-8", errors="replace")
if "[Failed URLs]:" in content:
return False
return bool(content.strip())
def _archive_url_record(self, sources_dir: Path, url: str) -> Path:
file_path = self._ensure_unique_path(sources_dir / f"{derive_url_basename(url)}.url.txt")
file_path.write_text(
f"URL: {url}\nImported: {datetime.now().isoformat(timespec='seconds')}\n",
encoding="utf-8",
)
return file_path
def _normalize_text_source(self, source_path: Path, sources_dir: Path) -> Path:
target = self._ensure_unique_path(sources_dir / f"{source_path.stem}.md")
content = source_path.read_text(encoding="utf-8", errors="replace")
target.write_text(content, encoding="utf-8")
return target
def _canonicalize_markdown_content(self, content: str) -> str:
canonical = content.replace("\r\n", "\n")
canonical = re.sub(r"(?m)^(\s*Crawled:\s+).*$", r"\1__IGNORED__", canonical)
canonical = re.sub(r"(?m)^(\s*Imported:\s+).*$", r"\1__IGNORED__", canonical)
canonical = re.sub(r"([^\s\]()/]+_files)/", "__ASSET_DIR__/", canonical)
return canonical.strip()
def _find_equivalent_markdown(self, source_path: Path, sources_dir: Path) -> Path | None:
source_content = source_path.read_text(encoding="utf-8", errors="replace")
canonical_source = self._canonicalize_markdown_content(source_content)
for existing in sorted(sources_dir.iterdir()):
if existing.suffix.lower() not in {".md", ".markdown"}:
continue
try:
if existing.resolve() == source_path.resolve():
continue
except FileNotFoundError:
pass
existing_content = existing.read_text(encoding="utf-8", errors="replace")
if self._canonicalize_markdown_content(existing_content) == canonical_source:
return existing
return None
def _companion_asset_dir(self, source_path: Path) -> Path | None:
candidate = source_path.with_name(f"{source_path.stem}_files")
if candidate.exists() and candidate.is_dir():
return candidate
return None
def _rewrite_markdown_asset_refs(
self,
markdown_path: Path,
original_asset_dirname: str,
imported_asset_dirname: str,
) -> None:
if original_asset_dirname == imported_asset_dirname:
return
content = markdown_path.read_text(encoding="utf-8", errors="replace")
updated = content.replace(f"{original_asset_dirname}/", f"{imported_asset_dirname}/")
if updated != content:
markdown_path.write_text(updated, encoding="utf-8")
def _merge_image_manifest(self, source_items: list[dict], destination_manifest: Path) -> None:
"""Merge per-source manifest items into the project-level manifest, keyed by filename."""
existing_data: list[object] = []
if destination_manifest.is_file():
try:
loaded = json.loads(destination_manifest.read_text(encoding="utf-8"))
if isinstance(loaded, list):
existing_data = loaded
else:
print(f"[WARN] Replacing non-list image manifest: {destination_manifest}")
except (OSError, json.JSONDecodeError) as exc:
print(f"[WARN] Replacing unreadable image manifest {destination_manifest}: {exc}")
new_by_filename: dict[str, dict] = {}
new_order: list[str] = []
for item in source_items:
filename = item.get("filename")
if not isinstance(filename, str):
continue
if filename not in new_by_filename:
new_order.append(filename)
new_by_filename[filename] = item
merged: list[dict] = []
seen: set[str] = set()
for item in existing_data:
if not isinstance(item, dict):
continue
filename = item.get("filename")
if not isinstance(filename, str):
continue
if filename in new_by_filename:
merged.append(new_by_filename[filename])
else:
merged.append(item)
seen.add(filename)
for filename in new_order:
if filename not in seen:
merged.append(new_by_filename[filename])
destination_manifest.write_text(
json.dumps(merged, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
@staticmethod
def _namespace_from_asset_dir(asset_dir: Path) -> str:
"""Derive a per-source namespace from a `<stem>_files` companion directory name."""
name = asset_dir.name
suffix = "_files"
return name[:-len(suffix)] if name.endswith(suffix) else name
def _image_destination_name(
self,
images_dir: Path,
source_file: Path,
namespace: str,
existing_manifest: dict[str, dict],
) -> str:
"""Return a short unique image filename for the runtime image pool."""
candidate = images_dir / source_file.name
if not candidate.exists():
return source_file.name
try:
meta = existing_manifest.get(candidate.name, {})
if (
meta.get("source_namespace") == namespace
and candidate.is_file()
and filecmp.cmp(source_file, candidate, shallow=False)
):
return candidate.name
except OSError:
pass
stem = source_file.stem
suffix = source_file.suffix
counter = 2
while True:
candidate = images_dir / f"{stem}_{counter}{suffix}"
if not candidate.exists():
return candidate.name
try:
meta = existing_manifest.get(candidate.name, {})
if (
meta.get("source_namespace") == namespace
and candidate.is_file()
and filecmp.cmp(source_file, candidate, shallow=False)
):
return candidate.name
except OSError:
pass
counter += 1
def _propagate_image_assets(self, asset_dir: Path, project_dir: Path) -> None:
"""Copy converter-generated image assets and manifest into project images/.
Filenames are preserved when possible because source Markdown commonly
uses short names that are meaningful in context. Only real collisions
receive a compact numeric suffix.
"""
manifest_path = asset_dir / "image_manifest.json"
if not manifest_path.is_file():
return
try:
source_data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"[WARN] Cannot read image manifest {manifest_path}: {exc}")
return
if not isinstance(source_data, list):
print(f"[WARN] Ignoring non-list image manifest: {manifest_path}")
return
images_dir = project_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
namespace = self._namespace_from_asset_dir(asset_dir)
existing_manifest: dict[str, dict] = {}
destination_manifest = images_dir / "image_manifest.json"
if destination_manifest.is_file():
try:
data = json.loads(destination_manifest.read_text(encoding="utf-8"))
if isinstance(data, list):
existing_manifest = {
item["filename"]: item
for item in data
if isinstance(item, dict) and isinstance(item.get("filename"), str)
}
except (OSError, json.JSONDecodeError):
existing_manifest = {}
rename_map: dict[str, str] = {}
copied_count = 0
for source_file in sorted(asset_dir.iterdir()):
if not source_file.is_file():
continue
if source_file.suffix.lower() not in IMAGE_ASSET_SUFFIXES:
continue
new_name = self._image_destination_name(
images_dir,
source_file,
namespace,
existing_manifest,
)
destination = images_dir / new_name
if source_file.resolve() != destination.resolve():
shutil.copy2(source_file, destination)
rename_map[source_file.name] = new_name
copied_count += 1
rebased_items: list[dict] = []
for item in source_data:
if not isinstance(item, dict):
continue
original = item.get("filename")
if not isinstance(original, str):
continue
new_item = dict(item)
new_item["filename"] = rename_map.get(original, original)
new_item["source_namespace"] = namespace
rebased_items.append(new_item)
self._merge_image_manifest(rebased_items, images_dir / "image_manifest.json")
print(
f"Propagated {copied_count} image asset(s) + manifest "
f"from {asset_dir} → images/ (namespace: {namespace})"
)
def _propagate_companion_image_assets(self, markdown_path: Path, project_dir: Path) -> None:
asset_dir = markdown_path.with_name(f"{markdown_path.stem}_files")
if asset_dir.is_dir():
self._propagate_image_assets(asset_dir, project_dir)
def _import_markdown_with_assets(
self,
source_path: Path,
sources_dir: Path,
move: bool,
) -> tuple[Path, Path | None, str | None]:
archived_markdown = self._copy_or_move_file(
source_path,
sources_dir / source_path.name,
move=move,
)
profile_src = source_path.with_name(f"{source_path.stem}.conversion_profile.json")
if profile_src.is_file():
self._copy_or_move_file(
profile_src,
sources_dir / f"{archived_markdown.stem}.conversion_profile.json",
move=move,
)
asset_dir = self._companion_asset_dir(source_path)
if asset_dir is None:
return archived_markdown, None, None
imported_asset_dir = self._copy_or_move_tree(
asset_dir,
sources_dir / f"{archived_markdown.stem}_files",
move=move,
)
self._rewrite_markdown_asset_refs(
archived_markdown,
original_asset_dirname=asset_dir.name,
imported_asset_dirname=imported_asset_dir.name,
)
note = None
if archived_markdown.stem != source_path.stem:
note = (
f"{source_path}: renamed imported markdown to {archived_markdown.name} "
f"and rewrote asset references to {imported_asset_dir.name}/"
)
return archived_markdown, imported_asset_dir, note
def import_sources(
self,
project_path: str,
source_items: list[str],
move: bool = False,
copy: bool = False,
) -> dict[str, list[str]]:
if move and copy:
raise ValueError("--move and --copy are mutually exclusive")
project_dir = Path(project_path)
if not project_dir.exists() or not project_dir.is_dir():
raise FileNotFoundError(f"Project directory not found: {project_dir}")
if not source_items:
raise ValueError("At least one source path or URL is required")
sources_dir = self._source_dir(project_dir)
summary: dict[str, list[str]] = {
"archived": [],
"markdown": [],
"assets": [],
"analysis": [],
"notes": [],
"skipped": [],
}
expanded_items: list[str] = []
for item in source_items:
if is_url(item):
expanded_items.append(item)
continue
item_path = Path(item)
if item_path.is_dir():
directory_files = sorted(
path for path in item_path.iterdir() if path.is_file()
)
if directory_files:
expanded_items.extend(str(path) for path in directory_files)
summary["notes"].append(
f"{item}: expanded directory into {len(directory_files)} file(s)"
)
else:
summary["skipped"].append(f"{item}: directory contains no files")
continue
expanded_items.append(item)
explicit_markdown_stems = {
Path(item).stem
for item in expanded_items
if not is_url(item)
and Path(item).exists()
and Path(item).is_file()
and Path(item).suffix.lower() in {".md", ".markdown"}
}
for item in expanded_items:
if is_url(item):
markdown_path = self._ensure_unique_path(
sources_dir / f"{derive_url_basename(item)}.md"
)
try:
self._import_url(item, markdown_path)
except Exception as exc: # pragma: no cover - summary path
archived = self._archive_url_record(sources_dir, item)
summary["archived"].append(str(archived))
summary["skipped"].append(f"{item}: {exc}")
continue
if not self._is_valid_imported_url_markdown(markdown_path):
markdown_path.unlink(missing_ok=True)
archived = self._archive_url_record(sources_dir, item)
summary["archived"].append(str(archived))
summary["skipped"].append(f"{item}: URL conversion produced no usable Markdown")
continue
summary["markdown"].append(str(markdown_path))
self._propagate_companion_image_assets(markdown_path, project_dir)
continue
source_path = Path(item)
if not source_path.exists():
summary["skipped"].append(f"{item}: path not found")
continue
if source_path.is_dir():
summary["skipped"].append(f"{item}: directories are not supported")
continue
if copy:
effective_move = False
elif move:
effective_move = True
elif is_within_path(source_path, REPO_ROOT):
effective_move = True
print(
f"note: {source_path} is inside the ppt-master repo; moved "
f"(not copied) to avoid accidental commit. Pass --copy to override.",
file=sys.stderr,
)
else:
effective_move = False
suffix = source_path.suffix.lower()
if suffix in {".md", ".markdown"}:
duplicate_markdown = self._find_equivalent_markdown(source_path, sources_dir)
if duplicate_markdown is not None:
summary["markdown"].append(str(duplicate_markdown))
self._propagate_companion_image_assets(duplicate_markdown, project_dir)
summary["notes"].append(
f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}"
)
continue
archived_markdown, asset_dir, note = self._import_markdown_with_assets(
source_path,
sources_dir,
move=effective_move,
)
summary["archived"].append(str(archived_markdown))
summary["markdown"].append(str(archived_markdown))
if asset_dir is not None:
summary["assets"].append(str(asset_dir))
self._propagate_image_assets(asset_dir, project_dir)
if note:
summary["notes"].append(note)
continue
archived_path = self._copy_or_move_file(
source_path,
sources_dir / source_path.name,
move=effective_move,
)
summary["archived"].append(str(archived_path))
if suffix in PDF_SUFFIXES:
canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
if archived_path.stem in explicit_markdown_stems:
summary["notes"].append(
f"{item}: skipped PDF auto-conversion because a same-stem Markdown source was provided"
)
continue
if canonical_markdown_path.exists():
summary["markdown"].append(str(canonical_markdown_path))
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
summary["notes"].append(
f"{item}: skipped PDF auto-conversion because {canonical_markdown_path.name} already exists"
)
continue
markdown_path = canonical_markdown_path
try:
self._import_pdf(archived_path, markdown_path)
summary["markdown"].append(str(markdown_path))
self._propagate_companion_image_assets(markdown_path, project_dir)
except Exception as exc: # pragma: no cover - summary path
summary["skipped"].append(f"{item}: PDF conversion failed ({exc})")
elif suffix in PRESENTATION_SUFFIXES:
canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
try:
intake_dir = self._import_pptx_intake(archived_path, project_dir)
intake_str = str(intake_dir)
if intake_str not in summary["analysis"]:
summary["analysis"].append(intake_str)
except Exception as exc: # pragma: no cover - summary path
summary["notes"].append(f"{item}: PPTX intake analysis failed ({exc})")
if archived_path.stem in explicit_markdown_stems:
summary["notes"].append(
f"{item}: skipped presentation auto-conversion because a same-stem Markdown source was provided"
)
continue
if canonical_markdown_path.exists():
summary["markdown"].append(str(canonical_markdown_path))
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
summary["notes"].append(
f"{item}: skipped presentation auto-conversion because {canonical_markdown_path.name} already exists"
)
continue
markdown_path = canonical_markdown_path
try:
self._import_presentation(archived_path, markdown_path)
summary["markdown"].append(str(markdown_path))
self._propagate_companion_image_assets(markdown_path, project_dir)
except Exception as exc: # pragma: no cover - summary path
summary["skipped"].append(f"{item}: presentation conversion failed ({exc})")
elif suffix in EXCEL_SUFFIXES:
canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
if archived_path.stem in explicit_markdown_stems:
summary["notes"].append(
f"{item}: skipped Excel auto-conversion because a same-stem Markdown source was provided"
)
continue
if canonical_markdown_path.exists():
summary["markdown"].append(str(canonical_markdown_path))
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
summary["notes"].append(
f"{item}: skipped Excel auto-conversion because {canonical_markdown_path.name} already exists"
)
continue
markdown_path = canonical_markdown_path
try:
self._import_excel(archived_path, markdown_path)
summary["markdown"].append(str(markdown_path))
self._propagate_companion_image_assets(markdown_path, project_dir)
except Exception as exc: # pragma: no cover - summary path
summary["skipped"].append(f"{item}: Excel conversion failed ({exc})")
elif suffix in LEGACY_EXCEL_SUFFIXES:
summary["notes"].append(
f"{item}: archived only; legacy .xls is not converted automatically. "
"Resave as .xlsx to generate Markdown."
)
elif suffix in TABLE_TEXT_SUFFIXES:
summary["notes"].append(
f"{item}: archived as a plain-text table source; no Markdown conversion needed"
)
elif suffix in DOC_SUFFIXES:
canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
if archived_path.stem in explicit_markdown_stems:
summary["notes"].append(
f"{item}: skipped document auto-conversion because a same-stem Markdown source was provided"
)
continue
if canonical_markdown_path.exists():
summary["markdown"].append(str(canonical_markdown_path))
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
summary["notes"].append(
f"{item}: skipped document auto-conversion because {canonical_markdown_path.name} already exists"
)
continue
markdown_path = canonical_markdown_path
try:
self._import_doc(archived_path, markdown_path)
summary["markdown"].append(str(markdown_path))
self._propagate_companion_image_assets(markdown_path, project_dir)
except Exception as exc: # pragma: no cover - summary path
summary["skipped"].append(f"{item}: document conversion failed ({exc})")
elif suffix == ".txt":
markdown_path = self._normalize_text_source(archived_path, sources_dir)
summary["markdown"].append(str(markdown_path))
else:
summary["notes"].append(f"{item}: archived only, no automatic conversion")
return summary
def validate_project(self, project_path: str) -> tuple[bool, list[str], list[str]]:
project_path_obj = Path(project_path)
is_valid, errors, warnings = validate_project_structure(str(project_path_obj))
if project_path_obj.exists() and project_path_obj.is_dir():
info = get_project_info_common(str(project_path_obj))
if info.get("svg_files"):
svg_files = [project_path_obj / "svg_output" / name for name in info["svg_files"]]
expected_format = info.get("format")
if expected_format == "unknown":
expected_format = None
warnings.extend(validate_svg_viewbox(svg_files, expected_format))
return is_valid, errors, warnings
def get_project_info(self, project_path: str) -> dict[str, object]:
shared = get_project_info_common(project_path)
return {
"name": shared.get("name", Path(project_path).name),
"path": shared.get("path", str(project_path)),
"exists": shared.get("exists", False),
"svg_count": shared.get("svg_count", 0),
"has_spec": shared.get("has_spec", False),
"has_source": shared.get("has_source", False),
"source_count": shared.get("source_count", 0),
"canvas_format": shared.get("format_name", "Unknown"),
"create_date": shared.get("date_formatted", "Unknown"),
}
def build_parser() -> argparse.ArgumentParser:
"""Build the command-line parser."""
parser = argparse.ArgumentParser(
description="PPT Master project management helpers.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
python3 scripts/project_manager.py init demo --format ppt169
python3 scripts/project_manager.py import-sources projects/demo file.md --move
python3 scripts/project_manager.py validate projects/demo
python3 scripts/project_manager.py info projects/demo
""",
)
subparsers = parser.add_subparsers(dest="command", required=True)
init = subparsers.add_parser("init", help="Create a project directory")
init.add_argument("project_name", help="Project name")
init.add_argument("--format", default="ppt169", help="Canvas format (default: ppt169)")
init.add_argument("--dir", default=None, help="Base directory for the project")
import_sources = subparsers.add_parser(
"import-sources",
help="Import source files or URLs into a project",
)
import_sources.add_argument("project_path", help="Project directory")
import_sources.add_argument("sources", nargs="+", help="Source files, directories, or URLs")
mode = import_sources.add_mutually_exclusive_group()
mode.add_argument("--move", action="store_true", help="Move local source files")
mode.add_argument("--copy", action="store_true", help="Copy local source files")
validate = subparsers.add_parser("validate", help="Validate a project directory")
validate.add_argument("project_path", help="Project directory")
info = subparsers.add_parser("info", help="Print project metadata")
info.add_argument("project_path", help="Project directory")
return parser
def main(argv: list[str] | None = None) -> int:
"""Run the CLI entry point."""
parser = build_parser()
args = parser.parse_args(argv)
manager = ProjectManager()
try:
if args.command == "init":
project_path = manager.init_project(
args.project_name,
args.format,
base_dir=args.dir,
)
print(f"[OK] Project initialized: {project_path}")
print("Next:")
print("1. Put source files into sources/ (or use import-sources)")
print("2. Save your design spec to the project root")
print("3. Generate SVG files into svg_output/")
return 0
if args.command == "import-sources":
summary = manager.import_sources(
args.project_path,
args.sources,
move=args.move,
copy=args.copy,
)
print(f"[OK] Imported sources into: {args.project_path}")
if summary["archived"]:
print("\nArchived originals / URL records:")
for item in summary["archived"]:
print(f" - {item}")
if summary["markdown"]:
print("\nNormalized markdown:")
for item in summary["markdown"]:
print(f" - {item}")
if summary["assets"]:
print("\nImported asset directories:")
for item in summary["assets"]:
print(f" - {item}")
if summary["analysis"]:
print("\nAnalysis artifacts:")
for item in summary["analysis"]:
print(f" - {item}")
if summary["notes"]:
print("\nNotes:")
for item in summary["notes"]:
print(f" - {item}")
if summary["skipped"]:
print("\nSkipped:")
for item in summary["skipped"]:
print(f" - {item}")
return 0
if args.command == "validate":
project_path = args.project_path
is_valid, errors, warnings = manager.validate_project(project_path)
print(f"\nProject validation: {project_path}")
print("=" * 60)
if errors:
print("\n[ERROR]")
for error in errors:
print(f" - {error}")
if warnings:
print("\n[WARN]")
for warning in warnings:
print(f" - {warning}")
if is_valid and not warnings:
print("\n[OK] Project structure is complete.")
elif is_valid:
print("\n[OK] Project structure is valid, with warnings.")
else:
print("\n[ERROR] Project structure is invalid.")
return 1
return 0
if args.command == "info":
project_path = args.project_path
info = manager.get_project_info(project_path)
print(f"\nProject info: {info['name']}")
print("=" * 60)
print(f"Path: {info['path']}")
print(f"Exists: {'Yes' if info['exists'] else 'No'}")
print(f"SVG files: {info['svg_count']}")
print(f"Design spec: {'Yes' if info['has_spec'] else 'No'}")
print(f"Source materials: {'Yes' if info['has_source'] else 'No'}")
print(f"Source count: {info['source_count']}")
print(f"Canvas format: {info['canvas_format']}")
print(f"Created: {info['create_date']}")
return 0
parser.error(f"Unknown command: {args.command}")
except Exception as exc:
print(f"[ERROR] {exc}")
return 1
if __name__ == "__main__":
raise SystemExit(main())