diff --git a/gui/batch_tab.py b/gui/batch_tab.py index 3032917..d63e26f 100644 --- a/gui/batch_tab.py +++ b/gui/batch_tab.py @@ -454,7 +454,7 @@ class BatchTab(QWidget): tt.select_files_by_name(file_set) # Switch to Translation page. if hasattr(parent, "switch_page"): - page = getattr(parent, "PAGE_TRANSLATION", 2) + page = getattr(parent, "PAGE_TRANSLATION", 3) parent.switch_page(page) reply = QMessageBox.question( self, diff --git a/gui/guide_tab.py b/gui/guide_tab.py index a628e68..be2a44a 100644 --- a/gui/guide_tab.py +++ b/gui/guide_tab.py @@ -316,5 +316,5 @@ class GuideTab(QWidget): def _open_config(self) -> None: pw = self.parent_window if pw is not None and hasattr(pw, "switch_page"): - page = getattr(pw, "PAGE_CONFIG", 5) + page = getattr(pw, "PAGE_CONFIG", 6) pw.switch_page(page) diff --git a/gui/main.py b/gui/main.py index 9520733..633d334 100644 --- a/gui/main.py +++ b/gui/main.py @@ -707,6 +707,7 @@ from gui.guide_tab import GuideTab from gui.translation_tab import TranslationTab from gui.workflow_tab import WorkflowTab from gui.wolf_workflow_tab import WolfWorkflowTab +from gui.rpgmaker_image_manager import RPGMakerImageManager from gui.skills_tab import SkillsTab from gui.batch_tab import BatchTab @@ -715,10 +716,11 @@ class DazedMTLGUI(QMainWindow): PAGE_GUIDE = 0 PAGE_WORKFLOW = 1 - PAGE_TRANSLATION = 2 - PAGE_BATCHES = 3 - PAGE_SKILLS = 4 - PAGE_CONFIG = 5 + PAGE_IMAGES = 2 + PAGE_TRANSLATION = 3 + PAGE_BATCHES = 4 + PAGE_SKILLS = 5 + PAGE_CONFIG = 6 def __init__(self): super().__init__() @@ -961,6 +963,13 @@ class DazedMTLGUI(QMainWindow): sidebar_layout.addWidget(btn_workflow) self.nav_buttons.append(btn_workflow) + # RPG Maker image decryption / patch selection + btn_images = self.create_nav_button("šŸ–¼", "Images") + btn_images.setToolTip("Images - decrypt, preview, encrypt, and add MV/MZ images to patches") + btn_images.clicked.connect(lambda: self.switch_page(self.PAGE_IMAGES)) + sidebar_layout.addWidget(btn_images) + self.nav_buttons.append(btn_images) + # Translation btn_translation = self.create_nav_button("🌐", "Translation") btn_translation.clicked.connect(lambda: self.switch_page(self.PAGE_TRANSLATION)) @@ -1027,6 +1036,8 @@ class DazedMTLGUI(QMainWindow): def switch_page(self, index): """Switch to the specified page and update button states.""" + if index == self.PAGE_IMAGES and hasattr(self, "image_manager_tab"): + self.image_manager_tab.refresh_game_root_from_settings() self.content_stack.setCurrentIndex(index) # Update button checked states @@ -1045,19 +1056,23 @@ class DazedMTLGUI(QMainWindow): # RPGMaker and Wolf guided panels while keeping a single sidebar button. self.content_stack.addWidget(self._create_workflow_container()) - # Translation Execution Tab (index 2) + # RPG Maker MV/MZ Image Manager (index 2) + self.image_manager_tab = RPGMakerImageManager(parent=self) + self.content_stack.addWidget(self.image_manager_tab) + + # Translation Execution Tab (index 3) self.translation_tab = TranslationTab(self) self.content_stack.addWidget(self.translation_tab) - # Batch History Tab (index 3) + # Batch History Tab (index 4) self.batch_tab = BatchTab(self) self.content_stack.addWidget(self.batch_tab) - # Skills Tab (index 4) + # Skills Tab (index 5) self.skills_tab = SkillsTab(self) self.content_stack.addWidget(self.skills_tab) - # Configuration Tab (index 5) + # Configuration Tab (index 6) self.config_tab = ConfigTab() self.config_tab.config_changed.connect(self.on_config_changed) self.content_stack.addWidget(self.config_tab) diff --git a/gui/rpgmaker_image_manager.py b/gui/rpgmaker_image_manager.py new file mode 100644 index 0000000..49eeab2 --- /dev/null +++ b/gui/rpgmaker_image_manager.py @@ -0,0 +1,672 @@ +"""Visual RPG Maker MV/MZ image decrypt/encrypt and patch manager.""" + +from __future__ import annotations + +from pathlib import Path + +from PyQt5.QtCore import Qt, QSize, QThread, pyqtSignal, QSettings +from PyQt5.QtGui import QColor, QIcon, QPixmap +from PyQt5.QtWidgets import ( + QComboBox, + QAbstractItemView, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QSplitter, + QVBoxLayout, + QWidget, +) + +from util.paths import APP_NAME, ORG_NAME + +from util.rpgmaker_images import ( + ImageAsset, + decrypt_assets, + encrypt_assets, + prepare_assets_for_patch, + preview_png_bytes, + read_encryption_key, + scan_image_assets, + thumbnail_png_bytes, +) + + +_ASSET_ID_ROLE = Qt.UserRole + 1 +_PAGE_SIZE = 80 + + +class _ImageScanWorker(QThread): + done = pyqtSignal(int, object) + error = pyqtSignal(int, str) + + def __init__(self, generation: int, game_root: Path): + super().__init__() + self.generation = generation + self.game_root = game_root + + def run(self) -> None: + try: + self.done.emit(self.generation, scan_image_assets(self.game_root)) + except Exception as exc: + self.error.emit(self.generation, str(exc)) + + +class _ThumbnailWorker(QThread): + done = pyqtSignal(int, object) + + def __init__(self, generation: int, assets: list[ImageAsset], key: bytes | None): + super().__init__() + self.generation = generation + self.assets = assets + self.key = key + + def run(self) -> None: + thumbnails: dict[str, bytes] = {} + for asset in self.assets: + if self.isInterruptionRequested(): + return + try: + data = thumbnail_png_bytes(asset, self.key, size=112) + except Exception: + data = b"" + thumbnails[asset.asset_id] = data + self.done.emit(self.generation, thumbnails) + + +class _ImageActionWorker(QThread): + status = pyqtSignal(str) + done = pyqtSignal(str, object) + error = pyqtSignal(str) + + def __init__( + self, + action: str, + game_root: Path, + assets: list[ImageAsset], + key: bytes | None, + ): + super().__init__() + self.action = action + self.game_root = game_root + self.assets = assets + self.key = key + + def run(self) -> None: + try: + if self.action == "decrypt": + result = decrypt_assets( + self.assets, self.key, overwrite=False, progress=self.status.emit + ) + elif self.action == "encrypt": + result = encrypt_assets( + self.game_root, self.assets, self.key, progress=self.status.emit + ) + else: + result = prepare_assets_for_patch( + self.game_root, self.assets, self.key, progress=self.status.emit + ) + self.done.emit(self.action, result) + except Exception as exc: + self.error.emit(str(exc)) + + +class RPGMakerImageManager(QWidget): + """Paginated contact sheet for projects containing thousands of images.""" + + def __init__( + self, + game_root: str | Path | None = None, + parent=None, + settings: QSettings | None = None, + ): + super().__init__(parent) + self.settings = settings or QSettings(ORG_NAME, APP_NAME) + saved_root = str(self.settings.value("workflow/last_game_folder", "") or "") + initial_root = str(game_root or saved_root).strip() + self.game_root = Path(initial_root).expanduser().resolve() if initial_root else None + self.assets: list[ImageAsset] = [] + self.assets_by_id: dict[str, ImageAsset] = {} + self.filtered_assets: list[ImageAsset] = [] + self.selected_ids: set[str] = set() + self.page = 0 + self.key: bytes | None = None + self._scan_worker: _ImageScanWorker | None = None + self._scan_workers: list[_ImageScanWorker] = [] + self._scan_generation = 0 + self._thumbnail_worker: _ThumbnailWorker | None = None + self._thumbnail_workers: list[_ThumbnailWorker] = [] + self._action_worker: _ImageActionWorker | None = None + self._thumbnail_generation = 0 + + self._build_ui() + if self.game_root: + self.folder_edit.setText(str(self.game_root)) + self._load_project() + else: + self.status_label.setText("Select an RPG Maker MV/MZ game folder to begin.") + self._set_actions_enabled(False) + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(18, 14, 18, 12) + title = QLabel("RPG Maker MV/MZ Image Manager") + title.setStyleSheet("font-size:18px;font-weight:bold;color:#e0e0e0;") + root.addWidget(title) + intro = QLabel( + "Browse encrypted images visually, create editable PNG copies, then select only " + "the translated images you want in the patch. Pages keep very large games responsive." + ) + intro.setWordWrap(True) + intro.setStyleSheet("color:#b8b8b8;font-size:13px;padding:2px 0 6px 0;") + root.addWidget(intro) + + folder_row = QHBoxLayout() + folder_row.addWidget(QLabel("Game folder:")) + self.folder_edit = QLineEdit() + self.folder_edit.setPlaceholderText("Select an RPG Maker MV/MZ game folder…") + self.folder_edit.returnPressed.connect(self._load_project) + folder_row.addWidget(self.folder_edit, 1) + browse_button = QPushButton("Browse…") + browse_button.clicked.connect(self._browse_game_root) + folder_row.addWidget(browse_button) + load_button = QPushButton("Load") + load_button.clicked.connect(self._load_project) + folder_row.addWidget(load_button) + root.addLayout(folder_row) + + filters = QHBoxLayout() + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText("Filter by any part of the folder or filename…") + self.search_edit.textChanged.connect(self._apply_filters) + filters.addWidget(self.search_edit, 2) + self.folder_combo = QComboBox() + self.folder_combo.addItem("All folders", "") + self.folder_combo.currentIndexChanged.connect(self._apply_filters) + filters.addWidget(self.folder_combo, 1) + self.state_combo = QComboBox() + self.state_combo.addItem("All images", "all") + self.state_combo.addItem("Encrypted", "encrypted") + self.state_combo.addItem("Editable PNG ready", "plain") + self.state_combo.addItem("Unencrypted PNG", "plain_only") + self.state_combo.currentIndexChanged.connect(self._apply_filters) + filters.addWidget(self.state_combo) + root.addLayout(filters) + + splitter = QSplitter(Qt.Horizontal) + self.image_list = QListWidget() + self.image_list.setViewMode(QListWidget.IconMode) + self.image_list.setResizeMode(QListWidget.Adjust) + self.image_list.setMovement(QListWidget.Static) + self.image_list.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.image_list.setIconSize(QSize(112, 112)) + self.image_list.setGridSize(QSize(160, 160)) + self.image_list.setUniformItemSizes(True) + self.image_list.setWordWrap(True) + self.image_list.setSpacing(4) + self.image_list.setObjectName("rpgImageList") + self.image_list.setStyleSheet( + "QListWidget#rpgImageList{background:#1e1e1e;color:#fff;" + "border:1px solid #555;padding:3px;outline:none;}" + "QListWidget#rpgImageList::item{border:1px solid transparent;" + "padding:3px;background:transparent;}" + "QListWidget#rpgImageList::item:selected{background:#264f78;" + "border-color:#4b8dc0;color:#fff;}" + "QListWidget#rpgImageList::item:hover:!selected{background:#333;" + "border-color:#555;}" + ) + self.image_list.itemSelectionChanged.connect(self._selection_changed) + self.image_list.currentItemChanged.connect(self._show_preview) + self.image_list.setToolTip( + "Select images normally. Ctrl-click toggles individual images, Shift-click selects " + "a range, and Ctrl+A selects the current page." + ) + splitter.addWidget(self.image_list) + + preview_host = QWidget() + preview_layout = QVBoxLayout(preview_host) + self.preview_label = QLabel("Select an image to preview it") + self.preview_label.setAlignment(Qt.AlignCenter) + self.preview_label.setMinimumSize(300, 300) + self.preview_label.setStyleSheet( + "background:#171717;border:1px solid #3c3c3c;color:#777;" + ) + preview_layout.addWidget(self.preview_label, 1) + self.path_label = QLabel() + self.path_label.setWordWrap(True) + self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.path_label.setStyleSheet("color:#c8c8c8;font-size:12px;padding:5px;") + preview_layout.addWidget(self.path_label) + splitter.addWidget(preview_host) + splitter.setSizes([850, 350]) + root.addWidget(splitter, 1) + + page_row = QHBoxLayout() + select_page = QPushButton("Select page") + select_page.clicked.connect(self._select_page) + page_row.addWidget(select_page) + clear_selection = QPushButton("Clear selection") + clear_selection.clicked.connect(self._clear_selection) + page_row.addWidget(clear_selection) + page_row.addStretch() + self.previous_button = QPushButton("← Previous") + self.previous_button.clicked.connect(lambda: self._change_page(-1)) + page_row.addWidget(self.previous_button) + self.page_label = QLabel("Page 0 / 0") + self.page_label.setAlignment(Qt.AlignCenter) + self.page_label.setMinimumWidth(210) + page_row.addWidget(self.page_label) + self.next_button = QPushButton("Next →") + self.next_button.clicked.connect(lambda: self._change_page(1)) + page_row.addWidget(self.next_button) + root.addLayout(page_row) + + action_row = QHBoxLayout() + self.decrypt_selected_button = QPushButton("Decrypt selected") + self.decrypt_selected_button.clicked.connect(self._decrypt_checked) + action_row.addWidget(self.decrypt_selected_button) + self.decrypt_all_button = QPushButton("Decrypt all encrypted") + self.decrypt_all_button.clicked.connect(self._decrypt_all) + action_row.addWidget(self.decrypt_all_button) + self.encrypt_button = QPushButton("Encrypt selected") + self.encrypt_button.setToolTip( + "Rebuild selected encrypted runtime files from their editable PNG copies." + ) + self.encrypt_button.clicked.connect(self._encrypt_checked) + action_row.addWidget(self.encrypt_button) + self.prepare_button = QPushButton("Encrypt + patch selected") + self.prepare_button.setStyleSheet( + "QPushButton{border:1px solid #4ec9b0;color:#4ec9b0;font-weight:bold;padding:6px 14px;}" + "QPushButton:hover{background:#18352f;}" + ) + self.prepare_button.setToolTip( + "Re-encrypt selected edited PNGs, preserve original encrypted files in " + ".dazedtl/image_backups, and add exact .gitignore allow-rules." + ) + self.prepare_button.clicked.connect(self._prepare_checked) + action_row.addWidget(self.prepare_button) + action_row.addStretch() + root.addLayout(action_row) + + self.status_label = QLabel("Scanning image folders…") + self.status_label.setWordWrap(True) + self.status_label.setStyleSheet("color:#8fbc8f;padding:4px 0;") + root.addWidget(self.status_label) + + def _browse_game_root(self) -> None: + start = self.folder_edit.text().strip() or str(Path.home()) + folder = QFileDialog.getExistingDirectory(self, "Select RPG Maker Game Folder", start) + if folder: + self.folder_edit.setText(folder) + self._load_project() + + def _load_project(self) -> None: + raw_path = self.folder_edit.text().strip() + if not raw_path: + return + root = Path(raw_path).expanduser().resolve() + if not root.is_dir(): + QMessageBox.warning(self, "Game Folder", f"Folder not found:\n{root}") + return + self.game_root = root + self.settings.setValue("workflow/last_game_folder", str(root)) + self.selected_ids.clear() + self._load_key() + self._start_scan() + + def refresh_game_root_from_settings(self) -> None: + """Load a game newly selected in the RPG Maker Workflow tab.""" + saved = str(self.settings.value("workflow/last_game_folder", "") or "").strip() + if not saved: + return + resolved = Path(saved).expanduser().resolve() + if resolved == self.game_root: + return + self.folder_edit.setText(str(resolved)) + self._load_project() + + def _load_key(self) -> None: + try: + self.key = read_encryption_key(self.game_root) + except Exception: + self.key = None + + def _start_scan(self) -> None: + if self.game_root is None: + return + self._set_actions_enabled(False) + self.status_label.setText("Scanning image folders…") + self._scan_generation += 1 + worker = _ImageScanWorker(self._scan_generation, self.game_root) + worker.done.connect(self._scan_done) + worker.error.connect(self._scan_error) + worker.finished.connect(lambda w=worker: self._forget_scan_worker(w)) + self._scan_workers.append(worker) + self._scan_worker = worker + worker.start() + + def _forget_scan_worker(self, worker: _ImageScanWorker) -> None: + if worker in self._scan_workers: + self._scan_workers.remove(worker) + + def _scan_done(self, generation: int, assets: list[ImageAsset]) -> None: + if generation != self._scan_generation: + return + self._set_actions_enabled(True) + self.assets = assets + self.assets_by_id = {asset.asset_id: asset for asset in assets} + self.selected_ids.intersection_update(self.assets_by_id) + self.folder_combo.blockSignals(True) + current_folder = self.folder_combo.currentData() or "" + self.folder_combo.clear() + self.folder_combo.addItem("All folders", "") + folders = sorted( + {asset.relative_png.parent.as_posix() for asset in assets}, key=str.casefold + ) + for folder in folders: + self.folder_combo.addItem(folder, folder) + index = self.folder_combo.findData(current_folder) + self.folder_combo.setCurrentIndex(max(0, index)) + self.folder_combo.blockSignals(False) + encrypted = sum(asset.has_encrypted for asset in assets) + editable = sum(asset.has_plain and asset.has_encrypted for asset in assets) + self.status_label.setText( + f"Found {len(assets):,} images Ā· {encrypted:,} encrypted Ā· " + f"{editable:,} editable PNG copies Ā· {len(self.selected_ids):,} selected" + ) + self._apply_filters() + + def _scan_error(self, generation: int, message: str) -> None: + if generation != self._scan_generation: + return + self._set_actions_enabled(False) + self.status_label.setText(f"Image scan failed: {message}") + QMessageBox.critical(self, "Image Scan Failed", message) + + def _apply_filters(self) -> None: + query = self.search_edit.text().strip().casefold() + folder = self.folder_combo.currentData() or "" + state = self.state_combo.currentData() + filtered: list[ImageAsset] = [] + for asset in self.assets: + if query and query not in asset.asset_id.casefold(): + continue + if folder and asset.relative_png.parent.as_posix() != folder: + continue + if state == "encrypted" and not asset.has_encrypted: + continue + if state == "plain" and not (asset.has_plain and asset.has_encrypted): + continue + if state == "plain_only" and not (asset.has_plain and not asset.has_encrypted): + continue + filtered.append(asset) + self.filtered_assets = filtered + self.page = 0 + self._render_page() + + def _page_assets(self) -> list[ImageAsset]: + start = self.page * _PAGE_SIZE + return self.filtered_assets[start:start + _PAGE_SIZE] + + def _render_page(self) -> None: + if self._thumbnail_worker and self._thumbnail_worker.isRunning(): + self._thumbnail_worker.requestInterruption() + page_count = max(1, (len(self.filtered_assets) + _PAGE_SIZE - 1) // _PAGE_SIZE) + self.page = min(self.page, page_count - 1) + page_assets = self._page_assets() + self.image_list.blockSignals(True) + self.image_list.clear() + placeholder = QPixmap(112, 112) + placeholder.fill(QColor("#292929")) + placeholder_icon = QIcon(placeholder) + for asset in page_assets: + label = asset.relative_png.name + item = QListWidgetItem(placeholder_icon, label) + item.setData(_ASSET_ID_ROLE, asset.asset_id) + kind = "encrypted + PNG" if asset.has_encrypted and asset.has_plain else ( + "encrypted" if asset.has_encrypted else "PNG" + ) + item.setToolTip(f"{asset.asset_id}\n{kind}") + self.image_list.addItem(item) + item.setSelected(asset.asset_id in self.selected_ids) + self.image_list.blockSignals(False) + self.page_label.setText( + f"Page {self.page + 1} / {page_count} Ā· {len(self.filtered_assets):,} matches" + ) + self.previous_button.setEnabled(self.page > 0) + self.next_button.setEnabled(self.page + 1 < page_count) + self._thumbnail_generation += 1 + worker = _ThumbnailWorker(self._thumbnail_generation, page_assets, self.key) + worker.done.connect(self._thumbnails_ready) + worker.finished.connect(lambda w=worker: self._forget_thumbnail_worker(w)) + self._thumbnail_workers.append(worker) + self._thumbnail_worker = worker + worker.start() + + def _forget_thumbnail_worker(self, worker: _ThumbnailWorker) -> None: + if worker in self._thumbnail_workers: + self._thumbnail_workers.remove(worker) + + def _thumbnails_ready(self, generation: int, thumbnails: dict[str, bytes]) -> None: + if generation != self._thumbnail_generation: + return + self.image_list.setUpdatesEnabled(False) + try: + for index in range(self.image_list.count()): + item = self.image_list.item(index) + data = thumbnails.get(item.data(_ASSET_ID_ROLE), b"") + if not data: + continue + pixmap = QPixmap() + if pixmap.loadFromData(data, "PNG"): + item.setIcon(QIcon(pixmap)) + self.image_list.doItemsLayout() + finally: + self.image_list.setUpdatesEnabled(True) + self.image_list.viewport().update() + + def _change_page(self, delta: int) -> None: + self.page += delta + self._render_page() + + def _selection_changed(self) -> None: + page_ids = { + self.image_list.item(index).data(_ASSET_ID_ROLE) + for index in range(self.image_list.count()) + } + selected_on_page = { + item.data(_ASSET_ID_ROLE) for item in self.image_list.selectedItems() + } + self.selected_ids.difference_update(page_ids) + self.selected_ids.update(selected_on_page) + self._update_selection_status() + + def _select_page(self) -> None: + self.image_list.blockSignals(True) + for index in range(self.image_list.count()): + item = self.image_list.item(index) + item.setSelected(True) + self.selected_ids.add(item.data(_ASSET_ID_ROLE)) + self.image_list.blockSignals(False) + self._update_selection_status() + + def _clear_selection(self) -> None: + self.selected_ids.clear() + self.image_list.clearSelection() + self._update_selection_status() + + def _update_selection_status(self) -> None: + self.status_label.setText( + f"{len(self.filtered_assets):,} matching images Ā· {len(self.selected_ids):,} selected" + ) + + def _show_preview(self, current: QListWidgetItem | None, _previous=None) -> None: + if current is None: + return + asset = self.assets_by_id.get(current.data(_ASSET_ID_ROLE)) + if asset is None: + return + try: + raw = preview_png_bytes(asset, self.key) + pixmap = QPixmap() + if not pixmap.loadFromData(raw, "PNG"): + raise ValueError("Qt could not decode this PNG") + self.preview_label.setPixmap( + pixmap.scaled( + self.preview_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + ) + except Exception as exc: + self.preview_label.setPixmap(QPixmap()) + self.preview_label.setText(f"Preview unavailable\n{exc}") + details = [asset.asset_id] + if asset.has_encrypted: + details.append(f"Encrypted: {asset.encrypted_path.name}") + details.append("Editable PNG: ready" if asset.has_plain else "Editable PNG: not decrypted") + self.path_label.setText("\n".join(details)) + + def _selected_assets(self) -> list[ImageAsset]: + return [self.assets_by_id[key] for key in self.selected_ids if key in self.assets_by_id] + + def _ensure_key(self) -> bool: + if self.key is not None: + return True + try: + self.key = read_encryption_key(self.game_root) + return True + except Exception as exc: + QMessageBox.warning(self, "Encryption Key Not Found", str(exc)) + return False + + def _decrypt_checked(self) -> None: + assets = [asset for asset in self._selected_assets() if asset.has_encrypted] + if not assets: + QMessageBox.information( + self, "Nothing Selected", "Select one or more encrypted images first." + ) + return + if self._ensure_key(): + self._start_action("decrypt", assets) + + def _decrypt_all(self) -> None: + assets = [asset for asset in self.assets if asset.has_encrypted and not asset.has_plain] + if not assets: + QMessageBox.information(self, "Nothing to Decrypt", "All encrypted images already have PNG copies.") + return + if self._ensure_key(): + self._start_action("decrypt", assets) + + def _prepare_checked(self) -> None: + assets = self._selected_assets() + if not assets: + QMessageBox.information( + self, "Nothing Selected", "Select the translated images to include first." + ) + return + if any(asset.has_encrypted for asset in assets) and not self._ensure_key(): + return + answer = QMessageBox.question( + self, + "Prepare Images for Patch", + f"Prepare exactly {len(assets):,} selected image(s)?\n\n" + "Encrypted images will be rebuilt from their editable PNG. Their original encrypted " + "files are backed up once under .dazedtl/image_backups. Exact allow-rules are then " + "added to the applicable .gitignore files.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer == QMessageBox.Yes: + self._start_action("prepare", assets) + + def _encrypt_checked(self) -> None: + assets = [ + asset for asset in self._selected_assets() + if asset.has_encrypted and asset.has_plain + ] + if not assets: + QMessageBox.information( + self, "Nothing Ready", "Select images with editable PNG copies first." + ) + return + if not self._ensure_key(): + return + answer = QMessageBox.question( + self, + "Encrypt Images", + f"Rebuild {len(assets):,} selected encrypted image(s) from their PNG copies?\n\n" + "Original encrypted files are backed up once under .dazedtl/image_backups.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer == QMessageBox.Yes: + self._start_action("encrypt", assets) + + def _start_action(self, action: str, assets: list[ImageAsset]) -> None: + self._set_actions_enabled(False) + worker = _ImageActionWorker(action, self.game_root, assets, self.key) + worker.status.connect(self.status_label.setText) + worker.done.connect(self._action_done) + worker.error.connect(self._action_error) + self._action_worker = worker + worker.start() + + def _action_done(self, action: str, result) -> None: + if action == "decrypt": + summary = f"Decrypted {result.completed:,} image(s); skipped {result.skipped:,}." + elif action == "encrypt": + summary = f"Encrypted {result.completed:,} image(s)." + else: + summary = ( + f"Prepared {result.completed:,} image(s) for the patch and updated " + f"{len(result.gitignore_files):,} .gitignore file(s)." + ) + if result.errors: + shown = "\n".join(result.errors[:12]) + if len(result.errors) > 12: + shown += f"\n…and {len(result.errors) - 12} more" + QMessageBox.warning(self, "Image Action Completed with Errors", f"{summary}\n\n{shown}") + else: + QMessageBox.information(self, "Image Action Complete", summary) + self.status_label.setText(summary) + self._start_scan() + + def _action_error(self, message: str) -> None: + self.status_label.setText(f"Image action failed: {message}") + self._set_actions_enabled(True) + QMessageBox.critical(self, "Image Action Failed", message) + + def _set_actions_enabled(self, enabled: bool) -> None: + for button in ( + self.decrypt_selected_button, + self.decrypt_all_button, + self.encrypt_button, + self.prepare_button, + ): + button.setEnabled(enabled) + + def closeEvent(self, event) -> None: + running = [ + worker for worker in ( + *self._scan_workers, *self._thumbnail_workers, self._action_worker + ) + if worker is not None and worker.isRunning() + ] + action_running = self._action_worker is not None and self._action_worker.isRunning() + if action_running: + QMessageBox.information( + self, "Image Action Running", "Wait for the current decrypt/encrypt action to finish." + ) + event.ignore() + return + for worker in running: + worker.requestInterruption() + worker.wait(1000) + if any(worker.isRunning() for worker in running): + self.status_label.setText("Finishing image scan/preview before closing…") + event.ignore() + return + super().closeEvent(event) diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 23c0e2c..a42c6b7 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -2281,13 +2281,13 @@ class WolfWorkflowTab(QWidget): pass if pw and hasattr(pw, "switch_page"): - page = getattr(pw, "PAGE_TRANSLATION", 2) + page = getattr(pw, "PAGE_TRANSLATION", 3) pw.switch_page(page) elif pw and hasattr(pw, "content_stack"): - pw.content_stack.setCurrentIndex(2) + pw.content_stack.setCurrentIndex(3) if hasattr(pw, "nav_buttons"): for i, btn in enumerate(pw.nav_buttons): - btn.setChecked(i == 2) + btn.setChecked(i == 3) if auto_start: QTimer.singleShot( diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index 749427e..3bf0cbf 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -3700,13 +3700,13 @@ class WorkflowTab(QWidget): # 5. Navigate to Translation tab if hasattr(pw, "switch_page"): - page = getattr(pw, "PAGE_TRANSLATION", 2) + page = getattr(pw, "PAGE_TRANSLATION", 3) pw.switch_page(page) elif hasattr(pw, "content_stack"): - pw.content_stack.setCurrentIndex(2) + pw.content_stack.setCurrentIndex(3) if hasattr(pw, "nav_buttons"): for i, btn in enumerate(pw.nav_buttons): - btn.setChecked(i == 2) + btn.setChecked(i == 3) # 6. Auto-start translation so the user doesn't need an extra click if auto_start: diff --git a/tests/test_rpgmaker_image_manager.py b/tests/test_rpgmaker_image_manager.py new file mode 100644 index 0000000..ecc11d4 --- /dev/null +++ b/tests/test_rpgmaker_image_manager.py @@ -0,0 +1,135 @@ +import os +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PIL import Image +from PyQt5.QtCore import QItemSelectionModel, QSettings, Qt +from PyQt5.QtTest import QTest +from PyQt5.QtWidgets import QApplication, QAbstractItemView + +from gui.rpgmaker_image_manager import RPGMakerImageManager + + +class RPGMakerImageManagerSelectionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + root = Path(self.temp.name) + self.game_root = root / "Game" + image_dir = self.game_root / "img" / "pictures" + data_dir = self.game_root / "data" + image_dir.mkdir(parents=True) + data_dir.mkdir(parents=True) + data_dir.joinpath("System.json").write_text( + '{"encryptionKey":"00112233445566778899aabbccddeeff"}', + encoding="utf-8", + ) + for index in range(4): + Image.new("RGBA", (24, 24), (index * 40, 10, 20, 255)).save( + image_dir / f"image{index}.png" + ) + settings = QSettings(str(root / "settings.ini"), QSettings.IniFormat) + self.manager = RPGMakerImageManager(self.game_root, settings=settings) + self.manager.resize(1100, 760) + self.manager.show() + self.manager._scan_worker.wait(5000) + self.app.processEvents() + + def tearDown(self): + for worker in list(self.manager._thumbnail_workers): + worker.wait(5000) + self.manager.close() + self.app.processEvents() + self.temp.cleanup() + + def _click(self, index: int, modifiers=Qt.NoModifier): + item = self.manager.image_list.item(index) + rect = self.manager.image_list.visualItemRect(item) + QTest.mouseClick( + self.manager.image_list.viewport(), + Qt.LeftButton, + modifiers, + rect.center(), + ) + self.app.processEvents() + + def test_ctrl_shift_and_ctrl_a_use_standard_extended_selection(self): + image_list = self.manager.image_list + self.assertEqual( + image_list.selectionMode(), QAbstractItemView.ExtendedSelection + ) + + self._click(0) + self._click(2, Qt.ControlModifier) + self.assertEqual(len(image_list.selectedItems()), 2) + self.assertEqual(len(self.manager.selected_ids), 2) + + self._click(3, Qt.ShiftModifier) + selected_names = {item.text() for item in image_list.selectedItems()} + self.assertIn("image2.png", selected_names) + self.assertIn("image3.png", selected_names) + + image_list.clearSelection() + image_list.setFocus() + QTest.keyClick(image_list, Qt.Key_A, Qt.ControlModifier) + self.app.processEvents() + self.assertEqual(len(image_list.selectedItems()), 4) + self.assertEqual(len(self.manager.selected_ids), 4) + + def test_selected_tiles_are_restored_after_filtering(self): + selection = self.manager.image_list.selectionModel() + selection.select( + self.manager.image_list.model().index(1, 0), + QItemSelectionModel.Select, + ) + selection.select( + self.manager.image_list.model().index(3, 0), + QItemSelectionModel.Select, + ) + self.app.processEvents() + selected_before = set(self.manager.selected_ids) + self.assertEqual(len(selected_before), 2) + + self.manager.search_edit.setText("image0") + self.app.processEvents() + self.manager.search_edit.clear() + self.app.processEvents() + + self.assertEqual(self.manager.selected_ids, selected_before) + self.assertEqual(len(self.manager.image_list.selectedItems()), 2) + + def test_thumbnail_batch_keeps_one_stable_tile_per_asset(self): + for worker in list(self.manager._thumbnail_workers): + worker.wait(5000) + self.app.processEvents() + + image_list = self.manager.image_list + self.assertTrue(image_list.uniformItemSizes()) + self.assertNotIn("border-bottom", image_list.styleSheet()) + self.assertEqual(image_list.count(), 4) + self.assertTrue( + all(not image_list.item(index).icon().isNull() for index in range(4)) + ) + rects = [image_list.visualItemRect(image_list.item(index)) for index in range(4)] + self.assertEqual(len({(rect.x(), rect.y()) for rect in rects}), 4) + + +class RPGMakerImageManagerNavigationTests(unittest.TestCase): + def test_image_manager_is_a_dedicated_sidebar_page(self): + root = Path(__file__).resolve().parents[1] + main_source = (root / "gui" / "main.py").read_text(encoding="utf-8") + workflow_source = (root / "gui" / "workflow_tab.py").read_text(encoding="utf-8") + self.assertIn("PAGE_IMAGES = 2", main_source) + self.assertIn("self.image_manager_tab = RPGMakerImageManager", main_source) + self.assertIn('create_nav_button("šŸ–¼", "Images")', main_source) + self.assertNotIn("Open Image Manager", workflow_source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rpgmaker_images.py b/tests/test_rpgmaker_images.py new file mode 100644 index 0000000..37d47df --- /dev/null +++ b/tests/test_rpgmaker_images.py @@ -0,0 +1,154 @@ +import tempfile +import unittest +from io import BytesIO +from pathlib import Path + +from PIL import Image + +from util.rpgmaker_images import ( + add_patch_exceptions, + decrypt_assets, + decrypt_image_bytes, + encrypt_assets, + encrypt_image_bytes, + prepare_assets_for_patch, + read_encryption_key, + scan_image_assets, +) + + +KEY_HEX = "00112233445566778899aabbccddeeff" +KEY = bytes.fromhex(KEY_HEX) + + +def png_bytes(color: str) -> bytes: + output = BytesIO() + Image.new("RGBA", (12, 8), color).save(output, format="PNG") + return output.getvalue() + + +class RPGMakerImageTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) / "Game" + self.content = self.root / "www" + (self.content / "data").mkdir(parents=True) + (self.content / "img" / "pictures").mkdir(parents=True) + (self.content / "data" / "System.json").write_text( + '{"encryptionKey":"' + KEY_HEX + '"}', encoding="utf-8" + ) + + def tearDown(self): + self.temp.cleanup() + + def _encrypted_asset(self, name: str = "001.rpgmvp", color: str = "red") -> Path: + path = self.content / "img" / "pictures" / name + path.write_bytes(encrypt_image_bytes(png_bytes(color), KEY)) + return path + + def test_scan_and_decrypt_mv_image_without_touching_encrypted_original(self): + encrypted = self._encrypted_asset() + original = encrypted.read_bytes() + + assets = scan_image_assets(self.root) + self.assertEqual([asset.asset_id for asset in assets], ["img/pictures/001.png"]) + self.assertFalse(assets[0].has_plain) + + result = decrypt_assets(assets, read_encryption_key(self.root)) + self.assertEqual(result.completed, 1) + self.assertEqual(result.errors, []) + self.assertEqual(assets[0].plain_path.read_bytes(), png_bytes("red")) + self.assertEqual(encrypted.read_bytes(), original) + + def test_existing_editable_png_is_not_overwritten_by_decrypt_all(self): + self._encrypted_asset() + plain = self.content / "img" / "pictures" / "001.png" + translated = png_bytes("blue") + plain.write_bytes(translated) + + result = decrypt_assets(scan_image_assets(self.root), KEY) + + self.assertEqual(result.completed, 0) + self.assertEqual(result.skipped, 1) + self.assertEqual(plain.read_bytes(), translated) + + def test_prepare_reencrypts_selected_image_backs_up_original_and_updates_ignore(self): + encrypted = self._encrypted_asset() + original = encrypted.read_bytes() + asset = scan_image_assets(self.root)[0] + decrypt_assets([asset], KEY) + translated = png_bytes("blue") + asset.plain_path.write_bytes(translated) + self.root.joinpath(".gitignore").write_text("*.*\n", encoding="utf-8") + + result = prepare_assets_for_patch(self.root, [asset], KEY) + + self.assertEqual(result.completed, 1) + self.assertEqual(result.errors, []) + self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated) + backup = self.root / ".dazedtl" / "image_backups" / "www/img/pictures/001.rpgmvp" + self.assertEqual(backup.read_bytes(), original) + ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8") + self.assertIn("/.dazedtl/", ignore) + self.assertIn("!/www/img/pictures/001.rpgmvp", ignore) + self.assertNotIn("!/www/img/pictures/001.png", ignore) + + prepare_assets_for_patch(self.root, [asset], KEY) + again = self.root.joinpath(".gitignore").read_text(encoding="utf-8") + self.assertEqual(again.count("!/www/img/pictures/001.rpgmvp"), 1) + self.assertEqual(backup.read_bytes(), original) + + def test_mz_png_uses_same_crypto_and_logical_png_name(self): + encrypted = self._encrypted_asset("title.png_", "green") + asset = scan_image_assets(self.root)[0] + + self.assertEqual(asset.asset_id, "img/pictures/title.png") + self.assertEqual(asset.encrypted_path, encrypted) + result = decrypt_assets([asset], KEY) + self.assertEqual(result.completed, 1) + self.assertEqual(asset.plain_path.read_bytes(), png_bytes("green")) + + def test_encrypt_only_rebuilds_runtime_file_without_changing_gitignore(self): + encrypted = self._encrypted_asset() + asset = scan_image_assets(self.root)[0] + decrypt_assets([asset], KEY) + translated = png_bytes("black") + asset.plain_path.write_bytes(translated) + + result = encrypt_assets(self.root, [asset], KEY) + + self.assertEqual(result.completed, 1) + self.assertEqual(result.errors, []) + self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated) + self.assertFalse((self.root / ".gitignore").exists()) + + def test_nested_gitignore_receives_relative_exact_exception(self): + target = self.content / "img" / "pictures" / "menu image.png" + target.write_bytes(png_bytes("purple")) + nested = self.content / "img" / ".gitignore" + nested.write_text("*\n", encoding="utf-8") + + changed = add_patch_exceptions(self.root, [target]) + + self.assertIn(self.root / ".gitignore", changed) + self.assertIn(nested, changed) + nested_text = nested.read_text(encoding="utf-8") + self.assertIn("!/pictures/menu\\ image.png", nested_text) + root_text = self.root.joinpath(".gitignore").read_text(encoding="utf-8") + self.assertIn("!/www/img/pictures/menu\\ image.png", root_text) + + def test_plain_selected_image_is_added_without_requiring_key(self): + plain = self.content / "img" / "pictures" / "menu.png" + plain.write_bytes(png_bytes("yellow")) + asset = scan_image_assets(self.root)[0] + + result = prepare_assets_for_patch(self.root, [asset], None) + + self.assertEqual(result.completed, 1) + self.assertEqual(result.errors, []) + ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8") + self.assertIn("!/www/img/pictures/menu.png", ignore) + + +if __name__ == "__main__": + unittest.main() diff --git a/util/rpgmaker_images.py b/util/rpgmaker_images.py new file mode 100644 index 0000000..7b51de6 --- /dev/null +++ b/util/rpgmaker_images.py @@ -0,0 +1,393 @@ +"""RPG Maker MV/MZ image encryption and patch-selection helpers. + +RPG Maker encrypts only the first 16 bytes of an asset, prefixes a fixed +16-byte header, and changes the file extension. This module intentionally +handles images only; audio assets use the same container but are outside the +image-translation workflow. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable +import io +import json +import os +import re +import shutil +import tempfile + + +RPGMV_HEADER = bytes( + [0x52, 0x50, 0x47, 0x4D, 0x56, 0x00, 0x00, 0x00, + 0x00, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00] +) +KEY_LEN = 16 +ENCRYPTED_IMAGE_EXTENSIONS = (".rpgmvp", ".png_") + + +@dataclass(frozen=True) +class ImageAsset: + """One logical image and its editable/encrypted representations.""" + + asset_id: str + relative_png: Path + plain_path: Path + encrypted_path: Path | None = None + + @property + def has_plain(self) -> bool: + return self.plain_path.is_file() + + @property + def has_encrypted(self) -> bool: + return self.encrypted_path is not None and self.encrypted_path.is_file() + + +@dataclass +class ImageActionResult: + completed: int = 0 + skipped: int = 0 + errors: list[str] | None = None + patch_files: list[Path] | None = None + gitignore_files: list[Path] | None = None + + def __post_init__(self) -> None: + if self.errors is None: + self.errors = [] + if self.patch_files is None: + self.patch_files = [] + if self.gitignore_files is None: + self.gitignore_files = [] + + +def resolve_content_root(game_root: str | Path) -> Path: + """Return the directory containing ``data/`` and ``img/``.""" + + root = Path(game_root).expanduser().resolve() + for candidate in (root / "www", root): + if (candidate / "img").is_dir(): + return candidate + raise FileNotFoundError( + f"Could not find an img folder under {root} or {root / 'www'}." + ) + + +def read_encryption_key(game_root: str | Path) -> bytes: + """Read and validate the 16-byte encryption key from System.json.""" + + content_root = resolve_content_root(game_root) + system_path = content_root / "data" / "System.json" + if not system_path.is_file(): + raise FileNotFoundError(f"System.json not found: {system_path}") + text = system_path.read_text(encoding="utf-8-sig", errors="replace") + try: + raw_key = json.loads(text).get("encryptionKey", "") + except json.JSONDecodeError: + match = re.search(r'"encryptionKey"\s*:\s*"([a-fA-F0-9]{32})"', text) + raw_key = match.group(1) if match else "" + normalized = str(raw_key).strip().replace(" ", "") + if not re.fullmatch(r"[a-fA-F0-9]{32}", normalized): + raise ValueError( + "System.json does not contain a valid 32-character encryptionKey." + ) + return bytes.fromhex(normalized) + + +def _logical_png(path: Path) -> Path: + lower = path.name.casefold() + if lower.endswith(".rpgmvp"): + return path.with_name(path.name[:-7] + ".png") + if lower.endswith(".png_"): + return path.with_name(path.name[:-5] + ".png") + return path + + +def scan_image_assets(game_root: str | Path) -> list[ImageAsset]: + """Scan ``img/`` and combine matching PNG/encrypted files into records.""" + + content_root = resolve_content_root(game_root) + image_root = content_root / "img" + by_id: dict[str, dict[str, Path]] = {} + for path in image_root.rglob("*"): + if not path.is_file(): + continue + name = path.name.casefold() + if not (name.endswith(".png") or name.endswith(ENCRYPTED_IMAGE_EXTENSIONS)): + continue + logical = _logical_png(path) + relative = logical.relative_to(content_root) + asset_id = relative.as_posix() + entry = by_id.setdefault(asset_id, {"relative": relative, "plain": logical}) + if name.endswith(ENCRYPTED_IMAGE_EXTENSIONS): + entry["encrypted"] = path + else: + entry["plain"] = path + + assets = [ + ImageAsset( + asset_id=asset_id, + relative_png=entry["relative"], + plain_path=entry["plain"], + encrypted_path=entry.get("encrypted"), + ) + for asset_id, entry in by_id.items() + ] + return sorted(assets, key=lambda asset: asset.asset_id.casefold()) + + +def decrypt_image_bytes(data: bytes, key: bytes) -> bytes: + if len(key) != KEY_LEN: + raise ValueError("The RPG Maker encryption key must contain 16 bytes.") + if len(data) < len(RPGMV_HEADER) + KEY_LEN: + raise ValueError("Encrypted image is too small.") + if data[: len(RPGMV_HEADER)] != RPGMV_HEADER: + raise ValueError("Encrypted image has an invalid RPG Maker header.") + start = len(RPGMV_HEADER) + block = bytes(value ^ key[index] for index, value in enumerate(data[start:start + KEY_LEN])) + return block + data[start + KEY_LEN:] + + +def encrypt_image_bytes(data: bytes, key: bytes) -> bytes: + if len(key) != KEY_LEN: + raise ValueError("The RPG Maker encryption key must contain 16 bytes.") + if len(data) < KEY_LEN: + raise ValueError("PNG image is too small.") + block = bytes(value ^ key[index] for index, value in enumerate(data[:KEY_LEN])) + return RPGMV_HEADER + block + data[KEY_LEN:] + + +def preview_png_bytes(asset: ImageAsset, key: bytes | None) -> bytes: + """Return displayable PNG bytes, decrypting in memory when necessary.""" + + if asset.has_plain: + return asset.plain_path.read_bytes() + if not asset.has_encrypted: + raise FileNotFoundError(asset.plain_path) + if key is None: + raise ValueError("The encryption key is required to preview this image.") + return decrypt_image_bytes(asset.encrypted_path.read_bytes(), key) + + +def thumbnail_png_bytes(asset: ImageAsset, key: bytes | None, size: int = 128) -> bytes: + """Create a small PNG thumbnail without writing a decrypted working file.""" + + from PIL import Image + + raw = preview_png_bytes(asset, key) + with Image.open(io.BytesIO(raw)) as image: + image.thumbnail((size, size)) + if image.mode not in {"RGB", "RGBA"}: + image = image.convert("RGBA") + output = io.BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + +def _atomic_write(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + handle = tempfile.NamedTemporaryFile( + mode="wb", prefix=f".{path.name}.", suffix=".tmp", dir=path.parent, delete=False + ) + temp_path = Path(handle.name) + try: + with handle: + handle.write(data) + os.replace(temp_path, path) + except Exception: + temp_path.unlink(missing_ok=True) + raise + + +def decrypt_assets( + assets: Iterable[ImageAsset], + key: bytes, + *, + overwrite: bool = False, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + """Create editable PNG copies beside encrypted images.""" + + result = ImageActionResult() + for asset in assets: + if not asset.has_encrypted: + result.skipped += 1 + continue + if asset.has_plain and not overwrite: + result.skipped += 1 + continue + try: + if progress: + progress(f"Decrypting {asset.asset_id}") + raw = decrypt_image_bytes(asset.encrypted_path.read_bytes(), key) + _atomic_write(asset.plain_path, raw) + result.completed += 1 + except Exception as exc: # continue a large batch and report all failures + result.errors.append(f"{asset.asset_id}: {exc}") + return result + + +def _path_inside(root: Path, path: Path) -> Path: + try: + return path.resolve().relative_to(root.resolve()) + except ValueError as exc: + raise ValueError(f"Image is outside the selected game folder: {path}") from exc + + +def _escape_gitignore_component(value: str) -> str: + if "\n" in value or "\r" in value: + raise ValueError("Image paths containing line breaks cannot be added to .gitignore.") + escaped = "" + for char in value: + if char in "\\*?[]!# " or char == "\t": + escaped += "\\" + escaped += char + return escaped + + +def _gitignore_pattern(relative: Path, *, directory: bool = False) -> str: + parts = [_escape_gitignore_component(part) for part in relative.parts] + pattern = "!/" + "/".join(parts) + if directory: + pattern += "/" + return pattern + + +def add_patch_exceptions( + game_root: str | Path, targets: Iterable[str | Path] +) -> list[Path]: + """Append exact allow-rules to applicable .gitignore files. + + Root and nested ignore files can both affect an asset. Adding a precise + exception to each existing file ensures a deeper rule cannot hide a chosen + image again. Existing content is preserved and entries are idempotent. + """ + + root = Path(game_root).expanduser().resolve() + relative_targets = sorted( + {_path_inside(root, Path(target)) for target in targets}, + key=lambda path: path.as_posix().casefold(), + ) + if not relative_targets: + return [] + + ignore_to_targets: dict[Path, list[Path]] = {root / ".gitignore": relative_targets} + for relative in relative_targets: + parent = root + for part in relative.parts[:-1]: + parent /= part + nested = parent / ".gitignore" + if nested.is_file(): + ignore_to_targets.setdefault(nested, []).append( + Path(*relative.parts[len(parent.relative_to(root).parts):]) + ) + + changed: list[Path] = [] + for ignore_path, entries in ignore_to_targets.items(): + existing = ( + ignore_path.read_text(encoding="utf-8", errors="surrogateescape") + if ignore_path.exists() + else "" + ) + rules: list[str] = [] + if ignore_path == root / ".gitignore": + rules.append("/.dazedtl/") + for relative in entries: + for depth in range(1, len(relative.parts)): + rules.append(_gitignore_pattern(Path(*relative.parts[:depth]), directory=True)) + rules.append(_gitignore_pattern(relative)) + additions = [rule for rule in dict.fromkeys(rules) if rule not in existing.splitlines()] + if not additions: + continue + text = existing + if text and not text.endswith("\n"): + text += "\n" + if "# DazedTL selected image patches" not in text: + text += "\n# DazedTL selected image patches\n" + text += "\n".join(additions) + "\n" + ignore_path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write(ignore_path, text.encode("utf-8", errors="surrogateescape")) + changed.append(ignore_path) + return changed + + +def prepare_assets_for_patch( + game_root: str | Path, + assets: Iterable[ImageAsset], + key: bytes | None, + *, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + """Re-encrypt edited PNGs and add only selected runtime assets to gitignore.""" + + root = Path(game_root).expanduser().resolve() + result = ImageActionResult() + targets: list[Path] = [] + for asset in assets: + try: + if asset.has_encrypted: + if not asset.has_plain: + raise FileNotFoundError("decrypt this image before preparing it") + if key is None: + raise ValueError("the encryption key is required") + relative_encrypted = _path_inside(root, asset.encrypted_path) + backup = root / ".dazedtl" / "image_backups" / relative_encrypted + if not backup.exists(): + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(asset.encrypted_path, backup) + if progress: + progress(f"Encrypting {asset.asset_id}") + encrypted = encrypt_image_bytes(asset.plain_path.read_bytes(), key) + _atomic_write(asset.encrypted_path, encrypted) + targets.append(asset.encrypted_path) + elif asset.has_plain: + targets.append(asset.plain_path) + else: + raise FileNotFoundError("image file no longer exists") + result.completed += 1 + except Exception as exc: + result.errors.append(f"{asset.asset_id}: {exc}") + + if targets: + if progress: + progress("Updating .gitignore image allow-rules") + result.patch_files = targets + try: + result.gitignore_files = add_patch_exceptions(root, targets) + except Exception as exc: + result.errors.append(f".gitignore: {exc}") + return result + + +def encrypt_assets( + game_root: str | Path, + assets: Iterable[ImageAsset], + key: bytes, + *, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + """Re-encrypt editable PNGs in place without changing gitignore files.""" + + root = Path(game_root).expanduser().resolve() + result = ImageActionResult() + for asset in assets: + try: + if not asset.has_encrypted: + raise FileNotFoundError("no matching encrypted runtime image exists") + if not asset.has_plain: + raise FileNotFoundError("decrypt this image before encrypting it") + relative_encrypted = _path_inside(root, asset.encrypted_path) + backup = root / ".dazedtl" / "image_backups" / relative_encrypted + if not backup.exists(): + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(asset.encrypted_path, backup) + if progress: + progress(f"Encrypting {asset.asset_id}") + encrypted = encrypt_image_bytes(asset.plain_path.read_bytes(), key) + _atomic_write(asset.encrypted_path, encrypted) + result.patch_files.append(asset.encrypted_path) + result.completed += 1 + except Exception as exc: + result.errors.append(f"{asset.asset_id}: {exc}") + return result