diff --git a/content_database.py b/content_database.py index 2d71c27..bd4ad4b 100644 --- a/content_database.py +++ b/content_database.py @@ -7,28 +7,30 @@ lock = threading.Lock() + def connect_database(db_path: str = "database/archives.db") -> sqlite3.Connection: """Connect to the SQLite database and ensure necessary tables exist.""" conn = sqlite3.connect(db_path) conn.execute("PRAGMA foreign_keys = ON") with conn: - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS archives ( id INTEGER PRIMARY KEY, archive_name TEXT NOT NULL ) - ''') - conn.execute(''' + """) + conn.execute(""" CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY, archive_id INTEGER, file_name TEXT NOT NULL, FOREIGN KEY (archive_id) REFERENCES archives (id) ON DELETE CASCADE ) - ''') + """) return conn + def add_archive(archive_name: str, files: list[str]) -> None: """ Add a new archive and its associated files to the database. @@ -40,7 +42,9 @@ def add_archive(archive_name: str, files: list[str]) -> None: with connect_database() as conn: cursor = conn.cursor() try: - cursor.execute("INSERT INTO archives (archive_name) VALUES (?)", (archive_name,)) + cursor.execute( + "INSERT INTO archives (archive_name) VALUES (?)", (archive_name,) + ) archive_id = cursor.lastrowid cursor.executemany( "INSERT INTO files (archive_id, file_name) VALUES (?, ?)", @@ -50,6 +54,7 @@ def add_archive(archive_name: str, files: list[str]) -> None: except sqlite3.IntegrityError: logging.error(f"Archive '{archive_name}' already exists. Skipping.") + def get_archives() -> list[tuple[str, str]]: """ Retrieve a list of all archives and their file counts. @@ -83,7 +88,9 @@ def delete_archive(archive_name: str) -> None: with lock: # Ensure thread safety with connect_database() as conn: cursor = conn.cursor() - cursor.execute("SELECT id FROM archives WHERE archive_name = ?", (archive_name,)) + cursor.execute( + "SELECT id FROM archives WHERE archive_name = ?", (archive_name,) + ) result = cursor.fetchone() if not result: @@ -91,7 +98,9 @@ def delete_archive(archive_name: str) -> None: return archive_id = result[0] - cursor.execute("SELECT file_name FROM files WHERE archive_id = ?", (archive_id,)) + cursor.execute( + "SELECT file_name FROM files WHERE archive_id = ?", (archive_id,) + ) files = cursor.fetchall() # Delete associated files from the filesystem @@ -125,14 +134,14 @@ def does_archive_exist(archive_name: str, file_list: list[str]) -> bool: with connect_database() as conn: cursor = conn.cursor() cursor.execute( - ''' + """ SELECT COUNT(*) FROM archives a JOIN files f ON a.id = f.archive_id WHERE a.archive_name = ? GROUP BY a.id HAVING COUNT(f.id) = ? - ''', + """, (archive_name, file_count), ) result = cursor.fetchone() - return result is not None \ No newline at end of file + return result is not None diff --git a/helper/config_operations.py b/helper/config_operations.py index c115080..35ca084 100644 --- a/helper/config_operations.py +++ b/helper/config_operations.py @@ -1,14 +1,17 @@ import configparser + def get_library_path(): config = _get_config_file() - return config["PATH"].get("LibraryPath").strip('\"') + return config["PATH"].get("LibraryPath").strip('"') + def get_debug_mode(): config = _get_config_file() return config["DEBUG"].getboolean("DebugMode") + def _get_config_file(): config = configparser.ConfigParser() config.read("config.ini") - return config \ No newline at end of file + return config diff --git a/helper/file_operations.py b/helper/file_operations.py index 6d4edfd..35b8c3e 100644 --- a/helper/file_operations.py +++ b/helper/file_operations.py @@ -5,10 +5,10 @@ from pathlib import Path, PurePath - def get_file_from_path(file_path): return PurePath(file_path).name + def get_file_name_without_extension(file): return file.rpartition(".")[0] @@ -52,6 +52,7 @@ def delete_temp_folder() -> None: if temp_path.exists(): shutil.rmtree(temp_path) + def get_file_size(file_path): file = Path(file_path) if not file.exists(): @@ -110,10 +111,11 @@ def create_logger() -> logging.Logger: filename=str(log_file), level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", - datefmt='%m/%d/%Y %I:%M:%S' + datefmt="%m/%d/%Y %I:%M:%S", ) return logging.getLogger(__name__) + def is_file_archive(file): - if file.lower().endswith(('.zip', '.rar', '.7z', '.tar')): + if file.lower().endswith((".zip", ".rar", ".7z", ".tar")): return True diff --git a/helper/updater.py b/helper/updater.py index 027a4dc..99eb4c5 100644 --- a/helper/updater.py +++ b/helper/updater.py @@ -6,6 +6,7 @@ api_url = "https://api.github.com/repos/Ati1707/DazContentInstaller/releases/latest" + def is_new_update_available(local_version): response = urllib.request.urlopen(api_url).read() data = json.loads(response) @@ -16,5 +17,8 @@ def is_new_update_available(local_version): return True return False + def open_release_page(): - webbrowser.open("https://github.com/Ati1707/DazContentInstaller/releases", new=0, autoraise=True) + webbrowser.open( + "https://github.com/Ati1707/DazContentInstaller/releases", new=0, autoraise=True + ) diff --git a/installer.py b/installer.py index bdb660b..e41d81b 100644 --- a/installer.py +++ b/installer.py @@ -1,4 +1,3 @@ -import logging import pathlib import patoolib import re @@ -20,9 +19,21 @@ # Folders to target during extraction TARGET_FOLDERS = [ - "aniBlocks", "data", "Environments", "Light Presets", "People", "Props", - "ReadMe's", "Render Presets", "Render Settings", "Runtime", "Scenes", - "Scripts", "Shader Presets", "Cameras", "Documentation" + "aniBlocks", + "data", + "Environments", + "Light Presets", + "People", + "Props", + "ReadMe's", + "Render Presets", + "Render Settings", + "Runtime", + "Scenes", + "Scripts", + "Shader Presets", + "Cameras", + "Documentation", ] # Determine base path based on execution context @@ -46,9 +57,9 @@ def get_relative_path(full_path: str) -> str: If the path contains a target folder, return the sub-path starting from the target folder. """ - pattern = r'|'.join([re.escape(folder) for folder in TARGET_FOLDERS]) + pattern = r"|".join([re.escape(folder) for folder in TARGET_FOLDERS]) match = re.search(pattern, full_path) - return full_path[match.start():] if match else full_path + return full_path[match.start() :] if match else full_path def clean_temp_folder() -> None: @@ -65,7 +76,7 @@ def extract_archive(item_path: pathlib.Path, is_debug_mode: bool) -> bool: Extract an archive into the temporary folder. """ base_item_name = item_path.name - if base_item_name.lower().endswith(('.zip', '.rar', '.7z', '.tar')): + if base_item_name.lower().endswith((".zip", ".rar", ".7z", ".tar")): logger.info(f"Extracting {base_item_name}") try: verbosity = 2 if is_debug_mode else -1 @@ -74,7 +85,7 @@ def extract_archive(item_path: pathlib.Path, is_debug_mode: bool) -> bool: outdir=str(TEMP_FOLDER), verbosity=verbosity, interactive=False, - program=str(SEVEN_ZIP_PATH) + program=str(SEVEN_ZIP_PATH), ) time.sleep(1) return True @@ -98,7 +109,11 @@ def add_to_database(root_path: pathlib.Path, item: pathlib.Path) -> bool: Add the extracted files to the content database. """ archive_name = item.stem.split(".")[0] - file_list = [get_relative_path(str(file_path)) for file_path in root_path.rglob("*") if file_path.is_file()] + file_list = [ + get_relative_path(str(file_path)) + for file_path in root_path.rglob("*") + if file_path.is_file() + ] if content_database.does_archive_exist(archive_name, file_list): logger.info(f"Archive '{archive_name}' already exists in the database.") @@ -106,7 +121,9 @@ def add_to_database(root_path: pathlib.Path, item: pathlib.Path) -> bool: archive_exists = True return True else: - logger.info(f"Adding archive '{archive_name}' with {len(file_list)} files to the database.") + logger.info( + f"Adding archive '{archive_name}' with {len(file_list)} files to the database." + ) content_database.add_archive(archive_name, file_list) time.sleep(1) return False @@ -119,7 +136,7 @@ def handle_nested_archives(root_path, files, is_debug_mode): archive_extracted = False for file in files: file_path = root_path / file - if file.lower().endswith(('.zip', '.rar', '.7z', '.tar')): + if file.lower().endswith((".zip", ".rar", ".7z", ".tar")): logger.info(f"Extracting nested archive: {file}") try: verbosity = 2 if is_debug_mode else -1 @@ -138,7 +155,9 @@ def handle_nested_archives(root_path, files, is_debug_mode): return archive_extracted -def process_manifest_and_target_folders(root_path, dirs, files, progressbar, current_item): +def process_manifest_and_target_folders( + root_path, dirs, files, progressbar, current_item +): """ Check for manifest files and target folders, and process them accordingly. """ @@ -149,16 +168,16 @@ def process_manifest_and_target_folders(root_path, dirs, files, progressbar, cur if manifest_exists and folder.lower().startswith("content"): content_path = root_path / folder clean_folder(content_path) - progressbar.set(progressbar.get() + 0.1) + progressbar.setValue(progressbar.value() + 0.1) if add_to_database(content_path, current_item): - progressbar.set(progressbar.get() + 0.1) + progressbar.setValue(progressbar.value() + 0.1) return False shutil.copytree(content_path, get_library_path(), dirs_exist_ok=True) return True if any(target.lower() == folder.lower() for target in TARGET_FOLDERS): clean_folder(root_path) - progressbar.set(progressbar.get() + 0.1) + progressbar.setValue(progressbar.value() + 0.1) if add_to_database(root_path, current_item): return False shutil.copytree(root_path, get_library_path(), dirs_exist_ok=True) @@ -166,7 +185,12 @@ def process_manifest_and_target_folders(root_path, dirs, files, progressbar, cur return False -def traverse_directory(folder_path: pathlib.Path, current_item: pathlib.Path, progressbar, is_debug_mode: bool): +def traverse_directory( + folder_path: pathlib.Path, + current_item: pathlib.Path, + progressbar, + is_debug_mode: bool, +): """ Traverse the directory structure and handle nested archives and target folders. """ @@ -174,19 +198,25 @@ def traverse_directory(folder_path: pathlib.Path, current_item: pathlib.Path, pr root_path = pathlib.Path(root) if handle_nested_archives(root_path, files, is_debug_mode): - progressbar.set(progressbar.get() + 0.1) - return traverse_directory(folder_path, current_item, progressbar, is_debug_mode) - if process_manifest_and_target_folders(root_path, dirs, files, progressbar, current_item): - progressbar.set(progressbar.get() + 0.1) + progressbar.setValue(progressbar.value() + 0.1) + return traverse_directory( + folder_path, current_item, progressbar, is_debug_mode + ) + if process_manifest_and_target_folders( + root_path, dirs, files, progressbar, current_item + ): + progressbar.setValue(progressbar.value() + 0.1) return True if archive_exists: return False - progressbar.set(progressbar.get() + 0.1) + progressbar.setValue(progressbar.value() + 0.1) return False -def start_installer_gui(file_path: str, progressbar, is_delete_archive: bool = False) -> bool: +def start_installer_gui( + file_path: str, progressbar, is_delete_archive: bool = False +) -> bool: """ Main function to handle the installation process via the GUI. @@ -204,20 +234,22 @@ def start_installer_gui(file_path: str, progressbar, is_delete_archive: bool = F logger.info(f"Installing {file_path}") create_temp_folder() clean_temp_folder() - progressbar.set(0.1) + progressbar.setValue(0.1) if not extract_archive(file_path, get_debug_mode()): clean_temp_folder() return is_archive_imported - progressbar.set(0.4) + progressbar.setValue(0.4) if traverse_directory(TEMP_FOLDER, file_path, progressbar, get_debug_mode()): is_archive_imported = True logger.info(f"Successfully imported: {file_path}") else: is_archive_imported = False - logger.warning(f"Failed to import {file_path}. Invalid folder structure or asset already exists.") + logger.warning( + f"Failed to import {file_path}. Invalid folder structure or asset already exists." + ) clean_temp_folder() delete_temp_folder() diff --git a/main.pyw b/main.pyw index 384e776..d27bd3f 100644 --- a/main.pyw +++ b/main.pyw @@ -1,340 +1,357 @@ -import customtkinter as ctk -import pywinstyles -import threading +import os +import sys +from pathlib import Path + +from PySide6.QtCore import QThread, Signal, QObject +from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QTabWidget, QScrollArea, + QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton, QProgressBar, + QLabel, QFrame, QFileDialog, QMessageBox) -from CTkMessagebox import CTkMessagebox -from CTkToolTip import CTkToolTip from content_database import get_archives, delete_archive -from customtkinter import CTk, filedialog, CTkLabel from helper import file_operations, updater from helper.file_operations import is_file_archive from installer import start_installer_gui -from tkinter import BooleanVar -from tkinter.constants import DISABLED, NORMAL -from webbrowser import open install_asset_list = [] remove_asset_list = [] -def center_window_to_display(screen: CTk, width: int, height: int, scale_factor: float = 1.0) -> str: +def center_window_to_display(window: QWidget, width: int, height: int) -> None: """Centers the window on the main display.""" - scaled_width = int(width / scale_factor) - scaled_height = int(height / scale_factor) - screen_width = screen.winfo_screenwidth() - screen_height = screen.winfo_screenheight() - - # Calculate the x and y coordinates for centering the window - x = int((screen_width - scaled_width) / 2) - y = int((screen_height - scaled_height) / 2) - - return f"{scaled_width}x{scaled_height}+{x}+{y}" + screen_geometry = QApplication.primaryScreen().availableGeometry() + x = (screen_geometry.width() - width) // 2 + y = (screen_geometry.height() - height) // 2 + window.setGeometry(x, y, width, height) def truncate_string(text: str, max_length: int = 50) -> str: """Truncates a string with ellipsis if it exceeds the max length.""" return text[:max_length - 3] + '...' if len(text) > max_length else text -def start_install_thread(target_function): - thread = threading.Thread(target=target_function) - thread.start() +class Worker(QObject): + finished = Signal() + progress = Signal(float) + + def __init__(self, target_function, *args, **kwargs): + super().__init__() + self.target_function = target_function + self.args = args + self.kwargs = kwargs + + def run(self): + self.target_function(*self.args, **self.kwargs) + self.finished.emit() + +class InstallTab(QWidget): + """Custom widget for the Install tab with drag-and-drop support.""" + def __init__(self, parent): + super().__init__(parent) + self.setup_ui() + self.setAcceptDrops(True) # Enable drops for this widget + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Check All checkbox + self.check_install = QCheckBox("Check all") + self.check_install.stateChanged.connect(self.toggle_install_checkboxes) + layout.addWidget(self.check_install) + + # Scroll area + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + self.scroll_content = QWidget() + self.scroll_layout = QVBoxLayout(self.scroll_content) + self.scroll_layout.addStretch() + scroll_area.setWidget(self.scroll_content) + layout.addWidget(scroll_area) + + # Bottom buttons + bottom_frame = QWidget() + bottom_layout = QHBoxLayout(bottom_frame) + + self.del_archive_checkbox = QCheckBox("Delete Archive after Installation") + self.del_archive_checkbox.stateChanged.connect( + lambda state: setattr(self.parent().parent(), 'is_delete_archive', state == self.del_archive_checkbox.checkState()) + ) + bottom_layout.addWidget(self.del_archive_checkbox) + + self.remove_button = QPushButton("Remove selected") + self.remove_button.clicked.connect(self.remove_selected) + bottom_layout.addWidget(self.remove_button) + + self.add_asset_button = QPushButton("Add Asset") + self.add_asset_button.clicked.connect(self.select_file) + bottom_layout.addWidget(self.add_asset_button) + + self.install_button = QPushButton("Install selected") + self.install_button.clicked.connect(self.install_assets) + bottom_layout.addWidget(self.install_button) + + layout.addWidget(bottom_frame) + + @staticmethod + def toggle_install_checkboxes(state): + for asset in install_asset_list: + asset.checkbox.setChecked(state == asset.checkbox.setChecked) + + def add_asset_widget(self, asset_name: str, asset_path: str): + """Adds a new asset widget to the install scroll area.""" + asset = AssetWidget(self.scroll_content, "Install", asset_name, asset_path) + self.scroll_layout.insertWidget(self.scroll_layout.count() - 1, asset) + install_asset_list.append(asset) + + def select_file(self): + """Prompts user to select a file and adds an asset widget.""" + file_path, _ = QFileDialog.getOpenFileName(self, "Select Asset File") + if file_path: + file_name = Path(file_path).name + if is_file_archive(file_name): + asset_name = file_operations.get_file_name_without_extension(file_name) + self.add_asset_widget(asset_name, file_path) + else: + QMessageBox.information(self, "Info", "The File is not an archive") + + @staticmethod + def remove_selected(): + for asset in install_asset_list.copy(): + if asset.checkbox.isChecked(): + asset.remove_from_view() + + def install_assets(self): + msg = QMessageBox.question( + self, + "Install?", + "Do you want to install the selected assets?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if msg == QMessageBox.StandardButton.Yes: + self.install_button.setEnabled(False) + for asset in install_asset_list.copy(): + if asset.checkbox.isChecked(): + asset.install_asset() + self.install_button.setEnabled(True) + self.check_install.setChecked(False) + + def dragEnterEvent(self, event): + """Accept drag events if the dragged content contains files.""" + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event): + """Handle dropped files.""" + files = [url.toLocalFile() for url in event.mimeData().urls()] + for file_path in files: + if is_file_archive(file_path): + file_name = Path(file_path).name + asset_name = file_operations.get_file_name_without_extension(file_name) + self.add_asset_widget(asset_name, file_path) + else: + QMessageBox.information(self, "Info", "The File is not an archive") + event.acceptProposedAction() + +class AssetWidget(QFrame): + """Custom widget to represent an asset in the UI.""" -class AssetWidget(ctk.CTkFrame): - """ - Custom widget to represent an asset in the UI. - """ def __init__(self, parent, tab_name: str, asset_name: str = "", file_path: str = ""): super().__init__(parent) self.asset_name = asset_name self.file_path = file_path self.file_size = file_operations.get_file_size(self.file_path) + self.tab_name = tab_name + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setLineWidth(1) + self.setStyleSheet("AssetWidget { border: 1px solid #4A90E2; }") - # Checkbox for asset selection - self.checkbox = ctk.CTkCheckBox(self, text=truncate_string(self.asset_name)) - self.checkbox.grid(row=0, column=0, padx=20, pady=10, sticky="w") + layout = QHBoxLayout(self) + self.checkbox = QCheckBox(truncate_string(self.asset_name)) + self.checkbox.setToolTip(self.asset_name) + layout.addWidget(self.checkbox) - # Tooltip for additional info - self.tooltip = CTkToolTip(self.checkbox, message=self.asset_name, delay=0.2) - - # Install or Uninstall button if tab_name == "Install": - self._create_install_widgets() + self._create_install_widgets(layout) else: - self._create_uninstall_widgets() - - # Configure layout - self.columnconfigure(0, weight=1) - self.columnconfigure(1, weight=0) - self.columnconfigure(2, weight=0) - self.columnconfigure(3, weight=0) + self._create_uninstall_widgets(layout) - def _create_install_widgets(self): - """Creates widgets specific to the 'Install' tab.""" - self.progressbar = ctk.CTkProgressBar(self) - self.progressbar.set(0) - self.progressbar.grid(row=0, column=1, padx=20, pady=10, sticky="w") + def _create_install_widgets(self, layout): + self.progressbar = QProgressBar() + self.progressbar.setValue(0) + layout.addWidget(self.progressbar) - self.label = CTkLabel(self, text=self.file_size) - self.label.grid(row=0, column=2, padx=20, pady=10, sticky="w") + self.label = QLabel(self.file_size) + layout.addWidget(self.label) - self.button = ctk.CTkButton(self, text="Install", command=lambda: start_install_thread(self.install_asset)) - self.button.grid(row=0, column=3, padx=20, pady=10, sticky="e") - - def _create_uninstall_widgets(self): - """Creates widgets specific to the 'Uninstall' tab.""" - self.button = ctk.CTkButton(self, text="Remove", command=self.remove_asset) - self.button.grid(row=0, column=1, padx=20, pady=10, sticky="e") + self.button = QPushButton("Install") + self.button.clicked.connect(self.install_asset) + layout.addWidget(self.button) + def _create_uninstall_widgets(self, layout): + self.button = QPushButton("Remove") + self.button.clicked.connect(self.remove_asset) + layout.addWidget(self.button) def remove_from_view(self): - """ - Removes the asset widget from the UI and the installation list. - """ + """Removes the asset widget from the UI.""" if self in install_asset_list: install_asset_list.remove(self) - self.grid_remove() + self.setParent(None) + self.deleteLater() def install_asset(self): - """ - Installs the asset and removes its widget from the grid. - """ - self.button.configure(state=DISABLED) # Disable the button during installation + """Installs the asset and removes its widget from the grid.""" + self.button.setEnabled(False) + thread = QThread() + worker = Worker(self._perform_installation) + worker.moveToThread(thread) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.started.connect(worker.run) + thread.start() + + def _perform_installation(self): archive_imported = start_installer_gui( self.file_path, self.progressbar, - is_delete_archive=self.winfo_toplevel().tab_view.is_delete_archive.get() + is_delete_archive=self.window().tab_view.is_delete_archive ) if not archive_imported: - CTkMessagebox( - title="Warning", - message=f"The archive '{self.asset_name}' was not imported. Check the log for more info.", - icon="warning" + QMessageBox.warning( + self, + "Warning", + f"The archive '{self.asset_name}' was not imported. Check the log for more info." ) self.remove_from_view() def remove_asset(self): - """ - Removes the asset from the database and the uninstall list. - """ + """Removes the asset from the database and the uninstall list.""" delete_archive(self.asset_name) if self in remove_asset_list: remove_asset_list.remove(self) - self.grid_remove() - -class MyTabView(ctk.CTkTabview): - """ - Custom tab view for managing install and uninstall tabs. - """ - def __init__(self, master, **kwargs): - super().__init__(master, command=self.refresh_tab, **kwargs) - self.is_delete_archive = BooleanVar() - self.create_tabs() - self.create_install_widgets() - self.create_uninstall_widgets() - - def create_tabs(self): - """ - Initializes the 'Install' and 'Uninstall' tabs. - """ - install_tab = self.add("Install") - uninstall_tab = self.add("Uninstall") - - # Configure tabs - install_tab.grid_columnconfigure(0, weight=1) - install_tab.grid_rowconfigure(1, weight=1) - uninstall_tab.grid_columnconfigure(0, weight=1) - uninstall_tab.grid_rowconfigure(1, weight=1) - - # 'Check All' checkboxes - self.check_install = ctk.CTkCheckBox(install_tab, text="Check all", command=self.toggle_all_checkboxes) - self.check_install.grid(row=0, column=0, sticky="we") - - self.check_uninstall = ctk.CTkCheckBox(uninstall_tab, text="Check all", command=self.toggle_all_checkboxes) - self.check_uninstall.grid(row=0, column=0, sticky="we") - - # Scrollable frames for each tab - self.scrollable_frame = ctk.CTkScrollableFrame(install_tab, width=600, height=500) - self.scrollable_frame.grid(row=1, column=0, sticky="news") - self.scrollable_frame.grid_columnconfigure(0, weight=1) - - self.uninstall_scrollable_frame = ctk.CTkScrollableFrame(uninstall_tab, width=600, height=500) - self.uninstall_scrollable_frame.grid(row=1, column=0, sticky="news") - self.uninstall_scrollable_frame.grid_columnconfigure(0, weight=1) - - def toggle_all_checkboxes(self): - """ - Toggles all checkboxes in the 'Install' and 'Uninstall' tabs. - """ - if self.check_install.get(): - for asset in install_asset_list: - asset.checkbox.select() - else: - for asset in install_asset_list: - asset.checkbox.deselect() + self.remove_from_view() - if self.check_uninstall.get(): - for asset in remove_asset_list: - asset.checkbox.select() - else: - for asset in remove_asset_list: - asset.checkbox.deselect() - - - def create_uninstall_widgets(self): - """ - Creates widgets for the 'Uninstall' tab. - """ - uninstall_tab = self.tab("Uninstall") - - uninstall_button = ctk.CTkButton(uninstall_tab, text="Remove selected", command=self.remove_assets) - uninstall_button.grid(row=2, column=0, padx=20, pady=10, sticky="se") - - def create_install_widgets(self): - """ - Creates widgets for the 'Install' tab. - """ - install_tab = self.tab("Install") - - install_frame = ctk.CTkFrame(install_tab) - install_frame.grid(row=2, column=0, sticky="news") - install_frame.grid_columnconfigure(0, weight=0) - install_frame.grid_columnconfigure(1, weight=1) - install_frame.grid_columnconfigure(1, weight=1) - install_frame.grid_columnconfigure(2, weight=0) - - # Delete Archive checkbox - del_archive_checkbox = ctk.CTkCheckBox(install_frame, text="Delete Archive after Installation", variable=self.is_delete_archive) - del_archive_checkbox.grid(row=0, column=0, padx=20, pady=10) - - self.remove_button = ctk.CTkButton(install_frame, text="Remove selected", command=self.remove_selected) - self.remove_button.grid(row=0, column=1, padx=20, pady=10, sticky="w") - - # Add Asset button - add_asset_button = ctk.CTkButton(install_frame, text="Add Asset", command=self.select_file) - add_asset_button.grid(row=0, column=1, padx=20, pady=10, sticky="s") - - # Install selected button - self.install_button = ctk.CTkButton(install_frame, text="Install selected", command=self.install_assets) - self.install_button.grid(row=0, column=2, padx=50, pady=10) - def add_asset_widget(self, asset_name: str, asset_path: str): - """Adds a new asset widget to the scrollable frame.""" - asset = AssetWidget(self.scrollable_frame, "Install", asset_name=asset_name, file_path=asset_path) - asset.configure(border_color="#4A90E2", border_width=1) - asset.grid(row=len(self.scrollable_frame.winfo_children()), column=0, padx=20, pady=5, sticky="news") - install_asset_list.append(asset) - - def select_file(self): - """Prompts user to select a file and adds an asset widget.""" - file_path = filedialog.askopenfilename() - if file_path: - file_name = file_operations.get_file_from_path(file_path) - asset_name = file_operations.get_file_name_without_extension(file_name) - if is_file_archive(file_name): - self.add_asset_widget(asset_name, file_path) - else: - CTkMessagebox(title="Info", - message="The File is not an archive", - option_1="Okay", - width=500, height=250) - - def remove_selected(self): - temp_install_list = install_asset_list.copy() - for asset in temp_install_list: - if asset.checkbox.get(): - asset.remove_from_view() - - def install_assets(self): - """Installs selected assets and removes their widgets.""" - msg = CTkMessagebox(title="Install?", message="Do you want to install the selected assets?", - icon="question", option_1="No", option_2="Yes") - if msg.get() == "Yes": - self.install_button.configure(state=DISABLED) - temp_install_list = install_asset_list.copy() - for asset in temp_install_list: - if asset.checkbox.get(): - start_install_thread(asset.install_asset) - self.install_button.configure(state=NORMAL) - self.check_install.deselect() +class MyTabView(QTabWidget): + """Custom tab view for managing install and uninstall tabs.""" + def __init__(self, parent): + super().__init__(parent) + self.is_delete_archive = False + self.setup_ui() + + def setup_ui(self): + self.install_tab = InstallTab(self) + self.uninstall_tab = QWidget() + self.addTab(self.install_tab, "Install") + self.addTab(self.uninstall_tab, "Uninstall") + + self.setup_uninstall_tab() + self.currentChanged.connect(self.refresh_tab) + + def setup_uninstall_tab(self): + layout = QVBoxLayout(self.uninstall_tab) + + # Check All checkbox + self.check_uninstall = QCheckBox("Check all") + self.check_uninstall.stateChanged.connect(self.toggle_uninstall_checkboxes) + layout.addWidget(self.check_uninstall) + + # Scroll area + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + self.uninstall_scroll_content = QWidget() + self.uninstall_scroll_layout = QVBoxLayout(self.uninstall_scroll_content) + self.uninstall_scroll_layout.addStretch() + scroll_area.setWidget(self.uninstall_scroll_content) + layout.addWidget(scroll_area) + + # Remove button + self.uninstall_button = QPushButton("Remove selected") + self.uninstall_button.clicked.connect(self.remove_assets) + layout.addWidget(self.uninstall_button) + + @staticmethod + def toggle_uninstall_checkboxes(state): + for asset in remove_asset_list: + asset.checkbox.setChecked(state == asset.checkbox.setChecked) def remove_assets(self): - """Removes the selected assets and their widgets.""" - msg = CTkMessagebox(title="Remove?", message="Do you want to remove the selected assets?", - icon="question", option_1="No", option_2="Yes") - if msg.get() == "Yes": - temp_uninstall_list = remove_asset_list.copy() - for asset in temp_uninstall_list: - if asset.checkbox.get(): + msg = QMessageBox.question( + self, + "Remove?", + "Do you want to remove the selected assets?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if msg == QMessageBox.StandardButton.Yes: + for asset in remove_asset_list.copy(): + if asset.checkbox.isChecked(): asset.remove_asset() - def drop_files(self, files): - """Handles file drop to create asset widgets.""" - for file_path in files: - if is_file_archive(file_path): - file_name = file_operations.get_file_from_path(file_path) - asset_name = file_operations.get_file_name_without_extension(file_name) - self.after(50, self.add_asset_widget, asset_name, file_path) - else: - CTkMessagebox(title="Info", - message="The File is not an archive", - option_1="Okay", - width=500, height=250) - + def refresh_tab(self, index): + if self.tabText(index) == "Uninstall": + # Clear existing widgets + while self.uninstall_scroll_layout.count() > 1: + item = self.uninstall_scroll_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() - def refresh_tab(self): - selected_tab = self.get() - if selected_tab == "Uninstall": - for asset_widgets in self.uninstall_scrollable_frame.winfo_children(): - asset_widgets.destroy() + # Add new assets assets = get_archives() remove_asset_list.clear() for asset in assets: - asset_widget = AssetWidget(self.uninstall_scrollable_frame, "Uninstall", asset_name=asset[0]) - asset_widget.configure(border_color="#4A90E2", border_width=1) - asset_widget.grid(row=len(self.uninstall_scrollable_frame.winfo_children()), column=0, padx=20, pady=5, sticky="news") - remove_asset_list.append(asset_widget) + widget = AssetWidget(self.uninstall_scroll_content, "Uninstall", asset_name=asset[0]) + self.uninstall_scroll_layout.insertWidget(0, widget) + remove_asset_list.append(widget) -class App(CTk): +class App(QMainWindow): local_version = "v0.9.1" - def __init__(self, local_version=local_version): + def __init__(self): super().__init__() - self.title(f"Daz Content Installer {local_version}") - self.geometry(center_window_to_display(self, 1100, 650, self._get_window_scaling())) + self.setWindowTitle(f"Daz Content Installer {self.local_version}") + self.resize(1100, 650) + center_window_to_display(self, 1100, 650) - # Initialize and place MyTabView - self.tab_view = MyTabView(master=self) - self.tab_view.grid(row=0, column=0, padx=20, pady=20, sticky="news") + # Main widget and layout + main_widget = QWidget() + self.setCentralWidget(main_widget) + layout = QVBoxLayout(main_widget) - # Enable drag-and-drop for the 'Install' tab - pywinstyles.apply_dnd(self.tab_view.tab("Install"), self.tab_view.drop_files) + # Initialize and place MyTabView + self.tab_view = MyTabView(self) + layout.addWidget(self.tab_view) - # Configure main window grid - self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(0, weight=1) + # Initial setup checks + self.initial_checks() + def initial_checks(self): if file_operations.create_database_folder(): - msg = CTkMessagebox(title="Info", - message="It seems like this is your first time opening the tool!\n\n" - "You can use the default library which will be in this folder but you can also use a different path.\n", - option_1="No", - option_2="Open configuration file", - width=500, height=250) - if msg.get() == "Open configuration file": - open("config.ini") - - if updater.is_new_update_available(local_version): - msg = CTkMessagebox(title="Info", - message="A new update is available! Do you want to open the GitHub repository?", - option_1="Cancel", - option_2="Open", - width=500, height=250) - if msg.get() == "Open": + msg = QMessageBox.information( + self, + "Info", + "It seems like this is your first time opening the tool!\n\n" + "You can use the default library which will be in this folder but you can also use a different path.\n" + "Do you want to open the config file?\n", + QMessageBox.StandardButton.Yes, + QMessageBox.StandardButton.No + ) + if msg == QMessageBox.StandardButton.Yes: + os.startfile("config.ini") + + if updater.is_new_update_available(self.local_version): + msg = QMessageBox.question( + self, + "Info", + "A new update is available! Do you want to open the GitHub repository?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if msg == QMessageBox.StandardButton.Yes: updater.open_release_page() -# Run the application -app = App() -app.mainloop() + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = App() + window.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/patches.py b/patches.py index 5f500d0..9dd06a4 100644 --- a/patches.py +++ b/patches.py @@ -16,6 +16,7 @@ def patched_run_checked(cmd, ret_ok=(0,), **kwargs): return retcode + def patched_guess_mime_file(filename: str) -> tuple[str | None, str | None]: """Determine MIME type of filename with file(1): (a) using `file --brief --mime-type` @@ -26,7 +27,7 @@ def patched_guess_mime_file(filename: str) -> tuple[str | None, str | None]: """ mime, encoding = None, None base, ext = os.path.splitext(filename) - if ext.lower() in ('.alz',): + if ext.lower() in (".alz",): # let mimedb recognize these extensions return mime, encoding if os.path.isfile(filename): @@ -50,7 +51,7 @@ def patched_guess_mime_file(filename: str) -> tuple[str | None, str | None]: # implementation return the original file type. # The following detects both cases. if ( - mime2 in ('application/x-empty', 'application/octet-stream') + mime2 in ("application/x-empty", "application/octet-stream") or mime2 in Mime2Encoding or not mime2 ): @@ -66,6 +67,7 @@ def patched_guess_mime_file(filename: str) -> tuple[str | None, str | None]: encoding = get_file_mime_encoding(outparts) return mime, encoding + def patched_run(cmd: Sequence[str], verbosity: int = 0, **kwargs) -> int: """Run command without error checking. @return: command return code @@ -84,20 +86,18 @@ def patched_run(cmd: Sequence[str], verbosity: int = 0, **kwargs) -> int: kwargs["input"] = "" if verbosity < 1: # hide command output on stdout - kwargs['stdout'] = subprocess.DEVNULL - kwargs['stderr'] = subprocess.DEVNULL + kwargs["stdout"] = subprocess.DEVNULL + kwargs["stderr"] = subprocess.DEVNULL if kwargs: if verbosity > 0: info = ", ".join(f"{k}={shell_quote(str(v))}" for k, v in kwargs.items()) log_info(f" with {info}") - kwargs['stdout'] = subprocess.PIPE - kwargs['stderr'] = subprocess.PIPE + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.PIPE if kwargs.get("shell"): # for shell calls the command must be a string cmd = " ".join(cmd) - kwargs["creationflags"] = ( - subprocess.CREATE_NO_WINDOW # pytype: disable=module-attr - ) + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW # pytype: disable=module-attr res = subprocess.run(cmd, text=True, **kwargs) if res.stdout: log_subprocess_output(res.stdout, level=logging.INFO) @@ -106,14 +106,16 @@ def patched_run(cmd: Sequence[str], verbosity: int = 0, **kwargs) -> int: log_subprocess_output(res.stderr, level=logging.ERROR) return res.returncode + def log_subprocess_output(output: str, level: int): logger = logging.getLogger(__name__) for line in output.splitlines(): logger.log(level, line) + # Apply the patch import patoolib patoolib.util.run = patched_run patoolib.util.run_checked = patched_run_checked -patoolib.mime.guess_mime_file = patched_guess_mime_file \ No newline at end of file +patoolib.mime.guess_mime_file = patched_guess_mime_file diff --git a/requirements.txt b/requirements.txt index e8cd1de..5748b54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ patool -customtkinter -pywinstyles -CTkToolTip -CTkMessagebox \ No newline at end of file +packaging +PySide6 \ No newline at end of file