history.py 6.03 KB
"""HistoryBridge — 历史记录 tab 的列表 + 详情桥。

桥层暴露 HistoryListModel 给 QML ListView 直接用(已经是 QAbstractListModel)。
QML 通过 history.refresh() 主动刷新;新生成图片完成时由 ImageGenBridge 串起来调
itemAdded(task #16 wiring 时再补)。

详情查看走 getItem(timestamp) → dict,避免 QML 直接持有 HistoryItem dataclass。
"""
import logging
import platform
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict

from PySide6.QtCore import Property, QObject, Signal, Slot
from PySide6.QtGui import QGuiApplication

from core.history import HistoryListModel
from ._icons import build_placeholder_icon


class HistoryBridge(QObject):
    countChanged = Signal()
    itemAdded = Signal(str)    # timestamp
    itemRemoved = Signal(str)  # timestamp

    def __init__(self, history_manager, parent=None):
        super().__init__(parent)
        self._logger = logging.getLogger(__name__)
        self._history = history_manager
        self._model = HistoryListModel(
            history_manager=history_manager,
            build_placeholder_icon=build_placeholder_icon,
            logger=self._logger,
            parent=self,
        )

    # ---- Properties -----------------------------------------------------

    @Property(QObject, constant=True)
    def model(self) -> HistoryListModel:
        """暴露给 QML ListView 的 model(QAbstractListModel)。"""
        return self._model

    @Property(int, notify=countChanged)
    def count(self) -> int:
        return self._model.rowCount()

    # ---- Slots ----------------------------------------------------------

    @Slot()
    def refresh(self) -> None:
        """从磁盘重读 index.json,整体 reset model。"""
        items = self._history.load_history_index()
        self._model.reset_timestamps([item.timestamp for item in items])
        self.countChanged.emit()
        self._logger.info(f"history refresh: {len(items)} 条")

    @Slot(str)
    def deleteItem(self, timestamp: str) -> None:
        ok = self._history.delete_history_item(timestamp)
        if ok:
            self._model.remove_timestamp(timestamp)
            self.itemRemoved.emit(timestamp)
            self.countChanged.emit()

    @Slot(str)
    def addNew(self, timestamp: str) -> None:
        """新生成完成时由 ImageGenBridge / 主线程调用,把新 timestamp 插到顶部。"""
        self._model.prepend_timestamp(timestamp)
        self.itemAdded.emit(timestamp)
        self.countChanged.emit()

    @Slot(result=bool)
    def clearAll(self) -> bool:
        """清空所有历史记录:删除整个 base_path 目录后重建空目录。返回是否成功。

        与旧 clear_history 等价:
          shutil.rmtree(base_path) → mkdir → reset_timestamps([])
        UI 层(QML)负责弹确认对话框,桥层不做交互。
        """
        try:
            base = self._history.base_path
            if base.exists():
                shutil.rmtree(base)
                base.mkdir(parents=True, exist_ok=True)
            self._model.reset_timestamps([])
            self._logger.info(f"历史记录已清空: {base}")
            self.countChanged.emit()
            return True
        except Exception:
            self._logger.exception("clearAll 失败")
            return False

    @Slot(str, result="QVariant")
    def getItem(self, timestamp: str) -> Dict[str, Any]:
        """返回单条历史的 QML 友好 dict(路径 → str / Path 不暴露)。"""
        item = self._history.load_history_item_fast(timestamp)
        if item is None:
            return {}
        return {
            "timestamp": item.timestamp,
            "prompt": item.prompt,
            "generatedImagePath": str(item.generated_image_path),
            "referenceImagePaths": [str(p) for p in item.reference_image_paths],
            "aspectRatio": item.aspect_ratio,
            "imageSize": item.image_size,
            "model": item.model,
            "createdAt": item.created_at.strftime("%Y-%m-%d %H:%M:%S"),
        }

    @Slot(str, result=bool)
    def copyToClipboard(self, text: str) -> bool:
        """复制纯文本到系统剪贴板。失败返回 False。"""
        try:
            cb = QGuiApplication.clipboard()
            if cb is None:
                return False
            cb.setText(text or "")
            return True
        except Exception:
            self._logger.exception("copyToClipboard 失败")
            return False

    @Slot(str)
    def revealInExplorer(self, path: str) -> None:
        """在系统文件管理器里打开 path 所在文件夹并选中该文件。

        Windows: explorer /select,<path>
        macOS:   open -R <path>
        Linux:   xdg-open <parent_dir>(无法选中具体文件,只能开父目录)
        """
        p = Path(path)
        if not p.exists():
            self._logger.warning(f"revealInExplorer: 文件不存在 {path}")
            return
        try:
            system = platform.system()
            if system == "Windows":
                # 注意:explorer /select, 需要逗号紧跟在 /select 后,path 用 native 反斜杠
                subprocess.Popen(["explorer", f"/select,{p}"])
            elif system == "Darwin":
                subprocess.Popen(["open", "-R", str(p)])
            else:
                subprocess.Popen(["xdg-open", str(p.parent)])
        except Exception:
            self._logger.exception(f"revealInExplorer 失败 {path}")

    @Slot(str, result=str)
    def thumbnailPath(self, timestamp: str) -> str:
        """返回该 timestamp 缩略图本地路径(按需生成)。源图缺失时返回 ""。"""
        item = self._history.load_history_item_fast(timestamp)
        if item is None or not item.generated_image_path.exists():
            return ""
        thumb = self._history.get_or_create_thumbnail(item.generated_image_path)
        if thumb is None:
            # 缩略图生成失败时回退到原图(QML Image 读 PNG 没问题)
            return str(item.generated_image_path)
        return str(thumb)