From d82895cc4dfea9107e100e5c1c4290a65a993b1b Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sat, 25 Jul 2026 16:29:19 -0500 Subject: [PATCH] feat(rpgmaker): add scoped image patch preparation - Patch highlighted editable images or all images when none are highlighted - Skip unchanged images while preserving editable workspace copies - Open the editable folder matching the current image or folder filter --- gui/rpgmaker_image_manager.py | 85 ++++++++++++++++++++++------ tests/test_rpgmaker_image_manager.py | 55 ++++++++++++++++++ tests/test_rpgmaker_images.py | 39 ++++++++++++- util/rpgmaker_images.py | 19 +++++-- 4 files changed, 176 insertions(+), 22 deletions(-) diff --git a/gui/rpgmaker_image_manager.py b/gui/rpgmaker_image_manager.py index 81f03d4..199ab0c 100644 --- a/gui/rpgmaker_image_manager.py +++ b/gui/rpgmaker_image_manager.py @@ -43,6 +43,7 @@ from util.rpgmaker_images import ( preview_png_bytes, read_encryption_key, remove_editable_assets, + resolve_content_root, scan_image_assets, thumbnail_png_bytes, ) @@ -198,7 +199,8 @@ class RPGMakerImageManager(QWidget): root.addWidget(title) intro = QLabel( "Select images while browsing, decrypt them into .dazedtl/images, edit the PNGs, " - "then encrypt the editable images when they are ready. " + "then highlight the finished images to patch only those, or clear highlights to " + "patch every editable image. " "Pages keep very large games responsive." ) intro.setWordWrap(True) @@ -291,7 +293,8 @@ class RPGMakerImageManager(QWidget): 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." + "Open the highlighted image's editable folder, the chosen folder filter, or the " + "editable img root." ) self.open_workspace_button.clicked.connect(self._open_editable_folder) self.decrypt_selected_button = QPushButton("Decrypt highlighted") @@ -304,14 +307,14 @@ class RPGMakerImageManager(QWidget): "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 = QPushButton("Encrypt all + 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." + "With highlighted images, patch only their editable PNGs. With no highlights, patch " + "every editable PNG. Editable copies remain in .dazedtl/images until removed." ) self.prepare_button.clicked.connect(self._prepare_checked) action_buttons = ( @@ -445,6 +448,7 @@ class RPGMakerImageManager(QWidget): f"Found {len(assets):,} images · {encrypted:,} encrypted · " f"{editable:,} editable PNG copies · {len(self.selected_ids):,} highlighted" ) + self._update_prepare_scope() self._apply_filters() def _scan_error(self, generation: int, message: str) -> None: @@ -549,12 +553,19 @@ class RPGMakerImageManager(QWidget): self._update_selection_status() def _update_selection_status(self) -> None: + self._update_prepare_scope() self.status_label.setText( 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 _update_prepare_scope(self) -> None: + if self.selected_ids: + self.prepare_button.setText("Encrypt highlighted + patch") + else: + self.prepare_button.setText("Encrypt all + patch") + def _show_preview(self, current: QListWidgetItem | None, _previous=None) -> None: if current is None: return @@ -595,9 +606,27 @@ class RPGMakerImageManager(QWidget): return try: workspace = ensure_editable_workspace(self.game_root) - if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(workspace))): + current = self.image_list.currentItem() + asset = ( + self.assets_by_id.get(current.data(_ASSET_ID_ROLE)) + if current is not None + else None + ) + if asset is not None: + target = asset.plain_path.parent + else: + root = Path(self.game_root).expanduser().resolve() + content_relative = resolve_content_root(root).relative_to(root) + folder = self.folder_combo.currentData() or "img" + relative_folder = Path(folder) + if relative_folder.is_absolute() or ".." in relative_folder.parts: + raise ValueError(f"Invalid editable image folder: {folder}") + target = workspace / content_relative / relative_folder + target.resolve().relative_to(workspace.resolve()) + target.mkdir(parents=True, exist_ok=True) + if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(target))): raise RuntimeError("The system file manager could not open the folder.") - self.status_label.setText(f"Editable images: {workspace}") + self.status_label.setText(f"Editable images: {target}") except Exception as exc: QMessageBox.warning(self, "Editable Image Folder", str(exc)) @@ -618,12 +647,16 @@ class RPGMakerImageManager(QWidget): return False def _decrypt_checked(self) -> None: - assets = [asset for asset in self._selected_assets() if asset.has_encrypted] + assets = [ + asset + for asset in self._selected_assets() + if asset.has_encrypted and not asset.has_plain + ] if not assets: QMessageBox.information( self, - "Nothing Selected", - "Select one or more encrypted images first.", + "Nothing to Decrypt", + "Highlight one or more encrypted images that are not already editable.", ) return if self._ensure_key(): @@ -667,24 +700,41 @@ class RPGMakerImageManager(QWidget): self._start_action("remove", assets) def _prepare_checked(self) -> None: - assets = self._editable_assets() + highlighted = bool(self.selected_ids) + assets = ( + [asset for asset in self._selected_assets() if asset.has_plain] + if highlighted + else self._editable_assets() + ) if not assets: + message = ( + "None of the highlighted images are in the editable folder. Decrypt them first " + "or clear the highlights to patch every editable image." + if highlighted + else "Decrypt images into the editable folder first." + ) QMessageBox.information( self, "No Editable Images", - "Decrypt images into the editable folder first.", + message, ) return if any(asset.has_encrypted for asset in assets) and not self._ensure_key(): return + scope = ( + f"the {len(assets):,} highlighted editable image(s)" + if highlighted + else f"all {len(assets):,} editable image(s)" + ) answer = QMessageBox.question( self, "Prepare Images for Patch", - 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 " + f"Check {scope} and add changed images to the patch?\n\n" + "Unchanged editable copies will be skipped. Changed encrypted images will be rebuilt " + "from .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.", + "added to the applicable .gitignore files. Editable PNG copies will remain available " + "for further changes until you remove them.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) @@ -721,7 +771,8 @@ class RPGMakerImageManager(QWidget): else: summary = ( f"Prepared {result.completed:,} image(s) for the patch and updated " - f"{len(result.gitignore_files):,} .gitignore file(s)." + f"{len(result.gitignore_files):,} .gitignore file(s); " + f"skipped {result.skipped:,} unchanged image(s)." ) if result.errors: shown = "\n".join(result.errors[:12]) diff --git a/tests/test_rpgmaker_image_manager.py b/tests/test_rpgmaker_image_manager.py index 544b130..fdbd61d 100644 --- a/tests/test_rpgmaker_image_manager.py +++ b/tests/test_rpgmaker_image_manager.py @@ -195,6 +195,46 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): self.assertEqual(self.manager.image_list.count(), 0) self.assertEqual(self.manager.remove_button.text(), "Remove highlighted") + def test_prepare_uses_only_highlighted_editable_images(self): + highlighted = self.manager.assets[0] + not_highlighted = self.manager.assets[1] + for asset in (highlighted, not_highlighted): + asset.plain_path.parent.mkdir(parents=True, exist_ok=True) + asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes()) + self._click(0) + + with ( + patch.object(QMessageBox, "question", return_value=QMessageBox.Yes), + patch.object(self.manager, "_start_action") as start_action, + ): + self.manager._prepare_checked() + + action, assets = start_action.call_args.args + self.assertEqual(action, "prepare") + self.assertEqual(assets, [highlighted]) + self.assertTrue(highlighted.plain_path.exists()) + self.assertTrue(not_highlighted.plain_path.exists()) + self.assertEqual( + self.manager.prepare_button.text(), "Encrypt highlighted + patch" + ) + + def test_prepare_uses_all_editable_images_without_highlights(self): + editable = self.manager.assets[:2] + for asset in editable: + asset.plain_path.parent.mkdir(parents=True, exist_ok=True) + asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes()) + + with ( + patch.object(QMessageBox, "question", return_value=QMessageBox.Yes), + patch.object(self.manager, "_start_action") as start_action, + ): + self.manager._prepare_checked() + + action, assets = start_action.call_args.args + self.assertEqual(action, "prepare") + self.assertEqual(assets, editable) + self.assertEqual(self.manager.prepare_button.text(), "Encrypt all + patch") + def test_bottom_controls_share_one_row_and_action_width(self): action_buttons = ( self.manager.open_workspace_button, @@ -212,6 +252,21 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): self.manager.open_workspace_button.geometry().top(), ) + def test_open_folder_uses_highlighted_images_editable_parent(self): + self._click(0) + asset_id = self.manager.image_list.currentItem().data(Qt.UserRole + 1) + expected = self.manager.assets_by_id[asset_id].plain_path.parent + + with patch( + "gui.rpgmaker_image_manager.QDesktopServices.openUrl", + return_value=True, + ) as open_url: + self.manager._open_editable_folder() + + opened_url = open_url.call_args.args[0] + self.assertEqual(Path(opened_url.toLocalFile()), expected) + self.assertTrue(expected.is_dir()) + def test_thumbnail_batch_keeps_one_stable_tile_per_asset(self): for worker in list(self.manager._thumbnail_workers): worker.wait(5000) diff --git a/tests/test_rpgmaker_images.py b/tests/test_rpgmaker_images.py index ce56e90..ee4d564 100644 --- a/tests/test_rpgmaker_images.py +++ b/tests/test_rpgmaker_images.py @@ -116,6 +116,7 @@ class RPGMakerImageTests(unittest.TestCase): self.assertEqual(result.completed, 1) self.assertEqual(result.errors, []) self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated) + self.assertEqual(asset.plain_path.read_bytes(), 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") @@ -123,11 +124,47 @@ class RPGMakerImageTests(unittest.TestCase): self.assertIn("!/www/img/pictures/001.rpgmvp", ignore) self.assertNotIn("!/www/img/pictures/001.png", ignore) - prepare_assets_for_patch(self.root, [asset], KEY) + second = prepare_assets_for_patch(self.root, [asset], KEY) again = self.root.joinpath(".gitignore").read_text(encoding="utf-8") + self.assertEqual(second.completed, 0) + self.assertEqual(second.skipped, 1) self.assertEqual(again.count("!/www/img/pictures/001.rpgmvp"), 1) self.assertEqual(backup.read_bytes(), original) + def test_prepare_skips_unchanged_decrypted_image(self): + encrypted = self._encrypted_asset() + original = encrypted.read_bytes() + asset = scan_image_assets(self.root)[0] + decrypt_assets([asset], KEY, game_root=self.root) + + result = prepare_assets_for_patch(self.root, [asset], KEY) + + self.assertEqual(result.completed, 0) + self.assertEqual(result.skipped, 1) + self.assertEqual(result.patch_files, []) + self.assertEqual(encrypted.read_bytes(), original) + self.assertFalse( + (self.root / ".dazedtl" / "image_backups" / "www/img/pictures/001.rpgmvp").exists() + ) + ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8") + self.assertNotIn("!/www/img/pictures/001.rpgmvp", ignore) + + def test_prepare_skips_unchanged_unencrypted_workspace_copy(self): + runtime = self.content / "img" / "pictures" / "menu.png" + original = png_bytes("yellow") + runtime.write_bytes(original) + asset = scan_image_assets(self.root)[0] + asset.plain_path.parent.mkdir(parents=True, exist_ok=True) + asset.plain_path.write_bytes(original) + + result = prepare_assets_for_patch(self.root, [asset], None) + + self.assertEqual(result.completed, 0) + self.assertEqual(result.skipped, 1) + self.assertEqual(result.patch_files, []) + self.assertEqual(runtime.read_bytes(), original) + self.assertFalse((self.root / ".dazedtl" / "image_backups").exists()) + 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] diff --git a/util/rpgmaker_images.py b/util/rpgmaker_images.py index 67adc60..a8b282b 100644 --- a/util/rpgmaker_images.py +++ b/util/rpgmaker_images.py @@ -518,6 +518,13 @@ def prepare_assets_for_patch( raise FileNotFoundError("decrypt this image before preparing it") if key is None: raise ValueError("the encryption key is required") + if progress: + progress(f"Checking {asset.asset_id}") + plain_bytes = asset.plain_path.read_bytes() + runtime_bytes = asset.encrypted_path.read_bytes() + if decrypt_image_bytes(runtime_bytes, key) == plain_bytes: + result.skipped += 1 + continue relative_encrypted = _path_inside(root, asset.encrypted_path) backup = root / ".dazedtl" / "image_backups" / relative_encrypted if not backup.exists(): @@ -525,7 +532,7 @@ def prepare_assets_for_patch( shutil.copy2(asset.encrypted_path, backup) if progress: progress(f"Encrypting {asset.asset_id}") - encrypted = encrypt_image_bytes(asset.plain_path.read_bytes(), key) + encrypted = encrypt_image_bytes(plain_bytes, key) _atomic_write(asset.encrypted_path, encrypted) targets.append(asset.encrypted_path) elif asset.has_runtime_plain: @@ -533,6 +540,12 @@ def prepare_assets_for_patch( # If the user made a workspace copy, publish it back there and # preserve the original once, just like encrypted assets. if asset.has_plain: + if progress: + progress(f"Checking {asset.asset_id}") + plain_bytes = asset.plain_path.read_bytes() + if asset.runtime_plain_path.read_bytes() == plain_bytes: + result.skipped += 1 + continue relative_plain = _path_inside(root, asset.runtime_plain_path) backup = root / ".dazedtl" / "image_backups" / relative_plain if not backup.exists(): @@ -540,9 +553,7 @@ def prepare_assets_for_patch( 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() - ) + _atomic_write(asset.runtime_plain_path, plain_bytes) targets.append(asset.runtime_plain_path) else: raise FileNotFoundError("image file no longer exists")