|
@@ -0,0 +1,249 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+"""
|
|
|
|
|
+Runtime validators/normalizers for state.json sections.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import re
|
|
|
|
|
+from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+FORESHADOWING_STATUS_PENDING = "未回收"
|
|
|
|
|
+FORESHADOWING_STATUS_RESOLVED = "已回收"
|
|
|
|
|
+
|
|
|
|
|
+FORESHADOWING_TIER_CORE = "核心"
|
|
|
|
|
+FORESHADOWING_TIER_SUB = "支线"
|
|
|
|
|
+FORESHADOWING_TIER_DECOR = "装饰"
|
|
|
|
|
+
|
|
|
|
|
+FORESHADOWING_PLANTED_KEYS = [
|
|
|
|
|
+ "planted_chapter",
|
|
|
|
|
+ "added_chapter",
|
|
|
|
|
+ "source_chapter",
|
|
|
|
|
+ "start_chapter",
|
|
|
|
|
+ "chapter",
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+FORESHADOWING_TARGET_KEYS = [
|
|
|
|
|
+ "target_chapter",
|
|
|
|
|
+ "due_chapter",
|
|
|
|
|
+ "deadline_chapter",
|
|
|
|
|
+ "resolve_by_chapter",
|
|
|
|
|
+ "target",
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+_PENDING_STATUS_TEXT = {"未回收", "待回收", "进行中", "未解决", "pending", "active"}
|
|
|
|
|
+_RESOLVED_STATUS_TEXT = {"已回收", "已完成", "已解决", "完成", "resolved", "done", "complete"}
|
|
|
|
|
+
|
|
|
|
|
+_TIER_CORE_TEXT = {"核心", "主线", "core", "main"}
|
|
|
|
|
+_TIER_DECOR_TEXT = {"装饰", "次要", "decor", "decoration"}
|
|
|
|
|
+
|
|
|
|
|
+_PATTERN_FIELDS = [
|
|
|
|
|
+ "coolpoint_patterns",
|
|
|
|
|
+ "coolpoint_pattern",
|
|
|
|
|
+ "cool_point_patterns",
|
|
|
|
|
+ "cool_point_pattern",
|
|
|
|
|
+ "patterns",
|
|
|
|
|
+ "pattern",
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+_PATTERN_SPLIT_RE = re.compile(r"[、,,/|+;;。]+")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def to_positive_int(value: Any) -> Optional[int]:
|
|
|
|
|
+ if value is None or isinstance(value, bool):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ number = int(value)
|
|
|
|
|
+ return number if number > 0 else None
|
|
|
|
|
+ except (TypeError, ValueError):
|
|
|
|
|
+ if isinstance(value, str):
|
|
|
|
|
+ matched = re.search(r"\d+", value)
|
|
|
|
|
+ if matched:
|
|
|
|
|
+ number = int(matched.group(0))
|
|
|
|
|
+ return number if number > 0 else None
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def resolve_chapter_field(item: Mapping[str, Any], keys: Sequence[str]) -> Optional[int]:
|
|
|
|
|
+ for key in keys:
|
|
|
|
|
+ if key in item:
|
|
|
|
|
+ chapter = to_positive_int(item.get(key))
|
|
|
|
|
+ if chapter is not None:
|
|
|
|
|
+ return chapter
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_foreshadowing_status(
|
|
|
|
|
+ raw_status: Any,
|
|
|
|
|
+ default: str = FORESHADOWING_STATUS_PENDING,
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ text = str(raw_status or "").strip()
|
|
|
|
|
+ if not text:
|
|
|
|
|
+ return default
|
|
|
|
|
+
|
|
|
|
|
+ text_lower = text.lower()
|
|
|
|
|
+ if (
|
|
|
|
|
+ text in _RESOLVED_STATUS_TEXT
|
|
|
|
|
+ or text_lower in _RESOLVED_STATUS_TEXT
|
|
|
|
|
+ or FORESHADOWING_STATUS_RESOLVED in text
|
|
|
|
|
+ ):
|
|
|
|
|
+ return FORESHADOWING_STATUS_RESOLVED
|
|
|
|
|
+
|
|
|
|
|
+ if text in _PENDING_STATUS_TEXT or text_lower in _PENDING_STATUS_TEXT:
|
|
|
|
|
+ return FORESHADOWING_STATUS_PENDING
|
|
|
|
|
+
|
|
|
|
|
+ return default
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def is_resolved_foreshadowing_status(raw_status: Any) -> bool:
|
|
|
|
|
+ return normalize_foreshadowing_status(raw_status) == FORESHADOWING_STATUS_RESOLVED
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_foreshadowing_tier(
|
|
|
|
|
+ raw_tier: Any,
|
|
|
|
|
+ default: str = FORESHADOWING_TIER_SUB,
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ text = str(raw_tier or "").strip()
|
|
|
|
|
+ if not text:
|
|
|
|
|
+ return default
|
|
|
|
|
+
|
|
|
|
|
+ text_lower = text.lower()
|
|
|
|
|
+ if text in _TIER_CORE_TEXT or text_lower in _TIER_CORE_TEXT:
|
|
|
|
|
+ return FORESHADOWING_TIER_CORE
|
|
|
|
|
+ if text in _TIER_DECOR_TEXT or text_lower in _TIER_DECOR_TEXT:
|
|
|
|
|
+ return FORESHADOWING_TIER_DECOR
|
|
|
|
|
+ return default
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def split_patterns(raw_value: Any) -> List[str]:
|
|
|
|
|
+ if raw_value is None:
|
|
|
|
|
+ return []
|
|
|
|
|
+
|
|
|
|
|
+ tokens: List[str] = []
|
|
|
|
|
+ if isinstance(raw_value, list):
|
|
|
|
|
+ for item in raw_value:
|
|
|
|
|
+ text = str(item).strip()
|
|
|
|
|
+ if text:
|
|
|
|
|
+ tokens.append(text)
|
|
|
|
|
+ elif isinstance(raw_value, str):
|
|
|
|
|
+ text = raw_value.strip()
|
|
|
|
|
+ if not text:
|
|
|
|
|
+ return []
|
|
|
|
|
+ split_values = [part.strip() for part in _PATTERN_SPLIT_RE.split(text)]
|
|
|
|
|
+ tokens.extend([part for part in split_values if part])
|
|
|
|
|
+ else:
|
|
|
|
|
+ return []
|
|
|
|
|
+
|
|
|
|
|
+ deduped: List[str] = []
|
|
|
|
|
+ seen = set()
|
|
|
|
|
+ for token in tokens:
|
|
|
|
|
+ if token not in seen:
|
|
|
|
|
+ seen.add(token)
|
|
|
|
|
+ deduped.append(token)
|
|
|
|
|
+ return deduped
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def count_patterns(raw_value: Any) -> Optional[int]:
|
|
|
|
|
+ patterns = split_patterns(raw_value)
|
|
|
|
|
+ if not patterns:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return len(patterns)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_foreshadowing_item(item: Mapping[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
+ normalized = dict(item)
|
|
|
|
|
+
|
|
|
|
|
+ normalized["status"] = normalize_foreshadowing_status(item.get("status"))
|
|
|
|
|
+ normalized["tier"] = normalize_foreshadowing_tier(item.get("tier"))
|
|
|
|
|
+
|
|
|
|
|
+ content = str(item.get("content") or "").strip()
|
|
|
|
|
+ if content:
|
|
|
|
|
+ normalized["content"] = content
|
|
|
|
|
+
|
|
|
|
|
+ planted_chapter = resolve_chapter_field(item, FORESHADOWING_PLANTED_KEYS)
|
|
|
|
|
+ if planted_chapter is not None:
|
|
|
|
|
+ normalized["planted_chapter"] = planted_chapter
|
|
|
|
|
+
|
|
|
|
|
+ target_chapter = resolve_chapter_field(item, FORESHADOWING_TARGET_KEYS)
|
|
|
|
|
+ if target_chapter is not None:
|
|
|
|
|
+ normalized["target_chapter"] = target_chapter
|
|
|
|
|
+
|
|
|
|
|
+ resolved_chapter = resolve_chapter_field(item, ["resolved_chapter", "resolved_at_chapter", "resolved"])
|
|
|
|
|
+ if resolved_chapter is not None:
|
|
|
|
|
+ normalized["resolved_chapter"] = resolved_chapter
|
|
|
|
|
+
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_foreshadowing_list(raw_items: Any) -> List[Dict[str, Any]]:
|
|
|
|
|
+ if not isinstance(raw_items, list):
|
|
|
|
|
+ return []
|
|
|
|
|
+
|
|
|
|
|
+ normalized: List[Dict[str, Any]] = []
|
|
|
|
|
+ for raw_item in raw_items:
|
|
|
|
|
+ if isinstance(raw_item, Mapping):
|
|
|
|
|
+ normalized.append(normalize_foreshadowing_item(raw_item))
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_chapter_meta_entry(entry: Mapping[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
+ normalized = dict(entry)
|
|
|
|
|
+
|
|
|
|
|
+ merged_patterns: List[str] = []
|
|
|
|
|
+ seen = set()
|
|
|
|
|
+ for field_name in _PATTERN_FIELDS:
|
|
|
|
|
+ for pattern in split_patterns(entry.get(field_name)):
|
|
|
|
|
+ if pattern not in seen:
|
|
|
|
|
+ seen.add(pattern)
|
|
|
|
|
+ merged_patterns.append(pattern)
|
|
|
|
|
+
|
|
|
|
|
+ if merged_patterns:
|
|
|
|
|
+ normalized["coolpoint_patterns"] = merged_patterns
|
|
|
|
|
+
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_chapter_meta(raw_chapter_meta: Any) -> Dict[str, Dict[str, Any]]:
|
|
|
|
|
+ if not isinstance(raw_chapter_meta, Mapping):
|
|
|
|
|
+ return {}
|
|
|
|
|
+
|
|
|
|
|
+ normalized: Dict[str, Dict[str, Any]] = {}
|
|
|
|
|
+ for chapter_key, chapter_entry in raw_chapter_meta.items():
|
|
|
|
|
+ if isinstance(chapter_entry, Mapping):
|
|
|
|
|
+ normalized[str(chapter_key)] = normalize_chapter_meta_entry(chapter_entry)
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def get_chapter_meta_entry(state: Mapping[str, Any], chapter: int) -> Dict[str, Any]:
|
|
|
|
|
+ chapter_meta = state.get("chapter_meta", {})
|
|
|
|
|
+ if not isinstance(chapter_meta, Mapping):
|
|
|
|
|
+ return {}
|
|
|
|
|
+
|
|
|
|
|
+ for lookup_key in (f"{chapter:04d}", str(chapter)):
|
|
|
|
|
+ value = chapter_meta.get(lookup_key)
|
|
|
|
|
+ if isinstance(value, Mapping):
|
|
|
|
|
+ return normalize_chapter_meta_entry(value)
|
|
|
|
|
+
|
|
|
|
|
+ for raw_key, raw_value in chapter_meta.items():
|
|
|
|
|
+ if to_positive_int(raw_key) == chapter and isinstance(raw_value, Mapping):
|
|
|
|
|
+ return normalize_chapter_meta_entry(raw_value)
|
|
|
|
|
+
|
|
|
|
|
+ return {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_state_runtime_sections(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
+ if not isinstance(state, dict):
|
|
|
|
|
+ return {}
|
|
|
|
|
+
|
|
|
|
|
+ plot_threads = state.get("plot_threads")
|
|
|
|
|
+ if not isinstance(plot_threads, dict):
|
|
|
|
|
+ plot_threads = {}
|
|
|
|
|
+ state["plot_threads"] = plot_threads
|
|
|
|
|
+ plot_threads["foreshadowing"] = normalize_foreshadowing_list(plot_threads.get("foreshadowing"))
|
|
|
|
|
+
|
|
|
|
|
+ state["chapter_meta"] = normalize_chapter_meta(state.get("chapter_meta", {}))
|
|
|
|
|
+ return state
|
|
|
|
|
+
|