"""场景的选择:人写的 `sources.yml` 优先,其次机器写的 `selection.yml`,最后才问人。 为什么要第二份文件:`sources.yml` 是人手写的(注释、顺序、措辞都是人的),用 PyYAML 回写会把 这些全吃掉。所以机器只写自己那一份 `selection.yml`,人写的文件永远不被机器改写。 优先级(明确写死,避免两份文件打架): 1. `sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(人的意志最高) 2. `selection.yml` 里有这一页的记录 → 用它(上次交互的结果) 3. 都没有 → 默认取骨架最多的场景 + 该场景全部骨架; 开了 `--interactive` 才问人 **这份选择不再裁剪产物**:`fetch` 会把页面里每个有资源的场景都落成一个壁纸目录(见 `tools/downloader/README.md`)。选择只决定"哪一档是推荐的"——写进 `page.json` 的 `chosenScene` / `chosenDir`,当 `promote` 没给 `--scene` 时的默认值。记录里的 `spines` / `images` 字段保留只为兼容旧文件,不再有任何裁剪语义。 """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Sequence import yaml __all__ = ["Choice", "load_selection", "save_selection", "resolve", "ask"] _HEADER = ( "# 场景/骨架选择记录(由 `python -m tools.downloader select` 写入,机器所有)。\n" "# 人写的来源清单是 wallpapers/sources.yml;这里只记「哪一页推荐哪个场景」。\n" "# 入库是为了换台机器重跑能复现同一份推荐值。\n" "# 注意:fetch 会落盘**全部**有资源的场景,这份记录不再裁剪产物。\n" "# 优先级:sources.yml 的显式声明 > 本文件 > 默认值(骨架最多的场景)。\n" ) @dataclass class Choice: """一页的选择结果。""" page: str scene: str spines: list[str] = field(default_factory=list) source: str = "default" # default | sources | selection | interactive images: list[str] = field(default_factory=list) def as_dict(self) -> dict[str, Any]: return {"scene": self.scene, "spines": list(self.spines), "images": list(self.images)} def load_selection(path: Path) -> dict[str, Any]: if not path.exists(): return {} data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} return data if isinstance(data, dict) else {} def save_selection(path: Path, selection: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) body = yaml.safe_dump(selection, allow_unicode=True, sort_keys=True, default_flow_style=False) path.write_text(_HEADER + body, encoding="utf-8") def resolve( page: str, scenes: Sequence[Any], *, sources_entry: dict[str, Any] | None = None, selection: dict[str, Any] | None = None, default_scene: str | None = None, default_spines: Iterable[str] = (), interactive: bool = False, input_fn: Any = input, output_fn: Any = print, ) -> Choice: """按优先级算出一页要抓的场景与骨架。""" selection = selection or {} record = selection.get(page) or {} scene = None spines: list[str] = [] source = "default" if sources_entry and sources_entry.get("scene"): scene = str(sources_entry["scene"]) source = "sources" if sources_entry and sources_entry.get("spines"): spines = [str(s) for s in sources_entry["spines"]] source = "sources" if scene is None and record.get("scene"): scene = str(record["scene"]) source = "selection" if not spines and record.get("spines"): spines = [str(s) for s in record["spines"]] if source == "default": source = "selection" if scene is None: scene = default_scene if not spines: spines = list(default_spines) # 记录里的场景若一个骨架都没有,它就是个**选不出壁纸**的记录(交互误选、或页面改版了)。 # 这种情况退回默认并标明来源,别让人拿到一个必然报红的产物。 if scenes and scene and scene != default_scene: chosen_scene = next((s for s in scenes if s.id == scene), None) if chosen_scene is not None and not chosen_scene.spine_ids and default_scene: source = f"{source}(无效→默认)" scene = default_scene spines = list(default_spines) if interactive and scenes: chosen = ask(page, scenes, scene=scene, spines=spines, input_fn=input_fn, output_fn=output_fn) if chosen is not None: scene, spines = chosen source = "interactive" return Choice(page=page, scene=scene or "", spines=spines, source=source) def ask( page: str, scenes: Sequence[Any], *, scene: str | None, spines: Sequence[str], input_fn: Any = input, output_fn: Any = print, ) -> tuple[str, list[str]] | None: """交互式选择:只选**推荐场景**。 为什么不再多选骨架:产物现在是"一个场景一个自包含的壁纸目录",砍掉几具骨架会让这个场景的 `sceneConfig` 缺件——那是坏壁纸,不是精简。要裁就在预设里裁(`preset.template.json` 是纯数据)。 """ output_fn(f"\n[{page}] 检测到 {len(scenes)} 个场景(**全部**会落盘,这里选的是推荐值):") for i, s in enumerate(scenes, 1): mark = " ← 推荐" if s.id == scene else "" has_assets = any(p.kind in ("spine", "image") for p in s.parts) note = "" if has_assets else "(无资源,不会落盘)" output_fn( f" {i}) {s.id} 骨架 {len(s.spine_ids)} 个,图片 " f"{sum(1 for p in s.parts if p.kind == 'image')} 个{note}{mark}" ) raw = input_fn("推荐场景编号(回车取推荐值):").strip() if raw: try: index = int(raw) - 1 if not 0 <= index < len(scenes): output_fn("编号超出范围,取推荐值。") else: scene = scenes[index].id except ValueError: output_fn("不是数字,取推荐值。") target = next((s for s in scenes if s.id == scene), None) if target is None: return None if not any(p.kind in ("spine", "image") for p in target.parts): # 一件资源都没有的场景落不成壁纸(`fetch` 会跳过它)。与其让人推荐一个必然没有产物的 # 场景,不如当场退回默认并说明原因。 output_fn(f"[{page}] {scene} 里没有任何需要资源的 part,退回默认场景。") return None return scene, list(spines)