├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/gui/assets/icons/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/folder-outline.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/arrow-right-drop-circle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/trash-can-outline.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/file-multiple.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/open-in-new.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/content-save.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/file-multiple-outline.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/gui/assets/icons/file-compare.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/refresh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/content-save-outline.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/file-image-outline.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gui/assets/icons/checkbox-multiple-marked-outline.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/gui/assets/icons/magnify.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
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()
--------------------------------------------------------------------------------