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:
parent
35d5d5968a
commit
3d82203496
4 changed files with 702 additions and 143 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue