From 2d276f167c9aca2b0afea19554faf3af371cfde6 Mon Sep 17 00:00:00 2001 From: Claude Project Manager Date: Sun, 16 Nov 2025 23:24:04 +0100 Subject: [PATCH] Batch-Export geht jetzt --- controllers/profile_export_controller.py | 161 ++++++++++++- views/components/accounts_overview_view.py | 251 ++++++++++++++++++++- views/platform_selector.py | 8 + views/widgets/account_card.py | 79 ++++++- 4 files changed, 471 insertions(+), 28 deletions(-) diff --git a/controllers/profile_export_controller.py b/controllers/profile_export_controller.py index efbf675..91f7203 100644 --- a/controllers/profile_export_controller.py +++ b/controllers/profile_export_controller.py @@ -176,9 +176,6 @@ class ProfileExportController: """ Exportiert mehrere Accounts gleichzeitig. - HINWEIS: Diese Funktion ist vorbereitet für zukünftige Erweiterung, - wird aber gemäß ROADMAP.md vorerst NICHT implementiert (YAGNI). - Args: parent_widget: Parent-Widget account_ids: Liste von Account-IDs @@ -186,10 +183,154 @@ class ProfileExportController: Returns: True bei Erfolg, False bei Fehler """ - show_warning( - parent_widget, - "Nicht verfügbar", - "Mehrfach-Export ist derzeit nicht verfügbar.\n" - "Bitte exportieren Sie Accounts einzeln." - ) - return False + try: + if not account_ids: + logger.warning("Keine Account-IDs zum Export übergeben") + return False + + logger.info(f"Starte Batch-Export für {len(account_ids)} Accounts") + + # 1. Alle Account-Daten laden + accounts_data = [] + for account_id in account_ids: + account_data = self.db_manager.get_account(account_id) + if account_data: + accounts_data.append(account_data) + else: + logger.warning(f"Account ID {account_id} nicht gefunden") + + if not accounts_data: + show_error( + parent_widget, + "Keine Accounts gefunden", + "Keiner der ausgewählten Accounts konnte geladen werden." + ) + return False + + logger.info(f"{len(accounts_data)} Accounts erfolgreich geladen") + + # 2. Export-Dialog anzeigen + accepted, formats, _ = show_export_dialog(parent_widget, f"{len(accounts_data)} Accounts") + + if not accepted: + logger.info("Batch-Export abgebrochen durch Nutzer") + return False + + logger.info(f"Batch-Export-Optionen: Formate={formats}") + + # 3. Hauptordner wählen + from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M") + suggested_folder_name = f"AccountForge_Export_{timestamp}" + + save_directory = QFileDialog.getExistingDirectory( + parent_widget, + "Hauptordner für Batch-Export auswählen", + str(Path.home() / "Downloads" / suggested_folder_name) + ) + + if not save_directory: + logger.info("Kein Speicherort ausgewählt - Export abgebrochen") + return False + + # Hauptordner erstellen falls nicht vorhanden + os.makedirs(save_directory, exist_ok=True) + logger.info(f"Batch-Export-Ordner: {save_directory}") + + # 4. Für jeden Account exportieren + exported_files = [] + failed_accounts = [] + + for account_data in accounts_data: + username = account_data.get("username", "unknown") + platform = account_data.get("platform", "unknown").lower() + + try: + # Plattform-Unterordner erstellen + platform_dir = os.path.join(save_directory, platform) + os.makedirs(platform_dir, exist_ok=True) + + # Export durchführen + files_dict = self.export_service.export_account( + account_data, + formats, + password_protect=False + ) + + # Dateien mit vereinfachten Namen speichern (ohne Timestamp) + for filename, content in files_dict.items(): + # Vereinfachter Name: username.extension + ext = filename.split('.')[-1] + simple_filename = f"{username}.{ext}" + file_path = os.path.join(platform_dir, simple_filename) + + with open(file_path, 'wb') as f: + f.write(content) + + exported_files.append(f"{platform}/{simple_filename}") + logger.info(f"Exportiert: {platform}/{simple_filename}") + + except Exception as e: + logger.error(f"Fehler beim Export von {username}: {e}") + failed_accounts.append(username) + + # 5. Summary-Datei erstellen + summary_path = os.path.join(save_directory, "export_summary.txt") + with open(summary_path, 'w', encoding='utf-8') as f: + f.write(f"AccountForge Batch-Export\n") + f.write(f"="*50 + "\n\n") + f.write(f"Exportiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n") + f.write(f"Anzahl Accounts: {len(accounts_data)}\n") + f.write(f"Erfolgreich: {len(accounts_data) - len(failed_accounts)}\n") + if failed_accounts: + f.write(f"Fehlgeschlagen: {len(failed_accounts)}\n") + f.write(f"\nFormate: {', '.join(formats).upper()}\n") + f.write(f"\n" + "="*50 + "\n\n") + + # Gruppiere nach Plattform + platforms = {} + for account_data in accounts_data: + platform = account_data.get("platform", "unknown").lower() + if platform not in platforms: + platforms[platform] = [] + platforms[platform].append(account_data.get("username", "")) + + for platform, usernames in sorted(platforms.items()): + f.write(f"{platform.capitalize()}:\n") + for username in usernames: + if username in failed_accounts: + f.write(f" ✗ {username} (FEHLER)\n") + else: + f.write(f" ✓ {username}\n") + f.write(f"\n") + + exported_files.append("export_summary.txt") + logger.info("Summary-Datei erstellt") + + # 6. Erfolgs-Dialog anzeigen + show_export_success( + parent_widget, + exported_files, + save_directory + ) + + if failed_accounts: + show_warning( + parent_widget, + "Teilweise erfolgreich", + f"Export abgeschlossen, aber {len(failed_accounts)} Account(s) fehlgeschlagen:\n" + + "\n".join(f"- {name}" for name in failed_accounts[:5]) + + (f"\n... und {len(failed_accounts)-5} weitere" if len(failed_accounts) > 5 else "") + ) + + logger.info(f"Batch-Export erfolgreich: {len(exported_files)} Datei(en)") + return True + + except Exception as e: + logger.error(f"Fehler beim Batch-Export: {e}", exc_info=True) + show_error( + parent_widget, + "Batch-Export fehlgeschlagen", + f"Beim Batch-Export ist ein Fehler aufgetreten:\n\n{str(e)}" + ) + return False diff --git a/views/components/accounts_overview_view.py b/views/components/accounts_overview_view.py index 424a016..5b78b39 100644 --- a/views/components/accounts_overview_view.py +++ b/views/components/accounts_overview_view.py @@ -5,7 +5,7 @@ Accounts Overview View - Account-Übersicht im Mockup-Style import logging from PyQt5.QtWidgets import ( QWidget, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, - QScrollArea, QGridLayout, QFrame, QMessageBox + QScrollArea, QGridLayout, QFrame, QMessageBox, QCheckBox ) from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QFont @@ -106,20 +106,25 @@ class AccountsOverviewView(QWidget): Account-Übersicht im Mockup-Style mit: - Sidebar-Filter - Grid-Layout mit Account-Karten + - Batch-Export mit Checkbox-Auswahl """ - + # Signals account_login_requested = pyqtSignal(dict) account_export_requested = pyqtSignal(dict) account_delete_requested = pyqtSignal(dict) export_requested = pyqtSignal() # Für Kompatibilität - + bulk_export_requested = pyqtSignal(list) # List[int] - account_ids + def __init__(self, db_manager=None, language_manager=None): super().__init__() self.db_manager = db_manager self.language_manager = language_manager self.current_filter = "all" self.accounts = [] + self.selected_account_ids = set() # Set of selected account IDs + self.account_cards = [] # List of AccountCard widgets + self._updating_selection = False # Flag to prevent selection loops self.init_ui() if self.language_manager: @@ -156,11 +161,145 @@ class AccountsOverviewView(QWidget): self.title.setFont(title_font) self.title.setObjectName("section_title") # For QSS targeting header_layout.addWidget(self.title) - + header_layout.addStretch() - + + # Selection Mode Button + self.selection_mode_btn = QPushButton("Exportieren") + self.selection_mode_btn.setCursor(Qt.PointingHandCursor) + self.selection_mode_btn.setStyleSheet(""" + QPushButton { + background-color: #10B981; + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + font-family: 'Poppins', sans-serif; + padding: 8px 16px; + min-width: 100px; + } + QPushButton:hover { + background-color: #059669; + } + QPushButton:pressed { + background-color: #047857; + } + """) + self.selection_mode_btn.clicked.connect(self._enable_selection_mode) + header_layout.addWidget(self.selection_mode_btn) + content_layout.addLayout(header_layout) - + + # Selection Toolbar (initially hidden) + self.toolbar = QFrame() + self.toolbar.setObjectName("selection_toolbar") + self.toolbar.setVisible(False) + self.toolbar.setStyleSheet(""" + #selection_toolbar { + background-color: #F3F4F6; + border-radius: 8px; + padding: 12px 16px; + } + """) + + toolbar_layout = QHBoxLayout(self.toolbar) + toolbar_layout.setContentsMargins(0, 0, 0, 0) + toolbar_layout.setSpacing(16) + + # "Alle auswählen" Checkbox + self.select_all_checkbox = QCheckBox("Alle auswählen") + self.select_all_checkbox.setTristate(True) + self.select_all_checkbox.setStyleSheet(""" + QCheckBox { + font-size: 13px; + font-family: 'Poppins', sans-serif; + font-weight: 500; + color: #374151; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 4px; + border: 2px solid #D1D5DB; + } + QCheckBox::indicator:checked { + background-color: #0099CC; + border-color: #0099CC; + } + QCheckBox::indicator:indeterminate { + background-color: #0099CC; + border-color: #0099CC; + opacity: 0.6; + } + """) + self.select_all_checkbox.stateChanged.connect(self._on_select_all_changed) + toolbar_layout.addWidget(self.select_all_checkbox) + + # Selection count label + self.selection_count_label = QLabel("0 ausgewählt") + self.selection_count_label.setStyleSheet(""" + color: #6B7280; + font-size: 13px; + font-family: 'Poppins', sans-serif; + """) + toolbar_layout.addWidget(self.selection_count_label) + + toolbar_layout.addStretch() + + # Export button + self.bulk_export_btn = QPushButton("Exportieren") + self.bulk_export_btn.setEnabled(False) + self.bulk_export_btn.setCursor(Qt.PointingHandCursor) + self.bulk_export_btn.setStyleSheet(""" + QPushButton { + background-color: #0099CC; + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + font-family: 'Poppins', sans-serif; + padding: 8px 16px; + min-width: 100px; + } + QPushButton:hover { + background-color: #0078A3; + } + QPushButton:pressed { + background-color: #005C7A; + } + QPushButton:disabled { + background-color: #D1D5DB; + color: #9CA3AF; + } + """) + self.bulk_export_btn.clicked.connect(self._on_bulk_export_clicked) + toolbar_layout.addWidget(self.bulk_export_btn) + + # Close selection mode button + close_btn = QPushButton("✕") + close_btn.setToolTip("Auswahl beenden") + close_btn.setCursor(Qt.PointingHandCursor) + close_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: #6B7280; + border: none; + font-size: 18px; + padding: 4px; + min-width: 24px; + max-width: 24px; + } + QPushButton:hover { + color: #374151; + } + """) + close_btn.clicked.connect(self._disable_selection_mode) + toolbar_layout.addWidget(close_btn) + + content_layout.addWidget(self.toolbar) + # Scroll Area für Accounts self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) @@ -202,7 +341,8 @@ class AccountsOverviewView(QWidget): def _update_display(self): """Aktualisiert die Anzeige basierend auf dem aktuellen Filter""" - # Clear existing widgets + # Clear existing widgets and account_cards list + self.account_cards = [] while self.grid_layout.count(): child = self.grid_layout.takeAt(0) if child.widget(): @@ -287,12 +427,16 @@ class AccountsOverviewView(QWidget): def _create_account_card(self, account_data): """Erstellt eine Account-Karte""" card = AccountCard(account_data, self.language_manager) - + # Verbinde Signals card.login_requested.connect(self.account_login_requested.emit) card.export_requested.connect(self.account_export_requested.emit) card.delete_requested.connect(self._on_delete_requested) - + card.selection_changed.connect(self._on_card_selection_changed) + + # Zu Liste hinzufügen für Batch-Operations + self.account_cards.append(card) + return card def _on_delete_requested(self, account_data): @@ -376,4 +520,91 @@ class AccountsOverviewView(QWidget): Session-Status-Update deaktiviert (Session-Funktionalität entfernt). """ # Session-Funktionalität wurde entfernt - diese Methode macht nichts mehr - pass \ No newline at end of file + pass + + # ========== Selection Mode Methods ========== + + def _enable_selection_mode(self): + """Aktiviert den Selection-Modus""" + # Show toolbar, hide selection button + self.toolbar.setVisible(True) + self.selection_mode_btn.setVisible(False) + + # Enable selection mode on all cards + for card in self.account_cards: + card.set_selection_mode(True) + + # Reset selection state + self.selected_account_ids.clear() + self._update_selection_ui() + + def _disable_selection_mode(self): + """Deaktiviert den Selection-Modus""" + # Hide toolbar, show selection button + self.toolbar.setVisible(False) + self.selection_mode_btn.setVisible(True) + + # Disable selection mode on all cards + for card in self.account_cards: + card.set_selection_mode(False) + + # Clear selection + self.selected_account_ids.clear() + self._update_selection_ui() + + def _on_card_selection_changed(self, account_id: int, selected: bool): + """Handler wenn eine Card ausgewählt/abgewählt wird""" + if selected: + self.selected_account_ids.add(account_id) + else: + self.selected_account_ids.discard(account_id) + + self._update_selection_ui() + + def _on_select_all_changed(self, state): + """Handler für "Alle auswählen" Checkbox""" + if self._updating_selection: + return + + select_all = (state == Qt.Checked) + + # Set all cards to the same selection state + self._updating_selection = True + for card in self.account_cards: + card.set_selected(select_all) + self._updating_selection = False + + def _update_selection_ui(self): + """Aktualisiert die Selection-UI (Count-Label, Button-Status)""" + count = len(self.selected_account_ids) + + # Update count label + if count == 0: + self.selection_count_label.setText("0 ausgewählt") + elif count == 1: + self.selection_count_label.setText("1 ausgewählt") + else: + self.selection_count_label.setText(f"{count} ausgewählt") + + # Enable/Disable export button + self.bulk_export_btn.setEnabled(count > 0) + + # Update "Alle auswählen" checkbox state + total_cards = len(self.account_cards) + if total_cards > 0: + self._updating_selection = True + if count == 0: + self.select_all_checkbox.setCheckState(Qt.Unchecked) + elif count == total_cards: + self.select_all_checkbox.setCheckState(Qt.Checked) + else: + self.select_all_checkbox.setCheckState(Qt.PartiallyChecked) + self._updating_selection = False + + def _on_bulk_export_clicked(self): + """Handler für Bulk-Export-Button""" + if len(self.selected_account_ids) == 0: + return + + # Emit signal with list of selected account IDs + self.bulk_export_requested.emit(list(self.selected_account_ids)) \ No newline at end of file diff --git a/views/platform_selector.py b/views/platform_selector.py index 06366fd..84c8270 100644 --- a/views/platform_selector.py +++ b/views/platform_selector.py @@ -60,6 +60,7 @@ class PlatformSelector(QWidget): self.accounts_overview.account_login_requested.connect(self._on_login_requested) self.accounts_overview.account_export_requested.connect(self._on_export_requested) self.accounts_overview.account_delete_requested.connect(self._on_delete_requested) + self.accounts_overview.bulk_export_requested.connect(self._on_bulk_export_requested) self.content_stack.addWidget(self.accounts_overview) # Für Kompatibilität mit MainController - accounts_tab Referenz @@ -123,6 +124,13 @@ class PlatformSelector(QWidget): except Exception as e: print(f"Fehler beim Löschen des Accounts: {e}") + def _on_bulk_export_requested(self, account_ids): + """Behandelt Bulk-Export-Anfragen.""" + from controllers.profile_export_controller import ProfileExportController + if self.db_manager: + controller = ProfileExportController(self.db_manager) + controller.export_multiple_accounts(self, account_ids) + def update_texts(self): """Aktualisiert die Texte gemäß der aktuellen Sprache.""" # Die Komponenten aktualisieren ihre Texte selbst diff --git a/views/widgets/account_card.py b/views/widgets/account_card.py index f0481fe..70e5412 100644 --- a/views/widgets/account_card.py +++ b/views/widgets/account_card.py @@ -4,7 +4,7 @@ Account Card Widget - Kompakte Account-Karte nach Styleguide from PyQt5.QtWidgets import ( QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QGridLayout, QWidget, QApplication + QGridLayout, QWidget, QApplication, QCheckBox ) from PyQt5.QtCore import Qt, pyqtSignal, QSize, QTimer from PyQt5.QtGui import QFont, QPixmap @@ -17,17 +17,20 @@ class AccountCard(QFrame): """ Kompakte Account-Karte nach Styleguide für Light Mode """ - + # Signals login_requested = pyqtSignal(dict) # Account-Daten export_requested = pyqtSignal(dict) # Account-Daten delete_requested = pyqtSignal(dict) # Account-Daten - + selection_changed = pyqtSignal(int, bool) # account_id, selected + def __init__(self, account_data, language_manager=None): super().__init__() self.account_data = account_data self.language_manager = language_manager self.password_visible = False + self.selection_mode = False + self.is_selected_state = False # Timer für Icon-Animation self.email_copy_timer = QTimer() @@ -136,16 +139,34 @@ class AccountCard(QFrame): # Header Zeile header_layout = QHBoxLayout() - + # Platform Icon + Username info_layout = QHBoxLayout() info_layout.setSpacing(8) - + # Status wird jetzt über Karten-Hintergrund und Umrandung angezeigt - + + # Selection Checkbox (hidden by default) + self.selection_checkbox = QCheckBox() + self.selection_checkbox.setVisible(False) + self.selection_checkbox.stateChanged.connect(self._on_selection_changed) + self.selection_checkbox.setStyleSheet(""" + QCheckBox::indicator { + width: 20px; + height: 20px; + border-radius: 4px; + border: 2px solid #D1D5DB; + } + QCheckBox::indicator:checked { + background-color: #0099CC; + border-color: #0099CC; + } + """) + info_layout.addWidget(self.selection_checkbox) + # Platform Icon platform_icon = IconFactory.create_icon_label( - self.account_data.get("platform", "").lower(), + self.account_data.get("platform", "").lower(), size=18 ) info_layout.addWidget(platform_icon) @@ -378,4 +399,46 @@ class AccountCard(QFrame): def update_status(self, new_status: str): """Aktualisiert den Status der Account-Karte und das Styling""" self.account_data["status"] = new_status - self._apply_status_styling() \ No newline at end of file + self._apply_status_styling() + + # ========== Selection Mode Methods ========== + + def set_selection_mode(self, enabled: bool): + """ + Aktiviert/Deaktiviert den Selection-Modus. + + Args: + enabled: True = Checkbox anzeigen, False = Checkbox verstecken + """ + self.selection_mode = enabled + self.selection_checkbox.setVisible(enabled) + + if not enabled: + # Reset selection when mode is disabled + self.set_selected(False) + + def _on_selection_changed(self, state): + """Handler für Checkbox state change""" + self.is_selected_state = (state == Qt.Checked) + account_id = self.account_data.get("id") + if account_id: + self.selection_changed.emit(account_id, self.is_selected_state) + + def is_selected(self) -> bool: + """ + Gibt zurück ob die Card ausgewählt ist. + + Returns: + True wenn ausgewählt, sonst False + """ + return self.is_selected_state + + def set_selected(self, selected: bool): + """ + Setzt den Auswahl-Status. + + Args: + selected: True = auswählen, False = Auswahl aufheben + """ + self.is_selected_state = selected + self.selection_checkbox.setChecked(selected) \ No newline at end of file