feat(rpgmaker): add editable image workflow

- Decrypt MV/MZ images into a game-local editable workspace
- Re-encrypt edited images, back up originals, and add patch rules
- Add visual filtering, multi-selection, removal, and large-library paging
- Keep runtime image directories free of duplicate decrypted files
This commit is contained in:
DazedAnon 2026-07-25 16:01:46 -05:00
parent 35d5d5968a
commit 3d82203496
4 changed files with 702 additions and 143 deletions

View file

@ -4,8 +4,15 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PyQt5.QtCore import Qt, QSize, QThread, pyqtSignal, QSettings from PyQt5.QtCore import (
from PyQt5.QtGui import QColor, QIcon, QPixmap Qt,
QSize,
QThread,
pyqtSignal,
QSettings,
QUrl,
)
from PyQt5.QtGui import QColor, QDesktopServices, QIcon, QPixmap
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QComboBox, QComboBox,
QAbstractItemView, QAbstractItemView,
@ -17,6 +24,7 @@ from PyQt5.QtWidgets import (
QListWidgetItem, QListWidgetItem,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QSizePolicy,
QSplitter, QSplitter,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@ -26,18 +34,45 @@ from util.paths import APP_NAME, ORG_NAME
from util.rpgmaker_images import ( from util.rpgmaker_images import (
ImageAsset, ImageAsset,
clean_runtime_image_duplicates,
decrypt_assets, decrypt_assets,
encrypt_assets, editable_workspace_root,
ensure_editable_workspace,
migrate_legacy_editable_workspace,
prepare_assets_for_patch, prepare_assets_for_patch,
preview_png_bytes, preview_png_bytes,
read_encryption_key, read_encryption_key,
remove_editable_assets,
scan_image_assets, scan_image_assets,
thumbnail_png_bytes, thumbnail_png_bytes,
) )
_ASSET_ID_ROLE = Qt.UserRole + 1 _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): class _ImageScanWorker(QThread):
@ -100,11 +135,15 @@ class _ImageActionWorker(QThread):
try: try:
if self.action == "decrypt": if self.action == "decrypt":
result = decrypt_assets( 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": elif self.action == "remove":
result = encrypt_assets( result = remove_editable_assets(
self.game_root, self.assets, self.key, progress=self.status.emit self.game_root, self.assets, progress=self.status.emit
) )
else: else:
result = prepare_assets_for_patch( result = prepare_assets_for_patch(
@ -158,8 +197,9 @@ class RPGMakerImageManager(QWidget):
title.setStyleSheet("font-size:18px;font-weight:bold;color:#e0e0e0;") title.setStyleSheet("font-size:18px;font-weight:bold;color:#e0e0e0;")
root.addWidget(title) root.addWidget(title)
intro = QLabel( intro = QLabel(
"Browse encrypted images visually, create editable PNG copies, then select only " "Select images while browsing, decrypt them into .dazedtl/images, edit the PNGs, "
"the translated images you want in the patch. Pages keep very large games responsive." "then encrypt the editable images when they are ready. "
"Pages keep very large games responsive."
) )
intro.setWordWrap(True) intro.setWordWrap(True)
intro.setStyleSheet("color:#b8b8b8;font-size:13px;padding:2px 0 6px 0;") 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) filters.addWidget(self.folder_combo, 1)
self.state_combo = QComboBox() self.state_combo = QComboBox()
self.state_combo.addItem("All images", "all") self.state_combo.addItem("All images", "all")
self.state_combo.addItem("Encrypted", "encrypted") self.state_combo.addItem("Editable images", "editable")
self.state_combo.addItem("Editable PNG ready", "plain")
self.state_combo.addItem("Unencrypted PNG", "plain_only")
self.state_combo.currentIndexChanged.connect(self._apply_filters) self.state_combo.currentIndexChanged.connect(self._apply_filters)
filters.addWidget(self.state_combo) filters.addWidget(self.state_combo)
root.addLayout(filters) root.addLayout(filters)
splitter = QSplitter(Qt.Horizontal) splitter = QSplitter(Qt.Horizontal)
self.image_list = QListWidget() self.image_list = _UserSelectionList()
self.image_list.setViewMode(QListWidget.IconMode) self.image_list.setViewMode(QListWidget.IconMode)
self.image_list.setResizeMode(QListWidget.Adjust) self.image_list.setResizeMode(QListWidget.Adjust)
self.image_list.setMovement(QListWidget.Static) self.image_list.setMovement(QListWidget.Static)
@ -219,11 +257,12 @@ class RPGMakerImageManager(QWidget):
"QListWidget#rpgImageList::item:hover:!selected{background:#333;" "QListWidget#rpgImageList::item:hover:!selected{background:#333;"
"border-color:#555;}" "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.currentItemChanged.connect(self._show_preview)
self.image_list.setToolTip( self.image_list.setToolTip(
"Select images normally. Ctrl-click toggles individual images, Shift-click selects " "Click highlights one image. Ctrl-click toggles individual images, Shift-click "
"a range, and Ctrl+A selects the current page." "selects a range, and Ctrl+A highlights the current page."
) )
splitter.addWidget(self.image_list) splitter.addWidget(self.image_list)
@ -245,14 +284,50 @@ class RPGMakerImageManager(QWidget):
splitter.setSizes([850, 350]) splitter.setSizes([850, 350])
root.addWidget(splitter, 1) 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() page_row = QHBoxLayout()
select_page = QPushButton("Select page") page_row.setSpacing(8)
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 = QPushButton("← Previous")
self.previous_button.clicked.connect(lambda: self._change_page(-1)) self.previous_button.clicked.connect(lambda: self._change_page(-1))
page_row.addWidget(self.previous_button) page_row.addWidget(self.previous_button)
@ -263,34 +338,17 @@ class RPGMakerImageManager(QWidget):
self.next_button = QPushButton("Next →") self.next_button = QPushButton("Next →")
self.next_button.clicked.connect(lambda: self._change_page(1)) self.next_button.clicked.connect(lambda: self._change_page(1))
page_row.addWidget(self.next_button) page_row.addWidget(self.next_button)
root.addLayout(page_row) nav_width = max(self.previous_button.sizeHint().width(), self.next_button.sizeHint().width())
self.previous_button.setFixedWidth(nav_width)
action_row = QHBoxLayout() self.next_button.setFixedWidth(nav_width)
self.decrypt_selected_button = QPushButton("Decrypt selected") common_height = max(
self.decrypt_selected_button.clicked.connect(self._decrypt_checked) 40,
action_row.addWidget(self.decrypt_selected_button) *(button.sizeHint().height() for button in (*action_buttons, self.previous_button, self.next_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) for button in (*action_buttons, self.previous_button, self.next_button):
action_row.addWidget(self.encrypt_button) button.setFixedHeight(common_height)
self.prepare_button = QPushButton("Encrypt + patch selected") controls_row.addLayout(page_row)
self.prepare_button.setStyleSheet( root.addLayout(controls_row)
"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 = QLabel("Scanning image folders…")
self.status_label.setWordWrap(True) self.status_label.setWordWrap(True)
@ -314,6 +372,15 @@ class RPGMakerImageManager(QWidget):
return return
self.game_root = root self.game_root = root
self.settings.setValue("workflow/last_game_folder", str(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.selected_ids.clear()
self._load_key() self._load_key()
self._start_scan() self._start_scan()
@ -373,10 +440,10 @@ class RPGMakerImageManager(QWidget):
self.folder_combo.setCurrentIndex(max(0, index)) self.folder_combo.setCurrentIndex(max(0, index))
self.folder_combo.blockSignals(False) self.folder_combo.blockSignals(False)
encrypted = sum(asset.has_encrypted for asset in assets) 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( self.status_label.setText(
f"Found {len(assets):,} images · {encrypted:,} encrypted · " 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() self._apply_filters()
@ -397,11 +464,7 @@ class RPGMakerImageManager(QWidget):
continue continue
if folder and asset.relative_png.parent.as_posix() != folder: if folder and asset.relative_png.parent.as_posix() != folder:
continue continue
if state == "encrypted" and not asset.has_encrypted: if state == "editable" and not asset.has_plain:
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 continue
filtered.append(asset) filtered.append(asset)
self.filtered_assets = filtered self.filtered_assets = filtered
@ -427,8 +490,8 @@ class RPGMakerImageManager(QWidget):
label = asset.relative_png.name label = asset.relative_png.name
item = QListWidgetItem(placeholder_icon, label) item = QListWidgetItem(placeholder_icon, label)
item.setData(_ASSET_ID_ROLE, asset.asset_id) item.setData(_ASSET_ID_ROLE, asset.asset_id)
kind = "encrypted + PNG" if asset.has_encrypted and asset.has_plain else ( kind = "encrypted + editable" if asset.has_encrypted and asset.has_plain else (
"encrypted" if asset.has_encrypted else "PNG" "encrypted" if asset.has_encrypted else "runtime PNG"
) )
item.setToolTip(f"{asset.asset_id}\n{kind}") item.setToolTip(f"{asset.asset_id}\n{kind}")
self.image_list.addItem(item) self.image_list.addItem(item)
@ -485,23 +548,11 @@ class RPGMakerImageManager(QWidget):
self.selected_ids.update(selected_on_page) self.selected_ids.update(selected_on_page)
self._update_selection_status() 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: def _update_selection_status(self) -> None:
self.status_label.setText( 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: 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}") self.preview_label.setText(f"Preview unavailable\n{exc}")
details = [asset.asset_id] details = [asset.asset_id]
if asset.has_encrypted: if asset.has_encrypted:
details.append(f"Encrypted: {asset.encrypted_path.name}") details.append(f"Runtime encrypted: {asset.encrypted_path}")
details.append("Editable PNG: ready" if asset.has_plain else "Editable PNG: not decrypted") 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)) 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]: 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] 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: def _ensure_key(self) -> bool:
if self.key is not None: if self.key is not None:
return True return True
@ -546,7 +621,9 @@ class RPGMakerImageManager(QWidget):
assets = [asset for asset in self._selected_assets() if asset.has_encrypted] assets = [asset for asset in self._selected_assets() if asset.has_encrypted]
if not assets: if not assets:
QMessageBox.information( QMessageBox.information(
self, "Nothing Selected", "Select one or more encrypted images first." self,
"Nothing Selected",
"Select one or more encrypted images first.",
) )
return return
if self._ensure_key(): if self._ensure_key():
@ -557,14 +634,45 @@ class RPGMakerImageManager(QWidget):
if not assets: if not assets:
QMessageBox.information(self, "Nothing to Decrypt", "All encrypted images already have PNG copies.") QMessageBox.information(self, "Nothing to Decrypt", "All encrypted images already have PNG copies.")
return 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) self._start_action("decrypt", assets)
def _prepare_checked(self) -> None: def _remove_highlighted(self) -> None:
assets = self._selected_assets() assets = [asset for asset in self._selected_assets() if asset.has_plain]
if not assets: if not assets:
QMessageBox.information( 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 return
if any(asset.has_encrypted for asset in assets) and not self._ensure_key(): if any(asset.has_encrypted for asset in assets) and not self._ensure_key():
@ -572,8 +680,9 @@ class RPGMakerImageManager(QWidget):
answer = QMessageBox.question( answer = QMessageBox.question(
self, self,
"Prepare Images for Patch", "Prepare Images for Patch",
f"Prepare exactly {len(assets):,} selected image(s)?\n\n" f"Encrypt and add all {len(assets):,} editable image(s) to the patch?\n\n"
"Encrypted images will be rebuilt from their editable PNG. Their original encrypted " "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 " "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.",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
@ -582,29 +691,6 @@ class RPGMakerImageManager(QWidget):
if answer == QMessageBox.Yes: if answer == QMessageBox.Yes:
self._start_action("prepare", assets) 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: def _start_action(self, action: str, assets: list[ImageAsset]) -> None:
self._set_actions_enabled(False) self._set_actions_enabled(False)
worker = _ImageActionWorker(action, self.game_root, assets, self.key) worker = _ImageActionWorker(action, self.game_root, assets, self.key)
@ -616,9 +702,22 @@ class RPGMakerImageManager(QWidget):
def _action_done(self, action: str, result) -> None: def _action_done(self, action: str, result) -> None:
if action == "decrypt": if action == "decrypt":
summary = f"Decrypted {result.completed:,} image(s); skipped {result.skipped:,}." workspace = editable_workspace_root(self.game_root)
elif action == "encrypt": summary = (
summary = f"Encrypted {result.completed:,} image(s)." 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: else:
summary = ( summary = (
f"Prepared {result.completed:,} image(s) for the patch and updated " 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: def _set_actions_enabled(self, enabled: bool) -> None:
for button in ( for button in (
self.open_workspace_button,
self.decrypt_selected_button, self.decrypt_selected_button,
self.decrypt_all_button, self.decrypt_all_button,
self.encrypt_button, self.remove_button,
self.prepare_button, self.prepare_button,
): ):
button.setEnabled(enabled) button.setEnabled(enabled)
@ -658,7 +758,7 @@ class RPGMakerImageManager(QWidget):
action_running = self._action_worker is not None and self._action_worker.isRunning() action_running = self._action_worker is not None and self._action_worker.isRunning()
if action_running: if action_running:
QMessageBox.information( 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() event.ignore()
return return

View file

@ -2,15 +2,16 @@ import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PIL import Image from PIL import Image
from PyQt5.QtCore import QItemSelectionModel, QSettings, Qt from PyQt5.QtCore import QSettings, Qt
from PyQt5.QtTest import QTest 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): class RPGMakerImageManagerSelectionTests(unittest.TestCase):
@ -34,7 +35,8 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase):
Image.new("RGBA", (24, 24), (index * 40, 10, 20, 255)).save( Image.new("RGBA", (24, 24), (index * 40, 10, 20, 255)).save(
image_dir / f"image{index}.png" 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 = RPGMakerImageManager(self.game_root, settings=settings)
self.manager.resize(1100, 760) self.manager.resize(1100, 760)
self.manager.show() self.manager.show()
@ -82,17 +84,56 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase):
self.assertEqual(len(image_list.selectedItems()), 4) self.assertEqual(len(image_list.selectedItems()), 4)
self.assertEqual(len(self.manager.selected_ids), 4) self.assertEqual(len(self.manager.selected_ids), 4)
def test_selected_tiles_are_restored_after_filtering(self): def test_plain_click_replaces_selection_and_ctrl_click_adds(self):
selection = self.manager.image_list.selectionModel() self._click(0)
selection.select( self._click(1)
self.manager.image_list.model().index(1, 0), self.assertEqual(len(self.manager.image_list.selectedItems()), 1)
QItemSelectionModel.Select, self.assertEqual(len(self.manager.selected_ids), 1)
)
selection.select( self._click(2, Qt.ControlModifier)
self.manager.image_list.model().index(3, 0), self.assertEqual(len(self.manager.image_list.selectedItems()), 2)
QItemSelectionModel.Select, 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() 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) selected_before = set(self.manager.selected_ids)
self.assertEqual(len(selected_before), 2) self.assertEqual(len(selected_before), 2)
@ -102,7 +143,74 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase):
self.app.processEvents() self.app.processEvents()
self.assertEqual(self.manager.selected_ids, selected_before) 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): def test_thumbnail_batch_keeps_one_stable_tile_per_asset(self):
for worker in list(self.manager._thumbnail_workers): for worker in list(self.manager._thumbnail_workers):
@ -121,6 +229,9 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase):
class RPGMakerImageManagerNavigationTests(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): def test_image_manager_is_a_dedicated_sidebar_page(self):
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
main_source = (root / "gui" / "main.py").read_text(encoding="utf-8") main_source = (root / "gui" / "main.py").read_text(encoding="utf-8")

View file

@ -7,12 +7,16 @@ from PIL import Image
from util.rpgmaker_images import ( from util.rpgmaker_images import (
add_patch_exceptions, add_patch_exceptions,
clean_runtime_image_duplicates,
decrypt_assets, decrypt_assets,
decrypt_image_bytes, decrypt_image_bytes,
editable_workspace_root,
encrypt_assets, encrypt_assets,
encrypt_image_bytes, encrypt_image_bytes,
migrate_legacy_editable_workspace,
prepare_assets_for_patch, prepare_assets_for_patch,
read_encryption_key, read_encryption_key,
remove_editable_assets,
scan_image_assets, scan_image_assets,
) )
@ -53,30 +57,56 @@ class RPGMakerImageTests(unittest.TestCase):
assets = scan_image_assets(self.root) assets = scan_image_assets(self.root)
self.assertEqual([asset.asset_id for asset in assets], ["img/pictures/001.png"]) self.assertEqual([asset.asset_id for asset in assets], ["img/pictures/001.png"])
self.assertFalse(assets[0].has_plain) 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.completed, 1)
self.assertEqual(result.errors, []) self.assertEqual(result.errors, [])
self.assertEqual(assets[0].plain_path.read_bytes(), png_bytes("red")) self.assertEqual(assets[0].plain_path.read_bytes(), png_bytes("red"))
self.assertEqual(encrypted.read_bytes(), original) 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): def test_existing_editable_png_is_not_overwritten_by_decrypt_all(self):
self._encrypted_asset() 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") translated = png_bytes("blue")
plain.write_bytes(translated) 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.completed, 0)
self.assertEqual(result.skipped, 1) self.assertEqual(result.skipped, 1)
self.assertEqual(plain.read_bytes(), translated) 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): def test_prepare_reencrypts_selected_image_backs_up_original_and_updates_ignore(self):
encrypted = self._encrypted_asset() encrypted = self._encrypted_asset()
original = encrypted.read_bytes() original = encrypted.read_bytes()
asset = scan_image_assets(self.root)[0] asset = scan_image_assets(self.root)[0]
decrypt_assets([asset], KEY) decrypt_assets([asset], KEY, game_root=self.root)
translated = png_bytes("blue") translated = png_bytes("blue")
asset.plain_path.write_bytes(translated) asset.plain_path.write_bytes(translated)
self.root.joinpath(".gitignore").write_text("*.*\n", encoding="utf-8") 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.asset_id, "img/pictures/title.png")
self.assertEqual(asset.encrypted_path, encrypted) 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(result.completed, 1)
self.assertEqual(asset.plain_path.read_bytes(), png_bytes("green")) self.assertEqual(asset.plain_path.read_bytes(), png_bytes("green"))
def test_encrypt_only_rebuilds_runtime_file_without_changing_gitignore(self): def test_encrypt_only_rebuilds_runtime_file_without_changing_gitignore(self):
encrypted = self._encrypted_asset() encrypted = self._encrypted_asset()
asset = scan_image_assets(self.root)[0] asset = scan_image_assets(self.root)[0]
decrypt_assets([asset], KEY) decrypt_assets([asset], KEY, game_root=self.root)
translated = png_bytes("black") translated = png_bytes("black")
asset.plain_path.write_bytes(translated) asset.plain_path.write_bytes(translated)
ignore_before = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
result = encrypt_assets(self.root, [asset], KEY) result = encrypt_assets(self.root, [asset], KEY)
self.assertEqual(result.completed, 1) self.assertEqual(result.completed, 1)
self.assertEqual(result.errors, []) self.assertEqual(result.errors, [])
self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated) 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): def test_nested_gitignore_receives_relative_exact_exception(self):
target = self.content / "img" / "pictures" / "menu image.png" 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") ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertIn("!/www/img/pictures/menu.png", ignore) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -25,6 +25,8 @@ RPGMV_HEADER = bytes(
) )
KEY_LEN = 16 KEY_LEN = 16
ENCRYPTED_IMAGE_EXTENSIONS = (".rpgmvp", ".png_") ENCRYPTED_IMAGE_EXTENSIONS = (".rpgmvp", ".png_")
EDITABLE_WORKSPACE_NAME = "images"
LEGACY_EDITABLE_WORKSPACE_NAME = "DazedTL_Images"
@dataclass(frozen=True) @dataclass(frozen=True)
@ -35,6 +37,7 @@ class ImageAsset:
relative_png: Path relative_png: Path
plain_path: Path plain_path: Path
encrypted_path: Path | None = None encrypted_path: Path | None = None
runtime_plain_path: Path | None = None
@property @property
def has_plain(self) -> bool: def has_plain(self) -> bool:
@ -44,6 +47,10 @@ class ImageAsset:
def has_encrypted(self) -> bool: def has_encrypted(self) -> bool:
return self.encrypted_path is not None and self.encrypted_path.is_file() 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 @dataclass
class ImageActionResult: class ImageActionResult:
@ -95,6 +102,16 @@ def read_encryption_key(game_root: str | Path) -> bytes:
return bytes.fromhex(normalized) 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: def _logical_png(path: Path) -> Path:
lower = path.name.casefold() lower = path.name.casefold()
if lower.endswith(".rpgmvp"): if lower.endswith(".rpgmvp"):
@ -107,7 +124,8 @@ def _logical_png(path: Path) -> Path:
def scan_image_assets(game_root: str | Path) -> list[ImageAsset]: def scan_image_assets(game_root: str | Path) -> list[ImageAsset]:
"""Scan ``img/`` and combine matching PNG/encrypted files into records.""" """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" image_root = content_root / "img"
by_id: dict[str, dict[str, Path]] = {} by_id: dict[str, dict[str, Path]] = {}
for path in image_root.rglob("*"): for path in image_root.rglob("*"):
@ -119,18 +137,25 @@ def scan_image_assets(game_root: str | Path) -> list[ImageAsset]:
logical = _logical_png(path) logical = _logical_png(path)
relative = logical.relative_to(content_root) relative = logical.relative_to(content_root)
asset_id = relative.as_posix() 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): if name.endswith(ENCRYPTED_IMAGE_EXTENSIONS):
entry["encrypted"] = path entry["encrypted"] = path
else: else:
entry["plain"] = path entry["runtime_plain"] = path
assets = [ assets = [
ImageAsset( ImageAsset(
asset_id=asset_id, asset_id=asset_id,
relative_png=entry["relative"], relative_png=entry["relative"],
plain_path=entry["plain"], plain_path=editable_workspace_root(root) / entry["workspace_relative"],
encrypted_path=entry.get("encrypted"), encrypted_path=entry.get("encrypted"),
runtime_plain_path=entry.get("runtime_plain"),
) )
for asset_id, entry in by_id.items() 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: if asset.has_plain:
return asset.plain_path.read_bytes() return asset.plain_path.read_bytes()
if asset.has_runtime_plain:
return asset.runtime_plain_path.read_bytes()
if not asset.has_encrypted: if not asset.has_encrypted:
raise FileNotFoundError(asset.plain_path) raise FileNotFoundError(asset.runtime_plain_path or asset.plain_path)
if key is None: if key is None:
raise ValueError("The encryption key is required to preview this image.") raise ValueError("The encryption key is required to preview this image.")
return decrypt_image_bytes(asset.encrypted_path.read_bytes(), key) return decrypt_image_bytes(asset.encrypted_path.read_bytes(), key)
@ -200,16 +227,127 @@ def _atomic_write(path: Path, data: bytes) -> None:
raise 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( def decrypt_assets(
assets: Iterable[ImageAsset], assets: Iterable[ImageAsset],
key: bytes, key: bytes,
*, *,
game_root: str | Path | None = None,
overwrite: bool = False, overwrite: bool = False,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
) -> ImageActionResult: ) -> ImageActionResult:
"""Create editable PNG copies beside encrypted images.""" """Decrypt images into the game-local editable image workspace."""
result = ImageActionResult() result = ImageActionResult()
assets = list(assets)
if game_root is not None:
ensure_editable_workspace(game_root)
for asset in assets: for asset in assets:
if not asset.has_encrypted: if not asset.has_encrypted:
result.skipped += 1 result.skipped += 1
@ -218,6 +356,16 @@ def decrypt_assets(
result.skipped += 1 result.skipped += 1
continue continue
try: 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: if progress:
progress(f"Decrypting {asset.asset_id}") progress(f"Decrypting {asset.asset_id}")
raw = decrypt_image_bytes(asset.encrypted_path.read_bytes(), key) raw = decrypt_image_bytes(asset.encrypted_path.read_bytes(), key)
@ -225,6 +373,11 @@ def decrypt_assets(
result.completed += 1 result.completed += 1
except Exception as exc: # continue a large batch and report all failures except Exception as exc: # continue a large batch and report all failures
result.errors.append(f"{asset.asset_id}: {exc}") 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 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 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: def _escape_gitignore_component(value: str) -> str:
if "\n" in value or "\r" in value: if "\n" in value or "\r" in value:
raise ValueError("Image paths containing line breaks cannot be added to .gitignore.") 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) encrypted = encrypt_image_bytes(asset.plain_path.read_bytes(), key)
_atomic_write(asset.encrypted_path, encrypted) _atomic_write(asset.encrypted_path, encrypted)
targets.append(asset.encrypted_path) targets.append(asset.encrypted_path)
elif asset.has_plain: elif asset.has_runtime_plain:
targets.append(asset.plain_path) # 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: else:
raise FileNotFoundError("image file no longer exists") raise FileNotFoundError("image file no longer exists")
result.completed += 1 result.completed += 1
@ -357,6 +558,10 @@ def prepare_assets_for_patch(
result.gitignore_files = add_patch_exceptions(root, targets) result.gitignore_files = add_patch_exceptions(root, targets)
except Exception as exc: except Exception as exc:
result.errors.append(f".gitignore: {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 return result
@ -367,7 +572,7 @@ def encrypt_assets(
*, *,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
) -> ImageActionResult: ) -> 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() root = Path(game_root).expanduser().resolve()
result = ImageActionResult() result = ImageActionResult()
@ -390,4 +595,8 @@ def encrypt_assets(
result.completed += 1 result.completed += 1
except Exception as exc: except Exception as exc:
result.errors.append(f"{asset.asset_id}: {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 return result