├── run.bat ├── i18n ├── it.py ├── zh_cn.py └── en.py ├── run_no_console.vbs ├── gui ├── assets │ ├── icon.png │ ├── splash.png │ └── icons │ │ ├── folder.svg │ │ ├── trash-can.svg │ │ ├── close.svg │ │ ├── folder-outline.svg │ │ ├── arrow-right-drop-circle.svg │ │ ├── trash-can-outline.svg │ │ ├── file-multiple.svg │ │ ├── open-in-new.svg │ │ ├── content-save.svg │ │ ├── file-multiple-outline.svg │ │ ├── file-image.svg │ │ ├── file-compare.svg │ │ ├── refresh.svg │ │ ├── content-save-outline.svg │ │ ├── file-image-outline.svg │ │ ├── checkbox-multiple-marked-outline.svg │ │ ├── package-up.svg │ │ ├── magnify.svg │ │ ├── dots-circle.svg │ │ └── package-variant.svg ├── components │ ├── error_dialog.py │ ├── button.py │ ├── spinner.py │ ├── csv_viewer │ │ ├── model.py │ │ └── table.py │ ├── dialog_window.py │ ├── progress_bar │ │ ├── ProgressBar.py │ │ └── ProgressBar_ui.py │ ├── search_bar.py │ ├── switch.py │ ├── update_dialog.py │ ├── preview.py │ └── tree_view.py ├── util │ ├── svg_icon.py │ ├── mouse.py │ ├── thread_process.py │ └── threads.py ├── theme │ ├── theme.py │ └── styles.py ├── tabs │ ├── settings │ │ ├── default_options.py │ │ ├── main.py │ │ └── row.py │ ├── credits │ │ └── main.py │ ├── decrypt │ │ └── main.py │ └── file_tree │ │ └── main.py ├── app.py ├── window.py └── pugin_toolbar.py ├── requirements.txt ├── .gitignore ├── app ├── util │ ├── exceptions.py │ ├── types.py │ ├── thread.py │ ├── misc.py │ ├── file.py │ ├── bytearray.py │ ├── pack_read.py │ └── tree.py ├── hooks │ └── before_write │ │ ├── webp.py │ │ └── sct.py ├── constants.py ├── strings.py ├── settings.py ├── full_decrypt.py ├── extract.py ├── update.py ├── pack.py └── load_hooks.py ├── main.py └── README.md /run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | py main.py 3 | pause -------------------------------------------------------------------------------- /i18n/it.py: -------------------------------------------------------------------------------- 1 | from .en import EN 2 | 3 | class IT(EN): 4 | pass -------------------------------------------------------------------------------- /run_no_console.vbs: -------------------------------------------------------------------------------- 1 | CreateObject("WScript.Shell").Run "py main.py", 0, false -------------------------------------------------------------------------------- /gui/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CeciliaBot/EpicSevenAssetRipper/HEAD/gui/assets/icon.png -------------------------------------------------------------------------------- /gui/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CeciliaBot/EpicSevenAssetRipper/HEAD/gui/assets/splash.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt6==6.9.1 2 | PyQt6-Qt6==6.9.1 3 | PyQt6-sip==13.10.2 4 | pathvalidate==3.1.0 5 | typing_extensions==4.12.2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | 3 | .* 4 | !/.gitignore 5 | 6 | .idea/ 7 | .git/ 8 | 9 | app/hooks/after_write/** 10 | app/hooks/before_write/** 11 | data.pack/ 12 | crash-report.txt 13 | settings.ini -------------------------------------------------------------------------------- /gui/assets/icons/folder.svg: -------------------------------------------------------------------------------- 1 | folder -------------------------------------------------------------------------------- /app/util/exceptions.py: -------------------------------------------------------------------------------- 1 | class OperationAbortedByUser(Exception): 2 | def __init__(self, msg=''): 3 | self.msg=msg 4 | 5 | class NotDataPackZip(Exception): 6 | def __init__(self, msg): 7 | self.msg=msg 8 | -------------------------------------------------------------------------------- /gui/assets/icons/trash-can.svg: -------------------------------------------------------------------------------- 1 | trash-can -------------------------------------------------------------------------------- /gui/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | close -------------------------------------------------------------------------------- /gui/assets/icons/folder-outline.svg: -------------------------------------------------------------------------------- 1 | folder-outline -------------------------------------------------------------------------------- /gui/assets/icons/arrow-right-drop-circle.svg: -------------------------------------------------------------------------------- 1 | arrow-right-drop-circle -------------------------------------------------------------------------------- /gui/assets/icons/trash-can-outline.svg: -------------------------------------------------------------------------------- 1 | trash-can-outline -------------------------------------------------------------------------------- /gui/assets/icons/file-multiple.svg: -------------------------------------------------------------------------------- 1 | file-multiple -------------------------------------------------------------------------------- /gui/assets/icons/open-in-new.svg: -------------------------------------------------------------------------------- 1 | open-in-new -------------------------------------------------------------------------------- /gui/assets/icons/content-save.svg: -------------------------------------------------------------------------------- 1 | content-save -------------------------------------------------------------------------------- /gui/assets/icons/file-multiple-outline.svg: -------------------------------------------------------------------------------- 1 | file-multiple-outline -------------------------------------------------------------------------------- /app/hooks/before_write/webp.py: -------------------------------------------------------------------------------- 1 | _ADDON_NAME_ = 'WEBP Loop' 2 | 3 | from ...extract import FileDescriptor 4 | 5 | def main( file: FileDescriptor ): 6 | 7 | data = file.bytes 8 | anmf = data.find( b'\x41\x4E\x4D\x46' ) # ANMF 9 | 10 | if anmf > -1: 11 | data[anmf - 2] = 0 -------------------------------------------------------------------------------- /gui/assets/icons/file-image.svg: -------------------------------------------------------------------------------- 1 | file-image -------------------------------------------------------------------------------- /gui/assets/icons/file-compare.svg: -------------------------------------------------------------------------------- 1 | file-compare -------------------------------------------------------------------------------- /gui/assets/icons/refresh.svg: -------------------------------------------------------------------------------- 1 | refresh -------------------------------------------------------------------------------- /gui/assets/icons/content-save-outline.svg: -------------------------------------------------------------------------------- 1 | content-save-outline -------------------------------------------------------------------------------- /gui/assets/icons/file-image-outline.svg: -------------------------------------------------------------------------------- 1 | file-image-outline -------------------------------------------------------------------------------- /gui/assets/icons/checkbox-multiple-marked-outline.svg: -------------------------------------------------------------------------------- 1 | checkbox-multiple-marked-outline -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | try: 4 | from gui.app import CreateApp 5 | 6 | CreateApp() 7 | 8 | except Exception as e: 9 | exception = traceback.format_exc() 10 | print(exception) 11 | with open('crash-report.txt', 'w') as f: 12 | f.write(exception) 13 | # traceback.TracebackException.from_exception(e).print(file=f) -------------------------------------------------------------------------------- /gui/assets/icons/package-up.svg: -------------------------------------------------------------------------------- 1 | package-up -------------------------------------------------------------------------------- /gui/assets/icons/magnify.svg: -------------------------------------------------------------------------------- 1 | magnify -------------------------------------------------------------------------------- /gui/components/error_dialog.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QMessageBox 2 | from .button import Button 3 | 4 | def ErrorWindow(title, message, parent=None): 5 | msg = QMessageBox(parent) 6 | msg.setIcon(QMessageBox.Icon.Warning) 7 | button = Button(text='Ok', pointer=True, minimum_width=150) 8 | msg.setDefaultButton(button) 9 | msg.setText(message) 10 | msg.setFixedWidth(550) 11 | # msg.setInformativeText('More information') 12 | msg.setWindowTitle(title) 13 | msg.exec() -------------------------------------------------------------------------------- /gui/components/button.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QWidget, QPushButton 2 | from PyQt6.QtCore import Qt 3 | 4 | class Button(QPushButton): 5 | def __init__(self, parent: QWidget | None = None, text = '', pointer: bool = False, minimum_width: int = None, disabled = False): 6 | super().__init__(parent) 7 | self.setText(text) 8 | if pointer: self.setCursor(Qt.CursorShape.PointingHandCursor) 9 | if minimum_width: self.setMinimumWidth(minimum_width) 10 | self.setDisabled(disabled) -------------------------------------------------------------------------------- /gui/assets/icons/dots-circle.svg: -------------------------------------------------------------------------------- 1 | dots-circle -------------------------------------------------------------------------------- /app/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | VERSION='2.02' 5 | 6 | KEY = b'\x21\x0c\xed\x10\xd8\x81\xd7\xa3\xfa\x9b\xc9\x7a\xd3\xae\xeb\x6d\x98\x89\x31\x34\x2d\x39\x1e\x1f\xe1\xc4\x7c\xdd\x2d\xef\x26\x37\x7a\xfa\xbf\xd2\xd9\x60\x79\xf1\xca\x99\xd0\x32\xf7\xd8\x4d\x4e\xf6\xce\x45\xda\x0c\x67\x99\x09\xe6\x89\x75\x69\x5f\xd9\x12\xa2\x3e\x77\x74\x3c\xf5\xbe\x2e\x57\x64\x05\x1a\x71\x96\x62\x23\x25\x80\x63\xfc\xe7\xc6\xd4\xe7\xca\x76\x7d\x70\x3c\xcb\xe2\x31\xc5\xed\x03\x8d\xcc\xad\x1a\x75\x53\x4a\x61\x27\xb8\x30\xca\xeb\x73\xb4\xc6\xd6\xdb\xda\x00\x88\xe2\x11\x21\xef\xd5\xf3\x8a\x02\x1f\x06' 7 | KEY_LEN = len(KEY) 8 | TEMP_FOLDER = os.path.join(tempfile.gettempdir(), 'EpicSevenAssetRipperTemp') 9 | IMG_FORMATS = ['png', 'jpeg', 'jpg', 'webp'] -------------------------------------------------------------------------------- /gui/util/svg_icon.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor 2 | import os 3 | 4 | def _svg_to_icon(img: QPixmap, color='black'): 5 | qp = QPainter(img) 6 | qp.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) 7 | qp.fillRect( img.rect(), QColor(color) ) 8 | qp.end() 9 | return QIcon(img) 10 | 11 | def QPixmap_from_svg(icon_name: str): 12 | svg_filepath = os.path.join('gui', 'assets', 'icons', icon_name) 13 | return QPixmap(svg_filepath) 14 | 15 | # https://stackoverflow.com/questions/15123544/change-the-color-of-an-svg-in-qt 16 | def QIcon_from_svg(icon_name: str, color='black'): 17 | return _svg_to_icon( 18 | QPixmap_from_svg(icon_name), 19 | color) -------------------------------------------------------------------------------- /gui/assets/icons/package-variant.svg: -------------------------------------------------------------------------------- 1 | package-variant -------------------------------------------------------------------------------- /app/util/types.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, Optional, List 2 | 3 | try: 4 | from typing import Self, Literal 5 | except ImportError: 6 | # Python <3.11 fallback definitions 7 | try: 8 | from typing_extensions import Self, Literal 9 | except ImportError: 10 | Self = None # or just object 11 | Literal = None 12 | 13 | 14 | class FileType(TypedDict): 15 | type: Literal['file'] 16 | name: str 17 | full_path: str 18 | format: str 19 | offset: int 20 | size: int 21 | extra_bytes: Optional[List[int]] 22 | 23 | class FolderType(TypedDict): 24 | type: Literal['folder'] 25 | name: str 26 | size: int 27 | files: int 28 | children: List[Self | FileType] 29 | 30 | FileTreeType = List[FolderType | FileType] -------------------------------------------------------------------------------- /gui/util/mouse.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | try: 4 | if os.name == 'nt': 5 | from ctypes import windll 6 | # check if the buttons are inverted: 0x01 = left ----- 0x02 = right mouse button 7 | btn_code = 0x01 if windll.user32.GetSystemMetrics(23) == 0 else 0x02 8 | def mouse_pressed(): 9 | ''' 10 | Check if the left button is pressed 11 | ''' 12 | return windll.user32.GetKeyState(btn_code) not in [0, 1] 13 | # ^ this returns either 0 or 1 when button is not being held down 14 | else: 15 | import mouse 16 | def mouse_pressed(): 17 | return mouse.is_pressed() 18 | except: # it's not important if the module wasn't loaded, drag and drop events won't be set 19 | mouse_pressed = None -------------------------------------------------------------------------------- /gui/theme/theme.py: -------------------------------------------------------------------------------- 1 | from app import settings 2 | from .styles import DarkTheme, LightTheme, RoundedLightTheme, RoundedDarkTheme, AccentColorDarkTheme, apply 3 | 4 | USE_THEME = DarkTheme 5 | 6 | THEMES = [ 7 | 'light', 8 | 'light-r', 9 | 'dark', 10 | 'dark-r', 11 | 'dark-accent' 12 | ] 13 | 14 | def get_theme_scheme(value: str): 15 | match(value): 16 | case 'light': 17 | return LightTheme 18 | case 'light-r': 19 | return RoundedLightTheme 20 | case 'dark': 21 | return DarkTheme 22 | case 'dark-r': 23 | return RoundedDarkTheme 24 | case 'dark-accent': 25 | return AccentColorDarkTheme 26 | case _: 27 | return RoundedDarkTheme 28 | 29 | def use_theme(app, val=settings.getTheme()): 30 | global USE_THEME 31 | USE_THEME = get_theme_scheme(val) 32 | 33 | app.ThemeColors = USE_THEME 34 | app.setStyleSheet(apply(USE_THEME)) -------------------------------------------------------------------------------- /app/strings.py: -------------------------------------------------------------------------------- 1 | from . import settings 2 | 3 | LOCALES = [ 4 | 'en', 5 | 'it', 6 | 'zh_cn' 7 | ] 8 | 9 | STRINGS = {} 10 | LOCALE = 'en' 11 | 12 | def setLocale(locale: str = LOCALE): 13 | global LOCALE, STRINGS 14 | try: 15 | match locale: 16 | case 'it': 17 | from i18n.it import IT 18 | STRINGS = IT 19 | case 'en': 20 | from i18n.en import EN 21 | STRINGS = EN 22 | case 'zh_cn': 23 | from i18n.zh_cn import ZH_CN 24 | STRINGS = ZH_CN 25 | case _: 26 | raise Exception() 27 | LOCALE = locale 28 | except Exception: # Fallback to english 29 | try: 30 | from i18n.en import EN # Fallback to en 31 | STRINGS = EN 32 | settings.setLanguage('en') 33 | except Exception as e: 34 | print(e) 35 | 36 | @staticmethod 37 | def translate(key: str, *args, **kwargs): 38 | try: 39 | return getattr(STRINGS, key) 40 | except Exception: 41 | return key -------------------------------------------------------------------------------- /app/util/thread.py: -------------------------------------------------------------------------------- 1 | def nothing(*args): 2 | pass 3 | 4 | class ThreadData: 5 | _abort: bool = False 6 | 7 | # listeners / connect 8 | onstart = nothing 9 | onprogress = nothing 10 | onfinished = nothing 11 | onresult = nothing 12 | onerror = nothing 13 | 14 | # set other stuff here... 15 | 16 | def __init__(self): 17 | pass 18 | 19 | @staticmethod 20 | def remove_emits(cls) -> None: 21 | cls.progress = nothing 22 | cls.finished = nothing 23 | cls.result = nothing 24 | cls.error = nothing 25 | 26 | def is_stopping(self) -> bool: 27 | return self._abort 28 | 29 | def stop(self) -> None: 30 | self._abort = True 31 | 32 | def start(self, *args): 33 | self.onstart(*args) 34 | 35 | def progress(self, *args): 36 | self.onprogress(*args) 37 | 38 | def finished(self, *args): 39 | self.onprogress(*args) 40 | 41 | def result(self, *args): 42 | self.onprogress(*args) 43 | 44 | def error(self, *args): 45 | self.onprogress(*args) -------------------------------------------------------------------------------- /app/util/misc.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from dataclasses import dataclass 3 | from threading import Timer 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from .bytearray import ByteArray 8 | from .types import FileType 9 | from .thread import ThreadData 10 | from ..pack import DataPack 11 | else: 12 | ByteArray = None 13 | FileType = None 14 | ThreadData = None 15 | DataPack = None 16 | 17 | 18 | @dataclass(init=False, eq=False, match_args=False) 19 | class FileDescriptor: 20 | path: str = None 21 | bytes: ByteArray = None 22 | tree_file: FileType = None 23 | pack: DataPack = None 24 | thread: ThreadData = None 25 | written: bool = False 26 | 27 | def __init__(self, data: ByteArray, path: str, tree_file: FileType, pack: DataPack, thread: ThreadData): 28 | self.path = path 29 | self.bytes = data 30 | self.tree_file = tree_file 31 | self.pack = pack 32 | self.thread = thread 33 | 34 | 35 | def debounce(timeout: float): 36 | def decorator(func): 37 | @functools.wraps(func) 38 | def wrapper(*args, **kwargs): 39 | wrapper.func.cancel() 40 | wrapper.func = Timer(timeout, func, args, kwargs) 41 | wrapper.func.start() 42 | 43 | wrapper.func = Timer(timeout, lambda: None) 44 | return wrapper 45 | return decorator -------------------------------------------------------------------------------- /gui/components/spinner.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QWidget 2 | from PyQt6.QtGui import QPixmap, QPainter, QColor 3 | from PyQt6.QtCore import Qt, QPropertyAnimation, pyqtProperty 4 | 5 | from gui.util.svg_icon import QPixmap_from_svg 6 | 7 | 8 | class Spinner(QWidget): 9 | def __init__(self, parent=None): 10 | super().__init__(parent) 11 | # self.setAlignment(Qt.AlignmentFlag.AlignCenter) 12 | self.pixmap = QPixmap_from_svg('dots-circle').scaled(40, 40, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) 13 | 14 | self.setFixedSize(40, 40) 15 | self._angle = 0 16 | 17 | self.animation = QPropertyAnimation(self, b"angle") 18 | self.animation.setStartValue(0) 19 | self.animation.setEndValue(360) 20 | self.animation.setLoopCount(-1) 21 | self.animation.setDuration(2000) 22 | 23 | def start(self): 24 | self.animation.start() 25 | 26 | def stop(self): 27 | self.animation.stop() 28 | 29 | @pyqtProperty(int) 30 | def angle(self): 31 | return self._angle 32 | 33 | @angle.setter 34 | def angle(self, value): 35 | self._angle = value 36 | self.update() 37 | 38 | def paintEvent(self, ev=None): 39 | painter = QPainter(self) 40 | 41 | painter.translate(20, 20) 42 | painter.rotate(self._angle) 43 | painter.translate(-20, -20) 44 | painter.drawPixmap(0, 0, self.pixmap) 45 | painter.end() -------------------------------------------------------------------------------- /app/util/file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mmap as memory_map 3 | import platform 4 | from pathlib import Path 5 | import math 6 | 7 | def convert_size(size_bytes): 8 | if size_bytes == 0: 9 | return "0B" 10 | size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") 11 | i = int(math.floor(math.log(size_bytes, 1024))) 12 | p = math.pow(1024, i) 13 | s = round(size_bytes / p, 2) 14 | return "%s %s" % (s, size_name[i]) 15 | 16 | def path_exists(path): 17 | return Path(path).exists() 18 | 19 | def fopen(path, write = False): # Cross platform os.open ( Used with fmap() ) 20 | access = None 21 | 22 | if platform.system() == 'Windows': 23 | if write: 24 | access = os.O_CREAT | os.O_RDWR | os.O_BINARY 25 | else: 26 | access = os.O_RDONLY | os.O_BINARY 27 | else: 28 | if write: 29 | access = os.O_CREAT | os.O_RDWR 30 | else: 31 | access = os.O_RDONLY 32 | 33 | return os.open(path, access) 34 | 35 | def mmap(file, length = 0, offset = 0, write = False): # Cross platform File Map 36 | access = None 37 | 38 | if platform.system() == 'Windows': 39 | if write: 40 | access = memory_map.ACCESS_WRITE 41 | else: 42 | access = memory_map.ACCESS_READ 43 | else: 44 | if write: 45 | access = memory_map.PROT_WRITE 46 | else: 47 | access = memory_map.PROT_READ 48 | 49 | return memory_map.mmap(fileno = file, length = length, offset = offset, access = access) -------------------------------------------------------------------------------- /gui/tabs/settings/default_options.py: -------------------------------------------------------------------------------- 1 | from app.strings import translate, LOCALES 2 | from app.settings import ( 3 | getLanguage, setLanguage, 4 | getStopOnClose, setStopOnClose, 5 | getAutosaveFileTree, setAutosaveFileTree, 6 | getAutomaticFilePreview, setAutomaticFilePreview, 7 | getTheme, setTheme, 8 | getDefaultFilePromptPath, setDefaultFilePromptPath) 9 | from gui.theme.theme import THEMES 10 | 11 | def getLocaleIndex(v): 12 | try: 13 | return LOCALES.index(v) 14 | except: 15 | return 0 16 | 17 | def getThemeIndex(v): 18 | try: 19 | return THEMES.index(v) 20 | except: 21 | return 0 22 | 23 | 24 | OPTIONS = [ 25 | [translate('language'), translate('set_language_desc'), 'select', getLocaleIndex( getLanguage() ), [ translate(f'lang_{locale}') for locale in LOCALES], lambda i: setLanguage(LOCALES[i])], 26 | [translate('theme'), translate('set_theme_desc'), 'select', getThemeIndex( getTheme() ), THEMES, lambda i: setTheme(THEMES[i])], 27 | [translate('set_stop_on_main_close'), translate('set_stop_on_main_close_desc'), 'checkbox', getStopOnClose(), None, lambda v: setStopOnClose(v != 0)], 28 | [translate('set_automatic_file_preview'), translate('set_automatic_file_preview_desc'), 'checkbox', getAutomaticFilePreview(), None, lambda v: setAutomaticFilePreview(v != 0)], 29 | [translate('set_autosave_tree'), translate('set_autosave_tree_desc'), 'checkbox', getAutosaveFileTree(), None, lambda v: setAutosaveFileTree(v != 0)], 30 | [translate('set_default_file_prompt_path'), translate('set_default_file_prompt_path_desc'), 'path', getDefaultFilePromptPath(), translate('select'), lambda v: setDefaultFilePromptPath(v) if v!=None else 0] 31 | ] -------------------------------------------------------------------------------- /gui/tabs/settings/main.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSizePolicy 2 | from PyQt6.QtCore import Qt 3 | from gui.tabs.settings.row import SettingsRow 4 | from gui.tabs.settings.default_options import OPTIONS 5 | 6 | from app.strings import translate 7 | 8 | class CreateTab(QWidget): 9 | order = 98 10 | def __init__(self, parent: QWidget | QApplication): 11 | super().__init__(parent) 12 | self.name = translate('settings') 13 | 14 | QApplication.instance().setProperty('CreateSetting', self.add_settings_row) 15 | 16 | 17 | self_layout = QHBoxLayout() 18 | self.setLayout(self_layout) 19 | scroll = QScrollArea() 20 | widgetWrapper = QWidget() 21 | scroll.setWidget(widgetWrapper) 22 | self.settings_rows_layout = QVBoxLayout() 23 | widgetWrapper.setLayout(self.settings_rows_layout) 24 | self_layout.addWidget(scroll) #, 0, Qt.AlignmentFlag.AlignHCenter) #, 1, Qt.AlignmentFlag.AlignHCenter) 25 | 26 | scroll.setWidgetResizable(True) 27 | scroll.setMaximumWidth(1000) 28 | # self_layout.setAlignment(scroll, Qt.AlignmentFlag.AlignCenter) 29 | 30 | for row in OPTIONS: 31 | self.add_settings_row(title=row[0], description=row[1], type=row[2], value=row[3], options=row[4], onchanged=row[5]) 32 | 33 | 34 | 35 | def add_settings_row(self, title, description, type, value, onchanged=lambda *arg: 0, options=[]): 36 | widget = SettingsRow(self, title=title, description=description, type=type, value=value, onchanged=onchanged, options=options) 37 | 38 | self.settings_rows_layout.addWidget(widget) 39 | 40 | return widget 41 | -------------------------------------------------------------------------------- /gui/util/thread_process.py: -------------------------------------------------------------------------------- 1 | from .threads import Worker, ThreadPool 2 | from app.util.thread import ThreadData 3 | 4 | class QtThreadedProcess(ThreadData): 5 | def __init__(self, pack, function, *args, **kwargs): 6 | self.pack = pack 7 | self.fn = function 8 | self.args = args 9 | self.kwargs = kwargs 10 | 11 | def set_worker(self): 12 | # Pass the function to execute 13 | self.worker = Worker(self.fn, *self.args, thread=self, **self.kwargs) 14 | self.start = self.worker.signals.start.emit 15 | self.finished = self.worker.signals.finished.emit 16 | self.result = self.worker.signals.result.emit 17 | self.progress = self.worker.signals.progress.emit 18 | self.error = self.worker.signals.error.emit 19 | 20 | def bind_listeners(self): 21 | 22 | self.worker.signals.start.connect( self.onstart ) 23 | self.worker.signals.finished.connect( self.onfinished ) 24 | self.worker.signals.progress.connect( self.onprogress ) 25 | self.worker.signals.result.connect( self.onresult ) 26 | self.worker.signals.error.connect( self.onerror ) 27 | 28 | def remove_listeners(self): 29 | self.worker.signals.start.disconnect( ) 30 | self.worker.signals.finished.disconnect( ) 31 | self.worker.signals.progress.disconnect( ) 32 | self.worker.signals.result.disconnect( ) 33 | self.worker.signals.error.disconnect( ) 34 | 35 | def run(self): 36 | self.set_worker() 37 | self.bind_listeners() 38 | 39 | # Execute 40 | ThreadPool.start(self.worker) 41 | 42 | def setStatus(self, *args): 43 | self.worker.signals.progress.emit( *args ) -------------------------------------------------------------------------------- /app/settings.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | 3 | 4 | config = configparser.ConfigParser() 5 | config.optionxform = str # Preserve casing 6 | config.read('settings.ini') 7 | 8 | def _setter(section: str, option: str, value: str): 9 | if section != 'DEFAULT': 10 | if not config.has_section(section): 11 | config.add_section(section) 12 | 13 | config.set(section=section, option=option, value=str(value)) 14 | writeSettings() 15 | 16 | def writeSettings(): 17 | with open('./settings.ini', 'w') as f: 18 | config.write(f) 19 | 20 | 21 | 22 | 23 | 24 | 25 | def getLanguage(fallback = None): 26 | return config.get( 'DEFAULT', 'Language', fallback=fallback if fallback else 'en') 27 | 28 | def setLanguage(value: str): 29 | _setter( 'DEFAULT', 'Language', value) 30 | 31 | 32 | 33 | def getStopOnClose(): 34 | return config.getint( 'GUI', 'QuitRunningProcesses', fallback=0 ) != 0 35 | 36 | def setStopOnClose(value: bool): 37 | _setter( 'GUI', 'QuitRunningProcesses', 0 if value == False else 1) 38 | 39 | 40 | 41 | def getTheme(): 42 | return config.get( 'GUI', 'Theme', fallback='dark' ) 43 | 44 | def setTheme(value: str): 45 | _setter( 'GUI', 'Theme', value) 46 | 47 | 48 | 49 | def getDefaultFilePromptPath(): 50 | return config.get('DEFAULT', 'DefaultFilePromptPath', fallback='') 51 | 52 | def setDefaultFilePromptPath(value: str): 53 | _setter('DEFAULT', 'DefaultFilePromptPath', value) 54 | 55 | 56 | 57 | def getAutosaveFileTree(): 58 | return config.getint( 'DEFAULT', 'AutosaveFileTree', fallback=0 ) != 0 59 | 60 | def setAutosaveFileTree(value: bool): 61 | _setter( 'DEFAULT', 'AutosaveFileTree', 0 if value == False else 1) 62 | 63 | 64 | 65 | def getAutomaticFilePreview(): 66 | return config.getint( 'GUI', 'AutomaticPreview', fallback=0 ) != 0 67 | 68 | def setAutomaticFilePreview(value: bool): 69 | _setter( 'GUI', 'AutomaticPreview', 0 if value == False else 1) 70 | -------------------------------------------------------------------------------- /gui/app.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import os 4 | from PyQt6.QtWidgets import QApplication, QSplashScreen 5 | from PyQt6.QtCore import Qt, QLocale 6 | from PyQt6.QtGui import QPixmap, QIcon 7 | from app.strings import translate, setLocale 8 | from app import settings 9 | from .theme .theme import use_theme 10 | 11 | try: import ctypes 12 | except ImportError: ctypes = None 13 | 14 | 15 | def CreateApp(): 16 | if os.name == 'nt' and ctypes: 17 | try: 18 | # Set app id for windows to separate this app from the default python script icon in the task bar 19 | ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(u'ceciliabot.epicseven.assetripper.2') 20 | except Exception: 21 | pass 22 | 23 | setLocale(settings.getLanguage(fallback=QLocale.languageToCode(QLocale.system().language()))) 24 | 25 | app = QApplication(sys.argv) 26 | app.setApplicationName(translate('app_name')) 27 | app.setWindowIcon(QIcon("./gui/assets/icon.png")) # Set deafult icon to be used in all windows 28 | app.styleHints().setColorScheme(Qt.ColorScheme.Dark) 29 | # app.setStyle('fusion') 30 | 31 | splash_pix = QPixmap('./gui/assets/icon.png') 32 | splash = QSplashScreen(splash_pix, Qt.WindowType.WindowStaysOnTopHint) 33 | splash.setWindowFlags(Qt.WindowType.SplashScreen) 34 | splash.show() 35 | 36 | # splash.showMessage(splash.tr('check_updates'), Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight) 37 | # from app.update import update_check 38 | # update = update_check() 39 | 40 | use_theme(app) 41 | 42 | from .window import AppMainWindow 43 | MainWindow = AppMainWindow() 44 | MainWindow.PluginToolbar.load_hooks() 45 | MainWindow.show() 46 | 47 | setattr(MainWindow, 'tr', translate) 48 | 49 | # Remove splash screen once MainWindow is ready 50 | splash.finish(MainWindow) 51 | 52 | app.exec() 53 | 54 | if __name__ == '__main__': 55 | CreateApp() -------------------------------------------------------------------------------- /i18n/zh_cn.py: -------------------------------------------------------------------------------- 1 | from .en import EN 2 | class ZH_CN(EN): 3 | app_name = 'Epic Seven Asset Ripper' 4 | check_updates = '检查更新' 5 | github_page = 'CeciliaBot GitHub' 6 | file_tree = '文件树' 7 | select_pack = "选择 data.pack" 8 | generate_file_tree = "生成文件树" 9 | load_file_tree = "加载 JSON 文件树" 10 | btn_save = '保存' 11 | tooltip_save_tree = '保存文件映射 (CTRL + S)' 12 | btn_compare_tree = '比较\n文件树' 13 | btn_compare_tree_stop = '停止\n比较' 14 | tooltip_compare_tree = '将此文件树与旧版本进行比较,以查找新文件和已编辑文件' 15 | btn_extract_selected = '仅提取\n所选' 16 | tooltip_extract_selected = '仅提取所选文件和文件夹' 17 | btn_img_only = '仅提取\n图像' 18 | tooltip_img_only = '从当前文件树视图中提取 [{0}] 文件' 19 | btn_all = '全部' 20 | tooltip_extract_all = '从当前文件树视图中提取所有文件' 21 | decrypt = '解密' 22 | extract = '提取' 23 | extracting_all = '正在提取所有...' 24 | credits = '致谢' 25 | search = '搜索' 26 | preview = '预览' 27 | name = '名称' 28 | type = '类型' 29 | size = '大小' 30 | files_in_folder = '文件夹中的文件' 31 | offset = '偏移量' 32 | select_encrypted_pack = '选择密文的 data.pack' 33 | select_decrypted_pack = '选择明文的 data.pack' 34 | output_path = '输出路径' 35 | cancel = '取消' 36 | folder = '文件夹' 37 | 38 | settings = '设置' 39 | language = '语言' 40 | set_language_desc = '设置 UI 的语言。\n需要重启。' 41 | theme = '主题' 42 | set_theme_desc = '更改 UI 的配色方案。\n需要重启。' 43 | set_stop_on_main_close = '在主窗口关闭时停止进程' 44 | set_stop_on_main_close_desc = '关闭此窗口时停止所有文件提取。\n如果禁用,提取过程将继续,直到所有文件提取完成。\n默认:关闭' 45 | set_autosave_tree = '自动保存文件树' 46 | set_autosave_tree_desc = '自动将文件树保存在与 data.pack 相同的目录中。\n文件名:tree.json,如果存在同名文件将被替换!' 47 | set_automatic_file_preview = '自动预览文件内容' 48 | set_automatic_file_preview_desc = '如果禁用,仍可以通过右键单击文件并从上下文菜单中选择“预览”来预览文件。' 49 | set_default_file_prompt_path = '默认文件路径' 50 | set_default_file_prompt_path_desc = '选择目录或文件时显示的默认文件路径。' 51 | 52 | reload_plugin_tooltip = '重新加载钩子' 53 | reloading_plugin = '正在重新加载钩子...' 54 | reload_plugin_done = '钩子重新加载完成:{0} 已加载 - {1} 个错误' 55 | 56 | lang_en = 'English英语' 57 | lang_it = 'Italian意大利语' 58 | lang_zh_cn = 'Chinese中文简体' 59 | -------------------------------------------------------------------------------- /gui/components/csv_viewer/model.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import QAbstractTableModel, Qt 2 | from PyQt6.QtWidgets import QItemDelegate 3 | 4 | class CsvTableModel(QAbstractTableModel): 5 | def __init__(self, data): 6 | super().__init__() 7 | self._setReadOnly() 8 | self._data = data 9 | 10 | def rowCount(self, index) -> int: 11 | # -1 because of the header being in the same array/list 12 | return len(self._data) - 1 if self._data else 0 13 | 14 | def columnCount(self, index) -> int: 15 | return len(self._data[0]) if self._data else 0 16 | 17 | def _setReadOnly(self, value: bool=True): 18 | self._flags = Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable if value else Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable 19 | 20 | def flags(self, index): 21 | return self._flags 22 | 23 | def headerData(self, section: int, orientation, role: int) -> str: 24 | if role == Qt.ItemDataRole.DisplayRole: 25 | if orientation == Qt.Orientation.Vertical: 26 | return section + 1 # row names 27 | elif orientation == Qt.Orientation.Horizontal: 28 | return self._data[0][section] # column names 29 | 30 | elif role == Qt.ItemDataRole.TextAlignmentRole: 31 | return Qt.AlignmentFlag.AlignLeft + Qt.AlignmentFlag.AlignVCenter 32 | 33 | def data(self, index, role): 34 | if role == Qt.ItemDataRole.DisplayRole: 35 | try: 36 | value = self._data[index.row() + 1][index.column()] 37 | except IndexError: 38 | value = '' 39 | 40 | return str(value) 41 | 42 | if role == Qt.ItemDataRole.TextAlignmentRole: 43 | return Qt.AlignmentFlag.AlignVCenter + Qt.AlignmentFlag.AlignLeft 44 | 45 | def _data_getter(self, row: int, column: int): 46 | return str(self._data[row + 1][column]) 47 | 48 | 49 | class DelegateEditor(QItemDelegate): 50 | def setEditorData(self, editor, index): 51 | text = index.data(Qt.ItemDataRole.EditRole) or index.data(Qt.ItemDataRole.DisplayRole) 52 | editor.setText(text) 53 | 54 | def setModelData(self, editor, model: CsvTableModel, index): 55 | model._data[index.row()+1][index.column()] = editor.text() -------------------------------------------------------------------------------- /gui/util/threads.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import QThreadPool, QRunnable, QObject, pyqtSignal, pyqtSlot 2 | import traceback, sys 3 | 4 | ThreadPool = QThreadPool() 5 | 6 | class WorkerSignals(QObject): 7 | ''' 8 | Defines the signals available from a running worker thread. 9 | 10 | Supported signals are: 11 | 12 | finished 13 | No data 14 | 15 | error 16 | tuple (exctype, value, traceback.format_exc() ) 17 | 18 | result 19 | object data returned from processing, anything 20 | 21 | progress 22 | tuple 23 | 0 = int indicating % progress 24 | 1 = text for label 25 | 26 | ''' 27 | start = pyqtSignal(tuple) 28 | finished = pyqtSignal(tuple) 29 | error = pyqtSignal(tuple, tuple) 30 | result = pyqtSignal(tuple, object) 31 | progress = pyqtSignal(tuple) 32 | do_main = pyqtSignal(object, tuple) 33 | 34 | 35 | class Worker(QRunnable): 36 | ''' 37 | Worker thread 38 | 39 | Inherits from QRunnable to handler worker thread setup, signals and wrap-up. 40 | 41 | :param callback: The function callback to run on this worker thread. Supplied args and 42 | kwargs will be passed through to the runner. 43 | :type callback: function 44 | :param args: Arguments to pass to the callback function 45 | :param kwargs: Keywords to pass to the callback function 46 | 47 | ''' 48 | 49 | def __init__(self, fn, *args, **kwargs): 50 | super(Worker, self).__init__() 51 | 52 | # Store constructor arguments (re-used for processing) 53 | self.fn = fn 54 | self.args = args 55 | self.kwargs = kwargs 56 | self.signals = WorkerSignals() 57 | 58 | @pyqtSlot() 59 | def run(self): 60 | ''' 61 | Initialise the runner function with passed args, kwargs. 62 | ''' 63 | 64 | # Retrieve args/kwargs here; and fire processing using them 65 | try: 66 | self.signals.start.emit( self.args ) 67 | result = self.fn(*self.args, **self.kwargs) 68 | except: 69 | traceback.print_exc() 70 | exctype, value = sys.exc_info()[:2] 71 | self.signals.error.emit( self.args, (exctype, value, traceback.format_exc())) 72 | else: 73 | self.signals.result.emit( self.args, result ) # Return the result of the processing 74 | finally: 75 | self.signals.finished.emit( self.args ) # Done 76 | -------------------------------------------------------------------------------- /app/full_decrypt.py: -------------------------------------------------------------------------------- 1 | from app.util.tree import PackFileScanner 2 | from .pack import DataPack 3 | from .util.thread import ThreadData 4 | from .util.misc import FileDescriptor 5 | from .util.exceptions import OperationAbortedByUser 6 | from .util.tree import create_file 7 | from .load_hooks import call_hooks 8 | import os 9 | 10 | def decrypt_and_write(pack: DataPack, dest: str, thread: ThreadData): 11 | b = 0 12 | chunkSize = 100000 13 | pr = 0 14 | l = os.path.getsize(pack.get_path()) 15 | with open(pack.get_path(), 'rb') as f: 16 | with open(dest, 'wb') as w: 17 | while True: 18 | if thread.is_stopping(): 19 | break 20 | 21 | p = round(b/l*100) 22 | if p>pr: 23 | thread.progress((p,)) 24 | pr = p 25 | 26 | data = pack.read_bytes(f, b, chunkSize) 27 | if len(data) == 0: 28 | break 29 | b+=len(data) 30 | w.write( bytes(data) ) 31 | 32 | 33 | 34 | def extract_all(pack: DataPack, dest: str, thread: ThreadData): 35 | file = PackFileScanner(pack) 36 | mmap = pack.mmap() 37 | p = 0 38 | s = mmap.size() 39 | 40 | while True: 41 | if thread.is_stopping(): 42 | raise OperationAbortedByUser('') 43 | 44 | member = file.next() 45 | 46 | if member is None: 47 | break 48 | 49 | _p = round(member.offset_data/s*100) 50 | if _p>p: 51 | thread.progress((_p,)) 52 | p = _p 53 | 54 | n = os.path.split(member.name) 55 | 56 | if n[0] != '': 57 | dir = os.path.join(dest, n[0]) 58 | os.makedirs(dir, exist_ok=True) 59 | 60 | path = os.path.join(dest, member.name) 61 | 62 | file_dict = create_file(name=n[1], full_name=member.name, size=member.size, offset=member.offset_data) 63 | 64 | data = file.get_file_content(file_dict) 65 | 66 | # mmap.seek(member.offset_data) 67 | 68 | # data = pack.read_bytes(mmap, member.offset_data, member.size) 69 | 70 | _file = FileDescriptor(data=data, path=path, tree_file=file_dict, pack=pack, thread=thread) 71 | 72 | call_hooks('before', _file) 73 | 74 | if _file.path is not None: 75 | with open(_file.path, 'wb') as f: 76 | f.write(_file.bytes) 77 | _file.written = True 78 | 79 | call_hooks('after', _file) -------------------------------------------------------------------------------- /app/extract.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import TYPE_CHECKING 3 | from .load_hooks import call_hooks 4 | from .util.exceptions import OperationAbortedByUser 5 | from .util.tree import count_size_tree 6 | from .util.pack_read import PackFileScanner 7 | from .util.misc import FileDescriptor 8 | from .util.types import FileTreeType, FileType 9 | from .util.thread import ThreadData 10 | 11 | if TYPE_CHECKING: 12 | from .pack import DataPack 13 | else: 14 | DataPack = None 15 | 16 | def extract(pack: DataPack, files: FileTreeType, base_path: str, thread: ThreadData): 17 | 18 | if not isinstance(files, list): 19 | files = [files] 20 | 21 | packio = PackFileScanner(pack) 22 | 23 | file_size = count_size_tree(files) 24 | processed_files = 0 25 | progress_percentage = -1 26 | 27 | def recrusive_writing(path, files): 28 | nonlocal processed_files, progress_percentage, file_size 29 | for file in files: 30 | 31 | if thread.is_stopping(): 32 | raise OperationAbortedByUser('Operation was interrupted by the user') 33 | 34 | if file['type'] == 'folder': 35 | ndir = os.path.join(path, file['name']) 36 | os.makedirs( ndir, exist_ok=True ) 37 | recrusive_writing(ndir, file['children']) 38 | else: 39 | _pfiles = processed_files + file['size'] 40 | _percentage = round( _pfiles / file_size *100) 41 | processed_files = _pfiles 42 | if _percentage > progress_percentage: 43 | thread.progress((progress_percentage,file['full_path'])) 44 | progress_percentage = _percentage 45 | 46 | data = packio.get_file_content(file) 47 | file_path = os.path.join(path, file['name']) 48 | 49 | file = FileDescriptor(data=data, path=file_path, tree_file=file, pack=pack, thread=thread) 50 | 51 | call_hooks('before', file) 52 | 53 | if file.path: 54 | with open(file.path, 'wb') as f: 55 | f.write(file.bytes) 56 | file.written = True 57 | 58 | call_hooks('after', file) 59 | 60 | 61 | recrusive_writing(base_path, files) 62 | 63 | packio.close() 64 | 65 | print('Done') 66 | 67 | def get_file(file: FileType, pack: DataPack = None): 68 | reader = PackFileScanner(pack) 69 | bytes_ = reader.get_file_content(file) 70 | reader.close() 71 | f = FileDescriptor(data=bytes_, path=None,tree_file=file, pack=pack, thread=ThreadData()) 72 | call_hooks('before', f) 73 | call_hooks('after', f) 74 | return f.bytes 75 | -------------------------------------------------------------------------------- /gui/window.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QMainWindow, QTabWidget, QWidget 2 | from PyQt6.QtCore import Qt, pyqtSignal 3 | from app.constants import VERSION 4 | from .pugin_toolbar import PuginToolbar 5 | from app.strings import translate 6 | from app import settings 7 | from .components.progress_bar.ProgressBar import ProgressBar 8 | import os 9 | import importlib 10 | import glob 11 | 12 | 13 | class AppMainWindow(QMainWindow): 14 | closed = pyqtSignal() 15 | PluginToolbar: PuginToolbar = None 16 | ProgressBarWindow: ProgressBar = None 17 | def __init__(self) -> None: 18 | QMainWindow.__init__(self) 19 | app = QApplication.instance() 20 | app.setProperty('MainWindow', self) 21 | 22 | 23 | app.setProperty('GetProgressBarWindow', self.get_progress_bar_window) 24 | 25 | self.setWindowTitle(f'{translate("app_name")} v{VERSION}') 26 | self.resize(900, 700) 27 | 28 | self.PluginToolbar = PuginToolbar(self) 29 | 30 | self.tabs = QTabWidget() 31 | self.tabs.setEnabled(True) 32 | self.tabs.setObjectName("MainWindowTabs") 33 | self.tabs.setDocumentMode(True) 34 | 35 | self.setCentralWidget(self.tabs) 36 | 37 | # Get all folders containing a main.py script in the tabs folder and load them 38 | tabs: list[QWidget] = [] 39 | py_files = glob.glob(os.path.join('gui', 'tabs', '*', 'main.py')) \ 40 | + glob.glob(os.path.join('gui', 'tabs', '.*', 'main.py')) 41 | 42 | for py in py_files: 43 | path, file = os.path.split(py) 44 | _, ext = os.path.split(path) 45 | spec = importlib.util.spec_from_file_location(ext, os.path.join(path, file)) 46 | module = importlib.util.module_from_spec(spec) 47 | spec.loader.exec_module(module) 48 | tabs.append(module.CreateTab(self)) 49 | 50 | tabs.sort(reverse=False, key=lambda e: e.order) 51 | 52 | for tab in tabs: 53 | self.tabs.addTab(tab, tab.name) 54 | 55 | def get_progress_bar_window(self): 56 | if not self.ProgressBarWindow: 57 | self.ProgressBarWindow = ProgressBar(self, id='ProgressBarWindow', flags=Qt.WindowType.Window | Qt.WindowType.SubWindow ) 58 | 59 | return self.ProgressBarWindow 60 | 61 | def closeEvent(self, a0): 62 | self.closed.emit() 63 | bars = self.get_progress_bar_window() 64 | if settings.getStopOnClose() == True: # Stop all threads 65 | bars.remove_all() 66 | else: # Make progress bar a standalone window 67 | if len( bars.activeBars() )>0: 68 | bars.promoteToWindow() 69 | 70 | return super().closeEvent(a0) -------------------------------------------------------------------------------- /gui/components/dialog_window.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QDialog, QScrollArea, QWidget, QHBoxLayout, QVBoxLayout, QSizePolicy 2 | from PyQt6.QtCore import Qt 3 | 4 | class ScrollableDialogWindow(QDialog): 5 | ''' 6 | Create a dialog window with 3 sections: Header, Body, Footer 7 | 8 | Body is scrollable 9 | ''' 10 | def __init__(self, parent = None, title:str='', flags = None): 11 | super().__init__(parent) 12 | 13 | if not flags: 14 | self.setWindowFlags(Qt.WindowType.Window | 15 | Qt.WindowType.CustomizeWindowHint | 16 | Qt.WindowType.WindowTitleHint) 17 | else: 18 | self.setWindowFlags(flags) 19 | 20 | if title: self.setWindowTitle( title ) 21 | self.setMaximumHeight(600) 22 | self.setMinimumWidth(300) 23 | self.setMaximumWidth(600) 24 | self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum) 25 | 26 | main_layout = QVBoxLayout(self) 27 | main_layout.setSpacing(0) 28 | self.setLayout(main_layout) 29 | 30 | self.header = QWidget(self) 31 | header_layout = QVBoxLayout() 32 | header_layout.setContentsMargins(0,0,0,0) 33 | self.header.setLayout(header_layout) 34 | self.header.setMinimumHeight(0) 35 | main_layout.addWidget(self.header) 36 | 37 | #-------- BODY 38 | self.body_scroll = QScrollArea(self) 39 | self.body = QWidget(self) 40 | 41 | self.body_layout = QVBoxLayout() 42 | self.body_layout.setContentsMargins(0, 0, 0, 0) 43 | self.body.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum) 44 | self.body.setLayout(self.body_layout) 45 | 46 | self.body_scroll.setWidget(self.body) 47 | self.body_scroll.setWidgetResizable(True) 48 | main_layout.addWidget(self.body_scroll) 49 | 50 | 51 | #-------- FOOTER 52 | self.footer = QWidget(self) 53 | footer_layout = QVBoxLayout() 54 | footer_layout.setContentsMargins(0,0,0,0) 55 | self.footer.setLayout(footer_layout) 56 | main_layout.addWidget(self.footer) 57 | 58 | self.show() 59 | 60 | def _clear_part(self, _widget: QWidget): 61 | ''' 62 | remove everything from this widget except the layout 63 | ''' 64 | _layout = _widget.layout() 65 | 66 | for item in _widget.children(): 67 | if item != _layout: 68 | _layout.removeWidget(item) 69 | item.deleteLater() 70 | 71 | def clear_header(self): 72 | self._clear_part(self.header) 73 | 74 | def clear_body(self): 75 | self._clear_part(self.body) 76 | 77 | def clear_footer(self): 78 | self._clear_part(self.footer) 79 | -------------------------------------------------------------------------------- /gui/tabs/credits/main.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QLabel 2 | from PyQt6.QtCore import Qt 3 | from PyQt6.QtGui import QPixmap 4 | from app.constants import VERSION 5 | from app.strings import translate 6 | from gui.components.button import Button 7 | from gui.components.update_dialog import UpdateDialog 8 | 9 | class CreateTab(QWidget): 10 | order = 99 11 | def __init__(self, parent: QWidget | QMainWindow): 12 | super().__init__(parent) 13 | self.name = translate('credits') 14 | vertical_box = QVBoxLayout() 15 | self.setLayout(vertical_box) 16 | vertical_box.addStretch() 17 | 18 | ## APP ICON 19 | app_icon = QLabel(self) 20 | pixmap = QPixmap('./gui/assets/icon.png') 21 | app_icon.setPixmap(pixmap) 22 | app_icon.setScaledContents(True) 23 | app_icon.setFixedSize(int(pixmap.width() * 200//pixmap.height()), 200) 24 | vertical_box.addWidget(app_icon) 25 | vertical_box.setAlignment(app_icon, Qt.AlignmentFlag.AlignCenter) 26 | 27 | ## APP NAME 28 | app_name = QLabel(self) 29 | app_name.setText(f'{translate("app_name")} v{VERSION}') 30 | app_name.setStyleSheet('font-size: 18px') 31 | vertical_box.addWidget(app_name) 32 | vertical_box.setAlignment(app_name, Qt.AlignmentFlag.AlignCenter) 33 | 34 | ## GITHUB LINK 35 | github = QLabel(self) 36 | github.setText(f'{translate("github_page")}') 37 | github.setOpenExternalLinks(True) 38 | vertical_box.addWidget(github) 39 | vertical_box.setAlignment(github, Qt.AlignmentFlag.AlignCenter) 40 | 41 | ## ICONS 42 | picto = QLabel(self) 43 | picto.setText(f'Material Design icons by pictogrammers') 44 | picto.setOpenExternalLinks(True) 45 | vertical_box.addWidget(picto) 46 | vertical_box.setAlignment(picto, Qt.AlignmentFlag.AlignCenter) 47 | 48 | ## UPDATE BUTTON 49 | check_for_updates = Button(self, text=translate('check_updates'), pointer=True) 50 | check_for_updates.clicked.connect(self.check_for_app_updates) 51 | vertical_box.addSpacing(20) 52 | vertical_box.addWidget(check_for_updates) 53 | vertical_box.setAlignment(check_for_updates, Qt.AlignmentFlag.AlignCenter) 54 | self.updates_btn = check_for_updates 55 | 56 | vertical_box.addStretch() 57 | 58 | def check_for_app_updates(self): 59 | self.updates_btn.setDisabled(True) 60 | 61 | dialog = UpdateDialog(self) 62 | dialog.closeEvent = lambda *a: self.updates_btn.setDisabled(False) 63 | 64 | 65 | -------------------------------------------------------------------------------- /gui/pugin_toolbar.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QToolBar, QMainWindow, QLabel, QSizePolicy 2 | from PyQt6.QtCore import Qt, QSize 3 | from PyQt6.QtGui import QAction 4 | from .util.svg_icon import QIcon_from_svg 5 | from app.load_hooks import HookClass, load_hooks 6 | from app.strings import translate 7 | 8 | class PuginToolbar(QToolBar): 9 | def __init__(self, parent: QMainWindow) -> None: 10 | super().__init__(parent) 11 | self.setObjectName('PluginToolbar') 12 | self.setOrientation(Qt.Orientation.Horizontal) 13 | self.setMovable(False) 14 | self.setIconSize(QSize(16, 16)) 15 | self.setMinimumHeight(27) 16 | self.setAutoFillBackground(True) 17 | self.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) 18 | parent.addToolBar(Qt.ToolBarArea.BottomToolBarArea, self) 19 | 20 | reload_action = QAction(translate('reload_plugin_tooltip'), parent) 21 | reload_action.setStatusTip(translate('reload_plugin_tooltip')) 22 | reload_action.setIcon(QIcon_from_svg('refresh.svg', QApplication.instance().ThemeColors.TOOLBAR_ICON_COLOR)) 23 | reload_action.setObjectName('RELOAD_HOOKS_ACTION') 24 | reload_action.triggered.connect(self.refresh_plugins) 25 | self.addAction(reload_action) 26 | self.widgetForAction(reload_action).setCursor(Qt.CursorShape.PointingHandCursor) 27 | 28 | bottom_description = QLabel() 29 | bottom_description.setText('') 30 | bottom_description.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 31 | bottom_description.setEnabled(True) 32 | bottom_description.setAlignment(Qt.AlignmentFlag.AlignVCenter) 33 | bottom_description.setObjectName('BOTTOM_INFO_TEXT') 34 | self.addWidget(bottom_description) 35 | self.info_label = bottom_description 36 | 37 | self.setProperty('class', 'BottomToolbar') 38 | 39 | def setText(self, value): 40 | self.info_label.setText(value) 41 | 42 | def create_add_on_buttons(self, plugin: HookClass): 43 | q = QAction(plugin.get_name(), self) 44 | q.setObjectName('ADDON_ICON') 45 | q.setCheckable(True) 46 | q.setChecked(plugin.get_is_enabled()) 47 | q.toggled.connect(plugin.set_is_enabled) 48 | self.addAction(q) 49 | self.widgetForAction(q).setCursor(Qt.CursorShape.PointingHandCursor) 50 | 51 | def load_hooks(self): 52 | return load_hooks(gui_create=self.create_add_on_buttons) 53 | 54 | def refresh_plugins(self): 55 | self.setText(translate('reloading_plugin')) 56 | 57 | children = self.findChildren(QAction, 'ADDON_ICON') 58 | for child in children: 59 | self.removeAction(child) 60 | children = None 61 | 62 | loaded, errors = self.load_hooks() 63 | 64 | self.setText(translate('reload_plugin_done').format(loaded, errors)) -------------------------------------------------------------------------------- /gui/components/progress_bar/ProgressBar.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QMainWindow, QDialog 2 | from PyQt6.QtCore import Qt, pyqtSlot 3 | from PyQt6.QtGui import QWindowStateChangeEvent 4 | import sys 5 | 6 | from .ProgressBar_ui import Ui_Form 7 | 8 | default_flags = (Qt.WindowType.Window | 9 | Qt.WindowType.CustomizeWindowHint | 10 | Qt.WindowType.WindowTitleHint | 11 | # Qt.WindowType.WindowCloseButtonHint | 12 | Qt.WindowType.WindowMinimizeButtonHint) 13 | 14 | class ProgressBar(QDialog, Ui_Form): 15 | ''' 16 | Don't pass a parent if you want this window to have a separate icon in the OS taskbar 17 | ''' 18 | 19 | def __init__(self, parent:QMainWindow=None, desc = None, app: QApplication = None, id: str = 'ProgressBarWindow', flags = default_flags ): 20 | super(ProgressBar, self).__init__(parent) 21 | self.setWindowFlags( flags ) 22 | 23 | if not app: 24 | app = QApplication.instance() 25 | 26 | app.setProperty(id, self) 27 | self.app = app 28 | 29 | self.setupUi() 30 | 31 | # if parent: 32 | # # parent.minimized.connect( self.showMinimized ) 33 | # # parent.restored.connect( self.showNormal ) 34 | # parent.destroyed.connect( self.remove_all ) 35 | 36 | if desc != None: 37 | self.setDescription(desc) 38 | 39 | def promoteToWindow(self): 40 | ''' 41 | Removes from the parents and adds minimize and close buttons 42 | ''' 43 | save_geometry = self.geometry() 44 | self.setParent(None) 45 | self.overrideWindowFlags( Qt.WindowType.Window | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowMinimizeButtonHint ) 46 | if self.isHidden(): 47 | self.setGeometry(save_geometry) 48 | self.setHidden(False) 49 | 50 | def setValue(self, val, index = 0): # Sets value 51 | self.getProgressBar(index).setValue(val) 52 | 53 | def setDescription(self, desc): # Sets Pbar window title 54 | self.setWindowTitle(desc) 55 | 56 | def setLabel(self, text, index = 0): 57 | self.getProgressBar(index).setLabel(text) 58 | 59 | def closeEvent(self, a0): 60 | self.remove_all() 61 | return super().closeEvent(a0) 62 | 63 | 64 | 65 | 66 | def main(): 67 | app = QApplication(sys.argv) # A new instance of QApplication 68 | form = ProgressBar('') # We set the form to be our MainWindow (design) 69 | bar1 = form.new() # We set the form to be our MainWindow (design) 70 | bar1.setValue(40) 71 | bar1.setLabel('Copy') 72 | bar2 = form.new() 73 | bar2.setValue(70) 74 | bar2.setLabel('c:\\dsfsd') 75 | app.exec() # and execute the app 76 | 77 | if __name__ == '__main__': # if we're running file directly and not importing it 78 | main() # run the main function 79 | -------------------------------------------------------------------------------- /app/update.py: -------------------------------------------------------------------------------- 1 | from .constants import VERSION 2 | import json 3 | import re 4 | import os 5 | from pathlib import Path 6 | from urllib.request import urlopen, Request 7 | 8 | def github_version_to_float(version: str): 9 | version = ''.join(version.rsplit('.', 1)) if version.count('.') > 1 else version 10 | return float( version ) 11 | 12 | def update_check(): 13 | resp = None 14 | versions = [] 15 | 16 | LOCAL_VERSION = float(VERSION) 17 | 18 | try: 19 | with urlopen( 20 | Request( 'https://api.github.com/repos/CeciliaBot/EpicSevenAssetRipper/releases', method='GET') 21 | ) as f: 22 | resp = json.loads(f.read()) 23 | 24 | if resp: 25 | for release in resp: 26 | v = github_version_to_float(release['name']) 27 | if v > LOCAL_VERSION: 28 | versions.append({ 29 | 'version': v, 30 | 'date': release['published_at'], # release['created_at'], 31 | 'changelog': release['body'], 32 | 'url': [ i['browser_download_url'] for i in release['assets'] if re.search(r'\.zip$', i['browser_download_url'], re.IGNORECASE)], 33 | 'zip_source': release['zipball_url'] 34 | }) 35 | 36 | return versions 37 | 38 | except Exception as e: 39 | print('Error in update checker') 40 | print(e) 41 | 42 | def download_update(url: str): 43 | with urlopen( 44 | Request( 45 | url, 46 | method='GET' 47 | ) 48 | ) as r: 49 | with open('.patch.zip', 'wb') as f: 50 | f.write(r.read()) 51 | 52 | def unpack_update(): 53 | ''' 54 | Unpack an update for this tool if found in the local path 55 | ''' 56 | if os.path.isfile('./.patch.zip'): 57 | print('Applying patch') 58 | import zipfile 59 | 60 | with zipfile.ZipFile(file='./.patch.zip', mode='r') as zip: 61 | filter = zipfile.Path(zip).iterdir() 62 | root_folders = [ i for i in filter] 63 | 64 | if len(root_folders) == 1: 65 | filter = root_folders[0].name + '/' 66 | else: 67 | filter = '' 68 | 69 | for file in zip.namelist(): 70 | if file.startswith(filter): 71 | destination_name = file.replace(filter, '') if filter != '' else file 72 | dest_path = os.path.join('./', destination_name) 73 | print('Extracting', destination_name, '...') 74 | info = zip.getinfo(file) 75 | if info.is_dir(): 76 | Path(dest_path).mkdir(parents=True, exist_ok=True) 77 | else: 78 | with open(dest_path, 'wb') as f: 79 | f.write( zip.read( info ) ) 80 | 81 | os.remove('./.patch.zip') 82 | print('Patch complete') 83 | else: 84 | pass -------------------------------------------------------------------------------- /gui/tabs/settings/row.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QWidget, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QFileDialog 2 | from PyQt6.QtCore import Qt 3 | from PyQt6.QtGui import QFont 4 | from gui.components.switch import AnimatedToggle 5 | from gui.components.button import Button 6 | 7 | class SettingsRow(QWidget): 8 | def __init__(self, parent, title, description, type, value, onchanged, options=[]): 9 | super().__init__(parent) 10 | 11 | self.setObjectName('SettingRow') 12 | 13 | self.setProperty('class', 'setting-row') 14 | self.setStyleSheet(f'background-color: {QApplication.instance().ThemeColors.BUTTON_BACKGROUND_COLOR};') 15 | layout = QHBoxLayout() 16 | self.setLayout(layout) 17 | layout.setSpacing(0) 18 | 19 | 20 | text_wrapper = QWidget(self) 21 | layout.addWidget(text_wrapper) 22 | description_layout = QVBoxLayout() 23 | text_wrapper.setLayout(description_layout) 24 | 25 | title_font = QFont() 26 | title_font.setPointSize(12) 27 | title_font.setBold(True) 28 | 29 | title_label = QLabel(self) 30 | title_label.setText(title) 31 | title_label.setFont(title_font) 32 | description_layout.addWidget(title_label) 33 | 34 | description_label = QLabel(self) 35 | description_label.setText(description) 36 | description_label.setWordWrap(True) 37 | description_layout.addWidget(description_label) 38 | 39 | 40 | option_wrapper = QWidget(self) 41 | option_wrapper.setLayout(QVBoxLayout()) 42 | option_widget = self.create_options_widget(type, value, onchanged, options) 43 | option_wrapper.layout().addWidget(option_widget) 44 | option_wrapper.layout().setAlignment(option_widget, Qt.AlignmentFlag.AlignRight) 45 | layout.addWidget(option_wrapper) 46 | option_wrapper.setMaximumWidth(200) 47 | 48 | layout.setStretchFactor(option_wrapper, 0) 49 | 50 | def create_options_widget(self, sett_type, value, onchanged, options): 51 | match(sett_type): 52 | case 'checkbox': 53 | box = AnimatedToggle(self) 54 | box.setCheckState(Qt.CheckState.Checked if value == True else Qt.CheckState.Unchecked) 55 | box.stateChanged.connect(onchanged) # 2==checked 0 == unchecked 56 | return box 57 | case 'select': 58 | box = QComboBox(self) 59 | box.setMinimumWidth(100) 60 | box.addItems(options) 61 | box.setCurrentIndex(value) 62 | box.setCurrentText(options[value]) 63 | box.currentIndexChanged.connect(onchanged) 64 | return box 65 | case 'path': 66 | box = Button(self, text=options, pointer=True, minimum_width=100) 67 | box.clicked.connect(lambda: onchanged(self._path_select(value))) 68 | return box 69 | 70 | def _path_select(self, value: str): 71 | return QFileDialog.getExistingDirectory( 72 | self, 73 | '', 74 | directory=value 75 | ) -------------------------------------------------------------------------------- /gui/components/search_bar.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QWidget, QLineEdit, QHBoxLayout, QVBoxLayout, QPushButton 2 | from PyQt6.QtCore import pyqtSignal, Qt, QEvent 3 | from gui.util.svg_icon import QIcon_from_svg 4 | 5 | class SearchBar(QWidget): 6 | # Emits only after the Enter key is pressed 7 | search = pyqtSignal(str) 8 | # Emits after each key press 9 | typing = pyqtSignal(str) 10 | 11 | def __init__(self, parent=None, placeholder=None): 12 | super().__init__(parent) 13 | 14 | THEME_COLORS = QApplication.instance().ThemeColors 15 | 16 | layout = QHBoxLayout(self) 17 | layout.setSpacing(0) 18 | self.setMinimumWidth(100) 19 | 20 | self.input_field = QLineEdit(self) 21 | self.input_field.setPlaceholderText(placeholder) 22 | self.input_field.setFixedHeight(30) 23 | self.input_field.setContentsMargins(0, 0, 0, 0) 24 | self.input_field.installEventFilter(self.input_field) 25 | self.input_field.eventFilter = self.eventFilter 26 | self.input_field.setStyleSheet(f'background-color: {THEME_COLORS.INPUT_BACKGROUND_COLOR}; color {THEME_COLORS.INPUT_FONT_COLOR}; border-top-left-radius: {THEME_COLORS.INPUT_BORDER_RADIUS}px; border-bottom-left-radius: {THEME_COLORS.INPUT_BORDER_RADIUS}px; border-top-right-radius: 0; border-bottom-right-radius: 0; border: {THEME_COLORS.INPUT_BORDER_WIDTH}px solid {THEME_COLORS.INPUT_BORDER_COLOR}; border-right: none;') 27 | layout.addWidget(self.input_field) 28 | layout.setStretchFactor(self.input_field, 1) 29 | 30 | self.go_button = QPushButton(self) 31 | self.go_button.setIcon(QIcon_from_svg('magnify.svg', THEME_COLORS.INPUT_ICON_COLOR)) 32 | self.go_button.setFixedHeight(30) 33 | self.go_button.setMinimumWidth(20) 34 | self.go_button.setContentsMargins(0, 0, 0, 0) 35 | self.go_button.setStyleSheet(f'background-color: {THEME_COLORS.INPUT_BACKGROUND_COLOR}; border-top-right-radius: {THEME_COLORS.INPUT_BORDER_RADIUS}px; border-bottom-right-radius: {THEME_COLORS.INPUT_BORDER_RADIUS}px; border-top-left-radius: 0; border-bottom-left-radius: 0; border: 0; border: {THEME_COLORS.INPUT_BORDER_WIDTH}px solid {THEME_COLORS.INPUT_BORDER_COLOR}; border-left: none;') 36 | self.go_button.clicked.connect(self._emit_change) 37 | layout.addWidget(self.go_button) 38 | layout.setStretchFactor(self.go_button, 0) 39 | 40 | 41 | def eventFilter(self, obj, event): # CHeck if enter was pressed 42 | if event.type() == QEvent.Type.KeyPress: # and obj is self.input_field 43 | if event.key() == Qt.Key.Key_Return.value: 44 | self._emit_change() 45 | else: 46 | self.typing.emit(self.getValue()) 47 | 48 | return super().eventFilter(obj, event) 49 | 50 | def set_focus(self): 51 | self.input_field.setFocus() 52 | 53 | # Internal call/connect 54 | def _emit_change(self): 55 | self.search.emit(self.getValue()) 56 | 57 | def clearValue(self): 58 | self.input_field.setText('') 59 | 60 | def setValue(self, value: str = ''): 61 | self.input_field.setText(value) 62 | 63 | def getValue(self): 64 | return self.input_field.text() -------------------------------------------------------------------------------- /app/util/bytearray.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | class ByteArray(bytearray): 4 | ''' 5 | Same as bytearray but with added read, seek, tell, peek and skip. Can be used instead of io.bytesio for some methods 6 | ''' 7 | 8 | _cursor = 0 9 | 10 | def _read(self, length:int=1): # Read n bytes and move cursor 11 | c = self._cursor 12 | self._cursor = min( len(self) , self._cursor+length ) 13 | 14 | return self[c:self._cursor] 15 | 16 | def seek(self, index: int): 17 | ''' 18 | Set the cursor's position 19 | ''' 20 | self._cursor = index 21 | 22 | def skip(self, length: int=1): 23 | ''' 24 | Advance n bytes without returning anything 25 | ''' 26 | self._cursor += length 27 | 28 | def peek(self, length: int = 1): 29 | ''' 30 | Return the next n bytes without advancing the array position 31 | ''' 32 | return bytes( self[self._cursor:self._cursor+length] ) 33 | 34 | def read(self, size: int = 1): 35 | ''' 36 | Read and return the next n bytes 37 | ''' 38 | return bytes( self._read(size if size else len(self) - self._cursor) ) 39 | 40 | def tell(self): 41 | ''' 42 | Return the cursor's position 43 | ''' 44 | return self._cursor 45 | 46 | def read_int(self, size: int, byteorder='little', signed=False): 47 | ''' 48 | Read n bytes and return them as int 49 | ''' 50 | return int.from_bytes(self._read(size), byteorder=byteorder, signed=signed) 51 | 52 | def read_int8(self): 53 | return int.from_bytes(self._read(1), signed=False) 54 | 55 | def read_uint8(self): 56 | return int.from_bytes(self._read(1), signed=False) 57 | 58 | def read_int16(self, big = False): 59 | return int.from_bytes(self._read(2), byteorder='big' if big else 'little', signed=True) 60 | 61 | def read_uint16(self, big = False): 62 | return int.from_bytes(self._read(2), byteorder='big' if big else 'little', signed=False) 63 | 64 | def read_int32(self, big = False): 65 | return int.from_bytes(self._read(4), byteorder='big' if big else 'little', signed=True) 66 | 67 | def read_uint32(self, big = False): 68 | return int.from_bytes(self._read(4), byteorder='big' if big else 'little', signed=False) 69 | 70 | def read_int64(self, big = False): 71 | return int.from_bytes(self._read(8), byteorder='big' if big else 'little', signed=True) 72 | 73 | def read_uint64(self, big = False): 74 | return int.from_bytes(self._read(8), byteorder='big' if big else 'little', signed=False) 75 | 76 | def read_float(self, big = False): 77 | return struct.unpack('>f' if big else 'f' if big else 'f' if byteorder=='big' else ' ByteArray: 110 | ''' 111 | This method doesn't move the cursor, offset is only used for 112 | ''' 113 | l = ByteArray(fmap.read(size)) 114 | for i in range(len(l)): 115 | l[i] = l[i] ^ KEY[ (offset+i) % KEY_LEN] 116 | return l 117 | 118 | @staticmethod 119 | def _plain_read(fmap, _: int, size: int): 120 | return ByteArray( fmap.read(size) ) 121 | 122 | @staticmethod 123 | def _zip_read_bytes(file, offset, size): 124 | return 125 | 126 | # Test 127 | read_bytes = _plain_read 128 | 129 | def build_tree(self, thread: ThreadData = None): 130 | if not thread: 131 | thread=ThreadData() 132 | self._tree = generate_dict_tree(self, thread=thread) 133 | 134 | if getAutosaveFileTree(): 135 | with open(os.path.join(self.parent_dir(), 'tree.json'), 'w') as f: 136 | f.write(json.dumps(self._tree)) 137 | 138 | def extract(self, files: FileTreeType, path: str, thread: ThreadData = None): 139 | if not thread: 140 | thread=ThreadData() 141 | extract(self, files, path, thread=thread) 142 | 143 | def destroy(self) -> None: 144 | try: 145 | if self.fileno() is not None: 146 | os.close( self.fileno() ) 147 | self._file = None 148 | self._path = None 149 | self.set_tree(None) 150 | self._type = None 151 | self._is_encrypted = None 152 | except Exception: 153 | pass -------------------------------------------------------------------------------- /gui/components/switch.py: -------------------------------------------------------------------------------- 1 | ## https://www.pythonguis.com/tutorials/qpropertyanimation/ 2 | 3 | from PyQt6.QtCore import ( 4 | Qt, QSize, QRect, QPoint, QPointF, QRectF, 5 | QEasingCurve, QPropertyAnimation, QSequentialAnimationGroup, 6 | pyqtSlot, pyqtProperty) 7 | 8 | from PyQt6.QtWidgets import QCheckBox 9 | from PyQt6.QtGui import QColor, QBrush, QPaintEvent, QPen, QPainter 10 | 11 | 12 | class AnimatedToggle(QCheckBox): 13 | 14 | _transparent_pen = QPen(Qt.GlobalColor.transparent) 15 | _light_grey_pen = QPen(Qt.GlobalColor.lightGray) 16 | 17 | def __init__(self, 18 | parent=None, 19 | bar_color=Qt.GlobalColor.gray, 20 | checked_color="#00B0FF", 21 | handle_color=Qt.GlobalColor.white, 22 | pulse_unchecked_color="#44999999", 23 | pulse_checked_color="#4400B0EE", 24 | ): 25 | super().__init__(parent) 26 | 27 | self.setFixedWidth(65) 28 | self.setCursor(Qt.CursorShape.PointingHandCursor) 29 | 30 | # Save our properties on the object via self, so we can access them later 31 | # in the paintEvent. 32 | self._bar_brush = QBrush(bar_color) 33 | self._bar_checked_brush = QBrush(QColor(checked_color).lighter()) 34 | 35 | self._handle_brush = QBrush(handle_color) 36 | self._handle_checked_brush = QBrush(QColor(checked_color)) 37 | 38 | self._pulse_unchecked_animation = QBrush(QColor(pulse_unchecked_color)) 39 | self._pulse_checked_animation = QBrush(QColor(pulse_checked_color)) 40 | 41 | # Setup the rest of the widget. 42 | self.setContentsMargins(8, 0, 8, 0) 43 | self._handle_position = 0 44 | 45 | self._pulse_radius = 0 46 | 47 | self.animation = QPropertyAnimation(self, b"handle_position", self) 48 | self.animation.setEasingCurve(QEasingCurve.Type.InOutCubic) 49 | self.animation.setDuration(200) # time in ms 50 | 51 | self.pulse_anim = QPropertyAnimation(self, b"pulse_radius", self) 52 | self.pulse_anim.setDuration(350) # time in ms 53 | self.pulse_anim.setStartValue(10) 54 | self.pulse_anim.setEndValue(20) 55 | 56 | self.animations_group = QSequentialAnimationGroup() 57 | self.animations_group.addAnimation(self.animation) 58 | self.animations_group.addAnimation(self.pulse_anim) 59 | 60 | self.stateChanged.connect(self.setup_animation) 61 | 62 | def sizeHint(self): 63 | return QSize(58, 45) 64 | 65 | def hitButton(self, pos: QPoint): 66 | return self.contentsRect().contains(pos) 67 | 68 | @pyqtSlot(int) 69 | def setup_animation(self, value): 70 | self.animations_group.stop() 71 | if value: 72 | self.animation.setEndValue(1) 73 | else: 74 | self.animation.setEndValue(0) 75 | self.animations_group.start() 76 | 77 | def paintEvent(self, e: QPaintEvent): 78 | 79 | contRect = self.contentsRect() 80 | handleRadius = round(0.24 * contRect.height()) 81 | 82 | p = QPainter(self) 83 | p.setRenderHint(QPainter.RenderHint.Antialiasing) 84 | 85 | p.setPen(self._transparent_pen) 86 | barRect = QRectF( 87 | 0, 0, 88 | contRect.width() - handleRadius, 0.40 * contRect.height() 89 | ) 90 | barRect.moveCenter(contRect.center().toPointF()) 91 | rounding = barRect.height() / 2 92 | 93 | # the handle will move along this line 94 | trailLength = contRect.width() - 2 * handleRadius 95 | 96 | xPos = contRect.x() + handleRadius + trailLength * self._handle_position 97 | 98 | if self.pulse_anim.state() == QPropertyAnimation.State.Running: 99 | p.setBrush( 100 | self._pulse_checked_animation if 101 | self.isChecked() else self._pulse_unchecked_animation) 102 | p.drawEllipse(QPointF(xPos, barRect.center().y()), 103 | self._pulse_radius, self._pulse_radius) 104 | 105 | if self.isChecked(): 106 | p.setBrush(self._bar_checked_brush) 107 | p.drawRoundedRect(barRect, rounding, rounding) 108 | p.setBrush(self._handle_checked_brush) 109 | 110 | else: 111 | p.setBrush(self._bar_brush) 112 | p.drawRoundedRect(barRect, rounding, rounding) 113 | p.setPen(self._light_grey_pen) 114 | p.setBrush(self._handle_brush) 115 | 116 | p.drawEllipse( 117 | QPointF(xPos, barRect.center().y()), 118 | handleRadius, handleRadius) 119 | 120 | p.end() 121 | 122 | @pyqtProperty(float) 123 | def handle_position(self): 124 | return self._handle_position 125 | 126 | @handle_position.setter 127 | def handle_position(self, pos): 128 | """change the property 129 | we need to trigger QWidget.update() method, either by: 130 | 1- calling it here [ what we doing ]. 131 | 2- connecting the QPropertyAnimation.valueChanged() signal to it. 132 | """ 133 | self._handle_position = pos 134 | self.update() 135 | 136 | @pyqtProperty(float) 137 | def pulse_radius(self): 138 | return self._pulse_radius 139 | 140 | @pulse_radius.setter 141 | def pulse_radius(self, pos): 142 | self._pulse_radius = pos 143 | self.update() -------------------------------------------------------------------------------- /gui/components/progress_bar/ProgressBar_ui.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar, QDialog, QPushButton, QLayout, QScrollArea 2 | from PyQt6.QtCore import Qt 3 | from PyQt6.QtGui import QIcon 4 | from gui.util.svg_icon import QIcon_from_svg 5 | from app.strings import translate 6 | 7 | _BAR_WIDGET_HEIGHT = 70 8 | 9 | class Progress(QWidget): 10 | def __init__(self, parent, can_cancel=True): 11 | super().__init__(parent) 12 | 13 | self._parent = parent 14 | parent.layout().addWidget(self) 15 | 16 | self.setFixedHeight(_BAR_WIDGET_HEIGHT) 17 | self.setStyleSheet('margin: 0') 18 | wrapper = QVBoxLayout() 19 | wrapper_bar = QHBoxLayout() 20 | # error_wrap = QHBoxLayout() 21 | self.setLayout( wrapper ) 22 | 23 | self.progress_info_label = QLabel() 24 | self.progress_info_label.move( 30, 10 ) 25 | self.progress_info_label.adjustSize() 26 | self.progress_info_label.setText('') 27 | wrapper.addWidget( self.progress_info_label ) 28 | 29 | wrapper.addLayout( wrapper_bar ) 30 | 31 | self.progressBar = QProgressBar() 32 | self.progressBar.setValue(0) 33 | self.progressBar.setObjectName("progressbar") 34 | self.progressBar.setVisible(True) 35 | wrapper_bar.addWidget(self.progressBar) 36 | 37 | if can_cancel: 38 | self.cancel_button = QPushButton() 39 | self.cancel_button.clicked.connect(self.remove) 40 | self.cancel_button.setIcon(QIcon(QIcon_from_svg('trash-can-outline', QApplication.instance().ThemeColors.BUTTON_CRITICAL_FONT_COLOR))) 41 | self.cancel_button.setCursor(Qt.CursorShape.PointingHandCursor) 42 | self.cancel_button.setText(translate('cancel')) 43 | self.cancel_button.setProperty('class', 'critical-button') 44 | wrapper_bar.addWidget(self.cancel_button) 45 | 46 | # wrapper.addLayout(error_wrap) 47 | # error = QLabel() 48 | # error.adjustSize() 49 | # error.setText('Some error') 50 | # error.setProperty('class', 'error-text') 51 | # error_wrap.addWidget( error ) 52 | 53 | def setValue(self, value): 54 | self.progressBar.setProperty("value", value) 55 | 56 | def setLabel(self, text): 57 | self.progress_info_label.setText(text) 58 | 59 | def remove(self): 60 | self._parent.remove( self ) 61 | 62 | 63 | class Ui_Form(object): 64 | _progress_bars: list[Progress] = [] 65 | 66 | def setupUi(self: QDialog): 67 | 68 | # self.setLayout( QVBoxLayout() ) 69 | # scrollWidget = QScrollArea( ) 70 | # scrollWidget.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 71 | # self.layout().addWidget(scrollWidget) 72 | # mainWidget = QWidget( scrollWidget ) 73 | 74 | self.setFixedWidth(550) 75 | # self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Minimum) 76 | # self.setSizeGripEnabled(False) 77 | self._layout = QVBoxLayout() 78 | self._layout.setSpacing(0) 79 | self._layout.setAlignment(Qt.AlignmentFlag.AlignTop) 80 | # self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize) 81 | 82 | # self.setLayout(self._layout) 83 | 84 | self.setLayout(self._layout) 85 | 86 | # self.new() 87 | 88 | def new(self: QDialog, value: int = 0, text = ''): 89 | bars = len(self._progress_bars) + 1 90 | if bars == 1: 91 | self.show() 92 | 93 | widget = Progress( self ) 94 | 95 | widget.setValue(value) 96 | 97 | widget.setLabel(text) 98 | 99 | # self._layout.addWidget(widget) 100 | 101 | self._progress_bars.append( widget ) 102 | 103 | # self.setFixedSize(550, self.minimumSizeHint().height()) 104 | 105 | self.setFixedHeight( bars*_BAR_WIDGET_HEIGHT + 22 ) 106 | 107 | return widget 108 | 109 | def remove(self: QDialog, bar: Progress): 110 | if bar in self._progress_bars: 111 | index = self._progress_bars.index( bar ) 112 | widget = self._progress_bars.pop( index ) 113 | self._layout.removeWidget( widget ) 114 | 115 | widget.destroy(True, True) 116 | widget.deleteLater() 117 | 118 | bars = len(self._progress_bars) 119 | 120 | if bars == 0: 121 | app: QMainWindow = self.app.property('MainWindow') 122 | if app and not app.isHidden(): 123 | # When hiding a dialog the main window will also disappear (go in the background/under other programs) 124 | # this will give the main window focus before hiding the dialog box 125 | app.activateWindow() 126 | self.hide() 127 | else: # No app window and all child progress bars removed -> remove this window 128 | self.close() 129 | else: 130 | self.setFixedHeight( bars*_BAR_WIDGET_HEIGHT + 22 ) 131 | 132 | def remove_all(self): 133 | for item in self._progress_bars.copy(): 134 | item.remove() 135 | 136 | def activeBars(self): 137 | return self._progress_bars 138 | 139 | def getProgressBar(self: QDialog, index = 0): 140 | print('remove all call') 141 | if index > len(self._progress_bars) -1: 142 | index = len(self._progress_bars) -1 143 | 144 | return self._progress_bars[index] -------------------------------------------------------------------------------- /gui/components/csv_viewer/table.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from PyQt6.QtWidgets import QWidget, QTableView, QScrollArea, QCheckBox, QMenu, QVBoxLayout, QHBoxLayout, QToolBar, QStyle, QFileDialog 4 | from PyQt6.QtCore import Qt, QSize, QModelIndex 5 | from PyQt6.QtGui import QAction 6 | 7 | from .model import CsvTableModel, DelegateEditor 8 | from ..search_bar import SearchBar 9 | from ..button import Button 10 | 11 | class CSVView(QWidget): 12 | data: list[list[str]] = [] 13 | 14 | def __init__(self, parent = None, data = None): 15 | super().__init__(parent) 16 | 17 | self.setLayout(QVBoxLayout()) 18 | self.layout().setContentsMargins(0,0,0,0) 19 | self.layout().setSpacing(0) 20 | 21 | #---- Toolbar 22 | self.toolbar = QToolBar("CSVToolbar") 23 | self.toolbar.setIconSize(QSize(16, 16)) 24 | self.toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) 25 | self.layout().addWidget(self.toolbar) 26 | 27 | style_csv = self.toolbar.style() 28 | icon = style_csv.standardIcon(QStyle.StandardPixmap.SP_FileIcon) 29 | self.button_csv = QAction(icon, "CSV", self) 30 | self.button_csv.setStatusTip("Export data to CSV file") 31 | self.button_csv.triggered.connect(self.onExportCSV) 32 | self.toolbar.addAction(self.button_csv) 33 | 34 | style_csv = self.toolbar.style() 35 | icon = style_csv.standardIcon(QStyle.StandardPixmap.SP_FileIcon) 36 | self.button_csv = QAction(icon, "JSON", self) 37 | self.button_csv.setStatusTip("Export data to JSON file") 38 | self.button_csv.triggered.connect(self.onExportJSON) 39 | self.toolbar.addAction(self.button_csv) 40 | 41 | #---- CSV Table 42 | self.table = QTableView() 43 | self.table.setModel(CsvTableModel(data)) 44 | self.table.setItemDelegate(DelegateEditor(self.table)) 45 | self.setReadOnly(True) 46 | self.layout().addWidget(self.table) 47 | self.table.setAlternatingRowColors(True) 48 | self.table.resizeColumnsToContents() 49 | 50 | self.header = self.table.horizontalHeader() 51 | self.header.sectionClicked.connect(self.head_clicked) 52 | 53 | self.data = data 54 | 55 | def setReadOnly(self, value: bool): 56 | self.table.model()._setReadOnly(value) 57 | 58 | def onExportJSON(self): 59 | """ Export data to a JSON file """ 60 | file_name, _ = QFileDialog.getSaveFileName(self, 'Export CSV to JSON', '', ".json(*.json)") 61 | if file_name: 62 | result = [] 63 | 64 | for i in range(1, len(self.data)): 65 | currentItem = self.data[i] 66 | itemData = {} 67 | 68 | for index, key in enumerate(self.data[0]): 69 | if currentItem[index] != "": 70 | itemData[key] = currentItem[index] 71 | 72 | result.append(itemData) 73 | 74 | with open(file_name, 'w') as f: 75 | f.write(json.dumps(result, indent=4)) 76 | 77 | def onExportCSV(self): 78 | """ Export data to a CSV file, tab separator """ 79 | file_name, _ = QFileDialog.getSaveFileName(self, 'Export CSV to JSON', '', ".csv(*.csv)") 80 | if file_name: 81 | # self.data will be edited in the self.table.model so there is no need to read the table itself we can just use the data directly 82 | csv = '\n'.join([ '\t'.join(i) for i in self.data]) 83 | 84 | with open(file_name, 'w') as f: 85 | f.write(csv) 86 | 87 | def head_clicked(self, index): 88 | model = self.table.model() 89 | head_menu = QMenu(self) 90 | head_menu_layout = QVBoxLayout() 91 | head_menu.setLayout(head_menu_layout) 92 | head_menu_layout.setSpacing(0) 93 | head_menu_layout.setContentsMargins(0,0,0,0) 94 | head_menu.setFixedWidth(200) 95 | head_menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) 96 | 97 | search = SearchBar(head_menu, placeholder=f'Search {self.table.model().headerData(index, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole)}...') 98 | search.set_focus() 99 | head_menu.layout().addWidget(search) 100 | 101 | wrap_btn = QWidget(head_menu) 102 | wrap_btn.setLayout(QHBoxLayout()) 103 | apply = Button(head_menu, text='Apply', pointer=True) 104 | cancel = Button(head_menu, text='Cancel', pointer=True) 105 | wrap_btn.layout().addWidget(apply) 106 | wrap_btn.layout().addWidget(cancel) 107 | head_menu.layout().addWidget(wrap_btn) 108 | 109 | def close(): 110 | head_menu.close() 111 | head_menu.deleteLater() 112 | 113 | def filter_apply(): 114 | _query = re.compile(search.getValue()) 115 | for i in range(model.rowCount(0)): 116 | data = model.data(model.index(i, index), Qt.ItemDataRole.DisplayRole) 117 | self.table.setRowHidden(i, re.search(_query, data) is None) 118 | close() 119 | 120 | search.search.connect(filter_apply) 121 | 122 | cancel.clicked.connect(close) 123 | 124 | apply.clicked.connect(filter_apply) 125 | 126 | headerPos = self.mapToGlobal(self.header.pos()) 127 | posX = headerPos.x() + self.header.sectionViewportPosition(index) + self.header.sectionSize(index)//2-head_menu.width()//2 128 | posY = self.mapToGlobal(self.header.pos()).y() + self.header.height() 129 | head_menu.move( 130 | posX, 131 | posY 132 | ) 133 | 134 | head_menu.show() -------------------------------------------------------------------------------- /app/util/pack_read.py: -------------------------------------------------------------------------------- 1 | import zipfile 2 | import tarfile 3 | import mmap 4 | from collections import namedtuple 5 | from pathvalidate import sanitize_filepath 6 | from ..constants import KEY, KEY_LEN 7 | from .bytearray import ByteArray 8 | from .types import FileType 9 | from typing import TYPE_CHECKING 10 | if TYPE_CHECKING: from ..pack import DataPack 11 | else: DataPack = None 12 | 13 | # Mimik tarfile info 14 | MemberInfo = namedtuple('MemberInfo', ['name', 'size', 'offset_data', 'mtime', 'isfile']) 15 | 16 | # data pack 17 | E7_PACK_FILE_HEADER_LENGTH = 15 # 11 known and 4 extra 18 | def e7_get_params(header: bytes | bytearray): 19 | 20 | container_length = int.from_bytes( header[ :4 ], byteorder='little', signed=False ) 21 | 22 | path_length = int.from_bytes( header[ 5:6 ], byteorder='little', signed=False ) 23 | 24 | data_length = int.from_bytes( header[ 6:10 ], byteorder='little', signed=False ) 25 | 26 | extra_bytes = list( header[10: ] ) 27 | 28 | return container_length, path_length, data_length, extra_bytes 29 | 30 | 31 | 32 | 33 | class BasePackIO: 34 | def __init__(self, pack: DataPack): 35 | self.pack = pack 36 | self.mmap = pack.mmap() 37 | self.read_bytes = pack.read_bytes 38 | 39 | def next(self) -> MemberInfo | None: 40 | ''' 41 | Get the next member 42 | ''' 43 | return MemberInfo() 44 | 45 | def get_file_content(self, file: FileType): 46 | ''' 47 | Given a file returns that file's content in bytes 48 | ''' 49 | self.mmap.seek(file['offset']) 50 | 51 | return self.mmap.read(file['size']) 52 | 53 | def close(self): 54 | self.mmap.close() 55 | 56 | 57 | class TarPack(BasePackIO): 58 | def __init__(self, pack: DataPack): 59 | super().__init__(pack) 60 | self.tar = tarfile.TarFile(pack.get_path(), 'r') 61 | self.next = self.tar.next # get the next member 62 | 63 | def get_file_content(self, file: FileType): 64 | self.mmap.seek(file['offset']) 65 | 66 | return self.read_bytes(self.mmap, file['offset'], file['size']) 67 | 68 | def close(self): 69 | super().close() 70 | self.tar.close() 71 | 72 | 73 | class ZipPack(BasePackIO): 74 | _next_cursor = 0 75 | 76 | def __init__(self, pack: DataPack): 77 | super().__init__(pack) 78 | self.zip = zipfile.ZipFile(file=pack.get_path(), mode='r') 79 | self.info_table = self.zip.infolist() 80 | 81 | def next(self): 82 | l = len(self.info_table) 83 | 84 | while self._next_cursor < l: 85 | f = self.info_table[self._next_cursor] 86 | 87 | size = getattr( f, 'file_size' ) 88 | 89 | self._next_cursor+=1 90 | 91 | if size: 92 | # file 93 | return MemberInfo(name=f.filename, size=f.file_size, offset_data=0, mtime=0, isfile=lambda:True) 94 | else: 95 | # folder 96 | continue 97 | 98 | return None 99 | 100 | def get_file_content(self, file: FileType): 101 | info = self.zip.getinfo(file['full_path']) 102 | if info: 103 | # Wrap in bytearray to keep the type consistent with the other byte readers 104 | return ByteArray( self.zip.read(info) ) 105 | else: 106 | return ByteArray() 107 | 108 | def close(self): 109 | super().close() 110 | self.zip.close() 111 | 112 | 113 | class EpicSevenDataPack(BasePackIO): 114 | find = None 115 | 116 | def __init__(self, pack): 117 | super().__init__(pack) 118 | self.read_bytes = pack.read_bytes 119 | if pack._is_encrypted: 120 | self.find = self._mmap_encrypted_find 121 | else: 122 | self.find = self.mmap.find 123 | 124 | def _mmap_encrypted_find(self, value, offset = None, stop = None): 125 | f: mmap.mmap = self.mmap 126 | 127 | if offset is not None: 128 | f.seek(offset) 129 | else: 130 | offset = f.tell() 131 | 132 | if not stop: 133 | stop = f.size() - 1 134 | 135 | while offset < stop: 136 | 137 | if f.read_byte() ^ KEY[ offset % KEY_LEN ] == 2: 138 | return offset 139 | 140 | offset += 1 141 | 142 | return -1 143 | 144 | def next(self): 145 | f = self.mmap 146 | pack_read = self.pack.read_bytes 147 | stop = f.size() - 19 148 | 149 | while True: 150 | cursor = self.find(b'\x02', f.tell(), stop) 151 | 152 | if cursor == -1: 153 | break 154 | 155 | f.seek(cursor - 4) 156 | 157 | container_length, path_length, data_length, extra_bytes = e7_get_params(pack_read(f, cursor - 4, E7_PACK_FILE_HEADER_LENGTH)) 158 | 159 | if container_length != path_length + data_length + 19: # Not a valid file, continue looking 160 | f.seek(cursor + 1) 161 | else: 162 | try: 163 | # if the path is not a clean "utf-8" it will raise an exception without the "ignore" 164 | # if the it's actually a file it won't trigger the exception 165 | name = sanitize_filepath( pack_read(f, f.tell(), path_length).decode("utf-8") ) 166 | offset_data = f.tell() 167 | f.seek(offset_data + data_length) 168 | return MemberInfo(name=name, size=data_length, offset_data=offset_data, mtime=extra_bytes, isfile=lambda: True) 169 | except UnicodeDecodeError: 170 | f.seek(cursor+1) 171 | 172 | return None 173 | 174 | def get_file_content(self, file: FileType): 175 | self.mmap.seek(file['offset']) 176 | return self.read_bytes(self.mmap, file['offset'], file['size']) 177 | 178 | 179 | def PackFileScanner(pack: DataPack): 180 | if pack._type == 'zip': 181 | return ZipPack(pack) 182 | elif pack._type == 'tar': 183 | return TarPack(pack) 184 | elif pack._type == 'pack': 185 | return EpicSevenDataPack(pack) 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EpicSevenAssetRipper 2 | 3 | > [!TIP] 4 | > This tool supports both Epic Seven (E7) and Chaos Zero Nightamre (CZN) 5 | 6 | image 7 | 8 | # Installation: 9 | 10 | ## Requirements 11 | Python 3.11+ 12 | 13 | ## How to install 14 | 15 | [Download the latest version](https://github.com/CeciliaBot/EpicSevenAssetRipper/releases/latest) and extract all the files in a folder of your choice. 16 | 17 | Open the command prompt (hold shift + right click inside the folder -> Power Shell on windows) and type 18 | 19 | pip install -r requirements.txt 20 | 21 | This should take care of all the dependencies required 22 | 23 | Now you can double click main.py or type py main.py in the command prompt to run the GUI 24 | 25 | A folder named data.pack will be created, you can use this folder to organize your files or just ignore it 26 | 27 | 28 | ## SCT to PNG 29 | To use the SCT to PNG 3 additional installations are required 30 | 31 | python3 -m pip install --upgrade Pillow 32 | 33 | python3 -m pip install texture2ddecoder 34 | 35 | python3 -m pip install lz4 36 | 37 | # How to Use 38 | 39 | After installing all the requirements open run.bat or run_no_console.vbs (if you don't want the console) 40 | 41 | image 42 | 43 | 1. Select the data.pack to use (this tool supports data.pack, .tar, .zip (No password) 44 | 2. Generate file tree or load file tree from json file. After generating this tool will automatically save the result to tree.json in the same folder as the data.pack, this tool will also try to automatically load the "tree.json" file in the same directory as the data.pack 45 | 3. All the assets inside the data.pack will now be displayed in the file tree. You can select and right click files to extract or preview. (You can enable automatic preview in the settings tab) Preview is only available for images only as of now. 46 | 4. (Optional) You can use the compare function to remove unchanged nodes when comparing to older file trees 47 | 5. You can enable and disable plugins/hooks by clicking the name at the bottom right of the window 48 | 49 | 50 | # What's new in 2.0 51 | SCT to PNG is now included, this hook allows to decode and convert sct and sct2 files to png 52 | 53 | Multiselect in the file tree: you can hold CTRL + Left click to select different nodes in the file tree view 54 | 55 | Reload hooks: Click the small refresh icon at the bottom left to relaod hooks (hooks dependencies wont be reloaded) 56 | 57 | Settings tab: Here you can change some settings 58 | 59 | Light and Dark themes: Improvments to the dark theme and new light theme 60 | 61 | # Hooks 62 | 63 | Hooks can be disabled by clicking the Hook's name at the bottom right of the window 64 | 65 | ## Custom hooks 66 | 67 | You can create your custom hooks to handle different file extensions. This tool provides the SCT to PNG and Webp loop hooks. 68 | 69 | To create a hook create a python file named after the extension/file format you want to handle. Inside this file you need to define a main function that takes only one argument 70 | 71 | ``` 72 | _ADDON_NAME_ = 'ATLAS' # The name of this hook, it will be displayed in the bottom right corner of the Asset Ripper 73 | _PREVENT_DEFAULT_ICON_ = True # Optional: default False. If set to True the icons in the bottom toolbar for this hook won't be created and displayed. 74 | 75 | 76 | from app.util.misc import FileDescriptor # only used for type hinting, you can skip this import 77 | 78 | # This is the main function and only thing required for the hooks to work. It will be called everytime a file extension matches this files name for example 'sct.py' 79 | def main(file: FileDescriptor): 80 | # the file argument is a class object 81 | content = file.bytes # -> This is a ByteArray which is a bytearray subclass with additional methods like seek, tell, read 82 | path = file.path # -> Destination path, set this to None if you want to prevent the default save function and handle it in your hook 83 | info = file.tree_file # -> The tree data 84 | thread = file.thread # -> You can check is_stopping() to check if the proccess was interrupted by the user or call progress((int, str)) NOTE: progress requires a tuple 85 | written = file.written # -> Check if the file has been written, this should be true if the hook is in the after_write folder 86 | 87 | # Optional 88 | def onEnabled(): 89 | # Do something to update the ui... 90 | # this will be called only when switching from disabled to enabled, this function won't be called when the script is loaded 91 | 92 | # Optional 93 | def onDisabled(): 94 | # Called when this hook has been disabled by clicking the hook's name in the bottom toolbar of the tool 95 | # it can be used to update the ui or the previewer 96 | 97 | # Optional 98 | def destroy(): 99 | # This function is only called if the refresh hooks button has been clicked and this script is no longer available in the hooks folder 100 | 101 | ``` 102 | 103 | ## Update UI 104 | 105 | If you need to update the ui you should first check if the ui is loaded: 106 | ``` 107 | if 'PyQt6' in sys.modules: 108 | # The ui was initialized 109 | from PyQt6.QtWidgets import QApplication, QWidget 110 | app = QApplication.instance() 111 | add_setting = app.property('CreateSetting') 112 | add_setting(title=f'[My Hook] Setting Title', description='', value=False, type='checkbox', options=[], onchanged=lambda v: print(v)) 113 | else: 114 | # No ui 115 | ``` 116 | 117 | For example the code above will create a new option in the settings tab 118 | 119 | `if 'PyQt6' in sys.modules:` is used to check if this tool is running as a GUI or not. `app = QApplication.instance()` returns None if the ui is not initialized. 120 | 121 | ## Adding a preview type for a file extension 122 | You can add a preview type from the supported components [image, csv (tab), text] for a specific file type by calling the following function 123 | ``` 124 | from gui.components.preview import FileContentPreview 125 | FileContentPreview.setPreviewType('stc', 'image') 126 | ``` 127 | 128 | You can do the same to remove a preview type 129 | ``` 130 | from gui.components.preview import FileContentPreview 131 | FileContentPreview.deletePreviewType('stc') 132 | ``` 133 | -------------------------------------------------------------------------------- /gui/components/update_dialog.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import QCoreApplication, Qt 2 | from PyQt6.QtWidgets import QWidget, QLabel, QPushButton, QSizePolicy 3 | from PyQt6.QtGui import QFont 4 | 5 | from app.constants import VERSION 6 | from .dialog_window import ScrollableDialogWindow 7 | from .button import Button 8 | from app.update import download_update, update_check, unpack_update 9 | from ..util.threads import ThreadPool, Worker 10 | from app.strings import translate 11 | 12 | import datetime 13 | 14 | class UpdateDialog(ScrollableDialogWindow): 15 | download_link: str = None 16 | 17 | def __init__(self, parent=None): 18 | super().__init__(parent, title=translate('version_update_title')) 19 | 20 | self.check_for_updates() 21 | 22 | self.show() 23 | 24 | def check_for_updates(self): 25 | self.checking_for_updates_view() 26 | checker = Worker(update_check) 27 | checker.signals.result.connect(self.update_checker_done) 28 | ThreadPool.start(checker) 29 | 30 | def update_checker_done(self, args, result: list | None): 31 | if result is None: 32 | self.no_update_found_view() 33 | elif len(result) == 0: 34 | self.no_update_found_view() 35 | else: 36 | self.download_link = result[0]['zip_source'] 37 | self.update_changelog_view(result) 38 | 39 | def download_and_apply_patch(self): 40 | download_update(self.download_link) 41 | unpack_update() 42 | 43 | def start_update_download(self): 44 | self.checking_for_updates_view() 45 | downloader = Worker(self.download_and_apply_patch) 46 | downloader.signals.result.connect(self.download_complete_view) 47 | downloader.signals.error.connect(self.download_error_view) 48 | ThreadPool.start(downloader) 49 | 50 | def restart_now(self): 51 | import os 52 | import sys 53 | app = QCoreApplication.instance() 54 | window: QWidget = app.property('MainWindow') 55 | window.close() 56 | window.deleteLater() 57 | self.close() 58 | app.quit() 59 | os.execv(sys.executable, ['python'] + sys.argv) 60 | 61 | 62 | 63 | 64 | def clear_view(self): 65 | self.clear_header() 66 | self.clear_body() 67 | self.clear_footer() 68 | 69 | def checking_for_updates_view(self): 70 | self.clear_view() 71 | l = QLabel(self) 72 | l.setAlignment(Qt.AlignmentFlag.AlignCenter) 73 | l.setText(translate('update_checking')) 74 | self.body.layout().addWidget(l) 75 | 76 | def no_update_found_view(self): 77 | self.clear_view() 78 | l = QLabel(self) 79 | l.setText(translate('current_is_latest_version')) 80 | self.body.layout().addWidget(l) 81 | self.body.layout().setAlignment(l, Qt.AlignmentFlag.AlignCenter) 82 | 83 | close_btn = QPushButton(self) 84 | close_btn = Button(self, translate('close'), pointer = True) 85 | close_btn.clicked.connect(self.close) 86 | self.footer.layout().addWidget(close_btn) 87 | 88 | def update_changelog_view(self, versions = []): 89 | self.clear_view() 90 | 91 | font_title = QFont() 92 | font_title.setPixelSize(18) 93 | font_title.setBold(True) 94 | 95 | l = QLabel(self) 96 | l.setText(translate('version_update_info').format(VERSION, versions[0]['version'])) 97 | self.header.layout().addWidget(l) 98 | 99 | self.body.layout().setAlignment(Qt.AlignmentFlag.AlignTop) 100 | self.layout().setSpacing(10) 101 | 102 | for version in versions: 103 | l = QLabel(self) 104 | l.setText(f"⦿ {version['version']} - {datetime.datetime.fromisoformat(version['date']).strftime('%d %b %Y %H:%M')}") 105 | l.setFont(font_title) 106 | self.body.layout().addWidget(l) 107 | 108 | l = QLabel(self) 109 | l.setText(version['changelog']) 110 | l.setWordWrap(True) 111 | l.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 112 | l.setContentsMargins(25,0,0,0) 113 | self.body.layout().addWidget(l) 114 | 115 | if self.download_link: 116 | update_btn = Button(self, translate('update_download'), pointer = True) 117 | update_btn.clicked.connect(self.start_update_download) 118 | self.footer.layout().addWidget(update_btn) 119 | 120 | close_btn = Button(self, translate('close'), pointer = True) 121 | close_btn.clicked.connect(self.close) 122 | self.footer.layout().addWidget(close_btn) 123 | 124 | self.setMinimumHeight(400) 125 | 126 | def downloading_update_view(self): 127 | self.clear_view() 128 | l = QLabel(self) 129 | l.setAlignment(Qt.AlignmentFlag.AlignCenter) 130 | l.setText(translate('update_downloading_info')) 131 | self.body.layout().addWidget(l) 132 | 133 | def download_complete_view(self): 134 | self.clear_view() 135 | l = QLabel(self) 136 | l.setAlignment(Qt.AlignmentFlag.AlignCenter) 137 | l.setText(translate('update_download_complete')) 138 | self.body.layout().addWidget(l) 139 | 140 | restart_btn = Button(self, translate('restart_now'), pointer = True) 141 | restart_btn.clicked.connect(self.restart_now) 142 | self.footer.layout().addWidget(restart_btn) 143 | 144 | close_btn = QPushButton(self) 145 | close_btn = Button(self, translate('restart_later'), pointer = True) 146 | close_btn.clicked.connect(self.close) 147 | self.footer.layout().addWidget(close_btn) 148 | 149 | def download_error_view(self): 150 | self.clear_view() 151 | l = QLabel(self) 152 | l.setAlignment(Qt.AlignmentFlag.AlignCenter) 153 | l.setText(translate('update_download_error')) 154 | self.body.layout().addWidget(l) 155 | 156 | restart_btn = Button(self, translate('retry'), pointer = True) 157 | restart_btn.clicked.connect(self.start_update_download) 158 | self.footer.layout().addWidget(restart_btn) 159 | 160 | close_btn = QPushButton(self) 161 | close_btn = Button(self, translate('close'), pointer = True) 162 | close_btn.clicked.connect(self.close) 163 | self.footer.layout().addWidget(close_btn) -------------------------------------------------------------------------------- /app/hooks/before_write/sct.py: -------------------------------------------------------------------------------- 1 | _ADDON_NAME_ = 'SCT to PNG' 2 | 3 | from ...util.misc import FileDescriptor 4 | from ...constants import TEMP_FOLDER 5 | 6 | import os 7 | import io 8 | import PIL.Image 9 | import lz4.block 10 | from texture2ddecoder import decode_astc, decode_etc2a8 11 | 12 | # --------- UI updater 13 | import sys 14 | from ...settings import _setter, config 15 | from ...constants import IMG_FORMATS 16 | SCT_AS_IMGAE_FORMAT = config.getint('PLUGIN', 'SctAsImageFormat', fallback=0) 17 | widget = None 18 | 19 | def addSctFormat(v: bool): 20 | if v: 21 | if not 'sct' in IMG_FORMATS: 22 | IMG_FORMATS.append('sct') 23 | else: 24 | if 'sct' in IMG_FORMATS: 25 | IMG_FORMATS.remove('sct') 26 | 27 | addSctFormat(SCT_AS_IMGAE_FORMAT) 28 | 29 | def changed(val): 30 | global SCT_AS_IMGAE_FORMAT 31 | SCT_AS_IMGAE_FORMAT = val != 0 32 | addSctFormat(SCT_AS_IMGAE_FORMAT) 33 | _setter('PLUGIN', 'SctAsImageFormat', 1 if SCT_AS_IMGAE_FORMAT else 0) 34 | 35 | if 'PyQt6' in sys.modules: 36 | from PyQt6.QtWidgets import QApplication, QWidget 37 | app = QApplication.instance() 38 | if app: 39 | add_setting = app.property('CreateSetting') 40 | 41 | if add_setting: 42 | widget: QWidget = add_setting(title=f'[{_ADDON_NAME_}] SCT as image format', description='Include .sct files when using the "Extract images only" option in the tree view.', value=SCT_AS_IMGAE_FORMAT, type='checkbox', options=[], onchanged=changed) 43 | 44 | def onEnabled(): 45 | if 'PyQt6' in sys.modules: 46 | from gui.components.preview import FileContentPreview 47 | FileContentPreview.setPreviewType('sct', 'image') 48 | def onDisabled(): 49 | if 'PyQt6' in sys.modules: 50 | from gui.components.preview import FileContentPreview 51 | FileContentPreview.deletePreviewType('sct') 52 | onEnabled() 53 | 54 | def destroy(): 55 | global widget 56 | if widget: 57 | widget.parentWidget().layout().removeWidget(widget) 58 | widget.deleteLater() 59 | widget = None 60 | addSctFormat(False) 61 | 62 | 63 | 64 | 65 | 66 | # ------- Main 67 | def main(file: FileDescriptor): 68 | 69 | dest_path = file.path 70 | data_bytes = file.bytes 71 | info = file.tree_file 72 | 73 | fm = data_bytes 74 | 75 | fm.seek(0) 76 | 77 | sct_sign = fm.read(3) 78 | 79 | is_sct2 = fm.read(1) == b'\x32' 80 | 81 | if is_sct2 is not True: # Old format 82 | 83 | byte_format = fm.read_uint8() 84 | 85 | width = fm.read_uint16() 86 | 87 | height = fm.read_uint16() 88 | 89 | uncompressed_size = fm.read_uint32() 90 | 91 | compressed_size = fm.read_uint32() 92 | 93 | byte_data = fm.read( compressed_size ) 94 | 95 | data = lz4.block.decompress( byte_data, uncompressed_size = uncompressed_size ) 96 | 97 | else: 98 | dataLen = fm.read_uint32() 99 | _ = fm.read_uint32() 100 | offset = fm.read_uint32() 101 | block_width = block_height = fm.read_uint32() 102 | byte_format = fm.read_uint32() # 40 for normal images, 19 for some smaller ones 103 | width = fm.read_uint16() 104 | height = fm.read_uint16() 105 | width2 = fm.read_uint16() 106 | height2 = fm.read_uint16() 107 | fm.seek(offset) 108 | uncompressed_size = fm.read_uint32() 109 | compressed_size = fm.read_uint32() 110 | 111 | if compressed_size == dataLen - 80: 112 | byte_data = fm.read( compressed_size ) 113 | data = lz4.block.decompress( byte_data, uncompressed_size = uncompressed_size ) 114 | else: 115 | fm.seek(offset) 116 | byte_data = fm.read( compressed_size ) 117 | data = byte_data 118 | 119 | if is_sct2: 120 | match byte_format: 121 | case 19: 122 | image_data = decode_etc2a8(data, width, height) 123 | case 40: 124 | image_data = decode_astc(data, width, height, 4, 4) 125 | case 44: # face/portrait/30009.sct face/portrait/20020.sct 126 | image_data = decode_astc(data, width, height, 6, 6) 127 | case 47: 128 | image_data = decode_astc(data, width, height, 8, 8) 129 | case _: 130 | raise Exception(f'Unknown SCT2 byte format for file {info["full_path"]} byte format f{byte_format}') 131 | 132 | image = PIL.Image.frombytes('RGBA', (width, height), image_data, 'raw', 'BGRA') 133 | else: 134 | match byte_format: 135 | case 2: 136 | image = pil_image_RGBA32( data, width, height ) 137 | 138 | case 4: 139 | image = pil_image_RGB16_A( data, width, height ) 140 | 141 | case 102: # czn -> lucas_attack_play2_bg_s.sct 142 | image = PIL.Image.frombytes('L', (width, height), data) 143 | 144 | case _: 145 | raise Exception(f'Unknown SCT byte format for file {info["full_path"]} byte format {byte_format}') 146 | 147 | 148 | if dest_path is None: 149 | file.bytes.clear() 150 | file.bytes += image_to_byte_array( image ) 151 | else: 152 | # check if it's a drag and drop operation 153 | # if it is moving file to the temp folder then don't change the file name! 154 | if os.path.dirname(dest_path) == TEMP_FOLDER : 155 | file_name = dest_path 156 | else: 157 | file_name = dest_path.replace(".sct", ".png") 158 | 159 | file.path = None #dest_path.replace(".sct", ".png") 160 | image.save( file_name, 'PNG' ) 161 | 162 | def pil_image_RGB16_A(data, width, height): 163 | img = PIL.Image.frombytes('RGB', (width, height), data, 'raw', 'BGR;16', 0, 1) 164 | alpha = PIL.Image.frombytes('L', (width, height), data[-width*height:]) 165 | img.putalpha(alpha) 166 | return img 167 | 168 | def pil_image_RGBA32(data, width, height): 169 | return PIL.Image.frombytes('RGBA', (width, height), data, 'raw', 'RGBA') 170 | 171 | 172 | def image_to_byte_array(image: PIL.Image, format='PNG') -> bytes: 173 | # BytesIO is a file-like buffer stored in memory 174 | imgByteArr = io.BytesIO() 175 | # image.save expects a file-like as a argument 176 | image.save(imgByteArr, format=format) 177 | # Turn the BytesIO object back into a bytes object 178 | imgByteArr = imgByteArr.getvalue() 179 | return imgByteArr -------------------------------------------------------------------------------- /app/load_hooks.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | import os 4 | import re 5 | from pathlib import Path 6 | from types import ModuleType 7 | from typing import Literal, TYPE_CHECKING 8 | import traceback 9 | 10 | if TYPE_CHECKING: from app.util.misc import FileDescriptor 11 | else: FileDescriptor = None 12 | 13 | TimingsType = Literal['before', 'after'] 14 | 15 | class HookClass: 16 | module: ModuleType = None 17 | _is_enabled = True 18 | def __init__(self, path: str = '', file_name: str = '', timing: TimingsType = 'before', module = None): 19 | self.path = path 20 | self.file_name = file_name 21 | self.timing = timing 22 | self.import_name = f'app.hooks.{self.timing}_write.{self.file_name}' 23 | 24 | try: 25 | self.module = self._load_module() 26 | self._is_enabled = getattr(self.module, '_IS_ENABLED_') 27 | except Exception: 28 | pass 29 | 30 | def __load_module__(self): 31 | spec = importlib.util.spec_from_file_location(self.import_name, os.path.join(self.path, f'{self.file_name}.py')) 32 | module = importlib.util.module_from_spec(spec) 33 | sys.modules[spec.name] = module 34 | spec.loader.exec_module(module) 35 | return module 36 | 37 | def _load_module(self, reload=False) -> ModuleType: 38 | python_path_style = self.import_name 39 | still_exists = self._exists() 40 | 41 | # Check for all possible conditions 42 | if still_exists: 43 | if reload: 44 | return self.__load_module__() 45 | elif python_path_style in sys.modules: 46 | return sys.modules[python_path_style] 47 | elif not self.module: 48 | return self.__load_module__() 49 | else: 50 | return self.module 51 | elif not still_exists and reload: # If a reload was requested but the file has been deleted then return null and in the reload function destroy this instance 52 | return None 53 | else: 54 | return self.module 55 | 56 | def _exists(self): 57 | try: 58 | return os.path.exists( os.path.join(self.path, self.file_name + '.py') ) 59 | except Exception: 60 | return False 61 | 62 | def reload(self): 63 | self.destroy() 64 | self.module = self._load_module(reload=True) 65 | return self.module 66 | 67 | def get_name(self): 68 | try: 69 | name = getattr(self.module, '_ADDON_NAME_') 70 | except AttributeError: 71 | name = self.file_name 72 | except Exception as e: 73 | name = self.file_name 74 | print(e) 75 | return name 76 | 77 | def get_is_enabled(self): 78 | return self._is_enabled 79 | # try: 80 | # return getattr(self.module, '_IS_ENABLED_') 81 | # except Exception: 82 | # return True 83 | 84 | def set_is_enabled(self, value: bool): 85 | # return setattr(self.module, '_IS_ENABLED_', value) 86 | try: 87 | self.module.onEnabled() if value == True else self.module.onDisabled() 88 | except AttributeError: 89 | pass 90 | except Exception as e: 91 | print(e) 92 | return setattr(self, '_is_enabled', value) 93 | 94 | def get_has_icon(self): 95 | return getattr(self.module, '_PREVENT_DEFAULT_ICON_', False) != True 96 | 97 | def exec(self, *args, **kwargs): 98 | try: 99 | if self.get_is_enabled(): 100 | self.module.main(*args, **kwargs) 101 | except Exception as e: 102 | traceback.print_exc() 103 | 104 | def on_before_destroy(self): 105 | pass 106 | 107 | def destroy(self): 108 | self.on_before_destroy() 109 | 110 | try: 111 | # Each module can provide a custom clean up destroy method 112 | self.module.destroy() 113 | except Exception: 114 | pass 115 | finally: 116 | self.module = None 117 | 118 | 119 | 120 | 121 | hooks: dict[TimingsType, dict[str, HookClass]] = { 122 | 'before': {}, 123 | 'after': {} 124 | } 125 | 126 | 127 | def load_hooks(gui_create=None): 128 | global hooks 129 | cfile_path = os.path.split(__file__)[0] 130 | errors = 0 131 | success = 0 132 | 133 | # Create folders if missing 134 | Path( cfile_path, 'hooks', 'before_write').mkdir(parents=True, exist_ok=True) 135 | Path( cfile_path, 'hooks', 'after_write').mkdir(parents=True, exist_ok=True) 136 | 137 | try: 138 | for hook in ['before', 'after']: 139 | 140 | # Reload Existing hooks 141 | keys = list(hooks[hook]) 142 | for h in keys: 143 | l = hooks[hook][h] 144 | l.reload() 145 | if l.module: 146 | if gui_create: 147 | gui_create(l) 148 | success+=1 149 | else: # If after reloading the module is None then the file has been deleted 150 | del hooks[hook][h] 151 | l.destroy() 152 | 153 | hook_folder_path = os.path.join(cfile_path, 'hooks', f'{hook}_write') 154 | 155 | files = os.listdir(hook_folder_path) 156 | for file in files: 157 | target_extension = re.search(r"^([^ ]+)\.py$", file) 158 | if target_extension: 159 | ext = target_extension.group(1) 160 | 161 | if ext in hooks[hook]: # Module was refreshed, no need to create another instance 162 | continue 163 | 164 | try: 165 | plugin = HookClass(file_name=ext, path=hook_folder_path, timing=hook) 166 | if plugin.module is None: 167 | continue 168 | except Exception as e: 169 | print(ext, traceback.format_exc()) 170 | errors+=1 171 | continue 172 | 173 | hooks[hook][ext] = plugin 174 | if gui_create and plugin.get_has_icon(): 175 | gui_create(plugin) 176 | 177 | success+=1 178 | 179 | except Exception as e: 180 | errors+=1 181 | print(e) 182 | 183 | 184 | return success, errors 185 | 186 | 187 | # █████████████████████████████████████████████████ 188 | def call_hooks(timing: TimingsType, file: FileDescriptor): 189 | ''' 190 | Use the hook for the file type. Timing can be "before" and "after" the writing phase 191 | ''' 192 | module = hooks[timing].get(file.tree_file['format']) 193 | if module: 194 | module.exec(file) 195 | -------------------------------------------------------------------------------- /gui/tabs/decrypt/main.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QWidget, QHBoxLayout, QVBoxLayout, QProgressBar, QCheckBox, QFileDialog 2 | from PyQt6.QtCore import pyqtSlot, Qt 3 | 4 | from app.strings import translate 5 | from app.pack import DataPack 6 | from app.full_decrypt import decrypt_and_write, extract_all 7 | from gui.components.button import Button 8 | from gui.util.thread_process import QtThreadedProcess 9 | 10 | 11 | class CreateTab(QWidget): 12 | order = 0 13 | _delete_encrypted_pack_after_decrypt = False 14 | _delete_decrypted_pack_after_extract = False 15 | 16 | encrypted_pack: DataPack = None 17 | decrypted_pack_destination: str = None 18 | 19 | pack_4_extraction: DataPack = None 20 | exctraction_path: str = None 21 | 22 | def __init__(self, parent: QWidget | QApplication): 23 | super().__init__(parent) 24 | self.name = f"{translate('decrypt')}/{translate('extract')}" 25 | vertical = QVBoxLayout() 26 | vertical.setSpacing(20) 27 | self.setLayout(vertical) 28 | row1 = QHBoxLayout() 29 | row2 = QHBoxLayout() 30 | row3 = QHBoxLayout() 31 | row1.setSpacing(5) 32 | row2.setSpacing(5) 33 | vertical.addLayout(row1) 34 | vertical.addLayout(row2) 35 | vertical.addLayout(row3) 36 | vertical.addStretch() 37 | 38 | # Row 1 39 | select_encrypted = Button(self, text=translate('select_encrypted_pack'), pointer=True) 40 | select_encrypted.clicked.connect(self.select_encrypted_pack) 41 | row1.addWidget(select_encrypted) 42 | row1.setStretchFactor(select_encrypted, 2) 43 | 44 | decrypt_btn = Button(self, text=translate('output_path'), pointer=True) 45 | decrypt_btn.clicked.connect(self.select_decrypt_output) 46 | row1.addWidget(decrypt_btn) 47 | row1.setStretchFactor(decrypt_btn, 2) 48 | 49 | decrypt_btn = Button(self, text=translate('decrypt'), disabled=True, pointer=True) 50 | row1.addWidget(decrypt_btn) 51 | row1.setStretchFactor(decrypt_btn, 1) 52 | decrypt_btn.clicked.connect(self.start_decrypting) 53 | self.decrypt_btn = decrypt_btn 54 | 55 | 56 | self.decrypt_progressbar = QProgressBar(self) 57 | self.decrypt_progressbar.setObjectName("full_decrypt_progress_bar") 58 | row1.addWidget(self.decrypt_progressbar) 59 | row1.setStretchFactor(self.decrypt_progressbar, 5) 60 | 61 | 62 | 63 | # Row2 64 | select_decrypted = Button(self, text=translate('select_decrypted_pack'), pointer=True) 65 | select_decrypted.clicked.connect(self.select_decrypted_pack) 66 | row2.addWidget(select_decrypted) 67 | row2.setStretchFactor(select_decrypted, 2) 68 | 69 | extr_path_btn = Button(self, text=translate('output_path'), pointer=True) 70 | extr_path_btn.clicked.connect(self.select_extract_output_folder) 71 | row2.addWidget(extr_path_btn) 72 | row2.setStretchFactor(extr_path_btn, 2) 73 | 74 | extract_btn = Button(self, text=translate('extract'), disabled=True, pointer=True) 75 | extract_btn.clicked.connect(self.start_extracting) 76 | row2.addWidget(extract_btn) 77 | row2.setStretchFactor(extract_btn, 1) 78 | self.extract_btn = extract_btn 79 | 80 | 81 | self.extraction_progressbar = QProgressBar(self) 82 | self.extraction_progressbar.setObjectName("full_extract_progress_bar") 83 | row2.addWidget(self.extraction_progressbar) 84 | row2.setStretchFactor(self.extraction_progressbar, 5) 85 | 86 | 87 | 88 | # TODO 89 | self.checkbox_delete_pack_after = QCheckBox("Delete data.pack after decryption", self) 90 | self.checkbox_delete_pack_after.setChecked(self._delete_encrypted_pack_after_decrypt) 91 | self.checkbox_delete_pack_after.checkStateChanged.connect(self.changed_delete_encrypted_pack) 92 | row3.addWidget(self.checkbox_delete_pack_after) 93 | self.checkbox_delete_pack_after.setHidden(True) 94 | 95 | # TODO 96 | self.delete_after_extract = QCheckBox("Delete decrypted data.pack after extraction", self) 97 | self.delete_after_extract.setChecked(self._delete_decrypted_pack_after_extract) 98 | self.delete_after_extract.checkStateChanged.connect(self.changed_delete_decrypted_pack) 99 | row3.addWidget(self.delete_after_extract) 100 | self.delete_after_extract.setHidden(True) 101 | 102 | def update_btn_state(self): 103 | if self.encrypted_pack and self.decrypted_pack_destination: 104 | self.decrypt_btn.setEnabled(True) 105 | 106 | if self.pack_4_extraction and self.exctraction_path: 107 | self.extract_btn.setEnabled(True) 108 | 109 | @pyqtSlot() 110 | def select_encrypted_pack(self): 111 | fname, ext = QFileDialog.getOpenFileName( 112 | self, 113 | translate('select_encrypted_pack'), 114 | "", 115 | "Epic Seven data pack (*.pack)" 116 | ) 117 | if fname: 118 | try: 119 | pack = DataPack(fname) 120 | if pack._is_encrypted != True: 121 | raise Exception('Not encrypted!') 122 | self.encrypted_pack = pack 123 | except: 124 | pass 125 | 126 | self.update_btn_state() 127 | 128 | @pyqtSlot() 129 | def select_decrypt_output(self): 130 | fname, ext = QFileDialog.getSaveFileName( 131 | self, 132 | translate('output_path'), 133 | "", 134 | "Pack (*.pack *.zip *.tar);;", # ;; to allow all files 135 | ) 136 | if fname: 137 | self.decrypted_pack_destination = fname 138 | self.update_btn_state() 139 | 140 | @pyqtSlot() 141 | def select_decrypted_pack(self): 142 | fname, ext = QFileDialog.getOpenFileName( 143 | self, 144 | translate('select_decrypted_pack'), 145 | "", 146 | "Epic Seven data pack (*.pack)", 147 | ) 148 | if fname: 149 | try: 150 | self.pack_4_extraction = DataPack( fname ) 151 | except Exception: 152 | pass 153 | 154 | self.update_btn_state() 155 | 156 | @pyqtSlot() 157 | def select_extract_output_folder(self): 158 | fname = QFileDialog.getExistingDirectory( 159 | self, 160 | translate('output_path'), 161 | "", 162 | ) 163 | if fname: 164 | self.exctraction_path = fname 165 | self.update_btn_state() 166 | 167 | def changed_delete_encrypted_pack(self, event: Qt.CheckState): 168 | self._delete_encrypted_pack_after_decrypt = event.value == Qt.CheckState.Checked.value 169 | 170 | def changed_delete_decrypted_pack(self, event: Qt.CheckState): 171 | self._delete_decrypted_pack_after_extract = event.value == Qt.CheckState.Checked.value 172 | 173 | def start_decrypting(self): 174 | self.decrypt_progressbar.setValue(0) 175 | 176 | worker = QtThreadedProcess(self.encrypted_pack, decrypt_and_write, self.encrypted_pack, self.decrypted_pack_destination) 177 | QApplication.instance().property('MainWindow').closed.connect(worker.stop) # Stop running as soon as the main window is closed 178 | worker.onprogress = lambda a: self.decrypt_progressbar.setValue(a[0]) 179 | worker.run() 180 | 181 | def start_extracting(self): 182 | self.extraction_progressbar.setValue(0) 183 | 184 | worker = QtThreadedProcess(self.encrypted_pack, extract_all, self.pack_4_extraction, self.exctraction_path) 185 | QApplication.instance().property('MainWindow').closed.connect(worker.stop) # Stop running as soon as the main window is closed 186 | worker.onprogress = lambda a: self.extraction_progressbar.setValue(a[0]) 187 | worker.run() -------------------------------------------------------------------------------- /app/util/tree.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .thread import ThreadData 3 | from .exceptions import OperationAbortedByUser 4 | from .types import FolderType, FileType 5 | from typing import TYPE_CHECKING 6 | from .pack_read import PackFileScanner 7 | 8 | 9 | if TYPE_CHECKING: 10 | from ..pack import DataPack 11 | else: 12 | DataPack = None 13 | 14 | def create_folder(name: str, files = 0, size = 0, children: list[FileType, FolderType] = None) -> FolderType: 15 | return { 16 | 'type': 'folder', 17 | 'name': name, 18 | 'files': files, 19 | 'size': size, 20 | 'children': children or [] 21 | } 22 | 23 | def create_file(name:str, full_name: str, size:int=0, offset:int=0, extra_bytes=None ) -> FileType: 24 | d = { 25 | 'type': 'file', 26 | 'name': name, 27 | 'full_path': full_name, 28 | 'format': os.path.splitext(full_name)[1][1:], 29 | 'size': size, 30 | 'offset': offset, 31 | } 32 | if extra_bytes: 33 | d['extra_bytes'] = extra_bytes 34 | 35 | return d 36 | 37 | def folder_increase_size_files(folder: FolderType, size_change: int = 0, files_count: int = 0): 38 | folder['size'] += size_change 39 | folder['files'] += files_count 40 | 41 | 42 | # class PackFileScanner: 43 | # pack = None 44 | # file = None 45 | # find = None 46 | # temp_zip_arr: list[zipfile.ZipInfo] = [] 47 | # offset = 0 48 | 49 | # def __init__(self, pack: DataPack): 50 | # self.pack = pack 51 | # if pack._type == 'tar': 52 | # self.file = tarfile.TarFile(pack._path, 'r') 53 | # self.next = self.file.next 54 | 55 | # elif pack._type == 'zip': 56 | # self.file = zipfile.ZipFile(pack._path, 'r') 57 | # self.temp_zip_arr = self.file.infolist() 58 | # self.next = self._zip_next 59 | 60 | # else: 61 | # self.file = pack.mmap() 62 | # self.next = self._pack_next 63 | # if pack._is_encrypted: 64 | # self.find = self._mmap_encrypted_find 65 | # else: 66 | # self.find = self.file.find 67 | 68 | # def _zip_next(self): 69 | # l = len(self.temp_zip_arr) 70 | 71 | # while self.offset < l: 72 | # f = self.temp_zip_arr[self.offset] 73 | 74 | # size = getattr( f, 'file_size' ) 75 | # self.offset+=1 76 | 77 | # if size: 78 | # # file 79 | # return MemberInfo(name=f.filename, size=f.file_size, offset_data=0, mtime=0, isfile=lambda:True) 80 | # else: 81 | # # folder 82 | # continue 83 | 84 | 85 | # # def find(*args): 86 | # # pass 87 | 88 | # # replace the memory map function with this if pack is encrypted 89 | # def _mmap_encrypted_find(self, value, offset = None, stop = None): 90 | # f: mmap.mmap = self.file 91 | 92 | # if offset is not None: 93 | # f.seek(offset) 94 | # else: 95 | # offset = f.tell() 96 | 97 | # if not stop: 98 | # stop = f.size() - 1 99 | 100 | # while offset < stop: 101 | 102 | # if f.read_byte() ^ KEY[ offset % KEY_LEN ] == 2: 103 | # return offset 104 | 105 | # offset += 1 106 | 107 | # return -1 108 | 109 | # def _pack_next(self): 110 | # f = self.file 111 | # pack_read = self.pack.read_bytes 112 | # stop = f.size() - 19 113 | 114 | # while True: 115 | # cursor = self.find(b'\x02', f.tell(), stop=stop) 116 | 117 | # if cursor == -1: 118 | # break 119 | 120 | # f.seek(cursor - 4) 121 | 122 | # container_length, path_length, data_length, extra_bytes = get_params(pack_read(f, cursor - 4, PACK_FILE_HEADER_LENGTH)) 123 | 124 | # if container_length != path_length + data_length + 19: # Not a valid file, continue looking 125 | # f.seek(cursor + 1) 126 | # else: 127 | # try: 128 | # # if the path is not a clean "utf-8" it will raise an exception without the "ignore" 129 | # # if the it's actually a file it won't trigger the exception 130 | # name = sanitize_filepath( pack_read(f, f.tell(), path_length).decode("utf-8") ) 131 | # offset_data = f.tell() 132 | # f.seek(offset_data + data_length) 133 | # return MemberInfo(name=name, size=data_length, offset_data=offset_data, mtime=extra_bytes, isfile=lambda: True) 134 | # except UnicodeDecodeError: 135 | # f.seek(cursor+1) 136 | 137 | # return None 138 | 139 | # def next(self) -> MemberInfo | None: 140 | # ''' 141 | # Replace this method with self._pack_next or tarlib.next or similar 142 | # ''' 143 | # return None 144 | 145 | # def close(self): 146 | # if self.file: 147 | # self.file.close() 148 | 149 | 150 | 151 | def generate_dict_tree(pack: DataPack, thread: ThreadData): 152 | f = PackFileScanner(pack) 153 | 154 | progress_percentage = 0 155 | 156 | res: list[FolderType, FileType] = [] 157 | 158 | size = os.path.getsize(pack._path) 159 | 160 | files_found = 0 161 | 162 | while True: 163 | 164 | if thread.is_stopping(): 165 | return res 166 | # raise OperationAbortedByUser('Operation was interrupted by the user') 167 | 168 | member = f.next() 169 | 170 | if not member: 171 | break 172 | 173 | is_file = member.isfile() 174 | if not is_file: 175 | continue 176 | 177 | segments = member.name.split('/') 178 | direct = res 179 | parents: list[FolderType] = [] 180 | 181 | while True: 182 | if len(segments) > 1: 183 | s = segments.pop(0) 184 | x = False 185 | for item in direct: 186 | if item['type'] == 'folder' and item['name'] == s: 187 | direct = item['children'] 188 | parents.append(item) 189 | x = True 190 | break 191 | # Not found 192 | if not x: 193 | direct.append( create_folder( s ) ) 194 | parents.append(direct[-1]) 195 | direct = direct[-1]['children'] # return the items just created 196 | else: 197 | break 198 | 199 | direct.append( create_file( 200 | segments[0], 201 | member.name, 202 | member.size, 203 | member.offset_data, 204 | extra_bytes=member.mtime if isinstance(member.mtime, list) and bytes(member.mtime) != b'\x00\x00\x00\x00\x00' else None 205 | )) 206 | 207 | files_found+=1 208 | 209 | 210 | for parent in parents: # For each parent in the tree update their size 211 | folder_increase_size_files(parent, files_count=1, size_change=member.size) 212 | 213 | percentage = round((member.offset_data + member.size)/size*100) 214 | 215 | if percentage > progress_percentage: 216 | progress_percentage = percentage 217 | thread.progress((percentage,)) # keep the comma 218 | 219 | f.close() 220 | print(f'{files_found} files found!') 221 | return res 222 | 223 | def count_files_tree(file_tree): 224 | file_count = 0 225 | 226 | def recursive(files): 227 | nonlocal file_count 228 | for file in files: 229 | if file['type'] == 'folder': 230 | recursive(file['children']) 231 | else: 232 | if not file.get('ignore', False): 233 | file_count+=1 234 | 235 | recursive(file_tree) 236 | 237 | return file_count 238 | 239 | def count_size_tree(file_tree): 240 | file_size = 0 241 | 242 | def recursive(files): 243 | nonlocal file_size 244 | for file in files: 245 | if file['type'] == 'folder': 246 | recursive(file['children']) 247 | else: 248 | if not file.get('ignore', False): 249 | file_size+=file['size'] 250 | 251 | recursive(file_tree) 252 | 253 | return file_size 254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /gui/components/preview.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QWidget, QDialog, QLabel, QPlainTextEdit, QPushButton, QHBoxLayout, QVBoxLayout, QScrollArea, QSizePolicy 2 | from PyQt6.QtGui import QPixmap, QMovie 3 | from PyQt6.QtCore import Qt, QByteArray, QBuffer 4 | 5 | from typing import Literal, Callable, Tuple 6 | 7 | from app.strings import translate 8 | from app.constants import IMG_FORMATS 9 | from app.util.types import FileType 10 | from ..util.threads import Worker, ThreadPool 11 | from ..util.svg_icon import QIcon_from_svg 12 | from .spinner import Spinner 13 | from .csv_viewer.table import CSVView 14 | 15 | PreviewWidgetTypes = Literal['image', 'csv', 'json', 'text'] 16 | ContentGetterArgType = Callable[[], bytes | bytearray | str] 17 | 18 | class FileContentPreview(QWidget): 19 | ''' 20 | You can set the preview type of a file format by calling: 21 | FileContentPreview.setPreviewType(format: str, PreviewWidgetType: str) 22 | ''' 23 | 24 | is_windowed = False 25 | previewedWidget: QPixmap | QLabel = None 26 | currentPreviewArgs: Tuple[FileType, bytes | bytearray | str] = () 27 | worker: Worker = None 28 | 29 | ''' 30 | For each format set a preview type 31 | ''' 32 | fileFormatToPreview: dict[str, PreviewWidgetTypes] = { 33 | 'json': 'json', 34 | 'timeline': 'json', 35 | 'tsv': 'csv', 36 | 'csv': 'csv', 37 | 'txt': 'text', 38 | 'atlas': 'text', 39 | 'bat': 'text', 40 | 'py': 'text', 41 | 'js': 'text' 42 | } 43 | 44 | def __init__(self, parent: QWidget | None = None, is_windowed=False): 45 | super().__init__(parent) 46 | 47 | main_layout = QVBoxLayout() 48 | self.setLayout(main_layout) 49 | self.layout().setContentsMargins(0,0,0,0) 50 | self.setProperty('class', 'file-preview') 51 | 52 | self.removeCurrentWidget() 53 | 54 | # Buttons 55 | self.header_widget = QWidget() 56 | self.header_widget.hide() 57 | hLine = QHBoxLayout() 58 | hLine.setContentsMargins(0,0,0,0) 59 | hLine.setSpacing(6) 60 | self.header_widget.setLayout(hLine) 61 | self.layout().addWidget(self.header_widget) 62 | 63 | self.label_title = QLabel(text=translate('preview')) 64 | hLine.addWidget(self.label_title) 65 | hLine.setStretchFactor(self.label_title, 0) 66 | self.label_title.setHidden(is_windowed) 67 | 68 | if not is_windowed: 69 | hLine.addStretch() 70 | 71 | close = QPushButton() 72 | close.setText('Windowed') 73 | close.setIcon(QIcon_from_svg('open-in-new', QApplication.instance().ThemeColors.BUTTON_FONT_COLOR)) 74 | close.setToolTip('Open this preview in a separate window.') 75 | close.setCursor(Qt.CursorShape.PointingHandCursor) 76 | close.clicked.connect(self._open_windowed) 77 | hLine.addWidget(close) 78 | 79 | close = QPushButton() 80 | close.setText('✕') 81 | close.setCursor(Qt.CursorShape.PointingHandCursor) 82 | close.setFixedWidth(25) 83 | close.clicked.connect(self.hide) 84 | hLine.addWidget(close) 85 | 86 | self.scrollWidget = QScrollArea() 87 | self.scrollWidget.setWidgetResizable(True) 88 | self.layout().addWidget(self.scrollWidget) 89 | 90 | self.create_spinner() 91 | 92 | def is_preview_supported(self, file_format: str) -> bool: 93 | 94 | if file_format in IMG_FORMATS or file_format in self.fileFormatToPreview: 95 | return True 96 | 97 | return False 98 | 99 | def create_spinner(self): 100 | self.spinner = Spinner() 101 | self.spinner_wrapper = QWidget() 102 | self.spinner_wrapper.setLayout(QVBoxLayout()) 103 | self.spinner_wrapper.layout().addWidget(self.spinner) 104 | self.spinner_wrapper.layout().setAlignment(self.spinner, Qt.AlignmentFlag.AlignCenter) 105 | self.spinner_wrapper.setContentsMargins(0,0,0,0) 106 | self.layout().addWidget(self.spinner_wrapper) 107 | self.layout().setStretchFactor(self.spinner_wrapper, 1) 108 | self.spinner_wrapper.setHidden(True) 109 | return self.spinner 110 | 111 | def hide_spinner(self): 112 | self.spinner.stop() 113 | self.spinner_wrapper.setHidden(True) 114 | self.scrollWidget.setHidden(False) 115 | 116 | def show_spinner(self): 117 | self.spinner.start() 118 | self.spinner_wrapper.setHidden(False) 119 | self.scrollWidget.setHidden(True) 120 | 121 | def _create_window(self, title): 122 | window = QDialog( 123 | parent=self, 124 | flags=Qt.WindowType.Window | 125 | Qt.WindowType.WindowTitleHint | 126 | Qt.WindowType.WindowMaximizeButtonHint | 127 | Qt.WindowType.WindowCloseButtonHint | 128 | Qt.WindowType.WindowMinimizeButtonHint 129 | ) 130 | window.setMinimumSize(200, 200) 131 | window.setBaseSize(200,200) 132 | window.setAttribute(Qt.WidgetAttribute.WA_QuitOnClose, False) 133 | window.setWindowTitle(title) 134 | window.setWindowRole('preview') 135 | window.setLayout(QHBoxLayout()) 136 | window.layout().setContentsMargins(0,0,0,0) 137 | window.show() 138 | preview = FileContentPreview(is_windowed=True) 139 | window.layout().addWidget(preview) 140 | return window, preview 141 | 142 | def _open_windowed(self): 143 | ''' 144 | open the preview in a new window (internal usage) 145 | ''' 146 | window, preview = self._create_window(self.currentPreviewArgs[0]['full_path']) 147 | # Passing the content directly to avoid another loading and pack reading 148 | preview._display(*self.currentPreviewArgs) 149 | 150 | def display_windowed(self, file: FileType, get): 151 | ''' 152 | open the preview directly in a new window 153 | ''' 154 | window, preview = self._create_window(file['full_path']) 155 | preview.display(file, get) 156 | 157 | @staticmethod 158 | def setPreviewType(format: str, preview_type: PreviewWidgetTypes): 159 | if not format or not preview_type: 160 | return 161 | FileContentPreview.fileFormatToPreview[format] = preview_type 162 | 163 | @staticmethod 164 | def deletePreviewType(format: str): 165 | if not format: 166 | return 167 | del FileContentPreview.fileFormatToPreview[format] 168 | 169 | @staticmethod 170 | def getFormatPreviewWidget(format): 171 | return FileContentPreview.fileFormatToPreview.get(format) 172 | 173 | def _imageWidget(self, content): 174 | wid = QLabel() 175 | image = QPixmap() 176 | image.loadFromData(content) 177 | wid.setPixmap(image) 178 | wid.setContentsMargins(0,0,0,0) 179 | wid.setAlignment(Qt.AlignmentFlag.AlignCenter) 180 | self.scrollWidget.setWidget(wid) 181 | self.previewedWidget = wid 182 | 183 | def _plainTextWidget(self, content: bytes | bytearray | str): 184 | if isinstance(content, bytes) or isinstance(content, bytearray): 185 | content = content.decode('utf-8') 186 | 187 | wid = QPlainTextEdit() 188 | wid.setPlainText(content) 189 | wid.setReadOnly(True) 190 | self.scrollWidget.setWidget(wid) 191 | self.previewedWidget = wid 192 | 193 | def _csvTableWidget(self, content: bytes | bytearray | str): 194 | if isinstance(content, bytes) or isinstance(content, bytearray): 195 | content = content.decode('utf-8') 196 | 197 | data = content 198 | 199 | if not isinstance(data, list): # CSV viiewer can only work with a list[list[str | number]] 200 | range = content.split('\n') 201 | data = [] 202 | 203 | for row in range: 204 | if row != '': # Sometimes the last line is empty 205 | data.append(row.split('\t')) 206 | 207 | wid = CSVView(data=data) 208 | self.scrollWidget.setWidget(wid) 209 | wid.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 210 | self.previewedWidget = wid 211 | 212 | def _animatedWidget(self, content: bytes | bytearray): 213 | ''' 214 | This seems to crash even when callign is valid 215 | 216 | :param self: 217 | :param content: The bytes of the file 218 | :type content: bytes | bytearray 219 | ''' 220 | import io 221 | wid = QLabel(self) 222 | movie = QMovie(self) 223 | movie.setDevice(QBuffer(QByteArray(io.BytesIO(content).getvalue()))) 224 | movie.setCacheMode(QMovie.CacheMode.CacheAll) 225 | movie.setFormat(QByteArray(b'webp')) 226 | 227 | wid.setMovie(movie) 228 | movie.jumpToFrame( movie.frameCount() - 1 ) 229 | self.scrollWidget.setWidget(wid) 230 | self.previewedWidget = wid 231 | movie.start() 232 | 233 | def _unknownPreviewWidget(self): 234 | wid = QLabel() 235 | wid.setText(f'File preview is not available for this content.') 236 | self.scrollWidget.setWidget(wid) 237 | 238 | def _display(self, file: FileType, content: bytearray | bytes | str): 239 | 240 | self.removeCurrentWidget() 241 | 242 | self.currentPreviewArgs = (file, content) 243 | 244 | _file_format = file.get('format') 245 | _type: PreviewWidgetTypes | None = self.getFormatPreviewWidget(_file_format) or ('image' if _file_format in IMG_FORMATS else None) 246 | 247 | try: 248 | match _type: 249 | case 'image': 250 | self._imageWidget(content) 251 | case 'csv': 252 | self._csvTableWidget(content) 253 | case 'text' | 'json': 254 | self._plainTextWidget(content) 255 | case 'animated': 256 | self._animatedWidget(content) 257 | case _: 258 | self._unknownPreviewWidget() 259 | 260 | self.label_title.setText(f'Preview {_type}: {file.get("full_path")}') 261 | self.header_widget.show() 262 | 263 | except Exception as e: 264 | wid = QLabel() 265 | wid.setWordWrap(True) 266 | wid.setText(f'Preview error: {e}') 267 | self.scrollWidget.setWidget(wid) 268 | 269 | def display(self, file: FileType, getter: ContentGetterArgType, windowed=False): 270 | ''' 271 | Run the getter in a thread and pass it's result to self._display 272 | ''' 273 | if windowed: 274 | return self.display_windowed(file, getter) 275 | 276 | self.header_widget.hide() 277 | 278 | if self.worker: 279 | self.worker.signals.finished.disconnect() 280 | self.worker.signals.result.disconnect() 281 | 282 | self.setHidden(False) 283 | 284 | self.show_spinner() 285 | 286 | # Get data in thread 287 | worker = Worker(getter) 288 | worker.signals.finished.connect( self.worker_finished ) 289 | worker.signals.result.connect( lambda args, result: self._display( file, result ) ) 290 | self.worker = worker 291 | ThreadPool.start(worker) 292 | 293 | def worker_finished(self, *args): 294 | self.hide_spinner() 295 | self.worker=None 296 | 297 | def removeCurrentWidget(self): 298 | self.currentPreviewArgs = [] 299 | if self.previewedWidget: 300 | self.scrollWidget.setWidget(None) 301 | self.previewedWidget.deleteLater() 302 | self.previewedWidget = None 303 | 304 | def closeEvent(self, a0): 305 | if self.worker: 306 | self.worker.signals.finished.disconnect() 307 | self.worker.signals.result.disconnect() 308 | self.worker = None 309 | 310 | return super().closeEvent(a0) 311 | 312 | def hide(self): 313 | self.removeCurrentWidget() 314 | return super().hide() -------------------------------------------------------------------------------- /gui/components/tree_view.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import List 4 | from PyQt6.QtWidgets import QApplication, QTreeWidget, QTreeWidgetItem, QMenu 5 | from PyQt6.QtCore import Qt, QUrl, QMimeData, QVariant, pyqtSignal, QObject 6 | from PyQt6.QtGui import QAction, QIcon, QColor, QDrag 7 | 8 | from app.util.tree import create_folder 9 | from app.util.types import FileTreeType, FileType, FolderType 10 | from ..util.svg_icon import QIcon_from_svg 11 | from ..util.mouse import mouse_pressed 12 | from app.util.file import convert_size 13 | from app.constants import IMG_FORMATS, TEMP_FOLDER 14 | 15 | 16 | class DelayedMimeData(QMimeData): 17 | 18 | def __init__(self): 19 | super().__init__() 20 | self.callbacks = [] 21 | 22 | def add_callback(self, callback): 23 | self.callbacks.append(callback) 24 | 25 | def retrieveData(self, mime_type: str, preferred_type: QVariant): 26 | if not mouse_pressed(): 27 | for callback in self.callbacks.copy(): 28 | self.callbacks.remove(callback) 29 | callback() 30 | 31 | p = QMimeData.retrieveData(self, mime_type, preferred_type) 32 | return p 33 | 34 | 35 | def eventFilter(self, *args): 36 | print(args) 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | class CustomQTreeWidgetItem(QTreeWidgetItem): 45 | _query_pass: bool = True # Item is not hidden by search query 46 | _compare_pass: bool = True # Item is not hidden by compare 47 | ____data: FileType | FolderType = {} 48 | 49 | def __init__(self, parent = None): 50 | return super().__init__(parent) 51 | 52 | def getJSONData(self): 53 | return self.____data 54 | 55 | def setJSONData(self, data): 56 | # self.setData(0, 256, data) using set data causes a lot of memory usage 57 | self.____data = data 58 | 59 | def shouldShowItem(self): 60 | return self._query_pass and self._compare_pass 61 | 62 | 63 | 64 | 65 | 66 | class TreeViewTable(QObject): 67 | treeItems = [] 68 | treeData = [] 69 | compareData = None 70 | tempDeletedNodes: List[CustomQTreeWidgetItem] = [] 71 | contextMenuOptions = [] 72 | # functionColumnContentItem = None 73 | 74 | dargdrop = pyqtSignal(list, str) 75 | 76 | def __init__(self): 77 | super().__init__() 78 | 79 | self.folderIcon = QIcon_from_svg('folder-outline', QApplication.instance().ThemeColors.FONT_COLOR) 80 | # self.fileIcon = QIcon( QIcon_from_svg('file.svg', QApplication.instance().ThemeColors.FONT_COLOR) ) 81 | self.tree = QTreeWidget() 82 | self.tree.setColumnWidth(0,500) 83 | self.tree.setColumnWidth(1,70) 84 | self.tree.setColumnWidth(2,70) 85 | self.tree.setColumnWidth(3,100) 86 | self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) 87 | self.tree.setSelectionMode(self.tree.SelectionMode.ExtendedSelection) 88 | self.tree.setRootIsDecorated(True) 89 | self.tree.setSelectionBehavior(self.tree.SelectionBehavior.SelectRows) 90 | self.tree.setAlternatingRowColors(True) 91 | 92 | 93 | if mouse_pressed != None: 94 | self.tree.setDragEnabled( True ) 95 | self.tree.setDragDropMode(self.tree.DragDropMode.DragOnly) 96 | self.tree.startDrag = self.startDrag 97 | 98 | def return_selected_items(self) -> List[CustomQTreeWidgetItem]: 99 | ''' 100 | Return each element only once: 101 | If a parent folder and a child element are selected only the parent will be returned 102 | ''' 103 | def any_parent_selected(item: CustomQTreeWidgetItem): 104 | parent = item.parent() 105 | while parent: 106 | if parent.isSelected(): 107 | return True 108 | else: 109 | parent = parent.parent() 110 | return False 111 | 112 | return [ item for item in self.tree.selectedItems() if not any_parent_selected(item) ] 113 | 114 | def startDrag(self, actions): 115 | drag = QDrag(self.tree) 116 | data = self.fileTreeJsonFromSelection() 117 | mime = DelayedMimeData() 118 | path_list = [] 119 | 120 | os.makedirs( TEMP_FOLDER, exist_ok=True ) 121 | 122 | # mime.add_callback(lambda: self.write_temp_files(data, TEMP_FOLDER)) 123 | mime.add_callback(lambda: self.dargdrop.emit(data, TEMP_FOLDER)) 124 | 125 | for item in data: 126 | path = os.path.join(TEMP_FOLDER, item.get('name')) 127 | 128 | path_list.append(QUrl.fromLocalFile(path)) 129 | 130 | mime.setUrls(path_list) 131 | mime.setData('application/x-qabstractitemmodeldatalist', b'') # self.tree.mimeData(self.tree.selectedItems()).data('application/x-qabstractitemmodeldatalist')) 132 | drag.setMimeData(mime) 133 | 134 | drag.exec(Qt.DropAction.MoveAction) 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | def widget(self): 143 | return self.tree 144 | 145 | def clearTree(self): 146 | self.tree.selectionModel().clearSelection() 147 | self.tree.clear() 148 | self.compareData = None 149 | self.tempDeletedNodes = [] 150 | # self.treeItems = [] 151 | 152 | def showTree(self, tree_map): 153 | self.clearTree() 154 | self.addItems(self.tree, tree_map) 155 | self.treeData = tree_map 156 | 157 | def setColumns(self, columns): 158 | self.tree.setColumnCount(len(columns)) 159 | self.tree.setHeaderLabels(columns) 160 | 161 | @staticmethod 162 | def __functionColumnContentItem(data): 163 | pass 164 | 165 | def setDictToColumn(self, fun): 166 | self.__functionColumnContentItem = fun 167 | 168 | def getTreeItemData(self, item: QTreeWidgetItem): 169 | return item.data(0 , 256) 170 | 171 | def getCurrentTreeData(self): 172 | return self.treeData 173 | 174 | def setWidgetItemText(self, item: CustomQTreeWidgetItem, data: FileTreeType): 175 | row = self.__functionColumnContentItem(data) 176 | for index, key in enumerate(row): 177 | if key is None: 178 | continue 179 | 180 | item.setText(index, key) 181 | 182 | def addItems(self, parent, items, callback=lambda _: 0): 183 | if not items: 184 | return None 185 | 186 | items.sort(key=self.sortByType, reverse=True) 187 | 188 | for item in items: 189 | try: 190 | treeitem = CustomQTreeWidgetItem(parent) 191 | treeitem.setJSONData(item) 192 | 193 | self.setWidgetItemText(treeitem, item) 194 | 195 | if item["type"] == "folder": 196 | treeitem.setIcon(0, self.folderIcon ) 197 | self.addItems(treeitem, item["children"], callback=callback) 198 | 199 | callback(treeitem) 200 | 201 | except Exception as e: 202 | print(e) 203 | 204 | 205 | @staticmethod 206 | def sortByType(item): 207 | return item["type"] 208 | 209 | #-------------------------------------- Search Filter Query ---------------------------------------------# 210 | def setQuery(self, query): 211 | try: 212 | self._query = re.compile(query) 213 | except: 214 | self._query = None 215 | return 216 | 217 | for i in range(self.tree.topLevelItemCount()): 218 | self._filterQuery(self.tree.topLevelItem(i)) 219 | 220 | def _filterQuery(self, item: CustomQTreeWidgetItem) -> tuple[int, int]: 221 | if item._query_pass and not item.shouldShowItem(): # No need to check if the element is hidden by something else other than the query 222 | return 0, 0 223 | 224 | childCount = item.childCount() 225 | if childCount > 0: 226 | size = 0 227 | files = 0 228 | for i in range(childCount): 229 | child = item.child(i) 230 | c, s = self._filterQuery( child ) 231 | files += c 232 | size += s 233 | 234 | item.setHidden(size == 0) 235 | 236 | if size > 0: 237 | item.setData(2, 0, convert_size(size)) 238 | item.setData(3, 0, str(files) + ' files') 239 | 240 | return files, size 241 | 242 | else: 243 | if re.search(self._query, item.data(0, 0)): 244 | item._query_pass = True 245 | if item.shouldShowItem(): 246 | item.setHidden(False) 247 | return 1, item.getJSONData()['size'] 248 | else: 249 | return 0, 0 250 | else: 251 | item._query_pass = False 252 | item.setHidden(not item.shouldShowItem()) 253 | return 0, 0 254 | 255 | 256 | #-------------------------------------- Compare ---------------------------------------------# 257 | def isComparing(self): 258 | return self.compareData != None 259 | 260 | def stopComparing(self): 261 | self.compareData = None 262 | ThemeColors = QApplication.instance().ThemeColors 263 | 264 | for item in self.tempDeletedNodes: 265 | parent = item.parent() 266 | if parent: 267 | parent.removeChild(item) 268 | 269 | def recursive(item: CustomQTreeWidgetItem, ThemeColors): 270 | childCount = item.childCount() 271 | if item._compare_pass: # if item was visible then it probably had a different color text 272 | item.setForeground( 0, QColor(ThemeColors.FONT_COLOR) ) # Reset the font color for any item 273 | if childCount > 0: 274 | size = 0 275 | files = 0 276 | item._compare_pass = True 277 | for i in range(childCount): 278 | c, s = recursive( item.child(i), ThemeColors ) 279 | files+=c 280 | size += s 281 | 282 | item.setHidden(size == 0) 283 | 284 | if size > 0: 285 | item.setData(2, 0, convert_size(size)) 286 | item.setData(3, 0, str(files) + ' files') 287 | 288 | return files, size 289 | 290 | else: 291 | item._compare_pass = True 292 | state = item.shouldShowItem() 293 | item.setHidden( not state ) 294 | if state: 295 | return 1, item.getJSONData()['size'] 296 | else: 297 | return 0, 0 298 | 299 | for i in range(self.tree.topLevelItemCount()): 300 | f,s = recursive(self.tree.topLevelItem(i), ThemeColors) 301 | 302 | def setCompare(self, compareTree): 303 | self.compareData = compareTree 304 | ThemeColors = QApplication.instance().ThemeColors 305 | 306 | def styleDeletedItem(item: CustomQTreeWidgetItem): 307 | if item not in self.tempDeletedNodes: 308 | self.tempDeletedNodes.append(item) 309 | 310 | c = item.childCount() 311 | 312 | for i in range(c): 313 | styleDeletedItem(item.child(i)) 314 | 315 | item.setForeground(0, QColor(ThemeColors.TABLE_FONT_COLOR_FILE_DELETED)) 316 | 317 | 318 | def recursive(treeItems: List[CustomQTreeWidgetItem], compData: FileTreeType, parent:CustomQTreeWidgetItem = None): 319 | nonlocal ThemeColors 320 | total_files = 0 321 | total_size = 0 322 | 323 | for item in treeItems: 324 | data = item.getJSONData() 325 | d = None 326 | 327 | for compdata in compData: 328 | if compdata['name'] == data['name'] and compdata['type'] == data['type']: 329 | d = compdata 330 | break 331 | 332 | if d is None: # New file 333 | item._compare_pass = True 334 | item.setForeground(0, QColor(ThemeColors.TABLE_FONT_COLOR_FILE_NEW)) 335 | if data['type'] == 'folder': 336 | size = [item.child(i).getJSONData()['size'] for i in range(item.childCount()) if not item.child(i).isHidden()] 337 | if len(size) > 0: 338 | total_files += len(data['children']) 339 | total_size += data['size'] 340 | elif item.shouldShowItem(): 341 | total_files += 1 342 | total_size += data['size'] 343 | 344 | else: # Old file 345 | compData.pop( compData.index(d) ) 346 | if d['type'] == 'folder': 347 | item._compare_pass = True 348 | fi, si = recursive( 349 | [item.child(i) for i in range(item.childCount())], 350 | d['children'], 351 | item 352 | ) 353 | if fi > 0: 354 | # No need to update the size and file count if it's hidden 355 | item.setData(2, 0, convert_size(si)) 356 | item.setData(3, 0, str(fi) + ' files') 357 | total_files += fi 358 | total_size += si 359 | item.setHidden( fi==0 ) 360 | else: 361 | if d['size'] != data['size']: 362 | item._compare_pass = True 363 | item.setForeground(0, QColor(ThemeColors.TABLE_FONT_COLOR_FILE_EDITED)) 364 | if item.shouldShowItem(): 365 | total_size += data['size'] 366 | total_files += 1 367 | else: 368 | item._compare_pass = False 369 | item.setForeground(0, QColor(ThemeColors.FONT_COLOR)) 370 | 371 | item.setHidden(not item.shouldShowItem()) 372 | 373 | try: 374 | self.addItems(parent, compData, callback=styleDeletedItem) 375 | # Add the missing files as deleted 376 | # added = self.addItems(parent, compData, callback=styleDeletedItem) 377 | 378 | # self.tempDeletedNodes += added 379 | 380 | # for i in added: 381 | # i.setForeground(0, QColor(ThemeColors.TABLE_FONT_COLOR_FILE_DELETED)) 382 | 383 | except: 384 | pass 385 | 386 | return total_files, total_size 387 | 388 | 389 | 390 | recursive( 391 | [self.tree.topLevelItem(i) for i in range(self.tree.topLevelItemCount())], 392 | compareTree, 393 | None 394 | ) 395 | 396 | def fileTreeJsonFromSelection(self): 397 | ''' 398 | Generate file view from the file tree selection without including hidden files 399 | ''' 400 | return self.fileTreeJsonFromTreeItems( self.return_selected_items() ) 401 | 402 | def fileTreeJsonFromView(self): 403 | ''' 404 | Generate file view from the file tree without including hidden files 405 | ''' 406 | return self.fileTreeJsonFromTreeItems( [self.tree.topLevelItem(i) for i in range(self.tree.topLevelItemCount()) ] ) 407 | 408 | def fileTreeJsonImagesOnly(self): 409 | return 410 | 411 | def fileTreeJsonFromTreeItems(self, items: List[CustomQTreeWidgetItem]): 412 | 413 | def recursive(item: CustomQTreeWidgetItem): 414 | i = item.childCount() 415 | if i > 0: 416 | data = item.getJSONData() 417 | content = [ recursive(item.child(j)) for j in range(i) if not item.child(j).isHidden() and item.child(j) not in self.tempDeletedNodes ] 418 | f = 0 419 | s = 0 420 | for x in content: 421 | if x['type'] == 'folder': 422 | f += x['files'] 423 | else: 424 | f += 1 425 | s += x['size'] 426 | return create_folder(name=data['name'], files=f, size=s, children=content) 427 | else: 428 | return item.getJSONData() 429 | 430 | res = [ recursive(item) for item in items if not item.isHidden() and item not in self.tempDeletedNodes] 431 | 432 | return res 433 | 434 | 435 | def fileTreeJsonFromTreeItemsWithFormatFilter(self, formats: list[str] = IMG_FORMATS) -> FileTreeType: 436 | ''' 437 | Generate a file tree from the current tree view containing only images 438 | ''' 439 | 440 | def recursive(item: CustomQTreeWidgetItem): 441 | if item in self.tempDeletedNodes: 442 | return None 443 | 444 | i = item.childCount() 445 | if i > 0: 446 | data = item.getJSONData() 447 | content = [ recursive(item.child(j)) for j in range(i) if not item.child(j).isHidden() ] 448 | content = [c for c in content if c is not None] 449 | f = 0 450 | s = 0 451 | for x in content: 452 | if x['type'] == 'folder': 453 | f += x['files'] 454 | else: 455 | f += 1 456 | s += x['size'] 457 | if f > 0: 458 | return create_folder(name=data['name'], files=f, size=s, children=content) 459 | else: 460 | return None 461 | else: 462 | data = item.getJSONData() 463 | if data['format'] in formats: 464 | return data 465 | else: 466 | return None 467 | 468 | res = [recursive(self.tree.topLevelItem(i)) for i in range(self.tree.topLevelItemCount())] 469 | res = [c for c in res if c is not None] 470 | 471 | return res -------------------------------------------------------------------------------- /gui/theme/styles.py: -------------------------------------------------------------------------------- 1 | class LightTheme: 2 | BACKGROUND_COLOR = '#f0f0f0' 3 | FONT_COLOR = '#000000' 4 | LINK_FONT_COLOR = '#90c8f6' 5 | ICON_COLOR = FONT_COLOR 6 | BORDER_LIGHT_COLOR = '#d9d9d9' 7 | BORDER_HEAVY_COLOR = '#a0a0a0' 8 | INACTIVE_BACKGROUND_COLOR = '#f0f0f0' 9 | 10 | TOOLBAR_BACKGROUND_COLOR = '#f0f0f0' 11 | TOOLBAR_FONT_COLOR = FONT_COLOR 12 | TOOLBAR_ICON_COLOR = ICON_COLOR 13 | TOOLBAR_RADIUS = 0 14 | 15 | HOVER_BACKGROUND = '#d8eaf9' 16 | # HOVER_BORDER_WIDTH = 0 17 | # HOVER_BORDER_COLOR = HOVER_BACKGROUND 18 | HOVER_FONT_COLOR = FONT_COLOR 19 | 20 | SELECTION_BG_COLOR = '#c0dcf3' 21 | SELECTION_BORDER_WIDTH = 2 22 | SELECTION_BORDER_COLOR = '#90c8f6' 23 | SELECTION_FONT_COLOR = FONT_COLOR 24 | 25 | BUTTON_BACKGROUND_COLOR = '#e1e1e1' 26 | BUTTON_FONT_COLOR = FONT_COLOR 27 | BUTTON_BORDER_COLOR = '#adadad' 28 | BUTTON_BORDER_WIDTH = 1 29 | BUTTON_RADIUS = 0 30 | BUTTON_HOVER_BACKGROUND = '#e5f1fb' 31 | BUTTON_HOVER_BORDER_COLOR = '#007ad7' 32 | BUTTON_HOVER_FONT_COLOR = BUTTON_FONT_COLOR 33 | BUTTON_HOVER_BORDER_WIDTH = 2 34 | BUTTON_DISABLED_BACKGROUND_COLOR = '#cccccc' 35 | BUTTON_DISABLED_FONT_COLOR = '#787878' 36 | BUTTON_DISABLED_BORDER_COLOR = '#bfbfbf' 37 | BUTTON_DISABLED_BORDER_WIDTH = BUTTON_BORDER_WIDTH 38 | BUTTON_CRITICAL_BACKGROUND = BUTTON_BACKGROUND_COLOR 39 | BUTTON_CRITICAL_FONT_COLOR = 'red' 40 | BUTTON_CRITICAL_BORDER_COLOR = BUTTON_CRITICAL_FONT_COLOR 41 | BUTTON_CRITICAL_HOVER_BACKGROUND = HOVER_BACKGROUND 42 | BUTTON_CRITICAL_HOVER_FONT_COLOR = BUTTON_CRITICAL_FONT_COLOR 43 | BUTTON_CRITICAL_HOVER_BORDER_COLOR = BUTTON_CRITICAL_FONT_COLOR 44 | 45 | 46 | TAB_BACKGROUND_COLOR = BACKGROUND_COLOR 47 | TAB_BORDER_COLOR = BORDER_LIGHT_COLOR 48 | TAB_BORDER_BOTTOM_COLOR = 'transparent' 49 | TAB_BORDER_WIDTH = 1 50 | TAB_BORDER_RADIUS = 0 51 | TAB_MIN_WIDTH = 0 52 | TAB_ACTIVE_BACKGROUND_COLOR = '#ffffff' 53 | TAB_ACTIVE_FONT_COLOR = FONT_COLOR 54 | TAB_ACTIVE_BORDER_BOTTOM = SELECTION_BORDER_COLOR 55 | 56 | 57 | TABLE_HEAD_BACKGROUND_COLOR = '#ffffff' 58 | TABLE_HEAD_BORDER_COLOR = TABLE_HEAD_BACKGROUND_COLOR 59 | TABLE_ROW_FONT_COLOR = FONT_COLOR 60 | TABLE_ROW_ALTERNATE_BACKGROUND = '#ffffff' 61 | TABLE_FONT_COLOR_FILE_EDITED = 'orange' 62 | TABLE_FONT_COLOR_FILE_NEW = 'green' 63 | TABLE_FONT_COLOR_FILE_DELETED = 'red' 64 | 65 | INPUT_BACKGROUND_COLOR = '#ffffff' 66 | INPUT_FONT_COLOR = '#000000' 67 | INPUT_ICON_COLOR = INPUT_FONT_COLOR 68 | INPUT_BORDER_COLOR = BORDER_LIGHT_COLOR 69 | INPUT_BORDER_WIDTH = 1 70 | INPUT_BORDER_RADIUS = 6 71 | INPUT_DISABLED_FONT_COLOR = 'gray' 72 | 73 | PROGRESS_BAR_BACKGROUND = 'transparent' 74 | PROGRESS_BAR_RADIUS = 0 75 | PROGRESS_BAR_BORDER_COLOR = BORDER_LIGHT_COLOR 76 | 77 | SCROLLBAR_BACKGROUND = '#f0edf1' 78 | SCROLLBAR_HANDLE = '#888588' 79 | SCROLLBAR_WIDTH = 15 80 | SCROLLBAR_HANDLE_W = round(SCROLLBAR_WIDTH*0.3) 81 | SCROLLBAR_WIDTH = 15 82 | SCROLLBAR_HANDLE_W = round(SCROLLBAR_WIDTH*0.3) 83 | 84 | 85 | class DarkTheme(LightTheme): 86 | BACKGROUND_COLOR = '#1f1f1f' 87 | FONT_COLOR = '#ffffff' 88 | LINK_FONT_COLOR = '#90c8f6' 89 | ICON_COLOR = FONT_COLOR 90 | BORDER_LIGHT_COLOR = '#414141' 91 | BORDER_HEAVY_COLOR = '#636363' 92 | INACTIVE_BACKGROUND_COLOR = '#000000' 93 | 94 | TOOLBAR_BACKGROUND_COLOR = '#333333' 95 | TOOLBAR_FONT_COLOR = FONT_COLOR 96 | TOOLBAR_ICON_COLOR = ICON_COLOR 97 | TOOLBAR_RADIUS = 0 98 | 99 | HOVER_BACKGROUND = "#4d4d4d" 100 | # HOVER_BORDER_WIDTH = 0 101 | # HOVER_BORDER_COLOR = HOVER_BACKGROUND 102 | HOVER_FONT_COLOR = FONT_COLOR 103 | 104 | SELECTION_BG_COLOR = '#777777' 105 | SELECTION_BORDER_WIDTH = 2 106 | SELECTION_BORDER_COLOR = '#3c3c3c' 107 | SELECTION_FONT_COLOR = FONT_COLOR 108 | 109 | BUTTON_BACKGROUND_COLOR = '#333333' 110 | BUTTON_FONT_COLOR = FONT_COLOR 111 | BUTTON_BORDER_COLOR = BUTTON_BACKGROUND_COLOR 112 | BUTTON_BORDER_WIDTH = 1 113 | BUTTON_RADIUS = 0 114 | BUTTON_HOVER_BACKGROUND = '#323232' 115 | BUTTON_HOVER_BORDER_COLOR = '#5d5d5d' 116 | BUTTON_HOVER_FONT_COLOR = BUTTON_FONT_COLOR 117 | BUTTON_HOVER_BORDER_WIDTH = 2 118 | BUTTON_DISABLED_BACKGROUND_COLOR = '#323130' 119 | BUTTON_DISABLED_FONT_COLOR = '#6a696b' 120 | BUTTON_DISABLED_BORDER_COLOR = BUTTON_DISABLED_BACKGROUND_COLOR 121 | BUTTON_DISABLED_BORDER_WIDTH = BUTTON_BORDER_WIDTH 122 | BUTTON_CRITICAL_BACKGROUND = BUTTON_BACKGROUND_COLOR 123 | BUTTON_CRITICAL_FONT_COLOR = 'red' 124 | BUTTON_CRITICAL_BORDER_COLOR = BUTTON_CRITICAL_FONT_COLOR 125 | BUTTON_CRITICAL_HOVER_BACKGROUND = HOVER_BACKGROUND 126 | BUTTON_CRITICAL_HOVER_FONT_COLOR = BUTTON_CRITICAL_FONT_COLOR 127 | BUTTON_CRITICAL_HOVER_BORDER_COLOR = BUTTON_CRITICAL_FONT_COLOR 128 | 129 | TAB_BACKGROUND_COLOR = INACTIVE_BACKGROUND_COLOR 130 | TAB_BORDER_COLOR = BORDER_LIGHT_COLOR 131 | TAB_BORDER_WIDTH = 1 132 | TAB_BORDER_RADIUS = 0 133 | TAB_MIN_WIDTH = 0 134 | TAB_ACTIVE_BACKGROUND_COLOR = BACKGROUND_COLOR 135 | TAB_ACTIVE_FONT_COLOR = FONT_COLOR 136 | TAB_ACTIVE_BORDER_BOTTOM = 'transparent' 137 | 138 | 139 | TABLE_HEAD_BACKGROUND_COLOR = BORDER_HEAVY_COLOR 140 | TABLE_HEAD_BORDER_COLOR = TABLE_HEAD_BACKGROUND_COLOR 141 | TABLE_ROW_FONT_COLOR = FONT_COLOR 142 | TABLE_ROW_ALTERNATE_BACKGROUND = '#2b2b2d' 143 | TABLE_FONT_COLOR_FILE_EDITED = 'yellow' 144 | TABLE_FONT_COLOR_FILE_NEW = 'green' 145 | TABLE_FONT_COLOR_FILE_DELETED = 'red' 146 | 147 | INPUT_BACKGROUND_COLOR = '#3f4042' 148 | INPUT_FONT_COLOR = FONT_COLOR 149 | INPUT_ICON_COLOR = INPUT_FONT_COLOR 150 | INPUT_BORDER_COLOR = BORDER_LIGHT_COLOR 151 | INPUT_BORDER_WIDTH = 1 152 | INPUT_BORDER_RADIUS = 0 153 | 154 | PROGRESS_BAR_BACKGROUND = 'transparent' 155 | PROGRESS_BAR_RADIUS = 0 156 | PROGRESS_BAR_BORDER_COLOR = BORDER_LIGHT_COLOR 157 | 158 | SCROLLBAR_BACKGROUND = '#171717' 159 | SCROLLBAR_HANDLE = '#4d4d4d' 160 | SCROLLBAR_WIDTH = 15 161 | SCROLLBAR_HANDLE_W = round(SCROLLBAR_WIDTH*0.3) 162 | SCROLLBAR_WIDTH = 15 163 | SCROLLBAR_HANDLE_W = round(SCROLLBAR_WIDTH*0.3) 164 | 165 | class RoundedThemeParams: 166 | _BORDER_RADIUS = 4 167 | _BORDER_RADIUS_L = 6 168 | 169 | BUTTON_RADIUS = _BORDER_RADIUS 170 | TOOLBAR_RADIUS = _BORDER_RADIUS_L 171 | 172 | INPUT_BORDER_RADIUS = _BORDER_RADIUS_L 173 | PROGRESS_BAR_RADIUS = _BORDER_RADIUS_L 174 | TAB_BORDER_RADIUS = _BORDER_RADIUS_L 175 | 176 | class RoundedDarkTheme(RoundedThemeParams, DarkTheme): 177 | TAB_BORDER_RADIUS = 0 178 | 179 | class RoundedLightTheme(RoundedThemeParams, LightTheme): 180 | BUTTON_BORDER_WIDTH = 0 181 | BUTTON_HOVER_BORDER_WIDTH = 0 182 | BUTTON_DISABLED_BORDER_WIDTH = 0 183 | 184 | 185 | class AccentColorDarkTheme(RoundedDarkTheme): 186 | _ACCENT = '#90CAF9' 187 | BUTTON_FONT_COLOR = _ACCENT 188 | BUTTON_BACKGROUNDG_COLOR = 'transparent' 189 | BUTTON_BORDER_COLOR = _ACCENT 190 | BUTTON_HOVER_BACKGROUND = _ACCENT 191 | BUTTON_HOVER_FONT_COLOR = '#ffffff' 192 | BUTTON_HOVER_BORDER_COLOR = _ACCENT 193 | 194 | def apply(theme: LightTheme = AccentColorDarkTheme): 195 | return f""" 196 | QWidget {{ 197 | color: {theme.FONT_COLOR}; 198 | border: none; 199 | background-color: {theme.BACKGROUND_COLOR}; 200 | }} 201 | 202 | QToolTip {{ 203 | color: {theme.FONT_COLOR}; 204 | border: none; 205 | background-color: {theme.BACKGROUND_COLOR}; 206 | }} 207 | 208 | QGroupBox {{ 209 | background-color: transparent; 210 | border: {theme.INPUT_BORDER_WIDTH}px solid {theme.BORDER_LIGHT_COLOR}; 211 | border-radius: {theme.BUTTON_RADIUS}px; 212 | margin: 0.55em 0 0 0; 213 | }} 214 | 215 | QGroupBox::title {{ 216 | subcontrol-origin: margin; 217 | subcontrol-position: top center; 218 | }} 219 | 220 | QMenu {{ 221 | background-color: {theme.BACKGROUND_COLOR}; 222 | border: {theme.BUTTON_BORDER_WIDTH}px solid {theme.BUTTON_BORDER_COLOR}; 223 | color: {theme.BUTTON_FONT_COLOR}; 224 | }} 225 | 226 | QMenu::item {{ 227 | background-color: transparent; 228 | }} 229 | 230 | QMenu::item:selected {{ 231 | background-color: {theme.HOVER_BACKGROUND}; 232 | color: {theme.HOVER_FONT_COLOR}; 233 | }} 234 | 235 | QPushButton {{ 236 | background-color: {theme.BUTTON_BACKGROUND_COLOR}; 237 | border: {theme.BUTTON_BORDER_WIDTH}px solid {theme.BUTTON_BORDER_COLOR}; 238 | color: {theme.BUTTON_FONT_COLOR}; 239 | border-radius: {theme.BUTTON_RADIUS}px; 240 | padding: 5px; 241 | }} 242 | QPushButton:hover {{ 243 | background-color: {theme.BUTTON_HOVER_BACKGROUND}; 244 | color: {theme.BUTTON_HOVER_FONT_COLOR}; 245 | border: {theme.BUTTON_HOVER_BORDER_WIDTH}px solid {theme.BUTTON_HOVER_BORDER_COLOR}; 246 | }} 247 | 248 | .critical-button {{ 249 | background-color: {theme.BUTTON_CRITICAL_BACKGROUND}; 250 | border: {theme.BUTTON_BORDER_WIDTH}px solid {theme.BUTTON_CRITICAL_BORDER_COLOR}; 251 | color: {theme.BUTTON_CRITICAL_FONT_COLOR}; 252 | }} 253 | .critical-button:hover {{ 254 | background-color: {theme.BUTTON_CRITICAL_HOVER_BACKGROUND}; 255 | border: {theme.BUTTON_HOVER_BORDER_WIDTH}px solid {theme.BUTTON_CRITICAL_HOVER_BORDER_COLOR}; 256 | color: {theme.BUTTON_CRITICAL_HOVER_FONT_COLOR}; 257 | }} 258 | 259 | QPushButton:disabled {{ 260 | background-color: {theme.BUTTON_DISABLED_BACKGROUND_COLOR}; 261 | color: {theme.BUTTON_DISABLED_FONT_COLOR}; 262 | border: {theme.BUTTON_DISABLED_BORDER_WIDTH}pxpx solid {theme.BUTTON_DISABLED_BORDER_COLOR}; 263 | }} 264 | 265 | 266 | 267 | QCheckBox {{ 268 | color: {theme.FONT_COLOR}; 269 | }} 270 | QLineEdit {{ 271 | background-color: {theme.INPUT_BACKGROUND_COLOR}; 272 | border: {theme.INPUT_BORDER_WIDTH}px solid {theme.INPUT_BORDER_COLOR}; 273 | color: {theme.INPUT_FONT_COLOR}; 274 | border-radius: {theme.INPUT_BORDER_RADIUS}; 275 | padding: 5px; 276 | }} 277 | QTextEdit {{ 278 | background-color: {theme.INPUT_BACKGROUND_COLOR}; 279 | border: {theme.INPUT_BORDER_WIDTH}px solid {theme.INPUT_BORDER_COLOR}; 280 | color: {theme.INPUT_FONT_COLOR}; 281 | border-radius: {theme.INPUT_BORDER_RADIUS}; 282 | padding: 5px; 283 | }} 284 | QLineEdit:disabled, QTextEdit:disabled, QCheckBox:disabled, QLabel:disabled {{ 285 | color: {theme.INPUT_DISABLED_FONT_COLOR}; 286 | }} 287 | 288 | QProgressBar {{ 289 | border: 1px solid {theme.BORDER_HEAVY_COLOR}; 290 | border-radius: {theme.PROGRESS_BAR_RADIUS}px; 291 | background-color: {theme.PROGRESS_BAR_BACKGROUND}; 292 | text-align: center; 293 | font-size: 10pt; 294 | color: {theme.FONT_COLOR}; 295 | }} 296 | QProgressBar:chunk {{ 297 | }} 298 | 299 | 300 | 301 | 302 | QTabWidget {{ 303 | background-color: {theme.BACKGROUND_COLOR}; 304 | }} 305 | QTabWidget::pane {{ 306 | border: none; 307 | }} 308 | QTabBar {{ /* Tab background */ 309 | background-color: {theme.INACTIVE_BACKGROUND_COLOR}; 310 | border-top: none; 311 | border-bottom: {theme.TAB_BORDER_WIDTH}px solid {theme.BORDER_HEAVY_COLOR}; 312 | }} 313 | QTabBar::tab {{ 314 | background-color: {theme.INACTIVE_BACKGROUND_COLOR}; 315 | color: {theme.FONT_COLOR}; 316 | min-width: {theme.TAB_MIN_WIDTH}px; 317 | padding: 5px 10px; 318 | margin-top: 3px; 319 | border-top-left-radius: {theme.TAB_BORDER_RADIUS}px; 320 | border-top-right-radius: {theme.TAB_BORDER_RADIUS}px; 321 | border-left: {theme.TAB_BORDER_WIDTH}px solid {theme.TAB_BORDER_COLOR}; 322 | border-top: {theme.TAB_BORDER_WIDTH}px solid {theme.TAB_BORDER_COLOR}; 323 | border-bottom: {theme.TAB_BORDER_WIDTH}px solid {theme.BORDER_HEAVY_COLOR}; 324 | border-right: {theme.TAB_BORDER_WIDTH}px solid {theme.TAB_BORDER_COLOR}; 325 | }} 326 | QTabBar::tab:hover {{ 327 | background-color: {theme.HOVER_BACKGROUND}; 328 | color: {theme.HOVER_FONT_COLOR}; 329 | }} 330 | QTabBar::tab:selected {{ 331 | color: {theme.TAB_ACTIVE_FONT_COLOR}; 332 | background-color: {theme.TAB_ACTIVE_BACKGROUND_COLOR}; 333 | margin-top: 0; 334 | border-bottom: {theme.TAB_BORDER_WIDTH}px solid {theme.TAB_ACTIVE_BORDER_BOTTOM}; 335 | }} 336 | QTabBar::tab:last {{ 337 | border-right: {theme.TAB_BORDER_WIDTH}px solid {theme.TAB_BORDER_COLOR}; 338 | }} 339 | 340 | 341 | QToolBar {{ 342 | background-color: {theme.TOOLBAR_BACKGROUND_COLOR}; 343 | border-radius: {theme.TOOLBAR_RADIUS}px; 344 | }} 345 | QToolBar QToolButton {{ 346 | background-color: transparent; 347 | border-radius: {theme.TOOLBAR_RADIUS}; 348 | }} 349 | QToolButton:hover {{ 350 | background-color: {theme.HOVER_BACKGROUND}; 351 | color: {theme.HOVER_FONT_COLOR}; 352 | }} 353 | 354 | 355 | .BottomToolbar:QToolBar {{ 356 | background-color: {theme.TOOLBAR_BACKGROUND_COLOR}; 357 | border-radius: {theme.TOOLBAR_RADIUS}px {theme.TOOLBAR_RADIUS}px 0px 0px; 358 | }} 359 | .BottomToolbar QLabel {{ 360 | background: none; 361 | }} 362 | .BottomToolbar QToolButton {{ 363 | background-color: none; 364 | color: {theme.BUTTON_DISABLED_FONT_COLOR}; 365 | margin: 1px; 366 | border: {theme.SELECTION_BORDER_WIDTH}px solid transparent; 367 | padding: 2px; 368 | }} 369 | .BottomToolbar QToolButton:hover {{ 370 | background-color: {theme.HOVER_BACKGROUND}; 371 | color: {theme.HOVER_FONT_COLOR}; 372 | }} 373 | .BottomToolbar QToolButton:checked {{ 374 | background-color: {theme.SELECTION_BG_COLOR}; 375 | color: {theme.SELECTION_FONT_COLOR}; 376 | border: {theme.SELECTION_BORDER_WIDTH}px solid {theme.SELECTION_BORDER_COLOR}; 377 | }} 378 | 379 | 380 | 381 | QTreeWidget, QTableView {{ 382 | border: 2px solid {theme.BORDER_HEAVY_COLOR}; 383 | border-radius: {theme.TOOLBAR_RADIUS}px; 384 | }} 385 | 386 | QHeaderView::section {{ 387 | background-color: {theme.TABLE_HEAD_BACKGROUND_COLOR}; 388 | color: {theme.FONT_COLOR}; 389 | border: 2px solid {theme.BORDER_LIGHT_COLOR}; 390 | border-left-color: transparent; 391 | border-top-color: transparent; 392 | border-bottom-color: transparent; 393 | }} 394 | QHeaderView::section:last {{ 395 | border-right-color: transparent; 396 | }} 397 | 398 | 399 | QTreeView, QTableView {{ 400 | alternate-background-color: {theme.TABLE_ROW_ALTERNATE_BACKGROUND}; 401 | color: {theme.TABLE_ROW_FONT_COLOR}; 402 | }} 403 | 404 | QTableView QTableCornerButton::section {{ 405 | background-color: {theme.TABLE_HEAD_BACKGROUND_COLOR}; 406 | }} 407 | 408 | QTreeView::item:hover, QTableView::item:hover {{ 409 | background-color: {theme.HOVER_BACKGROUND}; 410 | color: {theme.HOVER_FONT_COLOR}; 411 | }} 412 | 413 | QTreeView::item:selected, QTableView::item:selected {{ 414 | background-color: {theme.SELECTION_BG_COLOR}; 415 | color: {theme.SELECTION_FONT_COLOR}; 416 | }} 417 | 418 | QTreeView::item:active, QTableView::item:selected {{ 419 | outline: none; 420 | }} 421 | 422 | 423 | 424 | 425 | 426 | QScrollBar {{ 427 | background: {theme.SCROLLBAR_BACKGROUND}; 428 | }} 429 | QScrollBar::handle {{ 430 | background: {theme.SCROLLBAR_HANDLE}; 431 | border-radius: {round(theme.SCROLLBAR_HANDLE_W*0.5)}px; 432 | }} 433 | QScrollBar:vertical {{ 434 | width: {theme.SCROLLBAR_WIDTH}px; 435 | margin: 15px 0px 15px 0; 436 | }} 437 | QScrollBar::handle:vertical {{ 438 | min-height: 40px; 439 | width: {theme.SCROLLBAR_HANDLE_W}px; 440 | margin: 0 {(theme.SCROLLBAR_WIDTH-theme.SCROLLBAR_HANDLE_W)//2}px; 441 | }} 442 | QScrollBar::handle:vertical:hover, QScrollBar::handle:vertical:active {{ 443 | width: {theme.SCROLLBAR_WIDTH-6}px; 444 | margin: 0 3px; 445 | }} 446 | 447 | QScrollBar:horizontal {{ 448 | height: {theme.SCROLLBAR_WIDTH}px; 449 | margin: 0 15px 0 15px; 450 | }} 451 | QScrollBar::handle:horizontal {{ 452 | min-width: 40px; 453 | height: {theme.SCROLLBAR_HANDLE_W}px; 454 | margin: {(theme.SCROLLBAR_WIDTH-theme.SCROLLBAR_HANDLE_W)//2}px 0; 455 | }} 456 | QScrollBar::handle:horizontal:hover, QScrollBar::handle:horizontal:active {{ 457 | height: {theme.SCROLLBAR_WIDTH-6}px; 458 | margin: 3px 0; 459 | }} 460 | QScrollBar::add-line:horizontal {{ 461 | width: 15px; 462 | subcontrol-position: right; 463 | subcontrol-origin: margin; 464 | border: 0px solid black; 465 | }} 466 | 467 | QScrollBar::sub-line:horizontal {{ 468 | width: 15px; 469 | subcontrol-position: left; 470 | subcontrol-origin: margin; 471 | border: 0px solid black; 472 | }} 473 | 474 | 475 | QScrollBar::add-page, QScrollBar::sub-page {{ 476 | background: none; 477 | }} 478 | 479 | QScrollBar::add-line:vertical {{ 480 | height: 15px; 481 | subcontrol-position: bottom; 482 | subcontrol-origin: margin; 483 | color: white; 484 | border: 0 solid black; 485 | }} 486 | 487 | QScrollBar::sub-line:vertical {{ 488 | height: 15px; 489 | subcontrol-position: top; 490 | subcontrol-origin: margin; 491 | color: white; 492 | border: 0 solid black; 493 | }} 494 | QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical, QScrollBar::left-arrow:horizontal, QScrollBar::right-arrow:horizontal {{ 495 | border: 2px solid grey; 496 | width: 3px; 497 | height: 3px; 498 | background: white; 499 | }} 500 | 501 | 502 | QComboBox {{ 503 | border: {theme.INPUT_BORDER_WIDTH}px solid {theme.INPUT_BORDER_COLOR}; 504 | border-radius: {theme.INPUT_BORDER_RADIUS}px; 505 | padding: 1px 18px 1px 3px; 506 | min-width: 6em; 507 | }} 508 | QComboBox:editable {{ 509 | background-color: {theme.INPUT_BACKGROUND_COLOR}; 510 | color: {theme.INPUT_FONT_COLOR}; 511 | }} 512 | QComboBox QAbstractItemView {{ 513 | border-bottom-left-radius: {theme.INPUT_BORDER_WIDTH}px solid {theme.INPUT_BORDER_COLOR}; 514 | border-bottom-right-radius: {theme.INPUT_BORDER_WIDTH}px solid {theme.INPUT_BORDER_COLOR}; 515 | selection-background-color: lightgray; 516 | }} 517 | 518 | .setting-row {{ 519 | background-color: red; 520 | color: white; 521 | }} 522 | """ -------------------------------------------------------------------------------- /gui/tabs/file_tree/main.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QToolBar, QProgressBar, QFileDialog, QMessageBox, QMenu 2 | from PyQt6.QtCore import QSize, Qt, QThread 3 | from PyQt6.QtGui import QAction 4 | from app.constants import IMG_FORMATS 5 | from app.strings import translate 6 | from app.settings import getDefaultFilePromptPath, getAutomaticFilePreview 7 | from app.util.file import convert_size 8 | from app.util.thread import ThreadData 9 | from app.util.exceptions import OperationAbortedByUser 10 | from app.pack import DataPack, NotDataPackZip 11 | from app.extract import get_file 12 | from gui.components.search_bar import SearchBar 13 | from gui.components.button import Button 14 | from gui.components.tree_view import TreeViewTable, CustomQTreeWidgetItem 15 | from gui.components.preview import FileContentPreview 16 | from gui.components.error_dialog import ErrorWindow 17 | from gui.util.svg_icon import QIcon_from_svg 18 | from gui.components.progress_bar.ProgressBar import ProgressBar 19 | from gui.util.thread_process import QtThreadedProcess 20 | import json 21 | 22 | class CreateTab(QWidget): 23 | name: str = '' 24 | order: int = -99 25 | 26 | pack: DataPack = None 27 | toolbar: QToolBar = None 28 | tree: TreeViewTable = None 29 | is_generating_tree: bool = False 30 | 31 | disable_if_no_pack: list[QWidget] = [] 32 | disable_if_no_tree: list[QWidget] = [] 33 | disable_if_tree_gen: list[QWidget] = [] 34 | 35 | search_bar: SearchBar = None 36 | progressbar: QProgressBar = None 37 | compare_btn: QAction = None 38 | 39 | 40 | def __init__(self, parent: QWidget): 41 | super().__init__(parent) 42 | self.name = translate('file_tree') 43 | self.setObjectName('FileTreeTab') 44 | APP = QApplication.instance() 45 | 46 | main_view = QMainWindow(parent) 47 | vertical_box = QVBoxLayout() 48 | buttons_line = QHBoxLayout() 49 | progressbar_layout = QHBoxLayout() 50 | 51 | self.setLayout(vertical_box) 52 | vertical_box.addLayout(buttons_line) 53 | vertical_box.addLayout(progressbar_layout) 54 | vertical_box.addWidget(main_view) 55 | 56 | main_widget = QWidget() 57 | main_widget.setLayout(QHBoxLayout()) 58 | main_widget.layout().setSpacing(5) 59 | main_widget.layout().setContentsMargins(0,4,0,0) 60 | 61 | 62 | tree = TreeViewTable() 63 | self.tree = tree 64 | tree.setColumns([translate('name'), translate('type'), translate('size'), f'{translate("files_in_folder")} / {translate("offset")}']) 65 | tree.setDictToColumn( self.tree_file_to_column ) 66 | tree.dargdrop.connect(self._extract_sync) 67 | tree.widget().customContextMenuRequested.connect(self.tree_context_menu) 68 | tree.widget().itemDoubleClicked.connect(self.doubleClickTreeItemToPreview) 69 | tree.widget().currentItemChanged.connect(self.automaticFilePreview) 70 | main_widget.layout().addWidget(tree.widget(), stretch=1) 71 | 72 | preview = FileContentPreview() 73 | preview.setVisible(False) 74 | self.previewWidget = preview 75 | main_widget.layout().addWidget(preview, stretch=1) 76 | 77 | 78 | toolbar = QToolBar(self) 79 | toolbar.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) 80 | toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) 81 | toolbar.setIconSize(QSize(32,32)) 82 | toolbar.setBaseSize(0, 32) 83 | toolbar.setDisabled(False) 84 | self.toolbar = toolbar 85 | 86 | main_view.addToolBar(toolbar) 87 | # main_view.setCentralWidget(tree.widget()) 88 | main_view.setCentralWidget(main_widget) 89 | 90 | button_action = QAction(QIcon_from_svg('content-save-outline.svg', APP.ThemeColors.TOOLBAR_ICON_COLOR), translate('btn_save'), toolbar) 91 | button_action.setToolTip( translate('tooltip_save_tree') ) 92 | button_action.triggered.connect(self.prompt_select_save_tree_path) 93 | button_action.setShortcut('Ctrl+S') 94 | button_action.setDisabled(True) 95 | self.disable_if_no_tree.append(button_action) 96 | toolbar.addAction(button_action) 97 | 98 | button_action = QAction(QIcon_from_svg('file-compare.svg', APP.ThemeColors.TOOLBAR_ICON_COLOR), translate('btn_compare_tree'), toolbar) 99 | button_action.setToolTip( translate('tooltip_compare_tree') ) 100 | button_action.triggered.connect(self.prompt_select_compare_tree_path) 101 | button_action.setDisabled(True) 102 | self.disable_if_no_tree.append(button_action) 103 | toolbar.addAction(button_action) 104 | self.compare_btn = button_action 105 | 106 | toolbar.addSeparator() 107 | 108 | button_action = QAction(QIcon_from_svg('checkbox-multiple-marked-outline.svg', APP.ThemeColors.TOOLBAR_ICON_COLOR), translate('btn_extract_selected'), toolbar) 109 | button_action.setToolTip( translate('tooltip_extract_selected') ) 110 | button_action.triggered.connect(self.extract_selected_only) 111 | button_action.setDisabled(True) 112 | self.disable_if_no_tree.append(button_action) 113 | toolbar.addAction(button_action) 114 | 115 | button_action = QAction(QIcon_from_svg('file-image-outline.svg', APP.ThemeColors.TOOLBAR_ICON_COLOR), translate('btn_img_only'), toolbar) 116 | button_action.setToolTip( translate('tooltip_img_only').format(', '.join(IMG_FORMATS)) ) 117 | button_action.triggered.connect(self.extract_all_images) 118 | button_action.setDisabled(True) 119 | self.disable_if_no_tree.append(button_action) 120 | toolbar.addAction(button_action) 121 | 122 | button_action = QAction(QIcon_from_svg('package-variant.svg', APP.ThemeColors.TOOLBAR_ICON_COLOR), translate('btn_all'), toolbar) 123 | button_action.setToolTip( translate('tooltip_extract_all') ) 124 | button_action.triggered.connect(self.extract_all) 125 | button_action.setDisabled(True) 126 | button_action.setCheckable(True) 127 | self.disable_if_no_tree.append(button_action) 128 | toolbar.addAction(button_action) 129 | 130 | 131 | self.progressbar = QProgressBar(self) 132 | self.progressbar.setValue(0) 133 | self.progressbar.setFormat("%p%") 134 | self.progressbar.setTextVisible(False) 135 | self.progressbar.setObjectName("progressbar") 136 | self.progressbar.setHidden(True) 137 | progressbar_layout.addWidget(self.progressbar) 138 | 139 | 140 | button = Button(self, text=translate('select_pack'), pointer=True, minimum_width=150) 141 | button.clicked.connect(self.select_data_pack) 142 | # button.clicked.connect(self.parent().get_progress_bar_window().new) 143 | buttons_line.addWidget(button) 144 | self.disable_if_tree_gen.append(button) 145 | 146 | button = Button(self, text=translate('generate_file_tree'), pointer=True, minimum_width=150, disabled=True) 147 | button.clicked.connect(self.start_tree_generating) 148 | buttons_line.addWidget(button) 149 | self.disable_if_no_pack.append(button) 150 | self.disable_if_tree_gen.append(button) 151 | 152 | button = Button(self, text=translate('load_file_tree'), pointer=True, minimum_width=150, disabled=True) 153 | button.clicked.connect(self.select_tree_json) 154 | # button.clicked.connect(self.parent().get_progress_bar_window().new) 155 | button.setProperty('class', 'red-button') 156 | buttons_line.addWidget(button) 157 | self.disable_if_no_pack.append(button) 158 | self.disable_if_tree_gen.append(button) 159 | 160 | buttons_line.addStretch() 161 | 162 | self.search_bar = SearchBar(self, placeholder=translate('search')) 163 | self.search_bar.search.connect( self.search_bar_value_changed ) 164 | self.search_bar.setMaximumWidth(350) 165 | self.disable_if_no_tree.append(self.search_bar) 166 | buttons_line.addWidget(self.search_bar) 167 | 168 | def doubleClickTreeItemToPreview(self, item: CustomQTreeWidgetItem): 169 | if item.childCount() == 0: 170 | self.showTreeItemPreview(item) 171 | 172 | def automaticFilePreview(self): 173 | if getAutomaticFilePreview() == True: 174 | self.showTreeItemPreview(self.tree.tree.currentItem()) 175 | 176 | def showTreeItemPreview(self, item:CustomQTreeWidgetItem, windowed=False): 177 | if item: 178 | file = item.getJSONData() 179 | is_supported = self.previewWidget.is_preview_supported( file.get('format') ) 180 | 181 | if not is_supported: 182 | return self.previewWidget.setHidden(True) 183 | 184 | self.previewWidget.display( 185 | file, 186 | lambda: get_file(file, pack=self.pack), 187 | windowed=windowed 188 | ) 189 | 190 | 191 | def errorWindow(self, title, message): 192 | msg = QMessageBox(self) 193 | msg.setIcon(QMessageBox.Icon.Critical) 194 | msg.setText(message) 195 | msg.resize(400, 300) 196 | msg.setWindowTitle(title) 197 | msg.exec() 198 | 199 | @staticmethod 200 | def tree_file_to_column(data): 201 | if data["type"] == 'folder': 202 | return [data["name"], translate('folder'), convert_size(data["size"]), str(data["files"]) + ' files'] 203 | else: 204 | return [data["name"], data["format"].upper(), convert_size(data["size"]), str(data["offset"])] 205 | 206 | def search_bar_value_changed(self, value): 207 | self.tree.setQuery(value) 208 | 209 | def update_btn_state(self): 210 | for widget in self.disable_if_no_pack: 211 | widget.setDisabled( not self.pack ) 212 | 213 | for widget in self.disable_if_no_tree: 214 | widget.setDisabled( not self.pack or not self.pack.tree() ) 215 | 216 | for widget in self.disable_if_tree_gen: 217 | widget.setDisabled( self.is_generating_tree ) 218 | 219 | 220 | @staticmethod 221 | def tree_drag_and_drop(pack, files, path): 222 | thread_data = ThreadData() 223 | progwindow = ProgressBar() 224 | Qthread = QThread(progwindow) 225 | progwindow.moveToThread(Qthread) 226 | Qthread.run() 227 | p = progwindow.new() 228 | p.destroyed.connect(lambda: thread_data.stop()) 229 | thread_data.onprogress(lambda v: p.setValue(v[0])) 230 | thread_data.onfinished(lambda: p.setLabel('Done')) 231 | # progwindow.moveToThread(QThread) 232 | pack.extract(files, path, thread_data) 233 | progwindow.close() 234 | Qthread.quit() 235 | 236 | def tree_context_menu(self, position): 237 | options = [ 238 | [translate('extract'), self.extract_selected_only] 239 | ] 240 | currItem: CustomQTreeWidgetItem = self.tree.tree.currentItem() 241 | 242 | if currItem: 243 | data = currItem.getJSONData() 244 | 245 | file_extension = data.get('format', None) 246 | 247 | if self.previewWidget.is_preview_supported(file_extension): 248 | options.append(['Preview', lambda: self.showTreeItemPreview(currItem)]) 249 | options.append(['Preview (Windowed)', lambda: self.showTreeItemPreview(currItem, windowed=True)]) 250 | 251 | if file_extension in IMG_FORMATS: 252 | options.append(['View Image', lambda: self.view_image(data)]) 253 | 254 | menu = QMenu(self.tree.tree) 255 | menu.setLayout(QVBoxLayout()) 256 | for option in options: 257 | menu.addAction(option[0], option[1]) 258 | 259 | menu.exec(self.tree.tree.viewport().mapToGlobal(position)) 260 | 261 | def view_image(self, file): 262 | try: 263 | from PIL import Image 264 | _bytes = get_file(file, self.pack) 265 | img = Image.open( _bytes ) # _bytes should be a custom ByteArray with seek and tell, no need to wrap in BytesIO 266 | img.show() 267 | except ImportError: 268 | self.errorWindow('Missing Optional Module', 'PIL Module is missing!\nIf you want to use this function please run "pip install pillow" first!') 269 | except Exception as e: 270 | print(f'[View Image] {e}') 271 | 272 | def select_data_pack(self): 273 | fname, ext = QFileDialog.getOpenFileName( 274 | self, 275 | translate('select_decrypted_pack'), 276 | getDefaultFilePromptPath(), 277 | "Pack (*.pack *.zip *.tar);;", # ;; to allow all files 278 | ) 279 | if fname: 280 | try: 281 | pack = DataPack(fname) 282 | tree_data = pack.tree() 283 | # self.tree.write_temp_files = pack.extract # lambda *args: self.tree_drag_and_drop(pack, *args) # use this for the drag and drop 284 | if self.tree: 285 | self.tree.clearTree() 286 | self.tree.showTree(tree_data) 287 | self.update_compare_icon() 288 | if self.pack: # Clean up previous patch 289 | self.pack.destroy() 290 | self.pack = pack 291 | except NotDataPackZip: 292 | ErrorWindow('Error', 'Unknown data.pack type.\nMake sure it\'s a valid E7 data.pack') 293 | except Exception as e: 294 | print(e) 295 | self.update_btn_state() 296 | 297 | def select_tree_json(self): 298 | fname, ext = QFileDialog.getOpenFileName( 299 | self, 300 | translate('select_decrypted_pack'), 301 | getDefaultFilePromptPath(), 302 | "Epic Seven data pack (*.json);;", 303 | ) 304 | 305 | if fname: 306 | try: 307 | self.pack.load_json_tree_from_path(fname) 308 | self.tree.clearTree() 309 | self.update_compare_icon() 310 | tree = self.pack.tree() 311 | if tree: 312 | self.tree.showTree(tree) 313 | except Exception as e: 314 | ErrorWindow('Error', str(e)) 315 | self.update_btn_state() 316 | 317 | def prompt_select_save_tree_path(self): 318 | if not self.pack or not self.pack.tree(): 319 | return ErrorWindow('Error', 'No tree to save') 320 | 321 | fname, ext = QFileDialog.getSaveFileName( 322 | self, 323 | translate('select_decrypted_pack'), 324 | getDefaultFilePromptPath(), 325 | "JSON file (*.json);;", 326 | ) 327 | 328 | if fname: 329 | with open(fname, 'w') as f: 330 | f.write(json.dumps(self.pack.tree())) 331 | 332 | def prompt_select_compare_tree_path(self): 333 | if self.tree.isComparing(): 334 | self.tree.stopComparing() 335 | else: 336 | fname, ext = QFileDialog.getOpenFileName( 337 | self, 338 | translate('select_decrypted_pack'), 339 | getDefaultFilePromptPath(), 340 | "File Tree (*.json);;", #;; to allow *.* 341 | ) 342 | if fname: 343 | f = open(fname) 344 | tree=json.loads(f.read()) 345 | self.tree.setCompare(tree) 346 | 347 | self.update_compare_icon() 348 | 349 | def update_compare_icon(self): 350 | APP = QApplication.instance() 351 | if self.tree.isComparing(): 352 | self.compare_btn.setIcon(QIcon_from_svg('close.svg', APP.ThemeColors.TOOLBAR_ICON_COLOR)) 353 | self.compare_btn.setText(translate('btn_compare_tree_stop')) 354 | self.compare_btn.setChecked(True) 355 | else: 356 | self.compare_btn.setIcon(QIcon_from_svg('file-compare.svg', APP.ThemeColors.TOOLBAR_ICON_COLOR)) 357 | self.compare_btn.setText(translate('btn_compare_tree')) 358 | self.compare_btn.setChecked(False) 359 | 360 | 361 | def thread_process_error(self, tuple_data, tuple_error): 362 | a,b,c = tuple_error 363 | if a == OperationAbortedByUser: 364 | pass 365 | else: 366 | self.errorWindow('Error', str(b)) 367 | 368 | def extract_selected_only(self): 369 | selected = self.tree.fileTreeJsonFromSelection() 370 | if len(selected) == 0: 371 | return ErrorWindow('Error', 'No files selected!\nPlease select at least one file from the tree view.') 372 | 373 | dest_path = QFileDialog.getExistingDirectory( 374 | self, 375 | translate('select_decrypted_pack'), 376 | getDefaultFilePromptPath() 377 | ) 378 | 379 | if dest_path: 380 | self._extract( dest_path, selected ) 381 | 382 | def extract_all_images(self): 383 | dest_path = QFileDialog.getExistingDirectory( 384 | self, 385 | translate('select_decrypted_pack'), 386 | getDefaultFilePromptPath() 387 | ) 388 | 389 | if dest_path: 390 | self._extract( dest_path, self.tree.fileTreeJsonFromTreeItemsWithFormatFilter(formats=IMG_FORMATS) ) 391 | 392 | def extract_all(self): 393 | dest_path = QFileDialog.getExistingDirectory( 394 | self, 395 | translate('select_decrypted_pack'), 396 | getDefaultFilePromptPath() 397 | ) 398 | 399 | if dest_path: 400 | self._extract(dest_path, self.tree.fileTreeJsonFromView(), translate('extracting_all')) 401 | 402 | def _extract_sync(self, files, path): 403 | ''' 404 | This will freeze the ui until it's done 405 | ''' 406 | t = ThreadData() 407 | t.onprogress = lambda a: print(a) # Show the percentage update in the console 408 | self.pack.extract(files, path, thread=t) 409 | 410 | def _extract(self, dest_path, files, label = None): 411 | ''' 412 | Threaded extract process 413 | ''' 414 | worker = QtThreadedProcess(self.pack, self.pack.extract, files, dest_path) 415 | # worker = QtThreadedProcess(self.pack, self.extract_from_tree_view, self.pack, self.tree, dest_path) 416 | bar: ProgressBar = self.nativeParentWidget().get_progress_bar_window().new(0, label) 417 | bar.destroyed.connect( worker.stop ) 418 | # Stop if window is closed 419 | # c = self.destroyed.connect( bar.remove ) # This works but sometimes it triggers "RuntimeError: wrapped C/C++ object of type WorkerSignals has been deleted" 420 | worker.onprogress = (lambda v: (bar.setValue(v[0]), bar.setLabel(v[1]))) if not label else (lambda v: bar.setValue(v[0])) 421 | worker.onfinished = lambda: (bar.remove())#, self.disconnect(c)) 422 | worker.onerror = self.thread_process_error 423 | worker.run() 424 | 425 | def start_tree_generating(self): 426 | if not self.pack: 427 | return ErrorWindow('Error', 'No data.pack loaded') 428 | 429 | self.progressbar.setValue(0) 430 | self.pack.set_tree(None) 431 | self.tree.clearTree() 432 | self.update_compare_icon() 433 | self.is_generating_tree = True 434 | self.update_btn_state() 435 | self.progressbar.setHidden(False) 436 | worker = QtThreadedProcess(self.pack, self.pack.build_tree) 437 | QApplication.instance().property('MainWindow').closed.connect(worker.stop) # Stop running as soon as the main window is closed 438 | worker.onprogress = self.generation_update 439 | worker.onfinished = self.generation_finish 440 | worker.onerror = self.thread_process_error 441 | worker.onresult = self.generation_complete 442 | # p: ProgressBar = self.nativeParentWidget().get_progress_bar_window() 443 | # progress = p.new() 444 | # progress.setLabel('Generate tree') 445 | # progress.destroyed.connect(lambda *args: worker.stop()) 446 | worker.run() 447 | # self.is_generating_tree = not self.is_generating_tree 448 | 449 | def generation_update(self, value: tuple): 450 | self.progressbar.setValue(*value) 451 | 452 | def generation_finish(self, tuple_data): 453 | self.is_generating_tree = False 454 | self.progressbar.setHidden(True) 455 | self.update_btn_state() 456 | 457 | def generation_complete(self, tuple_data, result): 458 | self.tree.showTree(self.pack.tree()) 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | # This works but changing the file tree view will also change the result of this method while it's running 474 | # @pyqtSlot() 475 | # def extract_from_tree_view(self, pack: DataPack, treeWidget: TreeViewTable, path, thread = None): 476 | # import os 477 | # file_size = 1000000000000 478 | # processed_files = 0 479 | # progress_percentage = -1 480 | # mmap = pack.mmap() 481 | 482 | # def recursive(item: CustomQTreeWidgetItem, path): 483 | # nonlocal file_size, processed_files, progress_percentage, thread 484 | 485 | # if item.isHidden(): 486 | # return 487 | # else: 488 | # if thread.is_stopping(): 489 | # raise Exception() 490 | 491 | # childCount = item.childCount() 492 | # if childCount > 0: 493 | # # Folder 494 | # rel_path = os.path.join(path, item.getJSONData()['name']) 495 | # os.makedirs( rel_path, exist_ok=True ) 496 | # for i in range( childCount ): 497 | # recursive( item.child(i), rel_path ) 498 | # else: 499 | # file = item.getJSONData() 500 | # _pfiles = processed_files + file['size'] 501 | # _percentage = round( _pfiles / file_size *100) 502 | # processed_files = _pfiles 503 | # if _percentage > progress_percentage: 504 | # thread.progress((progress_percentage,file['full_path'])) # keep the comma 505 | # progress_percentage = _percentage 506 | # print(f'Still running {_percentage}') 507 | 508 | # mmap.seek(file['offset']) 509 | # data = pack.read_bytes(mmap, file['offset'], file['size']) 510 | # file_path = os.path.join(path, file['name']) 511 | 512 | # file = FileDescriptor(data=data, path=file_path, tree_file=file, pack=pack, thread=thread) 513 | 514 | # call_hooks('before', file) 515 | 516 | # if file.path: 517 | # with open(file.path, 'wb') as f: 518 | # f.write(file.bytes) 519 | # file.written = True 520 | 521 | # call_hooks('after', file) 522 | 523 | # for i in range(treeWidget.tree.topLevelItemCount()): 524 | # recursive(treeWidget.tree.topLevelItem(i), path) 525 | 526 | # mmap.close() --------------------------------------------------------------------------------