diff --git a/.DS_Store b/.DS_Store index 88788dd..0ec6926 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33be1b5..f1342d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,16 +30,16 @@ jobs: if: github.event_name == 'push' uses: softprops/action-gh-release@v2 with: - tag_name: 2.0.0 - name: 2.0.0 Stable + tag_name: 2.1.0A6 + name: 2.1.0 Alpha 4 body: ${{ github.event.head_commit.message }} - prerelease: false + prerelease: true files: | ./dist/Converter_arm64_darwin.zip build_intel: name: Build Intel runs-on: macos-15-intel - if: github.repository_owner == 'pyquick' + if: github.repository_owner == 'intsant' permissions: contents: write steps: @@ -58,10 +58,9 @@ jobs: if: github.event_name == 'push' uses: softprops/action-gh-release@v2 with: - tag_name: 2.0.0 - name: 2.0.0 Stable + tag_name: 2.1.0A6 + name: 2.1.0 Alpha 4 body: ${{ github.event.head_commit.message }} - prerelease: false + prerelease: true files: | - ./dist/Converter_intel_darwin.zip - \ No newline at end of file + ./dist/Converter_intel_darwin.zip \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52c80aa..572e1d7 100644 --- a/.gitignore +++ b/.gitignore @@ -201,7 +201,7 @@ cython_debug/ # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore - +.trae/ # Marimo marimo/_static/ marimo/_lsp/ diff --git a/CHANGELOG/v2.0.0_new_features.md b/CHANGELOG/v2.0.0_new_features.md deleted file mode 100644 index 4ff6c81..0000000 --- a/CHANGELOG/v2.0.0_new_features.md +++ /dev/null @@ -1,77 +0,0 @@ -# New Features and Code Improvements - -## New Features - -### 1. Password Protection Support -- **Password Detection Module**: Implemented password detection functionality for ZIP, RAR, and 7z formats -- **Secure Password Dialog**: Added secure password input interface with comprehensive error handling -- **Protected File Handling**: Enhanced capability for processing password-protected archive files - -### 2. Update Management System -- **GitHub PAT Integration**: Implemented secure storage and validation of GitHub Personal Access Tokens (PAT) -- **Automatic Update Mechanism**: Added automatic update detection, download, and installation workflow -- **Backup and Recovery**: Implemented seamless backup and recovery functionality during update processes - -### 3. Theme System -- **Automatic Theme Switching**: Implemented system theme detection and synchronization -- **Dark/Light Mode Support**: Added complete dark and light theme support -- **Theme Persistence**: User theme selections persist across application restarts - -### 4. Settings Management System -- **Real-time Auto-save**: Implemented real-time automatic saving for settings dialogs -- **Status Label Display**: Added visual feedback for save status -- **Settings Validation**: Implemented validation for configuration items - -### 5. Archive Management Tools -- **ZIP Processing Tools**: Added complete ZIP file creation, extraction, and modification functionality -- **Progress Tracking**: Added detailed progress display for archive operations -- **Content Listing**: Implemented archive file content viewing functionality - -## Code Improvements - -### 1. Architecture Refactoring -- **Modular Design**: Refactored code into a more modular architecture for improved maintainability -- **Component Separation**: Achieved clear separation between UI components and business logic -- **Dependency Injection**: Improved dependency relationship management between components - -### 2. UI Framework Migration -- **wxPython to PySide6 Migration**: Completed full migration from wxPython to PySide6 (Qt6) -- **Modernized UI**: Implemented a more modern, fluid user interface design -- **Responsive Layout**: Improved interface layout responsiveness and adaptability - -### 3. Error Handling and Logging -- **Comprehensive Error Handling**: Implemented complete exception catching and handling mechanisms -- **Structured Logging System**: Added structured logging system -- **User-Friendly Error Messages**: Improved user-friendliness of error messages - -### 4. Performance Optimization -- **Asynchronous Operations**: Converted time-consuming operations to asynchronous execution for better UI responsiveness -- **Memory Management**: Optimized memory usage and reduced memory leak risks -- **Resource Loading**: Improved resource loading and management methods - -### 5. Security Enhancements -- **Secure Storage**: Implemented secure credential storage using macOS Keychain -- **Input Validation**: Added comprehensive input validation and sanitization mechanisms -- **Permission Management**: Improved application permission management and requests - -### 6. Build System Improvements -- **Dual Architecture Support**: Implemented dual architecture builds for ARM64 and Intel Macs -- **Dependency Management**: Improved dependency management and version control during build processes -- **Automated CI/CD**: Enhanced GitHub Actions workflow automation - -### 7. Code Quality Enhancement -- **Type Hints**: Added comprehensive Python type hints -- **Docstrings**: Improved function and class documentation strings -- **Code Standards**: Unified code style and naming conventions - -## Summary - -These new features and code improvements collectively constitute a comprehensive upgrade from version 1.0.0 pre2 to the current 2.0.0 version, not only enhancing application functionality but also significantly improving code quality and user experience. - -The most significant changes include: -- Complete UI framework migration from wxPython to PySide6 (Qt6) -- Implementation of password protection and secure credential management -- Addition of automatic update management system -- Enhanced theme system with automatic switching -- Improved build system supporting dual architecture (ARM64 and Intel) -- Comprehensive code refactoring for better maintainability and performance \ No newline at end of file diff --git a/Converter.py b/Converter.py index 7e5a356..bcf801e 100644 --- a/Converter.py +++ b/Converter.py @@ -3,8 +3,8 @@ from concurrent.futures import thread from importlib import reload import sys - import os +import threading from PySide6.QtWidgets import ( QApplication, QWidget, @@ -16,14 +16,26 @@ QGridLayout, QSizePolicy, QGroupBox, - QDialog + QDialog, + QListWidget, + QListWidgetItem, + QMessageBox, + QFrame, + QStackedWidget ) -from PySide6.QtGui import QIcon, QPainter, QPixmap, QPalette -from PySide6.QtCore import QSize, Qt, QSettings, QPropertyAnimation, QEasingCurve, QTimer +from PySide6.QtGui import QIcon, QPainter, QPixmap, QPalette, QColor +from PySide6.QtCore import QSize, Qt, QSettings, QPropertyAnimation, QEasingCurve, QTimer, Signal import multiprocessing -from qfluentwidgets import Theme, setTheme,qconfig,SystemThemeListener - # Keep for freeze_support, but remove direct Process usage -from settings.update_settings_gui import UpdateDialog +from qfluentwidgets import ( + HeaderCardWidget, ImageLabel, Theme, setTheme, qconfig, SystemThemeListener, + FluentWindow, NavigationItemPosition, + CardWidget, PushButton, PrimaryPushButton, IconWidget, + BodyLabel, CaptionLabel, SubtitleLabel, TitleLabel, LargeTitleLabel, + FluentIcon as FIF, setFont, TransparentToolButton, SegmentedWidget, + setCustomStyleSheet, ElevatedCardWidget, ProgressBar, FlowLayout, + ScrollArea +) +from qfluentwidgets.components.widgets.card_widget import SimpleCardWidget from settings.settings_gui import SettingsDialog from con import CON # Import CON instance for theme settings # Encoding settings have been moved to debug_logger for handling @@ -55,7 +67,518 @@ def create_placeholder_icon(path: str, color: str, text: str): return True return False -class IconButtonsWindow(QWidget): + +class AppCard(CardWidget): + """Application card widget""" + + def __init__(self, icon_path, title, content, app_type, parent=None): + super().__init__(parent) + self.title = title + self.content = content + self.app_type = app_type + self.icon_path = icon_path + self.icon_widget = ImageLabel(icon_path, self) + self.title_label = BodyLabel(self.title, self) + self.content_label = CaptionLabel(self.content, self) + self.icon_widget.scaledToHeight(68) + self.icon_widget.setFixedSize(48, 48) + self.content_label.setTextColor(QColor("#606060"), QColor("#d2d2d2")) + self.setFixedHeight(73) + self.h_box_layout = QHBoxLayout(self) + self.v_box_layout = QVBoxLayout() + + + # Configure layouts + self.h_box_layout.setContentsMargins(20, 11, 11, 11) + self.h_box_layout.setSpacing(15) + self.v_box_layout.setContentsMargins(0, 0, 0, 0) + self.v_box_layout.setSpacing(0) + + + self.open_button = PrimaryPushButton('Open', self) + self.open_button.setFixedWidth(120) + self.more_button = TransparentToolButton(FIF.MORE, self) + self.more_button.setFixedSize(32, 32) + + # Add components to layouts + self.h_box_layout.addWidget(self.icon_widget) + + self.v_box_layout.addWidget(self.title_label, 0, Qt.AlignmentFlag.AlignVCenter) + self.v_box_layout.addWidget(self.content_label, 0, Qt.AlignmentFlag.AlignVCenter) + self.h_box_layout.addLayout(self.v_box_layout) + + self.h_box_layout.addStretch(1) + self.h_box_layout.addWidget(self.open_button, 0, Qt.AlignmentFlag.AlignRight) + self.h_box_layout.addWidget(self.more_button, 0, Qt.AlignmentFlag.AlignRight) + + self.open_button.clicked.connect(self.on_open_clicked) + + + def on_open_clicked(self): + """Handle open button clicked event""" + if self.app_type == 'image': + run_image_app() + elif self.app_type == 'arc': + run_zip_app() + + +class AppCardTask(CardWidget): + """Task card widget for task manager interface""" + + task_cancelled = Signal(str) + task_retried = Signal(str) + task_removed = Signal(str) + + def __init__(self, task_id, task_type, task_info, parent=None): + super().__init__(parent) + self.task_id = task_id + self.task_type = task_type + self.task_info = task_info + self.setup_ui() + self.setup_connections() + + def setup_ui(self): + """Initialize UI components""" + self.setFixedHeight(120) + self.setFixedWidth(280) + self.setBorderRadius(12) + + main_layout = QHBoxLayout(self) + main_layout.setContentsMargins(15, 12, 15, 12) + main_layout.setSpacing(12) + + icon_layout = QVBoxLayout() + icon_layout.setContentsMargins(0, 0, 0, 0) + icon_layout.setSpacing(4) + + if self.task_type == "image": + task_icon = FIF.PHOTO + else: + task_icon = FIF.FOLDER + self.icon_widget = IconWidget(task_icon, self) + self.icon_widget.setFixedSize(40, 40) + icon_layout.addWidget(self.icon_widget, 0, Qt.AlignmentFlag.AlignCenter) + + status_badges = { + "pending": ("Pending", "#9e9e9e"), + "running": ("Running", "#2196f3"), + "completed": ("Completed", "#4caf50"), + "failed": ("Failed", "#f44336"), + "cancelled": ("Cancelled", "#ff9800") + } + status = self.task_info.get("status", "pending") + status_text, status_color = status_badges.get(status, status_badges["pending"]) + + self.status_badge = BodyLabel(status_text) + self.status_badge.setStyleSheet(f""" + BodyLabel {{ + background-color: {status_color}20; + color: {status_color}; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: bold; + }} + """) + icon_layout.addWidget(self.status_badge, 0, Qt.AlignmentFlag.AlignCenter) + + main_layout.addLayout(icon_layout) + + content_layout = QVBoxLayout() + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(6) + + input_path = self.task_info.get("input_path", "Unknown") + filename = os.path.basename(input_path) if input_path else "Unknown" + self.title_label = BodyLabel(filename) + self.title_label.setTextColor(QColor("#333333"), QColor("#e0e0e0")) + self.title_label.setFixedWidth(160) + content_layout.addWidget(self.title_label) + + self.status_label = CaptionLabel(self.get_status_text()) + self.status_label.setTextColor(QColor("#666666"), QColor("#a0a0a0")) + self.status_label.setFixedWidth(160) + content_layout.addWidget(self.status_label) + + self.progress_bar = ProgressBar() + self.progress_bar.setFixedHeight(4) + self.progress_bar.setFixedWidth(160) + self.progress_bar.setValue(self.task_info.get("progress", 0)) + content_layout.addWidget(self.progress_bar) + + main_layout.addLayout(content_layout) + + button_layout = QVBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(8) + button_layout.addStretch(1) + + self.cancel_button = TransparentToolButton(FIF.CANCEL, self) + self.cancel_button.setFixedSize(28, 28) + self.cancel_button.setToolTip("Cancel Task") + button_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignRight) + + self.retry_button = TransparentToolButton(FIF.RETURN, self) + self.retry_button.setFixedSize(28, 28) + self.retry_button.setToolTip("Retry Task") + self.retry_button.setVisible(False) + button_layout.addWidget(self.retry_button, 0, Qt.AlignmentFlag.AlignRight) + + self.remove_button = TransparentToolButton(FIF.DELETE, self) + self.remove_button.setFixedSize(28, 28) + self.remove_button.setToolTip("Remove Task") + button_layout.addWidget(self.remove_button, 0, Qt.AlignmentFlag.AlignRight) + + button_layout.addStretch(1) + + main_layout.addLayout(button_layout) + + def get_status_text(self): + """Get status display text""" + status = self.task_info.get("status", "pending") + if status == "pending": + return "Waiting in queue..." + elif status == "running": + progress = self.task_info.get("progress", 0) + return f"Processing... {progress}%" + elif status == "completed": + return "Task completed successfully" + elif status == "failed": + error = self.task_info.get("error", "Unknown error") + return f"Failed: {error}" + elif status == "cancelled": + return "Task was cancelled" + return "Unknown status" + + def setup_connections(self): + """Setup button connections""" + self.cancel_button.clicked.connect(lambda: self.task_cancelled.emit(self.task_id)) + self.retry_button.clicked.connect(lambda: self.task_retried.emit(self.task_id)) + self.remove_button.clicked.connect(lambda: self.task_removed.emit(self.task_id)) + + def update_task(self, task_info): + """Update task information and UI""" + self.task_info = task_info + + status_badges = { + "pending": ("Pending", "#9e9e9e"), + "running": ("Running", "#2196f3"), + "completed": ("Completed", "#4caf50"), + "failed": ("Failed", "#f44336"), + "cancelled": ("Cancelled", "#ff9800") + } + status = task_info.get("status", "pending") + status_text, status_color = status_badges.get(status, status_badges["pending"]) + + self.status_badge.setText(status_text) + self.status_badge.setStyleSheet(f""" + BodyLabel {{ + background-color: {status_color}20; + color: {status_color}; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: bold; + }} + """) + + self.status_label.setText(self.get_status_text()) + progress = task_info.get("progress", 0) + self.progress_bar.setValue(progress) + + if status == "running": + self.cancel_button.setVisible(True) + self.retry_button.setVisible(False) + elif status in ["failed", "cancelled"]: + self.cancel_button.setVisible(False) + self.retry_button.setVisible(True) + else: + self.cancel_button.setVisible(False) + self.retry_button.setVisible(False) + + +class HomeInterface(QFrame): + """Home interface showing app cards""" + + def __init__(self, icon_paths, parent=None): + super().__init__(parent) + self.icon_paths = icon_paths + self.setObjectName("home_interface") + self.init_ui() + + def init_ui(self): + """Initialize UI components""" + # Layouts + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(40, 35, 40, 35) + main_layout.setSpacing(25) + + # Title + title_label = LargeTitleLabel("Converter") + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + main_layout.addWidget(title_label) + + # Image Converter card + image_card = AppCard( + icon_path=self.icon_paths['app_icon_path'], + title="Image Converter", + content="Convert PNG images to ICNS format for macOS applications", + app_type="image" + ) + image_card.setBorderRadius(35) + main_layout.addWidget(image_card) + + # Archive Converter card + archive_card = AppCard( + icon_path=self.icon_paths['zip_icon_path'], + title="Archive Converter", + content="Create and extract ZIP, RAR, and 7Z archive files", + app_type="arc" + ) + main_layout.addWidget(archive_card) + archive_card.setBorderRadius(35) + + # Add stretch to push content to top + main_layout.addStretch(1) + + +class TaskInterface(ScrollArea): + """Task management interface""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("task_interface") + self.setWidgetResizable(True) + self.task_cards = {} + self.init_ui() + + def init_ui(self): + """Initialize UI components""" + from qfluentwidgets import setCustomStyleSheet + + container_widget = QWidget() + container_widget.setObjectName("task_container") + self.setWidget(container_widget) + + main_layout = QVBoxLayout(container_widget) + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(20) + + title_label = SubtitleLabel("Task Manager") + main_layout.addWidget(title_label) + + self.task_container = QWidget() + self.task_flow_layout = FlowLayout(self.task_container) + self.task_flow_layout.setContentsMargins(10, 10, 10, 10) + self.task_flow_layout.setSpacing(15) + + main_layout.addWidget(self.task_container, 1) + + controls_layout = QHBoxLayout() + + self.clear_tasks_button = PrimaryPushButton("Clear Completed") + self.clear_tasks_button.setObjectName("clear_tasks_button") + self.clear_tasks_button.setFixedHeight(40) + controls_layout.addWidget(self.clear_tasks_button) + + controls_layout.addStretch(1) + + main_layout.addLayout(controls_layout) + + task_count_label = CaptionLabel("Tasks will appear here when you start conversions") + task_count_label.setTextColor(QColor("#888888"), QColor("#666666")) + main_layout.addWidget(task_count_label, 0, Qt.AlignmentFlag.AlignCenter) + + def add_task_card(self, task_id, task_type, task_info): + """Add a new task card to the interface""" + if task_id in self.task_cards: + return + + task_card = AppCardTask(task_id, task_type, task_info, self.task_container) + self.task_cards[task_id] = task_card + self.task_flow_layout.addWidget(task_card) + + task_card.task_cancelled.connect(self._on_task_cancelled) + task_card.task_retried.connect(self._on_task_retried) + task_card.task_removed.connect(self._on_task_removed) + + def update_task_card(self, task_id, task_info): + """Update an existing task card""" + if task_id in self.task_cards: + self.task_cards[task_id].update_task(task_info) + + def remove_task_card(self, task_id): + """Remove a task card from the interface""" + if task_id in self.task_cards: + task_card = self.task_cards.pop(task_id) + self.task_flow_layout.removeWidget(task_card) + task_card.deleteLater() + + def clear_all_cards(self): + """Clear all task cards""" + for task_id in list(self.task_cards.keys()): + self.remove_task_card(task_id) + + def _on_task_cancelled(self, task_id): + """Handle task cancellation""" + if hasattr(self.parent(), 'task_manager'): + self.parent().task_manager.cancel_task(task_id) + + def _on_task_retried(self, task_id): + """Handle task retry""" + if hasattr(self.parent(), 'task_manager'): + self.parent().task_manager.retry_task(task_id) + + def _on_task_removed(self, task_id): + """Handle task removal""" + self.remove_task_card(task_id) + + +class SettingsInterface(QFrame): + """Settings interface""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("settings_interface") + self.init_ui() + self.load_settings() + self._connect_settings_signals() + + def init_ui(self): + """Initialize UI components""" + # Layouts + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(15) + + # Create SegmentedWidget and QStackedWidget + self.segmented_widget = SegmentedWidget(self) + setCustomStyleSheet(self.segmented_widget, CON.qss_seg, CON.qss_seg) + self.stacked_widget = QStackedWidget(self) + + # General page + general_page = QWidget() + general_layout = QVBoxLayout(general_page) + general_layout.setContentsMargins(15, 15, 15, 15) + general_layout.setSpacing(15) + + from settings.general_settings import GeneralSettingsWidget + self.general_widget = GeneralSettingsWidget() + self.general_widget.setObjectName("general_widget") + general_layout.addWidget(self.general_widget) + general_layout.addStretch() + + self.stacked_widget.addWidget(general_page) + + # Debug page + debug_page = QWidget() + debug_layout = QVBoxLayout(debug_page) + debug_layout.setContentsMargins(15, 15, 15, 15) + debug_layout.setSpacing(15) + + from debug.debug_gui import DebugSettingsWidget + self.debug_widget = DebugSettingsWidget() + self.debug_widget.setObjectName("debug_widget") + debug_layout.addWidget(self.debug_widget) + debug_layout.addStretch() + + self.stacked_widget.addWidget(debug_page) + + # Update page + update_page = QWidget() + update_layout = QVBoxLayout(update_page) + update_layout.setContentsMargins(15, 15, 15, 15) + update_layout.setSpacing(15) + + from settings.update_settings_gui import UpdateSettingsWidget + self.update_group = UpdateSettingsWidget() + self.update_group.setObjectName("update_group") + update_layout.addWidget(self.update_group) + update_layout.addStretch() + + self.stacked_widget.addWidget(update_page) + + # Add tab items + self.add_sub_interface(general_page, "general_page", "General") + self.add_sub_interface(debug_page, "debug_page", "Debug") + self.add_sub_interface(update_page, "update_page", "Update") + + # Connect tab change signal + self.stacked_widget.currentChanged.connect(self.on_current_index_changed) + self.stacked_widget.setCurrentIndex(0) + self.segmented_widget.setCurrentItem("general_page") + + # Add to main layout + main_layout.addWidget(self.segmented_widget, 0, Qt.AlignmentFlag.AlignHCenter) + main_layout.addWidget(self.stacked_widget, 1) + + def add_sub_interface(self, widget: QWidget, object_name: str, text: str): + """Add sub-page to SegmentedWidget and QStackedWidget""" + widget.setObjectName(object_name) + self.segmented_widget.addItem( + routeKey=object_name, + text=text, + onClick=lambda: self.stacked_widget.setCurrentWidget(widget) + ) + + def on_current_index_changed(self, index): + """Handle current page change""" + widget = self.stacked_widget.widget(index) + if widget: + self.segmented_widget.setCurrentItem(widget.objectName()) + + def load_settings(self): + """Load settings from QSettings""" + settings = QSettings("MyCompany", "ConverterApp") + # Theme settings - always set to System Default (index 0) + settings.setValue("theme", 0) # Force save System Default + + # Load General settings (includes Image Converter settings) + if hasattr(self, 'general_widget'): + self.general_widget.load_settings() + + # Debug settings are now handled by the DebugSettingsWidget itself + + def _connect_settings_signals(self): + """Connect all settings controls' signals to real-time saving""" + # Connect general widget settings + if hasattr(self, 'general_widget'): + self.general_widget.settings_changed.connect(self.on_settings_changed) + + # Connect debug widget auto-save signals (already handled in DebugSettingsWidget) + # Debug settings are now handled by the DebugSettingsWidget itself + + # Connect update dialog settings + # Update settings related signal connections have been removed, handled internally by UpdateDialog + + def on_settings_changed(self): + """Handle any settings change and trigger auto-save""" + self.save_settings_async() + + def save_settings_async(self): + """Asynchronously save settings in a separate thread""" + def save_thread(): + settings = QSettings("MyCompany", "ConverterApp") + # Theme settings - always System Default + settings.setValue("theme", 0) + + # Save General settings + if hasattr(self, 'general_widget'): + self.general_widget.save_settings() + + # Debug settings are now handled by the DebugSettingsWidget itself + + # Image converter settings are now saved by the general widget + # No separate image converter widget exists anymore + + settings.sync() # Ensure settings are written to disk + + # Start separate thread to execute save operation + threading.Thread(target=save_thread).start() + + +class MainWindow(FluentWindow): + """Main application window""" def _load_qss_file(self, filename): """Load QSS content from external file""" @@ -84,13 +607,11 @@ def DARK_QSS(self): def __init__(self, q_app: QApplication): super().__init__() self._q_app = q_app # Store QApplication instance - self.setWindowTitle("Converter") - # Load theme setting immediately self.settings = QSettings("MyCompany", "ConverterApp") self.theme_setting = self.settings.value("theme", 0, type=int) self.themeListener = SystemThemeListener(self) - self.path= os.path.dirname(os.path.abspath(__file__)) + self.path = os.path.dirname(os.path.abspath(__file__)) # Define paths for icon files self.app_icon_path = os.path.join(self.path,"AppIcon.png") self.appd_icon_path = os.path.join(self.path,"AppIcond.png") @@ -113,15 +634,94 @@ def __init__(self, q_app: QApplication): print("Note: zipd.png file not found. Will try to create a PNG placeholder icon.") create_placeholder_icon(self.zipd_icon_path, "dimgray", "ZipD") - self.init_ui() + # Icon paths dictionary for home interface + self.icon_paths = { + 'app_icon_path': self.app_icon_path, + 'appd_icon_path': self.appd_icon_path, + 'zip_icon_path': self.zip_icon_path, + 'zipd_icon_path': self.zipd_icon_path + } + + # Initialize interfaces + self.init_interfaces() + + # Initialize window + self.init_window() + self.init_navigation() + + # Initialize task manager + self.init_task_manager() + + # Apply theme setTheme(Theme.AUTO) self.themeListener.start() qconfig.themeChanged.connect(self._onThemeChanged) - # Apply theme based on settings or initial system detection - self._apply_system_theme_from_settings() + self._apply_system_theme_from_settings() + + def init_interfaces(self): + """Initialize sub-interfaces""" + # Create home interface with app cards + self.home_interface = HomeInterface(self.icon_paths) + + # Create task interface for task management + self.task_interface = TaskInterface() + + # Create settings interface + self.settings_interface = SettingsInterface() + + def init_window(self): + """Initialize window properties""" + self.setWindowTitle("Converter") + self.setWindowIcon(QIcon(self.app_icon_path)) + self.resize(900, 700) + + def init_navigation(self): + """Initialize navigation items""" + self.addSubInterface( + self.home_interface, + FIF.HOME, + 'Home' + ) + + self.addSubInterface( + self.task_interface, + FIF.HISTORY, + 'Task Manager' + ) + + self.addSubInterface( + self.settings_interface, + FIF.SETTING, + 'Settings', + NavigationItemPosition.BOTTOM + ) def closeEvent(self, event): """窗口关闭事件""" - # 停止监听器线程 + # Check if task mode is enabled and if any sub-windows are open + task_mode_enabled = self.settings.value("task_mode", False, type=bool) + if task_mode_enabled: + has_open_windows = False + # Get all top level widgets + for widget in self._q_app.topLevelWidgets(): + # Check if there are any image or arc windows open + if widget is not self: + window_title = widget.windowTitle() + if "Image Converter" in window_title or "Archive File Processing Tool" in window_title: + has_open_windows = True + break + + if has_open_windows: + # Show modal dialog explaining why closing is not allowed + QMessageBox.information( + self, + "Cannot Close", + "Task Mode is enabled and sub-windows are open. Please close all sub-windows first.", + QMessageBox.StandardButton.Ok + ) + event.ignore() + return + + # Stop listener thread if hasattr(self, 'themeListener'): self.themeListener.terminate() self.themeListener.deleteLater() @@ -168,8 +768,59 @@ def update_sub_widgets_theme(self, is_dark_mode): self._settings_dialog.apply_theme(is_dark_mode) def init_ui(self): - # Create main layout - main_layout = QVBoxLayout() + # Create main horizontal layout for sidebar and content + main_horizontal_layout = QHBoxLayout() + + # --- Task Sidebar --- + # Create sidebar widget + from qfluentwidgets import PushButton, setCustomStyleSheet + + # Keep using QWidget as the container + self.sidebar_widget = QWidget() + self.sidebar_widget.setObjectName("sidebar_widget") + self.sidebar_layout = QVBoxLayout(self.sidebar_widget) + + # Sidebar title + sidebar_title = QLabel("Task Manager") + sidebar_title.setObjectName("sidebar_title") + sidebar_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.sidebar_layout.addWidget(sidebar_title) + + # Task list + self.task_list = QListWidget() + self.task_list.setObjectName("task_list") + from con import CON + setCustomStyleSheet(self.task_list, CON.qss_combo, CON.qss_combo) + self.sidebar_layout.addWidget(self.task_list) + + # Sidebar control buttons + sidebar_controls = QHBoxLayout() + + # Clear completed tasks button + self.clear_tasks_button = PushButton("Clear Completed") + self.clear_tasks_button.setObjectName("clear_tasks_button") + self.clear_tasks_button.setIconSize(QSize(16, 16)) + setCustomStyleSheet(self.clear_tasks_button, CON.qss, CON.qss) + sidebar_controls.addWidget(self.clear_tasks_button) + + # Collapse/Expand button + self.toggle_sidebar_button = PushButton("Collapse") + self.toggle_sidebar_button.setObjectName("toggle_sidebar_button") + self.toggle_sidebar_button.setIconSize(QSize(16, 16)) + self.toggle_sidebar_button.clicked.connect(self.toggle_sidebar) + setCustomStyleSheet(self.toggle_sidebar_button, CON.qss, CON.qss) + sidebar_controls.addWidget(self.toggle_sidebar_button) + + self.sidebar_layout.addLayout(sidebar_controls) + + # Add sidebar to main horizontal layout + self.sidebar_widget.setFixedWidth(250) + main_horizontal_layout.addWidget(self.sidebar_widget) + + # --- Main Content --- + # Create main content widget with vertical layout + content_widget = QWidget() + main_layout = QVBoxLayout(content_widget) main_layout.setSpacing(25) # Increased spacing for better visual separation main_layout.setContentsMargins(40, 35, 40, 35) # Better margins @@ -258,14 +909,183 @@ def init_ui(self): settings_button_layout.addWidget(settings_button) settings_button_layout.addStretch() main_layout.addLayout(settings_button_layout) - - # Set the main layout for the window - self.setLayout(main_layout) + + # Add content widget to main horizontal layout + main_horizontal_layout.addWidget(content_widget, 1) # Give content stretch priority + + # Add task count indicator (visible when sidebar is collapsed) + self.task_count_indicator = QLabel("0") + self.task_count_indicator.setObjectName("task_count_indicator") + self.task_count_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.task_count_indicator.setStyleSheet(""" + background-color: #0078d4; + color: white; + border-radius: 12px; + padding: 5px 10px; + font-weight: bold; + """) + self.task_count_indicator.setFixedSize(30, 30) + self.task_count_indicator.hide() # Initially hidden + + # Set the main horizontal layout for the window + self.setLayout(main_horizontal_layout) + + # Initialize task manager + self.init_task_manager() def show_settings(self): settings_dialog = SettingsDialog(self) self._settings_dialog = settings_dialog # Save dialog reference settings_dialog.show() # Use show() instead of exec() to keep dialog non-modal + + def toggle_sidebar(self): + """Toggle sidebar visibility""" + if self.sidebar_widget.isVisible(): + # Hide sidebar and show task count indicator + self.sidebar_widget.hide() + self.task_count_indicator.show() + self.toggle_sidebar_button.setText("Expand") + else: + # Show sidebar and hide task count indicator + self.sidebar_widget.show() + self.task_count_indicator.hide() + self.toggle_sidebar_button.setText("Collapse") + + def init_task_manager(self): + """Initialize task manager and connect signals""" + # Import task manager + from support.task_manager import TaskManager + + # Create task manager instance + self.task_manager = TaskManager() + + # Connect task manager signals to UI updates + self.task_manager.task_added.connect(self._on_task_added) + self.task_manager.task_updated.connect(self._on_task_updated) + self.task_manager.task_completed.connect(self._on_task_completed) + self.task_manager.task_failed.connect(self._on_task_failed) + self.task_manager.progress_updated.connect(self._on_task_progress_updated) + + # Connect task interface control buttons + self.task_interface.clear_tasks_button.clicked.connect(self._clear_completed_tasks) + + # Start task file monitor + self._start_task_monitor() + + def _start_task_monitor(self): + """Start task file monitor to check for new or updated tasks""" + # Create a timer to check task files every 1 second + self.task_monitor_timer = QTimer(self) + self.task_monitor_timer.timeout.connect(self._check_task_files) + self.task_monitor_timer.start(1000) # Check every 1 second + + # Create task directory if it doesn't exist + self.task_dir = os.path.expanduser("~/.converter/tasks") + os.makedirs(self.task_dir, exist_ok=True) + + def _check_task_files(self): + """Check for new or updated task files""" + import json + + # Get all task files in the directory + try: + task_files = [f for f in os.listdir(self.task_dir) if f.startswith("task_") and f.endswith(".json")] + + for task_file in task_files: + task_file_path = os.path.join(self.task_dir, task_file) + task_id = task_file[5:-5] # Extract task_id from filename + + # Read task info from file + with open(task_file_path, "r") as f: + task_info = json.load(f) + + # Check if task already exists in task manager + if task_id in self.task_manager.tasks: + # Update existing task + self._update_existing_task(task_id, task_info) + else: + # Add new task + self._add_new_task(task_id, task_info) + + except Exception as e: + print(f"Error checking task files: {e}") + + def _add_new_task(self, task_id, task_info): + """Add a new task to task manager""" + # Create task using task manager's add_task method + self.task_manager.add_task( + task_info["task_type"], + task_info["input_path"], + task_info["output_path"] + ) + + def _update_existing_task(self, task_id, task_info): + """Update an existing task in task manager""" + # Update task progress + self.task_manager.tasks[task_id].progress = task_info["progress"] + self.task_manager.tasks[task_id].status = task_info["status"] + + # Emit progress updated signal + self.task_manager.progress_updated.emit(task_id, task_info["progress"]) + + # If task is completed or failed, emit appropriate signal + if task_info["status"] == "completed": + self.task_manager.task_completed.emit(task_id, task_info) + elif task_info["status"] == "failed": + self.task_manager.task_failed.emit(task_id, task_info, task_info.get("error", "Unknown error")) + + def _on_task_added(self, task_id, task_info): + """Handle new task added""" + self.task_interface.add_task_card(task_id, task_info['task_type'], task_info) + self._update_task_count() + + def _on_task_updated(self, task_id, task_info): + """Handle task updated""" + self.task_interface.update_task_card(task_id, task_info) + + def _on_task_completed(self, task_id, task_info): + """Handle task completed""" + self._on_task_updated(task_id, task_info) + + # Send system notification + from support.notification import send_notification + send_notification( + "Task Completed", + f"{task_info['task_type'].capitalize()} conversion completed: {os.path.basename(task_info['input_path'])}" + ) + + def _on_task_failed(self, task_id, task_info, error): + """Handle task failed""" + # Update task in list + self._on_task_updated(task_id, task_info) + + # Send system notification + from support.notification import send_notification + send_notification( + "Task Failed", + f"{task_info['task_type'].capitalize()} conversion failed: {os.path.basename(task_info['input_path'])}" + ) + + def _on_task_progress_updated(self, task_id, progress): + """Handle task progress updated""" + for task_id_key in self.task_interface.task_cards: + if task_id_key == task_id: + task_info = self.task_manager.tasks.get(task_id, {}) + if task_info: + task_info['progress'] = progress + self.task_interface.update_task_card(task_id, task_info) + break + + def _clear_completed_tasks(self): + """Clear completed tasks from list""" + self.task_manager.clear_completed_tasks() + self.task_interface.clear_all_cards() + self._update_task_count() + + def _update_task_count(self): + """Update task count indicator (no longer needed in FluentWindow)""" + # Task count indicator is no longer needed in FluentWindow + pass class AnimatedAppDialog(QDialog): def __init__(self, parent=None, app_type=""): @@ -420,16 +1240,25 @@ def run_image_app(): try: # Get the main window instance app = QApplication.instance() + if app is None: + return main_window = None for widget in app.topLevelWidgets(): - if isinstance(widget, IconButtonsWindow): + if isinstance(widget, MainWindow): main_window = widget break if main_window: - # Create and show the animation dialog - dialog = ImageAppDialog(main_window) - dialog.show() + # Check if task mode is enabled + task_mode_enabled = main_window.settings.value("task_mode", False, type=bool) + + if task_mode_enabled: + # In task mode, run directly in the same process + run_image() + else: + # Create and show the animation dialog + dialog = ImageAppDialog(main_window) + dialog.show() else: # Fallback to multiprocessing if no main window found multiprocessing.Process(target=run_image).start() @@ -444,16 +1273,25 @@ def run_zip_app(): try: # Get the main window instance app = QApplication.instance() + if app is None: + return main_window = None for widget in app.topLevelWidgets(): - if isinstance(widget, IconButtonsWindow): + if isinstance(widget, MainWindow): main_window = widget break if main_window: - # Create and show the animation dialog - dialog = ZipAppDialog(main_window) - dialog.show() + # Check if task mode is enabled + task_mode_enabled = main_window.settings.value("task_mode", False, type=bool) + + if task_mode_enabled: + # In task mode, run directly in the same process + run_zip() + else: + # Create and show the animation dialog + dialog = ZipAppDialog(main_window) + dialog.show() else: # Fallback to multiprocessing if no main window found multiprocessing.Process(target=run_zip).start() @@ -481,7 +1319,7 @@ def run_zip_app(): from support.toggle import theme_manager theme_manager.start() setTheme(Theme.AUTO) - window = IconButtonsWindow(q_app=app) + window = MainWindow(q_app=app) window.show() # Connect to palette changes for real-time theme switching ONLY if setting is System Default app.paletteChanged.connect(lambda: window._apply_system_theme(app.palette().color(QPalette.ColorRole.Window).lightnessF() < 0.5)) diff --git a/Converter.spec b/Converter.spec index 0c8a875..c6b098a 100644 --- a/Converter.spec +++ b/Converter.spec @@ -261,12 +261,12 @@ app = BUNDLE( coll, name='Converter.app', icon=os.path.join(current_dir, 'AppIcon.icns'), - bundle_identifier='com.pyquick.converter', + bundle_identifier='com.intsant.converter', info_plist={ 'CFBundleDisplayName': 'Converter', 'CFBundleExecutable': 'Converter', 'CFBundleIconFile': 'AppIcon.icns', - 'CFBundleIdentifier': 'com.pyquick.converter', + 'CFBundleIdentifier': 'com.intsant.converter', 'CFBundleInfoDictionaryVersion': '6.0', 'CFBundleName': 'Converter', 'CFBundlePackageType': 'APPL', @@ -275,5 +275,5 @@ app = BUNDLE( 'NSHighResolutionCapable': True, 'LSMinimumSystemVersion': '11.7', }, - version='2.0.0', + version='2.1.0A6', ) \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..53d1f3d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,675 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/arc_gui.py b/arc_gui.py index 6e8f913..fdf01fa 100644 --- a/arc_gui.py +++ b/arc_gui.py @@ -5,8 +5,8 @@ import shutil from pathlib import Path -from PySide6.QtCore import QThread, Signal, Qt, QTimer, QUrl, QObject -from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, +from PySide6.QtCore import QThread, Signal, Qt, QTimer, QUrl, QObject, QSize, QSettings +from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton, QLabel, QLineEdit, QTextEdit, QProgressBar, QTabWidget, QWidget, QGroupBox, QListWidget, QListWidgetItem, QFileDialog, QCheckBox, QComboBox, QFrame, QMessageBox, QMenu) @@ -15,10 +15,12 @@ from con import CON from support.toggle import ThemeManager +from support.GUI.arc_support import BatchDropZoneWidget # Add the current directory to Python path to import convertzip module sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from support.archive_manager import create_archive, extract_archive, add_to_archive, list_archive_contents, SUPPORTED_ARCHIVE_FORMATS -from support.password_detector import PasswordDetector, detect_password_protection +from support.archive_manager import create_archive, extract_archive, add_to_archive, list_archive_contents, SUPPORTED_ARCHIVE_FORMATS, batch_extract_archives +from support.password_detector import PasswordDetector +from password_dialog import PasswordDialog, SimplePasswordDialog # Remove the problematic reconfigure calls # sys.stdout.reconfigure(encoding='utf-8') @@ -28,6 +30,7 @@ class CreateZipWorker(QObject): finished = Signal() progress_updated = Signal(str, int) conversion_error = Signal(str) + canceled = Signal() # Add canceled signal def __init__(self, output_path, sources, archive_format, password=None): super().__init__() @@ -35,6 +38,12 @@ def __init__(self, output_path, sources, archive_format, password=None): self.sources = sources self.archive_format = archive_format self.password = password + self.is_stopped = False # Add stop flag + + def stop(self): + """Stop the archive creation process""" + self.is_stopped = True + self.progress_updated.emit("Canceling archive creation...", 0) def run(self): try: @@ -51,8 +60,22 @@ def run(self): if not os.path.exists(source): raise ValueError(f"Source file does not exist: {source}") - create_archive(self.output_path, self.sources, self.archive_format, self._update_progress_callback, self.password) - self.finished.emit() + # Update progress callback to check for cancellation + def progress_callback(message, percentage): + if self.is_stopped: + raise RuntimeError("Archive creation canceled by user") + self.progress_updated.emit(message, percentage) + + create_archive(self.output_path, self.sources, self.archive_format, progress_callback, self.password) + if not self.is_stopped: + self.finished.emit() + else: + self.canceled.emit() + except RuntimeError as e: + if "canceled" in str(e).lower(): + self.canceled.emit() + else: + self.conversion_error.emit(str(e)) except ValueError as e: # Handle value errors self.conversion_error.emit(f"Input error: {str(e)}") @@ -70,9 +93,12 @@ def run(self): self.conversion_error.emit(str(e)) except Exception as e: # Handle all other exceptions - import traceback - error_msg = f"Unexpected error: {str(e)}\n{traceback.format_exc()}" - self.conversion_error.emit(error_msg) + if self.is_stopped: + self.canceled.emit() + else: + import traceback + error_msg = f"Unexpected error: {str(e)}\n{traceback.format_exc()}" + self.conversion_error.emit(error_msg) def _update_progress_callback(self, message, percentage): self.progress_updated.emit(message, percentage) @@ -82,25 +108,47 @@ class ExtractZipWorker(QObject): progress_updated = Signal(str, int) conversion_error = Signal(str) password_required = Signal(str) # Emits error message when password is required + canceled = Signal() # Add canceled signal def __init__(self, zip_path, dest_path, password=None): super().__init__() self.archive_path = zip_path # Renamed for clarity with generic archive_manager self.extract_to = dest_path self.password = password + self.is_stopped = False # Add stop flag + + def stop(self): + """Stop the archive extraction process""" + self.is_stopped = True + self.progress_updated.emit("Canceling archive extraction...", 0) def run(self): try: - extract_archive(self.archive_path, self.extract_to, self._update_progress_callback, self.password) - self.finished.emit() + # Update progress callback to check for cancellation + def progress_callback(message, percentage): + if self.is_stopped: + raise RuntimeError("Archive extraction canceled by user") + self.progress_updated.emit(message, percentage) + + extract_archive(self.archive_path, self.extract_to, progress_callback, self.password) + if not self.is_stopped: + self.finished.emit() + else: + self.canceled.emit() except RuntimeError as e: - # Handle password required case - if "password" in str(e).lower() or "encrypted" in str(e).lower(): - self.password_required.emit(str(e)) + if "canceled" in str(e).lower(): + self.canceled.emit() else: - self.conversion_error.emit(str(e)) + # Handle password required case + if "password" in str(e).lower() or "encrypted" in str(e).lower(): + self.password_required.emit(str(e)) + else: + self.conversion_error.emit(str(e)) except Exception as e: - self.conversion_error.emit(str(e)) + if self.is_stopped: + self.canceled.emit() + else: + self.conversion_error.emit(str(e)) def _update_progress_callback(self, message, percentage): self.progress_updated.emit(message, percentage) @@ -109,26 +157,48 @@ class AddToZipWorker(QObject): finished = Signal() progress_updated = Signal(str, int) conversion_error = Signal(str) + canceled = Signal() # Add canceled signal def __init__(self, zip_path, file_paths): super().__init__() self.archive_path = zip_path # Renamed for clarity with generic archive_manager self.files_to_add = file_paths if isinstance(file_paths, list) else [file_paths] + self.is_stopped = False # Add stop flag + + def stop(self): + """Stop the add to archive process""" + self.is_stopped = True + self.progress_updated.emit("Canceling add to archive...", 0) def run(self): try: # Handle multiple files total_files = len(self.files_to_add) for i, file_path in enumerate(self.files_to_add): + # Check if canceled before processing each file + if self.is_stopped: + raise RuntimeError("Add to archive canceled by user") + self._update_progress_callback(f"Adding file {i+1}/{total_files}: {os.path.basename(file_path)}", (i/total_files)*100) add_to_archive(self.archive_path, file_path, None) # No individual progress for each file - self._update_progress_callback(f"Added {total_files} files to archive", 100) - self.finished.emit() + if not self.is_stopped: + self._update_progress_callback(f"Added {total_files} files to archive", 100) + self.finished.emit() + else: + self.canceled.emit() + except RuntimeError as e: + if "canceled" in str(e).lower(): + self.canceled.emit() + else: + self.conversion_error.emit(str(e)) except NotImplementedError as e: self.conversion_error.emit(str(e)) except Exception as e: - self.conversion_error.emit(str(e)) + if self.is_stopped: + self.canceled.emit() + else: + self.conversion_error.emit(str(e)) def _update_progress_callback(self, message, percentage): self.progress_updated.emit(message, percentage) @@ -137,32 +207,227 @@ class ListZipContentsWorker(QObject): finished = Signal(list) # Emits list of contents conversion_error = Signal(str) password_required = Signal(str) # Emits error message when password is required + canceled = Signal() # Add canceled signal def __init__(self, zip_path, password=None): super().__init__() self.archive_path = zip_path # Renamed for clarity with generic archive_manager self.password = password self.result = None # Add result attribute to store results + self.is_stopped = False # Add stop flag + + def stop(self): + """Stop the list contents process""" + self.is_stopped = True + print(f"[DEBUG] ListZipContentsWorker: Stop requested") def run(self): try: + # Check if canceled before starting + if self.is_stopped: + self.canceled.emit() + return + print(f"[DEBUG] ListZipContentsWorker: Starting to list contents of {self.archive_path}") + # This operation is typically fast, but we'll still add a cancel check contents = list_archive_contents(self.archive_path, password=self.password) print(f"[DEBUG] ListZipContentsWorker: Got {len(contents) if contents else 0} items") - self.result = contents # 设置result属性 - self.finished.emit(contents) + + if not self.is_stopped: + self.result = contents # 设置result属性 + self.finished.emit(contents) + else: + self.canceled.emit() except RuntimeError as e: - # Handle password required case - print(f"[DEBUG] ListZipContentsWorker: RuntimeError - {str(e)}") - if "password" in str(e).lower() or "encrypted" in str(e).lower(): - self.password_required.emit(str(e)) + if not self.is_stopped: + # Handle password required case + print(f"[DEBUG] ListZipContentsWorker: RuntimeError - {str(e)}") + if "password" in str(e).lower() or "encrypted" in str(e).lower(): + self.password_required.emit(str(e)) + else: + self.conversion_error.emit(str(e)) else: + self.canceled.emit() + except Exception as e: + if not self.is_stopped: + print(f"[DEBUG] ListZipContentsWorker: Exception - {str(e)}") + import traceback + traceback.print_exc() self.conversion_error.emit(str(e)) + else: + self.canceled.emit() + +class BatchExtractWorker(QObject): + """Worker for batch archive extraction""" + finished = Signal(int, int, list, list) # Emits success_count, failed_count, success_files, failed_files + progress_updated = Signal(int, int, str, int, int) # processed_count, total_count, current_file, success_count, failed_count + conversion_error = Signal(str) # Emits error messages + individual_progress = Signal(str, str, int) # Emits archive name, message, percentage + status_updated = Signal(str) # Emits status messages + canceled = Signal() # Add canceled signal for consistency + + def __init__(self, archive_paths, dest_folder, create_subfolders=True, overwrite_files=False, parent_gui=None): + super().__init__() + self.archive_paths = archive_paths + self.dest_folder = dest_folder + self.create_subfolders = create_subfolders + self.overwrite_files = overwrite_files + self.is_stopped = False + self.parent_gui = parent_gui # Reference to main GUI for password dialogs + + # Track detailed statistics + self.success_count = 0 + self.failed_count = 0 + self.success_files = [] + self.failed_files = [] + + def stop(self): + """Stop the batch extraction process""" + self.is_stopped = True + self.status_updated.emit("Stopping batch extraction...") + + def run(self): + """Execute batch extraction""" + try: + if not self.archive_paths: + raise ValueError("No archive files to extract") + + if not self.dest_folder: + raise ValueError("Destination folder is not specified") + + # Ensure destination folder exists + if not os.path.exists(self.dest_folder): + try: + os.makedirs(self.dest_folder, exist_ok=True) + except Exception as e: + raise ValueError(f"Failed to create destination folder: {str(e)}") + + # Initialize password detector + password_detector = PasswordDetector() + + # Reset statistics + self.success_count = 0 + self.failed_count = 0 + self.success_files = [] + self.failed_files = [] + + total_files = len(self.archive_paths) + self.status_updated.emit(f"Starting batch extraction of {total_files} archive(s)...") + + def progress_callback(current, total, current_file=""): + if self.is_stopped: + return # Stop processing if requested + + # Handle different call patterns + if isinstance(current, str): + # Called with (message, progress_percent) pattern for individual file extraction + message = current + progress_percent = total + archive_name = os.path.basename(current_file) if current_file else "" + # Emit individual file progress + self.individual_progress.emit(archive_name, message, int(progress_percent)) + else: + # Called with (current, total, current_file) pattern for batch progress + current_val = int(current) if isinstance(current, (int, float)) else 0 + total_val = int(total) if isinstance(total, (int, float)) else total_files + + # Get current file path + if isinstance(current, int) and 1 <= current <= total_files: + current_file_path = self.archive_paths[current - 1] + else: + current_file_path = str(current_file) if current_file else "" + + # Calculate overall progress percentage + overall_progress = (current_val / total_val * 100) if total_val > 0 else 0 + + # Emit batch progress update + self.progress_updated.emit(current_val, total_val, current_file_path, self.success_count, self.failed_count) + + def password_callback(archive_path, format_name, is_protected): + """Callback to request password from user via GUI""" + if self.is_stopped: + return None + if self.parent_gui and hasattr(self.parent_gui, 'request_password'): + try: + # Request password from main GUI thread + return self.parent_gui.request_password(archive_path, format_name, is_protected) + except Exception as e: + self.conversion_error.emit(f"Error requesting password: {str(e)}") + return None + return None + + def error_callback(archive_path, error_message): + """Callback for individual archive errors""" + self.failed_count += 1 # Increment failed count + self.failed_files.append((archive_path, error_message)) + self.conversion_error.emit(f"Error processing {os.path.basename(archive_path)}: {error_message}") + # Update progress after error + processed = self.success_count + self.failed_count + self.progress_updated.emit(processed, total_files, archive_path, self.success_count, self.failed_count) + + def success_callback(archive_path): + """Callback for successful archive extraction""" + self.success_count += 1 # Increment success count + self.success_files.append(archive_path) + # Update progress after success + processed = self.success_count + self.failed_count + self.progress_updated.emit(processed, total_files, archive_path, self.success_count, self.failed_count) + + # Prepare options for batch extraction + options = { + 'create_subfolders': self.create_subfolders, + 'overwrite_existing': self.overwrite_files, + 'progress_callback': progress_callback if not self.is_stopped else None, + 'password_callback': password_callback if not self.is_stopped else None, + 'password_detector': password_detector, + 'error_callback': error_callback, + 'success_callback': success_callback + } + + if self.is_stopped: + self.status_updated.emit("Batch extraction stopped by user") + self.finished.emit(self.success_count, self.failed_count, self.success_files, self.failed_files) + return + + # Call batch extraction function with password detection + result = batch_extract_archives( + self.archive_paths, + self.dest_folder, + **options + ) + + if not self.is_stopped: + # Update final statistics from result + self.success_count = result.get('success_count', self.success_count) + self.failed_count = result.get('error_count', self.failed_count) + + # Emit final status + self.status_updated.emit(f"Batch extraction completed: {self.success_count} successful, {self.failed_count} failed") + + # Emit finished signal with detailed results + self.finished.emit(self.success_count, self.failed_count, self.success_files, self.failed_files) + else: + self.status_updated.emit("Batch extraction stopped by user") + # Emit canceled signal instead of finished for consistency with other workers + self.canceled.emit() + self.finished.emit(self.success_count, self.failed_count, self.success_files, self.failed_files) + + except ValueError as e: + error_msg = f"Input error: {str(e)}" + self.conversion_error.emit(error_msg) + self.status_updated.emit(f"Batch extraction failed: {error_msg}") + self.finished.emit(0, total_files if 'total_files' in locals() else 0, [], self.archive_paths) + except RuntimeError as e: + error_msg = str(e) + self.conversion_error.emit(error_msg) + self.status_updated.emit(f"Batch extraction failed: {error_msg}") + self.finished.emit(0, total_files if 'total_files' in locals() else 0, [], self.archive_paths) except Exception as e: - print(f"[DEBUG] ListZipContentsWorker: Exception - {str(e)}") import traceback - traceback.print_exc() - self.conversion_error.emit(str(e)) + error_msg = f"Unexpected error during batch extraction: {str(e)}\n{traceback.format_exc()}" + self.conversion_error.emit(error_msg) + self.status_updated.emit(f"Batch extraction failed unexpectedly") + self.finished.emit(0, total_files if 'total_files' in locals() else 0, [], self.archive_paths) class ZipGUI(QMainWindow): @@ -220,8 +485,8 @@ def DARK_QSS(self): def __init__(self, initial_dark_mode=False): super().__init__() self.setWindowTitle("Archive File Processing Tool") - self.setGeometry(200, 200, 800, 600) - self.setMinimumSize(600, 780) + self.setGeometry(200, 200, 1200, 900) + self.setMinimumSize(1200, 900) # Enable drag and drop for the main window self.setAcceptDrops(True) @@ -235,6 +500,9 @@ def __init__(self, initial_dark_mode=False): setTheme(Theme.AUTO) self.themeListener.start() qconfig.themeChanged.connect(self._onThemeChanged) + + # Load settings + self.load_settings() def closeEvent(self, event): """Window close event""" # Stop listener thread @@ -245,6 +513,12 @@ def closeEvent(self, event): def _onThemeChanged(self, theme: Theme): """Theme change handling""" # Update interface to respond to theme changes + is_dark_mode = theme == Theme.DARK + + # Update drag and drop area theme + if hasattr(self, 'batch_drop_area'): + self.batch_drop_area.set_theme(is_dark_mode) + self.update() setTheme(Theme.AUTO) def init_variables(self): @@ -255,12 +529,25 @@ def init_variables(self): self.create_zip_worker_thread = None # Renamed to generic for clarity self.create_zip_worker = None # Renamed to generic for clarity + # Task mode setting + self.task_mode = False + # Variables for Extract ZIP tab self.extract_zip_path = "" self.extract_dest_path = "" self.extract_zip_worker_thread = None # Renamed to generic for clarity self.extract_zip_worker = None # Renamed to generic for clarity + # Variables for Batch Extract tab + self.batch_extract_files = [] + self.batch_extract_dest_path = "" + self.batch_extract_worker = None + self.batch_extract_worker_thread = None + self.batch_extract_running = False + self.batch_extract_success_count = 0 + self.batch_extract_failed_count = 0 + self.batch_extract_password = None + # Variables for Add to ZIP tab self.add_zip_path = "" self.add_file_path = "" @@ -275,6 +562,35 @@ def init_variables(self): # Password protection status for archive contents self.is_password_protected = False self._current_password = None + self._password_dialog = None + + def request_password(self, archive_path, format_name, is_protected): + """Request password from user for a protected archive""" + archive_name = os.path.basename(archive_path) + title = "Password Required" + + if is_protected: + content = f"The archive '{archive_name}' ({format_name.upper()}) is password protected.\nPlease enter the password:" + else: + content = f"Enter password for archive '{archive_name}' ({format_name.upper()}):" + + # Create and show password dialog + self._password_dialog = PasswordDialog( + parent=self, + title=title, + content=content, + error_message="" + ) + + # Show dialog and get result + if self._password_dialog.exec() == PasswordDialog.DialogCode.Accepted: + password = self._password_dialog.get_password() + self._password_dialog = None + return password + else: + # User canceled + self._password_dialog = None + return None def setup_ui(self): self.main_widget = QWidget(self) @@ -307,9 +623,34 @@ def _apply_theme(self, is_dark_mode): self.setStyleSheet(self.DARK_QSS) else: self.setStyleSheet(self.LIGHT_QSS) + + # Update drag and drop area theme + if hasattr(self, 'batch_drop_area'): + self.batch_drop_area.set_theme(is_dark_mode) def _apply_system_theme(self, is_dark_mode): self._apply_theme(is_dark_mode) + + def load_settings(self): + """Load settings from QSettings""" + settings = QSettings("MyCompany", "ConverterApp") + + # Load task mode setting + self.task_mode = settings.value("task_mode", False, type=bool) + if hasattr(self, 'task_mode_check'): + self.task_mode_check.setChecked(self.task_mode) + + def save_settings(self): + """Save settings to QSettings""" + settings = QSettings("MyCompany", "ConverterApp") + + # Save task mode setting + if hasattr(self, 'task_mode_check'): + settings.setValue("task_mode", self.task_mode_check.isChecked()) + else: + settings.setValue("task_mode", self.task_mode) + + settings.sync() def center_window(self): qr = self.frameGeometry() @@ -334,17 +675,36 @@ def create_create_tab(self): tab_sizer = QVBoxLayout(tab_panel) self.notebook.addTab(tab_panel, "Create Archive") # Changed tab title - # Output file selection + # Output file selection with ScrollArea inside GroupBox output_box = QGroupBox("Output Archive File") # Changed group box title - output_box_sizer = QHBoxLayout(output_box) + output_box.setMinimumHeight(200) # Set minimum height + output_box_sizer = QVBoxLayout(output_box) + + # Create ScrollArea inside GroupBox + output_scroll_area = ScrollArea() + output_scroll_area.setWidgetResizable(True) + output_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + output_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + content_widget = QWidget() + content_layout = QHBoxLayout(content_widget) self.create_output_text = LineEdit() setCustomStyleSheet(self.create_output_text, CON.qss_line, CON.qss_line) # self.create_output_text.setReadOnly(True) # Allow users to manually input path - output_box_sizer.addWidget(self.create_output_text, 1) + content_layout.addWidget(self.create_output_text, 1) output_button = PushButton("Browse...") output_button.clicked.connect(self.browse_create_output) - output_box_sizer.addWidget(output_button) + content_layout.addWidget(output_button) + + # Set content widget to ScrollArea + output_scroll_area.setWidget(content_widget) + + # Add ScrollArea to GroupBox layout + output_box_sizer.addWidget(output_scroll_area) + + # Add GroupBox to main layout tab_sizer.addWidget(output_box) # Archive Format Selection (new) @@ -364,13 +724,24 @@ def create_create_tab(self): format_layout.addWidget(self.create_format_combo, 1) tab_sizer.addLayout(format_layout) - # Source files list + # Source files list with ScrollArea inside GroupBox sources_box = QGroupBox("Source Files/Directories") + sources_box.setMinimumHeight(200) # Set minimum height sources_box_sizer = QVBoxLayout(sources_box) + # Create ScrollArea inside GroupBox + sources_scroll_area = ScrollArea() + sources_scroll_area.setWidgetResizable(True) + sources_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + sources_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + content_widget = QWidget() + content_layout = QVBoxLayout(content_widget) + self.sources_listbox = ListWidget() self.sources_listbox.setMinimumHeight(280) # Set minimum height - sources_box_sizer.addWidget(self.sources_listbox, 1) # Increase stretch weight + content_layout.addWidget(self.sources_listbox, 1) # Increase stretch weight # Set right-click to immediately select self.sources_listbox.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) # Context menu functionality removed @@ -391,9 +762,22 @@ def create_create_tab(self): button_sizer.addWidget(remove_button) button_sizer.addStretch(1) # Push buttons to left - sources_box_sizer.addLayout(button_sizer) + content_layout.addLayout(button_sizer) + + # Set content widget to ScrollArea + sources_scroll_area.setWidget(content_widget) + + # Add ScrollArea to GroupBox layout + sources_box_sizer.addWidget(sources_scroll_area) + + # Add GroupBox to main layout tab_sizer.addWidget(sources_box, 1) # Give sources box more stretch + # Task mode control + self.task_mode_check = CheckBox("Enable Task Mode") + self.task_mode_check.setChecked(False) + tab_sizer.addWidget(self.task_mode_check) + # Progress bar self.create_progress_label = QLabel("") tab_sizer.addWidget(self.create_progress_label) @@ -403,11 +787,28 @@ def create_create_tab(self): self.create_progress.setValue(0) tab_sizer.addWidget(self.create_progress) + # Create button layout with cancel button + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + # Create button self.create_button = PrimaryPushButton("Create Archive") # Changed button text - self.create_button.clicked.connect(self.start_create_archive) # Changed signal - tab_sizer.addWidget(self.create_button, 0, Qt.AlignmentFlag.AlignCenter) + button_layout.addWidget(self.create_button) + + # Cancel button + self.create_cancel_button = PushButton("Cancel") + self.create_cancel_button.clicked.connect(self.cancel_create_archive) + self.create_cancel_button.setEnabled(False) # Initially disabled + button_layout.addWidget(self.create_cancel_button) + + # Create a container widget to center the button layout + button_container = QWidget() + button_container_layout = QHBoxLayout(button_container) + button_container_layout.addStretch() + button_container_layout.addLayout(button_layout) + button_container_layout.addStretch() + tab_sizer.addWidget(button_container) tab_sizer.addStretch(1) # Push content to top @@ -416,33 +817,86 @@ def create_extract_tab(self): tab_sizer = QVBoxLayout(tab_panel) self.notebook.addTab(tab_panel, "Extract Archive") # Changed tab title - # Archive file selection (changed title) + # Tab selector for single/batch extract + self.extract_tab_widget = QTabWidget() + tab_sizer.addWidget(self.extract_tab_widget) + + # Single Extract Tab + self.create_single_extract_tab() + + # Batch Extract Tab + self.create_batch_extract_tab() + + tab_sizer.addStretch(1) # Push content to top + + def create_single_extract_tab(self): + """Create single archive extraction tab""" + single_panel = QWidget() + single_sizer = QVBoxLayout(single_panel) + + # Archive file selection with ScrollArea inside GroupBox (changed title) zip_box = QGroupBox("Archive File to Extract") - zip_box_sizer = QHBoxLayout(zip_box) + zip_box.setMinimumHeight(200) # Set minimum height + zip_box_sizer = QVBoxLayout(zip_box) + # Create ScrollArea inside GroupBox + zip_scroll_area = ScrollArea() + zip_scroll_area.setWidgetResizable(True) + zip_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + zip_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + content_widget = QWidget() + content_layout = QHBoxLayout(content_widget) + self.extract_zip_text = LineEdit() setCustomStyleSheet(self.extract_zip_text, CON.qss_line, CON.qss_line) # self.extract_zip_text.setReadOnly(True) # Allow users to manually input path - zip_box_sizer.addWidget(self.extract_zip_text, 1) + content_layout.addWidget(self.extract_zip_text, 1) zip_button = PushButton("Browse...") - zip_button.clicked.connect(self.browse_extract_archive) # Changed signal - zip_box_sizer.addWidget(zip_button) - tab_sizer.addWidget(zip_box) + content_layout.addWidget(zip_button) + + # Set content widget to ScrollArea + zip_scroll_area.setWidget(content_widget) + + # Add ScrollArea to GroupBox layout + zip_box_sizer.addWidget(zip_scroll_area) + + # Add GroupBox to main layout + single_sizer.addWidget(zip_box) - # Destination folder selection + # Destination folder selection with ScrollArea inside GroupBox dest_box = QGroupBox("Destination Folder") - dest_box_sizer = QHBoxLayout(dest_box) + dest_box.setMinimumHeight(200) # Set minimum height + dest_box_sizer = QVBoxLayout(dest_box) + # Create ScrollArea inside GroupBox + dest_scroll_area = ScrollArea() + dest_scroll_area.setWidgetResizable(True) + dest_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + dest_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + content_widget = QWidget() + content_layout = QHBoxLayout(content_widget) + self.extract_dest_text = LineEdit() setCustomStyleSheet(self.extract_dest_text, CON.qss_line, CON.qss_line) # self.extract_dest_text.setReadOnly(True) # Allow users to manually input path - dest_box_sizer.addWidget(self.extract_dest_text, 1) + content_layout.addWidget(self.extract_dest_text, 1) dest_button = PushButton("Browse...") - dest_button.clicked.connect(self.browse_extract_dest) - dest_box_sizer.addWidget(dest_button) - tab_sizer.addWidget(dest_box) + content_layout.addWidget(dest_button) + + # Set content widget to ScrollArea + dest_scroll_area.setWidget(content_widget) + + # Add ScrollArea to GroupBox layout + dest_box_sizer.addWidget(dest_scroll_area) + + # Add GroupBox to main layout + single_sizer.addWidget(dest_box) # Password status indicator password_status_box = QHBoxLayout() @@ -452,58 +906,358 @@ def create_extract_tab(self): password_status_box.addWidget(self.extract_password_status_label) password_status_box.addWidget(self.extract_password_status_icon) password_status_box.addStretch() - tab_sizer.addLayout(password_status_box) + single_sizer.addLayout(password_status_box) # Progress bar self.extract_progress_label = QLabel("") - tab_sizer.addWidget(self.extract_progress_label) + single_sizer.addWidget(self.extract_progress_label) self.extract_progress = ProgressBar() self.extract_progress.setRange(0, 100) self.extract_progress.setValue(0) - tab_sizer.addWidget(self.extract_progress) + single_sizer.addWidget(self.extract_progress) + # Extract button layout with cancel button + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + # Extract button self.extract_button = PrimaryPushButton("Extract Archive") # Changed button text - self.extract_button.clicked.connect(self.start_extract_archive) # Changed signal - tab_sizer.addWidget(self.extract_button, 0, Qt.AlignmentFlag.AlignCenter) + button_layout.addWidget(self.extract_button) + + # Cancel button + self.extract_cancel_button = PushButton("Cancel") + self.extract_cancel_button.clicked.connect(self.cancel_extract_archive) + self.extract_cancel_button.setEnabled(False) # Initially disabled + button_layout.addWidget(self.extract_cancel_button) + + # Create a container widget to center the button layout + button_container = QWidget() + button_container_layout = QHBoxLayout(button_container) + button_container_layout.addStretch() + button_container_layout.addLayout(button_layout) + button_container_layout.addStretch() + single_sizer.addWidget(button_container) + + self.extract_tab_widget.addTab(single_panel, "Single Extract") + + def create_batch_extract_tab(self): + """Create batch archive extraction tab""" + batch_panel = QWidget() + main_layout = QHBoxLayout(batch_panel) + + # Left side - File management and selection + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + + # File selection group with ScrollArea inside GroupBox + file_group = QGroupBox("Batch Archive Files") + file_group.setMinimumHeight(200) # Set minimum height + file_group_layout = QVBoxLayout(file_group) + + # Create ScrollArea inside GroupBox + file_scroll_area = ScrollArea() + file_scroll_area.setWidgetResizable(True) + file_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + file_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + file_content_widget = QWidget() + file_content_layout = QVBoxLayout(file_content_widget) + + # Drag and drop area + self.batch_drop_area = BatchDropZoneWidget("Drag archive files here\nor click to browse") + file_content_layout.addWidget(self.batch_drop_area) + + # File list + self.batch_files_listbox = ListWidget() + self.batch_files_listbox.setMinimumHeight(200) + self.batch_files_listbox.setMinimumWidth(300) + file_content_layout.addWidget(self.batch_files_listbox) + + # File management buttons + file_buttons_layout = QHBoxLayout() + + self.batch_add_files_btn = PushButton("Add Files") + self.batch_add_files_btn.setMinimumWidth(80) + self.batch_add_files_btn.clicked.connect(self.browse_batch_archive_files) + file_buttons_layout.addWidget(self.batch_add_files_btn) + + self.batch_remove_files_btn = PushButton("Remove Selected") + self.batch_remove_files_btn.setMinimumWidth(100) + self.batch_remove_files_btn.clicked.connect(self.remove_selected_batch_files) + file_buttons_layout.addWidget(self.batch_remove_files_btn) + + self.batch_clear_files_btn = PushButton("Clear All") + self.batch_clear_files_btn.setMinimumWidth(80) + self.batch_clear_files_btn.clicked.connect(self.clear_batch_files) + file_buttons_layout.addWidget(self.batch_clear_files_btn) + + file_buttons_layout.addStretch() + file_content_layout.addLayout(file_buttons_layout) + + # Set content widget to ScrollArea + file_scroll_area.setWidget(file_content_widget) + + # Add ScrollArea to GroupBox layout + file_group_layout.addWidget(file_scroll_area) + + left_layout.addWidget(file_group) + left_layout.addStretch(1) + + # Right side - Configuration and progress + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + + # Destination folder group with ScrollArea inside GroupBox + dest_group = QGroupBox("Destination Folder") + dest_group.setMinimumHeight(200) # Set minimum height + dest_group_layout = QVBoxLayout(dest_group) + + # Create ScrollArea inside GroupBox + dest_scroll_area = ScrollArea() + dest_scroll_area.setWidgetResizable(True) + dest_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + dest_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + dest_content_widget = QWidget() + dest_content_layout = QVBoxLayout(dest_content_widget) + + dest_layout = QHBoxLayout() + self.batch_extract_dest_text = LineEdit() + setCustomStyleSheet(self.batch_extract_dest_text, CON.qss_line, CON.qss_line) + dest_layout.addWidget(self.batch_extract_dest_text, 1) + + batch_dest_button = PushButton("Browse...") + batch_dest_button.setMinimumWidth(70) + batch_dest_button.clicked.connect(self.browse_batch_extract_dest) + dest_layout.addWidget(batch_dest_button) + + dest_content_layout.addLayout(dest_layout) + + # Set content widget to ScrollArea + dest_scroll_area.setWidget(dest_content_widget) + + # Add ScrollArea to GroupBox layout + dest_group_layout.addWidget(dest_scroll_area) + + right_layout.addWidget(dest_group) + + # Options group with ScrollArea inside GroupBox + options_group = QGroupBox("Extract Options") + options_group.setMinimumHeight(200) # Set minimum height + options_group_layout = QVBoxLayout(options_group) + + # Create ScrollArea inside GroupBox + options_scroll_area = ScrollArea() + options_scroll_area.setWidgetResizable(True) + options_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + options_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + options_content_widget = QWidget() + options_content_layout = QVBoxLayout(options_content_widget) + + options_layout = QHBoxLayout() + + # Left options column + left_options = QVBoxLayout() + self.batch_create_subfolders_check = CheckBox("Create subfolder for each archive") + self.batch_create_subfolders_check.setChecked(True) + left_options.addWidget(self.batch_create_subfolders_check) + + # Overwrite options + self.batch_overwrite_files_check = CheckBox("Overwrite existing files") + self.batch_overwrite_files_check.setChecked(False) + left_options.addWidget(self.batch_overwrite_files_check) + + # Skip existing files option + self.batch_skip_existing_files_check = CheckBox("Skip existing files") + self.batch_skip_existing_files_check.setChecked(False) + left_options.addWidget(self.batch_skip_existing_files_check) + + # Overwrite strategy options + self.overwrite_strategy_combo = ModelComboBox() + self.overwrite_strategy_combo.addItems([ + "Overwrite all", + "Skip existing", + "Rename new", + "Overwrite if newer" + ]) + self.overwrite_strategy_combo.setMinimumHeight(30) + setCustomStyleSheet(self.overwrite_strategy_combo, CON.qss_combo_2, CON.qss_combo_2) + left_options.addWidget(QLabel("Overwrite Strategy:")) + + left_options.addWidget(self.overwrite_strategy_combo) - tab_sizer.addStretch(1) # Push content to top + options_layout.addLayout(left_options) + options_layout.addStretch(1) + + options_content_layout.addLayout(options_layout) + + # Set content widget to ScrollArea + options_scroll_area.setWidget(options_content_widget) + + # Add ScrollArea to GroupBox layout + options_group_layout.addWidget(options_scroll_area) + + right_layout.addWidget(options_group) + + # Progress group with ScrollArea inside GroupBox + progress_group = QGroupBox("Progress & Statistics") + progress_group.setMinimumHeight(200) # Set minimum height + progress_group_layout = QVBoxLayout(progress_group) + + # Create ScrollArea inside GroupBox + progress_scroll_area = ScrollArea() + progress_scroll_area.setWidgetResizable(True) + progress_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + progress_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + progress_content_widget = QWidget() + progress_content_layout = QVBoxLayout(progress_content_widget) + + # Progress label + self.batch_progress_label = QLabel("Ready to extract archives") + self.batch_progress_label.setWordWrap(True) + progress_content_layout.addWidget(self.batch_progress_label) + + # Progress bar + self.batch_progress = ProgressBar() + self.batch_progress.setRange(0, 100) + self.batch_progress.setValue(0) + progress_content_layout.addWidget(self.batch_progress) + + # Statistics in a grid layout + stats_widget = QWidget() + stats_layout = QGridLayout(stats_widget) + stats_layout.setSpacing(10) + + # Statistics labels + self.batch_total_count_label = QLabel("Total Archives:") + self.batch_total_count_value = QLabel("0") + stats_layout.addWidget(self.batch_total_count_label, 0, 0) + stats_layout.addWidget(self.batch_total_count_value, 0, 1) + + self.batch_success_count_label = QLabel("Successful:") + self.batch_success_count_value = QLabel("0") + stats_layout.addWidget(self.batch_success_count_label, 1, 0) + stats_layout.addWidget(self.batch_success_count_value, 1, 1) + + self.batch_failed_count_label = QLabel("Failed:") + self.batch_failed_count_value = QLabel("0") + stats_layout.addWidget(self.batch_failed_count_label, 2, 0) + stats_layout.addWidget(self.batch_failed_count_value, 2, 1) + + progress_content_layout.addWidget(stats_widget) + + # Set content widget to ScrollArea + progress_scroll_area.setWidget(progress_content_widget) + + # Add ScrollArea to GroupBox layout + progress_group_layout.addWidget(progress_scroll_area) + + right_layout.addWidget(progress_group) + + # Control buttons + button_widget = QWidget() + button_layout = QHBoxLayout(button_widget) + button_layout.setSpacing(10) + + self.batch_start_btn = PrimaryPushButton("Start Extract") + self.batch_start_btn.setMinimumWidth(100) + self.batch_start_btn.clicked.connect(self.start_batch_extract) + button_layout.addWidget(self.batch_start_btn) + + self.batch_stop_btn = PushButton("Stop") + self.batch_stop_btn.setMinimumWidth(60) + self.batch_stop_btn.clicked.connect(self.stop_batch_extract) + self.batch_stop_btn.setEnabled(False) + button_layout.addWidget(self.batch_stop_btn) + + button_layout.addStretch(1) + right_layout.addWidget(button_widget) + + # Add panels to main layout with proportional sizing + main_layout.addWidget(left_panel, 2) # 2/4 of width for file management + main_layout.addWidget(right_panel, 2) # 2/4 of width for options and progress - equal width for both panels + + self.extract_tab_widget.addTab(batch_panel, "Batch Extract") + + # Connect drop area signals + self.batch_drop_area.files_dropped.connect(self.on_batch_files_dropped) def create_add_tab(self): tab_panel = QWidget() tab_sizer = QVBoxLayout(tab_panel) self.notebook.addTab(tab_panel, "Add to Archive") # Changed tab title - # Existing Archive file selection + # Existing Archive file selection with ScrollArea inside GroupBox zip_box = QGroupBox("Existing Archive File") # Changed group box title - zip_box_sizer = QHBoxLayout(zip_box) + zip_box.setMinimumHeight(200) # Set minimum height + zip_box_sizer = QVBoxLayout(zip_box) + + # Create ScrollArea inside GroupBox + zip_scroll_area = ScrollArea() + zip_scroll_area.setWidgetResizable(True) + zip_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + zip_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + zip_content_widget = QWidget() + zip_content_layout = QHBoxLayout(zip_content_widget) self.add_zip_text = LineEdit() setCustomStyleSheet(self.add_zip_text, CON.qss_line, CON.qss_line) # self.add_zip_text.setReadOnly(True) # Allow users to manually input path - zip_box_sizer.addWidget(self.add_zip_text, 1) + zip_content_layout.addWidget(self.add_zip_text, 1) zip_button = PushButton("Browse...") zip_button.clicked.connect(self.browse_add_archive) # Changed signal - zip_box_sizer.addWidget(zip_button) + zip_content_layout.addWidget(zip_button) + + # Set content widget to ScrollArea + zip_scroll_area.setWidget(zip_content_widget) + + # Add ScrollArea to GroupBox layout + zip_box_sizer.addWidget(zip_scroll_area) + tab_sizer.addWidget(zip_box) - # File to add selection + # File to add selection with ScrollArea inside GroupBox file_box = QGroupBox("Files to Add") + file_box.setMinimumHeight(200) # Set minimum height file_box_sizer = QVBoxLayout(file_box) + + # Create ScrollArea inside GroupBox + file_scroll_area = ScrollArea() + file_scroll_area.setWidgetResizable(True) + file_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + file_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + file_content_widget = QWidget() + file_content_layout = QVBoxLayout(file_content_widget) # File list for multiple files (always visible) self.add_files_listbox = ListWidget() self.add_files_listbox.setMinimumHeight(150) self.add_files_listbox.setVisible(True) # Always visible - file_box_sizer.addWidget(self.add_files_listbox) + file_content_layout.addWidget(self.add_files_listbox) # Browse button file_button = PushButton("Browse...") file_button.clicked.connect(self.browse_add_file) - file_box_sizer.addWidget(file_button) + file_content_layout.addWidget(file_button) + + # Set content widget to ScrollArea + file_scroll_area.setWidget(file_content_widget) + + # Add ScrollArea to GroupBox layout + file_box_sizer.addWidget(file_scroll_area) tab_sizer.addWidget(file_box) @@ -516,11 +1270,28 @@ def create_add_tab(self): self.add_progress.setValue(0) tab_sizer.addWidget(self.add_progress) + # Add button layout with cancel button + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + # Add button self.add_button = PrimaryPushButton("Add to Archive") # Changed button text - self.add_button.clicked.connect(self.start_add_to_archive) # Changed signal - tab_sizer.addWidget(self.add_button, 0, Qt.AlignmentFlag.AlignCenter) + button_layout.addWidget(self.add_button) + + # Cancel button + self.add_cancel_button = PushButton("Cancel") + self.add_cancel_button.clicked.connect(self.cancel_add_to_archive) + self.add_cancel_button.setEnabled(False) # Initially disabled + button_layout.addWidget(self.add_cancel_button) + + # Create a container widget to center the button layout + button_container = QWidget() + button_container_layout = QHBoxLayout(button_container) + button_container_layout.addStretch() + button_container_layout.addLayout(button_layout) + button_container_layout.addStretch() + tab_sizer.addWidget(button_container) tab_sizer.addStretch(1) # Push content to top @@ -529,18 +1300,36 @@ def create_list_tab(self): tab_sizer = QVBoxLayout(tab_panel) self.notebook.addTab(tab_panel, "List Contents") - # Archive file selection (changed title) + # Archive file selection with ScrollArea inside GroupBox (changed title) zip_box = QGroupBox("Archive File") - zip_box_sizer = QHBoxLayout(zip_box) + zip_box.setMinimumHeight(200) # Set minimum height + zip_box_sizer = QVBoxLayout(zip_box) + + # Create ScrollArea inside GroupBox + zip_scroll_area = ScrollArea() + zip_scroll_area.setWidgetResizable(True) + zip_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + zip_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + zip_content_widget = QWidget() + zip_content_layout = QHBoxLayout(zip_content_widget) self.list_zip_text = LineEdit() setCustomStyleSheet(self.list_zip_text, CON.qss_line, CON.qss_line) # self.list_zip_text.setReadOnly(True) # Allow users to manually input path - zip_box_sizer.addWidget(self.list_zip_text, 1) + zip_content_layout.addWidget(self.list_zip_text, 1) zip_button = PushButton("Browse...") zip_button.clicked.connect(self.browse_list_archive) # Changed signal - zip_box_sizer.addWidget(zip_button) + zip_content_layout.addWidget(zip_button) + + # Set content widget to ScrollArea + zip_scroll_area.setWidget(zip_content_widget) + + # Add ScrollArea to GroupBox layout + zip_box_sizer.addWidget(zip_scroll_area) + tab_sizer.addWidget(zip_box) # Password status indicator @@ -553,25 +1342,60 @@ def create_list_tab(self): password_status_box.addStretch() tab_sizer.addLayout(password_status_box) - # Listbox for contents + # Listbox for contents with ScrollArea inside GroupBox contents_box = QGroupBox("Archive Contents") # Changed group box title + contents_box.setMinimumHeight(200) # Set minimum height contents_box_sizer = QVBoxLayout(contents_box) + + # Create ScrollArea inside GroupBox + contents_scroll_area = ScrollArea() + contents_scroll_area.setWidgetResizable(True) + contents_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + contents_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Create content widget for ScrollArea + contents_content_widget = QWidget() + contents_content_layout = QVBoxLayout(contents_content_widget) self.contents_listbox = ListWidget() self.contents_listbox.setMinimumHeight(250) # Set larger minimum height self.contents_listbox.setDragEnabled(True) # Enable drag functionality - contents_box_sizer.addWidget(self.contents_listbox, 3) # Increase stretch weight + contents_content_layout.addWidget(self.contents_listbox, 3) # Increase stretch weight # Set right-click menu self.contents_listbox.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) # Context menu functionality removed # self.contents_listbox.customContextMenuRequested.connect(self.show_contents_context_menu) - tab_sizer.addWidget(contents_box, 2) # Give contents box more stretch + + # Set content widget to ScrollArea + contents_scroll_area.setWidget(contents_content_widget) + + # Add ScrollArea to GroupBox layout + contents_box_sizer.addWidget(contents_scroll_area) + + tab_sizer.addWidget(contents_box, 2) # Give contents group box more stretch + # List button layout with cancel button + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + # List button self.list_button = PrimaryPushButton("List Contents") - self.list_button.clicked.connect(self.start_list_archive_contents) # Changed signal - tab_sizer.addWidget(self.list_button, 0, Qt.AlignmentFlag.AlignCenter) + button_layout.addWidget(self.list_button) + + # Cancel button + self.list_cancel_button = PushButton("Cancel") + self.list_cancel_button.clicked.connect(self.cancel_list_archive_contents) + self.list_cancel_button.setEnabled(False) # Initially disabled + button_layout.addWidget(self.list_cancel_button) + + # Create a container widget to center the button layout + button_container = QWidget() + button_container_layout = QHBoxLayout(button_container) + button_container_layout.addStretch() + button_container_layout.addLayout(button_layout) + button_container_layout.addStretch() + tab_sizer.addWidget(button_container) tab_sizer.addStretch(1) # Push content to top @@ -699,88 +1523,38 @@ def _verify_password_strength(self, password): # We don't need to verify it against an existing archive since we're creating a new one return True - def on_tab_changed(self, index): - """Handle tab change with optional slide animation effect based on UI_FLUENT environment variable""" - import sys - import os - sys.path.append(os.path.join(os.path.dirname(__file__), 'support')) - from support.check_flag import check_flag - - # Check if UI_FLUENT environment variable is set to YES using check_flag function - ui_fluent_enabled = check_flag("UI_FLUENT") - - # Skip animation if UI_FLUENT is not enabled - if not ui_fluent_enabled: - self._previous_tab_index = index - # Force layout update when animation is disabled - self.notebook.currentWidget().updateGeometry() - if self.notebook.currentWidget().layout(): - self.notebook.currentWidget().layout().update() - self.notebook.currentWidget().layout().activate() - return - - # Proceed with animation if UI_FLUENT is enabled - from PySide6.QtCore import QPropertyAnimation, QEasingCurve, QRect + def confirm_cancel(self, operation_name): + """Show confirmation dialog for canceling an operation - # Get current tab widget - current_widget = self.notebook.currentWidget() - if not current_widget: - return - - # Skip animation during initial startup to prevent layout issues - if not hasattr(self, '_previous_tab_index') and not self.notebook.isVisible(): - self._previous_tab_index = index - return + Args: + operation_name: Name of the operation being canceled - # Get tab widget dimensions - tab_width = self.notebook.width() - tab_height = self.notebook.height() - - # Skip animation if window is not yet properly sized - if tab_width <= 0 or tab_height <= 0: - self._previous_tab_index = index - return - - # Determine slide direction based on tab index - if hasattr(self, '_previous_tab_index'): - if index > self._previous_tab_index: - # Sliding from right to left - start from 80% of width to prevent going out of bounds - start_pos = QRect(int(tab_width * 0.8), 0, tab_width, tab_height) - else: - # Sliding from left to right - start from -80% of width to prevent going out of bounds - start_pos = QRect(int(-tab_width * 0.8), 0, tab_width, tab_height) - else: - # First time, slide from right - start from 80% of width - start_pos = QRect(int(tab_width * 0.8), 0, tab_width, tab_height) - - # Set initial position - current_widget.setGeometry(start_pos) - - # Create slide animation - self.slide_animation = QPropertyAnimation(current_widget, b"geometry") - self.slide_animation.setDuration(300) # 300ms animation for smooth slide - self.slide_animation.setStartValue(start_pos) - self.slide_animation.setEndValue(QRect(0, 0, tab_width, tab_height)) - self.slide_animation.setEasingCurve(QEasingCurve.Type.OutCubic) - - # Store current tab index for next animation - self._previous_tab_index = index - - # Connect animation finished signal to update layout - self.slide_animation.finished.connect(lambda: self._update_tab_layout(current_widget)) + Returns: + bool: True if user confirmed cancel, False otherwise + """ + reply = QMessageBox.question( + self, + "取消操作", + f"确定要取消当前的{operation_name}操作吗?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + return reply == QMessageBox.StandardButton.Yes + + def log_cancel(self, operation_type): + """Log a cancel operation - # Start the animation - self.slide_animation.start() + Args: + operation_type: Type of operation that was canceled + """ + import datetime + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{timestamp}] [CANCEL] {operation_type} operation canceled by user") - def _update_tab_layout(self, widget): - """Update widget layout after animation completes""" - # Force layout update to prevent layout issues - if widget and widget.layout(): - widget.layout().update() - widget.layout().activate() - widget.updateGeometry() - # Repaint the widget to ensure all elements are properly displayed - widget.repaint() + def on_tab_changed(self, index): + """Handle tab change without animation""" + # Simply store the previous tab index and return + self._previous_tab_index = index @@ -950,6 +1724,10 @@ def start_create_archive(self): self.create_progress_label.setText("Starting archive creation...") self.create_progress.setValue(0) + # Enable cancel button and disable create button during operation + self.create_button.setEnabled(False) + self.create_cancel_button.setEnabled(True) + self.create_zip_worker = CreateZipWorker(self.create_output_path, self.create_sources, self.create_archive_format, password) self.create_zip_worker_thread = QThread() self.create_zip_worker.moveToThread(self.create_zip_worker_thread) @@ -957,6 +1735,7 @@ def start_create_archive(self): self.create_zip_worker.finished.connect(self.on_create_archive_finished) self.create_zip_worker.progress_updated.connect(self.update_create_progress) self.create_zip_worker.conversion_error.connect(self.on_create_archive_error) + self.create_zip_worker.canceled.connect(self.on_create_archive_canceled) self.create_zip_worker_thread.started.connect(self.create_zip_worker.run) self.create_zip_worker_thread.start() @@ -998,8 +1777,43 @@ def on_create_archive_error(self, error_message): # Update archive status display self.update_archive_status(archive_info, False) + def cancel_create_archive(self): + """Cancel the archive creation process""" + if self.confirm_cancel("归档创建"): + self.log_cancel("Create Archive") + # Stop the worker + if self.create_zip_worker: + self.create_zip_worker.stop() + + def on_create_archive_canceled(self): + """Handle archive creation canceled""" + # 使用强制线程清理方法 + self._force_cleanup_create_thread() + + # Reset button states + self.create_button.setEnabled(True) + self.create_cancel_button.setEnabled(False) + + # Update progress and status + self.create_progress.setValue(0) + self.create_progress_label.setText("Archive creation canceled") + + # Show cancel notification + self._show_info_bar( + title='Canceled', + content='Archive creation canceled by user', + duration=2000 + ) + + # Update archive status display + self.update_archive_status("Archive creation canceled", False) + def _force_cleanup_create_thread(self): """强制清理创建归档的线程,确保完全终止""" + # Reset button states + self.create_button.setEnabled(True) + self.create_cancel_button.setEnabled(False) + if self.create_zip_worker_thread: if self.create_zip_worker_thread.isRunning(): # 先尝试正常退出 @@ -1056,6 +1870,390 @@ def browse_extract_dest(self): self.extract_dest_path = dir_dialog.selectedFiles()[0] self.extract_dest_text.setText(self.extract_dest_path) + def browse_batch_extract_dest(self): + """Browse for batch extraction destination folder""" + dir_dialog = QFileDialog(self) + dir_dialog.setFileMode(QFileDialog.FileMode.Directory) + dir_dialog.setOption(QFileDialog.Option.ShowDirsOnly, True) + if dir_dialog.exec(): + self.batch_extract_dest_path = dir_dialog.selectedFiles()[0] + self.batch_extract_dest_text.setText(self.batch_extract_dest_path) + + def browse_batch_archive_files(self): + """Browse for archive files to batch extract""" + file_dialog = QFileDialog(self) + file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles) + file_dialog.setNameFilters([ + "Archive files (*.zip *.rar *.7z *.tar *.gz *.bz2 *.xz *.tar.gz *.tar.bz2 *.tar.xz *.tgz *.tbz2)", + "All files (*)" + ]) + if file_dialog.exec(): + file_paths = file_dialog.selectedFiles() + self.add_batch_files(file_paths) + + def add_batch_files(self, file_paths): + """Add files to batch list""" + supported_formats = ('.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.tar.gz', '.tar.bz2', '.tar.xz', '.tgz', '.tbz2') + + for file_path in file_paths: + # Check if file is a supported archive format + if os.path.splitext(file_path.lower())[1] in supported_formats: + # Avoid duplicates + if file_path not in [self.batch_files_listbox.item(i).toolTip() for i in range(self.batch_files_listbox.count())]: + item = QListWidgetItem(os.path.basename(file_path)) + item.setToolTip(file_path) + self.batch_files_listbox.addItem(item) + + self.update_batch_stats() + + def remove_selected_batch_files(self): + """Remove selected files from batch list""" + selected_items = self.batch_files_listbox.selectedItems() + if not selected_items: + return + + # Remove items in reverse order to maintain correct indices + indices = [] + for item in selected_items: + indices.append(self.batch_files_listbox.row(item)) + + indices.sort(reverse=True) + + for index in indices: + self.batch_files_listbox.takeItem(index) + + self.update_batch_stats() + + def clear_batch_files(self): + """Clear all files from batch list""" + self.batch_files_listbox.clear() + self.update_batch_stats() + + def get_batch_archive_files(self): + """Get list of all archive files in batch list""" + file_paths = [] + for i in range(self.batch_files_listbox.count()): + item = self.batch_files_listbox.item(i) + file_paths.append(item.toolTip()) + return file_paths + + def on_batch_files_dropped(self, file_paths): + """Handle files dropped in batch area""" + self.add_batch_files(file_paths) + + def update_batch_stats(self): + """Update batch statistics display""" + total_count = self.batch_files_listbox.count() + self.batch_total_count_label.setText(f"Total: {total_count}") + self.batch_success_count_label.setText("Success: 0") + self.batch_failed_count_label.setText("Failed: 0") + + def start_batch_extract(self): + """Start batch extraction process""" + file_paths = self.get_batch_archive_files() + + if not file_paths: + self._show_popup( + target=self.batch_start_btn, + icon=InfoBarIcon.ERROR, + title='Error', + content='Please add archive files to extract', + duration=2000 + ) + return + + if not self.batch_extract_dest_path: + self._show_popup( + target=self.batch_extract_dest_text, + icon=InfoBarIcon.ERROR, + title='Error', + content='Please specify the extraction destination folder', + duration=2000 + ) + return + + # Validate destination folder + if not os.path.exists(self.batch_extract_dest_path): + try: + os.makedirs(self.batch_extract_dest_path, exist_ok=True) + except Exception as e: + self._show_popup( + target=self.batch_start_btn, + icon=InfoBarIcon.ERROR, + title='Error', + content=f'Failed to create destination folder: {str(e)}', + duration=2000 + ) + return + + # Create batch extract options + create_subfolders = self.batch_create_subfolders_check.isChecked() + + # Get overwrite strategy + overwrite_strategy = self.overwrite_strategy_combo.currentText() + + # Determine overwrite behavior based on strategy + if overwrite_strategy == "Overwrite all": + overwrite_files = True + skip_existing = False + elif overwrite_strategy == "Skip existing": + overwrite_files = False + skip_existing = True + elif overwrite_strategy == "Rename new": + overwrite_files = False + skip_existing = False + # Note: Rename new functionality would need to be implemented in the archive_manager + elif overwrite_strategy == "Overwrite if newer": + overwrite_files = True + skip_existing = False + # Note: Overwrite if newer functionality would need to be implemented in the archive_manager + else: + # Default behavior + overwrite_files = self.batch_overwrite_files_check.isChecked() + skip_existing = self.batch_skip_existing_files_check.isChecked() + + # Reset progress and statistics + self.batch_progress.setValue(0) + self.update_batch_stats() + self.batch_progress_label.setText("Preparing for batch extraction...") + + # Disable start button and enable stop button + self.batch_start_btn.setEnabled(False) + self.batch_stop_btn.setEnabled(True) + + # Create and start batch worker + self.batch_extract_worker = BatchExtractWorker( + file_paths, + self.batch_extract_dest_path, + create_subfolders, + overwrite_files, + parent_gui=self # Pass reference to main GUI for password dialogs + ) + self.batch_extract_worker_thread = QThread() + self.batch_extract_worker.moveToThread(self.batch_extract_worker_thread) + + # Connect signals + self.batch_extract_worker.finished.connect(self.on_batch_extract_finished) + self.batch_extract_worker.progress_updated.connect(self.on_batch_extract_progress) + self.batch_extract_worker.conversion_error.connect(self.on_batch_extract_error) + self.batch_extract_worker.individual_progress.connect(self.on_batch_individual_progress) + self.batch_extract_worker.status_updated.connect(self.on_batch_status_updated) + self.batch_extract_worker_thread.started.connect(self.batch_extract_worker.run) + self.batch_extract_worker_thread.start() + + def stop_batch_extract(self): + """Stop batch extraction process""" + if hasattr(self, 'batch_extract_worker'): + self.batch_extract_worker.stop() + self.batch_progress_label.setText("Stopping batch extraction...") + # Disable stop button to prevent multiple stops + self.batch_stop_btn.setEnabled(False) + # Start a timer to check if thread has stopped after a timeout + QTimer.singleShot(2000, self._check_batch_thread_status) + + def _check_batch_thread_status(self): + """Check if batch thread has stopped after timeout""" + if hasattr(self, 'batch_extract_worker_thread') and self.batch_extract_worker_thread.isRunning(): + # Thread is still running, try to force stop + self.batch_progress_label.setText("Force stopping batch extraction...") + self._force_stop_batch_thread() + self.on_batch_extract_stopped() + + def _force_stop_batch_thread(self): + """Force stop batch extraction thread""" + if hasattr(self, 'batch_extract_worker_thread') and self.batch_extract_worker_thread.isRunning(): + try: + # Try to quit gracefully first + self.batch_extract_worker_thread.quit() + if not self.batch_extract_worker_thread.wait(1000): # Wait 1 second + # If graceful quit fails, terminate + self.batch_extract_worker_thread.terminate() + self.batch_extract_worker_thread.wait(1000) # Wait another second + except Exception as e: + print(f"Error stopping batch thread: {str(e)}") + + def on_batch_extract_stopped(self): + """Handle batch extraction stopped by user""" + self.batch_progress_label.setText("Batch extraction stopped by user") + self._cleanup_batch_thread() + # Update UI + self.batch_start_btn.setEnabled(True) + self.batch_stop_btn.setEnabled(False) + + def _cleanup_batch_thread(self): + """Clean up batch extraction thread and worker resources""" + # Clean up worker thread + if hasattr(self, 'batch_extract_worker_thread'): + if self.batch_extract_worker_thread.isRunning(): + try: + self.batch_extract_worker_thread.quit() + self.batch_extract_worker_thread.wait(1000) + except Exception as e: + print(f"Error cleaning up batch thread: {str(e)}") + + # Delete thread object + self.batch_extract_worker_thread.deleteLater() + delattr(self, 'batch_extract_worker_thread') + + # Clean up worker + if hasattr(self, 'batch_extract_worker'): + # Delete worker object + self.batch_extract_worker.deleteLater() + delattr(self, 'batch_extract_worker') + + def on_batch_extract_progress(self, processed_count, total_count, current_file, success_count, failed_count): + """Handle batch extraction progress update""" + progress_percentage = int((processed_count / total_count) * 100) + self.batch_progress.setValue(progress_percentage) + + # Update statistics + self.batch_total_count_value.setText(str(total_count)) + self.batch_success_count_value.setText(str(success_count)) + self.batch_failed_count_value.setText(str(failed_count)) + + # Update progress label with more detailed information + current_file_name = os.path.basename(current_file) if current_file else "" + self.batch_progress_label.setText(f"Processing: {current_file_name} ({processed_count}/{total_count}) - {progress_percentage}%") + + def on_batch_individual_progress(self, archive_name, message, progress): + """Handle individual archive extraction progress""" + # Update status bar with individual file progress + self.status_bar.showMessage(f"Extracting {archive_name}: {message} - {progress}%") + + def on_batch_status_updated(self, status_message): + """Handle batch extraction status updates""" + # Update status bar with general status messages + self.status_bar.showMessage(status_message) + + def on_batch_extract_finished(self, success_count, failed_count, success_files=None, failed_files=None): + """Handle batch extraction finished""" + # Clean up thread + self._cleanup_batch_thread() + + # Update final statistics + total_count = success_count + failed_count + self.batch_total_count_value.setText(str(total_count)) + self.batch_success_count_value.setText(str(success_count)) + self.batch_failed_count_value.setText(str(failed_count)) + + # Re-enable start button and disable stop button + self.batch_start_btn.setEnabled(True) + self.batch_stop_btn.setEnabled(False) + + # Show completion message with detailed results + result_message = f"Batch extraction completed: {success_count} successful, {failed_count} failed" + self.batch_progress_label.setText(result_message) + + # Show appropriate message based on results + if failed_count == 0: + self._show_info_bar( + title='Success', + content=f'All {total_count} archives extracted successfully!', + duration=3000 + ) + elif success_count == 0: + self._show_popup( + target=self.batch_progress, + icon=InfoBarIcon.ERROR, + title='Error', + content=f'Failed to extract all {total_count} archives.', + duration=3000 + ) + # Show detailed failures if available + if failed_files: + self._show_batch_extract_failures(failed_files) + else: + self._show_info_bar( + title='Partially Complete', + content=f'Extracted {success_count} out of {total_count} archives successfully.', + duration=3000 + ) + # Show detailed failures if available + if failed_files: + self._show_batch_extract_failures(failed_files) + + # Clear status bar + self.status_bar.clearMessage() + + def _show_batch_extract_failures(self, failed_files): + """Show detailed information about failed extractions""" + from qfluentwidgets import MessageBox + + # Create detailed failure message + failure_details = "Failed to extract the following archives:\n\n" + for file_path, error_msg in failed_files[:10]: # Show first 10 failures + file_name = os.path.basename(file_path) + failure_details += f"• {file_name}: {error_msg}\n" + + if len(failed_files) > 10: + failure_details += f"\n... and {len(failed_files) - 10} more failures." + + # Show message box with failure details + msg_box = MessageBox( + 'Batch Extraction Failures', + failure_details, + self + ) + msg_box.yesButton.setText('OK') + msg_box.exec() + + def _force_cleanup_batch_thread(self): + """Deprecated method, use _cleanup_batch_thread instead""" + self._cleanup_batch_thread() + + def on_batch_extract_error(self, error_message): + """Handle batch extraction error""" + # Clean up thread + self._force_cleanup_batch_thread() + + # Re-enable start button and disable stop button + self.batch_start_btn.setEnabled(True) + self.batch_stop_btn.setEnabled(False) + + self._show_popup( + target=self.batch_progress, + icon=InfoBarIcon.ERROR, + title='Error', + content=f'Batch extraction error: {str(error_message)}', + duration=3000 + ) + + def on_batch_extract_stopped(self): + """Handle batch extraction stopped by user""" + # Clean up thread + self._force_cleanup_batch_thread() + + # Re-enable start button and disable stop button + self.batch_start_btn.setEnabled(True) + self.batch_stop_btn.setEnabled(False) + + self.batch_progress_label.setText("Stopped") + + self._show_popup( + target=self.batch_progress, + icon=InfoBarIcon.WARNING, + title='Stopped', + content='Batch extraction stopped by user.', + duration=2000 + ) + + def _force_cleanup_batch_thread(self): + """Force cleanup batch extraction thread""" + if hasattr(self, 'batch_extract_worker_thread') and self.batch_extract_worker_thread.isRunning(): + self.batch_extract_worker_thread.quit() + self.batch_extract_worker_thread.wait() + + def reset_batch_ui(self): + """Reset batch extraction UI to initial state""" + self.batch_files_listbox.clear() + self.batch_extract_dest_path = "" + self.batch_extract_dest_text.setText("Select destination folder...") + self.batch_create_subfolders_check.setChecked(True) + self.batch_overwrite_files_check.setChecked(False) + self.batch_progress.setValue(0) + self.update_batch_stats() + self.batch_progress_label.setText("Ready") + def auto_set_extract_dest_from_file(self, file_path): """Automatically set the extract destination to the file's parent directory""" try: @@ -1097,6 +2295,10 @@ def start_extract_archive(self): self.extract_progress_label.setText("Starting archive extraction...") self.extract_progress.setValue(0) + + # Enable cancel button and disable extract button during operation + self.extract_button.setEnabled(False) + self.extract_cancel_button.setEnabled(True) # Check if archive is password protected by attempting to list contents first try: @@ -1148,6 +2350,7 @@ def start_extract_archive(self): self.extract_zip_worker.progress_updated.connect(self.update_extract_progress) self.extract_zip_worker.conversion_error.connect(self.on_extract_archive_error) self.extract_zip_worker.password_required.connect(self.on_extract_archive_error) + self.extract_zip_worker.canceled.connect(self.on_extract_archive_canceled) self.extract_zip_worker_thread.started.connect(self.extract_zip_worker.run) self.extract_zip_worker_thread.start() @@ -1206,6 +2409,9 @@ def on_extract_archive_error(self, error_message): self.extract_zip_worker = ExtractZipWorker(self.extract_zip_path, self.extract_dest_path, password) self.extract_zip_worker_thread = QThread() self.extract_zip_worker.moveToThread(self.extract_zip_worker_thread) + + # Connect signals including canceled signal + self.extract_zip_worker.canceled.connect(self.on_extract_archive_canceled) # 连接信号 self.extract_zip_worker.finished.connect(self.on_extract_archive_finished) @@ -1248,8 +2454,40 @@ def on_extract_archive_error(self, error_message): ) self.extract_progress_label.setText("Archive extraction failed.") + def cancel_extract_archive(self): + """Cancel the archive extraction process""" + if self.confirm_cancel("归档解压"): + self.log_cancel("Extract Archive") + # Stop the worker + if self.extract_zip_worker: + self.extract_zip_worker.stop() + + def on_extract_archive_canceled(self): + """Handle archive extraction canceled""" + # 使用强制线程清理方法 + self._force_cleanup_thread() + + # Update progress and status + self.extract_progress.setValue(0) + self.extract_progress_label.setText("Archive extraction canceled") + + # Show cancel notification + self._show_info_bar( + title='Canceled', + content='Archive extraction canceled by user', + duration=2000 + ) + + # Update password status to indicate unknown status + self.is_password_protected = False + self.update_password_status_extract(False, "Archive Status Unknown") + def _force_cleanup_thread(self): """强制清理线程,确保完全终止""" + # Reset button states + self.extract_button.setEnabled(True) + self.extract_cancel_button.setEnabled(False) + if self.extract_zip_worker_thread: if self.extract_zip_worker_thread.isRunning(): # 先尝试正常退出 @@ -1339,6 +2577,10 @@ def start_add_to_archive(self): self.add_progress_label.setText("Starting archive file addition...") self.add_progress.setValue(0) + + # Enable cancel button and disable add button during operation + self.add_button.setEnabled(False) + self.add_cancel_button.setEnabled(True) # Handle multiple files - split by semicolon if contains multiple paths if isinstance(self.add_file_path, list): @@ -1357,7 +2599,8 @@ def start_add_to_archive(self): self.add_to_zip_worker.finished.connect(self.on_add_to_archive_finished) self.add_to_zip_worker.progress_updated.connect(self.update_add_progress) - self.add_to_zip_worker.conversion_error.connect(self.on_add_to_archive_error) + self.add_to_zip_worker.finished.connect(self.on_add_to_archive_finished) + self.add_to_zip_worker.canceled.connect(self.on_add_to_archive_canceled) self.add_to_zip_worker_thread.started.connect(self.add_to_zip_worker.run) self.add_to_zip_worker_thread.start() @@ -1392,8 +2635,36 @@ def on_add_to_archive_error(self, error_message): ) self.add_progress_label.setText("Archive file addition failed.") + def cancel_add_to_archive(self): + """Cancel the add to archive process""" + if self.confirm_cancel("添加到归档"): + self.log_cancel("Add to Archive") + # Stop the worker + if self.add_to_zip_worker: + self.add_to_zip_worker.stop() + + def on_add_to_archive_canceled(self): + """Handle add to archive canceled""" + # 使用强制线程清理方法 + self._force_cleanup_add_thread() + + # Update progress and status + self.add_progress.setValue(0) + self.add_progress_label.setText("Add to archive canceled") + + # Show cancel notification + self._show_info_bar( + title='Canceled', + content='Add to archive canceled by user', + duration=2000 + ) + def _force_cleanup_add_thread(self): """强制清理添加到归档的线程,确保完全终止""" + # Reset button states + self.add_button.setEnabled(True) + self.add_cancel_button.setEnabled(False) + if self.add_to_zip_worker_thread: if self.add_to_zip_worker_thread.isRunning(): # 先尝试正常退出 @@ -1475,6 +2746,10 @@ def start_list_archive_contents(self): self.contents_listbox.clear() self.contents_listbox.addItem("Listing contents...") + + # Enable cancel button and disable list button during operation + self.list_button.setEnabled(False) + self.list_cancel_button.setEnabled(True) # Reset password protection status self.is_password_protected = False @@ -1490,6 +2765,7 @@ def start_list_archive_contents(self): self.list_zip_worker.finished.connect(self.on_list_zip_finished) self.list_zip_worker.conversion_error.connect(self.on_list_archive_error) self.list_zip_worker.password_required.connect(self.on_password_required) + self.list_zip_worker.canceled.connect(self.on_list_archive_canceled) self.list_zip_worker_thread.started.connect(self.list_zip_worker.run) self.list_zip_worker_thread.start() @@ -1678,8 +2954,36 @@ def on_list_archive_error(self, error_message): # Update password status for other errors self.update_password_status_list(False, "Error Listing Contents") + def cancel_list_archive_contents(self): + """Cancel the list archive contents process""" + if self.confirm_cancel("列出内容"): + self.log_cancel("List Contents") + # Stop the worker + if self.list_zip_worker: + self.list_zip_worker.stop() + + def on_list_archive_canceled(self): + """Handle list archive contents canceled""" + # 使用强制线程清理方法 + self._force_cleanup_list_thread() + + # Update listbox and status + self.contents_listbox.clear() + self.contents_listbox.addItem("List contents canceled") + + # Show cancel notification + self._show_info_bar( + title='Canceled', + content='List contents canceled by user', + duration=2000 + ) + def _force_cleanup_list_thread(self): """强制清理列出归档内容的线程,确保完全终止""" + # Reset button states + self.list_button.setEnabled(True) + self.list_cancel_button.setEnabled(False) + if self.list_zip_worker_thread: if self.list_zip_worker_thread.isRunning(): # 先尝试正常退出 diff --git a/build_project_arm64.py b/build_project_arm64.py index d2127b2..4852cc9 100644 --- a/build_project_arm64.py +++ b/build_project_arm64.py @@ -2,7 +2,7 @@ import clean import buildzip from setup_ccache import setup_ccache -target="com.pyquick.converter" +target="com.intsant.converter" from patch import enable from plistedit import add_utf_info if __name__ == "__main__": diff --git a/build_project_base.py b/build_project_base.py index 58570da..9b9bb6b 100644 --- a/build_project_base.py +++ b/build_project_base.py @@ -53,7 +53,7 @@ def compile_gui(): "--include-package=image_converter", "--include-package=support", "--include-package=update", # 添加update包以确保所有更新功能正常工作 - "--macos-signed-app-name=com.pyquick.converter", + "--macos-signed-app-name=com.intsant.converter", "--enable-plugin=pyside6", "--prefer-source-code", "--output-dir=dist", # Output directory @@ -227,7 +227,7 @@ def create_macos_app_bundle(current_dir, dist_path): CFBundleIconFile AppIcon.icns CFBundleIdentifier - com.pyquick.converter + com.intsant.converter CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/debug/debug_gui.py b/debug/debug_gui.py index 9e049f9..9c47a27 100644 --- a/debug/debug_gui.py +++ b/debug/debug_gui.py @@ -5,187 +5,231 @@ import os import sys -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QCheckBox, QGroupBox, QSpacerItem, QSizePolicy, - QPushButton, QTextBrowser -) +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout from PySide6.QtCore import QSettings, Qt from PySide6.QtGui import QFont from qfluentwidgets import ( - CheckBox, TextBrowser, IndeterminateProgressBar, - ProgressBar, PrimaryPushButton + SettingCardGroup, SwitchSettingCard, PushSettingCard, PrimaryPushSettingCard, + FluentIcon, BodyLabel, CaptionLabel, TextBrowser, InfoBar, InfoBarPosition, + setCustomStyleSheet, HeaderCardWidget, SingleDirectionScrollArea ) import sys import os -from qfluentwidgets import * -# Add support directory to path for debug_logger import sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'support')) from support.debug_logger import DebugLogger -class DebugSettingsWidget(QWidget): - """Debug Settings GUI Widget""" - - def __init__(self): - super().__init__() - self.setWindowTitle("Debug Settings") - self.debug_logger = DebugLogger() - self.settings = QSettings("MyCompany", "ConverterApp") +class DebugStatusCard(HeaderCardWidget): + """Debug status card using HeaderCardWidget""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setTitle('Debug Status') - self.init_ui() - self.load_settings() - self.connect_auto_save_signals() - - def init_ui(self): - """Initialize the UI""" - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(25, 25, 25, 25) - main_layout.setSpacing(20) - - # Debug Settings Group - debug_group = QGroupBox("Debug Settings") - debug_layout = QVBoxLayout() - debug_layout.setContentsMargins(25, 25, 25, 25) - debug_layout.setSpacing(20) - - # Add top spacing - debug_layout.addSpacerItem(QSpacerItem(0, 15, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)) - - # Debug Mode Toggle - self.debug_enabled_checkbox = CheckBox("Enable Debug Mode") - self.debug_enabled_checkbox.setMinimumHeight(60) - # Signal connection will be done in connect_auto_save_signals - debug_layout.addWidget(self.debug_enabled_checkbox) - - # Enhanced Logging - self.enhanced_logging_checkbox = CheckBox("Enable Enhanced Logging (with module info)") - self.enhanced_logging_checkbox.setMinimumHeight(60) - # Signal connection will be done in connect_auto_save_signals - debug_layout.addWidget(self.enhanced_logging_checkbox) - - # Log File Info - log_info_layout = QHBoxLayout() - log_info_layout.addWidget(QLabel("Log Directory:")) - self.log_dir_label = QLabel("~/.converter/log/") - self.log_dir_label.setWordWrap(True) - self.log_dir_label.setStyleSheet("QLabel { color: #666; }") - log_info_layout.addWidget(self.log_dir_label) - log_info_layout.addStretch() - debug_layout.addLayout(log_info_layout) - - # Status Label - self.status_label = QLabel("Debug mode is currently disabled.") - self.status_label.setMinimumHeight(60) - self.status_label.setMinimumWidth(550) - self.status_label.setWordWrap(True) - self.status_label.setStyleSheet(""" - QLabel { - padding: 8px; - background-color: #f8f9fa; - border-radius: 16px; - border: 1px solid #e9ecef; - } - """) - debug_layout.addWidget(self.status_label) - - # Log Preview Area - self.log_preview_browser = TextBrowser() - self.log_preview_browser.setMinimumHeight(150) - self.log_preview_browser.setPlaceholderText("Log preview will appear here when debug mode is enabled...") - debug_layout.addWidget(self.log_preview_browser) - - # Buttons Container - button_container = QHBoxLayout() - button_container.setSpacing(15) - from con import CON - # Test Debug Button - self.test_debug_button = PrimaryPushButton("Test Debug Output") - setCustomStyleSheet(self.test_debug_button, CON.qss_debug, CON.qss_debug) - self.test_debug_button.setFixedSize(180, 60) - self.test_debug_button.clicked.connect(self.test_debug_output) - - # View Logs Button - self.view_logs_button = PrimaryPushButton("View Log Directory") - self.view_logs_button.setFixedSize(180, 60) - setCustomStyleSheet(self.view_logs_button, CON.qss_debug, CON.qss_debug) - self.view_logs_button.clicked.connect(self.view_log_directory) - - # Clear Logs Button - self.clear_logs_button = PrimaryPushButton("Clear Logs") - self.clear_logs_button.setFixedSize(180, 60) - setCustomStyleSheet(self.clear_logs_button, CON.qss_debug, CON.qss_debug) - self.clear_logs_button.clicked.connect(self.clear_logs) - - button_container.addStretch() - button_container.addWidget(self.test_debug_button) - button_container.addWidget(self.view_logs_button) - button_container.addWidget(self.clear_logs_button) - button_container.addStretch() - debug_layout.addLayout(button_container) - - # Add bottom spacing - debug_layout.addSpacerItem(QSpacerItem(0, 15, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)) - - debug_group.setLayout(debug_layout) - main_layout.addWidget(debug_group) - - self.setLayout(main_layout) - - # Set button fonts - font = QFont() - font.setPointSize(12) - font.setBold(True) - self.test_debug_button.setFont(font) - self.view_logs_button.setFont(font) - self.clear_logs_button.setFont(font) - - def load_settings(self): - """Load current debug settings""" - debug_enabled = bool(self.settings.value("debug_enabled", False, type=bool)) - enhanced_logging = bool(self.settings.value("enhanced_logging", True, type=bool)) + from qfluentwidgets import IconWidget, HyperlinkLabel - self.debug_enabled_checkbox.setChecked(debug_enabled) - self.enhanced_logging_checkbox.setChecked(enhanced_logging) + # Create status icon + self.statusIcon = IconWidget(FluentIcon.INFO, self) + self.statusIcon.setFixedSize(16, 16) - self.update_status_label() - - def update_status_label(self): - """Update the status label based on current settings""" - debug_enabled = self.debug_enabled_checkbox.isChecked() - enhanced_logging = self.enhanced_logging_checkbox.isChecked() + # Create status label + self.statusLabel = BodyLabel('Debug mode is currently disabled.', self) + + # Create detail button + self.detailButton = HyperlinkLabel('View Logs', self) + self.detailButton.clicked.connect(self.view_logs) + + # Setup layout + self.vBoxLayout = QVBoxLayout() + self.hBoxLayout = QHBoxLayout() + + self.hBoxLayout.setSpacing(10) + self.vBoxLayout.setSpacing(16) + self.hBoxLayout.setContentsMargins(0, 0, 0, 0) + self.vBoxLayout.setContentsMargins(0, 0, 0, 0) + + self.hBoxLayout.addWidget(self.statusIcon) + self.hBoxLayout.addWidget(self.statusLabel) + self.vBoxLayout.addLayout(self.hBoxLayout) + self.vBoxLayout.addWidget(self.detailButton) + self.viewLayout.addLayout(self.vBoxLayout) + + self.update_status(False, False) + + def update_status(self, debug_enabled, enhanced_logging): + """Update status display based on debug settings""" if debug_enabled: - status_text = "✓ Debug mode is ENABLED" + from qfluentwidgets import InfoBarIcon + self.statusIcon.setIcon(InfoBarIcon.SUCCESS) + status_text = " Debug mode is ENABLED" if enhanced_logging: status_text += " with enhanced logging" status_text += ". All debug output is being logged to ~/.converter/log/" - self.status_label.setStyleSheet(""" - QLabel { - padding: 8px; - background-color: #e8f5e8; - border-radius: 16px; - border: 1px solid #d4edda; + self.statusLabel.setStyleSheet(""" + BodyLabel { color: #155724; } """) else: + self.statusIcon.setIcon(FluentIcon.INFO) status_text = "Debug mode is DISABLED. Only basic console output will be shown." - self.status_label.setStyleSheet(""" - QLabel { - padding: 8px; - background-color: #f8f9fa; - border-radius: 16px; - border: 1px solid #e9ecef; + self.statusLabel.setStyleSheet(""" + BodyLabel { color: #6c757d; } """) - self.status_label.setText(status_text) - + self.statusLabel.setText(status_text) + + def view_logs(self): + """View log directory""" + log_dir = os.path.expanduser("~/.converter/log") + + if not os.path.exists(log_dir): + os.makedirs(log_dir, exist_ok=True) + + try: + if sys.platform == "darwin": # macOS + os.system(f"open '{log_dir}'") + elif sys.platform == "win32": # Windows + os.system(f"explorer '{log_dir}'") + else: # Linux + os.system(f"xdg-open '{log_dir}'") + except Exception as e: + print(f"Failed to open log directory: {e}") +class DebugSettingsWidget(QWidget): + """Debug settings widget using qfluentwidgets SettingCard components""" + + def __init__(self, parent=None): + super().__init__(parent) + self.debug_logger = DebugLogger() + self.settings = QSettings("MyCompany", "ConverterApp") + self.setup_ui() + self.connect_signals() + self.load_settings() + + def setup_ui(self): + """Setup the UI layout""" + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Create scroll area + self.scroll_area = SingleDirectionScrollArea(orient=Qt.Orientation.Vertical) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.enableTransparentBackground() + + # Create scroll content widget + scroll_content = QWidget() + scroll_content.setObjectName("scroll_content") + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setContentsMargins(30, 30, 30, 30) + scroll_layout.setSpacing(20) + + # Create setting card groups + self.debug_group = SettingCardGroup("Debug Configuration", scroll_content) + self.log_group = SettingCardGroup("Log Management", scroll_content) + + # Create debug setting cards + self.debug_enabled_card = SwitchSettingCard( + FluentIcon.DEVELOPER_TOOLS, + "Enable Debug Mode", + "Enable debug logging for troubleshooting", + parent=self.debug_group + ) + + self.enhanced_logging_card = SwitchSettingCard( + FluentIcon.DOCUMENT, + "Enhanced Logging", + "Include module information in debug output", + parent=self.debug_group + ) + + # Create log management cards + self.test_debug_card = PrimaryPushSettingCard( + "Test Debug Output", + FluentIcon.CODE, + "Generate test debug messages" + ) + self.test_debug_card.clicked.connect(self.test_debug_output) + + self.view_logs_card = PushSettingCard( + "View Log Directory", + FluentIcon.FOLDER, + "Open log folder in file explorer" + ) + self.view_logs_card.clicked.connect(self.view_log_directory) + + self.clear_logs_card = PushSettingCard( + "Clear Logs", + FluentIcon.DELETE, + "Remove all log files" + ) + self.clear_logs_card.clicked.connect(self.clear_logs) + + # Add cards to groups + self.debug_group.addSettingCards([ + self.debug_enabled_card, + self.enhanced_logging_card + ]) + + self.log_group.addSettingCards([ + self.test_debug_card, + self.view_logs_card, + self.clear_logs_card + ]) + + # Add log info and preview to log group + log_info_label = CaptionLabel("Log files are stored in ~/.converter/log/") + self.log_group.vBoxLayout.addWidget(log_info_label) + + # Debug status card + self.debug_status_card = DebugStatusCard(scroll_content) + + # Log preview area + self.log_preview_browser = TextBrowser() + self.log_preview_browser.setMinimumHeight(150) + self.log_preview_browser.setPlaceholderText("Log preview will appear here when debug mode is enabled...") + self.log_group.vBoxLayout.addWidget(self.log_preview_browser) + + # Add groups to scroll layout + scroll_layout.addWidget(self.debug_group) + scroll_layout.addWidget(self.debug_status_card) + scroll_layout.addWidget(self.log_group) + scroll_layout.addStretch() + + # Set scroll content + self.scroll_area.setWidget(scroll_content) + + # Add scroll area to main layout + main_layout.addWidget(self.scroll_area) + + def connect_signals(self): + """Connect signals for auto-save""" + self.debug_enabled_card.checkedChanged.connect(self.on_debug_setting_changed) + self.enhanced_logging_card.checkedChanged.connect(self.on_enhanced_logging_changed) + + def load_settings(self): + """Load current debug settings""" + self.debug_enabled_card.setChecked(bool(self.settings.value("debug_enabled", False, type=bool))) + self.enhanced_logging_card.setChecked(bool(self.settings.value("enhanced_logging", True, type=bool))) + + # Disable enhanced logging checkbox if debug mode is not enabled + self.enhanced_logging_card.setEnabled(self.debug_enabled_card.isChecked()) + + self.update_status_label() + + def update_status_label(self): + """Update the status label based on current settings""" + debug_enabled = self.debug_enabled_card.isChecked() + enhanced_logging = self.enhanced_logging_card.isChecked() + + self.debug_status_card.update_status(debug_enabled, enhanced_logging) + def test_debug_output(self): """Test debug output functionality""" self.debug_logger.log_debug("This is a test debug message from Debug Settings GUI") @@ -208,9 +252,9 @@ def test_debug_output(self): self.log_preview_browser.setPlainText(preview) except Exception as e: self.log_preview_browser.setPlainText(f"Error reading log file: {e}") - + def view_log_directory(self): - """Open the log directory in file explorer""" + """Open log directory in file explorer""" log_dir = os.path.expanduser("~/.converter/log") if not os.path.exists(log_dir): @@ -225,7 +269,7 @@ def view_log_directory(self): os.system(f"xdg-open '{log_dir}'") except Exception as e: self.debug_logger.log_error(f"Failed to open log directory: {e}") - + def clear_logs(self): """Clear all log files""" log_dir = os.path.expanduser("~/.converter/log") @@ -241,24 +285,25 @@ def clear_logs(self): except Exception as e: self.log_preview_browser.setPlainText(f"Error clearing logs: {e}") - # Use print instead of log_error to avoid potential recursion print(f"ERROR: Failed to clear logs: {e}") else: self.log_preview_browser.setPlainText("Log directory does not exist.") - def connect_auto_save_signals(self): - """Connect all UI controls to auto-save functionality""" - # Connect checkboxes to auto-save - the toggle methods already call auto_save_settings - # But we also add a direct connection to ensure the signal is triggered - self.debug_enabled_checkbox.stateChanged.connect(self.on_debug_setting_changed) - self.enhanced_logging_checkbox.stateChanged.connect(self.on_enhanced_logging_changed) - def on_debug_setting_changed(self): """Handle debug setting change and trigger auto-save""" - debug_enabled = self.debug_enabled_checkbox.isChecked() + debug_enabled = self.debug_enabled_card.isChecked() self.settings.setValue("debug_enabled", debug_enabled) self.settings.sync() + # Enable/disable enhanced logging checkbox based on debug mode + self.enhanced_logging_card.setEnabled(debug_enabled) + + # If debug mode is disabled, also disable enhanced logging + if not debug_enabled: + self.enhanced_logging_card.setChecked(False) + self.settings.setValue("enhanced_logging", False) + self.settings.sync() + # Reinitialize debug logger with new settings self.debug_logger = DebugLogger() @@ -268,13 +313,10 @@ def on_debug_setting_changed(self): self.debug_logger.log_info("Debug mode enabled via GUI (auto-save)") else: self.debug_logger.log_info("Debug mode disabled via GUI (auto-save)") - - # Emit auto-save signal to parent if exists - self.auto_save_settings() def on_enhanced_logging_changed(self): """Handle enhanced logging setting change and trigger auto-save""" - enhanced_logging = self.enhanced_logging_checkbox.isChecked() + enhanced_logging = self.enhanced_logging_card.isChecked() self.settings.setValue("enhanced_logging", enhanced_logging) self.settings.sync() @@ -284,17 +326,6 @@ def on_enhanced_logging_changed(self): self.debug_logger.log_info("Enhanced logging enabled via GUI (auto-save)") else: self.debug_logger.log_info("Enhanced logging disabled via GUI (auto-save)") - - # Emit auto-save signal to parent if exists - self.auto_save_settings() - - def auto_save_settings(self): - """Auto-save settings""" - try: - # Force sync settings to disk - self.settings.sync() - except Exception as e: - print(f"Error in auto_save_settings: {e}") if __name__ == "__main__": @@ -307,4 +338,4 @@ def auto_save_settings(self): widget.resize(800, 600) widget.show() - sys.exit(app.exec()) \ No newline at end of file + sys.exit(app.exec()) diff --git a/image_converter.py b/image_converter.py index 4f77733..fd79a42 100644 --- a/image_converter.py +++ b/image_converter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -PNG to ICNS Converter with wxPython GUI +PNG to ICNS Converter with PySide6 This script provides a graphical interface for converting PNG images to ICNS format. """ @@ -11,12 +11,13 @@ import os import threading import subprocess +from concurrent.futures import ThreadPoolExecutor, as_completed from PIL import Image from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QLabel, + QLabel, QComboBox, QSpinBox, QListWidget, QFileDialog, QMessageBox, QTabWidget, QGroupBox, QSizePolicy, - QTreeWidgetItem, QFrame, QScrollArea, QListWidgetItem + QTreeWidgetItem, QFrame,QListWidgetItem ) from PySide6.QtGui import QPixmap, QIcon, QFont, QImage, QPalette from PySide6.QtCore import Qt, QSize, Signal, QObject, QThread @@ -28,8 +29,10 @@ # Add the current directory to Python path to import convert module sys.path.append(os.path.dirname(os.path.abspath(__file__))) from support import convert +from support.GUI.image_support import DropZoneWidget, DirectoryDropLineEdit, PreviewTab, ThumbnailGridWidget from con import CON + class ConversionWorker(QObject): finished = Signal() progress_updated = Signal(str, int) @@ -53,6 +56,7 @@ def __init__(self, input_path, output_path, output_format, min_size_param=None, def run(self): try: if self.output_format == "icns": + # For icns format, use positional arguments with correct order convert.convert_image( self.input_path, self.output_path, @@ -63,21 +67,262 @@ def run(self): progress_callback=self._update_progress_callback ) else: + # For non-icns formats, use keyword arguments for clarity and to avoid parameter order issues convert.convert_image( - self.input_path, - self.output_path, - self.output_format, + input_path=self.input_path, + output_path=self.output_path, + output_format=self.output_format, quality=self.quality, progress_callback=self._update_progress_callback ) self.finished.emit() except Exception as e: - self.conversion_error.emit(str(e)) - - def _update_progress_callback(self, message, percentage): + error_msg = f"Conversion error: {str(e)}" + self.conversion_error.emit(error_msg) + print(f"[ERROR] ConversionWorker: {error_msg}") + + def _update_progress_callback(self, *args): + """Handle variable number of arguments from progress_callback + Can be called with: + - (message, percentage) + - (current, total, message) + - (message, percentage, extra) + """ + # Determine the arguments format + if len(args) == 2: + # Format: (message, percentage) + message, percentage = args + elif len(args) == 3: + # Format: (current, total, message) or (message, percentage, extra) + if isinstance(args[0], (int, float)) and isinstance(args[1], (int, float)): + # Format: (current, total, message) + current, total, message = args + percentage = int((current / total) * 100) if total > 0 else 0 + else: + # Format: (message, percentage, extra) + message, percentage, _ = args + else: + # Unexpected format, use default values + message = "Processing..." if args else "Unknown progress" + percentage = 0 + self.progress_updated.emit(message, percentage) +class BatchConversionWorker(QObject): + finished = Signal() + progress_updated = Signal(int, int, str, int) # current_index, total_count, current_file, percentage + file_processed = Signal(str, str, str, bool, str) # filename, input_path, output_path, success, error_message + batch_error = Signal(str) + total_progress_updated = Signal(int) # overall progress percentage + + def __init__(self, input_paths, output_dir, output_format, min_size_param=None, max_size_param=None, quality_param=None, + preserve_folder_structure=False, prefix="", suffix="", auto_detect_max_size=False): + super().__init__() + self.input_paths = input_paths + self.output_dir = output_dir + self.output_format = output_format + self.quality = int(quality_param) if quality_param is not None else 85 + self.is_cancelled = False + self.preserve_folder_structure = preserve_folder_structure + self.prefix = prefix + self.suffix = suffix + self.auto_detect_max_size = auto_detect_max_size + + if output_format == "icns": + self.min_size = int(min_size_param) if min_size_param is not None else 16 + self.max_size = int(max_size_param) if max_size_param is not None else None + else: + self.min_size = None + self.max_size = None + + def cancel(self): + """Cancel the batch conversion process""" + self.is_cancelled = True + + def run(self): + try: + total_files = len(self.input_paths) + if total_files == 0: + self.finished.emit() + return + + # Get common parent directory if preserving folder structure + common_parent = None + if self.preserve_folder_structure and self.input_paths: + # Get all directories + directories = [os.path.dirname(path) for path in self.input_paths] + if directories: + # Find common parent directory + common_parent = os.path.commonpath(directories) + + # Calculate optimal number of threads (use CPU cores * 2 for I/O bound tasks) + max_workers = min(16, os.cpu_count() * 2) + + # Track processed files and progress + processed_files = 0 + + # Create a list to hold conversion tasks + conversion_tasks = [] + + # Prepare all conversion tasks + for i, input_path in enumerate(self.input_paths): + filename = os.path.basename(input_path) + name_without_ext = os.path.splitext(filename)[0] + + # Apply prefix and suffix + output_filename = f"{self.prefix}{name_without_ext}{self.suffix}.{self.output_format.lower()}" + + # Determine output path based on folder structure option + if self.preserve_folder_structure and common_parent: + # Get relative path from common parent + relative_dir = os.path.relpath(os.path.dirname(input_path), common_parent) + output_path = os.path.join(self.output_dir, relative_dir, output_filename) + # Create directories if they don't exist + os.makedirs(os.path.dirname(output_path), exist_ok=True) + else: + # Create "converted" subdirectory in the output directory + converted_dir = os.path.join(self.output_dir, "converted") + os.makedirs(converted_dir, exist_ok=True) + output_path = os.path.join(converted_dir, output_filename) + + # Add to conversion tasks + conversion_tasks.append({ + 'index': i, + 'input_path': input_path, + 'output_path': output_path, + 'filename': filename, + 'total_files': total_files + }) + + # Use ThreadPoolExecutor for concurrent conversion + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit all tasks + future_to_task = {} + for task in conversion_tasks: + if self.is_cancelled: + error_msg = "Batch conversion was cancelled" + self.batch_error.emit(error_msg) + print(f"[ERROR] BatchConversionWorker: {error_msg}") + return + + try: + future = executor.submit(self._convert_single_file, task) + future_to_task[future] = task + except Exception as e: + error_msg = f"Error submitting task for {task['filename']}: {str(e)}" + self.batch_error.emit(error_msg) + print(f"[ERROR] BatchConversionWorker: {error_msg}") + # Continue with other tasks instead of failing completely + + # Process completed tasks + for future in as_completed(future_to_task): + if self.is_cancelled: + error_msg = "Batch conversion was cancelled" + self.batch_error.emit(error_msg) + print(f"[ERROR] BatchConversionWorker: {error_msg}") + executor.shutdown(wait=False, cancel_futures=True) + return + + task = future_to_task[future] + try: + success, message = future.result() + # Signal that this file was processed + self.file_processed.emit(task['filename'], task['input_path'], task['output_path'], success, message if not success else "") + except Exception as e: + # Signal that this file failed + error_msg = f"Error processing {task['filename']}: {str(e)}" + self.file_processed.emit(task['filename'], task['input_path'], task['output_path'], False, error_msg) + print(f"[ERROR] BatchConversionWorker: {error_msg}") + + # Update processed count and overall progress + processed_files += 1 + overall_progress = int((processed_files / total_files) * 100) + self.total_progress_updated.emit(overall_progress) + + # Final progress update + self.total_progress_updated.emit(100) + self.finished.emit() + + except Exception as e: + error_msg = f"Batch conversion error: {str(e)}" + self.batch_error.emit(error_msg) + print(f"[ERROR] BatchConversionWorker: {error_msg}") + + def _convert_single_file(self, task): + """Convert a single file (thread-safe)""" + if self.is_cancelled: + raise Exception("Batch conversion was cancelled") + + # Update file-specific progress + self.progress_updated.emit(task['index']+1, task['total_files'], task['filename'], 0) + + # Create a progress callback for this specific file + def progress_callback(*args): + """Handle variable number of arguments from progress_callback + Can be called with: + - (message, percentage) + - (current, total, message) + - (message, percentage, extra) + """ + # Determine the arguments format + if len(args) == 2: + # Format: (message, percentage) + message, percentage = args + elif len(args) == 3: + # Format: (current, total, message) or (message, percentage, extra) + if isinstance(args[0], (int, float)) and isinstance(args[1], (int, float)): + # Format: (current, total, message) + current, total, message = args + percentage = int((current / total) * 100) if total > 0 else 0 + else: + # Format: (message, percentage, extra) + message, percentage, _ = args + else: + # Unexpected format, use default values + message = "Processing..." if args else "Unknown progress" + percentage = 0 + + # Update the file-specific progress + self.progress_updated.emit(task['index']+1, task['total_files'], task['filename'], percentage) + + # Perform the conversion + if self.output_format == "icns": + # Auto-detect max size if enabled + current_max_size = int(self.max_size) if self.max_size is not None else None + if self.auto_detect_max_size: + try: + # Get image dimensions + width, height = convert.get_image_info(task['input_path']) + # Use the minimum of width and height as the max size (since ICNS uses square sizes) + current_max_size = min(width, height) + except Exception as e: + # Fall back to default if auto-detect fails + print(f"[WARNING] Failed to auto-detect max size for {task['filename']}: {str(e)}") + current_max_size = int(self.max_size) if self.max_size is not None else None + + success, message = convert.convert_image( + task['input_path'], + task['output_path'], + self.output_format, + int(self.min_size) if self.min_size is not None else 16, + current_max_size, + quality=self.quality, + progress_callback=progress_callback + ) + else: + # For non-icns formats, use keyword arguments for clarity and to avoid parameter order issues + success, message = convert.convert_image( + input_path=task['input_path'], + output_path=task['output_path'], + output_format=self.output_format, + quality=self.quality, + progress_callback=progress_callback + ) + + return success, message + + class ICNSConverterGUI(QMainWindow): def _load_qss_file(self, filename): @@ -139,12 +384,13 @@ def closeEvent(self, e): def _apply_theme(self, is_dark_mode): if is_dark_mode: self.setStyleSheet(self.DARK_QSS) - - else: self.setStyleSheet(self.LIGHT_QSS) - + # Update DropZoneWidget theme if it exists + if hasattr(self, 'drop_zone') and self.drop_zone: + self.drop_zone.set_theme(is_dark_mode) + # Update success view theme if it exists and is visible if hasattr(self, 'success_widget') and self.success_widget and self.success_widget.isVisible(): self._apply_success_theme() @@ -171,10 +417,12 @@ def load_settings(self): auto_preview_val = settings.value("image_converter/auto_preview", True, type=bool) remember_path_val = settings.value("image_converter/remember_path", True, type=bool) completion_notify_val = settings.value("image_converter/completion_notify", True, type=bool) + task_mode_val = settings.value("task_mode", False, type=bool) self.auto_preview = bool(auto_preview_val) if auto_preview_val is not None else True self.remember_path = bool(remember_path_val) if remember_path_val is not None else True self.completion_notify = bool(completion_notify_val) if completion_notify_val is not None else True + self.task_mode = bool(task_mode_val) if task_mode_val is not None else False # Load remembered paths if setting is enabled if self.remember_path: @@ -201,8 +449,10 @@ def load_settings(self): self.icns_method_combo.setCurrentText(str(self.icns_method)) if hasattr(self, 'overwrite_confirm_check'): self.overwrite_confirm_check.setChecked(bool(self.overwrite_confirm)) + if hasattr(self, 'task_mode_check'): + self.task_mode_check.setChecked(bool(self.task_mode)) - print(f"Settings loaded: min_size={self.min_size}, max_size={self.max_size}, output_format={self.output_format}") + print(f"Settings loaded: min_size={self.min_size}, max_size={self.max_size}, output_format={self.output_format}, task_mode={self.task_mode}") def save_settings(self): """Save settings to QSettings""" @@ -227,6 +477,12 @@ def save_settings(self): settings.setValue("image_converter/remember_path", self.remember_path) settings.setValue("image_converter/completion_notify", self.completion_notify) + # Save task mode setting + if hasattr(self, 'task_mode_check'): + settings.setValue("task_mode", self.task_mode_check.isChecked()) + else: + settings.setValue("task_mode", self.task_mode) + # Save remembered paths if setting is enabled if self.remember_path: if hasattr(self, 'last_input_dir'): @@ -260,6 +516,18 @@ def init_variables(self, reset_all=False): self.icns_method = "iconutil (Recommended)" self.overwrite_confirm = True + # Batch conversion variables + self.batch_files = [] + self.batch_converting = False + self.batch_worker = None + self.batch_thread = None + self.batch_current_index = 0 + self.batch_current_file = "" + self.batch_success_count = 0 + self.batch_failed_count = 0 + self.batch_canceled = False + self.batch_results = [] # Store conversion results + # Only reset interface behavior settings if explicitly requested if reset_all or not hasattr(self, 'auto_preview'): # Interface behavior settings - prioritize reading from launcher settings @@ -332,6 +600,7 @@ def setup_ui(self): self.create_widgets() self.create_success_view() + self.create_batch_success_view() self.create_history_tab() self.show_main_view() @@ -355,11 +624,44 @@ def create_widgets(self): # Main converter tab self.converter_tab = QWidget() - self.tab_widget.addTab(self.converter_tab, "Converter") + self.tab_widget.addTab(self.converter_tab, "Signle Converter") # Setup main converter content self.setup_converter_tab() + # Batch converter tab + self.batch_converter_tab = QWidget() + self.tab_widget.addTab(self.batch_converter_tab, "Batch Converter") + + # Setup batch converter content + self.setup_batch_converter_tab() + + # Preview tab - for image preview functionality + self.preview_tab = PreviewTab() + self.tab_widget.addTab(self.preview_tab, "Preview") + + # Connect drop signals to update preview tab + self._connect_preview_signals() + + def _connect_preview_signals(self): + """Connect signals to update preview tab""" + # Connect drop zone signals to preview tab + self.drop_zone.filesDropped.connect(self.preview_tab.show_multiple_previews) + self.drop_zone.folderDropped.connect(self.preview_tab.show_multiple_previews) + + # Connect input text change to preview tab + self.input_text.textChanged.connect(self.on_input_text_changed) + + def on_input_text_changed(self): + """Handle input text change to update preview""" + input_path = self.input_text.text().strip() + if input_path and os.path.exists(input_path): + # Single file change + self.preview_tab.show_single_preview(input_path) + else: + # Clear preview if invalid path + self.preview_tab.clear_previews() + def setup_converter_tab(self): """Setup the main converter tab content""" converter_layout = QVBoxLayout(self.converter_tab) @@ -415,19 +717,6 @@ def setup_converter_tab(self): output_layout.addWidget(output_button) file_ops_group_layout.addLayout(output_layout) - # Image Preview (moved from right panel) - preview_group_box = QGroupBox("Image Preview") - preview_group_layout = QVBoxLayout(preview_group_box) - preview_group_layout.setContentsMargins(10, 25, 10, 10) - left_layout.addWidget(preview_group_box, 1) # Give preview some stretch on the left - - self.preview_label = QLabel() - self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.preview_label.setFixedSize(250, 250) # Reduced fixed size for the preview area - self.preview_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Allow it to expand - self._set_placeholder_preview() - preview_group_layout.addWidget(self.preview_label, 1, Qt.AlignmentFlag.AlignCenter) - # Add stretch to left layout to push content to top left_layout.addStretch(1) @@ -453,15 +742,18 @@ def setup_converter_tab(self): # Conversion Options with TreeWidget for better organization options_group_box = QGroupBox("Conversion Options") options_group_layout = QVBoxLayout(options_group_box) - options_group_layout.setContentsMargins(10, 25, 10, 10) # Reset margins for options group - right_side_v_layout.addWidget(options_group_box, 6) # Give more stretch to options + options_group_layout.setContentsMargins(10, 10, 10, 10) # Reset margins for options group + options_group_box.setMinimumSize(250,500) + right_side_v_layout.addWidget(options_group_box, 8) # Give even more stretch to options # Create scroll area for TreeWidget - scroll_area = QScrollArea() + scroll_area = ScrollArea() scroll_area.setWidgetResizable(True) + #scroll_area.setMinimumSize(90,90) scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - scroll_area.setMinimumHeight(450) # Set minimum height for scroll area + # 使用更灵活的高度设置,允许根据内容自动调整 + scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Create TreeWidget for organized settings self.options_tree = TreeWidget() @@ -482,6 +774,12 @@ def setup_converter_tab(self): progress_group_layout.setContentsMargins(10, 25, 10, 10) left_layout.addWidget(progress_group_box, 0) # No stretch for progress, compact + # Task mode control + self.task_mode_check = CheckBox("Enable Task Mode") + self.task_mode_check.setChecked(False) + progress_group_layout.addWidget(self.task_mode_check) + progress_group_layout.addSpacing(10) + self.progress_label = QLabel("Ready") self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.progress = ProgressBar() @@ -490,7 +788,11 @@ def setup_converter_tab(self): progress_group_layout.addWidget(self.progress_label) progress_group_layout.addWidget(self.progress) - # Convert Button (moved before Progress) - Replace with PrimaryPushButton and apply custom style + # Convert and Cancel Button Layout + convert_control_layout = QHBoxLayout() + convert_control_layout.setSpacing(10) + + # Convert Button - Replace with PrimaryPushButton and apply custom style self.convert_button = PrimaryPushButton("Convert to ICNS") self.convert_button.setFixedSize(180, 40) font = self.convert_button.font() @@ -500,7 +802,26 @@ def setup_converter_tab(self): # Apply custom style to convert button setCustomStyleSheet(self.convert_button, CON.qss, CON.qss) self.convert_button.clicked.connect(self.on_start_conversion) - left_layout.addWidget(self.convert_button, 0, Qt.AlignmentFlag.AlignCenter) + + # Cancel Button + self.cancel_button = PushButton("Cancel") + self.cancel_button.setFixedSize(180, 40) + font = self.cancel_button.font() + font.setPointSize(font.pointSize() + 1) + self.cancel_button.setFont(font) + self.cancel_button.setEnabled(False) + # Apply custom style to cancel button + setCustomStyleSheet(self.cancel_button, CON.qss, CON.qss) + self.cancel_button.clicked.connect(self.on_cancel_conversion) + + convert_control_layout.addWidget(self.convert_button) + convert_control_layout.addWidget(self.cancel_button) + # Create a wrapper widget to center the button layout + button_wrapper = QWidget() + button_wrapper_layout = QVBoxLayout(button_wrapper) + button_wrapper_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + button_wrapper_layout.addLayout(convert_control_layout) + left_layout.addWidget(button_wrapper) @@ -513,6 +834,575 @@ def setup_converter_tab(self): # Add a stretch to the converter layout to push everything to the top converter_layout.addStretch(1) + def setup_batch_converter_tab(self): + """Setup the batch converter tab content""" + batch_layout = QVBoxLayout(self.batch_converter_tab) + + # Title for batch converter + batch_title_label = QLabel("Batch Image Converter") + batch_title_font = QFont() + batch_title_font.setPointSize(batch_title_label.font().pointSize() + 6) + batch_title_font.setBold(True) + batch_title_label.setFont(batch_title_font) + batch_title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + batch_layout.addWidget(batch_title_label, 0, Qt.AlignmentFlag.AlignHCenter) + + # Create horizontal layout for main content + main_batch_h_layout = QHBoxLayout() + main_batch_h_layout.setSpacing(20) + batch_layout.addLayout(main_batch_h_layout) + + # Left Panel: File Selection and Preview + left_batch_panel = QWidget() + left_batch_layout = QVBoxLayout(left_batch_panel) + left_batch_layout.setContentsMargins(15, 15, 15, 15) + left_batch_layout.setSpacing(15) + main_batch_h_layout.addWidget(left_batch_panel, 1) + + # Drop zone for files and folders + drop_group_box = QGroupBox("File Selection") + drop_group_layout = QVBoxLayout(drop_group_box) + drop_group_layout.setContentsMargins(10, 25, 10, 10) + drop_group_layout.setSpacing(15) + left_batch_layout.addWidget(drop_group_box) + + # Drop zone + self.drop_zone = DropZoneWidget() + self.drop_zone.setFixedHeight(160) + drop_group_layout.addWidget(self.drop_zone) + + # Browse button for manual file selection + browse_file_layout = QHBoxLayout() + self.browse_file_button = PushButton("Browse...") + self.browse_file_button.setText("Browse...") + setCustomStyleSheet(self.browse_file_button, CON.qss, CON.qss) + self.browse_file_button.clicked.connect(self.on_browse_batch_input) + + browse_file_layout.addStretch() + browse_file_layout.addWidget(self.browse_file_button) + drop_group_layout.addLayout(browse_file_layout) + + # Connect drop signals + self.drop_zone.filesDropped.connect(self.on_batch_files_dropped) + self.drop_zone.folderDropped.connect(self.on_batch_folder_dropped) + + # Output Directory Selection (moved here) + output_dir_group_box = QGroupBox("Output Directory") + output_dir_group_layout = QVBoxLayout(output_dir_group_box) + output_dir_group_layout.setContentsMargins(10, 25, 10, 10) + output_dir_group_layout.setSpacing(10) + left_batch_layout.addWidget(output_dir_group_box, 0) + + # Batch output directory layout with drag-drop support + batch_output_layout = QHBoxLayout() + batch_output_label = QLabel("📁 Output Directory:") + self.batch_output_text = DirectoryDropLineEdit() # 使用新的拖拽支持输入框 + self.batch_output_text.setPlaceholderText("Same as input files directory") + setCustomStyleSheet(self.batch_output_text, CON.qss_line, CON.qss_line) + + batch_output_button = PushButton("Browse...") + setCustomStyleSheet(batch_output_button, CON.qss, CON.qss) + batch_output_button.clicked.connect(self.on_browse_batch_output) + + batch_output_layout.addWidget(batch_output_label) + batch_output_layout.addWidget(self.batch_output_text, 1) + batch_output_layout.addWidget(batch_output_button) + output_dir_group_layout.addLayout(batch_output_layout) + + # File list with remove functionality + file_list_group_box = QGroupBox("Selected Files") + file_list_group_layout = QVBoxLayout(file_list_group_box) + file_list_group_layout.setContentsMargins(10, 25, 10, 10) + left_batch_layout.addWidget(file_list_group_box, 1) + + # File list with scroll area + file_scroll_area = ScrollArea() + file_scroll_area.setWidgetResizable(True) + file_scroll_area.setMinimumHeight(200) + file_scroll_area.setMaximumHeight(300) + + self.file_list_widget = ListWidget() + file_scroll_area.setWidget(self.file_list_widget) + file_list_group_layout.addWidget(file_scroll_area) + + # File management buttons + file_mgmt_layout = QHBoxLayout() + self.clear_files_btn = PushButton("Clear All") + self.remove_selected_btn = PushButton("Remove Selected") + setCustomStyleSheet(self.clear_files_btn, CON.qss, CON.qss) + setCustomStyleSheet(self.remove_selected_btn, CON.qss, CON.qss) + + self.clear_files_btn.clicked.connect(self.clear_batch_files) + self.remove_selected_btn.clicked.connect(self.remove_selected_files) + + file_mgmt_layout.addWidget(self.clear_files_btn) + file_mgmt_layout.addWidget(self.remove_selected_btn) + file_list_group_layout.addLayout(file_mgmt_layout) + + # Preview area removed - now in dedicated Preview tab + + # Right Panel: Options and Progress + right_batch_panel = QWidget() + right_batch_layout = QVBoxLayout(right_batch_panel) + right_batch_layout.setContentsMargins(15, 15, 15, 15) + right_batch_layout.setSpacing(15) + main_batch_h_layout.addWidget(right_batch_panel, 1) + + # Batch options (reusing existing options tree) + options_group_box = QGroupBox("Conversion Options") + options_group_layout = QVBoxLayout(options_group_box) + options_group_layout.setContentsMargins(10, 25, 10, 10) + right_batch_layout.addWidget(options_group_box, 10) + + # Create scroll area for options + batch_scroll_area = ScrollArea() + batch_scroll_area.setWidgetResizable(True) + # 使用更灵活的尺寸策略 + batch_scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self.batch_options_tree = TreeWidget() + self.batch_options_tree.setHeaderHidden(True) + self.batch_options_tree.setRootIsDecorated(True) + self.batch_options_tree.setIndentation(20) + + batch_scroll_area.setWidget(self.batch_options_tree) + options_group_layout.addWidget(batch_scroll_area) + + # Setup batch options tree (reuse existing option structure) + self._setup_batch_options_tree() + + # Batch Progress & Results section + progress_results_group_box = QGroupBox("Batch Progress & Results") + progress_results_layout = QVBoxLayout(progress_results_group_box) + progress_results_layout.setContentsMargins(10, 25, 10, 10) + progress_results_layout.setSpacing(15) + right_batch_layout.addWidget(progress_results_group_box, 2) + + # Overall progress + self.batch_progress_label = QLabel("Ready") + self.batch_progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.batch_progress = ProgressBar() + self.batch_progress.setRange(0, 100) + self.batch_progress.setValue(0) + + progress_results_layout.addWidget(self.batch_progress_label) + progress_results_layout.addWidget(self.batch_progress) + + # Current file progress + self.current_file_label = QLabel("No file processing") + self.current_file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.current_file_progress = ProgressBar() + self.current_file_progress.setRange(0, 100) + self.current_file_progress.setValue(0) + + progress_results_layout.addWidget(self.current_file_label) + progress_results_layout.addWidget(self.current_file_progress) + + # Separator line + separator = QFrame() + separator.setFrameShape(QFrame.HLine) + separator.setFrameShadow(QFrame.Sunken) + progress_results_layout.addWidget(separator) + + # Results display with statistics + results_layout = QVBoxLayout() + results_layout.setSpacing(10) + + # Statistics + stats_layout = QHBoxLayout() + self.success_count_label = QLabel("Success: 0") + self.failed_count_label = QLabel("Failed: 0") + self.total_count_label = QLabel("Total: 0") + stats_layout.addWidget(self.success_count_label) + stats_layout.addWidget(self.failed_count_label) + stats_layout.addWidget(self.total_count_label) + stats_layout.addStretch(1) + results_layout.addLayout(stats_layout) + + # Results list with scroll + results_scroll_area = ScrollArea() + results_scroll_area.setWidgetResizable(True) + results_scroll_area.setMinimumHeight(150) + + self.results_list_widget = ListWidget() + results_scroll_area.setWidget(self.results_list_widget) + results_layout.addWidget(results_scroll_area) + + progress_results_layout.addLayout(results_layout) + + # Batch control buttons + batch_control_layout = QHBoxLayout() + # Initialize with default format text + batch_output_format = self.batch_format_combo.currentText().lower() if hasattr(self, 'batch_format_combo') else 'icns' + self.start_batch_btn = PrimaryPushButton(f"Convert to {batch_output_format.upper()}") + self.stop_batch_btn = PushButton("Stop") + self.stop_batch_btn.setEnabled(False) + + setCustomStyleSheet(self.start_batch_btn, CON.qss, CON.qss) + setCustomStyleSheet(self.stop_batch_btn, CON.qss, CON.qss) + + self.start_batch_btn.clicked.connect(self.start_batch_conversion) + self.stop_batch_btn.clicked.connect(self.stop_batch_conversion) + + batch_control_layout.addWidget(self.start_batch_btn) + batch_control_layout.addWidget(self.stop_batch_btn) + right_batch_layout.addLayout(batch_control_layout) + + # Add stretch to push content to top + right_batch_layout.addStretch(1) + + # Add stretch to push content to top-left + batch_layout.addStretch(1) + + # Initialize batch variables + self.batch_files = [] + self.batch_converting = False + + def _setup_batch_options_tree(self): + """Setup the TreeWidget with organized settings for batch conversion (same structure as single conversion)""" + # Clear existing items + self.batch_options_tree.clear() + + # Create main categories with icons for better visual hierarchy (same as single conversion) + basic_item = QTreeWidgetItem(["📋 Basic Options"]) + processing_item = QTreeWidgetItem(["🎨 Image Processing"]) + output_item = QTreeWidgetItem(["📤 Output Options"]) + advanced_item = QTreeWidgetItem(["⚙️ Advanced Settings"]) + + # Add to tree + self.batch_options_tree.addTopLevelItem(basic_item) + self.batch_options_tree.addTopLevelItem(processing_item) + self.batch_options_tree.addTopLevelItem(output_item) + self.batch_options_tree.addTopLevelItem(advanced_item) + + # Set font for categories to make them stand out (same as single conversion) + bold_font = self.batch_options_tree.font() + bold_font.setBold(True) + bold_font.setPointSize(bold_font.pointSize() + 1) + + basic_item.setFont(0, bold_font) + processing_item.setFont(0, bold_font) + output_item.setFont(0, bold_font) + advanced_item.setFont(0, bold_font) + + # Set background colors for categories (same as single conversion) + basic_item.setBackground(0, QColor(240, 248, 255, 80)) # Light blue + processing_item.setBackground(0, QColor(240, 255, 240, 80)) # Light green + output_item.setBackground(0, QColor(255, 250, 205, 80)) # Light yellow + advanced_item.setBackground(0, QColor(255, 248, 240, 80)) # Light orange + + # Basic Options (same structure as single conversion) + self._create_batch_basic_options(basic_item) + + # Processing Options (same structure as single conversion) + self._create_batch_processing_options(processing_item) + + # Output Options (new) + self._create_batch_output_options(output_item) + + # Advanced Options (same structure as single conversion) + self._create_batch_advanced_options(advanced_item) + + # Set expansion state (same as single conversion) + basic_item.setExpanded(True) + processing_item.setExpanded(True) + output_item.setExpanded(True) + advanced_item.setExpanded(False) # Hidden by default + + # Connect tree signals (same as single conversion) + self.batch_options_tree.itemExpanded.connect(self._on_batch_tree_item_expanded) + self.batch_options_tree.itemCollapsed.connect(self._on_batch_tree_item_collapsed) + + def _create_batch_basic_options(self, parent_item): + """Create basic options widgets for batch conversion with responsive layout""" + # Output Format + format_widget = QWidget() + # 使用更灵活的尺寸策略 + format_widget.setMinimumHeight(55) + format_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + format_layout = QHBoxLayout(format_widget) + format_layout.setContentsMargins(5, 8, 5, 8) + + format_label = QLabel("🗂️ Output Format:") + format_label.setMinimumWidth(120) # Ensure consistent width for labels + + self.batch_format_combo = ModelComboBox() + self.batch_format_combo.addItems(convert.SUPPORTED_FORMATS) + self.batch_format_combo.currentIndexChanged.connect(self.on_batch_format_change) + setCustomStyleSheet(self.batch_format_combo, CON.qss_combo, CON.qss_combo) + + format_layout.addWidget(format_label) + format_layout.addWidget(self.batch_format_combo, 1) # Give combo box more stretch + + format_item = QTreeWidgetItem() + parent_item.addChild(format_item) + self.batch_options_tree.setItemWidget(format_item, 0, format_widget) + + # Size Options (grouped in a sub-item) + size_item = QTreeWidgetItem(["📏 Size Options"]) + parent_item.addChild(size_item) + + # Minimum Size + min_size_widget = QWidget() + min_size_layout = QHBoxLayout(min_size_widget) + min_size_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item + # 使用更灵活的尺寸策略 + min_size_widget.setMinimumHeight(45) + min_size_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + min_size_label = QLabel("Min Size:") + min_size_label.setMinimumWidth(80) + self.batch_min_spin = SpinBox() + setCustomStyleSheet(self.batch_min_spin, CON.qss_spin, CON.qss_spin) + self.batch_min_spin.setRange(16, 512) + self.batch_min_spin.setValue(self.min_size) + self.batch_min_spin.setSuffix(" px") # Add unit suffix + self.batch_min_spin.valueChanged.connect(self.on_batch_min_size_change) + + min_size_layout.addWidget(min_size_label) + min_size_layout.addWidget(self.batch_min_spin, 1) + + min_size_sub_item = QTreeWidgetItem() + size_item.addChild(min_size_sub_item) + self.batch_options_tree.setItemWidget(min_size_sub_item, 0, min_size_widget) + + # Maximum Size + max_size_widget = QWidget() + max_size_layout = QHBoxLayout(max_size_widget) + max_size_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item + # 使用更灵活的尺寸策略 + max_size_widget.setMinimumHeight(45) + max_size_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + max_size_label = QLabel("Max Size:") + max_size_label.setMinimumWidth(80) + self.batch_max_spin = SpinBox() + setCustomStyleSheet(self.batch_max_spin, CON.qss_spin, CON.qss_spin) + self.batch_max_spin.setRange(32, 1024) + self.batch_max_spin.setValue(self.max_size) + self.batch_max_spin.setSuffix(" px") # Add unit suffix + self.batch_max_spin.valueChanged.connect(self.on_batch_max_size_change) + + max_size_layout.addWidget(max_size_label) + max_size_layout.addWidget(self.batch_max_spin, 1) + + max_size_sub_item = QTreeWidgetItem() + size_item.addChild(max_size_sub_item) + self.batch_options_tree.setItemWidget(max_size_sub_item, 0, max_size_widget) + + # Auto-detect max size checkbox + auto_detect_widget = QWidget() + auto_detect_layout = QHBoxLayout(auto_detect_widget) + auto_detect_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item + # 使用更灵活的尺寸策略 + auto_detect_widget.setMinimumHeight(45) + auto_detect_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + self.batch_auto_detect_check = CheckBox("🔍 Auto-detect max size for each image") + self.batch_auto_detect_check.stateChanged.connect(self.on_batch_auto_detect_toggle) + auto_detect_layout.addWidget(self.batch_auto_detect_check) + + auto_detect_sub_item = QTreeWidgetItem() + size_item.addChild(auto_detect_sub_item) + self.batch_options_tree.setItemWidget(auto_detect_sub_item, 0, auto_detect_widget) + + # Auto-detect button (disabled for now, will be removed later) + # auto_widget = QWidget() + # auto_layout = QHBoxLayout(auto_widget) + # auto_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item + # # 使用更灵活的尺寸策略 + # auto_widget.setMinimumHeight(40) + # auto_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + # + # self.batch_auto_button = PrimaryPushButton("🔍 Auto-detect Max Size") + # setCustomStyleSheet(self.batch_auto_button, CON.qss_debug, CON.qss_debug) + # self.batch_auto_button.clicked.connect(self.on_batch_auto_detect) + # auto_layout.addWidget(self.batch_auto_button) + # + # auto_sub_item = QTreeWidgetItem() + # size_item.addChild(auto_sub_item) + # self.batch_options_tree.setItemWidget(auto_sub_item, 0, auto_widget) + + # Expand size options by default + size_item.setExpanded(True) + + def _create_batch_processing_options(self, parent_item): + """Create image processing options widgets for batch conversion (same as single conversion)""" + # Keep aspect ratio + aspect_widget = QWidget() + aspect_layout = QHBoxLayout(aspect_widget) + aspect_layout.setContentsMargins(5, 8, 5, 8) + # 使用更灵活的尺寸策略 + aspect_widget.setMinimumHeight(45) + aspect_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.batch_keep_aspect_check = CheckBox("📐 Maintain original aspect ratio") + self.batch_keep_aspect_check.setChecked(self.keep_aspect_ratio) + self.batch_keep_aspect_check.stateChanged.connect(self.on_batch_keep_aspect_changed) + aspect_layout.addWidget(self.batch_keep_aspect_check) + + aspect_item = QTreeWidgetItem() + parent_item.addChild(aspect_item) + self.batch_options_tree.setItemWidget(aspect_item, 0, aspect_widget) + + # Auto crop + crop_widget = QWidget() + crop_layout = QHBoxLayout(crop_widget) + crop_layout.setContentsMargins(5, 8, 5, 8) + # 使用更灵活的尺寸策略 + crop_widget.setMinimumHeight(45) + crop_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.batch_auto_crop_check = CheckBox("✂️ Auto-crop non-square to square") + self.batch_auto_crop_check.setChecked(self.auto_crop) + self.batch_auto_crop_check.stateChanged.connect(self.on_batch_auto_crop_changed) + crop_layout.addWidget(self.batch_auto_crop_check) + crop_item = QTreeWidgetItem() + parent_item.addChild(crop_item) + self.batch_options_tree.setItemWidget(crop_item, 0, crop_widget) + + # Quality slider + quality_widget = QWidget() + quality_layout = QHBoxLayout(quality_widget) + quality_layout.setContentsMargins(5, 8, 5, 8) + # 使用更灵活的尺寸策略 + quality_widget.setMinimumHeight(55) + quality_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + quality_label = QLabel("🎨 Quality:") + quality_label.setMinimumWidth(100) # Ensure consistent width for labels + quality_layout.addWidget(quality_label) + + self.batch_quality_slider = Slider(Qt.Orientation.Horizontal) + self.batch_quality_slider.setRange(1, 100) + self.batch_quality_slider.setValue(self.quality) + self.batch_quality_slider.valueChanged.connect(self.on_batch_quality_changed) + quality_layout.addWidget(self.batch_quality_slider, 1) # Give slider more stretch + + # Use read-only QLabel for quality value, same as single conversion + self.batch_quality_label = QLabel(str(self.quality)) + self.batch_quality_label.setMinimumWidth(30) # Ensure consistent width for label + self.batch_quality_label.setMaximumWidth(60) # Limit width + self.batch_quality_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + quality_layout.addWidget(self.batch_quality_label) + + quality_item = QTreeWidgetItem() + parent_item.addChild(quality_item) + self.batch_options_tree.setItemWidget(quality_item, 0, quality_widget) + + def _create_batch_output_options(self, parent_item): + """Create batch output options widgets""" + # Output Structure options + output_structure_item = QTreeWidgetItem(["📁 Output Structure Options"]) + parent_item.addChild(output_structure_item) + + # Preserve folder structure + preserve_folder_widget = QWidget() + preserve_folder_layout = QHBoxLayout(preserve_folder_widget) + preserve_folder_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item + # 使用更灵活的尺寸策略 + preserve_folder_widget.setMinimumHeight(45) + preserve_folder_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + self.batch_preserve_folder_check = CheckBox("Preserve original folder structure") + self.batch_preserve_folder_check.setChecked(False) + preserve_folder_layout.addWidget(self.batch_preserve_folder_check) + + preserve_folder_sub_item = QTreeWidgetItem() + output_structure_item.addChild(preserve_folder_sub_item) + self.batch_options_tree.setItemWidget(preserve_folder_sub_item, 0, preserve_folder_widget) + + # Filename modification options + filename_mod_item = QTreeWidgetItem(["📝 Filename Modification"]) + parent_item.addChild(filename_mod_item) + + # Prefix + prefix_widget = QWidget() + prefix_layout = QHBoxLayout(prefix_widget) + prefix_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item + # 使用更灵活的尺寸策略 + prefix_widget.setMinimumHeight(45) + prefix_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + prefix_label = QLabel("Prefix:") + prefix_label.setMinimumWidth(80) + self.batch_prefix_edit = LineEdit() + self.batch_prefix_edit.setPlaceholderText("Add prefix to filenames") + prefix_layout.addWidget(prefix_label) + prefix_layout.addWidget(self.batch_prefix_edit, 1) + + prefix_sub_item = QTreeWidgetItem() + filename_mod_item.addChild(prefix_sub_item) + self.batch_options_tree.setItemWidget(prefix_sub_item, 0, prefix_widget) + + # Suffix + suffix_widget = QWidget() + suffix_layout = QHBoxLayout(suffix_widget) + suffix_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item + # 使用更灵活的尺寸策略 + suffix_widget.setMinimumHeight(45) + suffix_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + suffix_label = QLabel("Suffix:") + suffix_label.setMinimumWidth(80) + self.batch_suffix_edit = LineEdit() + self.batch_suffix_edit.setPlaceholderText("Add suffix to filenames") + suffix_layout.addWidget(suffix_label) + suffix_layout.addWidget(self.batch_suffix_edit, 1) + + suffix_sub_item = QTreeWidgetItem() + filename_mod_item.addChild(suffix_sub_item) + self.batch_options_tree.setItemWidget(suffix_sub_item, 0, suffix_widget) + + # Expand output structure options by default + output_structure_item.setExpanded(True) + filename_mod_item.setExpanded(True) + + def _create_batch_advanced_options(self, parent_item): + """Create advanced options widgets for batch conversion (same as single conversion)""" + # ICNS method + method_widget = QWidget() + method_layout = QHBoxLayout(method_widget) + method_layout.setContentsMargins(5, 8, 5, 8) + # 使用更灵活的尺寸策略 + method_widget.setMinimumHeight(55) + method_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + method_label = QLabel("⚙️ ICNS method:") + method_label.setMinimumWidth(120) # Ensure consistent width for labels + method_layout.addWidget(method_label) + + self.batch_icns_method_combo = ModelComboBox() + self.batch_icns_method_combo.addItems(["iconutil (Recommended)", "Pillow Fallback"]) + self.batch_icns_method_combo.setCurrentText(self.icns_method) + self.batch_icns_method_combo.currentTextChanged.connect(self.on_batch_icns_method_changed) + setCustomStyleSheet(self.batch_icns_method_combo, CON.qss_combo, CON.qss_combo) + method_layout.addWidget(self.batch_icns_method_combo, 1) # Give combo box more stretch + + method_item = QTreeWidgetItem() + parent_item.addChild(method_item) + self.batch_options_tree.setItemWidget(method_item, 0, method_widget) + + # Overwrite confirmation + overwrite_widget = QWidget() + overwrite_layout = QHBoxLayout(overwrite_widget) + overwrite_layout.setContentsMargins(5, 8, 5, 8) + # 使用更灵活的尺寸策略 + overwrite_widget.setMinimumHeight(45) + overwrite_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.batch_overwrite_confirm_check = CheckBox("⚠️ Confirm before overwriting files") + self.batch_overwrite_confirm_check.setChecked(self.overwrite_confirm) + self.batch_overwrite_confirm_check.stateChanged.connect(self.on_batch_overwrite_confirm_changed) + overwrite_layout.addWidget(self.batch_overwrite_confirm_check) + + overwrite_item = QTreeWidgetItem() + parent_item.addChild(overwrite_item) + self.batch_options_tree.setItemWidget(overwrite_item, 0, overwrite_widget) + + def _update_batch_basic_options(self): + """Update batch basic options display""" + # Find and update the basic options item + basic_item = self.batch_options_tree.topLevelItem(0) + if basic_item and basic_item.text(0) == "Basic Options": + basic_item.setText(0, f"Basic Options (Min: {self.batch_min_spin.value()}px, Max: {self.batch_max_spin.value()}px, Format: {self.batch_format_combo.currentText()})") + def create_history_tab(self): """Create the history tab for conversion history""" if not self.remember_path: @@ -724,10 +1614,12 @@ def _setup_options_tree(self): self.options_tree.itemCollapsed.connect(self._on_tree_item_collapsed) def _create_basic_options(self, parent_item): - """Create basic options widgets""" + """Create basic options widgets with responsive layout""" # Output Format format_widget = QWidget() - format_widget.setMinimumSize(300, 55) + # 使用更灵活的尺寸策略而不是固定最小尺寸 + format_widget.setMinimumHeight(55) + format_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) format_layout = QHBoxLayout(format_widget) format_layout.setContentsMargins(5, 8, 5, 8) @@ -811,12 +1703,14 @@ def _create_basic_options(self, parent_item): size_item.setExpanded(True) def _create_processing_options(self, parent_item): - """Create image processing options widgets""" + """Create image processing options widgets with responsive layout""" # Keep aspect ratio aspect_widget = QWidget() aspect_layout = QHBoxLayout(aspect_widget) aspect_layout.setContentsMargins(5, 8, 5, 8) - aspect_widget.setMinimumSize(300, 45) + # 使用更灵活的尺寸策略 + aspect_widget.setMinimumHeight(45) + aspect_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.keep_aspect_check = CheckBox("📐 Maintain original aspect ratio") self.keep_aspect_check.stateChanged.connect(self.on_keep_aspect_changed) aspect_layout.addWidget(self.keep_aspect_check) @@ -861,12 +1755,14 @@ def _create_processing_options(self, parent_item): self.options_tree.setItemWidget(quality_item, 0, quality_widget) def _create_advanced_options(self, parent_item): - """Create advanced options widgets""" - # ICNS method + """Create advanced settings widgets with responsive layout""" + # ICNS method selection method_widget = QWidget() method_layout = QHBoxLayout(method_widget) method_layout.setContentsMargins(5, 8, 5, 8) - method_widget.setMinimumSize(300, 55) + # 使用更灵活的尺寸策略 + method_widget.setMinimumHeight(55) + method_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) method_label = QLabel("⚙️ ICNS method:") method_label.setMinimumWidth(120) # Ensure consistent width for labels @@ -896,69 +1792,9 @@ def _create_advanced_options(self, parent_item): self.options_tree.setItemWidget(overwrite_item, 0, overwrite_widget) def on_tab_changed(self, index): - """Handle tab change with optional slide animation effect based on UI_FLUENT environment variable""" - import sys - import os - sys.path.append(os.path.join(os.path.dirname(__file__), 'support')) - from support.check_flag import check_flag - - # Check if UI_FLUENT environment variable is set to YES using check_flag function - ui_fluent_enabled = check_flag("UI_FLUENT") - - # Skip animation if UI_FLUENT is not enabled - if not ui_fluent_enabled: - self._previous_tab_index = index - return - - # Proceed with animation if UI_FLUENT is enabled - from PySide6.QtCore import QPropertyAnimation, QEasingCurve, QRect - - # Get current tab widget - current_widget = self.tab_widget.currentWidget() - if not current_widget: - return - - # Skip animation during initial startup to prevent layout issues - if not hasattr(self, '_previous_tab_index') and not self.tab_widget.isVisible(): - self._previous_tab_index = index - return - - # Get tab widget dimensions - tab_width = self.tab_widget.width() - tab_height = self.tab_widget.height() - - # Skip animation if window is not yet properly sized - if tab_width <= 0 or tab_height <= 0: - self._previous_tab_index = index - return - - # Determine slide direction based on tab index - if hasattr(self, '_previous_tab_index'): - if index > self._previous_tab_index: - # Sliding from right to left - start from 80% of width to prevent going out of bounds - start_pos = QRect(int(tab_width * 0.8), 0, tab_width, tab_height) - else: - # Sliding from left to right - start from -80% of width to prevent going out of bounds - start_pos = QRect(int(-tab_width * 0.8), 0, tab_width, tab_height) - else: - # First time, slide from right - start from 80% of width - start_pos = QRect(int(tab_width * 0.8), 0, tab_width, tab_height) - - # Set initial position - current_widget.setGeometry(start_pos) - - # Create slide animation - self.slide_animation = QPropertyAnimation(current_widget, b"geometry") - self.slide_animation.setDuration(300) # 300ms animation for smooth slide - self.slide_animation.setStartValue(start_pos) - self.slide_animation.setEndValue(QRect(0, 0, tab_width, tab_height)) - self.slide_animation.setEasingCurve(QEasingCurve.Type.OutCubic) - - # Store current tab index for next animation + """Handle tab change without animation""" + # Simply store the previous tab index and return self._previous_tab_index = index - - # Start the animation - self.slide_animation.start() def _on_tree_item_expanded(self, item): """Handle tree item expansion""" @@ -1048,18 +1884,177 @@ def create_success_view(self): self.success_widget.hide() # Initially hidden + def create_batch_success_view(self): + """Create a specialized batch conversion success view""" + # Create batch success widget as a top-level overlay + self.batch_success_widget = QWidget(self) + self.batch_success_widget.setObjectName("success_overlay") + + # Set it to cover the entire window + self.batch_success_widget.setGeometry(self.rect()) + + # Create layout for batch success widget + self.batch_success_layout = QVBoxLayout(self.batch_success_widget) + self.batch_success_layout.setContentsMargins(0, 0, 0, 0) + + # Create a semi-transparent overlay background + overlay = QWidget() + overlay.setObjectName("success_overlay") + overlay_layout = QVBoxLayout(overlay) + overlay_layout.setContentsMargins(0, 0, 0, 0) + + center_panel = QWidget() + center_panel.setObjectName("success_center_panel") + center_layout = QVBoxLayout(center_panel) + center_layout.setContentsMargins(50, 50, 50, 50) # Increased margins + center_layout.setSpacing(20) + + # Add stretch to center the panel vertically + overlay_layout.addStretch() + overlay_layout.addWidget(center_panel, 0, Qt.AlignmentFlag.AlignCenter) + overlay_layout.addStretch() + + self.batch_success_layout.addWidget(overlay) + + center_layout.addStretch() + + # Batch success title + title = QLabel("Batch Conversion Complete!") + font = title.font() + font.setPointSize(font.pointSize() + 8) # Larger title + font.setBold(True) + title.setFont(font) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + title.setObjectName("success_title_label") + center_layout.addWidget(title) + + # Batch checkmark with check symbol + checkmark = QLabel("✓") + checkmark_font = checkmark.font() + checkmark_font.setPointSize(checkmark_font.pointSize() + 30) # Larger checkmark + checkmark_font.setBold(True) + checkmark.setFont(checkmark_font) + checkmark.setObjectName("success_checkmark_label") + checkmark.setAlignment(Qt.AlignmentFlag.AlignCenter) + center_layout.addWidget(checkmark) + + # Dynamic message - will be updated with actual batch stats + self.batch_success_message = QLabel("Processing batch conversion results...") + self.batch_success_message.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.batch_success_message.setObjectName("success_message_label") + center_layout.addWidget(self.batch_success_message) + + # Batch statistics display + stats_widget = QWidget() + stats_layout = QVBoxLayout(stats_widget) + stats_layout.setSpacing(10) + + self.batch_file_count_label = QLabel("Files Processed: 0") + self.batch_file_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.batch_file_count_label.setObjectName("batch_file_count_label") + stats_layout.addWidget(self.batch_file_count_label) + + self.batch_success_count_label = QLabel("Successfully Converted: 0") + self.batch_success_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.batch_success_count_label.setObjectName("batch_success_count_label") + stats_layout.addWidget(self.batch_success_count_label) + + self.batch_failed_count_label = QLabel("Failed Conversions: 0") + self.batch_failed_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.batch_failed_count_label.setObjectName("batch_failed_count_label") + stats_layout.addWidget(self.batch_failed_count_label) + + self.batch_output_dir_label = QLabel("Output Directory: ") + self.batch_output_dir_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.batch_output_dir_label.setWordWrap(True) + self.batch_output_dir_label.setObjectName("batch_output_dir_label") + stats_layout.addWidget(self.batch_output_dir_label) + + center_layout.addWidget(stats_widget) + + # Button layout + button_layout = QHBoxLayout() + + # Open output folder button + open_folder_btn = PrimaryPushButton("Open Output Folder") + open_folder_btn.clicked.connect(self.on_open_batch_output_folder) + open_folder_btn.setFixedSize(200, 45) # Larger buttons + open_folder_btn.setObjectName("open_converted_file_button") # Object name for QSS + setCustomStyleSheet(open_folder_btn, CON.qss, CON.qss) + button_layout.addWidget(open_folder_btn, 0, Qt.AlignmentFlag.AlignCenter) + + # Return to converter button + return_btn = PushButton("Return to Converter") + return_btn.clicked.connect(self.show_main_view) + return_btn.setFixedSize(200, 45) # Larger buttons + return_btn.setObjectName("return_to_converter_button") # Object name for QSS + setCustomStyleSheet(return_btn, CON.qss, CON.qss) + button_layout.addWidget(return_btn, 0, Qt.AlignmentFlag.AlignCenter) + + center_layout.addLayout(button_layout) + center_layout.addStretch() + + self.batch_success_widget.hide() # Initially hidden + + def show_batch_success_view(self, total_files=0, success_count=0, failed_count=0, output_dir="", format_name="PNG"): + """Show the batch success view with dynamic statistics""" + # Update the batch success widget geometry to match the window + self.batch_success_widget.setGeometry(self.rect()) + + # Update dynamic content + if hasattr(self, 'batch_success_message'): + if failed_count == 0: + self.batch_success_message.setText(f"All {total_files} {format_name.upper()} files converted successfully!") + elif success_count == 0: + self.batch_success_message.setText(f"Batch conversion failed: {failed_count} files could not be converted") + else: + self.batch_success_message.setText(f"Batch conversion completed: {success_count}/{total_files} files converted successfully") + + if hasattr(self, 'batch_file_count_label'): + self.batch_file_count_label.setText(f"Files Processed: {total_files}") + + if hasattr(self, 'batch_success_count_label'): + self.batch_success_count_label.setText(f"Successfully Converted: {success_count}") + + if hasattr(self, 'batch_failed_count_label'): + if failed_count > 0: + self.batch_failed_count_label.setText(f"Failed Conversions: {failed_count}") + self.batch_failed_count_label.show() + else: + # Hide failed count when it's 0 + self.batch_failed_count_label.hide() + + if hasattr(self, 'batch_output_dir_label'): + self.batch_output_dir_label.setText(f"Output Directory: {output_dir}") + + # Show the batch success widget as an overlay + self.batch_success_widget.show() + self.batch_success_widget.raise_() # Bring to front + + # Apply theme-specific styles + self._apply_success_theme() + + def on_open_batch_output_folder(self): + """Open the batch output folder in the file explorer""" + output_dir = self.output_dir_input.text().strip() + if output_dir and os.path.exists(output_dir): + try: + if sys.platform == "win32": + os.startfile(output_dir) + elif sys.platform == "darwin": + subprocess.run(["open", output_dir]) + else: # Linux and other POSIX systems + subprocess.run(["xdg-open", output_dir]) + except Exception as e: + QMessageBox.warning(self, "Error", f"Could not open folder: {str(e)}") + else: + QMessageBox.warning(self, "Error", "Output directory not found") + def _set_placeholder_preview(self): - placeholder_text = "Drag and drop image here\nor click 'Browse...' to select file\n🖼️" - font = QFont() - font.setPointSize(16) # Larger font for placeholder - self.preview_label.setFont(font) - self.preview_label.setText(placeholder_text) - self.preview_label.setPixmap(QPixmap()) # Clear any previous image - - # Enable drag and drop for the preview label - self.preview_label.setAcceptDrops(True) - self.preview_label.dragEnterEvent = self.dragEnterEvent - self.preview_label.dropEvent = self.dropEvent + """Set placeholder text in the preview tab""" + # Clear existing previews and show default info + self.preview_tab.clear_previews() + def on_browse_input(self): file_dialog = QFileDialog(self) @@ -1131,6 +2126,41 @@ def on_browse_output(self): # Remember the directory for next time if setting is enabled if self.remember_path: self.last_output_dir = os.path.dirname(self.output_path) + + def on_browse_batch_input(self): + """Browse for batch input files""" + file_dialog = QFileDialog(self) + file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles) + file_dialog.setNameFilter("Supported Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp *.ico *.tiff);;All Files (*)") + file_dialog.setViewMode(QFileDialog.ViewMode.Detail) + + # Use last directory if available + if hasattr(self, 'last_batch_input_dir') and self.last_batch_input_dir: + file_dialog.setDirectory(self.last_batch_input_dir) + + if file_dialog.exec() == QFileDialog.DialogCode.Accept: + selected_files = file_dialog.selectedFiles() + if selected_files: + # Remember this directory for future use + self.last_batch_input_dir = os.path.dirname(selected_files[0]) + + # Add files to batch file list + for file_path in selected_files: + self._add_file_to_batch_list(file_path) + + def on_browse_batch_output(self): + """Browse for batch output directory""" + directory = QFileDialog.getExistingDirectory( + self, + "Select Output Directory for Batch Conversion", + self.batch_output_text.text() if self.batch_output_text.text() else os.path.expanduser("~"), + QFileDialog.Option.ShowDirsOnly + ) + + if directory: + self.batch_output_text.setText(directory) + # Remember this directory for future use + self.last_batch_output_dir = directory def on_format_change(self, index): self.output_format = self.format_combo.currentText().lower() @@ -1147,6 +2177,28 @@ def on_format_change(self, index): self.max_spin.setEnabled(enable_size_options) self.auto_button.setEnabled(enable_size_options) + def on_batch_format_change(self, index): + """Handle format change in batch conversion""" + batch_output_format = self.batch_format_combo.currentText().lower() + + # Update batch conversion button text to match format + if hasattr(self, 'start_batch_btn'): + self.start_batch_btn.setText(f"Convert to {batch_output_format.upper()}") + + # Enable/disable auto-detect button based on format + if hasattr(self, 'batch_use_auto_detect'): + if batch_output_format in ['png', 'jpg', 'jpeg', 'webp']: + self.batch_use_auto_detect.setEnabled(True) + else: + self.batch_use_auto_detect.setEnabled(False) + self.batch_use_auto_detect.setChecked(False) + + # Enable/disable min/max spin boxes based on format + if hasattr(self, 'batch_min_spin') and hasattr(self, 'batch_max_spin'): + enable_size_options = (batch_output_format == "icns") + self.batch_min_spin.setEnabled(enable_size_options) + self.batch_max_spin.setEnabled(enable_size_options) + def auto_set_output(self): if self.input_path: base_name = os.path.splitext(os.path.basename(self.input_path))[0] @@ -1199,30 +2251,17 @@ def on_auto_detect(self): def show_preview(self): + """Show preview in the preview tab""" if self.input_path and os.path.exists(self.input_path): try: + # Use the preview_tab to display the single preview + self.preview_tab.show_single_preview(self.input_path) img = Image.open(self.input_path) - # img.thumbnail((350, 350)) # Removed PIL thumbnailing - - if img.mode == 'RGBA': - qimage = QImage(img.tobytes("raw", "RGBA"), img.size[0], img.size[1], QImage.Format.Format_RGBA8888) # Corrected enum - else: - qimage = QImage(img.tobytes("raw", "RGB"), img.size[0], img.size[1], QImage.Format.Format_RGB888) # Corrected enum - - pixmap = QPixmap.fromImage(qimage) - # Scale pixmap to fit the label, maintaining aspect ratio - scaled_pixmap = pixmap.scaled(self.preview_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - self.preview_label.setPixmap(scaled_pixmap) - # Reset font to default if it was changed by placeholder - self.preview_label.setFont(QFont()) - self.preview_label.setText("") # Clear placeholder text self.status_bar.showMessage(f"Loaded: {os.path.basename(self.input_path)} ({img.size[0]}x{img.size[1]})") except Exception as e: - self.preview_label.setText("Preview error") self.status_bar.showMessage("Preview error") else: - self.preview_label.clear() - self._set_placeholder_preview() # Show placeholder when no image selected + self.preview_tab.clear_previews() self.status_bar.showMessage("Ready") def on_min_size_change(self, value): @@ -1247,6 +2286,32 @@ def on_quality_changed(self, value): self.quality_label.setText(str(value)) self.save_settings() + def on_batch_auto_detect_toggle(self, state): + """Handle auto-detect toggle in batch conversion""" + auto_detect_enabled = bool(state) + # Enable/disable min and max size spin boxes based on auto-detect setting + if hasattr(self, 'batch_min_spin') and hasattr(self, 'batch_max_spin'): + self.batch_min_spin.setEnabled(not auto_detect_enabled) + self.batch_max_spin.setEnabled(not auto_detect_enabled) + + # Enable/disable auto-detect button if it exists + if hasattr(self, 'batch_auto_button'): + self.batch_auto_button.setEnabled(not auto_detect_enabled) + + def on_batch_auto_detect(self): + """Handle auto-detect in batch conversion""" + # This method is deprecated, will be removed in future + PopupTeachingTip.create( + target=self.batch_auto_button, + icon=InfoBarIcon.WARNING, + title='Notice', + content="Auto-detect is now available through the checkbox option.", + isClosable=True, + tailPosition=TeachingTipTailPosition.TOP, + duration=3000, + parent=self + ) + def on_icns_method_changed(self, text): self.icns_method = text self.save_settings() @@ -1254,6 +2319,45 @@ def on_icns_method_changed(self, text): def on_overwrite_confirm_changed(self, state): self.overwrite_confirm = bool(state) self.save_settings() + + # Batch conversion event handlers (mirroring single conversion handlers) + def on_batch_min_size_change(self, value): + if hasattr(self, 'batch_min_size'): + self.batch_min_size = value + + def on_batch_max_size_change(self, value): + if hasattr(self, 'batch_max_size'): + self.batch_max_size = value + + def on_batch_quality_changed(self, value): + if hasattr(self, 'batch_quality'): + self.batch_quality = value + if hasattr(self, 'batch_quality_label'): + self.batch_quality_label.setText(str(value)) + + def on_batch_icns_method_changed(self, text): + if hasattr(self, 'batch_icns_method'): + self.batch_icns_method = text + + def on_batch_overwrite_confirm_changed(self, state): + if hasattr(self, 'batch_overwrite_confirm'): + self.batch_overwrite_confirm = bool(state) + + def on_batch_keep_aspect_changed(self, state): + if hasattr(self, 'batch_keep_aspect_ratio'): + self.batch_keep_aspect_ratio = bool(state) + + def on_batch_auto_crop_changed(self, state): + if hasattr(self, 'batch_auto_crop'): + self.batch_auto_crop = bool(state) + + def _on_batch_tree_item_expanded(self, item): + """Handle batch tree item expansion""" + pass # Could be enhanced for batch-specific behavior + + def _on_batch_tree_item_collapsed(self, item): + """Handle batch tree item collapse""" + pass # Could be enhanced for batch-specific behavior def on_interface_setting_changed(self): """Handle changes to interface behavior settings""" @@ -1371,8 +2475,40 @@ def on_start_conversion(self): ) return + # Check if task mode is enabled + settings = QSettings("MyCompany", "ConverterApp") + task_mode_enabled = settings.value("task_mode", False, type=bool) + + if task_mode_enabled: + # Create a simple task notification file + import json + import uuid + import time + + task_id = str(uuid.uuid4()) + task_info = { + "task_id": task_id, + "task_type": "image", + "input_path": self.input_path, + "output_path": self.output_path, + "status": "pending", + "progress": 0, + "timestamp": time.time() + } + + # Write task info to a temporary file + task_dir = os.path.expanduser("~/.converter/tasks") + os.makedirs(task_dir, exist_ok=True) + task_file = os.path.join(task_dir, f"task_{task_id}.json") + with open(task_file, "w") as f: + json.dump(task_info, f) + + # Store task_id for later updates + self.current_task_id = task_id + self.converting = True self.convert_button.setEnabled(False) + self.cancel_button.setEnabled(True) self.progress.setValue(0) self.progress_label.setText("Starting conversion...") @@ -1391,12 +2527,37 @@ def on_start_conversion(self): self._thread.started.connect(self._worker.run) self._thread.start() + def on_cancel_conversion(self): + """Cancel the ongoing conversion process""" + if not self.converting: + return + + # Update UI to show cancellation + self.progress_label.setText("Canceling conversion...") + self.cancel_button.setEnabled(False) + + # Stop the worker thread safely + if hasattr(self, '_thread') and self._thread.isRunning(): + self._thread.quit() + self._thread.wait() + + # Update UI state + self.converting = False + self.convert_button.setEnabled(True) + self.progress.setValue(0) + self.progress_label.setText("Conversion canceled") + self.status_bar.showMessage("Conversion canceled by user") + def on_conversion_finished(self): self.converting = False if hasattr(self, '_thread') and self._thread.isRunning(): self._thread.quit() self._thread.wait() + # Reset button states + self.convert_button.setEnabled(True) + self.cancel_button.setEnabled(False) + # Add to history if remember_path is enabled if self.remember_path: self.add_to_history(self.input_path, self.output_path, str(self.output_format)) @@ -1421,17 +2582,63 @@ def on_conversion_finished(self): self.show_success_view() else: # If completion notification is disabled, just show a simple status message - self.convert_button.setEnabled(True) self.progress.setValue(100) self.progress_label.setText("Conversion completed successfully!") self.status_bar.showMessage(f"Conversion completed: {os.path.basename(self.output_path)}") + # Auto-reset after conversion completion + self._reset_after_conversion() + + def _reset_after_conversion(self): + """Reset conversion state after completion to prepare for next conversion""" + # Only reset variables if remember_path is disabled + if not self.remember_path: + # Reset all variables when remember_path is disabled + self.init_variables(reset_all=True) + + # Reset UI elements + if hasattr(self, 'input_text'): + self.input_text.clear() + if hasattr(self, 'output_text'): + self.output_text.clear() + if hasattr(self, 'info_text'): + self.info_text.setText("No image selected") + if hasattr(self, 'preview_tab'): + self.preview_tab.clear_previews() # Clear previews when returning to main view + def on_conversion_error(self, error_message): self.converting = False self.convert_button.setEnabled(True) + self.cancel_button.setEnabled(False) # Reset cancel button state if hasattr(self, '_thread') and self._thread.isRunning(): self._thread.quit() self._thread.wait() + + # Update task status if task mode is enabled + if hasattr(self, 'current_task_id') and self.current_task_id: + try: + import json + import time + + task_dir = os.path.expanduser("~/.converter/tasks") + task_file = os.path.join(task_dir, f"task_{self.current_task_id}.json") + + # Read existing task info + if os.path.exists(task_file): + with open(task_file, "r") as f: + task_info = json.load(f) + + # Update task info + task_info["status"] = "failed" + task_info["error"] = error_message + task_info["timestamp"] = time.time() + + # Write updated task info + with open(task_file, "w") as f: + json.dump(task_info, f) + except Exception as e: + print(f"Error updating task status: {e}") + PopupTeachingTip.create( target=self.convert_button, icon=InfoBarIcon.ERROR, @@ -1444,6 +2651,370 @@ def on_conversion_error(self, error_message): ) self.progress_label.setText("Conversion Failed") + # Batch conversion callback methods + def on_batch_files_dropped(self, file_paths): + """Handle multiple files dropped in the batch converter""" + # Filter only supported image files + supported_formats = ('.png', '.jpg', '.jpeg', '.webp', '.ico', '.tiff', '.tif', '.icns', '.bmp', '.gif', '.svg', '.heic', '.heif', '.avif', '.jxl', '.pdf', '.eps', '.dds', '.exr') + new_files = [] + + for file_path in file_paths: + if os.path.splitext(file_path.lower())[1] in supported_formats: + new_files.append(file_path) + + if new_files: + self.batch_files.extend(new_files) + self.update_batch_file_list() + # Update preview tab with multiple previews + self.preview_tab.show_multiple_previews(new_files) + self.update_batch_statistics() + + def on_batch_folder_dropped(self, folder_path): + """Handle folder dropped in the batch converter""" + # Scan folder for supported image files + supported_formats = ('.png', '.jpg', '.jpeg', '.webp', '.ico', '.tiff', '.tif', '.icns', '.bmp', '.gif', '.svg', '.heic', '.heif', '.avif', '.jxl', '.pdf', '.eps', '.dds', '.exr') + found_files = [] + + try: + for root, dirs, files in os.walk(folder_path): + for file in files: + if any(file.lower().endswith(ext) for ext in supported_formats): + file_path = os.path.join(root, file) + found_files.append(file_path) + except Exception as e: + PopupTeachingTip.create( + target=self.drop_zone, + icon=InfoBarIcon.ERROR, + title='ERROR', + content=f"Cannot scan folder: {str(e)}", + isClosable=True, + tailPosition=TeachingTipTailPosition.TOP, + duration=3000, + parent=self + ) + return + + if found_files: + self.batch_files.extend(found_files) + self.update_batch_file_list() + # Update preview tab with multiple previews from folder + self.preview_tab.show_multiple_previews(found_files) + self.update_batch_statistics() + + PopupTeachingTip.create( + target=self.drop_zone, + icon=InfoBarIcon.SUCCESS, + title='SUCCESS', + content=f"Added {len(found_files)} files from folder: {os.path.basename(folder_path)}", + isClosable=True, + tailPosition=TeachingTipTailPosition.TOP, + duration=3000, + parent=self + ) + else: + PopupTeachingTip.create( + target=self.drop_zone, + icon=InfoBarIcon.WARNING, + title='WARNING', + content="No supported image files found in the folder", + isClosable=True, + tailPosition=TeachingTipTailPosition.TOP, + duration=3000, + parent=self + ) + + def update_batch_file_list(self): + """Update the file list widget with current batch files""" + self.file_list_widget.clear() + for file_path in self.batch_files: + item = QListWidgetItem(os.path.basename(file_path)) + item.setToolTip(file_path) + self.file_list_widget.addItem(item) + + def update_batch_statistics(self): + """Update batch conversion statistics""" + total_count = len(self.batch_files) + self.total_count_label.setText(f"Total: {total_count}") + + def clear_batch_files(self): + """Clear all batch files""" + self.batch_files.clear() + self.update_batch_file_list() + # Clear preview tab + self.preview_tab.clear_previews() + self.update_batch_statistics() + self.clear_batch_results() + + def remove_selected_files(self): + """Remove selected files from batch""" + selected_items = self.file_list_widget.selectedItems() + if not selected_items: + return + + # Get indices of selected items + indices = [] + for item in selected_items: + indices.append(self.file_list_widget.row(item)) + + # Sort indices in descending order to remove from end + indices.sort(reverse=True) + + # Remove files from batch_files list + for index in indices: + if 0 <= index < len(self.batch_files): + del self.batch_files[index] + + self.update_batch_file_list() + # Update preview tab with remaining files + self.preview_tab.show_multiple_previews(self.batch_files) + self.update_batch_statistics() + + def start_batch_conversion(self): + """Start batch conversion process""" + if not self.batch_files: + PopupTeachingTip.create( + target=self.start_batch_btn, + icon=InfoBarIcon.WARNING, + title='WARNING', + content="Please select files to convert", + isClosable=True, + tailPosition=TeachingTipTailPosition.TOP, + duration=2000, + parent=self + ) + return + + # Get current conversion options + options = self._get_batch_conversion_options() + if not options: + return + + # Start batch conversion in background thread + self.batch_converting = True + self.start_batch_btn.setEnabled(False) + self.stop_batch_btn.setEnabled(True) + + # Reset progress and results + self.batch_progress.setValue(0) + self.current_file_progress.setValue(0) + self.clear_batch_results() + + # Start batch conversion thread + self.batch_thread = QThread() + self.batch_worker = BatchConversionWorker( + self.batch_files, + self.get_batch_output_directory(), + options['output_format'], + min_size_param=options['min_size'], + max_size_param=options['max_size'], + quality_param=options['quality'], + preserve_folder_structure=options['preserve_folder_structure'], + prefix=options['prefix'], + suffix=options['suffix'], + auto_detect_max_size=options['auto_detect_max_size'] + ) + self.batch_worker.moveToThread(self.batch_thread) + + # Connect signals + self.batch_worker.total_progress_updated.connect(self.on_batch_progress) + self.batch_worker.progress_updated.connect(self.on_batch_file_progress) + self.batch_worker.file_processed.connect(self.on_batch_file_processed) + self.batch_worker.finished.connect(self.on_batch_finished) + self.batch_worker.batch_error.connect(self.on_batch_error) + + # Start the thread + self.batch_thread.started.connect(self.batch_worker.run) + self.batch_thread.start() + + def stop_batch_conversion(self): + """Stop batch conversion process""" + if hasattr(self, 'batch_worker') and self.batch_converting: + self.batch_worker.cancel() + self.on_batch_stopped() + + def on_batch_stopped(self): + """Handle batch conversion stopped""" + self.batch_converting = False + self.start_batch_btn.setEnabled(True) + self.stop_batch_btn.setEnabled(False) + self.batch_progress_label.setText("Stopped") + self.current_file_label.setText("No file processing") + + # Clean up thread + if hasattr(self, 'batch_thread') and self.batch_thread.isRunning(): + self.batch_thread.quit() + self.batch_thread.wait() + + def on_batch_progress(self, overall_progress, message=None): + """Update batch overall progress""" + self.batch_progress.setValue(overall_progress) + if message: + self.batch_progress_label.setText(message) + else: + self.batch_progress_label.setText(f"Overall progress: {overall_progress}%") + + def on_batch_file_progress(self, current_index, total_count, current_file, percentage): + """Update current file progress""" + self.current_file_progress.setValue(percentage) + self.current_file_label.setText(f"Processing {current_file} ({current_index}/{total_count})") + + def on_batch_file_processed(self, filename, input_path, output_path, success, error_message): + """Handle individual file processing result""" + if success: + item_text = f"✓ {filename}" + item = QListWidgetItem(item_text) + item.setForeground(Qt.green) + # Add to history if conversion was successful and remember_path is enabled + if self.remember_path: + format_type = self.batch_format_combo.currentText().lower() + self.add_to_history(input_path, output_path, format_type) + else: + item_text = f"✗ {filename}: {error_message}" + item = QListWidgetItem(item_text) + item.setForeground(Qt.red) + + self.results_list_widget.addItem(item) + + def on_batch_finished(self): + """Handle batch conversion finished""" + self.batch_converting = False + self.start_batch_btn.setEnabled(True) + self.stop_batch_btn.setEnabled(False) + + # Clean up thread + if hasattr(self, 'batch_thread') and self.batch_thread.isRunning(): + self.batch_thread.quit() + self.batch_thread.wait() + + # Get statistics from results list + success_count = 0 + failed_count = 0 + + for i in range(self.results_list_widget.count()): + item = self.results_list_widget.item(i) + if item: + if "✓" in item.text(): + success_count += 1 + else: + failed_count += 1 + + self.success_count_label.setText(f"Success: {success_count}") + self.failed_count_label.setText(f"Failed: {failed_count}") + + # Update statistics label + total_files = success_count + failed_count + self.batch_progress_label.setText(f"Completed: {total_files} files") + + # Get output directory path + output_dir = self.get_batch_output_directory() + + # Show batch success view (even if there were some failures, we still show the results) + self.show_batch_success_view( + total_files=total_files, + success_count=success_count, + failed_count=failed_count, + output_dir=output_dir, + format_name=self.batch_format_combo.currentText() + ) + + # Auto-reset after batch conversion completion + self._reset_batch_after_conversion() + + def _reset_batch_after_conversion(self): + """Reset batch conversion state after completion to prepare for next conversion""" + # Clear file list and results + self.clear_batch_files() + self.clear_batch_results() + + # Reset progress bars + self.batch_progress.setValue(0) + self.current_file_progress.setValue(0) + self.batch_progress_label.setText("Ready for new conversion") + self.current_file_label.setText("No file processing") + + # Clear preview tab + if hasattr(self, 'preview_tab'): + self.preview_tab.clear_previews() + + def on_batch_error(self, error_message): + """Handle batch conversion error""" + self.batch_converting = False + self.start_batch_btn.setEnabled(True) + self.stop_batch_btn.setEnabled(False) + + PopupTeachingTip.create( + target=self.start_batch_btn, + icon=InfoBarIcon.ERROR, + title='ERROR', + content=f"Batch conversion failed: {error_message}", + isClosable=True, + tailPosition=TeachingTipTailPosition.TOP, + duration=5000, + parent=self + ) + + # Clean up thread + if hasattr(self, 'batch_thread') and self.batch_thread.isRunning(): + self.batch_thread.quit() + self.batch_thread.wait() + + def clear_batch_results(self): + """Clear batch conversion results""" + self.results_list_widget.clear() + self.success_count_label.setText("Success: 0") + self.failed_count_label.setText("Failed: 0") + + def _get_batch_conversion_options(self): + """Get conversion options from batch options tree (same structure as single conversion)""" + try: + options = { + 'output_format': self.batch_format_combo.currentText(), + 'min_size': self.batch_min_spin.value(), + 'max_size': self.batch_max_spin.value(), + 'use_auto_detect': getattr(self, 'batch_use_auto_detect', False), + 'auto_detect_max_size': self.batch_auto_detect_check.isChecked() if hasattr(self, 'batch_auto_detect_check') else False, + 'keep_aspect_ratio': self.batch_keep_aspect_check.isChecked(), + 'auto_crop': self.batch_auto_crop_check.isChecked(), + 'quality': self.batch_quality_slider.value(), + 'icns_method': self.batch_icns_method_combo.currentText(), + 'overwrite_confirm': self.batch_overwrite_confirm_check.isChecked(), + 'preserve_folder_structure': self.batch_preserve_folder_check.isChecked() if hasattr(self, 'batch_preserve_folder_check') else False, + 'prefix': self.batch_prefix_edit.text().strip() if hasattr(self, 'batch_prefix_edit') else "", + 'suffix': self.batch_suffix_edit.text().strip() if hasattr(self, 'batch_suffix_edit') else "" + } + return options + except Exception as e: + PopupTeachingTip.create( + target=self.batch_options_tree, + icon=InfoBarIcon.ERROR, + title='ERROR', + content=f"Invalid conversion options: {str(e)}", + isClosable=True, + tailPosition=TeachingTipTailPosition.TOP, + duration=3000, + parent=self + ) + return None + + def get_output_directory(self): + """Get output directory for batch conversion""" + if hasattr(self, 'output_text') and self.output_text.text(): + return self.output_text.text() + else: + # Default to current directory if no output path set + return os.path.dirname(self.input_text.text()) if hasattr(self, 'input_text') and self.input_text.text() else os.getcwd() + + def get_batch_output_directory(self): + """Get output directory for batch conversion""" + if hasattr(self, 'batch_output_text') and self.batch_output_text.text(): + return self.batch_output_text.text() + elif hasattr(self, 'output_text') and self.output_text.text(): + # Fall back to single conversion output directory + return self.output_text.text() + else: + # Default to current directory if no output path set + return os.getcwd() + def show_success_view(self): # Update the success widget geometry to match the window self.success_widget.setGeometry(self.rect()) @@ -1496,24 +3067,12 @@ def show_main_view(self): if hasattr(self, 'success_widget'): self.success_widget.hide() - # Only reset variables if remember_path is disabled - if not self.remember_path: - # Reset all variables when remember_path is disabled - self.init_variables(reset_all=True) - - # Reset UI elements - if hasattr(self, 'input_text'): - self.input_text.clear() - if hasattr(self, 'output_text'): - self.output_text.clear() - if hasattr(self, 'info_text'): - self.info_text.setText("No image selected") - if hasattr(self, 'preview_label'): - self.preview_label.clear() - self._set_placeholder_preview() # Show placeholder when returning to main view - else: - # If remember_path is enabled, only reset the UI state but keep the paths - pass + # Hide the batch success widget if it's shown + if hasattr(self, 'batch_success_widget'): + self.batch_success_widget.hide() + + # Reset conversion state when returning to main view + self._reset_after_conversion() # Always reset these UI elements regardless of remember_path setting if hasattr(self, 'min_spin'): @@ -1548,6 +3107,35 @@ def show_main_view(self): if hasattr(self, 'status_bar'): self.status_bar.showMessage("Ready") + # Reset batch conversion related UI elements when returning to main view + if hasattr(self, 'batch_output_text'): + self.batch_output_text.clear() # Clear batch output directory + if hasattr(self, 'batch_format_combo'): + self.batch_format_combo.setCurrentText("icns") # Reset batch output format + if hasattr(self, 'batch_min_spin'): + self.batch_min_spin.setValue(16) # Reset batch min size + if hasattr(self, 'batch_max_spin'): + self.batch_max_spin.setValue(1024) # Reset batch max size + if hasattr(self, 'batch_options_tree'): + self.batch_options_tree.collapseAll() # Reset batch options tree + # Expand basic categories for batch options + if self.batch_options_tree.topLevelItemCount() >= 3: + item0 = self.batch_options_tree.topLevelItem(0) + item1 = self.batch_options_tree.topLevelItem(1) + item2 = self.batch_options_tree.topLevelItem(2) + if item0: + item0.setExpanded(True) # Basic Options + if item1: + item1.setExpanded(True) # Image Processing + if item2: + item2.setExpanded(False) # Advanced Settings + if hasattr(self, 'start_batch_btn'): + self.start_batch_btn.setEnabled(True) # Reset batch start button + if hasattr(self, 'stop_batch_btn'): + self.stop_batch_btn.setEnabled(False) # Reset batch stop button + if hasattr(self, 'results_list_widget'): + self.results_list_widget.clear() # Clear batch results list + # Only update history tab visibility if it's the first time or remember_path setting changed if not hasattr(self, '_history_tab_initialized'): self.create_history_tab() @@ -1557,6 +3145,32 @@ def show_main_view(self): def update_progress(self, message, percentage): self.progress_label.setText(message) self.progress.setValue(percentage) + + # Update task progress file if task mode is enabled + if hasattr(self, 'current_task_id') and self.current_task_id: + try: + import json + import time + + task_dir = os.path.expanduser("~/.converter/tasks") + task_file = os.path.join(task_dir, f"task_{self.current_task_id}.json") + + # Read existing task info + if os.path.exists(task_file): + with open(task_file, "r") as f: + task_info = json.load(f) + + # Update task info + task_info["status"] = "running" + task_info["progress"] = percentage + task_info["message"] = message + task_info["timestamp"] = time.time() + + # Write updated task info + with open(task_file, "w") as f: + json.dump(task_info, f) + except Exception as e: + print(f"Error updating task progress: {e}") def center_window(self): qr = self.frameGeometry() @@ -1610,6 +3224,9 @@ def resizeEvent(self, event): # Update the success widget geometry when the window is resized if hasattr(self, 'success_widget') and self.success_widget: self.success_widget.setGeometry(self.rect()) + # Update the batch success widget geometry when the window is resized + if hasattr(self, 'batch_success_widget') and self.batch_success_widget: + self.batch_success_widget.setGeometry(self.rect()) class ICNSConverterApp: diff --git a/qss/converter_dark.qss b/qss/converter_dark.qss index f14d2c4..d80a149 100644 --- a/qss/converter_dark.qss +++ b/qss/converter_dark.qss @@ -106,42 +106,3 @@ QTabBar::tab:!selected:hover { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #0056b3, stop:1 #004085); } -/* Enhanced CheckBox */ -QCheckBox { - color: #e0e0e0; - font-weight: 500; -} - -QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 4px; - border: 2px solid rgba(255, 255, 255, 0.2); - background-color: rgba(45, 45, 45, 0.8); -} - -QCheckBox::indicator:checked { - background-color: #2196F3; - border-color: #2196F3; - image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIHZpZXdCb3g9IjAgMCAxMCAxMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTggM0w0IDdMMiA1IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K); -} - -QCheckBox::indicator:hover { - border-color: #2196F3; - background-color: rgba(33, 150, 243, 0.1); -} - -/* Success overlay styles */ -#success_overlay { - background-color: rgba(0, 0, 0, 180); -} - -#success_center_panel { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(60, 60, 60, 0.9), - stop:1 rgba(45, 45, 45, 0.7)); - border-radius: 25px; - border: 1px solid rgba(255, 255, 255, 0.1); - padding: 35px; - min-width: 420px; -} \ No newline at end of file diff --git a/qss/settings_dark.qss b/qss/settings_dark.qss deleted file mode 100644 index 3443e09..0000000 --- a/qss/settings_dark.qss +++ /dev/null @@ -1,259 +0,0 @@ -QDialog { - background-color: #2b2b2b; - color: #e0e0e0; - font-family: Arial, sans-serif; - border-radius: 20px; -} - -QLabel { - color: #e0e0e0; - background-color: transparent; -} - -/* Checkbox Styles */ -QCheckBox { - color: #e0e0e0; - background-color: transparent; - spacing: 8px; -} - -QCheckBox::indicator { - width: 18px; - height: 18px; - background-color: transparent; - border: 2px solid #5a5a5a; - border-radius: 16px; -} - -QCheckBox::indicator:checked { - background-color: #0078d4; - border-color: #0078d4; - image: url("data:image/svg+xml;utf8,"); -} - -QCheckBox::indicator:hover { - border-color: #0078d4; - background-color: rgba(0, 120, 212, 0.1); -} - -QCheckBox::indicator:checked:hover { - background-color: #106ebe; - border-color: #106ebe; -} - -QCheckBox:focus { - outline: none; - background-color: transparent; -} - -QPushButton { - background-color: #2196F3; - border: none; - color: white; - padding: 12px 20px; - border-radius: 18px; - font-weight: bold; - font-size: 13px; -} -PushButton,ToolButton,PrimaryPushButton,PrimaryToolButton{ - border-radius: 16px -} -QPushButton:hover { - background-color: #1976D2; -} - -QPushButton:pressed { - background-color: #0D47A1; -} - -QComboBox { - border: none; - border-radius: 16px; - padding: 8px 12px; - background-color: rgba(45, 45, 45, 0.6); - color: #ffffff; - selection-background-color: #4a90e2; - selection-color: #ffffff; -} - -QComboBox:editable { - background-color: rgba(45, 45, 45, 0.6); -} - -/* Debug Settings Widget Styles */ -QGroupBox { - border: none; - border-radius: 20px; - margin-top: 15px; - padding-top: 25px; - font-weight: bold; - background-color: rgba(51, 51, 51, 0.4); - color: #e0e0e0; -} - -QGroupBox::title { - subcontrol-origin: margin; - left: 15px; - padding: 0 8px 0 8px; - color: #e0e0e0; - background-color: transparent; -} - -QComboBox:!editable, QComboBox::drop-down:editable { - background-color: rgba(45, 45, 45, 0.6); -} - -QComboBox:!editable:on, QComboBox::drop-down:editable:on { - background-color: rgba(45, 45, 45, 0.6); -} - -QComboBox:on { - background-color: rgba(45, 45, 45, 0.6); -} - -QComboBox::drop-down { - subcontrol-origin: padding; - subcontrol-position: top right; - width: 24px; - border: none; - border-top-right-radius: 15px; - border-bottom-right-radius: 15px; - background-color: rgba(68, 68, 68, 0.7); -} - -QComboBox::down-arrow { - image: url("data:image/svg+xml;utf8,"); - width: 16px; - height: 12px; -} - -QComboBox::down-arrow:on { - image: url("data:image/svg+xml;utf8,"); -} - -QComboBox QAbstractItemView { - border: none; - border-radius: 16px; - background-color: rgba(45, 45, 45, 0.8); - color: #ffffff; - selection-background-color: #4a90e2; - selection-color: #ffffff; - outline: 0px; -} - -QComboBox QAbstractItemView::item { - padding: 8px 15px; - border-radius: 16px; - margin: 2px; -} - -QComboBox QAbstractItemView::item:selected { - background-color: #4a90e2; - color: #ffffff; -} - -QComboBox QAbstractItemView::item:hover { - background-color: rgba(58, 58, 58, 0.8); -} - -#status_label { - color: #aaaaaa; - font-size: 12px; - font-style: italic; - background-color: transparent; -} - -#settings_apply_button { - background-color: #4CAF50; /* Green for apply button */ - border-radius: 18px; -} - -#settings_apply_button:hover { - background-color: #43A047; -} - -#settings_apply_button:pressed { - background-color: #2E7D32; -} - -/* Update Button Styles */ -QPushButton#settings_button { - background-color: #2196F3; /* Blue */ - border: none; - color: white; - padding: 15px 20px; - border-radius: 20px; - font-weight: bold; - font-size: 14px; - text-align: center; -} - -QPushButton#settings_button:hover { - background-color: #1976D2; -} - -QPushButton#settings_button:pressed { - background-color: #0D47A1; -} - -/* Text Elements with Transparent Backgrounds */ -QWidget QLabel { - color: #e0e0e0; - background-color: transparent; -} - -/* TabWidget with Slide Animation */ -QTabWidget::pane { - border: 1px solid #5a5a5a; - border-radius: 20px; - padding: 5px; - background-color: rgba(45, 45, 45, 0.6); -} - -QTabBar::tab { - background: #404040; - border: 1px solid #5a5a5a; - border-radius: 4px 4px 0px 0px; - padding: 12px 20px; - margin-right: 4px; - color: #b3b3b3; - font-weight: 500; -} - -QTabBar::tab:selected { - background: #0078d4; - color: #ffffff; - border-bottom: 2px solid #0078d4; -} - -QTabBar::tab:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a4a4a, stop:1 #3a3a3a); - color: #e0e0e0; -} - -QTabBar::tab:!selected:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #454545, stop:1 #353535); - color: #cccccc; -} - background-color: transparent; -} - -SegmentedWidget QLabel { - background-color: transparent; - color: #e0e0e0; -} - -StackedWidget QLabel { - background-color: transparent; - color: #e0e0e0; -} - -/* Ensure all text has transparent background */ -* { - selection-background-color: rgba(0, 120, 212, 0.3); -} - -*:focus { - outline: none; - background-color: transparent; -} \ No newline at end of file diff --git a/qss/settings_light.qss b/qss/settings_light.qss deleted file mode 100644 index 8b09c06..0000000 --- a/qss/settings_light.qss +++ /dev/null @@ -1,259 +0,0 @@ -QDialog { - background-color: #f0f0f0; - color: #333; - font-family: Arial, sans-serif; - border-radius: 20px; -} - -QLabel { - color: #333; - background-color: transparent; -} - -/* Checkbox Styles */ -QCheckBox { - color: #333; - background-color: transparent; - spacing: 8px; -} - -QCheckBox::indicator { - width: 18px; - height: 18px; - background-color: transparent; - border: 2px solid #bdc3c7; - border-radius: 16px; -} - -QCheckBox::indicator:checked { - background-color: #3498db; - border-color: #3498db; - image: url("data:image/svg+xml;utf8,"); -} - -QCheckBox::indicator:hover { - border-color: #3498db; - background-color: rgba(52, 152, 219, 0.1); -} - -QCheckBox::indicator:checked:hover { - background-color: #2980b9; - border-color: #2980b9; -} - -QCheckBox:focus { - outline: none; - background-color: transparent; -} - -QPushButton { - background-color: #2196F3; - border: none; - color: white; - padding: 12px 20px; - border-radius: 18px; - font-weight: bold; - font-size: 13px; -} -PushButton,ToolButton,PrimaryPushButton,PrimaryToolButton{ - border-radius: 16px -} -QPushButton:hover { - background-color: #1976D2; -} - -QPushButton:pressed { - background-color: #0D47A1; -} - -QComboBox { - border: none; - border-radius: 16px; - padding: 8px 12px; - background-color: rgba(255, 255, 255, 0.7); - color: #000000; - selection-background-color: #4a90e2; - selection-color: #ffffff; -} - -QComboBox:editable { - background-color: rgba(255, 255, 255, 0.7); -} - -QComboBox:!editable, QComboBox::drop-down:editable { - background-color: rgba(255, 255, 255, 0.7); -} - -/* Debug Settings Widget Styles */ -QGroupBox { - border: none; - border-radius: 20px; - margin-top: 15px; - padding-top: 25px; - font-weight: bold; - background-color: rgba(248, 249, 250, 0.6); - color: #333333; -} - -QGroupBox::title { - subcontrol-origin: margin; - left: 15px; - padding: 0 8px 0 8px; - color: #333333; - background-color: transparent; -} - -QComboBox:!editable:on, QComboBox::drop-down:editable:on { - background-color: rgba(255, 255, 255, 0.7); -} - -QComboBox:on { - background-color: rgba(255, 255, 255, 0.7); -} - -QComboBox::drop-down { - subcontrol-origin: padding; - subcontrol-position: top right; - width: 24px; - border: none; - border-top-right-radius: 15px; - border-bottom-right-radius: 15px; - background-color: rgba(204, 204, 204, 0.7); -} - -QComboBox::down-arrow { - image: url("data:image/svg+xml;utf8,"); - width: 16px; - height: 12px; -} - -QComboBox::down-arrow:on { - image: url("data:image/svg+xml;utf8,"); -} - -QComboBox QAbstractItemView { - border: none; - border-radius: 16px; - background-color: rgba(255, 255, 255, 0.9); - color: #000000; - selection-background-color: #4a90e2; - selection-color: #ffffff; - outline: 0px; -} - -QComboBox QAbstractItemView::item { - padding: 8px 15px; - border-radius: 16px; - margin: 2px; -} - -QComboBox QAbstractItemView::item:selected { - background-color: #4a90e2; - color: #ffffff; -} - -QComboBox QAbstractItemView::item:hover { - background-color: rgba(0, 0, 0, 0.05); -} - -#status_label { - color: #555; - font-size: 12px; - font-style: italic; - background-color: transparent; -} - -#settings_apply_button { - background-color: #4CAF50; /* Green for apply button */ - border-radius: 18px; -} - -#settings_apply_button:hover { - background-color: #43A047; -} - -#settings_apply_button:pressed { - background-color: #2E7D32; -} - -/* Update Button Styles */ -QPushButton#settings_button { - background-color: #2196F3; /* Blue */ - border: none; - color: white; - padding: 15px 20px; - border-radius: 20px; - font-weight: bold; - font-size: 14px; - text-align: center; -} - -QPushButton#settings_button:hover { - background-color: #1976D2; -} - -QPushButton#settings_button:pressed { - background-color: #0D47A1; -} - -/* Text Elements with Transparent Backgrounds */ -QWidget QLabel { - color: #333; - background-color: transparent; -} - -/* TabWidget with Slide Animation */ -QTabWidget::pane { - border: 1px solid #bdc3c7; - border-radius: 20px; - padding: 5px; - background-color: rgba(255, 255, 255, 0.7); -} - -QTabBar::tab { - background: #ecf0f1; - border: 1px solid #bdc3c7; - border-radius: 4px 4px 0px 0px; - padding: 12px 20px; - margin-right: 4px; - color: #7f8c8d; - font-weight: 500; -} - -QTabBar::tab:selected { - background: #3498db; - color: #ffffff; - border-bottom: 2px solid #3498db; -} - -QTabBar::tab:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #d5dbdb, stop:1 #c5cbcb); - color: #2c3e50; -} - -QTabBar::tab:!selected:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #d0d3d4, stop:1 #c0c3c4); - color: #34495e; -} - background-color: transparent; -} - -SegmentedWidget QLabel { - background-color: transparent; - color: #333; -} - -StackedWidget QLabel { - background-color: transparent; - color: #333; -} - -/* Ensure all text has transparent background */ -* { - selection-background-color: rgba(52, 152, 219, 0.3); -} - -*:focus { - outline: none; - background-color: transparent; -} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5b2b4e0..155cf77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,4 @@ rarfile py7zr requests PySide6-Fluent-Widgets[full] -patool cryptography diff --git a/settings/general_settings.py b/settings/general_settings.py index 5e4a635..aec90b9 100644 --- a/settings/general_settings.py +++ b/settings/general_settings.py @@ -2,149 +2,81 @@ General Settings Widget """ -from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, - QLabel, QLineEdit) +from PySide6.QtWidgets import QWidget, QVBoxLayout from PySide6.QtCore import Qt, Signal, QSettings -from PySide6.QtGui import QIntValidator -from qfluentwidgets import * +from qfluentwidgets import ( + SettingCardGroup, SwitchSettingCard, PushSettingCard, PrimaryPushSettingCard, + FluentIcon, BodyLabel, CaptionLabel, PasswordLineEdit, InfoBar, InfoBarPosition, + setCustomStyleSheet, ExpandGroupSettingCard, SingleDirectionScrollArea +) import os import sys sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from utils.security import encrypt_pat, decrypt_pat -from con import CON -class GeneralSettingsWidget(QWidget): - """General settings widget""" - - settings_changed = Signal() + + +class GitHubSettingCard(ExpandGroupSettingCard): + """GitHub PAT settings card using ExpandGroupSettingCard""" def __init__(self, parent=None): - super().__init__(parent) - self.settings = QSettings("pyquick", "converter") - self.pat_input = None # Initialize PAT input field reference - self.setup_ui() - self.load_settings() - self.connect_signals() - - def setup_ui(self): - """Setup the UI layout""" - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(15, 15, 15, 15) - main_layout.setSpacing(15) + super().__init__( + FluentIcon.GITHUB, + "GitHub Settings", + "Configure GitHub Personal Access Token for API access", + parent + ) + from qfluentwidgets import ComboBox, PushButton - - - # Image Converter settings group - image_converter_group = QGroupBox("Image Converter Settings") - image_converter_layout = QVBoxLayout(image_converter_group) - - # Interface behavior settings - behavior_group = QGroupBox("Interface Behavior Settings") - behavior_layout = QVBoxLayout(behavior_group) - - self.auto_preview_check = CheckBox("Auto-show preview after selecting file") - behavior_layout.addWidget(self.auto_preview_check) - behavior_layout.addSpacing(10) - self.remember_path_check = CheckBox("Remember last selected input/output paths") - behavior_layout.addWidget(self.remember_path_check) - behavior_layout.addSpacing(10) - self.completion_notify_check = CheckBox("Show success notification after conversion") - behavior_layout.addWidget(self.completion_notify_check) - behavior_layout.addSpacing(10) - - image_converter_layout.addWidget(behavior_group) - image_converter_layout.addStretch() - - # GitHub Settings Group - github_group = QGroupBox("GitHub Settings") - github_layout = QVBoxLayout() - - # GitHub PAT Settings - pat_layout = QHBoxLayout() - pat_label = QLabel("GitHub PAT (Personal Access Token):") self.pat_input = PasswordLineEdit() self.pat_input.setPlaceholderText("ghp_xxxxxxxxxxxxxxxxxxxx") self.pat_input.setClearButtonEnabled(True) - self.pat_input.setFixedHeight(33) - setCustomStyleSheet(self.pat_input,"PasswordLineEdit{ border-radius: 16px; }","PasswordLineEdit{ border-radius: 16px; }") - pat_layout.addWidget(pat_label) - pat_layout.addWidget(self.pat_input) - github_layout.addLayout(pat_layout) - - # PAT Info - pat_info = QLabel("Used for GitHub API access, recommend only 'repo' permission") - pat_info.setStyleSheet("color: #666; font-size: 11px;") - github_layout.addWidget(pat_info) - self.qss_debug = """PushButton{ border-radius: 12px; }""" - # PAT Test Button - test_pat_btn = PushButton("Test PAT") - setCustomStyleSheet(test_pat_btn,self.qss_debug,self.qss_debug) - test_pat_btn.clicked.connect(self.test_pat) - github_layout.addWidget(test_pat_btn) - - github_group.setLayout(github_layout) - main_layout.addWidget(github_group) - main_layout.addWidget(image_converter_group) - main_layout.addStretch() - - def connect_signals(self): - """Connect signals for auto-save""" - # Image converter settings signals - self.auto_preview_check.stateChanged.connect(self.on_settings_changed) - self.remember_path_check.stateChanged.connect(self.on_settings_changed) - self.completion_notify_check.stateChanged.connect(self.on_settings_changed) - - # Placeholder for future general settings signals - pass - - def on_settings_changed(self): - """Emit settings changed signal and save settings""" - self.save_settings() - self.settings_changed.emit() - - def load_settings(self): - """Load settings from QSettings""" - settings = QSettings("MyCompany", "ConverterApp") + self.pat_input.setFixedHeight(40) + setCustomStyleSheet(self.pat_input, "PasswordLineEdit{ border-radius: 16px; }", "PasswordLineEdit{ border-radius: 16px; }") - # Image converter settings - self.auto_preview_check.setChecked(settings.value("image_converter/auto_preview", True, type=bool)) - self.remember_path_check.setChecked(settings.value("image_converter/remember_path", True, type=bool)) - self.completion_notify_check.setChecked(settings.value("image_converter/completion_notify", True, type=bool)) + self.test_button = PushButton("Test PAT") + self.test_button.setFixedWidth(135) + self.test_button.setFixedHeight(46) + self.test_button.clicked.connect(self.test_pat) - # GitHub PAT settings - encrypted_pat = settings.value("general/github_pat", "", type=str) - if encrypted_pat: - decrypted_pat = decrypt_pat(encrypted_pat) - self.pat_input.setText(decrypted_pat) - - def save_settings(self): - """Save settings to QSettings""" - settings = QSettings("MyCompany", "ConverterApp") + self.permission_combo = ComboBox() + self.permission_combo.addItems(["repo", "public_repo", "repo:status", "repo_deployment"]) + self.permission_combo.setFixedWidth(135) + self.permission_combo.setFixedHeight(46) + self.permission_combo.setCurrentText("repo") - # Image converter settings - settings.setValue("image_converter/auto_preview", self.auto_preview_check.isChecked()) - settings.setValue("image_converter/remember_path", self.remember_path_check.isChecked()) - settings.setValue("image_converter/completion_notify", self.completion_notify_check.isChecked()) + self.viewLayout.setContentsMargins(0, 0, 0, 0) + self.viewLayout.setSpacing(0) - # GitHub PAT settings - pat_text = self.pat_input.text().strip() - if pat_text: - encrypted_pat = encrypt_pat(pat_text) - settings.setValue("general/github_pat", encrypted_pat) - else: - settings.setValue("general/github_pat", "") - + self.addGroup( + FluentIcon.SAVE, + "Personal Access Token", + "Enter your GitHub PAT for API access", + self.pat_input + ) + self.addGroup( + FluentIcon.CHECKBOX, + "Recommended Permission", + "Select the permission scope for your PAT", + self.permission_combo + ) + self.addGroup( + FluentIcon.EDIT, + "Test Connection", + "Verify your PAT is valid", + self.test_button + ) + def test_pat(self): """Test GitHub PAT validity""" pat = self.pat_input.text().strip() if not pat: - PopupTeachingTip.create( - target=self.pat_input, - icon=InfoBarIcon.WARNING, + InfoBar.warning( title='Warning', content='Please enter GitHub PAT first', + orient=Qt.Orientation.Horizontal, isClosable=True, - tailPosition=TeachingTipTailPosition.TOP, + position=InfoBarPosition.TOP, duration=2000, parent=self ) @@ -152,7 +84,6 @@ def test_pat(self): try: import requests - # Test PAT API endpoint response = requests.get( "https://api.github.com/user", headers={ @@ -165,61 +96,211 @@ def test_pat(self): if response.status_code == 200: user_data = response.json() username = user_data.get("login", "Unknown") - PopupTeachingTip.create( - target=self.pat_input, - icon=InfoBarIcon.SUCCESS, + InfoBar.success( title='Success', content=f'PAT verified! Associated user: {username}', + orient=Qt.Orientation.Horizontal, isClosable=True, - tailPosition=TeachingTipTailPosition.TOP, + position=InfoBarPosition.TOP, duration=3000, parent=self ) elif response.status_code == 401: - PopupTeachingTip.create( - target=self.pat_input, - icon=InfoBarIcon.ERROR, + InfoBar.error( title='Error', content='PAT invalid or expired', + orient=Qt.Orientation.Horizontal, isClosable=True, - tailPosition=TeachingTipTailPosition.TOP, + position=InfoBarPosition.TOP, duration=3000, parent=self ) else: - PopupTeachingTip.create( - target=self.pat_input, - icon=InfoBarIcon.ERROR, + InfoBar.error( title='Error', content=f'PAT verification failed (status code: {response.status_code})', + orient=Qt.Orientation.Horizontal, isClosable=True, - tailPosition=TeachingTipTailPosition.TOP, + position=InfoBarPosition.TOP, duration=3000, parent=self ) except requests.exceptions.RequestException as e: - PopupTeachingTip.create( - target=self.pat_input, - icon=InfoBarIcon.ERROR, + InfoBar.error( title='Error', content=f'Network error: {str(e)}', + orient=Qt.Orientation.Horizontal, isClosable=True, - tailPosition=TeachingTipTailPosition.TOP, + position=InfoBarPosition.TOP, duration=3000, parent=self ) except Exception as e: - PopupTeachingTip.create( - target=self.pat_input, - icon=InfoBarIcon.ERROR, + InfoBar.error( title='Error', content=f'Test failed: {str(e)}', + orient=Qt.Orientation.Horizontal, isClosable=True, - tailPosition=TeachingTipTailPosition.TOP, - duration=3000, - parent=self - ) + position=InfoBarPosition.TOP, + duration=3000, + parent=self + ) + + def get_pat(self): + """Get current PAT text""" + return self.pat_input.text().strip() + + def set_pat(self, pat): + """Set PAT text""" + self.pat_input.setText(pat) + + def get_permission(self): + """Get selected permission""" + return self.permission_combo.currentText() + + def set_permission(self, permission): + """Set permission""" + self.permission_combo.setCurrentText(permission) + + +class GeneralSettingsWidget(QWidget): + """General settings widget using qfluentwidgets SettingCard components""" + + settings_changed = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.settings = QSettings("MyCompany", "ConverterApp") + self.setup_ui() + self.connect_signals() + self.load_settings() + + def setup_ui(self): + """Setup the UI layout""" + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + self.scroll_area = SingleDirectionScrollArea(orient=Qt.Orientation.Vertical) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.enableTransparentBackground() + + scroll_content = QWidget() + scroll_content.setObjectName("scroll_content") + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setContentsMargins(30, 30, 30, 30) + scroll_layout.setSpacing(20) + + self.behavior_group = SettingCardGroup("Interface Behavior", scroll_content) + + self.auto_preview_card = SwitchSettingCard( + FluentIcon.VIEW, + "Auto-show preview", + "Automatically show preview after selecting file", + parent=self.behavior_group + ) + + self.remember_path_card = SwitchSettingCard( + FluentIcon.HISTORY, + "Remember paths", + "Remember last selected input/output paths", + parent=self.behavior_group + ) + + self.completion_notify_card = SwitchSettingCard( + FluentIcon.COMPLETED, + "Completion notification", + "Show success notification after conversion", + parent=self.behavior_group + ) + + self.task_mode_card = SwitchSettingCard( + FluentIcon.SETTING, + "Task Mode", + "Enable task queue for batch processing", + parent=self.behavior_group + ) + + self.github_card = GitHubSettingCard(scroll_content) + + self.behavior_group.addSettingCards([ + self.auto_preview_card, + self.remember_path_card, + self.completion_notify_card, + self.task_mode_card + ]) + + scroll_layout.addWidget(self.behavior_group) + scroll_layout.addWidget(self.github_card) + scroll_layout.addStretch() + + self.scroll_area.setWidget(scroll_content) + + main_layout.addWidget(self.scroll_area) + + def connect_signals(self): + """Connect signals for auto-save""" + self.auto_preview_card.checkedChanged.connect(self.on_settings_changed) + self.remember_path_card.checkedChanged.connect(self.on_settings_changed) + self.completion_notify_card.checkedChanged.connect(self.on_settings_changed) + self.task_mode_card.checkedChanged.connect(self.on_settings_changed) + + # Connect GitHub settings signals + self.github_card.pat_input.textChanged.connect(self.on_settings_changed) + self.github_card.permission_combo.currentTextChanged.connect(self.on_settings_changed) + + def on_settings_changed(self): + """Emit settings changed signal and save settings""" + self.save_settings() + self.settings_changed.emit() + + def load_settings(self): + """Load settings from QSettings""" + # Block signals during loading to prevent auto-save + self.auto_preview_card.blockSignals(True) + self.remember_path_card.blockSignals(True) + self.completion_notify_card.blockSignals(True) + self.task_mode_card.blockSignals(True) + self.github_card.pat_input.blockSignals(True) + self.github_card.permission_combo.blockSignals(True) + + self.auto_preview_card.setChecked(bool(self.settings.value("image_converter/auto_preview", True, type=bool))) + self.remember_path_card.setChecked(bool(self.settings.value("image_converter/remember_path", True, type=bool))) + self.completion_notify_card.setChecked(bool(self.settings.value("image_converter/completion_notify", True, type=bool))) + self.task_mode_card.setChecked(bool(self.settings.value("task_mode", False, type=bool))) + + encrypted_pat = self.settings.value("general/github_pat", "", type=str) + if encrypted_pat: + decrypted_pat = decrypt_pat(str(encrypted_pat)) + self.github_card.set_pat(decrypted_pat) + + permission = self.settings.value("general/github_permission", "repo", type=str) + self.github_card.set_permission(str(permission)) + + # Unblock signals after loading + self.auto_preview_card.blockSignals(False) + self.remember_path_card.blockSignals(False) + self.completion_notify_card.blockSignals(False) + self.task_mode_card.blockSignals(False) + self.github_card.pat_input.blockSignals(False) + self.github_card.permission_combo.blockSignals(False) + + def save_settings(self): + """Save settings to QSettings""" + self.settings.setValue("image_converter/auto_preview", self.auto_preview_card.isChecked()) + self.settings.setValue("image_converter/remember_path", self.remember_path_card.isChecked()) + self.settings.setValue("image_converter/completion_notify", self.completion_notify_card.isChecked()) + self.settings.setValue("task_mode", self.task_mode_card.isChecked()) + + pat_text = self.github_card.get_pat() + if pat_text: + encrypted_pat = encrypt_pat(pat_text) + self.settings.setValue("general/github_pat", encrypted_pat) + else: + self.settings.setValue("general/github_pat", "") + + self.settings.setValue("general/github_permission", self.github_card.get_permission()) if __name__ == "__main__": @@ -229,4 +310,4 @@ def test_pat(self): app = QApplication(sys.argv) widget = GeneralSettingsWidget() widget.show() - sys.exit(app.exec()) \ No newline at end of file + sys.exit(app.exec()) diff --git a/settings/settings_gui.py b/settings/settings_gui.py index 27526bc..3d9671d 100644 --- a/settings/settings_gui.py +++ b/settings/settings_gui.py @@ -15,7 +15,7 @@ from PySide6.QtGui import QPalette from PySide6.QtCore import Qt, QSettings, QPropertyAnimation, QEasingCurve, Signal, Slot from qfluentwidgets import SegmentedWidget, setCustomStyleSheet -from .update_settings_gui import UpdateDialog +from .update_settings_gui import UpdateSettingsWidget from con import CON class SettingsDialog(QDialog): @@ -133,7 +133,7 @@ def init_ui(self): update_layout.setContentsMargins(15, 15, 15, 15) update_layout.setSpacing(15) - self.update_group = UpdateDialog() + self.update_group = UpdateSettingsWidget() self.update_group.setObjectName("update_group") update_layout.addWidget(self.update_group) update_layout.addStretch() diff --git a/settings/update_settings_gui.py b/settings/update_settings_gui.py index fd3519c..654059b 100644 --- a/settings/update_settings_gui.py +++ b/settings/update_settings_gui.py @@ -1,35 +1,21 @@ # -*- coding: utf-8 -*- -#Please do not change import -from concurrent.futures import thread -import multiprocessing +""" +Update Settings GUI Widget for Converter application +""" + import sys import os -import threading - -from PySide6.QtWidgets import ( - QApplication, - QWidget, - QPushButton, - QVBoxLayout, - QHBoxLayout, - QLabel, - QCheckBox, - QGroupBox, - QSpacerItem, - QSizePolicy, - QProgressBar, - QMessageBox, - QAbstractButton -) -from PySide6.QtCore import QSettings, Qt, QThread, Signal, QTimer, QTimer +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout +from PySide6.QtCore import QSettings, Qt, QThread, Signal, QTimer from PySide6.QtGui import QFont +from qfluentwidgets import * from darkdetect import isDark from update.update_manager import UpdateManager from update.download_update import download_and_apply_update from qframelesswindow.utils import getSystemAccentColor -from qfluentwidgets import * -import time from con import CON + + class CheckUpdateThread(QThread): check_finished = Signal(dict) @@ -58,13 +44,12 @@ def __init__(self, download_url, version, include_prerelease=False): self.include_prerelease = include_prerelease self.is_cancelled = False self.progress_callback = None - self.downloader = None # Initialize downloader attribute + self.downloader = None def run(self): try: print(f"DownloadThread started, is_cancelled: {self.is_cancelled}") - # Check if cancelled before starting download if self.is_cancelled: print("DownloadThread: cancelled before download") result = { @@ -74,7 +59,6 @@ def run(self): self.finished.emit(result) return - # Create a simple progress callback that emits signals def progress_callback(progress, downloaded, total_size): if not self.is_cancelled: progress_data = { @@ -84,16 +68,13 @@ def progress_callback(progress, downloaded, total_size): } self.progress_updated.emit(progress_data) - # Set the progress callback self.progress_callback = progress_callback - # Create update info dictionary update_info = { "download_url": self.download_url, "latest_version": self.version } - # Create UpdateDownloader instance for cancellation from update.download_update import UpdateDownloader import tempfile temp_dir = tempfile.mkdtemp(prefix="update_") @@ -102,21 +83,18 @@ def progress_callback(progress, downloaded, total_size): self.downloader = UpdateDownloader( download_url=self.download_url, target_directory=temp_dir, - max_threads=64 # Use 64 threads + max_threads=64 ) - # Download update only (don't apply it) print("DownloadThread: starting download") result = self.downloader.download_update(self.version, progress_callback) print(f"DownloadThread: download completed with status: {result.get('status')}") - # Check if the download was cancelled if result.get("status") == "cancelled": print("DownloadThread: result status is cancelled") self.finished.emit(result) return - # Check if cancelled during download if self.is_cancelled or (self.downloader and hasattr(self.downloader, '_cancelled') and self.downloader._cancelled): print("DownloadThread: cancelled during download") result = { @@ -127,7 +105,6 @@ def progress_callback(progress, downloaded, total_size): self.finished.emit(result) return - # Check again if cancelled if self.is_cancelled: print("DownloadThread: cancelled after download") result = { @@ -138,7 +115,6 @@ def progress_callback(progress, downloaded, total_size): self.finished.emit(result) return - # Add downloader to result for cleanup result["downloader"] = self.downloader result["temp_dir"] = temp_dir print("DownloadThread: emitting success result") @@ -146,10 +122,15 @@ def progress_callback(progress, downloaded, total_size): except Exception as e: print(f"DownloadThread exception: {e}") + import shutil + try: + shutil.rmtree(temp_dir) + except Exception as cleanup_error: + print(f"Failed to clean up temp directory: {cleanup_error}") + error_result = { "status": "error", - "message": f"Download failed: {str(e)}", - "temp_dir": temp_dir + "message": f"Download failed: {str(e)}" } self.finished.emit(error_result) @@ -162,214 +143,192 @@ def progress_callback(progress, downloaded, total_size): self.finished.emit(error_result) def cancel(self): - """Cancel the download""" + """Cancel download""" print("DownloadThread: cancel() called") self.is_cancelled = True - # Cancel the downloader if self.downloader: print("DownloadThread: cancelling downloader") self.downloader.cancel() - # Wait a moment to ensure cancellation is complete import time time.sleep(0.1) - # Request thread exit self.quit() - # Wait for thread to completely stop - if not self.wait(1000): # Wait up to 1 second + if not self.wait(1000): print("Warning: DownloadThread did not stop within 1 second, forcing termination") self.terminate() - self.wait(500) # Wait another 500ms to ensure termination is complete + self.wait(500) -class UpdateDialog(QWidget): - __version__ = "2.0.0RC1" - def __init__(self): - super().__init__() - # Remove SystemThemeListener to avoid thread issues - # self.themeListener = SystemThemeListener(self) - - - self.setWindowTitle("Update Settings") +class UpdateSettingsWidget(QWidget): + """Update settings widget using qfluentwidgets SettingCard components""" + + __version__ = "2.1.0A6" + + def __init__(self, parent=None): + super().__init__(parent) self.update_manager = UpdateManager(self.__version__) self.check_thread = None self.download_thread = None - - # Detect current version type - self._detect_current_version_type() + self.current_update_info = None - self.init_ui() + self._detect_current_version_type() + self.setup_ui() self.load_settings() - self.connect_auto_save_signals() - - def _detect_current_version_type(self): - """检测当前版本类型,如果是Alpha或Deepdev版本,则限制可用通道""" - try: - # 解析当前版本 - version_info = self.update_manager._parse_version(self.__version__) - _, _, _, current_tag, _ = version_info - - # 检查是否为Alpha或Deepdev版本 - self.is_alpha_version = (current_tag == 'A') - self.is_deepdev_version = (current_tag == 'D') - self.is_internal_version = self.is_alpha_version or self.is_deepdev_version - - if self.is_internal_version: - print(f"检测到内部版本: {self.__version__} (标签: {current_tag})") - - except Exception as e: - print(f"版本检测失败: {e}") - self.is_internal_version = False - self.is_alpha_version = False - self.is_deepdev_version = False + self.connect_signals() - - def init_ui(self): - + def setup_ui(self): + """Setup UI layout""" main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(25, 25, 25, 25) - main_layout.setSpacing(20) - - update_group = QGroupBox("Update Settings") - update_layout = QVBoxLayout() - update_layout.setContentsMargins(25, 25, 25, 25) - update_layout.setSpacing(20) - - # 添加顶部间距 - update_layout.addSpacerItem(QSpacerItem(0, 15, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)) - - # 添加版本类型选择器 - prerelease_container = QHBoxLayout() - prerelease_container.setSpacing(10) - - self.prerelease_type_label = QLabel("Update channel:") - prerelease_container.addWidget(self.prerelease_type_label) - - self.prerelease_type_combo = ModelComboBox() - setCustomStyleSheet(self.prerelease_type_combo,CON.qss_combo_2,CON.qss_combo_2) - # 根据版本类型设置可用通道 + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Create scroll area + self.scroll_area = SingleDirectionScrollArea(orient=Qt.Orientation.Vertical) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.enableTransparentBackground() + + # Create scroll content widget + scroll_content = QWidget() + scroll_content.setObjectName("scroll_content") + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setContentsMargins(30, 30, 30, 30) + scroll_layout.setSpacing(20) + + # Create setting card groups + self.update_group = SettingCardGroup("Update Configuration", scroll_content) + + # Determine available channels based on version type + available_channels = ["Stable"] if self.is_internal_version: - # 内部版本只显示对应的通道和稳定版 if self.is_alpha_version: - self.prerelease_type_combo.addItems([ "Alpha"]) + available_channels = ["Stable", "Alpha"] print("Alpha版本:只显示Alpha通道") elif self.is_deepdev_version: - self.prerelease_type_combo.addItems(["Deepdev"]) + available_channels = ["Stable", "Deepdev"] print("Deepdev版本:只显示Deepdev通道") else: - # 普通版本显示所有通道 - self.prerelease_type_combo.addItems(["Stable", "RC (Release Candidate)", "Beta", "Deepdev", "Alpha"]) - - self.prerelease_type_combo.setFixedWidth(200) - prerelease_container.addWidget(self.prerelease_type_combo) - - prerelease_container.addStretch() - update_layout.addLayout(prerelease_container) - - self.update_status_label = QLabel("Ready to check for updates.") - self.update_status_label.setMinimumHeight(60) - self.update_status_label.setMinimumWidth(550) + available_channels = ["Stable", "RC (Release Candidate)", "Beta", "Deepdev", "Alpha"] + + channel_layout = QHBoxLayout() + channel_label = BodyLabel("Update Channel:") + channel_label.setFixedWidth(120) + self.channel_combo = ComboBox() + self.channel_combo.addItems(available_channels) + self.channel_combo.setFixedWidth(250) + channel_layout.addWidget(channel_label) + channel_layout.addWidget(self.channel_combo) + channel_layout.addStretch() + self.update_group.vBoxLayout.addLayout(channel_layout) + + channel_info = CaptionLabel("Select update channel to receive updates from") + self.update_group.vBoxLayout.addWidget(channel_info) + + # Create check for updates card + self.check_update_card = PrimaryPushSettingCard( + "Check for Updates", + FluentIcon.SYNC, + "Check for new updates" + ) + self.check_update_card.clicked.connect(self.check_for_updates) + + # Create download update card (initially disabled) + self.download_update_card = PrimaryPushSettingCard( + "Download Update", + FluentIcon.DOWNLOAD, + "Download and install latest update" + ) + self.download_update_card.clicked.connect(self.download_update) + self.download_update_card.setEnabled(False) + + # Create restart application card (initially disabled) + self.restart_card = PrimaryPushSettingCard( + "Restart Application", + FluentIcon.SYNC, + "Restart to apply update" + ) + self.restart_card.clicked.connect(self.restart_application) + self.restart_card.setEnabled(False) + + # Add cards to group + self.update_group.addSettingCards([ + self.check_update_card, + self.download_update_card, + self.restart_card + ]) + + # Add update status and progress to group + self.update_status_label = BodyLabel("Ready to check for updates.") self.update_status_label.setWordWrap(True) - #self.update_status_label.setStyleSheet("QLabel { padding: 8px; background-color: #f8f9fa; border-radius: 5px; }") - update_layout.addWidget(self.update_status_label) + self.update_group.vBoxLayout.addWidget(self.update_status_label) - # 更新内容显示区域 + # Release content browser self.release_content_browser = TextBrowser() self.release_content_browser.setMinimumHeight(150) self.release_content_browser.setVisible(False) - update_layout.addWidget(self.release_content_browser) + self.update_group.vBoxLayout.addWidget(self.release_content_browser) - # 下载进度条 + # Progress bars self.progress_bar = IndeterminateProgressBar() self.progress_bar.setVisible(False) - update_layout.addWidget(self.progress_bar) + self.update_group.vBoxLayout.addWidget(self.progress_bar) - # 实际下载进度条 self.download_progress_bar = ProgressBar() self.download_progress_bar.setRange(0, 100) self.download_progress_bar.setValue(0) self.download_progress_bar.setVisible(False) - update_layout.addWidget(self.download_progress_bar) + self.update_group.vBoxLayout.addWidget(self.download_progress_bar) - # 进度标签(显示百分比、下载大小、速度等信息) - self.progress_label = QLabel("0%") + # Progress label + self.progress_label = BodyLabel("0%") self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) font = QFont() font.setPointSize(10) font.setBold(True) self.progress_label.setFont(font) self.progress_label.setVisible(False) - update_layout.addWidget(self.progress_label) - - # 按钮容器 - button_container = QHBoxLayout() - button_container.setSpacing(15) - - self.update_button = PrimaryPushButton("Check for Updates") - self.update_button.setFixedSize(180, 60) - setCustomStyleSheet(self.update_button, CON.qss_debug, CON.qss_debug) - self.update_button.clicked.connect(self.check_for_updates) - - self.download_button =PrimaryPushButton("Download Update") - self.download_button.setFixedSize(180, 60) - setCustomStyleSheet(self.download_button, CON.qss_debug, CON.qss_debug) - self.download_button.clicked.connect(self.download_update) - self.download_button.setVisible(False) - self.download_button.setEnabled(False) - - self.restart_button = PrimaryPushButton("Restart Application") - self.restart_button.setFixedSize(180, 60) - setCustomStyleSheet(self.restart_button, CON.qss_debug, CON.qss_debug) - self.restart_button.clicked.connect(self.restart_application) - self.restart_button.setVisible(False) - self.restart_button.setEnabled(False) - - button_container.addStretch() - button_container.addWidget(self.update_button) - button_container.addWidget(self.download_button) - button_container.addWidget(self.restart_button) - button_container.addStretch() - update_layout.addLayout(button_container) - - # 添加底部间距 - update_layout.addSpacerItem(QSpacerItem(0, 15, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)) - - update_group.setLayout(update_layout) - main_layout.addWidget(update_group) - - self.setLayout(main_layout) - - + self.update_group.vBoxLayout.addWidget(self.progress_label) - # 设置按钮字体 - font = QFont() - font.setPointSize(12) - font.setBold(True) - self.update_button.setFont(font) - self.download_button.setFont(font) - self.restart_button.setFont(font) - - # 设置标签字体 - label_font = QFont() - label_font.setPointSize(11) - self.update_status_label.setFont(label_font) - - # 设置标签字体 - label_font = QFont() - label_font.setPointSize(11) - self.prerelease_type_label.setFont(label_font) - - - - - + # Add groups to scroll layout + scroll_layout.addWidget(self.update_group) + scroll_layout.addStretch() + + # Set scroll content + self.scroll_area.setWidget(scroll_content) + + # Add scroll area to main layout + main_layout.addWidget(self.scroll_area) + + def connect_signals(self): + """Connect signals""" + self.channel_combo.currentIndexChanged.connect(self.on_update_channel_changed) + + def _detect_current_version_type(self): + """Detect current version type""" + try: + version_info = self.update_manager._parse_version(self.__version__) + _, _, _, current_tag, _ = version_info + + self.is_alpha_version = (current_tag == 'A') + self.is_deepdev_version = (current_tag == 'D') + self.is_internal_version = self.is_alpha_version or self.is_deepdev_version + + if self.is_internal_version: + print(f"检测到内部版本: {self.__version__} (标签: {current_tag})") + + except Exception as e: + print(f"版本检测失败: {e}") + self.is_internal_version = False + self.is_alpha_version = False + self.is_deepdev_version = False + def load_settings(self): + """Load settings from QSettings""" settings = QSettings("MyCompany", "ConverterApp") prerelease_type = settings.value("update/prerelease_type", "stable", type=str) - # 设置版本类型选择器 - type_index = 0 # 默认为 "Stable" + + type_index = 0 if prerelease_type == "rc": type_index = 1 elif prerelease_type == "beta": @@ -378,16 +337,15 @@ def load_settings(self): type_index = 3 elif prerelease_type == "alpha": type_index = 4 - self.prerelease_type_combo.setCurrentIndex(type_index) - + + self.channel_combo.setCurrentIndex(type_index) + def save_settings(self): - """保存设置""" + """Save settings to QSettings""" try: - # 保存更新通道设置 - prerelease_type = "stable" # 默认 + prerelease_type = "stable" - # 根据选择的索引确定类型 - current_index = self.prerelease_type_combo.currentIndex() + current_index = self.channel_combo.currentIndex() if current_index == 1: prerelease_type = "rc" elif current_index == 2: @@ -404,43 +362,25 @@ def save_settings(self): except Exception as e: print(f"Error saving settings: {e}") - def connect_auto_save_signals(self): - """Connect UI controls to auto-save functionality""" - # Connect combo box to auto-save - self.prerelease_type_combo.currentIndexChanged.connect(self.on_update_channel_changed) - - def auto_save_settings(self): - """Auto-save settings immediately upon change""" - try: - # Save settings immediately - self.save_settings() - except Exception as e: - print(f"Error in auto_save_settings: {e}") - def on_update_channel_changed(self, index): - """处理更新通道选择变化""" - # 自动保存设置 - self.auto_save_settings() + """Handle update channel change""" + self.save_settings() def _get_update_check_params(self): - """获取更新检查参数""" - prerelease_type = "stable" # 默认稳定版 + """Get update check parameters""" + prerelease_type = "stable" - current_index = self.prerelease_type_combo.currentIndex() - current_text = self.prerelease_type_combo.currentText() + current_index = self.channel_combo.currentIndex() + current_text = self.channel_combo.currentText() - # 根据版本类型调整索引映射 if self.is_internal_version: if self.is_alpha_version: - # Alpha版本:索引0=Stable, 索引1=Alpha if current_index == 1: prerelease_type = "alpha" elif self.is_deepdev_version: - # Deepdev版本:索引0=Stable, 索引1=Deepdev if current_index == 1: prerelease_type = "deepdev" else: - # 普通版本:标准索引映射 if current_index == 1: prerelease_type = "rc" elif current_index == 2: @@ -450,42 +390,37 @@ def _get_update_check_params(self): elif current_index == 4: prerelease_type = "alpha" - # 根据通道类型决定是否包含预发布版本 include_prerelease = (prerelease_type != "stable") return include_prerelease, prerelease_type if include_prerelease else None - + def check_for_updates(self): - """检查更新""" + """Check for updates""" self.update_status_label.setText("Checking for updates...") setThemeColor(getSystemAccentColor(), save=False) - self.update_button.setEnabled(False) + self.check_update_card.setEnabled(False) - # 隐藏TextBrowser、下载按钮和重启按钮 self.release_content_browser.setVisible(False) - self.download_button.setVisible(False) - self.restart_button.setVisible(False) + self.download_update_card.setEnabled(False) + self.restart_card.setEnabled(False) - # 显示进度条并设置主题色 self.progress_bar.setVisible(True) self.progress_bar.resume() self.progress_bar.start() - QApplication.processEvents() # 确保界面更新 - # 获取更新检查参数 - include_prerelease, prerelease_type = self._get_update_check_params() + from PySide6.QtWidgets import QApplication + QApplication.processEvents() - # Auto-save settings immediately when checking for updates - self.auto_save_settings() + include_prerelease, prerelease_type = self._get_update_check_params() + self.save_settings() - # 启动检查更新线程 self.check_thread = CheckUpdateThread(self.update_manager, include_prerelease, prerelease_type if include_prerelease else None) self.check_thread.check_finished.connect(self.on_check_finished) self.check_thread.start() def on_check_finished(self, result): + """Handle check finished""" if result["status"] == "update_available": - # 获取版本类型信息 version_type = "" if "version_info" in result: version_tuple = result["version_info"] @@ -494,11 +429,9 @@ def on_check_finished(self, result): version_type = f" ({version_type})" self.update_status_label.setText(f"✅ {result['message']}\n\nVersion: {result['latest_version']}{version_type}") - self.download_button.setVisible(True) - self.download_button.setEnabled(True) + self.download_update_card.setEnabled(True) self.current_update_info = result - # 显示更新内容 if result.get("release_body"): self.release_content_browser.setMarkdown(result["release_body"]) self.release_content_browser.setVisible(True) @@ -506,23 +439,23 @@ def on_check_finished(self, result): self.release_content_browser.setVisible(False) elif result["status"] == "error": self.update_status_label.setText(f"❌ Check failed: {result['message']}") - self.download_button.setVisible(False) + self.download_update_card.setEnabled(False) self.release_content_browser.setVisible(False) else: self.update_status_label.setText(f"ℹ️ {result['message']}") - self.download_button.setVisible(False) + self.download_update_card.setEnabled(False) self.release_content_browser.setVisible(False) self.progress_bar.pause() self.progress_bar.setVisible(False) - self.update_button.setEnabled(True) + self.check_update_card.setEnabled(True) def download_update(self): + """Download update""" if hasattr(self, 'current_update_info'): - self.download_button.setEnabled(False) - self.update_button.setEnabled(False) + self.download_update_card.setEnabled(False) + self.check_update_card.setEnabled(False) - # 隐藏TextBrowser self.release_content_browser.setVisible(False) self.progress_bar.setVisible(True) @@ -530,18 +463,14 @@ def download_update(self): self.download_progress_bar.setVisible(False) self.update_status_label.setText("Download in progress...") - # 显示进度标签 self.progress_label.setText("0%") self.progress_label.setVisible(True) - # 获取下载URL和版本信息 download_url = self.current_update_info.get("download_url") latest_version = self.current_update_info.get("latest_version") - # 获取预发布设置 include_prerelease, prerelease_type = self._get_update_check_params() - # 重置下载计时器 if hasattr(self, '_download_start_time'): delattr(self, '_download_start_time') if hasattr(self, '_last_downloaded'): @@ -549,388 +478,106 @@ def download_update(self): if hasattr(self, '_last_time'): delattr(self, '_last_time') - # 使用新的DownloadThread类 self.download_thread = DownloadThread(download_url, latest_version, include_prerelease) self.download_thread.progress_updated.connect(self.on_progress_updated) self.download_thread.finished.connect(self.on_download_finished) self.download_thread.start() - # 将下载按钮改为取消按钮 - self.download_button.setText("Cancel Download") - self.download_button.setEnabled(True) - self.download_button.clicked.disconnect() - self.download_button.clicked.connect(self.cancel_download) + self.download_update_card.setText("Cancel Download") + self.download_update_card.setEnabled(True) + self.download_update_card.clicked.disconnect() + self.download_update_card.clicked.connect(self.cancel_download) def cancel_download(self): - """取消下载""" + """Cancel download""" if hasattr(self, 'download_thread') and self.download_thread is not None and self.download_thread.isRunning(): self.download_thread.cancel() - self.download_button.setEnabled(False) + self.download_update_card.setEnabled(False) self.update_status_label.setText("Cancelling download...") - # 设置一个定时器来检查线程是否已停止 def check_thread_stopped(): - # 检查线程是否已停止 if self.download_thread is None: - # 线程已被清理,直接返回 return if not self.download_thread.isRunning(): - # 线程已停止,显示取消状态 self.show_cancelled_state() else: - # 线程仍在运行,继续等待 QTimer.singleShot(500, check_thread_stopped) - # 开始检查线程状态 QTimer.singleShot(500, check_thread_stopped) - def start_swing_animation(self): - """启动左右摆动动画""" - pass - - def update_swing_animation(self): - """更新摆动动画""" - pass + def show_cancelled_state(self): + """Show cancelled state""" + self.update_status_label.setText("Download cancelled") + self.progress_bar.setVisible(False) + self.download_progress_bar.setVisible(False) + self.progress_label.setVisible(False) + self.check_update_card.setEnabled(True) + self.download_update_card.setText("Download Update") + self.download_update_card.clicked.disconnect() + self.download_update_card.clicked.connect(self.download_update) def on_progress_updated(self, progress_data): - """处理下载进度更新""" + """Handle progress update""" if not progress_data: return - - # 从progress_data中获取进度信息 + progress = progress_data.get("progress", 0) - downloaded_bytes = progress_data.get("downloaded", 0) - total_bytes = progress_data.get("total", 0) - - # 更新进度条显示实际进度 - if progress > 0: - # 显示实际进度条,隐藏不确定进度条 - self.progress_bar.setVisible(False) - self.download_progress_bar.setVisible(True) - self.download_progress_bar.setValue(progress) - - # 计算下载速度和时间估计 - current_time = time.time() - if not hasattr(self, '_download_start_time'): - self._download_start_time = current_time - self._last_downloaded = 0 - self._last_time = current_time - - # 计算平均速度和剩余时间 - elapsed_time = current_time - self._download_start_time - if elapsed_time > 1.0: # 至少1秒后再计算速度 - # 计算平均下载速度 - avg_download_speed = downloaded_bytes / elapsed_time # bytes per second - - # 计算剩余时间 - remaining_bytes = total_bytes - downloaded_bytes - if avg_download_speed > 0 and total_bytes > 0: - remaining_time = remaining_bytes / avg_download_speed - # 格式化剩余时间 - if remaining_time > 3600: - eta_str = f"{remaining_time/3600:.1f}h" - elif remaining_time > 60: - eta_str = f"{remaining_time/60:.1f}m" - else: - eta_str = f"{remaining_time:.0f}s" - else: - eta_str = "计算中" - - # 格式化文件大小 - def format_size(bytes_size): - if bytes_size >= 1024*1024*1024: - return f"{bytes_size/(1024*1024*1024):.2f} GB" - elif bytes_size >= 1024*1024: - return f"{bytes_size/(1024*1024):.2f} MB" - elif bytes_size >= 1024: - return f"{bytes_size/1024:.2f} KB" - else: - return f"{bytes_size} B" - - # 更新数字进度标签 - if hasattr(self, 'progress_label'): - downloaded_str = format_size(downloaded_bytes) - total_str = format_size(total_bytes) - speed_str = format_size(avg_download_speed) + "/s" - - self.progress_label.setText( - f"{progress}% - {downloaded_str}/{total_str} - {speed_str} - ETA: {eta_str}" - ) + downloaded = progress_data.get("downloaded", 0) + total = progress_data.get("total", 0) - if progress == 100: - self.progress_bar.pause() + self.download_progress_bar.setVisible(True) + self.download_progress_bar.setValue(int(progress)) - def on_download_finished(self, result): - # 停止进度条 - if result["status"] == "success": - # 下载成功,立即开始应用更新 - self.progress_bar.pause() - self.progress_bar.setVisible(False) - self.download_progress_bar.setVisible(False) - - # 隐藏数字进度标签 - if hasattr(self, 'progress_label'): - self.progress_label.setVisible(False) - - # 清理下载计时器变量 - if hasattr(self, '_download_start_time'): - delattr(self, '_download_start_time') - if hasattr(self, '_last_downloaded'): - delattr(self, '_last_downloaded') - if hasattr(self, '_last_time'): - delattr(self, '_last_time') - - # 清理下载线程 - if hasattr(self, 'download_thread') and self.download_thread: - if self.download_thread.downloader: - self.download_thread.downloader.cleanup() - - # 确保线程完全停止后再设置为None - if self.download_thread.isRunning(): - self.download_thread.quit() - self.download_thread.wait(2000) # 最多等待2秒 - - self.download_thread = None - - # 保存更新信息 - self.update_result = result - - # 显示应用更新进度 - self.update_status_label.setText("Applying updates...") - - # 使用IndeterminateProgressBar显示应用更新进度 - self.progress_bar.setVisible(True) - self.progress_bar.start() - - # 立即执行更新应用 - self.apply_update() - + if total > 0: + downloaded_mb = downloaded / (1024 * 1024) + total_mb = total / (1024 * 1024) + self.progress_label.setText(f"{progress:.1f}% - {downloaded_mb:.1f} MB / {total_mb:.1f} MB") else: - # 下载失败或取消 - if result["status"] == "cancelled": - # 先将进度条设置为100% - self.download_progress_bar.setValue(100) - self.progress_label.setText("100%") - QApplication.processEvents() # 确保界面更新 - - # 短暂延迟后显示取消状态 - QTimer.singleShot(500, lambda: self.show_cancelled_state()) - else: - # 确保错误消息正确处理非ASCII字符 - try: - error_message = str(result['message']) - self.update_status_label.setText(f"❌ 下载失败: {error_message}") - except Exception as e: - # 如果出现编码问题,显示基本错误信息 - self.update_status_label.setText(f"❌ 下载失败: 处理错误消息时出现编码问题") - - self.progress_bar.setVisible(False) - self.download_progress_bar.setVisible(False) - - # 隐藏数字进度标签 - if hasattr(self, 'progress_label'): - self.progress_label.setVisible(False) - - # 清理下载计时器变量 - if hasattr(self, '_download_start_time'): - delattr(self, '_download_start_time') - if hasattr(self, '_last_downloaded'): - delattr(self, '_last_downloaded') - if hasattr(self, '_last_time'): - delattr(self, '_last_time') - - # 恢复下载按钮 - self.download_button.setText("Download Update") - self.download_button.clicked.disconnect() - self.download_button.clicked.connect(self.download_update) - self.download_button.setEnabled(True) - self.download_button.setVisible(True) - - self.update_button.setEnabled(True) + self.progress_label.setText(f"{progress:.1f}%") - def show_cancelled_state(self): - """显示取消下载的状态,恢复到初始形态""" - self.update_status_label.setText("Ready to check for updates.") - - # 隐藏进度条 + def on_download_finished(self, result): + """Handle download finished""" self.progress_bar.setVisible(False) self.download_progress_bar.setVisible(False) + self.progress_label.setVisible(False) - # 隐藏数字进度标签 - if hasattr(self, 'progress_label'): - self.progress_label.setVisible(False) - - # 隐藏发布内容浏览器 - self.release_content_browser.setVisible(False) - - # 清理下载计时器变量 - if hasattr(self, '_download_start_time'): - delattr(self, '_download_start_time') - if hasattr(self, '_last_downloaded'): - delattr(self, '_last_downloaded') - if hasattr(self, '_last_time'): - delattr(self, '_last_time') - - # Clean up downloader and thread - if hasattr(self, 'download_thread') and self.download_thread: - if self.download_thread.downloader: - self.download_thread.downloader.cleanup() - - # 确保线程完全停止后再设置为None - if self.download_thread.isRunning(): - self.download_thread.quit() - self.download_thread.wait(2000) # 最多等待2秒 - - self.download_thread = None - - # 隐藏下载按钮,显示检查更新按钮 - self.download_button.setVisible(False) - self.download_button.setEnabled(False) - - # 启用检查更新按钮 - self.update_button.setEnabled(True) - - def apply_update(self): - """应用更新""" - if hasattr(self, 'update_result'): - # 新的下载流程已经自动应用了更新,所以这里只需要显示成功消息 - if self.update_result.get("status") == "success": - # 更新状态显示 - self.update_status_label.setText("✅ Update applied successfully! Please restart the application.") - - # 显示重启按钮 - self.restart_button.setVisible(True) - self.restart_button.setEnabled(True) - - # 停止进度条 - self.progress_bar.pause() - self.progress_bar.setVisible(False) - else: - # 处理错误情况 - error_message = self.update_result.get("message", "Unknown error") - self.update_status_label.setText(f"❌ Update failed: {error_message}") - - # 停止进度条 - self.progress_bar.pause() - self.progress_bar.setVisible(False) - else: - print("❌ update_result属性不存在") - self.update_status_label.setText("❌ Update information lost, please restart manually") - - - - def __del__(self): - """析构函数,确保所有线程都被正确清理""" - try: - # 清理下载线程 - if hasattr(self, 'download_thread') and self.download_thread is not None: - if self.download_thread.isRunning(): - self.download_thread.quit() - if not self.download_thread.wait(1000): - self.download_thread.terminate() - self.download_thread.wait(500) - - # 清理下载器 - if hasattr(self.download_thread, 'downloader') and self.download_thread.downloader: - self.download_thread.downloader.cleanup() - - self.download_thread = None - - # 清理检查更新线程 - if hasattr(self, 'check_thread') and self.check_thread is not None: - if self.check_thread.isRunning(): - self.check_thread.quit() - if not self.check_thread.wait(1000): - self.check_thread.terminate() - self.check_thread.wait(500) - - self.check_thread = None - except: - # 在析构函数中忽略所有异常 - pass - - def closeEvent(self, event): - """重写窗口关闭事件,下载时阻止关闭""" - # 检查是否有下载正在进行 - if hasattr(self, 'download_thread') and self.download_thread is not None and self.download_thread.isRunning(): - # 下载正在进行,显示提示并阻止关闭 - reply = QMessageBox.question( - self, - "下载进行中", - "正在下载更新,确定要关闭窗口吗?\n关闭窗口将取消下载。", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - - if reply == QMessageBox.StandardButton.Yes: - # 用户确认关闭,先取消下载 - self.cancel_download() - - # 等待更长时间确保线程完全停止 - max_wait_time = 5000 # 最多等待5秒 - wait_step = 500 # 每次等待500ms - total_waited = 0 - - while self.download_thread and self.download_thread.isRunning() and total_waited < max_wait_time: - QApplication.processEvents() # 处理待处理的事件 - self.download_thread.wait(wait_step) # 等待一小段时间 - total_waited += wait_step - - # 如果线程仍在运行,强制终止 - if self.download_thread and self.download_thread.isRunning(): - self.download_thread.terminate() - self.download_thread.wait(1000) # 再等待1秒 - - # 清理下载器 - if self.download_thread and self.download_thread.downloader: - self.download_thread.downloader.cleanup() - - self.download_thread = None - - # 允许关闭窗口 - event.accept() - else: - # 用户取消关闭,阻止窗口关闭 - event.ignore() + if result["status"] == "success": + self.update_status_label.setText("✅ Download completed successfully!") + self.restart_card.setEnabled(True) + self.check_update_card.setEnabled(True) + self.download_update_card.setText("Download Update") + self.download_update_card.clicked.disconnect() + self.download_update_card.clicked.connect(self.download_update) + elif result["status"] == "cancelled": + self.show_cancelled_state() else: - # 没有下载在进行,检查是否有检查更新线程 - if hasattr(self, 'check_thread') and self.check_thread is not None and self.check_thread.isRunning(): - # 检查更新正在进行,取消它 - self.check_thread.quit() - if not self.check_thread.wait(1000): # 等待最多1秒 - self.check_thread.terminate() - self.check_thread.wait(500) # 再等待500ms - self.check_thread = None - - # 允许正常关闭 - event.accept() + self.update_status_label.setText(f"❌ Download failed: {result.get('message', 'Unknown error')}") + self.check_update_card.setEnabled(True) + self.download_update_card.setText("Download Update") + self.download_update_card.clicked.disconnect() + self.download_update_card.clicked.connect(self.download_update) def restart_application(self): - """重启应用程序""" + """Restart application""" + self.update_status_label.setText("Restarting application...") + + import subprocess + import sys + try: - # 执行重启脚本 - restart_script_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "update", "restart.command") - - if os.path.exists(restart_script_path): - # 执行重启脚本 - os.system(f"'{restart_script_path}' &") - print(f"✅ 重启脚本已执行: {restart_script_path}") - - # 关闭当前应用程序 - QApplication.quit() - - else: - print(f"❌ 重启脚本不存在: {restart_script_path}") - self.update_status_label.setText("❌ Restart script not found, please restart manually") - + subprocess.Popen([sys.executable] + sys.argv) + from PySide6.QtWidgets import QApplication + QApplication.instance().quit() except Exception as e: - print(f"❌ 执行重启脚本时出错: {e}") - self.update_status_label.setText(f"❌ Restart failed: {e}") + self.update_status_label.setText(f"Failed to restart: {e}") -def main(): + +if __name__ == "__main__": + from PySide6.QtWidgets import QApplication + app = QApplication(sys.argv) - window = UpdateDialog() - window.resize(500, 350) # 扩大窗口大小以容纳更多内容 - window.show() - sys.exit(app.exec()) \ No newline at end of file + widget = UpdateSettingsWidget() + widget.resize(800, 600) + widget.show() + sys.exit(app.exec()) diff --git a/support/GUI/arc_support.py b/support/GUI/arc_support.py new file mode 100644 index 0000000..9a910cb --- /dev/null +++ b/support/GUI/arc_support.py @@ -0,0 +1,253 @@ +import os +from PySide6.QtCore import Qt, QSize, Signal +from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel, QFileDialog +from PySide6.QtGui import QDragEnterEvent, QDropEvent, QPalette, QPixmap + + +class BatchDropZoneWidget(QFrame): + """Custom widget for drag and drop archive file selection""" + files_dropped = Signal(list) # Signal for multiple archive files dropped + + def __init__(self, placeholder_text="Drag archive files here or click to browse", parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setMinimumHeight(100) # 增加到100确保足够空间 + self.setFixedHeight(100) # 设置固定高度防止缩小 + self.setMinimumWidth(200) # 设置最小宽度 + self.is_dark_mode = False # Track current theme mode + + # Define supported archive formats + self.supported_formats = { + '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.cab', '.iso', '.arj', '.ace', '.lzh', '.lha' + } + + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setContentsMargins(10, 10, 10, 10) # 设置内边距防止内容紧贴边框 + + self.icon_label = QLabel("📁") + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon_label.setStyleSheet("font-size: 20px;") + + self.text_label = QLabel(placeholder_text) + self.text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.text_label.setStyleSheet("color: #666; font-size: 12px;") + self.text_label.setWordWrap(True) + + layout.addWidget(self.icon_label) + layout.addWidget(self.text_label) + + self.drag_over = False + + # Click to browse + self.mousePressEvent = self.browse_files + + # Apply initial light theme style after all widgets are created + self._apply_light_theme_style() + + def sizeHint(self): + """Return fixed size hint to prevent resizing""" + return super().sizeHint() + + def minimumSizeHint(self): + """Return minimum size hint to prevent shrinking""" + return QSize(200, 100) + + def set_theme(self, is_dark_mode): + """Update the theme of the drag and drop area""" + self.is_dark_mode = is_dark_mode + if self.is_dark_mode: + self._apply_dark_theme_style() + else: + self._apply_light_theme_style() + + def _apply_light_theme_style(self): + """Apply light theme styles""" + self.setStyleSheet(""" + QFrame { + border: 2px dashed #aaa; + border-radius: 10px; + background-color: #f9f9f9; + } + QFrame:hover { + border-color: #007acc; + background-color: #f0f8ff; + } + QFrame:drop { + border-color: #28a745; + background-color: #f0fff0; + } + """) + self.text_label.setStyleSheet("color: #666; font-size: 12px;") + + def _apply_dark_theme_style(self): + """Apply dark theme styles""" + self.setStyleSheet(""" + QFrame { + border: 2px dashed #555; + border-radius: 10px; + background-color: #2d2d2d; + } + QFrame:hover { + border-color: #007acc; + background-color: #1e3a5f; + } + QFrame:drop { + border-color: #28a745; + background-color: #1a2f1a; + } + """) + self.text_label.setStyleSheet("color: #aaa; font-size: 12px;") + + def browse_files(self, event): + """Open file browser when clicked""" + file_dialog = QFileDialog() + file_paths, _ = file_dialog.getOpenFileNames( + self, + "Select Archive Files", + "", + "Archive Files (*.zip *.rar *.7z *.tar *.gz *.bz2 *.xz *.cab *.iso *.arj *.ace *.lzh *.lha);;All Files (*)" + ) + if file_paths: + self.files_dropped.emit(file_paths) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + # Check if any of the dragged items are supported archive formats + has_supported_files = False + total_files = 0 + supported_files = 0 + + for url in event.mimeData().urls(): + if hasattr(url, 'toLocalFile'): + path = url.toLocalFile() + else: + path = url.path() if hasattr(url, 'path') else "" + + if path and os.path.isfile(path): + total_files += 1 + if self._is_supported_archive_file(path): + supported_files += 1 + + # Accept if there are supported archive files + if supported_files > 0: + self._set_drag_over_style(True) + event.acceptProposedAction() + self._update_text_label(total_files, supported_files, True) + else: + self._set_reject_style() + else: + event.ignore() + + def dragLeaveEvent(self, event): + self._reset_style() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + self._set_drag_over_style(True) + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + self._reset_style() + + files = [] + + for url in event.mimeData().urls(): + path = url.toLocalFile() + if os.path.isfile(path) and self._is_supported_archive_file(path): + files.append(path) + + # Emit signal for valid archive files + if files: + self.files_dropped.emit(files) + + def _is_supported_archive_file(self, file_path): + """Check if the file has a supported archive format extension""" + if not file_path: + return False + _, ext = os.path.splitext(file_path.lower()) + return ext in self.supported_formats + + def _set_drag_over_style(self, has_supported=True): + """Set style for drag over state""" + # Ensure fixed size is maintained during style changes + current_width = self.width() + current_height = self.height() + + if self.is_dark_mode: + self.setStyleSheet(""" + QFrame { + border: 2px solid #28a745; + border-radius: 10px; + background-color: #1a2f1a; + } + """) + else: + self.setStyleSheet(""" + QFrame { + border: 2px solid #28a745; + border-radius: 10px; + background-color: #f0fff0; + } + """) + + # Restore fixed size after style change + self.setFixedHeight(100) + if current_width > 0: + self.setMinimumWidth(current_width) + + self.drag_over = True + + def _set_reject_style(self): + """Set style for rejected drag items""" + # Ensure fixed size is maintained during style changes + current_width = self.width() + + if self.is_dark_mode: + self.setStyleSheet(""" + QFrame { + border: 2px solid #dc3545; + border-radius: 10px; + background-color: #2a1a1a; + } + """) + else: + self.setStyleSheet(""" + QFrame { + border: 2px solid #dc3545; + border-radius: 10px; + background-color: #fff5f5; + } + """) + + # Restore fixed size after style change + self.setFixedHeight(100) + if current_width > 0: + self.setMinimumWidth(current_width) + + def _reset_style(self): + """Reset to default style""" + # Ensure fixed size is maintained during style changes + current_width = self.width() + + if self.is_dark_mode: + self._apply_dark_theme_style() + else: + self._apply_light_theme_style() + + # Restore fixed size after style change + self.setFixedHeight(100) + if current_width > 0: + self.setMinimumWidth(current_width) + + self.drag_over = False + + def _update_text_label(self, total_files, supported_files, has_supported): + """Update the text label during drag operations""" + if has_supported: + if total_files == supported_files: + self.text_label.setText(f"Release to add {supported_files} archive file(s)") + else: + self.text_label.setText(f"Release to add {supported_files} archive file(s)\n({total_files - supported_files} unsupported file(s) will be ignored)") diff --git a/support/GUI/image_support.py b/support/GUI/image_support.py new file mode 100644 index 0000000..0dcef90 --- /dev/null +++ b/support/GUI/image_support.py @@ -0,0 +1,935 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +from PySide6.QtWidgets import ( + QFrame, QVBoxLayout, QLabel, QHBoxLayout, QWidget, + QFileDialog +) +from PySide6.QtCore import QRect +from PySide6.QtGui import QPixmap, QFont, QPainter, QColor +from PySide6.QtCore import Qt, QSize, Signal +from qfluentwidgets import * + + +class DropZoneWidget(QFrame): + """Custom widget for drag and drop file/folder selection with support for all image formats""" + filesDropped = Signal(list) # Signal for multiple files dropped + folderDropped = Signal(str) # Signal for folder dropped + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setMinimumHeight(100) + self.setFixedHeight(100) # 设置固定高度防止缩小 + self.setMinimumWidth(200) # 设置最小宽度 + self.is_dark_mode = False # Track current theme mode + + # Define supported image formats + self.supported_formats = { + # Common formats + '.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.tif', '.ico', '.icns', + # Web formats + '.webp', '.svg', + # High efficiency formats + '.heic', '.heif', '.avif', '.jxl', + # Other formats + '.pdf', '.eps', '.dds', '.exr' + } + + # 设置初始状态变量 + self.is_dark_mode = False # 初始化为浅色主题 + + # 创建布局 + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setContentsMargins(10, 10, 10, 10) # 设置内边距防止内容紧贴边框 + + self.icon_label = QLabel("📁") + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon_label.setStyleSheet("font-size: 24px;") + + self.text_label = QLabel("Drag files or folders here\n(Supports: PNG, JPG, JPEG, BMP, GIF, TIFF, ICO, ICNS, WebP, SVG, HEIC, HEIF, AVIF, JXL, PDF, EPS, DDS, EXR)") + self.text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.text_label.setStyleSheet("color: #666; font-size: 12px;") + self.text_label.setWordWrap(True) + + # 应用初始浅色主题样式(在组件创建之后) + self._apply_light_theme_style() + + layout.addWidget(self.icon_label) + layout.addWidget(self.text_label) + + self.drag_over = False + + # Click to browse + self.mousePressEvent = self.browse_files + + def sizeHint(self): + """Return fixed size hint to prevent resizing""" + return super().sizeHint() + + def minimumSizeHint(self): + """Return minimum size hint to prevent shrinking""" + return QSize(200, 100) + + def set_theme(self, is_dark_mode): + """Update the theme of the drag and drop area""" + self.is_dark_mode = is_dark_mode + if self.is_dark_mode: + self._apply_dark_theme_style() + else: + self._apply_light_theme_style() + + def _apply_light_theme_style(self): + """Apply light theme styles""" + self.setStyleSheet(""" + QFrame { + border: 2px dashed #aaa; + border-radius: 10px; + background-color: #f9f9f9; + } + QFrame:hover { + border-color: #007acc; + background-color: #f0f8ff; + } + QFrame:drop { + border-color: #28a745; + background-color: #f0fff0; + } + """) + self.text_label.setStyleSheet("color: #666; font-size: 12px;") + + def _apply_dark_theme_style(self): + """Apply dark theme styles""" + self.setStyleSheet(""" + QFrame { + border: 2px dashed #555; + border-radius: 10px; + background-color: #2d2d2d; + } + QFrame:hover { + border-color: #007acc; + background-color: #1e3a5f; + } + QFrame:drop { + border-color: #28a745; + background-color: #1a2f1a; + } + """) + self.text_label.setStyleSheet("color: #aaa; font-size: 12px;") + + def browse_files(self, event): + """Open file browser when clicked""" + file_dialog = QFileDialog() + file_paths, _ = file_dialog.getOpenFileNames( + self, + "Select Image Files", + "", + "Image Files (*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.ico *.webp);;All Files (*)" + ) + if file_paths: + self.filesDropped.emit(file_paths) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + # Check if any of the dragged items are supported formats + has_supported_files = False + total_files = 0 + supported_files = 0 + has_folders = False + + for url in event.mimeData().urls(): + if hasattr(url, 'toLocalFile'): + path = url.toLocalFile() + else: + path = url.path() if hasattr(url, 'path') else "" + + if path and os.path.isfile(path): + total_files += 1 + if self._is_supported_image_file(path): + supported_files += 1 + elif path and os.path.isdir(path): + total_files += 1 + has_folders = True # Folders count as one item + + # Accept if there are supported image files or folders + if supported_files > 0 or has_folders: + self._set_drag_over_style(True) + event.acceptProposedAction() + self._update_text_label(total_files, supported_files, True) + else: + # Show rejection style + self._set_reject_style() + else: + event.ignore() + + def dragLeaveEvent(self, event): + self._reset_style() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + self._set_drag_over_style(True) + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + self._reset_style() + + files = [] + folders = [] + rejected_files = [] + + for url in event.mimeData().urls(): + path = url.toLocalFile() + if os.path.isdir(path): + folders.append(path) + elif os.path.isfile(path): + if self._is_supported_image_file(path): + files.append(path) + else: + rejected_files.append(path) + + # Emit signals for valid items + if folders: + # For now, emit first folder (can be extended for multiple folders) + self.folderDropped.emit(folders[0]) + + if files: + self.filesDropped.emit(files) + + # Show result message + if rejected_files: + self._show_rejected_files_message(rejected_files) + + def _is_supported_image_file(self, file_path): + """Check if the file has a supported image format extension""" + if not file_path: + return False + _, ext = os.path.splitext(file_path.lower()) + return ext in self.supported_formats + + def _set_drag_over_style(self, has_supported=True): + """Set style for drag over state""" + # Ensure fixed size is maintained during style changes + current_width = self.width() + + if has_supported: + if self.is_dark_mode: + self.setStyleSheet(""" + QFrame { + border: 2px solid #28a745; + border-radius: 10px; + background-color: #1a2f1a; + } + """) + else: + self.setStyleSheet(""" + QFrame { + border: 2px solid #28a745; + border-radius: 10px; + background-color: #f0fff0; + } + """) + self.drag_over = True + else: + if self.is_dark_mode: + self.setStyleSheet(""" + QFrame { + border: 2px solid #007acc; + border-radius: 10px; + background-color: #1e3a5f; + } + """) + else: + self.setStyleSheet(""" + QFrame { + border: 2px solid #007acc; + border-radius: 10px; + background-color: #e6f3ff; + } + """) + + # Restore fixed size after style change + self.setFixedHeight(100) + if current_width > 0: + self.setMinimumWidth(current_width) + + def _set_reject_style(self): + """Set style for rejected drag items""" + # Ensure fixed size is maintained during style changes + current_width = self.width() + + if self.is_dark_mode: + self.setStyleSheet(""" + QFrame { + border: 2px solid #dc3545; + border-radius: 10px; + background-color: #2a1a1a; + } + """) + else: + self.setStyleSheet(""" + QFrame { + border: 2px solid #dc3545; + border-radius: 10px; + background-color: #fff5f5; + } + """) + + # Restore fixed size after style change + self.setFixedHeight(100) + if current_width > 0: + self.setMinimumWidth(current_width) + + def _reset_style(self): + """Reset to default style""" + # Ensure fixed size is maintained during style changes + current_width = self.width() + + if self.is_dark_mode: + self._apply_dark_theme_style() + else: + self._apply_light_theme_style() + + # Restore fixed size after style change + self.setFixedHeight(100) + if current_width > 0: + self.setMinimumWidth(current_width) + + self.drag_over = False + + def _update_text_label(self, total_files, supported_files, has_supported): + """Update the text label during drag operations""" + if has_supported: + if supported_files > 0: + if total_files == supported_files: + self.text_label.setText(f"Release to add {supported_files} image file(s)\n(Supported formats: {len(self.supported_formats)} types)") + else: + self.text_label.setText(f"Release to add {supported_files} image file(s)\n({total_files - supported_files} unsupported file(s) will be ignored)") + else: + # Check if we're dealing with folders + # If total_files is 1 and supported_files is 0, it's likely a folder + if total_files == 1: + self.text_label.setText("Release to process folder\n(Folder will be scanned for image files)") + else: + self.text_label.setText("Release to process folders\n(Folders will be scanned for image files)") + else: + self.text_label.setText("Drag image files or folders here") + + def _show_rejected_files_message(self, rejected_files): + """Show message about rejected files""" + if rejected_files: + rejected_names = [os.path.basename(f) for f in rejected_files[:3]] # Show first 3 + if len(rejected_files) > 3: + rejected_names.append(f"and {len(rejected_files) - 3} more...") + + # You can add a tooltip or status message here + print(f"Rejected {len(rejected_files)} unsupported file(s): {', '.join(rejected_names)}") + + +class DirectoryDropLineEdit(LineEdit): + """支持文件夹拖拽的输出路径输入框""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setPlaceholderText("Drag folder here or click Browse...") + + def dragEnterEvent(self, event): + """处理拖拽进入事件""" + if event.mimeData().hasUrls(): + urls = event.mimeData().urls() + # 检查是否包含文件夹 + has_folder = False + for url in urls: + if hasattr(url, 'toLocalFile'): + file_path = url.toLocalFile() + if file_path and os.path.isdir(file_path): + has_folder = True + break + elif hasattr(url, 'path') and os.path.isdir(url.path()): + has_folder = True + break + + if has_folder: + event.acceptProposedAction() + self.setStyleSheet(""" + LineEdit { + border: 2px solid #28a745; + border-radius: 4px; + background-color: #f0fff0; + } + """) + return + event.ignore() + + def dragLeaveEvent(self, event): + """处理拖拽离开事件""" + self.setStyleSheet("") + + def dragMoveEvent(self, event): + """处理拖拽移动事件""" + if event.mimeData().hasUrls(): + urls = event.mimeData().urls() + # 检查是否包含文件夹 + has_folder = False + for url in urls: + if hasattr(url, 'toLocalFile'): + file_path = url.toLocalFile() + if file_path and os.path.isdir(file_path): + has_folder = True + break + elif hasattr(url, 'path') and os.path.isdir(url.path()): + has_folder = True + break + + if has_folder: + event.acceptProposedAction() + return + event.ignore() + + def dropEvent(self, event): + """处理放置事件""" + self.setStyleSheet("") + + if event.mimeData().hasUrls(): + urls = event.mimeData().urls() + for url in urls: + if hasattr(url, 'toLocalFile'): + file_path = url.toLocalFile() + if file_path and os.path.isdir(file_path): + self.setText(file_path) + # 发射信号表示路径已设置 + self.editingFinished.emit() + event.acceptProposedAction() + return + elif hasattr(url, 'path') and os.path.isdir(url.path()): + file_path = url.path() + self.setText(file_path) + self.editingFinished.emit() + event.acceptProposedAction() + return + event.ignore() + + +class PreviewTab(QWidget): + """独立的预览标签页,支持单张和多张图片预览""" + + def __init__(self, parent=None): + super().__init__(parent) + self.current_previews = [] # 当前显示的预览图片 + self.setup_ui() + + def setup_ui(self): + """设置预览标签页界面""" + layout = QVBoxLayout(self) + + # 标题 + title_label = QLabel("Preview") + title_font = QFont() + title_font.setPointSize(title_label.font().pointSize() + 4) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title_label, 0, Qt.AlignmentFlag.AlignHCenter) + + # 信息标签 + self.info_label = QLabel("Drag image files to the tab above for preview") + self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.info_label.setStyleSheet("color: #666; font-style: italic;") + layout.addWidget(self.info_label, 0, Qt.AlignmentFlag.AlignHCenter) + + # 滚动区域用于显示多个预览 + scroll_area = ScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + scroll_area.setMinimumHeight(400) + + # 预览容器 + self.preview_container = QWidget() + self.preview_layout = QVBoxLayout(self.preview_container) + self.preview_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_layout.setSpacing(20) + + scroll_area.setWidget(self.preview_container) + layout.addWidget(scroll_area) + + + + + + + def show_single_preview(self, file_path): + """显示单张图片预览""" + self.clear_previews() + self._create_single_preview_widget(file_path) + self._update_info_label(1, [file_path]) + + def show_multiple_previews(self, file_paths): + """显示多张图片预览""" + self.clear_previews() + + # Filter out folders and only process files + valid_files = [] + for file_path in file_paths: + if os.path.isfile(file_path): + valid_files.append(file_path) + self._create_single_preview_widget(file_path) + else: + # Skip folders, they will be handled differently + print(f"Skipping folder: {file_path}") + + self._update_info_label(len(valid_files), valid_files) + + def clear_previews(self): + """清除所有预览""" + # 清除现有的预览widget + for i in reversed(range(self.preview_layout.count())): + child = self.preview_layout.itemAt(i).widget() + if child: + child.setParent(None) + child.deleteLater() + + self.current_previews = [] + + def _create_single_preview_widget(self, file_path): + """为单张图片创建预览widget""" + if not os.path.exists(file_path): + return + + # Skip folders, they are not image files + if os.path.isdir(file_path): + print(f"Skipping folder preview: {file_path}") + return + + # 创建单个预览的容器 + preview_widget = QWidget() + preview_widget.setFixedSize(300, 320) # 固定大小 + preview_widget.setStyleSheet(""" + QWidget { + border: 1px solid #ddd; + border-radius: 8px; + background-color: white; + } + QWidget:hover { + border-color: #007acc; + background-color: #f8f9fa; + } + """) + + preview_layout = QVBoxLayout(preview_widget) + preview_layout.setContentsMargins(10, 10, 10, 10) + preview_layout.setSpacing(10) + + # 图片显示区域 + image_label = QLabel() + image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + image_label.setFixedSize(280, 220) + image_label.setStyleSheet("border: 1px solid #eee; border-radius: 4px; background-color: #f5f5f5;") + + # 加载并显示图片 + if self._can_load_image(file_path): + pixmap = None + try: + pixmap = QPixmap(file_path) + if not pixmap.isNull(): + # 缩放图片以适应显示区域 + scaled_pixmap = pixmap.scaled( + 270, 210, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + image_label.setPixmap(scaled_pixmap) + else: + self._set_thumbnail_placeholder(image_label, file_path) + except Exception as e: + print(f"Error loading preview for {file_path}: {e}") + self._set_thumbnail_placeholder(image_label, file_path) + else: + self._set_thumbnail_placeholder(image_label, file_path) + + preview_layout.addWidget(image_label, 0, Qt.AlignmentFlag.AlignCenter) + + # 文件信息 + info_widget = QWidget() + info_layout = QVBoxLayout(info_widget) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(2) + + # 文件名 + filename_label = QLabel(os.path.basename(file_path)) + filename_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + filename_label.setStyleSheet("font-weight: bold; color: #333;") + filename_label.setWordWrap(True) + + # 文件大小 + try: + file_size = os.path.getsize(file_path) + size_text = self._format_file_size(file_size) + except: + size_text = "Unknown size" + + size_label = QLabel(size_text) + size_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + size_label.setStyleSheet("color: #666; font-size: 11px;") + + info_layout.addWidget(filename_label) + info_layout.addWidget(size_label) + preview_layout.addWidget(info_widget) + + # 添加到容器 + self.preview_layout.addWidget(preview_widget, 0, Qt.AlignmentFlag.AlignCenter) + self.current_previews.append((file_path, preview_widget)) + + def _can_load_image(self, file_path): + """检查是否能直接加载图片""" + if not file_path: + return False + + _, ext = os.path.splitext(file_path.lower()) + direct_load_formats = {'.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.ico', '.icns', '.webp'} + + return ext in direct_load_formats + + def _set_thumbnail_placeholder(self, label, file_path): + """设置缩略图占位符""" + _, ext = os.path.splitext(file_path.lower()) + + # 格式图标映射 + format_icons = { + '.svg': '🖼️', + '.pdf': '📄', + '.eps': '📄', + '.heic': '📱', + '.heif': '📱', + '.avif': '🖼️', + '.jxl': '🖼️', + '.dds': '🎮', + '.exr': '🎬' + } + + icon = format_icons.get(ext, '📷') + + # 创建占位符 + placeholder_pixmap = QPixmap(270, 210) + placeholder_pixmap.fill(QColor('#f5f5f5')) + + painter = QPainter(placeholder_pixmap) + painter.setPen(QColor('#999')) + painter.setFont(QFont('Arial', 48)) + + # 绘制图标 + painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, icon) + + # 绘制格式信息 + painter.setFont(QFont('Arial', 12)) + painter.drawText( + QRect(0, 170, 270, 40), + Qt.AlignmentFlag.AlignCenter, + f"{ext.upper()[1:]} Format" + ) + + painter.end() + + label.setPixmap(placeholder_pixmap) + + # 设置tooltip + label.setToolTip(f"Preview not available - {ext.upper()[1:]} Format\nWill show converted result") + + def _format_file_size(self, size_bytes): + """格式化文件大小""" + if size_bytes == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB"] + import math + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {size_names[i]}" + + def _update_info_label(self, count, file_paths): + """更新信息标签""" + if count == 0: + self.info_label.setText("Drag image files to the tab above for preview") + self.info_label.setStyleSheet("color: #666; font-style: italic;") + elif count == 1: + filename = os.path.basename(file_paths[0]) + self.info_label.setText(f"Previewing: {filename}") + self.info_label.setStyleSheet("color: #007acc; font-weight: bold;") + else: + self.info_label.setText(f"Previewing {count} images") + self.info_label.setStyleSheet("color: #28a745; font-weight: bold;") + + +class ThumbnailGridWidget(QWidget): + """Custom widget for displaying thumbnail grid of batch files""" + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(150) + self.thumbnails = [] + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + self.scroll_area = ScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setMaximumHeight(200) + + self.thumbnail_container = QWidget() + self.thumbnail_layout = QVBoxLayout(self.thumbnail_container) + + self.scroll_area.setWidget(self.thumbnail_container) + layout.addWidget(self.scroll_area) + + # Placeholder text + placeholder_label = QLabel("No files selected") + placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + placeholder_label.setStyleSheet("color: #999; font-style: italic;") + self.thumbnail_layout.addWidget(placeholder_label) + + def add_thumbnails(self, file_paths): + """Add thumbnails for the given file paths""" + # Clear existing thumbnails + self.clear_thumbnails() + + # Define supported image formats + supported_formats = {'.png', '.jpg', '.jpeg', '.webp', '.ico', '.bmp', '.tiff', '.tif', '.gif', '.svg', '.psd'} + + for file_path in file_paths: + try: + if os.path.exists(file_path): + _, ext = os.path.splitext(file_path.lower()) + + if ext in supported_formats: + thumbnail = self.create_thumbnail_widget(file_path, ext) + self.thumbnail_layout.addWidget(thumbnail) + self.thumbnails.append(thumbnail) + else: + # Create placeholder for unsupported formats + thumbnail = self.create_unsupported_thumbnail(file_path, ext) + self.thumbnail_layout.addWidget(thumbnail) + self.thumbnails.append(thumbnail) + except Exception as e: + print(f"Error creating thumbnail for {file_path}: {e}") + + # Remove placeholder if thumbnails were added + if self.thumbnails: + for i in range(self.thumbnail_layout.count()): + item = self.thumbnail_layout.itemAt(i) + if item and item.widget() and isinstance(item.widget(), QLabel): + if item.widget().text() == "No files selected": + widget = item.widget() + self.thumbnail_layout.removeWidget(widget) + widget.deleteLater() + break + + def create_thumbnail_widget(self, file_path, file_ext): + """Create a thumbnail widget for a single file""" + container = QWidget() + container.setMaximumHeight(80) + container.setStyleSheet(""" + QWidget { + border: 1px solid #ddd; + border-radius: 5px; + margin: 2px; + padding: 5px; + } + QWidget:hover { + border-color: #007acc; + background-color: #f5f5f5; + } + """) + + layout = QHBoxLayout(container) + layout.setContentsMargins(5, 5, 5, 5) + + # Thumbnail image or icon + thumbnail_label = QLabel() + thumbnail_label.setFixedSize(60, 60) + thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Try to load image for supported formats + if self._can_load_image(file_ext): + pixmap = None + try: + pixmap = QPixmap(file_path) + if not pixmap.isNull(): + scaled_pixmap = pixmap.scaled(60, 60, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + thumbnail_label.setPixmap(scaled_pixmap) + else: + self._set_thumbnail_placeholder(thumbnail_label, file_ext, "Load Error") + except Exception as e: + print(f"Error loading image {file_path}: {e}") + self._set_thumbnail_placeholder(thumbnail_label, file_ext, "Error") + else: + self._set_thumbnail_placeholder(thumbnail_label, file_ext) + + # File info + info_layout = QVBoxLayout() + filename_label = QLabel(os.path.basename(file_path)) + filename_label.setStyleSheet("font-weight: bold; font-size: 12px;") + + # Get file size + try: + size_bytes = os.path.getsize(file_path) + size_str = self.format_file_size(size_bytes) + except: + size_str = "Unknown size" + + size_label = QLabel(f"Size: {size_str}") + size_label.setStyleSheet("color: #666; font-size: 10px;") + + info_layout.addWidget(filename_label) + info_layout.addWidget(size_label) + info_layout.addStretch() + + layout.addWidget(thumbnail_label) + layout.addLayout(info_layout) + layout.addStretch() + + return container + + def create_unsupported_thumbnail(self, file_path, file_ext): + """Create a thumbnail widget for unsupported file formats""" + container = QWidget() + container.setMaximumHeight(80) + container.setStyleSheet(""" + QWidget { + border: 1px dashed #ccc; + border-radius: 5px; + margin: 2px; + padding: 5px; + background-color: #f9f9f9; + } + QWidget:hover { + border-color: #999; + background-color: #f0f0f0; + } + """) + + layout = QHBoxLayout(container) + layout.setContentsMargins(5, 5, 5, 5) + + # Icon for unsupported format + icon_label = QLabel() + icon_label.setFixedSize(60, 60) + icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._set_thumbnail_placeholder(icon_label, file_ext, show_format=True) + + # File info + info_layout = QVBoxLayout() + filename_label = QLabel(os.path.basename(file_path)) + filename_label.setStyleSheet("font-weight: bold; font-size: 12px; color: #666;") + + # Get file size + try: + size_bytes = os.path.getsize(file_path) + size_str = self.format_file_size(size_bytes) + except: + size_str = "Unknown size" + + size_label = QLabel(f"Size: {size_str}") + size_label.setStyleSheet("color: #999; font-size: 10px;") + + info_layout.addWidget(filename_label) + info_layout.addWidget(size_label) + info_layout.addStretch() + + layout.addWidget(icon_label) + layout.addLayout(info_layout) + layout.addStretch() + + return container + + def _can_load_image(self, file_ext): + """Check if the file extension can be loaded as an image by Qt""" + # Qt supports most common formats, but some require plugins + loadable_formats = {'.png', '.jpg', '.jpeg', '.webp', '.ico', '.bmp', '.tif', '.tiff', '.gif'} + return file_ext.lower() in loadable_formats + + def _set_thumbnail_placeholder(self, label, file_ext, error_text=None, show_format=False): + """Set placeholder text and style for thumbnail label""" + if error_text: + # Error state + label.setText(error_text) + label.setStyleSheet(""" + QLabel { + border: 1px solid #ddd; + border-radius: 5px; + background-color: #ffe6e6; + color: #dc3545; + font-size: 10px; + font-weight: bold; + } + """) + elif show_format: + # Show format information for unsupported but recognized formats + format_name = file_ext.upper()[1:] if file_ext.startswith('.') else file_ext.upper() + icon_map = { + 'SVG': '🎨', + 'PSD': '🎨', + 'BMP': '🖼️', + 'TIFF': '🖼️', + 'TIF': '🖼️', + 'GIF': '🖼️', + 'PNG': '🖼️', + 'JPG': '🖼️', + 'JPEG': '🖼️', + 'WEBP': '🖼️', + 'ICO': '🖼️' + } + icon = icon_map.get(format_name, '📄') + label.setText(f"{icon}\n{format_name}") + label.setStyleSheet(""" + QLabel { + border: 1px solid #ddd; + border-radius: 5px; + background-color: #f5f5f5; + color: #666; + font-size: 8px; + font-weight: bold; + text-align: center; + } + """) + else: + # Generic file icon + label.setText("📄") + label.setStyleSheet(""" + QLabel { + border: 1px solid #ddd; + border-radius: 5px; + background-color: #f5f5f5; + color: #666; + font-size: 24px; + } + """) + + def clear_thumbnails(self): + """Clear all thumbnails""" + # Remove all thumbnail widgets + for thumbnail in self.thumbnails: + if thumbnail and thumbnail.parent(): + thumbnail.setParent(None) + thumbnail.deleteLater() + + self.thumbnails.clear() + + # Add placeholder back + placeholder_label = QLabel("No files selected") + placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + placeholder_label.setStyleSheet("color: #999; font-style: italic;") + self.thumbnail_layout.addWidget(placeholder_label) + + def format_file_size(self, size_bytes): + """Format file size in human readable format""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + else: + return f"{size_bytes / (1024 * 1024):.1f} MB" diff --git a/support/__pycache__/convert.cpython-314.pyc b/support/__pycache__/convert.cpython-314.pyc index a5343ad..ed845e0 100644 Binary files a/support/__pycache__/convert.cpython-314.pyc and b/support/__pycache__/convert.cpython-314.pyc differ diff --git a/support/archive_manager.py b/support/archive_manager.py index f284b3a..f7842df 100644 --- a/support/archive_manager.py +++ b/support/archive_manager.py @@ -122,12 +122,12 @@ def _get_archive_type(file_path): return "lzh" return None -def _run_command_with_timeout(cmd, timeout=2, progress_callback=None): +def _run_command_with_timeout(cmd, timeout=2, progress_callback=None, cwd=None): """Run command with timeout limit""" start_time = time.time() try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd) if progress_callback: elapsed = time.time() - start_time @@ -355,13 +355,13 @@ def create_archive(output_path, source_paths, archive_format, progress_callback= if progress_callback: progress_callback(f"Starting {archive_format} archive creation...", 0) - # For password-protected ZIP files, use direct processing without wrapper directory + # For password-protected ZIP files, use CLI tool if archive_format == "zip" and password: if progress_callback: - progress_callback(f"Creating password-protected ZIP file directly...", 10) + progress_callback(f"Creating password-protected ZIP file with CLI tool...", 10) - # Use a specialized function for password-protected ZIP files - success = _create_password_protected_zip(output_path, source_paths, password, progress_callback) + # Use CLI tool for password-protected ZIP files + success = _create_zip_with_cli(output_path, source_paths, progress_callback, password) if not success: raise RuntimeError(f"Failed to create password-protected ZIP archive") else: @@ -370,10 +370,14 @@ def create_archive(output_path, source_paths, archive_format, progress_callback= temp_dir = tempfile.mkdtemp() wrapper_dir = os.path.join(temp_dir, archive_name) - # Ensure the wrapper directory doesn't already exist - if os.path.exists(wrapper_dir): - shutil.rmtree(wrapper_dir) - os.makedirs(wrapper_dir) + # Create the wrapper directory - ensure it doesn't already exist + try: + os.makedirs(wrapper_dir, exist_ok=False) + except FileExistsError: + # If directory already exists, remove it and create a new one + if os.path.exists(wrapper_dir): + shutil.rmtree(wrapper_dir) + os.makedirs(wrapper_dir) try: # Copy all source files to the wrapper directory @@ -405,9 +409,12 @@ def create_archive(output_path, source_paths, archive_format, progress_callback= progress_callback(f"Creating {archive_format} archive...", 40) success = False - if archive_format in ["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "zipx"]: - # Use patool for processing - success = _create_with_patool(output_path, [wrapped_source_path], archive_format, progress_callback, password) + if archive_format == "zip": + # Use CLI tool for ZIP creation + success = _create_zip_with_cli(output_path, [wrapped_source_path], progress_callback, password) + elif archive_format in ["tar", "tar.gz", "tar.bz2", "tar.xz", "zipx"]: + # Use CLI tool for processing + success = _create_tar_with_cli(output_path, [wrapped_source_path], archive_format, progress_callback) elif archive_format in ["bz2", "xz", "lzma", "gz"]: # These formats can only compress single files, so need to create tar first, then compress success = _create_single_file_compression(output_path, [wrapped_source_path], archive_format, progress_callback) @@ -445,279 +452,213 @@ def create_archive(output_path, source_paths, archive_format, progress_callback= progress_callback(f"Error creating archive: {str(e)}", -1) return False -def _create_with_patool(output_path, source_paths, archive_format, progress_callback=None, password=None): - """Create archive file using patool""" - import subprocess - try: - import patoolib - except ImportError: - raise ImportError("patool is required for this format. Install with: pip install patool") - - # Create temporary directory - temp_dir = tempfile.mkdtemp() + + +def _create_single_file_compression(output_path, source_paths, compression_format, progress_callback=None): + """ + Create single file compression format (bz2, xz, lzma, gz) + These formats can only compress single files, so need to create tar first, then compress + """ try: - # Copy all source files to temporary directory - for source_path in source_paths: - if os.path.isfile(source_path): - shutil.copy2(source_path, temp_dir) - elif os.path.isdir(source_path): - dest_dir = os.path.join(temp_dir, os.path.basename(source_path)) - # Ensure the destination directory doesn't already exist - if os.path.exists(dest_dir): - shutil.rmtree(dest_dir) - shutil.copytree(source_path, dest_dir) - - # Use patool to create archive - fix path issues, use full paths - temp_files = [] - for item in os.listdir(temp_dir): - item_path = os.path.join(temp_dir, item) - temp_files.append(item_path) - - if not temp_files: - raise ValueError("No files found to archive") + # Create temporary directory + temp_dir = tempfile.mkdtemp() + try: + # Step 1: Create tar file using tar command + base_name = os.path.splitext(os.path.basename(output_path))[0] + tar_path = os.path.join(temp_dir, f"{base_name}.tar") - if progress_callback: - progress_callback(f"Creating {archive_format} archive with patool...", 50) - - # Add debug information - if progress_callback: - progress_callback(f"Output path: {output_path}", 55) - progress_callback(f"Temp files: {temp_files}", 60) - - # For ZIP format with password, use Python's zipfile module - if archive_format == "zip" and password: if progress_callback: - progress_callback(f"Creating password-protected ZIP file...", 65) - - password_success = False - - # First try to use zip command if available (most compatible) - zip_tool = _get_cli_tool("zip") - if zip_tool: - if progress_callback: - progress_callback(f"Trying to create password-protected ZIP with zip command...", 70) - - # Create a temporary directory for zip to work with - temp_zip_dir = tempfile.mkdtemp() - try: - # Ensure output file doesn't exist - if os.path.exists(output_path): - os.remove(output_path) - - # Copy files to temp directory - for file_path in temp_files: - if os.path.isfile(file_path): - shutil.copy2(file_path, temp_zip_dir) - elif os.path.isdir(file_path): - dest_dir = os.path.join(temp_zip_dir, os.path.basename(file_path)) - shutil.copytree(file_path, dest_dir) - - # Create password-protected ZIP with zip command - cmd = [zip_tool, "-r", "-P", password, output_path, "."] - result = subprocess.run(cmd, cwd=temp_zip_dir, capture_output=True, text=True) - - # Check if the command succeeded (return code 0) and the output file exists - if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0: - password_success = True - if progress_callback: - progress_callback(f"Password-protected ZIP created with zip command", 85) - else: - if progress_callback: - progress_callback(f"Failed to create password-protected ZIP with zip command: return code {result.returncode}", 75) - finally: - shutil.rmtree(temp_zip_dir) + progress_callback(f"Creating intermediate tar file...", 30) + + # Build tar command + cmd = ["tar", "-c", "-f", tar_path] + + # Process source paths + if len(source_paths) == 1: + source_path = source_paths[0] + if os.path.isdir(source_path): + # For directories, add directory contents + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) + cmd.append(rel_path) + else: + # For files, use directory containing the file + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) + cmd.append(rel_path) else: - if progress_callback: - progress_callback(f"zip command not available, trying pyzipper...", 75) - - # If zip command failed, try to use pyzipper - if not password_success: - try: - import pyzipper - if progress_callback: - progress_callback(f"Creating password-protected ZIP with pyzipper...", 70) - - # Create a password-protected ZIP with pyzipper - with pyzipper.AESZipFile(output_path, 'w', compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES) as zf: - zf.setpassword(password.encode()) - for file_path in temp_files: - if os.path.isfile(file_path): - # 只使用文件名,不包含路径 - zf.write(file_path, os.path.basename(file_path)) - elif os.path.isdir(file_path): - # 对于目录,递归添加所有文件,保留目录结构 - for root, dirs, files in os.walk(file_path): - for file in files: - file_full_path = os.path.join(root, file) - # 计算相对于目录的路径,保留子目录结构 - arcname = os.path.relpath(file_full_path, file_path) - zf.write(file_full_path, arcname) - - password_success = True - if progress_callback: - progress_callback(f"Password-protected ZIP created with pyzipper", 85) - - except ImportError: - if progress_callback: - progress_callback(f"pyzipper not available, trying 7zz...", 75) - except Exception as e: - if progress_callback: - progress_callback(f"Failed to create password-protected ZIP with pyzipper: {str(e)}", 75) + # Multiple sources - use parent directory as working directory + work_dir = os.path.commonpath([os.path.dirname(p) if os.path.isfile(p) else p for p in source_paths]) + for source_path in source_paths: + if os.path.isfile(source_path): + cmd.append(os.path.basename(source_path)) + elif os.path.isdir(source_path): + cmd.append(os.path.basename(source_path)) - # If pyzipper failed, try to use 7zz - if not password_success: - sevenz_tool = _get_cli_tool("7zz") - if sevenz_tool: - if progress_callback: - progress_callback(f"Trying to create password-protected ZIP with 7zz...", 75) - - # Create a temporary directory for 7zz to work with - temp_7z_dir = tempfile.mkdtemp() - try: - # Copy files to temp directory - for file_path in temp_files: - if os.path.isfile(file_path): - shutil.copy2(file_path, temp_7z_dir) - elif os.path.isdir(file_path): - dest_dir = os.path.join(temp_7z_dir, os.path.basename(file_path)) - shutil.copytree(file_path, dest_dir) - - # Create password-protected ZIP with 7zz - cmd = [sevenz_tool, "a", f"-p{password}", "-y", output_path, os.path.join(temp_7z_dir, "*")] - result = subprocess.run(cmd, capture_output=True, text=True) - - if result.returncode == 0: - password_success = True - if progress_callback: - progress_callback(f"Password-protected ZIP created with 7zz", 85) - else: - if progress_callback: - progress_callback(f"Failed to create password-protected ZIP with 7zz: {result.stderr}", 80) - finally: - shutil.rmtree(temp_7z_dir) - else: - if progress_callback: - progress_callback(f"7zz not available", 75) + # Run tar command + result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=30) - # If pyzipper and 7zz both failed, we'll fall back to standard ZIP without password - if not password_success: - if progress_callback: - progress_callback(f"All password protection methods failed", 75) + if result.returncode != 0: + raise RuntimeError(f"tar creation failed: {result.stderr}") - # If all methods failed, create a non-password-protected ZIP - if not password_success: - if progress_callback: - progress_callback(f"Warning: Could not create password-protected ZIP, creating standard ZIP", 75) - - with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf: - for file_path in temp_files: - if os.path.isfile(file_path): - # 只使用文件名,不包含路径 - zf.write(file_path, os.path.basename(file_path)) - elif os.path.isdir(file_path): - # 对于目录,递归添加所有文件,保留目录结构 - for root, dirs, files in os.walk(file_path): - for file in files: - file_full_path = os.path.join(root, file) - # 计算相对于目录的路径,保留子目录结构 - arcname = os.path.relpath(file_full_path, file_path) - zf.write(file_full_path, arcname) - - if progress_callback: - progress_callback(f"Standard ZIP created (no password protection)", 85) + if not os.path.exists(tar_path): + raise RuntimeError("Failed to create intermediate tar file") + # Step 2: Compress tar file using appropriate command if progress_callback: - progress_callback(f"ZIP archive created", 90) - else: - # Try different patool calling methods - try: - # Method 1: Directly pass file paths - patoolib.create_archive(output_path, temp_files) - except Exception as e1: - # Method 2: Switch to temporary directory and use relative paths + progress_callback(f"Compressing tar file with {compression_format}...", 60) + + # Build compression command based on format + if compression_format == "gz": try: - original_cwd = os.getcwd() - os.chdir(temp_dir) - rel_files = [os.path.basename(f) for f in temp_files] - patoolib.create_archive(output_path, rel_files) - os.chdir(original_cwd) - except Exception as e2: - os.chdir(original_cwd) - - # Method 3: Use full paths and patool's --verbose option + gzip_tool = _get_cli_tool("gzip") + cmd = [gzip_tool, "-c", tar_path] + except FileNotFoundError: + # Fallback to system gzip + cmd = ["gzip", "-c", tar_path] + output_file = open(output_path, "wb") + result = subprocess.run(cmd, stdout=output_file, stderr=subprocess.PIPE) + output_file.close() + elif compression_format == "bz2": + try: + bzip2_tool = _get_cli_tool("bzip2") + cmd = [bzip2_tool, "-c", tar_path] + except FileNotFoundError: + # Fallback to system bzip2 + cmd = ["bzip2", "-c", tar_path] + output_file = open(output_path, "wb") + result = subprocess.run(cmd, stdout=output_file, stderr=subprocess.PIPE) + output_file.close() + elif compression_format == "xz": + try: + xz_tool = _get_cli_tool("xz") + cmd = [xz_tool, "-c", tar_path] + output_file = open(output_path, "wb") + result = subprocess.run(cmd, stdout=output_file, stderr=subprocess.PIPE) + output_file.close() + except FileNotFoundError: + # Fallback to 7zz tool try: - import subprocess - cmd = ["patool", "create", output_path] + temp_files + sevenz_tool = _get_cli_tool("7zz") + cmd = [sevenz_tool, "a", "-txz", output_path, tar_path] result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - raise RuntimeError(f"patool command failed: {result.stderr}") - except Exception as e3: - raise RuntimeError(f"All patool methods failed: {e1}, {e2}, {e3}") + except FileNotFoundError: + # Final fallback to system xz + cmd = ["xz", "-c", tar_path] + output_file = open(output_path, "wb") + result = subprocess.run(cmd, stdout=output_file, stderr=subprocess.PIPE) + output_file.close() + elif compression_format == "lzma": + # Use Python's lzma module directly + try: + import lzma + with open(tar_path, 'rb') as f_in: + with lzma.open(output_path, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + # Create a dummy successful result + class DummyResult: + def __init__(self): + self.returncode = 0 + self.stderr = "" + result = DummyResult() + except Exception as e: + raise RuntimeError(f"Failed to create LZMA archive: {str(e)}") + else: + raise ValueError(f"Unsupported compression format: {compression_format}") - if progress_callback: - progress_callback(f"{archive_format} archive created", 90) + if result.returncode != 0: + raise RuntimeError(f"Compression failed: {result.stderr}") - return True + if progress_callback: + progress_callback(f"{compression_format} archive created", 90) + + return True + finally: + shutil.rmtree(temp_dir) except Exception as e: if progress_callback: - progress_callback(f"Error creating {archive_format} archive: {str(e)}", -1) + progress_callback(f"Error creating {compression_format} archive: {str(e)}", -1) return False - finally: - shutil.rmtree(temp_dir) -def _create_single_file_compression(output_path, source_paths, compression_format, progress_callback=None): - """ - Create single file compression format (bz2, xz, lzma) - These formats can only compress single files, so need to create tar first, then compress - """ +def _create_zip_with_cli(output_path, source_paths, progress_callback=None, password=None): + """Create ZIP file using zip CLI tool""" try: - import patoolib - except ImportError: - raise ImportError("patool is required for this format. Install with: pip install patool") + zip_tool = _get_cli_tool("zip") + except FileNotFoundError: + # If zip tool is not found, raise an error instead of falling back to patool + raise RuntimeError("zip tool not found. Please install zip command-line tool") + + # Build zip command - use working directory approach + temp_dir = None # Initialize temp_dir to None + if len(source_paths) == 1: + # Single source - can use directly + source_path = source_paths[0] + if os.path.isdir(source_path): + # For directories, use relative path from parent directory + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) + else: + # For files, use directory containing the file + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) + else: + # Multiple sources - create temporary directory approach + temp_dir = tempfile.mkdtemp() + work_dir = temp_dir + rel_paths = [] + + try: + for source_path in source_paths: + if os.path.isfile(source_path): + dest_path = os.path.join(temp_dir, os.path.basename(source_path)) + shutil.copy2(source_path, dest_path) + rel_paths.append(os.path.basename(source_path)) + elif os.path.isdir(source_path): + dest_path = os.path.join(temp_dir, os.path.basename(source_path)) + shutil.copytree(source_path, dest_path) + rel_paths.append(os.path.basename(source_path)) + except Exception: + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + raise + + source_paths = rel_paths - # Create temporary directory - temp_dir = tempfile.mkdtemp() try: - # Step 1: Create tar file - base_name = os.path.splitext(os.path.basename(output_path))[0] - tar_path = os.path.join(temp_dir, f"{base_name}.tar") + cmd = [zip_tool, "-r"] + if password: + cmd.extend(["-P", password]) + cmd.append(output_path) + cmd.extend(source_paths if len(source_paths) > 1 else [rel_path]) + + # Ensure output path is absolute + output_path_abs = os.path.abspath(output_path) + cmd[cmd.index(output_path)] = output_path_abs if progress_callback: - progress_callback(f"Creating intermediate tar file...", 50) + progress_callback(f"Creating ZIP archive with CLI tool...", 50) - # Create tar file - patoolib.create_archive(tar_path, source_paths) + result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=2, progress_callback=progress_callback) - if not os.path.exists(tar_path): - raise RuntimeError("Failed to create intermediate tar file") + if result.returncode != 0: + raise RuntimeError(f"ZIP creation failed: {result.stderr}") - # Step 2: Compress tar file if progress_callback: - progress_callback(f"Compressing tar file with {compression_format}...", 70) - - # Use patool to compress tar file - patoolib.create_archive(output_path, [tar_path]) + progress_callback(f"ZIP archive created successfully", 90) - if progress_callback: - progress_callback(f"{compression_format} archive created", 90) - return True - except Exception as e: - if progress_callback: - progress_callback(f"Error creating {compression_format} archive: {str(e)}", -1) - return False finally: - shutil.rmtree(temp_dir) + # Clean up temporary directory if it was created + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir) def _create_cab_with_cli(output_path, source_paths, progress_callback=None): """Create CAB archive file using gcab tool""" - try: - gcab_tool = _get_cli_tool("gcab") - except FileNotFoundError: - # Try to get gcab from system path - gcab_tool = shutil.which("gcab") - if not gcab_tool: - raise RuntimeError("gcab tool not found. Please install with: brew install gcab") + # Use project's built-in gcab tool directly + gcab_tool = os.path.join(CLI_BASE_PATH, "Universal", "gcab") + + if not os.path.exists(gcab_tool): + raise RuntimeError(f"Built-in gcab tool not found at {gcab_tool}") # Create temporary directory for source files with tempfile.TemporaryDirectory() as temp_dir: @@ -744,12 +685,12 @@ def _create_cab_with_cli(output_path, source_paths, progress_callback=None): # Update progress if progress_callback: progress = (i + 1) / len(source_paths) * 50 # First half of progress for copying files - progress_callback(progress) + progress_callback(f"Copying files for CAB archive...", progress) if not copied_files: raise ValueError("No valid source files found") - # Build gcab command + # Build gcab command for CAB format cmd = [gcab_tool, "-c", "-n", output_path] cmd.extend([str(f) for f in copied_files]) @@ -765,7 +706,7 @@ def _create_cab_with_cli(output_path, source_paths, progress_callback=None): # Update final progress if progress_callback: - progress_callback(100) + progress_callback("CAB archive created successfully", 100) return True except subprocess.CalledProcessError as e: @@ -779,21 +720,140 @@ def _create_rar_with_cli(output_path, source_paths, progress_callback=None, pass # Fallback: Use unar/lsar return _create_with_unar(output_path, source_paths, "rar", progress_callback) - # Build rar command - cmd = [rar_tool, "a", "-r"] - if password: - cmd.extend(["-p" + password, "-y"]) + # Build rar command - use working directory approach + temp_dir = None # Initialize temp_dir to None + if len(source_paths) == 1: + # Single source - can use directly + source_path = source_paths[0] + if os.path.isdir(source_path): + # For directories, use relative path from parent directory + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) + else: + # For files, use directory containing the file + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) else: - cmd.append("-y") - cmd.append(output_path) - cmd.extend(source_paths) - - result = _run_command_with_timeout(cmd, timeout=2, progress_callback=progress_callback) + # Multiple sources - create temporary directory approach + temp_dir = tempfile.mkdtemp() + work_dir = temp_dir + rel_paths = [] + + try: + for source_path in source_paths: + if os.path.isfile(source_path): + dest_path = os.path.join(temp_dir, os.path.basename(source_path)) + shutil.copy2(source_path, dest_path) + rel_paths.append(os.path.basename(source_path)) + elif os.path.isdir(source_path): + dest_path = os.path.join(temp_dir, os.path.basename(source_path)) + shutil.copytree(source_path, dest_path) + rel_paths.append(os.path.basename(source_path)) + except Exception: + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + raise + + source_paths = rel_paths - if result.returncode != 0: - raise RuntimeError(f"RAR creation failed: {result.stderr}") + try: + cmd = [rar_tool, "a", "-r"] + if password: + cmd.extend(["-p" + password, "-y"]) + else: + cmd.append("-y") + cmd.append(output_path) + cmd.extend(source_paths if len(source_paths) > 1 else [rel_path]) + + # Ensure output path is absolute + output_path_abs = os.path.abspath(output_path) + cmd[cmd.index(output_path)] = output_path_abs + + result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=2, progress_callback=progress_callback) + + if result.returncode != 0: + raise RuntimeError(f"RAR creation failed: {result.stderr}") + + return True + finally: + # Clean up temporary directory if it was created + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + +def _create_tar_with_cli(output_path, source_paths, archive_format, progress_callback=None, password=None): + """Create tar file using system tar command""" + try: + if progress_callback: + progress_callback(f"Creating {archive_format} archive with tar command...", 0) + + # Ensure output path is absolute + output_path_abs = os.path.abspath(output_path) + + # Build tar command based on format + cmd = ["tar", "-c"] + + # Add compression options based on format + if archive_format == "tar.gz" or archive_format == "tgz": + cmd.append("-z") + elif archive_format == "tar.bz2" or archive_format == "tbz2": + cmd.append("-j") + elif archive_format == "tar.xz" or archive_format == "txz": + cmd.append("-J") + + # Add output file option + cmd.extend(["-f", output_path_abs]) + + # Handle password protection for zipx format + if archive_format == "zipx" and password: + # For zipx, we'll create a standard zip with password protection + return _create_zip_with_cli(output_path, source_paths, progress_callback, password) + + # Process source paths + if len(source_paths) == 1: + source_path = source_paths[0] + if os.path.isdir(source_path): + # For directories, add directory contents + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) + cmd.append(rel_path) + else: + # For files, use directory containing the file + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) + cmd.append(rel_path) + else: + # Multiple sources - use parent directory as working directory + work_dir = os.path.commonpath([os.path.dirname(p) if os.path.isfile(p) else p for p in source_paths]) + for source_path in source_paths: + if os.path.isfile(source_path): + cmd.append(os.path.basename(source_path)) + elif os.path.isdir(source_path): + cmd.append(os.path.basename(source_path)) + + if progress_callback: + progress_callback(f"Running tar command for {archive_format}...", 50) + + # Run tar command + result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=30, progress_callback=progress_callback) + + if result.returncode != 0: + raise RuntimeError(f"tar creation failed: {result.stderr}") + + # Handle zipx format (rename tar to zipx) + if archive_format == "zipx": + temp_path = output_path_abs + ".tmp" + os.rename(output_path_abs, temp_path) + os.rename(temp_path, output_path) + + if progress_callback: + progress_callback(f"{archive_format} archive created successfully", 100) + + return True - return True + except Exception as e: + if progress_callback: + progress_callback(f"Error creating {archive_format} archive: {str(e)}", -1) + return False def _create_7z_with_cli(output_path, source_paths, progress_callback=None, password=None): """Create 7z file using 7zz CLI tool""" @@ -804,20 +864,65 @@ def _create_7z_with_cli(output_path, source_paths, progress_callback=None, passw _create_with_unar(output_path, source_paths, "7z", progress_callback) return True - cmd = [sevenz_tool, "a"] - if password: - cmd.extend(["-p" + password, "-y"]) + # Build 7z command - use working directory approach + temp_dir = None # Initialize temp_dir to None + if len(source_paths) == 1: + # Single source - can use directly + source_path = source_paths[0] + if os.path.isdir(source_path): + # For directories, use relative path from parent directory + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) + else: + # For files, use directory containing the file + work_dir = os.path.dirname(source_path) or "." + rel_path = os.path.basename(source_path) else: - cmd.append("-y") - cmd.append(output_path) - cmd.extend(source_paths) - - result = _run_command_with_timeout(cmd, timeout=2, progress_callback=progress_callback) - - if result.returncode != 0: - raise RuntimeError(f"7z creation failed: {result.stderr}") + # Multiple sources - create temporary directory approach + temp_dir = tempfile.mkdtemp() + work_dir = temp_dir + rel_paths = [] + + try: + for source_path in source_paths: + if os.path.isfile(source_path): + dest_path = os.path.join(temp_dir, os.path.basename(source_path)) + shutil.copy2(source_path, dest_path) + rel_paths.append(os.path.basename(source_path)) + elif os.path.isdir(source_path): + dest_path = os.path.join(temp_dir, os.path.basename(source_path)) + shutil.copytree(source_path, dest_path) + rel_paths.append(os.path.basename(source_path)) + except Exception: + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + raise + + source_paths = rel_paths - return True + try: + cmd = [sevenz_tool, "a"] + if password: + cmd.extend(["-p" + password, "-y"]) + else: + cmd.append("-y") + cmd.append(output_path) + cmd.extend(source_paths if len(source_paths) > 1 else [rel_path]) + + # Ensure output path is absolute + output_path_abs = os.path.abspath(output_path) + cmd[cmd.index(output_path)] = output_path_abs + + result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=2, progress_callback=progress_callback) + + if result.returncode != 0: + raise RuntimeError(f"7z creation failed: {result.stderr}") + + return True + finally: + # Clean up temporary directory if it was created + if temp_dir and os.path.exists(temp_dir): + shutil.rmtree(temp_dir) def _create_iso_with_system(output_path, source_paths, progress_callback=None): """Create ISO file using system command""" @@ -826,12 +931,15 @@ def _create_iso_with_system(output_path, source_paths, progress_callback=None): source_dir = source_paths[0] + # Ensure output path is absolute + output_path_abs = os.path.abspath(output_path) + # Try using hdiutil (macOS) if platform.system() == "Darwin": - cmd = ["hdiutil", "makehybrid", "-o", output_path, "-hfs", "-iso", "-joliet", source_dir] + cmd = ["hdiutil", "makehybrid", "-o", output_path_abs, "-hfs", "-iso", "-joliet", source_dir] else: # Other systems use mkisofs - cmd = ["mkisofs", "-o", output_path, "-J", "-R", source_dir] + cmd = ["mkisofs", "-o", output_path_abs, "-J", "-R", source_dir] result = _run_command_with_timeout(cmd, timeout=60, progress_callback=progress_callback) @@ -958,11 +1066,8 @@ def extract_archive(archive_path, extract_to, progress_callback=None, password=N # Use unar as fallback on failure _extract_with_unar(archive_path, extract_to, progress_callback, password) elif archive_format in ["zipx"]: - # Use patool, fallback to unar on failure - try: - _extract_with_patool(archive_path, extract_to, progress_callback, password) - except Exception: - _extract_with_unar(archive_path, extract_to, progress_callback, password) + # Use unar directly for zipx format + _extract_with_unar(archive_path, extract_to, progress_callback, password) elif archive_format == "cab": # Prioritize using cabextract tool to extract CAB files try: @@ -1019,6 +1124,17 @@ def extract_archive(archive_path, extract_to, progress_callback=None, password=N if progress_callback: progress_callback(f"Archive extracted to: {extract_to}", 100) + + # 为解压出的可执行文件添加执行权限 + try: + _set_executable_permissions(extract_to, progress_callback) + if progress_callback: + progress_callback("Executable permissions set", 100) + except Exception as e: + # 权限设置失败不应该影响解压结果,只是记录警告 + if progress_callback: + progress_callback(f"Warning: Failed to set executable permissions: {str(e)}", 90) + return True except Exception as e: @@ -1045,6 +1161,18 @@ def _extract_with_python(archive_path, extract_to, archive_format, progress_call total_files = len(file_list) for i, file_name in enumerate(file_list): try: + # 清理绝对路径,防止解压到系统目录 + if file_name.startswith('/') or file_name.startswith('./'): + # 将绝对路径转换为相对路径 + file_name = os.path.relpath(file_name, '/') + if file_name == '.': + continue # 跳过当前目录引用 + + # 防止路径遍历攻击(../) + if '..' in file_name.split(os.sep): + # 跳过包含父目录引用的路径 + continue + zipf.extract(file_name, extract_to) except (RuntimeError, pyzipper.BadZipFile) as e: error_msg = str(e).lower() @@ -1095,29 +1223,27 @@ def _extract_with_python(archive_path, extract_to, archive_format, progress_call total_files = len(file_list) for i, file_name in enumerate(file_list): try: - # 确保文件直接解压到目标目录,不保留路径结构 + # 清理绝对路径,防止解压到系统目录 + if file_name.startswith('/') or file_name.startswith('./'): + # 将绝对路径转换为相对路径 + file_name = os.path.relpath(file_name, '/') + if file_name == '.': + continue # 跳过当前目录引用 + + # 防止路径遍历攻击(../) + if '..' in file_name.split(os.sep): + # 跳过包含父目录引用的路径 + continue + + # 保留压缩包的目录结构 if file_name.endswith('/'): - # 如果是目录,创建目录 - dir_name = os.path.basename(file_name.rstrip('/')) - if dir_name: # 只有当目录名不为空时才创建 - os.makedirs(os.path.join(extract_to, dir_name), exist_ok=True) + # 如果是目录,创建完整路径 + dir_path = os.path.join(extract_to, file_name.rstrip('/')) + if dir_path and dir_path != extract_to: # 确保不是空路径或根目录 + os.makedirs(dir_path, exist_ok=True) else: - # 如果是文件,直接解压到目标目录 + # 如果是文件,直接解压,保留路径结构 zipf.extract(file_name, extract_to) - # 如果文件在子目录中,移动到目标目录 - if '/' in file_name: - base_name = os.path.basename(file_name) - src_path = os.path.join(extract_to, file_name) - dst_path = os.path.join(extract_to, base_name) - if os.path.exists(src_path) and src_path != dst_path: - shutil.move(src_path, dst_path) - # 尝试删除空的子目录 - parent_dir = os.path.dirname(src_path) - try: - if os.path.exists(parent_dir) and parent_dir != extract_to: - os.rmdir(parent_dir) - except: - pass # 如果目录不为空,忽略错误 except (RuntimeError, zipfile.BadZipFile) as e: error_msg = str(e).lower() # 检查更多可能的密码错误关键词 @@ -1132,6 +1258,20 @@ def _extract_with_python(archive_path, extract_to, archive_format, progress_call if progress_callback: progress = ((i + 1) / total_files) * 100 progress_callback(f"Extracting {file_name}", progress) + + # 检查解压后的目录中是否包含系统目录 + if progress_callback: + try: + # 检查解压后的目录中是否包含系统目录 + for root, dirs, files in os.walk(extract_to): + for dir_name in dirs: + # 检查是否是系统目录 + if dir_name.lower() in ['bin', 'sbin', 'usr', 'etc', 'var', 'sys', 'proc', 'dev', 'boot', 'lib', 'lib64', 'opt', 'run', 'srv', 'tmp']: + full_path = os.path.join(root, dir_name) + progress_callback(f"警告: 检测到系统目录 {full_path}", 50) + except Exception as e: + # 忽略检查过程中的错误 + pass elif archive_format.startswith("tar"): mode = "r" @@ -1146,61 +1286,89 @@ def _extract_with_python(archive_path, extract_to, archive_format, progress_call members = tarf.getmembers() total_members = len(members) for i, member in enumerate(members): - # Fix TAR.GZ extraction path issue - keep only filename, remove path + # 保留TAR文件的目录结构,但清理绝对路径 if member.name.startswith('/') or member.name.startswith('./'): - member.name = os.path.basename(member.name) - elif os.path.dirname(member.name): # If it contains a path - member.name = os.path.basename(member.name) + # 将绝对路径转换为相对路径,但保留目录结构 + member.name = os.path.relpath(member.name, '/') + if member.name == '.': + member.name = os.path.basename(member.name) tarf.extract(member, extract_to) if progress_callback: progress = ((i + 1) / total_members) * 100 progress_callback(f"Extracting {member.name}", progress) + + # 检查解压后的目录中是否包含系统目录 + if progress_callback: + try: + # 检查解压后的目录中是否包含系统目录 + for root, dirs, files in os.walk(extract_to): + for dir_name in dirs: + # 检查是否是系统目录 + if dir_name.lower() in ['bin', 'sbin', 'usr', 'etc', 'var', 'sys', 'proc', 'dev', 'boot', 'lib', 'lib64', 'opt', 'run', 'srv', 'tmp']: + full_path = os.path.join(root, dir_name) + progress_callback(f"警告: 检测到系统目录 {full_path}", 50) + except Exception as e: + # 忽略检查过程中的错误 + pass elif archive_format in ["bz2", "xz", "lzma"]: # Single file compression format import bz2 import lzma + import tarfile - output_file = os.path.join(extract_to, os.path.basename(archive_path).rsplit('.', 1)[0]) + # First, decompress the file to a temporary tar file + temp_tar = os.path.join(extract_to, os.path.basename(archive_path).rsplit('.', 1)[0] + '.tar') - if archive_format == "bz2": - with bz2.open(archive_path, 'rb') as f_in: - with open(output_file, 'wb') as f_out: - # Use binary mode for reading and writing - while True: - chunk = f_in.read(8192) - if not chunk: - break - f_out.write(chunk) - else: - with lzma.open(archive_path, 'rb') as f_in: - with open(output_file, 'wb') as f_out: - # Use binary mode for reading and writing - while True: - chunk = f_in.read(8192) - if not chunk: - break - f_out.write(chunk) + try: + if archive_format == "bz2": + with bz2.open(archive_path, 'rb') as f_in: + with open(temp_tar, 'wb') as f_out: + # Use binary mode for reading and writing + while True: + chunk = f_in.read(8192) + if not chunk: + break + f_out.write(chunk) + else: + with lzma.open(archive_path, 'rb') as f_in: + with open(temp_tar, 'wb') as f_out: + # Use binary mode for reading and writing + while True: + chunk = f_in.read(8192) + if not chunk: + break + f_out.write(chunk) + + # Now extract the tar file + with tarfile.open(temp_tar, 'r') as tarf: + members = tarf.getmembers() + total_members = len(members) + for i, member in enumerate(members): + # 保留TAR文件的目录结构,但清理绝对路径 + if member.name.startswith('/') or member.name.startswith('./'): + # 将绝对路径转换为相对路径,但保留目录结构 + member.name = os.path.relpath(member.name, '/') + if member.name == '.': + member.name = os.path.basename(member.name) + tarf.extract(member, extract_to) + if progress_callback: + progress = ((i + 1) / total_members) * 100 + progress_callback(f"Extracting {member.name}", progress) + + # Clean up the temporary tar file + os.remove(temp_tar) + + except Exception as e: + # Clean up the temporary tar file if it exists + if os.path.exists(temp_tar): + os.remove(temp_tar) + raise e if progress_callback: - progress_callback(f"Extracted {output_file}", 100) + progress_callback(f"Extracted {archive_format} archive", 100) + -def _extract_with_patool(archive_path, extract_to, progress_callback=None, password=None): - """Extract using patool""" - try: - import patoolib - except ImportError: - raise ImportError("patool is required for this format") - - # patoolib doesn't directly support password, so we'll use unar for password-protected archives - if password: - _extract_with_unar(archive_path, extract_to, progress_callback, password) - return - - patoolib.extract_archive(archive_path, outdir=extract_to) - - if progress_callback: - progress_callback("Archive extracted", 100) def _extract_rar_with_cli(archive_path, extract_to, progress_callback=None, password=None): """Extract RAR file using unrar CLI tool""" @@ -1215,7 +1383,7 @@ def _extract_rar_with_cli(archive_path, extract_to, progress_callback=None, pass if password == "": raise RuntimeError("Empty password not allowed for encrypted RAR file") - cmd = [unrar_tool, "x", archive_path, extract_to + "/", "-ep"] # Add -ep parameter to ignore paths + cmd = [unrar_tool, "x", archive_path, extract_to + "/"] # 保留目录结构 if password: cmd.extend(["-p" + password, "-y"]) # Add password and assume yes for all queries else: @@ -1261,6 +1429,17 @@ def _extract_rar_with_cli(archive_path, extract_to, progress_callback=None, pass if not extracted_files: raise RuntimeError("Password required for encrypted RAR file") + + # 检查并警告潜在的绝对路径解压问题 + if os.path.exists(extract_to): + # 检查解压目录中是否包含系统目录 + for item in os.listdir(extract_to): + item_path = os.path.join(extract_to, item) + if os.path.isdir(item_path): + # 检查是否是常见的系统目录 + if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']: + if progress_callback: + progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1) def _extract_7z_with_cli(archive_path, extract_to, progress_callback=None, password=None): """Extract 7z file using 7zz CLI tool""" @@ -1319,27 +1498,16 @@ def _extract_7z_with_cli(archive_path, extract_to, progress_callback=None, passw raise RuntimeError("Password required for encrypted 7Z file") raise RuntimeError(f"7z extraction failed: {result.stderr}") - # 检查是否有额外的子目录,如果有,将文件移动到根目录 + # 检查并警告潜在的绝对路径解压问题 if os.path.exists(extract_to): - items = os.listdir(extract_to) - # 如果只有一个子目录,且该子目录包含所有文件 - if len(items) == 1 and os.path.isdir(os.path.join(extract_to, items[0])): - sub_dir = os.path.join(extract_to, items[0]) - # 移动子目录中的所有内容到根目录 - for item in os.listdir(sub_dir): - src = os.path.join(sub_dir, item) - dst = os.path.join(extract_to, item) - if os.path.exists(dst): - if os.path.isdir(dst): - shutil.rmtree(dst) - else: - os.remove(dst) - shutil.move(src, dst) - # 尝试删除空子目录 - try: - os.rmdir(sub_dir) - except: - pass + # 检查解压目录中是否包含系统目录 + for item in os.listdir(extract_to): + item_path = os.path.join(extract_to, item) + if os.path.isdir(item_path): + # 检查是否是常见的系统目录 + if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']: + if progress_callback: + progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1) return True @@ -1393,6 +1561,17 @@ def _extract_iso_with_system(archive_path, extract_to, progress_callback=None): if result.returncode != 0: raise RuntimeError(f"Failed to copy files from ISO: {result.stderr}") + # 检查并警告潜在的绝对路径解压问题 + if os.path.exists(extract_to): + # 检查解压目录中是否包含系统目录 + for item in os.listdir(extract_to): + item_path = os.path.join(extract_to, item) + if os.path.isdir(item_path): + # 检查是否是常见的系统目录 + if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']: + if progress_callback: + progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1) + if progress_callback: progress_callback("ISO extracted successfully", 100) @@ -1411,6 +1590,17 @@ def _extract_iso_with_system(archive_path, extract_to, progress_callback=None): if result.returncode != 0: raise RuntimeError(f"7z extraction failed: {result.stderr}") + # 检查并警告潜在的绝对路径解压问题 + if os.path.exists(extract_to): + # 检查解压目录中是否包含系统目录 + for item in os.listdir(extract_to): + item_path = os.path.join(extract_to, item) + if os.path.isdir(item_path): + # 检查是否是常见的系统目录 + if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']: + if progress_callback: + progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1) + if progress_callback: progress_callback("ISO extracted with 7z", 100) @@ -1430,6 +1620,17 @@ def _extract_iso_with_system(archive_path, extract_to, progress_callback=None): if result.returncode != 0: raise RuntimeError(f"ISO extraction failed: {result.stderr}") + # 检查并警告潜在的绝对路径解压问题 + if os.path.exists(extract_to): + # 检查解压目录中是否包含系统目录 + for item in os.listdir(extract_to): + item_path = os.path.join(extract_to, item) + if os.path.isdir(item_path): + # 检查是否是常见的系统目录 + if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']: + if progress_callback: + progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1) + if progress_callback: progress_callback("ISO extracted successfully", 100) @@ -1457,6 +1658,17 @@ def _extract_cab_with_cabextract(archive_path, extract_to, progress_callback=Non if result.returncode != 0: raise RuntimeError(f"CAB extraction failed: {result.stderr}") + + # 检查并警告潜在的绝对路径解压问题 + if os.path.exists(extract_to): + # 检查解压目录中是否包含系统目录 + for item in os.listdir(extract_to): + item_path = os.path.join(extract_to, item) + if os.path.isdir(item_path): + # 检查是否是常见的系统目录 + if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']: + if progress_callback: + progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1) def _extract_with_unar(archive_path, extract_to, progress_callback=None, password=None): """Use unar as ultimate fallback""" @@ -1491,6 +1703,19 @@ def _extract_with_unar(archive_path, extract_to, progress_callback=None, passwor raise RuntimeError("Password required for encrypted archive") raise RuntimeError(f"Extraction failed: {result.stderr}") + # 即使unar命令成功执行,也需要检查解压结果,防止创建系统目录 + # 检查是否创建了var目录或其他系统目录 + system_dirs = ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'tmp', 'dev', 'proc', 'sys'] + for root, dirs, files in os.walk(extract_to): + for dir_name in dirs: + if dir_name in system_dirs: + # 如果发现系统目录,需要检查是否是绝对路径导致的问题 + full_path = os.path.join(root, dir_name) + rel_path = os.path.relpath(full_path, extract_to) + # 如果是直接在解压根目录下创建的系统目录,可能是绝对路径问题 + if os.path.dirname(rel_path) == '' and dir_name in ['var', 'tmp']: + print(f"Warning: Detected potential system directory extraction: {rel_path}") + # 即使命令成功执行,也需要检查是否真的解压了文件 if not password: # 检查解压目录是否为空 @@ -2199,6 +2424,155 @@ def _add_7z_with_cli(archive_path, file_to_add_path, progress_callback=None): raise RuntimeError(f"7z add failed: {result.stderr}") # Test function +def batch_extract_archives(archive_paths, extract_to_base, progress_callback=None, password=None, + overwrite_existing=False, create_subfolders=True, error_callback=None, + password_callback=None, password_detector=None): + """ + Extract multiple archive files in batch. + + Args: + archive_paths (list): List of paths to archive files to extract. + extract_to_base (str): Base directory to extract all archives to. + progress_callback (function): Optional callback for overall progress updates (filename, progress). + password (str): Optional password for encrypted archives. + overwrite_existing (bool): Whether to overwrite existing files. + create_subfolders (bool): Whether to create subfolders for each archive. + error_callback (function): Optional callback for individual file error updates (filename, error). + password_callback (function): Optional callback to request password from user (archive_path, format, is_protected). + password_detector (PasswordDetector): Optional password detector instance for checking protected archives. + + Returns: + dict: Results dictionary with 'success_count', 'error_count', 'results' list. + """ + results = { + 'success_count': 0, + 'error_count': 0, + 'results': [] + } + + # 初始化密码检测器 + if password_detector is None: + try: + from password_detector import PasswordDetector + password_detector = PasswordDetector() + except ImportError: + password_detector = None + + # 缓存密码避免重复询问 + password_cache = {} + + total_archives = len(archive_paths) + + for i, archive_path in enumerate(archive_paths): + try: + # Calculate overall progress + if progress_callback: + overall_progress = (i / total_archives) * 100 + progress_callback(f"Processing {i+1}/{total_archives}: {os.path.basename(archive_path)}", overall_progress) + + # Determine extraction directory for this archive + if create_subfolders: + # Create subfolder named after archive (without extension) + archive_name = os.path.splitext(os.path.basename(archive_path))[0] + archive_extract_to = os.path.join(extract_to_base, archive_name) + else: + # Extract directly to base directory + archive_extract_to = extract_to_base + + os.makedirs(archive_extract_to, exist_ok=True) + + # 检测密码保护状态 + current_password = password + if password_detector and password_callback: + try: + detection_result = password_detector.is_password_protected(archive_path) + if detection_result['is_protected']: + archive_format = detection_result.get('format', 'unknown') + + # 检查是否已有该格式的密码 + if archive_format not in password_cache: + # 询问用户输入密码 + if progress_callback: + progress_callback(f"检测到密码保护的 {archive_format.upper()} 文件: {os.path.basename(archive_path)}", + (i / total_archives) * 100) + + requested_password = password_callback(archive_path, archive_format, True) + if requested_password: + password_cache[archive_format] = requested_password + current_password = requested_password + else: + # 用户取消操作 + results['error_count'] += 1 + error_msg = "用户取消密码输入" + if error_callback: + error_callback(archive_path, error_msg) + results['results'].append({ + 'archive_path': archive_path, + 'extract_to': archive_extract_to, + 'status': 'cancelled', + 'error': error_msg + }) + continue + else: + current_password = password_cache[archive_format] + elif detection_result.get('error'): + # 检测出错,但仍然尝试正常解压 + if error_callback: + error_callback(archive_path, f"密码检测失败: {detection_result['error']}") + except Exception as e: + # 密码检测失败,继续尝试正常解压 + if error_callback: + error_callback(archive_path, f"密码检测出错: {str(e)}") + + # Create individual progress callback for this archive + def archive_progress(message, percent): + if progress_callback: + # Combine overall progress with archive-specific progress + archive_progress_value = (i + percent/100) / total_archives * 100 + progress_callback(f"{os.path.basename(archive_path)}: {message}", archive_progress_value) + + # Extract the archive + success = extract_archive(archive_path, archive_extract_to, + progress_callback=archive_progress, password=current_password) + + if success: + results['success_count'] += 1 + results['results'].append({ + 'archive_path': archive_path, + 'extract_to': archive_extract_to, + 'status': 'success', + 'error': None + }) + else: + results['error_count'] += 1 + error_msg = f"Extraction failed" + if error_callback: + error_callback(archive_path, error_msg) + results['results'].append({ + 'archive_path': archive_path, + 'extract_to': archive_extract_to, + 'status': 'error', + 'error': error_msg + }) + + except Exception as e: + results['error_count'] += 1 + error_msg = str(e) + if error_callback: + error_callback(archive_path, error_msg) + results['results'].append({ + 'archive_path': archive_path, + 'extract_to': archive_extract_to if 'archive_extract_to' in locals() else extract_to_base, + 'status': 'error', + 'error': error_msg + }) + + # Final progress update + if progress_callback: + progress_callback(results['success_count'], total_archives, f"Batch extraction complete: {results['success_count']}/{total_archives} successful") + + return results + def test_archive_functions(): """Test archive functions""" import tempfile @@ -2282,5 +2656,80 @@ def test_archive_functions(): print("\n=== Archive Functions Test Complete ===") +def _set_executable_permissions(extract_to, progress_callback=None): + """ + 为解压出的可执行文件添加执行权限 + + Args: + extract_to (str): 解压目标目录 + progress_callback (function): 可选的进度回调函数 + """ + import stat + import re + + # 定义可执行文件的常见扩展名 + executable_extensions = { + '.sh', '.bash', '.zsh', '.fish', '.cmd', '.bat', # Shell脚本 + '.py', '.pl', '.rb', '.php', '.js', '.ts', # 脚本语言 + '.exe', '.app', '.bin', '.com', # 可执行文件 + '.run', '.install', '.setup' # 安装程序 + } + + # 需要检查shebang的文件扩展名 + script_extensions = {'.sh', '.bash', '.zsh', '.fish', '.py', '.pl', '.rb', '.php', '.js', '.ts'} + + executable_files = [] + + def check_file_executable(file_path): + """检查文件是否可能是可执行文件""" + # 检查文件扩展名 + _, ext = os.path.splitext(file_path.lower()) + if ext in executable_extensions: + return True + + # 对于没有后缀名的文件,直接返回True + if not ext: + return True + + # 检查shebang行(对于脚本文件) + if ext in script_extensions: + try: + with open(file_path, 'rb') as f: + first_line = f.readline(100) # 读取前100字节 + if first_line.startswith(b'#!'): + return True + except (IOError, UnicodeDecodeError): + # 如果无法读取文件,跳过 + pass + + return False + + # 遍历解压目录中的所有文件 + for root, dirs, files in os.walk(extract_to): + for file_name in files: + file_path = os.path.join(root, file_name) + + # 跳过隐藏文件和系统文件 + if file_name.startswith('.') or file_name in ['.DS_Store', 'Thumbs.db']: + continue + + try: + if check_file_executable(file_path): + # 添加执行权限 (755: rwxr-xr-x) + current_stat = os.stat(file_path) + os.chmod(file_path, current_stat.st_mode | stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + executable_files.append(file_path) + + if progress_callback: + progress_callback(f"Set executable permission: {file_name}", -1) # -1表示不更新总体进度 + + except (OSError, IOError) as e: + # 权限设置失败,记录但不影响解压 + if progress_callback: + progress_callback(f"Warning: Failed to set permission for {file_name}: {str(e)}", -1) + + if progress_callback and executable_files: + progress_callback(f"Set executable permissions for {len(executable_files)} files", -1) + if __name__ == "__main__": test_archive_functions() \ No newline at end of file diff --git a/support/convert.py b/support/convert.py index 437d580..713092d 100644 --- a/support/convert.py +++ b/support/convert.py @@ -45,27 +45,46 @@ def convert_image(input_path, output_path, output_format, min_size=16, max_size= quality (int): Image quality for lossy formats like JPG (default: 85, range: 1-100). progress_callback (function): Callback function to report progress. interface_settings (dict): Interface behavior settings for controlling conversion behavior. + + Returns: + tuple: (success, message) where success is a boolean and message is a string """ - if output_format not in SUPPORTED_FORMATS: - raise ValueError(f"Unsupported output format: {output_format}. Supported formats are: {', '.join(SUPPORTED_FORMATS)}") + try: + if output_format not in SUPPORTED_FORMATS: + error_msg = f"Unsupported output format: {output_format}. Supported formats are: {', '.join(SUPPORTED_FORMATS)}" + if progress_callback: + progress_callback(error_msg, 0) + return False, error_msg - if output_format == "icns": - # Existing ICNS conversion logic - _create_icns_internal(input_path, output_path, min_size, max_size, progress_callback) - else: - # Generic image conversion using Pillow - try: - img = Image.open(input_path) - - # For JPG, ensure the image is in RGB mode as JPG does not support alpha channel - if output_format.lower() == "jpg": - if progress_callback: - progress_callback(f"Processing image for JPG conversion...", 30) + if not os.path.exists(input_path): + error_msg = f"Input file not found: {input_path}" + if progress_callback: + progress_callback(error_msg, 0) + return False, error_msg + + if output_format == "icns": + # Existing ICNS conversion logic + _create_icns_internal(input_path, output_path, min_size, max_size, progress_callback) + else: + # Generic image conversion using Pillow + img = None + try: + img = Image.open(input_path) + + # Create output directory if it doesn't exist + output_dir = os.path.dirname(output_path) + if output_dir and not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) - # Handle various image modes that need conversion to RGB - if img.mode in ('RGBA', 'LA', 'P', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F'): + # For JPG, ensure the image is in RGB mode as JPG does not support alpha channel + if output_format.lower() == "jpg": if progress_callback: - progress_callback(f"Converting from {img.mode} mode to RGB...", 40) + progress_callback(f"Processing image for JPG conversion...", 30) + + # Handle various image modes that need conversion to RGB + if img.mode in ('RGBA', 'LA', 'P', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F'): + if progress_callback: + progress_callback(f"Converting from {img.mode} mode to RGB...", 40) # For modes with transparency, use white background if img.mode in ('RGBA', 'LA', 'P'): @@ -78,237 +97,299 @@ def convert_image(input_path, output_path, output_format, min_size=16, max_size= else: # For other modes, convert directly to RGB img = img.convert('RGB') + + if progress_callback: + progress_callback(f"Image converted to RGB mode", 50) - if progress_callback: - progress_callback(f"Image converted to RGB mode", 50) - - # Save with appropriate options for each format - save_options = {} - - if output_format.lower() == "jpg": - # Save JPG with specified quality and optimized settings - img.save(output_path, format='JPEG', quality=quality, optimize=True, progressive=True) - elif output_format.lower() == "webp": - # Save WebP with quality settings - save_options = {'quality': quality, 'method': 6} - img.save(output_path, format='WEBP', **save_options) - elif output_format.lower() == "tiff": - # Save TIFF with compression - save_options = {'compression': 'tiff_lzw'} - img.save(output_path, format='TIFF', **save_options) - elif output_format.lower() in ["svg", "pdf", "eps"]: - # Vector formats require special handling - # For now, convert raster image to these formats with basic settings - if progress_callback: - progress_callback(f"Converting to vector format {output_format.upper()}...", 70) - img.save(output_path, format=output_format.upper()) - elif output_format.lower() in ["heic", "heif"]: - # HEIC/HEIF format support - if progress_callback: - progress_callback(f"Converting to {output_format.upper()} format...", 70) - # Try to save as HEIF if available, otherwise fallback - try: - img.save(output_path, format='HEIF', quality=quality) - except Exception: - # Fallback to PNG if HEIF not supported + # Save with appropriate options for each format + save_options = {} + + if output_format.lower() == "jpg": + # Save JPG with specified quality and optimized settings + img.save(output_path, format='JPEG', quality=quality, optimize=True, progressive=True) + elif output_format.lower() == "webp": + # Save WebP with quality settings + save_options = {'quality': quality, 'method': 6} + img.save(output_path, format='WEBP', **save_options) + elif output_format.lower() == "tiff": + # Save TIFF with compression + save_options = {'compression': 'tiff_lzw'} + img.save(output_path, format='TIFF', **save_options) + elif output_format.lower() in ["svg", "pdf", "eps"]: + # Vector formats require special handling + # For now, convert raster image to these formats with basic settings if progress_callback: - progress_callback(f"HEIF format not available, falling back to PNG", 80) - img.save(output_path, format='PNG') - elif output_format.lower() in ["avif", "jxl"]: - # Modern formats that may require additional libraries - if progress_callback: - progress_callback(f"Converting to {output_format.upper()} format...", 70) - try: - img.save(output_path, format=output_format.upper(), quality=quality) - except Exception as format_error: - # Fallback to WebP if modern format not supported + progress_callback(f"Converting to vector format {output_format.upper()}...", 70) + img.save(output_path, format=output_format.upper()) + elif output_format.lower() in ["heic", "heif"]: + # HEIC/HEIF format support if progress_callback: - progress_callback(f"{output_format.upper()} format not available, falling back to WebP", 80) - img.save(output_path, format='WEBP', quality=quality) - elif output_format.lower() in ["dds", "exr"]: - # Specialized formats for gaming and HDR - if progress_callback: - progress_callback(f"Converting to {output_format.upper()} format...", 70) - img.save(output_path, format=output_format.upper()) - else: - # Default handling for other formats - img.save(output_path, format=output_format.upper()) + progress_callback(70, 100, f"Converting to {output_format.upper()} format...") + # Try to save as HEIF if available, otherwise fallback + try: + img.save(output_path, format='HEIF', quality=quality) + except Exception: + # Fallback to PNG if HEIF not supported + if progress_callback: + progress_callback(f"HEIF format not available, falling back to PNG", 80) + img.save(output_path, format='PNG') + elif output_format.lower() in ["avif", "jxl"]: + # Modern formats that may require additional libraries + if progress_callback: + progress_callback(f"Converting to {output_format.upper()} format...", 70) + try: + img.save(output_path, format=output_format.upper(), quality=quality) + except Exception as format_error: + # Fallback to WebP if modern format not supported + if progress_callback: + progress_callback(f"{output_format.upper()} format not available, falling back to WebP", 80) + img.save(output_path, format='WEBP', quality=quality) + elif output_format.lower() in ["dds", "exr"]: + # Specialized formats for gaming and HDR + if progress_callback: + progress_callback(f"Converting to {output_format.upper()} format...", 70) + img.save(output_path, format=output_format.upper()) + else: + # Default handling for other formats + img.save(output_path, format=output_format.upper()) + finally: + # Ensure image resources are released + if img is not None: + img.close() + del img if progress_callback: - progress_callback(f"Successfully converted {input_path} to {output_path} ({output_format})", 100) + progress_callback(100, 100, f"Successfully converted {input_path} to {output_path} ({output_format})") else: print(f"Successfully converted {input_path} to {output_path} ({output_format})") - except Exception as e: - error_msg = f"Error converting image to {output_format.upper()}: {e}" - if progress_callback: - progress_callback(error_msg, 0) - raise Exception(error_msg) from e + + return True, f"Successfully converted {os.path.basename(input_path)} to {output_format.upper()}" + except Exception as e: + error_msg = f"Error converting image {os.path.basename(input_path)}: {str(e)}" + if progress_callback: + progress_callback(error_msg, 0) + return False, error_msg def _create_icns_internal(png_path, icns_path, min_size=16, max_size=None, progress_callback=None): """ Internal function to convert a PNG image to ICNS format using iconset method. This function contains the original logic of create_icns. """ - # Open the source image - img = Image.open(png_path) - - # Automatically detect image size if not provided - if max_size is None: - max_size = min(img.width, img.height) - if progress_callback: - progress_callback(f"Auto-detected maximum size: {max_size}", 5) - else: - print(f"Auto-detected maximum size: {max_size}") - - if progress_callback: - progress_callback(f"Source image size: {img.width}x{img.height}", 10) - else: - print(f"Source image size: {img.width}x{img.height}") - - # Ensure the image is square - if img.width != img.height: + img = None + try: + # Open the source image + img = Image.open(png_path) + + # Automatically detect image size if not provided + if max_size is None: + max_size = min(img.width, img.height) + if progress_callback: + progress_callback(5, 100, f"Auto-detected maximum size: {max_size}") + else: + print(f"Auto-detected maximum size: {max_size}") + if progress_callback: - progress_callback("Warning: Image is not square. Cropping to square.", 15) + progress_callback(10, 100, f"Source image size: {img.width}x{img.height}") else: - print("Warning: Image is not square. Cropping to square.") - min_dimension = min(img.width, img.height) - left = (img.width - min_dimension) // 2 - top = (img.height - min_dimension) // 2 - right = left + min_dimension - bottom = top + min_dimension - img = img.crop((left, top, right, bottom)) - - # Create a temporary directory for iconset - with tempfile.TemporaryDirectory() as tmp_dir: - iconset_dir = os.path.join(tmp_dir, "iconset.iconset") - os.makedirs(iconset_dir) + print(f"Source image size: {img.width}x{img.height}") - # Define standard sizes for ICNS (based on Apple's specifications) - standard_sizes = [16, 32, 64, 128, 256, 512, 1024] - - # Generate icons for standard sizes within our range - generated_sizes = [] - total_steps = len(standard_sizes) * 2 # Approximate steps - current_step = 0 + # Ensure the image is square + if img.width != img.height: + if progress_callback: + progress_callback(15, 100, "Warning: Image is not square. Cropping to square.") + else: + print("Warning: Image is not square. Cropping to square.") + min_dimension = min(img.width, img.height) + left = (img.width - min_dimension) // 2 + top = (img.height - min_dimension) // 2 + right = left + min_dimension + bottom = top + min_dimension + img = img.crop((left, top, right, bottom)) - for size in standard_sizes: - if min_size <= size <= max_size: - current_step += 1 - if progress_callback: - progress_callback(f"Generating size: {size}x{size}", 20 + int(60 * current_step / total_steps)) - else: - print(f"Generated size: {size}x{size}") - - # Create a copy of the image and resize it - resized_img = img.copy().resize((size, size), Image.Resampling.LANCZOS) - - # Save as PNG in iconset directory - filename = f"icon_{size}x{size}.png" - resized_img.save(os.path.join(iconset_dir, filename), "PNG") - generated_sizes.append(size) - - # For specific sizes, also generate retina versions - retina_pairs = {16: 32, 32: 64, 128: 256, 256: 512, 512: 1024} - if size in retina_pairs and retina_pairs[size] <= max_size: + # Create a temporary directory for iconset + with tempfile.TemporaryDirectory() as tmp_dir: + iconset_dir = os.path.join(tmp_dir, "iconset.iconset") + os.makedirs(iconset_dir) + + # Define standard sizes for ICNS (based on Apple's specifications) + standard_sizes = [16, 32, 64, 128, 256, 512, 1024] + + # Generate icons for standard sizes within our range + generated_sizes = [] + total_steps = len(standard_sizes) * 2 # Approximate steps + current_step = 0 + + for size in standard_sizes: + if min_size <= size <= max_size: current_step += 1 - retina_size = retina_pairs[size] if progress_callback: - progress_callback(f"Generating retina size: {retina_size}x{retina_size}", 20 + int(60 * current_step / total_steps)) + progress_callback(20 + int(60 * current_step / total_steps), 100, f"Generating size: {size}x{size}") else: - print(f"Generated retina size: {retina_size}x{retina_size}") + print(f"Generated size: {size}x{size}") - retina_img = img.copy().resize((retina_size, retina_size), Image.Resampling.LANCZOS) - retina_filename = f"icon_{size}x{size}@2x.png" - retina_img.save(os.path.join(iconset_dir, retina_filename), "PNG") - generated_sizes.append(retina_size) - - # Make sure we include max_size if it's not already included - if max_size not in generated_sizes: - if progress_callback: - progress_callback(f"Generating max size: {max_size}x{max_size}", 85) - else: - print(f"Generated max size: {max_size}x{max_size}") + # Create a copy of the image and resize it + resized_img = img.copy().resize((size, size), Image.Resampling.LANCZOS) + + # Save as PNG in iconset directory + filename = f"icon_{size}x{size}.png" + resized_img.save(os.path.join(iconset_dir, filename), "PNG") + generated_sizes.append(size) + + # For specific sizes, also generate retina versions + retina_pairs = {16: 32, 32: 64, 128: 256, 256: 512, 512: 1024} + if size in retina_pairs and retina_pairs[size] <= max_size: + current_step += 1 + retina_size = retina_pairs[size] + if progress_callback: + progress_callback(20 + int(60 * current_step / total_steps), 100, f"Generating retina size: {retina_size}x{retina_size}") + else: + print(f"Generated retina size: {retina_size}x{retina_size}") + + retina_img = img.copy().resize((retina_size, retina_size), Image.Resampling.LANCZOS) + retina_filename = f"icon_{size}x{size}@2x.png" + retina_img.save(os.path.join(iconset_dir, retina_filename), "PNG") + generated_sizes.append(retina_size) - resized_img = img.copy().resize((max_size, max_size), Image.Resampling.LANCZOS) - filename = f"icon_{max_size}x{max_size}.png" - resized_img.save(os.path.join(iconset_dir, filename), "PNG") - generated_sizes.append(max_size) - - # Use iconutil to create ICNS file (macOS only) - if progress_callback: - progress_callback("Creating ICNS file with iconutil...", 90) - else: - print("Creating ICNS file with iconutil...") + # Make sure we include max_size if it's not already included + if max_size not in generated_sizes: + if progress_callback: + progress_callback(85, 100, f"Generating max size: {max_size}x{max_size}") + else: + print(f"Generated max size: {max_size}x{max_size}") + + resized_img = img.copy().resize((max_size, max_size), Image.Resampling.LANCZOS) + filename = f"icon_{max_size}x{max_size}.png" + resized_img.save(os.path.join(iconset_dir, filename), "PNG") + generated_sizes.append(max_size) - try: - subprocess.run(["iconutil", "-c", "icns", iconset_dir, "-o", icns_path], check=True) + # Use iconutil to create ICNS file (macOS only) if progress_callback: - progress_callback(f"Successfully converted {png_path} to {icns_path}", 100) + progress_callback(90, 100, "Creating ICNS file with iconutil...") else: - print(f"Successfully converted {png_path} to {icns_path}") - print(f"Generated sizes: {sorted(set(generated_sizes))}") - except subprocess.CalledProcessError as e: - error_detail = e.stderr.decode() if e.stderr else str(e) - error_msg = f"Error creating ICNS with iconutil: {error_detail}" + print("Creating ICNS file with iconutil...") + + try: + subprocess.run(["iconutil", "-c", "icns", iconset_dir, "-o", icns_path], check=True, capture_output=True, text=True) + if progress_callback: + progress_callback(100, 100, f"Successfully converted {png_path} to {icns_path}") + else: + print(f"Successfully converted {png_path} to {icns_path}") + print(f"Generated sizes: {sorted(set(generated_sizes))}") + except subprocess.CalledProcessError as e: + error_detail = e.stderr if e.stderr else str(e) + error_msg = f"Error creating ICNS with iconutil: {error_detail}" + if progress_callback: + progress_callback(90, 100, error_msg) + progress_callback(90, 100, "Falling back to Pillow method...") + else: + print(error_msg) + print("Falling back to Pillow method...") + # Fallback to Pillow method if iconutil fails + _fallback_method_internal(iconset_dir, icns_path, progress_callback) + except Exception as e: + error_msg = f"Unexpected error during iconutil conversion: {e}" + if progress_callback: + progress_callback(90, 100, error_msg) + progress_callback(90, 100, "Falling back to Pillow method...") + else: + print(error_msg) + print("Falling back to Pillow method...") + _fallback_method_internal(iconset_dir, icns_path, progress_callback) + finally: + # Ensure image resources are released + if img is not None: + img.close() + del img + +def _fallback_method_internal(iconset_dir, icns_path, progress_callback=None): + """ + Internal fallback method using Pillow if iconutil is not available + """ + try: + icon_files = os.listdir(iconset_dir) + if not icon_files: + error_msg = "No icons generated, cannot create ICNS file" if progress_callback: - progress_callback(error_msg, 90) - progress_callback("Falling back to Pillow method...", 90) + progress_callback(100, 100, error_msg) else: print(error_msg) - print("Falling back to Pillow method...") - # Fallback to Pillow method if iconutil fails - _fallback_method_internal(iconset_dir, icns_path, progress_callback) - except Exception as e: - error_msg = f"Unexpected error during iconutil conversion: {e}" + return + + # Find the largest icon file to use as main image + icon_paths = [os.path.join(iconset_dir, f) for f in icon_files] + + # Get sizes of all icons + icon_sizes = [] + for icon_path in icon_paths: + img = None + try: + img = Image.open(icon_path) + icon_sizes.append((img.size[0], icon_path)) + finally: + if img is not None: + img.close() + del img + + if not icon_sizes: + error_msg = "Could not read any icon files for fallback method" if progress_callback: - progress_callback(error_msg, 90) - progress_callback("Falling back to Pillow method...", 90) + progress_callback(100, 100, error_msg) else: print(error_msg) - print("Falling back to Pillow method...") - _fallback_method_internal(iconset_dir, icns_path, progress_callback) - -def _fallback_method_internal(iconset_dir, icns_path, progress_callback=None): - """ - Internal fallback method using Pillow if iconutil is not available - """ - icon_files = os.listdir(iconset_dir) - if not icon_files: - error_msg = "No icons generated, cannot create ICNS file" + return + + # Find the largest icon + largest_icon_path = max(icon_sizes, key=lambda x: x[0])[1] + + # Open the largest icon as main image + main_img = None + append_images = [] + try: + main_img = Image.open(largest_icon_path) + + # Create list of additional images + for icon_path in icon_paths: + if icon_path != largest_icon_path: + img = None + try: + img = Image.open(icon_path) + append_images.append(img) + except Exception as e: + # Skip individual images that can't be opened + print(f"Warning: Could not open icon {icon_path} for fallback: {e}") + if img is not None: + img.close() + del img + + # Save as ICNS + if append_images: + main_img.save( + icns_path, + format='ICNS', + append_images=append_images + ) + else: + main_img.save(icns_path, format='ICNS') + finally: + # Ensure all image resources are released + if main_img is not None: + main_img.close() + del main_img + for img in append_images: + img.close() + del img + + success_msg = f"Successfully converted using fallback method to {icns_path}" if progress_callback: - progress_callback(error_msg, 100) + progress_callback(100, 100, success_msg) + else: + print(success_msg) + except Exception as e: + error_msg = f"Error in fallback method: {e}" + if progress_callback: + progress_callback(100, 100, error_msg) else: print(error_msg) - return - - # Find the largest icon file to use as main image - icon_paths = [os.path.join(iconset_dir, f) for f in icon_files] - largest_icon = max(icon_paths, key=lambda p: Image.open(p).size[0]) - - # Open the largest icon as main image - main_img = Image.open(largest_icon) - - # Create list of additional images - append_images = [] - for icon_path in icon_paths: - if icon_path != largest_icon: - append_images.append(Image.open(icon_path)) - - # Save as ICNS - if append_images: - main_img.save( - icns_path, - format='ICNS', - append_images=append_images - ) - else: - main_img.save(icns_path, format='ICNS') - - success_msg = f"Successfully converted using fallback method to {icns_path}" - if progress_callback: - progress_callback(success_msg, 100) - else: - print(success_msg) def main(): parser = argparse.ArgumentParser(description="Convert images to various formats (ICNS, PNG, JPG, WebP)") diff --git a/support/notification.py b/support/notification.py new file mode 100644 index 0000000..694fc15 --- /dev/null +++ b/support/notification.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +System-level notification module for Converter application +Supports macOS, Windows, and Linux platforms +""" + +import platform +import subprocess +import sys + + +def send_notification(title, message): + """Send system-level notification + + Args: + title (str): Notification title + message (str): Notification content + """ + system = platform.system() + + try: + if system == "Darwin": + # macOS: Use terminal-notifier or osascript + try: + # Try terminal-notifier first (if installed) + subprocess.run([ + "terminal-notifier", + "-title", title, + "-message", message, + "-appIcon", "Terminal" + ], check=True, capture_output=True) + except (subprocess.CalledProcessError, FileNotFoundError): + # Fallback to osascript (built-in) + script = f'display notification "{message}" with title "{title}"' + subprocess.run(["osascript", "-e", script], check=True, capture_output=True) + + elif system == "Windows": + # Windows: Use win10toast if available, otherwise fallback + try: + from win10toast import ToastNotifier + toaster = ToastNotifier() + toaster.show_toast(title, message, duration=5) + except ImportError: + # Fallback to Windows PowerShell + script = f'''Add-Type -AssemblyName System.Windows.Forms; + [System.Windows.Forms.MessageBox]::Show("{message}", "{title}", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Information)''' + subprocess.run(["powershell", "-Command", script], check=True, capture_output=True) + + elif system == "Linux": + # Linux: Use notify-send (most desktop environments) + subprocess.run([ + "notify-send", + "-a", "Converter", + title, + message + ], check=True, capture_output=True) + + else: + # Unsupported platform + print(f"[Notification] {title}: {message}") + + except subprocess.CalledProcessError as e: + # Log error but don't crash the application + print(f"Failed to send notification: {e}") + except Exception as e: + # Catch all other exceptions + print(f"Unexpected error sending notification: {e}") + + +if __name__ == "__main__": + # Test notification functionality + send_notification("Test Notification", "This is a test notification from Converter") diff --git a/support/task_manager.py b/support/task_manager.py new file mode 100644 index 0000000..268405e --- /dev/null +++ b/support/task_manager.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Task manager module for Converter application +Handles task queue, concurrency, and progress tracking +""" + +from concurrent.futures import ThreadPoolExecutor, as_completed +import uuid +import time +from PySide6.QtCore import QObject, Signal, QThread +import threading + + +class TaskStatus: + """Enum for task status""" + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class Task: + """Task class to represent a conversion task""" + + def __init__(self, task_type, input_path, output_path, **kwargs): + self.task_id = str(uuid.uuid4()) + self.task_type = task_type # "image" or "arc" + self.input_path = input_path + self.output_path = output_path + self.status = TaskStatus.PENDING + self.progress = 0 + self.start_time = None + self.end_time = None + self.result = None + self.error = None + self.metadata = kwargs + + def __str__(self): + return f"Task {self.task_id}: {self.task_type} - {self.status} ({self.progress}%)" + + +class TaskManager(QObject): + """Task manager to handle task queue and concurrency""" + + # Signals for progress and status updates + task_added = Signal(str, dict) # task_id, task_info + task_updated = Signal(str, dict) # task_id, task_info + task_completed = Signal(str, dict) # task_id, task_info + task_failed = Signal(str, dict, str) # task_id, task_info, error + progress_updated = Signal(str, int) # task_id, progress + + def __init__(self, max_workers=4): + super().__init__() + self.tasks = {} + self.task_queue = [] + self.executor = ThreadPoolExecutor(max_workers=max_workers) + self.futures = {} + self.is_running = False + self.lock = threading.Lock() + + def add_task(self, task_type, input_path, output_path, **kwargs): + """Add a new task to the queue""" + task = Task(task_type, input_path, output_path, **kwargs) + + with self.lock: + self.tasks[task.task_id] = task + self.task_queue.append(task) + + # Emit signal + self.task_added.emit(task.task_id, self._get_task_info(task)) + + # Start processing if not already running + if not self.is_running: + self._start_processing() + + return task.task_id + + def _start_processing(self): + """Start processing tasks from the queue""" + if self.is_running: + return + + self.is_running = True + + def process_queue(): + while True: + with self.lock: + if not self.task_queue: + self.is_running = False + break + + # Get next task + task = self.task_queue.pop(0) + task.status = TaskStatus.RUNNING + task.start_time = time.time() + + # Emit task updated signal + self.task_updated.emit(task.task_id, self._get_task_info(task)) + + # Submit task to executor + future = self.executor.submit(self._execute_task, task) + self.futures[future] = task.task_id + + # Handle task completion + future.add_done_callback(self._handle_task_completion) + + # Start processing in a separate thread to avoid blocking the main thread + threading.Thread(target=process_queue, daemon=True).start() + + def _execute_task(self, task): + """Execute a task""" + try: + # Simulate task execution with progress updates + # In real implementation, this would call the actual conversion function + for i in range(101): + time.sleep(0.1) # Simulate work + task.progress = i + self.progress_updated.emit(task.task_id, i) + + task.status = TaskStatus.COMPLETED + task.end_time = time.time() + task.result = "Success" + + return task + + except Exception as e: + task.status = TaskStatus.FAILED + task.end_time = time.time() + task.error = str(e) + raise + + def _handle_task_completion(self, future): + """Handle task completion""" + task_id = self.futures.pop(future) + + try: + task = future.result() + task_info = self._get_task_info(task) + + if task.status == TaskStatus.COMPLETED: + self.task_completed.emit(task_id, task_info) + else: + self.task_failed.emit(task_id, task_info, task.error) + + except Exception as e: + with self.lock: + task = self.tasks.get(task_id) + if task: + task.status = TaskStatus.FAILED + task.end_time = time.time() + task.error = str(e) + + if task: + task_info = self._get_task_info(task) + self.task_failed.emit(task_id, task_info, str(e)) + + # Emit task updated signal + with self.lock: + if task_id in self.tasks: + task = self.tasks[task_id] + self.task_updated.emit(task_id, self._get_task_info(task)) + + def _get_task_info(self, task): + """Get task information as a dictionary""" + return { + "task_id": task.task_id, + "task_type": task.task_type, + "input_path": task.input_path, + "output_path": task.output_path, + "status": task.status, + "progress": task.progress, + "start_time": task.start_time, + "end_time": task.end_time, + "result": task.result, + "error": task.error, + "metadata": task.metadata + } + + def get_task(self, task_id): + """Get a task by its ID""" + with self.lock: + return self.tasks.get(task_id) + + def get_all_tasks(self): + """Get all tasks""" + with self.lock: + return list(self.tasks.values()) + + def get_active_tasks(self): + """Get all active tasks""" + with self.lock: + return [task for task in self.tasks.values() + if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]] + + def get_completed_tasks(self): + """Get all completed tasks""" + with self.lock: + return [task for task in self.tasks.values() + if task.status == TaskStatus.COMPLETED] + + def cancel_task(self, task_id): + """Cancel a task""" + with self.lock: + if task_id in self.tasks: + task = self.tasks[task_id] + if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]: + task.status = TaskStatus.CANCELLED + # Remove from queue if pending + if task in self.task_queue: + self.task_queue.remove(task) + return True + return False + + def retry_task(self, task_id): + """Retry a failed or cancelled task""" + with self.lock: + if task_id in self.tasks: + old_task = self.tasks[task_id] + if old_task.status in [TaskStatus.FAILED, TaskStatus.CANCELLED]: + new_task = Task( + old_task.task_type, + old_task.input_path, + old_task.output_path, + **old_task.metadata + ) + self.tasks[new_task.task_id] = new_task + self.task_queue.append(new_task) + return new_task.task_id + return None + + def clear_completed_tasks(self): + """Clear all completed tasks""" + with self.lock: + completed_ids = [task.task_id for task in self.tasks.values() + if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]] + for task_id in completed_ids: + del self.tasks[task_id] + + def shutdown(self): + """Shutdown the task manager""" + self.executor.shutdown(wait=True) diff --git a/update/README.md b/update/README.md index ef2a947..3d450fa 100644 --- a/update/README.md +++ b/update/README.md @@ -41,8 +41,8 @@ from update.download_update import download_and_apply_update # 假设从UpdateManager获取到了更新信息 update_info = { "status": "update_available", - "download_url": "https://github.com/user/repo/releases/tag/v2.0.0", - "latest_version": "v2.0.0" # 注意:需要完整的tag名称 + "download_url": "https://github.com/user/repo/releases/tag/v2.1.0A6", + "latest_version": "v2.1.0A6" # 注意:需要完整的tag名称 } # 下载并应用更新 @@ -132,7 +132,7 @@ def perform_update(): #### `download_update(tag_name: str) -> dict` 下载并应用更新。 -- `tag_name`: 版本标签名称(如v2.0.0) +- `tag_name`: 版本标签名称(如v2.1.0A6) 返回字典结构: ```python diff --git a/update/download_update.py b/update/download_update.py index 42c9552..78c1000 100644 --- a/update/download_update.py +++ b/update/download_update.py @@ -47,7 +47,7 @@ def _extract_download_url(self, tag_name: str) -> Optional[str]: Get the actual download URL from GitHub API Args: - tag_name: Version tag name (e.g., v2.0.0) + tag_name: Version tag name (e.g., v2.1.0A6) Returns: str: Actual zip file download URL, returns None if extraction fails @@ -64,13 +64,13 @@ def _extract_download_url(self, tag_name: str) -> Optional[str]: platform_str = "intel" # Use GitHub API to get release information - api_url = f"https://api.github.com/repos/pyquick/Converter/releases/tags/{tag_name}" + api_url = f"https://api.github.com/repos/intsant/Converter/releases/tags/{tag_name}" response = requests.get(api_url, timeout=10) response.encoding = 'utf-8' if response.status_code != 200: print(f"GitHub API request failed: {api_url} (status code: {response.status_code})") # Fallback to manually constructed URL - download_url = f"https://github.com/pyquick/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip" + download_url = f"https://github.com/intsant/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip" return download_url release_data = response.json() @@ -87,7 +87,7 @@ def _extract_download_url(self, tag_name: str) -> Optional[str]: # If no matching file is found, fallback to manually constructed URL print(f"No matching file found {expected_filename}, using manually constructed URL") - download_url = f"https://github.com/pyquick/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip" + download_url = f"https://github.com/intsant/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip" # Verify if URL is valid - use GET request as HEAD requests might be handled differently by CDN try: @@ -113,7 +113,7 @@ def _extract_download_url(self, tag_name: str) -> Optional[str]: else: platform_str = "intel" - download_url = f"https://github.com/pyquick/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip" + download_url = f"https://github.com/intsant/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip" return download_url def download_update(self, tag_name: str, progress_callback=None) -> Dict[str, Any]: @@ -121,7 +121,7 @@ def download_update(self, tag_name: str, progress_callback=None) -> Dict[str, An Download and extract update files Args: - tag_name: Version tag name (e.g., v2.0.0) + tag_name: Version tag name (e.g., v2.1.0A6) progress_callback: Progress callback function Returns: @@ -690,6 +690,9 @@ def download_and_apply_update(update_info: Dict[str, Any], progress_callback=Non # Create target directory for update target_directory = tempfile.mkdtemp(prefix="converter_update_") downloader = UpdateDownloader(download_url, target_directory) + created_downloader = True + else: + created_downloader = False try: result = downloader.download_update(latest_version, progress_callback) @@ -717,14 +720,19 @@ def download_and_apply_update(update_info: Dict[str, Any], progress_callback=Non print(f"❌ Failed to start update process: {e}") result["status"] = "error" result["message"] = f"Failed to start update process: {e}" + # Clean up if we created the downloader and update failed + if created_downloader: + downloader.cleanup() return result # Add downloader object to result for cleanup by caller when appropriate result["downloader"] = downloader + result["created_downloader"] = created_downloader return result except Exception as e: # Clean up immediately if an exception occurs - downloader.cleanup() + if created_downloader: + downloader.cleanup() return { "status": "error", "message": f"Error occurred during download: {e}" @@ -734,8 +742,8 @@ def download_and_apply_update(update_info: Dict[str, Any], progress_callback=Non if __name__ == "__main__": # Test code test_info = { - "download_url": "https://github.com/pyquick/converter/releases/tag/v2.0.0", - "latest_version": "2.0.0" + "download_url": "https://github.com/intsant/converter/releases/tag/v2.1.0A6", + "latest_version": "2.1.0A6" } result = download_and_apply_update(test_info, "./test_update") diff --git a/update/example_usage.py b/update/example_usage.py index 51d985d..ffacd4f 100644 --- a/update/example_usage.py +++ b/update/example_usage.py @@ -20,7 +20,7 @@ def example_update_workflow(): print("=== 应用程序更新工作流程示例 ===") # 1. 初始化更新管理器 - current_version = "2.0.0RC1" # 从配置文件或代码中获取当前版本 + current_version = "2.1.0A6" # 从配置文件或代码中获取当前版本 manager = UpdateManager(current_version) print(f"当前应用程序版本: {current_version}") @@ -82,8 +82,8 @@ def quick_download_example(): test_update_info = { "status": "update_available", "message": "测试更新", - "download_url": "https://github.com/pyquick/converter/releases/tag/v2.0.0", - "latest_version": "v2.0.0" # 注意:这里需要完整的tag名称 + "download_url": "https://github.com/intsant/converter/releases/tag/v2.1.0A6", + "latest_version": "v2.1.0A6" # 注意:这里需要完整的tag名称 } print("开始测试下载功能...") diff --git a/update/run_complete_update.py b/update/run_complete_update.py index 17108cb..91c79e9 100755 --- a/update/run_complete_update.py +++ b/update/run_complete_update.py @@ -19,7 +19,7 @@ def main(): print("🔄 开始检查更新...") # 获取当前版本 - current_version = "2.0.0RC1" # 从settings/update_settings_gui.py获取 + current_version = "2.1.0A6" # 从settings/update_settings_gui.py获取 # 创建更新管理器 update_manager = UpdateManager(current_version) diff --git a/update/test_update.py b/update/test_update.py index b6e7d78..e3fb959 100644 --- a/update/test_update.py +++ b/update/test_update.py @@ -19,7 +19,7 @@ def main(): print("✅ 成功导入UpdateManager") # 获取当前版本 - current_version = "2.0.0RC1" + current_version = "2.1.0A6" print(f"📍 当前版本: {current_version}") # 创建更新管理器 diff --git a/update/update_complete.command b/update/update_complete.command index 63bd197..6328243 100755 --- a/update/update_complete.command +++ b/update/update_complete.command @@ -32,7 +32,7 @@ pkill -f "python.*converter" 2>/dev/null || true # Kill Converter.app processes echo "🛑 Terminating Converter.app processes..." pkill -f "Converter.app" 2>/dev/null || true -pkill -f "com.pyquick.converter" 2>/dev/null || true +pkill -f "com.intsant.converter" 2>/dev/null || true # Wait for processes to fully terminate echo "⏳ Waiting for processes to terminate..." @@ -44,7 +44,7 @@ pkill -9 -f "python.*arc_gui.py" 2>/dev/null || true pkill -9 -f "python.*Converter.py" 2>/dev/null || true pkill -9 -f "python.*converter" 2>/dev/null || true pkill -9 -f "Converter.app" 2>/dev/null || true -pkill -9 -f "com.pyquick.converter" 2>/dev/null || true +pkill -9 -f "com.intsant.converter" 2>/dev/null || true # Wait again sleep 2 diff --git a/update/update_manager.py b/update/update_manager.py index 760456d..d3a7918 100644 --- a/update/update_manager.py +++ b/update/update_manager.py @@ -10,7 +10,7 @@ def __init__(self, current_version: str): self.current_version = self._parse_version(current_version) def _parse_version(self, version_str: str) -> tuple: - # Handles versions like 2.0.0 and pre-release like 2.0.0RC1, 2.0.0A1, 2.0.0D1, 2.0.0RC1 + # Handles versions like 2.1.0A6 and pre-release like 2.1.0A6RC1, 2.1.0A6A1, 2.1.0A6D1, 2.1.0A6RC1 parts = version_str.split('.') if len(parts) != 3: raise ValueError(f"Invalid version string: {version_str}") @@ -46,13 +46,13 @@ def _parse_version(self, version_str: str) -> tuple: def check_for_updates(self, include_prerelease: bool, prerelease_type: Optional[str] = None) -> dict: try: from con import CON - repo_owner = "pyquick" + repo_owner = "intsant" repo_name = "converter" url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases" headers = CON.headers.copy() # 读取PAT设置 - settings = QSettings("pyquick", "converter") + settings = QSettings("intsant", "converter") encrypted_pat = settings.value("general/github_pat", "", type=str) # 如果有PAT,添加到headers @@ -227,7 +227,7 @@ def get_github_pat(self) -> str: str: 解密后的PAT,如果没有设置则返回空字符串 """ try: - settings = QSettings("pyquick", "converter") + settings = QSettings("intsant", "converter") encrypted_pat = settings.value("general/github_pat", "", type=str) if encrypted_pat: import os, sys diff --git a/update_complete.sh b/update_complete.sh index d38f048..75a1952 100644 --- a/update_complete.sh +++ b/update_complete.sh @@ -39,7 +39,7 @@ try: print('🔄 Starting to check for updates...') # Get current version - current_version = '2.0.0RC1' + current_version = '2.1.0A6' print(f'📍 Current version: {current_version}') # Create update manager