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
This commit is contained in:
parent
3d82203496
commit
d82895cc4d
4 changed files with 176 additions and 22 deletions
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue