diff --git a/gui/rpgmaker_image_manager.py b/gui/rpgmaker_image_manager.py index 49eeab2..81f03d4 100644 --- a/gui/rpgmaker_image_manager.py +++ b/gui/rpgmaker_image_manager.py @@ -4,8 +4,15 @@ 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.QtCore import ( + Qt, + QSize, + QThread, + pyqtSignal, + QSettings, + QUrl, +) +from PyQt5.QtGui import QColor, QDesktopServices, QIcon, QPixmap from PyQt5.QtWidgets import ( QComboBox, QAbstractItemView, @@ -17,6 +24,7 @@ from PyQt5.QtWidgets import ( QListWidgetItem, QMessageBox, QPushButton, + QSizePolicy, QSplitter, QVBoxLayout, QWidget, @@ -26,18 +34,45 @@ from util.paths import APP_NAME, ORG_NAME from util.rpgmaker_images import ( ImageAsset, + clean_runtime_image_duplicates, decrypt_assets, - encrypt_assets, + editable_workspace_root, + ensure_editable_workspace, + migrate_legacy_editable_workspace, prepare_assets_for_patch, preview_png_bytes, read_encryption_key, + remove_editable_assets, scan_image_assets, thumbnail_png_bytes, ) _ASSET_ID_ROLE = Qt.UserRole + 1 -_PAGE_SIZE = 80 +_PAGE_SIZE = 1000 + + +class _UserSelectionList(QListWidget): + """Report user selection changes without reacting to list rebuilding.""" + + userSelectionChanged = pyqtSignal() + deleteRequested = pyqtSignal() + + def mousePressEvent(self, event) -> None: + super().mousePressEvent(event) + self.userSelectionChanged.emit() + + def mouseReleaseEvent(self, event) -> None: + super().mouseReleaseEvent(event) + self.userSelectionChanged.emit() + + def keyPressEvent(self, event) -> None: + if event.key() == Qt.Key_Delete: + self.deleteRequested.emit() + event.accept() + return + super().keyPressEvent(event) + self.userSelectionChanged.emit() class _ImageScanWorker(QThread): @@ -100,11 +135,15 @@ class _ImageActionWorker(QThread): try: if self.action == "decrypt": result = decrypt_assets( - self.assets, self.key, overwrite=False, progress=self.status.emit + self.assets, + self.key, + game_root=self.game_root, + overwrite=False, + progress=self.status.emit, ) - elif self.action == "encrypt": - result = encrypt_assets( - self.game_root, self.assets, self.key, progress=self.status.emit + elif self.action == "remove": + result = remove_editable_assets( + self.game_root, self.assets, progress=self.status.emit ) else: result = prepare_assets_for_patch( @@ -158,8 +197,9 @@ class RPGMakerImageManager(QWidget): 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." + "Select images while browsing, decrypt them into .dazedtl/images, edit the PNGs, " + "then encrypt the editable images when they are ready. " + "Pages keep very large games responsive." ) intro.setWordWrap(True) intro.setStyleSheet("color:#b8b8b8;font-size:13px;padding:2px 0 6px 0;") @@ -190,15 +230,13 @@ class RPGMakerImageManager(QWidget): 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.addItem("Editable images", "editable") 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 = _UserSelectionList() self.image_list.setViewMode(QListWidget.IconMode) self.image_list.setResizeMode(QListWidget.Adjust) self.image_list.setMovement(QListWidget.Static) @@ -219,11 +257,12 @@ class RPGMakerImageManager(QWidget): "QListWidget#rpgImageList::item:hover:!selected{background:#333;" "border-color:#555;}" ) - self.image_list.itemSelectionChanged.connect(self._selection_changed) + self.image_list.userSelectionChanged.connect(self._selection_changed) + self.image_list.deleteRequested.connect(self._remove_highlighted) 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." + "Click highlights one image. Ctrl-click toggles individual images, Shift-click " + "selects a range, and Ctrl+A highlights the current page." ) splitter.addWidget(self.image_list) @@ -245,14 +284,50 @@ class RPGMakerImageManager(QWidget): splitter.setSizes([850, 350]) root.addWidget(splitter, 1) + controls_row = QHBoxLayout() + controls_row.setSpacing(12) + + action_row = QHBoxLayout() + action_row.setSpacing(8) + self.open_workspace_button = QPushButton("Open folder") + self.open_workspace_button.setToolTip( + "Open the game-local .dazedtl/images folder in your file manager." + ) + self.open_workspace_button.clicked.connect(self._open_editable_folder) + self.decrypt_selected_button = QPushButton("Decrypt highlighted") + self.decrypt_selected_button.clicked.connect(self._decrypt_checked) + self.decrypt_all_button = QPushButton("Decrypt all") + self.decrypt_all_button.clicked.connect(self._decrypt_all) + self.remove_button = QPushButton("Remove highlighted") + self.remove_button.setToolTip( + "Delete highlighted PNG copies from the editable folder. Runtime images remain " + "untouched and can be decrypted again. The Delete key does the same thing." + ) + self.remove_button.clicked.connect(self._remove_highlighted) + self.prepare_button = QPushButton("Encrypt + patch") + 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 every PNG in the editable folder, preserve original encrypted files in " + ".dazedtl/image_backups, and add exact .gitignore allow-rules." + ) + self.prepare_button.clicked.connect(self._prepare_checked) + action_buttons = ( + self.open_workspace_button, + self.decrypt_selected_button, + self.decrypt_all_button, + self.remove_button, + self.prepare_button, + ) + for button in action_buttons: + button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + action_row.addWidget(button, 1) + controls_row.addLayout(action_row, 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() + page_row.setSpacing(8) self.previous_button = QPushButton("← Previous") self.previous_button.clicked.connect(lambda: self._change_page(-1)) page_row.addWidget(self.previous_button) @@ -263,34 +338,17 @@ class RPGMakerImageManager(QWidget): 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." + nav_width = max(self.previous_button.sizeHint().width(), self.next_button.sizeHint().width()) + self.previous_button.setFixedWidth(nav_width) + self.next_button.setFixedWidth(nav_width) + common_height = max( + 40, + *(button.sizeHint().height() for button in (*action_buttons, self.previous_button, self.next_button)), ) - 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) + for button in (*action_buttons, self.previous_button, self.next_button): + button.setFixedHeight(common_height) + controls_row.addLayout(page_row) + root.addLayout(controls_row) self.status_label = QLabel("Scanning image folders…") self.status_label.setWordWrap(True) @@ -314,6 +372,15 @@ class RPGMakerImageManager(QWidget): return self.game_root = root self.settings.setValue("workflow/last_game_folder", str(root)) + try: + migrate_legacy_editable_workspace(root) + clean_runtime_image_duplicates(root) + except Exception as exc: + QMessageBox.warning( + self, + "Editable Image Cleanup", + f"Could not consolidate editable images under .dazedtl:\n{exc}", + ) self.selected_ids.clear() self._load_key() self._start_scan() @@ -373,10 +440,10 @@ class RPGMakerImageManager(QWidget): 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) + editable = sum(asset.has_plain 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" + f"{editable:,} editable PNG copies · {len(self.selected_ids):,} highlighted" ) self._apply_filters() @@ -397,11 +464,7 @@ class RPGMakerImageManager(QWidget): 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): + if state == "editable" and not asset.has_plain: continue filtered.append(asset) self.filtered_assets = filtered @@ -427,8 +490,8 @@ class RPGMakerImageManager(QWidget): 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" + kind = "encrypted + editable" if asset.has_encrypted and asset.has_plain else ( + "encrypted" if asset.has_encrypted else "runtime PNG" ) item.setToolTip(f"{asset.asset_id}\n{kind}") self.image_list.addItem(item) @@ -485,23 +548,11 @@ class RPGMakerImageManager(QWidget): 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" + f"{len(self.filtered_assets):,} matching images · " + f"{len(self.selected_ids):,} highlighted · " + f"{sum(asset.has_plain for asset in self.assets):,} editable" ) def _show_preview(self, current: QListWidgetItem | None, _previous=None) -> None: @@ -525,13 +576,37 @@ class RPGMakerImageManager(QWidget): 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") + details.append(f"Runtime encrypted: {asset.encrypted_path}") + elif asset.has_runtime_plain: + details.append(f"Runtime PNG: {asset.runtime_plain_path}") + details.append( + f"Editable PNG: {asset.plain_path}" + if asset.has_plain + else f"Editable folder target: {asset.plain_path} (not created)" + ) + details.append( + "Highlighted: yes" if asset.asset_id in self.selected_ids + else "Highlighted: no" + ) self.path_label.setText("\n".join(details)) + def _open_editable_folder(self) -> None: + if self.game_root is None: + return + try: + workspace = ensure_editable_workspace(self.game_root) + if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(workspace))): + raise RuntimeError("The system file manager could not open the folder.") + self.status_label.setText(f"Editable images: {workspace}") + except Exception as exc: + QMessageBox.warning(self, "Editable Image Folder", str(exc)) + 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 _editable_assets(self) -> list[ImageAsset]: + return [asset for asset in self.assets if asset.has_plain] + def _ensure_key(self) -> bool: if self.key is not None: return True @@ -546,7 +621,9 @@ class RPGMakerImageManager(QWidget): 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." + self, + "Nothing Selected", + "Select one or more encrypted images first.", ) return if self._ensure_key(): @@ -557,14 +634,45 @@ class RPGMakerImageManager(QWidget): if not assets: QMessageBox.information(self, "Nothing to Decrypt", "All encrypted images already have PNG copies.") return - if self._ensure_key(): + answer = QMessageBox.question( + self, + "Decrypt Every Image", + f"Decrypt all {len(assets):,} encrypted image(s) into .dazedtl/images?\n\n" + "Existing editable copies will not be overwritten.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer == QMessageBox.Yes and self._ensure_key(): self._start_action("decrypt", assets) - def _prepare_checked(self) -> None: - assets = self._selected_assets() + def _remove_highlighted(self) -> None: + assets = [asset for asset in self._selected_assets() if asset.has_plain] if not assets: QMessageBox.information( - self, "Nothing Selected", "Select the translated images to include first." + self, + "No Editable Images Highlighted", + "Highlight one or more images from the Editable images filter first.", + ) + return + answer = QMessageBox.question( + self, + "Remove Editable Images", + f"Remove {len(assets):,} highlighted image(s) from the editable folder?\n\n" + "Their editable PNG copies will be deleted. The original game images will not " + "be changed, and encrypted images can be decrypted again later.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer == QMessageBox.Yes: + self._start_action("remove", assets) + + def _prepare_checked(self) -> None: + assets = self._editable_assets() + if not assets: + QMessageBox.information( + self, + "No Editable Images", + "Decrypt images into the editable folder first.", ) return if any(asset.has_encrypted for asset in assets) and not self._ensure_key(): @@ -572,8 +680,9 @@ class RPGMakerImageManager(QWidget): 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 " + f"Encrypt and add all {len(assets):,} editable image(s) to the patch?\n\n" + "Encrypted images will be rebuilt from their editable PNG in .dazedtl/images. " + "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, @@ -582,29 +691,6 @@ class RPGMakerImageManager(QWidget): 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) @@ -616,9 +702,22 @@ class RPGMakerImageManager(QWidget): 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)." + workspace = editable_workspace_root(self.game_root) + summary = ( + f"Created {result.completed:,} editable image(s) in {workspace}; " + f"skipped {result.skipped:,}." + ) + elif action == "remove": + self.selected_ids = { + asset_id + for asset_id in self.selected_ids + if self.assets_by_id.get(asset_id) is not None + and self.assets_by_id[asset_id].has_plain + } + summary = ( + f"Removed {result.completed:,} editable image(s); " + f"skipped {result.skipped:,}. Runtime images were left unchanged." + ) else: summary = ( f"Prepared {result.completed:,} image(s) for the patch and updated " @@ -641,9 +740,10 @@ class RPGMakerImageManager(QWidget): def _set_actions_enabled(self, enabled: bool) -> None: for button in ( + self.open_workspace_button, self.decrypt_selected_button, self.decrypt_all_button, - self.encrypt_button, + self.remove_button, self.prepare_button, ): button.setEnabled(enabled) @@ -658,7 +758,7 @@ class RPGMakerImageManager(QWidget): 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." + self, "Image Action Running", "Wait for the current image action to finish." ) event.ignore() return diff --git a/tests/test_rpgmaker_image_manager.py b/tests/test_rpgmaker_image_manager.py index ecc11d4..544b130 100644 --- a/tests/test_rpgmaker_image_manager.py +++ b/tests/test_rpgmaker_image_manager.py @@ -2,15 +2,16 @@ import os import tempfile import unittest from pathlib import Path +from unittest.mock import patch os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PIL import Image -from PyQt5.QtCore import QItemSelectionModel, QSettings, Qt +from PyQt5.QtCore import QSettings, Qt from PyQt5.QtTest import QTest -from PyQt5.QtWidgets import QApplication, QAbstractItemView +from PyQt5.QtWidgets import QApplication, QAbstractItemView, QMessageBox -from gui.rpgmaker_image_manager import RPGMakerImageManager +from gui.rpgmaker_image_manager import RPGMakerImageManager, _PAGE_SIZE class RPGMakerImageManagerSelectionTests(unittest.TestCase): @@ -34,7 +35,8 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): 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.settings_path = root / "settings.ini" + settings = QSettings(str(self.settings_path), QSettings.IniFormat) self.manager = RPGMakerImageManager(self.game_root, settings=settings) self.manager.resize(1100, 760) self.manager.show() @@ -82,17 +84,56 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): 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, - ) + def test_plain_click_replaces_selection_and_ctrl_click_adds(self): + self._click(0) + self._click(1) + self.assertEqual(len(self.manager.image_list.selectedItems()), 1) + self.assertEqual(len(self.manager.selected_ids), 1) + + self._click(2, Qt.ControlModifier) + self.assertEqual(len(self.manager.image_list.selectedItems()), 2) + self.assertEqual(len(self.manager.selected_ids), 2) + + def test_workspace_images_are_selected_after_manager_reopens(self): + self._click(0) + self._click(2, Qt.ControlModifier) + expected = set(self.manager.selected_ids) + for asset_id in expected: + asset = self.manager.assets_by_id[asset_id] + asset.plain_path.parent.mkdir(parents=True, exist_ok=True) + asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes()) + manifest = self.game_root / ".dazedtl" / "image_selection.json" + manifest.write_text("not used", encoding="utf-8") + + settings = QSettings(str(self.settings_path), QSettings.IniFormat) + reopened = RPGMakerImageManager(self.game_root, settings=settings) + reopened.resize(1100, 760) + reopened.show() + reopened._scan_worker.wait(5000) self.app.processEvents() + try: + self.assertEqual(reopened.selected_ids, set()) + self.assertEqual(reopened.image_list.selectedItems(), []) + self.assertEqual(manifest.read_text(encoding="utf-8"), "not used") + + reopened.folder_combo.setCurrentIndex( + reopened.folder_combo.findData("img/pictures") + ) + reopened.state_combo.setCurrentIndex( + reopened.state_combo.findData("editable") + ) + self.app.processEvents() + self.assertEqual(reopened.image_list.count(), len(expected)) + self.assertEqual(reopened.image_list.selectedItems(), []) + finally: + for worker in list(reopened._thumbnail_workers): + worker.wait(5000) + reopened.close() + self.app.processEvents() + + def test_highlights_survive_filters_without_refreshing_editable_view(self): + self._click(1) + self._click(3, Qt.ControlModifier) selected_before = set(self.manager.selected_ids) self.assertEqual(len(selected_before), 2) @@ -102,7 +143,74 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): self.app.processEvents() self.assertEqual(self.manager.selected_ids, selected_before) - self.assertEqual(len(self.manager.image_list.selectedItems()), 2) + selected_ids = { + item.data(Qt.UserRole + 1) + for item in self.manager.image_list.selectedItems() + } + self.assertEqual(selected_ids, selected_before) + + for asset_id in selected_before: + asset = self.manager.assets_by_id[asset_id] + asset.plain_path.parent.mkdir(parents=True, exist_ok=True) + asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes()) + editable_filter = self.manager.state_combo.findData("editable") + self.manager.state_combo.setCurrentIndex(editable_filter) + QTest.qWait(20) + self.app.processEvents() + self.assertEqual(self.manager.image_list.count(), 2) + self.assertEqual(self.manager.selected_ids, selected_before) + + removed_id = self.manager.image_list.item(0).data(Qt.UserRole + 1) + self._click(0, Qt.ControlModifier) + self.app.processEvents() + self.assertNotIn(removed_id, self.manager.selected_ids) + self.assertEqual(self.manager.image_list.count(), 2) + + def test_delete_key_removes_only_highlighted_workspace_copy(self): + asset = self.manager.assets[0] + asset.plain_path.parent.mkdir(parents=True, exist_ok=True) + edited = asset.runtime_plain_path.read_bytes() + asset.plain_path.write_bytes(edited) + self.manager.state_combo.setCurrentIndex( + self.manager.state_combo.findData("editable") + ) + self.app.processEvents() + self.assertEqual(self.manager.image_list.count(), 1) + self._click(0) + + with ( + patch.object(QMessageBox, "question", return_value=QMessageBox.Yes), + patch.object(QMessageBox, "information"), + ): + QTest.keyClick(self.manager.image_list, Qt.Key_Delete) + action_worker = self.manager._action_worker + self.assertIsNotNone(action_worker) + action_worker.wait(5000) + self.app.processEvents() + self.manager._scan_worker.wait(5000) + self.app.processEvents() + + self.assertFalse(asset.plain_path.exists()) + self.assertTrue(asset.runtime_plain_path.exists()) + self.assertEqual(self.manager.image_list.count(), 0) + self.assertEqual(self.manager.remove_button.text(), "Remove highlighted") + + def test_bottom_controls_share_one_row_and_action_width(self): + action_buttons = ( + self.manager.open_workspace_button, + self.manager.decrypt_selected_button, + self.manager.decrypt_all_button, + self.manager.remove_button, + self.manager.prepare_button, + ) + widths = [button.width() for button in action_buttons] + tops = [button.geometry().top() for button in action_buttons] + self.assertLessEqual(max(widths) - min(widths), 1) + self.assertEqual(len(set(tops)), 1) + self.assertEqual( + self.manager.previous_button.geometry().top(), + self.manager.open_workspace_button.geometry().top(), + ) def test_thumbnail_batch_keeps_one_stable_tile_per_asset(self): for worker in list(self.manager._thumbnail_workers): @@ -121,6 +229,9 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): class RPGMakerImageManagerNavigationTests(unittest.TestCase): + def test_page_size_supports_large_image_sets(self): + self.assertEqual(_PAGE_SIZE, 1000) + 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") diff --git a/tests/test_rpgmaker_images.py b/tests/test_rpgmaker_images.py index 37d47df..ce56e90 100644 --- a/tests/test_rpgmaker_images.py +++ b/tests/test_rpgmaker_images.py @@ -7,12 +7,16 @@ from PIL import Image from util.rpgmaker_images import ( add_patch_exceptions, + clean_runtime_image_duplicates, decrypt_assets, decrypt_image_bytes, + editable_workspace_root, encrypt_assets, encrypt_image_bytes, + migrate_legacy_editable_workspace, prepare_assets_for_patch, read_encryption_key, + remove_editable_assets, scan_image_assets, ) @@ -53,30 +57,56 @@ class RPGMakerImageTests(unittest.TestCase): 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) + self.assertEqual( + assets[0].plain_path, + self.root / ".dazedtl" / "images" / "www" / "img" / "pictures" / "001.png", + ) - result = decrypt_assets(assets, read_encryption_key(self.root)) + result = decrypt_assets( + assets, read_encryption_key(self.root), game_root=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) + ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8") + self.assertIn("/.dazedtl/", ignore) def test_existing_editable_png_is_not_overwritten_by_decrypt_all(self): self._encrypted_asset() - plain = self.content / "img" / "pictures" / "001.png" + asset = scan_image_assets(self.root)[0] + plain = asset.plain_path + plain.parent.mkdir(parents=True) translated = png_bytes("blue") plain.write_bytes(translated) - result = decrypt_assets(scan_image_assets(self.root), KEY) + result = decrypt_assets( + scan_image_assets(self.root), KEY, game_root=self.root + ) self.assertEqual(result.completed, 0) self.assertEqual(result.skipped, 1) self.assertEqual(plain.read_bytes(), translated) + def test_remove_editable_image_deletes_only_workspace_copy(self): + self._encrypted_asset() + asset = scan_image_assets(self.root)[0] + decrypt_assets([asset], KEY, game_root=self.root) + edited = png_bytes("blue") + asset.plain_path.write_bytes(edited) + + result = remove_editable_assets(self.root, [asset]) + + self.assertEqual(result.completed, 1) + self.assertEqual(result.errors, []) + self.assertFalse(asset.plain_path.exists()) + self.assertTrue(asset.encrypted_path.exists()) + 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) + decrypt_assets([asset], KEY, game_root=self.root) translated = png_bytes("blue") asset.plain_path.write_bytes(translated) self.root.joinpath(".gitignore").write_text("*.*\n", encoding="utf-8") @@ -104,23 +134,27 @@ class RPGMakerImageTests(unittest.TestCase): self.assertEqual(asset.asset_id, "img/pictures/title.png") self.assertEqual(asset.encrypted_path, encrypted) - result = decrypt_assets([asset], KEY) + result = decrypt_assets([asset], KEY, game_root=self.root) 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) + decrypt_assets([asset], KEY, game_root=self.root) translated = png_bytes("black") asset.plain_path.write_bytes(translated) + ignore_before = self.root.joinpath(".gitignore").read_text(encoding="utf-8") 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()) + self.assertEqual( + self.root.joinpath(".gitignore").read_text(encoding="utf-8"), + ignore_before, + ) def test_nested_gitignore_receives_relative_exact_exception(self): target = self.content / "img" / "pictures" / "menu image.png" @@ -149,6 +183,111 @@ class RPGMakerImageTests(unittest.TestCase): ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8") self.assertIn("!/www/img/pictures/menu.png", ignore) + def test_workspace_edit_of_plain_game_image_is_published_and_backed_up(self): + runtime = self.content / "img" / "pictures" / "menu.png" + original = png_bytes("yellow") + translated = png_bytes("blue") + runtime.write_bytes(original) + asset = scan_image_assets(self.root)[0] + asset.plain_path.parent.mkdir(parents=True) + asset.plain_path.write_bytes(translated) + + result = prepare_assets_for_patch(self.root, [asset], None) + + self.assertEqual(result.completed, 1) + self.assertEqual(runtime.read_bytes(), translated) + backup = self.root / ".dazedtl" / "image_backups" / "www/img/pictures/menu.png" + self.assertEqual(backup.read_bytes(), original) + self.assertEqual(result.patch_files, [runtime]) + + def test_legacy_side_by_side_editable_png_is_migrated_to_workspace(self): + self._encrypted_asset() + legacy = self.content / "img" / "pictures" / "001.png" + translated = png_bytes("purple") + legacy.write_bytes(translated) + asset = scan_image_assets(self.root)[0] + + result = decrypt_assets([asset], KEY, game_root=self.root) + + self.assertEqual(result.completed, 1) + self.assertEqual(asset.plain_path.read_bytes(), translated) + self.assertFalse(legacy.exists()) + + def test_conflicting_runtime_duplicate_is_preserved_outside_game_images(self): + self._encrypted_asset() + runtime_duplicate = self.content / "img" / "pictures" / "001.png" + runtime_duplicate.write_bytes(png_bytes("blue")) + asset = scan_image_assets(self.root)[0] + asset.plain_path.parent.mkdir(parents=True) + workspace_edit = png_bytes("purple") + asset.plain_path.write_bytes(workspace_edit) + + cleaned = clean_runtime_image_duplicates(self.root) + + self.assertEqual(cleaned, 1) + self.assertFalse(runtime_duplicate.exists()) + self.assertEqual(asset.plain_path.read_bytes(), workspace_edit) + conflict = ( + self.root + / ".dazedtl" + / "runtime_plain_conflicts" + / "www" + / "img" + / "pictures" + / "001.png" + ) + self.assertEqual(conflict.read_bytes(), png_bytes("blue")) + + def test_mz_workspace_preserves_runtime_hierarchy(self): + mz_root = Path(self.temp.name) / "MZGame" + image_dir = mz_root / "img" / "pictures" + data_dir = mz_root / "data" + image_dir.mkdir(parents=True) + data_dir.mkdir(parents=True) + data_dir.joinpath("System.json").write_text( + '{"encryptionKey":"' + KEY_HEX + '"}', encoding="utf-8" + ) + encrypted = image_dir / "title.png_" + encrypted.write_bytes(encrypt_image_bytes(png_bytes("green"), KEY)) + + asset = scan_image_assets(mz_root)[0] + result = decrypt_assets([asset], KEY, game_root=mz_root) + + self.assertEqual(result.completed, 1) + self.assertEqual( + asset.plain_path, + editable_workspace_root(mz_root) / "img" / "pictures" / "title.png", + ) + + def test_legacy_editable_folder_is_moved_under_dazedtl(self): + legacy = self.root / "DazedTL_Images" / "www" / "img" / "pictures" + legacy.mkdir(parents=True) + translated = png_bytes("purple") + legacy.joinpath("001.png").write_bytes(translated) + + moved = migrate_legacy_editable_workspace(self.root) + + destination = ( + self.root / ".dazedtl" / "images" / "www" / "img" / "pictures" / "001.png" + ) + self.assertEqual(moved, 1) + self.assertEqual(destination.read_bytes(), translated) + self.assertFalse((self.root / "DazedTL_Images").exists()) + + def test_scan_uses_editable_workspace_as_the_edit_list(self): + self._encrypted_asset("001.rpgmvp") + self._encrypted_asset("002.rpgmvp", "blue") + assets = scan_image_assets(self.root) + assets[1].plain_path.parent.mkdir(parents=True, exist_ok=True) + assets[1].plain_path.write_bytes(png_bytes("purple")) + + rescanned = scan_image_assets(self.root) + + self.assertEqual( + {asset.asset_id for asset in rescanned if asset.has_plain}, + {assets[1].asset_id}, + ) + if __name__ == "__main__": unittest.main() diff --git a/util/rpgmaker_images.py b/util/rpgmaker_images.py index 7b51de6..67adc60 100644 --- a/util/rpgmaker_images.py +++ b/util/rpgmaker_images.py @@ -25,6 +25,8 @@ RPGMV_HEADER = bytes( ) KEY_LEN = 16 ENCRYPTED_IMAGE_EXTENSIONS = (".rpgmvp", ".png_") +EDITABLE_WORKSPACE_NAME = "images" +LEGACY_EDITABLE_WORKSPACE_NAME = "DazedTL_Images" @dataclass(frozen=True) @@ -35,6 +37,7 @@ class ImageAsset: relative_png: Path plain_path: Path encrypted_path: Path | None = None + runtime_plain_path: Path | None = None @property def has_plain(self) -> bool: @@ -44,6 +47,10 @@ class ImageAsset: def has_encrypted(self) -> bool: return self.encrypted_path is not None and self.encrypted_path.is_file() + @property + def has_runtime_plain(self) -> bool: + return self.runtime_plain_path is not None and self.runtime_plain_path.is_file() + @dataclass class ImageActionResult: @@ -95,6 +102,16 @@ def read_encryption_key(game_root: str | Path) -> bytes: return bytes.fromhex(normalized) +def editable_workspace_root(game_root: str | Path) -> Path: + """Return the DazedTL working folder used for editable image copies.""" + + return ( + Path(game_root).expanduser().resolve() + / ".dazedtl" + / EDITABLE_WORKSPACE_NAME + ) + + def _logical_png(path: Path) -> Path: lower = path.name.casefold() if lower.endswith(".rpgmvp"): @@ -107,7 +124,8 @@ def _logical_png(path: Path) -> 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) + root = Path(game_root).expanduser().resolve() + content_root = resolve_content_root(root) image_root = content_root / "img" by_id: dict[str, dict[str, Path]] = {} for path in image_root.rglob("*"): @@ -119,18 +137,25 @@ def scan_image_assets(game_root: str | Path) -> list[ImageAsset]: 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}) + entry = by_id.setdefault( + asset_id, + { + "relative": relative, + "workspace_relative": logical.relative_to(root), + }, + ) if name.endswith(ENCRYPTED_IMAGE_EXTENSIONS): entry["encrypted"] = path else: - entry["plain"] = path + entry["runtime_plain"] = path assets = [ ImageAsset( asset_id=asset_id, relative_png=entry["relative"], - plain_path=entry["plain"], + plain_path=editable_workspace_root(root) / entry["workspace_relative"], encrypted_path=entry.get("encrypted"), + runtime_plain_path=entry.get("runtime_plain"), ) for asset_id, entry in by_id.items() ] @@ -163,8 +188,10 @@ def preview_png_bytes(asset: ImageAsset, key: bytes | None) -> bytes: if asset.has_plain: return asset.plain_path.read_bytes() + if asset.has_runtime_plain: + return asset.runtime_plain_path.read_bytes() if not asset.has_encrypted: - raise FileNotFoundError(asset.plain_path) + raise FileNotFoundError(asset.runtime_plain_path or 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) @@ -200,16 +227,127 @@ def _atomic_write(path: Path, data: bytes) -> None: raise +def _ensure_image_manager_ignores(game_root: str | Path) -> None: + root = Path(game_root).expanduser().resolve() + ignore_path = root / ".gitignore" + existing = ( + ignore_path.read_text(encoding="utf-8", errors="surrogateescape") + if ignore_path.exists() + else "" + ) + rules = ("/.dazedtl/",) + additions = [rule for rule in rules if rule not in existing.splitlines()] + if additions: + text = existing + if text and not text.endswith("\n"): + text += "\n" + if "# DazedTL image manager working files" not in text: + text += "\n# DazedTL image manager working files\n" + text += "\n".join(additions) + "\n" + _atomic_write(ignore_path, text.encode("utf-8", errors="surrogateescape")) + + +def ensure_editable_workspace(game_root: str | Path) -> Path: + """Create the editable folder and keep working files out of patches.""" + + root = Path(game_root).expanduser().resolve() + workspace = editable_workspace_root(root) + workspace.mkdir(parents=True, exist_ok=True) + migrate_legacy_editable_workspace(root) + _ensure_image_manager_ignores(root) + return workspace + + +def migrate_legacy_editable_workspace(game_root: str | Path) -> int: + """Move editable PNGs from the former top-level workspace into .dazedtl.""" + + root = Path(game_root).expanduser().resolve() + legacy = root / LEGACY_EDITABLE_WORKSPACE_NAME + if not legacy.is_dir(): + return 0 + destination_root = editable_workspace_root(root) + conflict_root = root / ".dazedtl" / "legacy_image_conflicts" + moved = 0 + for source in sorted(path for path in legacy.rglob("*") if path.is_file()): + relative = source.relative_to(legacy) + destination = destination_root / relative + if destination.exists(): + destination = conflict_root / relative + counter = 2 + while destination.exists(): + destination = conflict_root / relative.with_name( + f"{relative.stem}_{counter}{relative.suffix}" + ) + counter += 1 + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(destination)) + moved += 1 + + directories = sorted( + (path for path in legacy.rglob("*") if path.is_dir()), + key=lambda path: len(path.parts), + reverse=True, + ) + for directory in directories: + try: + directory.rmdir() + except OSError: + pass + try: + legacy.rmdir() + except OSError: + pass + _ensure_image_manager_ignores(root) + return moved + + +def clean_runtime_image_duplicates(game_root: str | Path) -> int: + """Move PNGs that duplicate encrypted assets out of the runtime image tree.""" + + root = Path(game_root).expanduser().resolve() + cleaned = 0 + for asset in scan_image_assets(root): + if not (asset.has_encrypted and asset.has_runtime_plain): + continue + source = asset.runtime_plain_path + destination = asset.plain_path + destination.parent.mkdir(parents=True, exist_ok=True) + if not destination.exists(): + shutil.move(str(source), str(destination)) + elif source.read_bytes() == destination.read_bytes(): + source.unlink() + else: + relative = source.relative_to(root) + conflict_root = root / ".dazedtl" / "runtime_plain_conflicts" + conflict = conflict_root / relative + counter = 2 + while conflict.exists(): + conflict = (conflict_root / relative).with_name( + f"{relative.stem}_{counter}{relative.suffix}" + ) + counter += 1 + conflict.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(conflict)) + cleaned += 1 + if cleaned: + _ensure_image_manager_ignores(root) + return cleaned + + def decrypt_assets( assets: Iterable[ImageAsset], key: bytes, *, + game_root: str | Path | None = None, overwrite: bool = False, progress: Callable[[str], None] | None = None, ) -> ImageActionResult: - """Create editable PNG copies beside encrypted images.""" + """Decrypt images into the game-local editable image workspace.""" result = ImageActionResult() + assets = list(assets) + if game_root is not None: + ensure_editable_workspace(game_root) for asset in assets: if not asset.has_encrypted: result.skipped += 1 @@ -218,6 +356,16 @@ def decrypt_assets( result.skipped += 1 continue try: + # Older versions of the image manager placed the editable PNG + # beside its encrypted runtime file. Preserve any work done in + # that layout by migrating it instead of decrypting over it. + if asset.has_runtime_plain and not asset.has_plain and not overwrite: + if progress: + progress(f"Moving editable copy for {asset.asset_id}") + _atomic_write(asset.plain_path, asset.runtime_plain_path.read_bytes()) + asset.runtime_plain_path.unlink() + result.completed += 1 + continue if progress: progress(f"Decrypting {asset.asset_id}") raw = decrypt_image_bytes(asset.encrypted_path.read_bytes(), key) @@ -225,6 +373,11 @@ def decrypt_assets( result.completed += 1 except Exception as exc: # continue a large batch and report all failures result.errors.append(f"{asset.asset_id}: {exc}") + if game_root is not None: + try: + clean_runtime_image_duplicates(game_root) + except Exception as exc: + result.errors.append(f"Runtime image cleanup: {exc}") return result @@ -235,6 +388,40 @@ def _path_inside(root: Path, path: Path) -> Path: raise ValueError(f"Image is outside the selected game folder: {path}") from exc +def remove_editable_assets( + game_root: str | Path, + assets: Iterable[ImageAsset], + *, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + """Delete editable copies while leaving their runtime assets untouched.""" + + root = Path(game_root).expanduser().resolve() + workspace = editable_workspace_root(root) + result = ImageActionResult() + for asset in assets: + try: + if not asset.has_plain: + result.skipped += 1 + continue + _path_inside(workspace, asset.plain_path) + if progress: + progress(f"Removing {asset.asset_id} from editable images") + asset.plain_path.unlink() + result.completed += 1 + + parent = asset.plain_path.parent + while parent != workspace: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + except Exception as exc: + result.errors.append(f"{asset.asset_id}: {exc}") + return result + + 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.") @@ -341,8 +528,22 @@ def prepare_assets_for_patch( 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) + elif asset.has_runtime_plain: + # Unencrypted games load the PNG from the runtime image tree. + # If the user made a workspace copy, publish it back there and + # preserve the original once, just like encrypted assets. + if asset.has_plain: + relative_plain = _path_inside(root, asset.runtime_plain_path) + backup = root / ".dazedtl" / "image_backups" / relative_plain + if not backup.exists(): + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(asset.runtime_plain_path, backup) + if progress: + progress(f"Publishing {asset.asset_id}") + _atomic_write( + asset.runtime_plain_path, asset.plain_path.read_bytes() + ) + targets.append(asset.runtime_plain_path) else: raise FileNotFoundError("image file no longer exists") result.completed += 1 @@ -357,6 +558,10 @@ def prepare_assets_for_patch( result.gitignore_files = add_patch_exceptions(root, targets) except Exception as exc: result.errors.append(f".gitignore: {exc}") + try: + clean_runtime_image_duplicates(root) + except Exception as exc: + result.errors.append(f"Runtime image cleanup: {exc}") return result @@ -367,7 +572,7 @@ def encrypt_assets( *, progress: Callable[[str], None] | None = None, ) -> ImageActionResult: - """Re-encrypt editable PNGs in place without changing gitignore files.""" + """Re-encrypt workspace PNGs without changing gitignore files.""" root = Path(game_root).expanduser().resolve() result = ImageActionResult() @@ -390,4 +595,8 @@ def encrypt_assets( result.completed += 1 except Exception as exc: result.errors.append(f"{asset.asset_id}: {exc}") + try: + clean_runtime_image_duplicates(root) + except Exception as exc: + result.errors.append(f"Runtime image cleanup: {exc}") return result