|
@@ -17,6 +17,8 @@ from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
_CHAPTER_NUM_RE = re.compile(r"第(?P<num>\d+)章")
|
|
_CHAPTER_NUM_RE = re.compile(r"第(?P<num>\d+)章")
|
|
|
|
|
+_OUTLINE_HEADING_RE = re.compile(r"^#{1,6}\s*第\s*(?P<num>\d+)\s*章[::]\s*(?P<title>.+?)\s*$", re.MULTILINE)
|
|
|
|
|
+_SPLIT_OUTLINE_FILENAME_RE = re.compile(r"^第0*(?P<num>\d+)章[-—_ ]+(?P<title>.+?)\.md$")
|
|
|
|
|
|
|
|
|
|
|
|
|
def volume_num_for_chapter(chapter_num: int, *, chapters_per_volume: int = 50) -> int:
|
|
def volume_num_for_chapter(chapter_num: int, *, chapters_per_volume: int = 50) -> int:
|
|
@@ -35,6 +37,75 @@ def extract_chapter_num_from_filename(filename: str) -> Optional[int]:
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _safe_title_for_filename(title: str) -> str:
|
|
|
|
|
+ cleaned = title.strip()
|
|
|
|
|
+ if not cleaned:
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ from security_utils import sanitize_filename
|
|
|
|
|
+ except ImportError: # pragma: no cover
|
|
|
|
|
+ from scripts.security_utils import sanitize_filename
|
|
|
|
|
+
|
|
|
|
|
+ safe_title = sanitize_filename(cleaned, max_length=60)
|
|
|
|
|
+ return "" if safe_title == "unnamed_entity" else safe_title
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _extract_title_from_outline_text(outline_text: str, chapter_num: int) -> str:
|
|
|
|
|
+ for match in _OUTLINE_HEADING_RE.finditer(outline_text):
|
|
|
|
|
+ if int(match.group("num")) != chapter_num:
|
|
|
|
|
+ continue
|
|
|
|
|
+ return _safe_title_for_filename(match.group("title"))
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _extract_title_from_split_outline_filename(outline_dir: Path, chapter_num: int) -> str:
|
|
|
|
|
+ patterns = [
|
|
|
|
|
+ f"第{chapter_num}章*.md",
|
|
|
|
|
+ f"第{chapter_num:02d}章*.md",
|
|
|
|
|
+ f"第{chapter_num:03d}章*.md",
|
|
|
|
|
+ f"第{chapter_num:04d}章*.md",
|
|
|
|
|
+ ]
|
|
|
|
|
+ for pattern in patterns:
|
|
|
|
|
+ for path in sorted(outline_dir.glob(pattern)):
|
|
|
|
|
+ match = _SPLIT_OUTLINE_FILENAME_RE.match(path.name)
|
|
|
|
|
+ if not match:
|
|
|
|
|
+ continue
|
|
|
|
|
+ if int(match.group("num")) != chapter_num:
|
|
|
|
|
+ continue
|
|
|
|
|
+ title = _safe_title_for_filename(match.group("title"))
|
|
|
|
|
+ if title:
|
|
|
|
|
+ return title
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def extract_chapter_title(project_root: Path, chapter_num: int) -> str:
|
|
|
|
|
+ """从详细大纲提取章节标题,用于生成更直观的章节文件名。"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ from chapter_outline_loader import load_chapter_outline
|
|
|
|
|
+ except ImportError: # pragma: no cover
|
|
|
|
|
+ from scripts.chapter_outline_loader import load_chapter_outline
|
|
|
|
|
+
|
|
|
|
|
+ outline_text = load_chapter_outline(project_root, chapter_num, max_chars=None)
|
|
|
|
|
+ if not outline_text.startswith("⚠️"):
|
|
|
|
|
+ title = _extract_title_from_outline_text(outline_text, chapter_num)
|
|
|
|
|
+ if title:
|
|
|
|
|
+ return title
|
|
|
|
|
+
|
|
|
|
|
+ outline_dir = project_root / "大纲"
|
|
|
|
|
+ if outline_dir.exists():
|
|
|
|
|
+ return _extract_title_from_split_outline_filename(outline_dir, chapter_num)
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_chapter_filename(project_root: Path, chapter_num: int, *, use_volume_layout: bool) -> str:
|
|
|
|
|
+ padded = f"{chapter_num:03d}" if use_volume_layout else f"{chapter_num:04d}"
|
|
|
|
|
+ title = extract_chapter_title(project_root, chapter_num)
|
|
|
|
|
+ if title:
|
|
|
|
|
+ return f"第{padded}章-{title}.md"
|
|
|
|
|
+ return f"第{padded}章.md"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def find_chapter_file(project_root: Path, chapter_num: int) -> Optional[Path]:
|
|
def find_chapter_file(project_root: Path, chapter_num: int) -> Optional[Path]:
|
|
|
"""
|
|
"""
|
|
|
Find an existing chapter file for chapter_num under project_root/正文.
|
|
Find an existing chapter file for chapter_num under project_root/正文.
|
|
@@ -71,14 +142,14 @@ def default_chapter_draft_path(project_root: Path, chapter_num: int, *, use_volu
|
|
|
Args:
|
|
Args:
|
|
|
project_root: 项目根目录
|
|
project_root: 项目根目录
|
|
|
chapter_num: 章节号
|
|
chapter_num: 章节号
|
|
|
- use_volume_layout: True 使用卷布局 (正文/第N卷/第NNN章.md),False 使用平坦布局 (正文/第NNNN章.md)
|
|
|
|
|
|
|
+ use_volume_layout: True 使用卷布局 (正文/第N卷/第NNN章-章节标题.md),False 使用平坦布局 (正文/第NNNN章-章节标题.md)
|
|
|
|
|
|
|
|
- Default is flat layout to match SKILL.md documentation.
|
|
|
|
|
|
|
+ Default is flat layout. If the detailed outline already has a chapter title,
|
|
|
|
|
+ append it to the filename for better discoverability.
|
|
|
"""
|
|
"""
|
|
|
if use_volume_layout:
|
|
if use_volume_layout:
|
|
|
vol_dir = project_root / "正文" / f"第{volume_num_for_chapter(chapter_num)}卷"
|
|
vol_dir = project_root / "正文" / f"第{volume_num_for_chapter(chapter_num)}卷"
|
|
|
- return vol_dir / f"第{chapter_num:03d}章.md"
|
|
|
|
|
|
|
+ return vol_dir / _build_chapter_filename(project_root, chapter_num, use_volume_layout=True)
|
|
|
else:
|
|
else:
|
|
|
- # Flat layout: 正文/第NNNN章.md (matches SKILL.md)
|
|
|
|
|
- return project_root / "正文" / f"第{chapter_num:04d}章.md"
|
|
|
|
|
|
|
+ return project_root / "正文" / _build_chapter_filename(project_root, chapter_num, use_volume_layout=False)
|
|
|
|
|
|