main.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""
  4. main.py
  5. Unified controller for prompt-library conversions.
  6. Capabilities
  7. - Scan default folders and let user select a source to convert
  8. - If you select an Excel file (.xlsx), it will convert Excel → Docs
  9. - If you select a prompt docs folder, it will convert Docs → Excel
  10. - Fully non-interactive CLI flags are also supported (automation-friendly)
  11. Conventions (relative to repository root = this file's parent)
  12. - Excel sources under: ./prompt_excel/
  13. - Docs sources under: ./prompt_docs/
  14. - Outputs:
  15. - Excel→Docs: ./prompt_docs/prompt_docs_YYYY_MMDD_HHMMSS/{prompts,docs}
  16. - Docs→Excel: ./prompt_excel/prompt_excel_YYYY_MMDD_HHMMSS/rebuilt.xlsx
  17. Examples
  18. # Interactive selection
  19. python3 main.py
  20. # Non-interactive: choose one Excel file
  21. python3 main.py --select "prompt_excel/prompt (3).xlsx"
  22. # Non-interactive: choose one docs set directory
  23. python3 main.py --select "prompt_docs/prompt_docs_2025_0903_055708"
  24. Notes
  25. - This script is a thin orchestrator that delegates actual work to
  26. scripts/start_convert.py to ensure a single source of truth.
  27. """
  28. from __future__ import annotations
  29. import argparse
  30. import os
  31. import subprocess
  32. import sys
  33. from dataclasses import dataclass
  34. from pathlib import Path
  35. from typing import List, Optional, Sequence, Tuple
  36. # Optional Rich UI imports (fallback to plain if unavailable)
  37. try:
  38. from rich.console import Console
  39. from rich.layout import Layout
  40. from rich.panel import Panel
  41. from rich.table import Table
  42. from rich.text import Text
  43. from rich import box
  44. from rich.prompt import IntPrompt
  45. _RICH_AVAILABLE = True
  46. except Exception: # pragma: no cover
  47. _RICH_AVAILABLE = False
  48. # Optional InquirerPy for arrow-key selection
  49. try:
  50. from InquirerPy import inquirer as _inq
  51. _INQUIRER_AVAILABLE = True
  52. except Exception: # pragma: no cover
  53. _INQUIRER_AVAILABLE = False
  54. @dataclass
  55. class Candidate:
  56. index: int
  57. kind: str # "excel" | "docs"
  58. path: Path
  59. label: str
  60. def get_repo_root() -> Path:
  61. return Path(__file__).resolve().parent
  62. def list_excel_files(excel_dir: Path) -> List[Path]:
  63. if not excel_dir.exists():
  64. return []
  65. return sorted([p for p in excel_dir.iterdir() if p.is_file() and p.suffix.lower() == ".xlsx"], key=lambda p: p.stat().st_mtime)
  66. def has_prompt_files(directory: Path) -> bool:
  67. if not directory.exists():
  68. return False
  69. # Detect files like "(r,c)_*.md" anywhere under the directory
  70. for file_path in directory.rglob("*.md"):
  71. name = file_path.name
  72. if name.startswith("(") and ")_" in name:
  73. return True
  74. return False
  75. def list_doc_sets(docs_dir: Path) -> List[Path]:
  76. results: List[Path] = []
  77. if not docs_dir.exists():
  78. return results
  79. # If the docs_dir itself looks like a set, include it
  80. if has_prompt_files(docs_dir):
  81. results.append(docs_dir)
  82. # Also include any immediate children that look like a docs set
  83. for child in sorted(docs_dir.iterdir()):
  84. if child.is_dir() and has_prompt_files(child):
  85. results.append(child)
  86. return results
  87. def run_start_convert(start_convert: Path, mode: str, project_root: Path, select_path: Optional[Path] = None, excel_dir: Optional[Path] = None, docs_dir: Optional[Path] = None) -> int:
  88. """Delegate to scripts/start_convert.py with appropriate flags."""
  89. python_exe = sys.executable
  90. cmd: List[str] = [python_exe, str(start_convert), "--mode", mode]
  91. if select_path is not None:
  92. # Always pass as repo-root-relative or absolute string
  93. cmd.extend(["--select", str(select_path)])
  94. if excel_dir is not None:
  95. cmd.extend(["--excel-dir", str(excel_dir)])
  96. if docs_dir is not None:
  97. cmd.extend(["--docs-dir", str(docs_dir)])
  98. # Execute in repo root to ensure relative defaults resolve correctly
  99. proc = subprocess.run(cmd, cwd=str(project_root))
  100. return proc.returncode
  101. def build_candidates(project_root: Path, excel_dir: Path, docs_dir: Path) -> List[Candidate]:
  102. candidates: List[Candidate] = []
  103. idx = 1
  104. for path in list_excel_files(excel_dir):
  105. label = f"[Excel] {path.name}"
  106. candidates.append(Candidate(index=idx, kind="excel", path=path, label=label))
  107. idx += 1
  108. for path in list_doc_sets(docs_dir):
  109. display = path.relative_to(project_root) if path.is_absolute() else path
  110. label = f"[Docs] {display}"
  111. candidates.append(Candidate(index=idx, kind="docs", path=path, label=label))
  112. idx += 1
  113. return candidates
  114. def select_interactively(candidates: Sequence[Candidate]) -> Optional[Candidate]:
  115. if not candidates:
  116. print("没有可用的 Excel 或 Docs 源。请将 .xlsx 放到 prompt_excel/ 或将文档放到 prompt_docs/ 下。")
  117. return None
  118. # Prefer arrow-key selection if available
  119. if _INQUIRER_AVAILABLE:
  120. try:
  121. choices = [
  122. {"name": f"{'[Excel]' if c.kind=='excel' else '[Docs]'} {c.label}", "value": c.index}
  123. for c in candidates
  124. ]
  125. selection = _inq.select(
  126. message="选择要转换的源(上下箭头,回车确认,Ctrl+C 取消):",
  127. choices=choices,
  128. default=choices[0]["value"],
  129. ).execute()
  130. match = next((c for c in candidates if c.index == selection), None)
  131. return match
  132. except KeyboardInterrupt:
  133. return None
  134. if _RICH_AVAILABLE:
  135. console = Console()
  136. layout = Layout()
  137. layout.split_column(
  138. Layout(name="header", size=3),
  139. Layout(name="list"),
  140. Layout(name="footer", size=3),
  141. )
  142. header = Panel(Text("提示词库转换器", style="bold cyan"), subtitle="选择一个源开始转换", box=box.ROUNDED)
  143. table = Table(box=box.SIMPLE_HEAVY)
  144. table.add_column("编号", style="bold yellow", justify="right", width=4)
  145. table.add_column("类型", style="magenta", width=8)
  146. table.add_column("路径/名称", style="white")
  147. for c in candidates:
  148. table.add_row(str(c.index), "Excel" if c.kind == "excel" else "Docs", c.label)
  149. layout["header"].update(header)
  150. layout["list"].update(Panel(table, title="可选源", border_style="cyan"))
  151. layout["footer"].update(Panel(Text("输入编号并回车(0 退出)", style="bold"), box=box.ROUNDED))
  152. console.print(layout)
  153. while True:
  154. try:
  155. choice = IntPrompt.ask("编号", default=0)
  156. except Exception:
  157. return None
  158. if choice == 0:
  159. return None
  160. match = next((c for c in candidates if c.index == choice), None)
  161. if match is not None:
  162. return match
  163. console.print("[red]编号不存在,请重试[/red]")
  164. # Plain fallback
  165. print("请选择一个源进行转换:")
  166. for c in candidates:
  167. print(f" {c.index:2d}. {c.label}")
  168. print(" 0. 退出")
  169. while True:
  170. try:
  171. raw = input("输入编号后回车:").strip()
  172. except EOFError:
  173. return None
  174. if not raw:
  175. continue
  176. if raw == "0":
  177. return None
  178. if not raw.isdigit():
  179. print("请输入有效数字。")
  180. continue
  181. choice = int(raw)
  182. match = next((c for c in candidates if c.index == choice), None)
  183. if match is None:
  184. print("编号不存在,请重试。")
  185. continue
  186. return match
  187. def parse_args() -> argparse.Namespace:
  188. p = argparse.ArgumentParser(description="prompt-library conversion controller")
  189. p.add_argument("--excel-dir", type=str, default="prompt_excel", help="Excel sources directory (default: prompt_excel)")
  190. p.add_argument("--docs-dir", type=str, default="prompt_docs", help="Docs sources directory (default: prompt_docs)")
  191. p.add_argument("--select", type=str, default=None, help="Path to a specific .xlsx file or a docs folder")
  192. p.add_argument("--non-interactive", action="store_true", help="Do not prompt; require --select or exit")
  193. return p.parse_args()
  194. def main() -> int:
  195. repo_root = get_repo_root()
  196. start_convert = repo_root / "scripts" / "start_convert.py"
  197. if not start_convert.exists():
  198. print("找不到 scripts/start_convert.py。")
  199. return 1
  200. args = parse_args()
  201. excel_dir = (repo_root / args.excel_dir).resolve() if not Path(args.excel_dir).is_absolute() else Path(args.excel_dir).resolve()
  202. docs_dir = (repo_root / args.docs_dir).resolve() if not Path(args.docs_dir).is_absolute() else Path(args.docs_dir).resolve()
  203. # Non-interactive path with explicit selection
  204. if args.non_interactive or args.select:
  205. if not args.select:
  206. print("--non-interactive 需要配合 --select 使用。")
  207. return 2
  208. selected = Path(args.select)
  209. if not selected.is_absolute():
  210. selected = (repo_root / selected).resolve()
  211. if not selected.exists():
  212. print(f"选择的路径不存在: {selected}")
  213. return 2
  214. if selected.is_file() and selected.suffix.lower() == ".xlsx":
  215. return run_start_convert(start_convert, mode="excel2docs", project_root=repo_root, select_path=selected, excel_dir=excel_dir)
  216. if selected.is_dir():
  217. # Treat as docs set
  218. return run_start_convert(start_convert, mode="docs2excel", project_root=repo_root, select_path=selected, docs_dir=docs_dir)
  219. print("无法识别的选择类型(既不是 .xlsx 文件也不是目录)。")
  220. return 2
  221. # Interactive selection
  222. candidates = build_candidates(repo_root, excel_dir, docs_dir)
  223. chosen = select_interactively(candidates)
  224. if chosen is None:
  225. return 0
  226. if chosen.kind == "excel":
  227. return run_start_convert(start_convert, mode="excel2docs", project_root=repo_root, select_path=chosen.path, excel_dir=excel_dir)
  228. else:
  229. return run_start_convert(start_convert, mode="docs2excel", project_root=repo_root, select_path=chosen.path, docs_dir=docs_dir)
  230. if __name__ == "__main__":
  231. sys.exit(main())