This commit is contained in:
Yuvi9587
2025-07-01 22:48:58 +05:30
parent 69ddc2ca08
commit 9082c0c94a
36 changed files with 11396 additions and 11188 deletions

View File

@@ -0,0 +1,177 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout
)
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object
# --- Constants for Dialog Choices ---
# These were moved from main.py to be self-contained within this module's context.
CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
class ConfirmAddAllDialog(QDialog):
"""
A dialog to confirm adding multiple new character/series names to Known.txt.
It appears when the user provides filter names that are not already known,
allowing them to persist these names for future use.
"""
def __init__(self, new_filter_objects_list, parent_app, parent=None):
"""
Initializes the dialog.
Args:
new_filter_objects_list (list): A list of filter objects (dicts) to propose adding.
parent_app (DownloaderApp): A reference to the main application window for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app
self.setModal(True)
self.new_filter_objects_list = new_filter_objects_list
# Default choice if the dialog is closed without a button press
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 480, 350
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
self.info_label = QLabel()
self.info_label.setWordWrap(True)
main_layout.addWidget(self.info_label)
self.names_list_widget = QListWidget()
self._populate_list()
main_layout.addWidget(self.names_list_widget)
# --- Selection Buttons ---
selection_buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items)
selection_buttons_layout.addWidget(self.select_all_button)
self.deselect_all_button = QPushButton()
self.deselect_all_button.clicked.connect(self._deselect_all_items)
selection_buttons_layout.addWidget(self.deselect_all_button)
selection_buttons_layout.addStretch()
main_layout.addLayout(selection_buttons_layout)
# --- Action Buttons ---
buttons_layout = QHBoxLayout()
self.add_selected_button = QPushButton()
self.add_selected_button.clicked.connect(self._accept_add_selected)
self.add_selected_button.setDefault(True)
buttons_layout.addWidget(self.add_selected_button)
self.skip_adding_button = QPushButton()
self.skip_adding_button.clicked.connect(self._reject_skip_adding)
buttons_layout.addWidget(self.skip_adding_button)
buttons_layout.addStretch()
self.cancel_download_button = QPushButton()
self.cancel_download_button.clicked.connect(self._reject_cancel_download)
buttons_layout.addWidget(self.cancel_download_button)
main_layout.addLayout(buttons_layout)
def _populate_list(self):
"""Populates the list widget with the new names to be confirmed."""
for filter_obj in self.new_filter_objects_list:
item_text = filter_obj["name"]
list_item = QListWidgetItem(item_text)
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
list_item.setCheckState(Qt.Checked)
list_item.setData(Qt.UserRole, filter_obj)
self.names_list_widget.addItem(list_item)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("confirm_add_all_dialog_title", "Confirm Adding New Names"))
self.info_label.setText(self._tr("confirm_add_all_info_label", "The following new names/groups..."))
self.select_all_button.setText(self._tr("confirm_add_all_select_all_button", "Select All"))
self.deselect_all_button.setText(self._tr("confirm_add_all_deselect_all_button", "Deselect All"))
self.add_selected_button.setText(self._tr("confirm_add_all_add_selected_button", "Add Selected to Known.txt"))
self.skip_adding_button.setText(self._tr("confirm_add_all_skip_adding_button", "Skip Adding These"))
self.cancel_download_button.setText(self._tr("confirm_add_all_cancel_download_button", "Cancel Download"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme())
def _select_all_items(self):
"""Checks all items in the list."""
for i in range(self.names_list_widget.count()):
self.names_list_widget.item(i).setCheckState(Qt.Checked)
def _deselect_all_items(self):
"""Unchecks all items in the list."""
for i in range(self.names_list_widget.count()):
self.names_list_widget.item(i).setCheckState(Qt.Unchecked)
def _accept_add_selected(self):
"""Sets the user choice to the list of selected items and accepts the dialog."""
selected_objects = []
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
if item.checkState() == Qt.Checked:
filter_obj = item.data(Qt.UserRole)
if filter_obj:
selected_objects.append(filter_obj)
self.user_choice = selected_objects
self.accept()
def _reject_skip_adding(self):
"""Sets the user choice to skip adding and rejects the dialog."""
self.user_choice = CONFIRM_ADD_ALL_SKIP_ADDING
self.reject()
def _reject_cancel_download(self):
"""Sets the user choice to cancel the entire download and rejects the dialog."""
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
self.reject()
def exec_(self):
"""
Overrides the default exec_ to handle the return value logic, ensuring a
sensible default if no items are selected but the "Add" button is clicked.
"""
super().exec_()
# If the user clicked "Add Selected" but didn't select any items, treat it as skipping.
if isinstance(self.user_choice, list) and not self.user_choice:
return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice

View File

@@ -0,0 +1,135 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
class CookieHelpDialog(QDialog):
"""
A dialog to explain how to get a cookies.txt file.
It can be displayed as a simple informational popup or as a modal choice
when cookies are required but not found.
"""
# Constants to define the user's choice from the dialog
CHOICE_PROCEED_WITHOUT_COOKIES = 1
CHOICE_CANCEL_DOWNLOAD = 2
CHOICE_OK_INFO_ONLY = 3
def __init__(self, parent_app, parent=None, offer_download_without_option=False):
"""
Initializes the dialog.
Args:
parent_app (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
offer_download_without_option (bool): If True, shows buttons to
"Download without Cookies" and "Cancel Download". If False,
shows only an "OK" button for informational purposes.
"""
super().__init__(parent)
self.parent_app = parent_app
self.setModal(True)
self.offer_download_without_option = offer_download_without_option
self.user_choice = None
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
self.setMinimumWidth(500)
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
self.info_label = QLabel()
self.info_label.setTextFormat(Qt.RichText)
self.info_label.setOpenExternalLinks(True)
self.info_label.setWordWrap(True)
main_layout.addWidget(self.info_label)
button_layout = QHBoxLayout()
button_layout.addStretch(1)
if self.offer_download_without_option:
# Add buttons for making a choice
self.download_without_button = QPushButton()
self.download_without_button.clicked.connect(self._proceed_without_cookies)
button_layout.addWidget(self.download_without_button)
self.cancel_button = QPushButton()
self.cancel_button.clicked.connect(self._cancel_download)
button_layout.addWidget(self.cancel_button)
else:
# Add a simple OK button for informational display
self.ok_button = QPushButton()
self.ok_button.clicked.connect(self._ok_info_only)
button_layout.addWidget(self.ok_button)
main_layout.addLayout(button_layout)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("cookie_help_dialog_title", "Cookie File Instructions"))
instruction_html = f"""
{self._tr("cookie_help_instruction_intro", "<p>To use cookies...</p>")}
{self._tr("cookie_help_how_to_get_title", "<p><b>How to get cookies.txt:</b></p>")}
<ol>
{self._tr("cookie_help_step1_extension_intro", "<li>Install extension...</li>")}
{self._tr("cookie_help_step2_login", "<li>Go to website...</li>")}
{self._tr("cookie_help_step3_click_icon", "<li>Click icon...</li>")}
{self._tr("cookie_help_step4_export", "<li>Click export...</li>")}
{self._tr("cookie_help_step5_save_file", "<li>Save file...</li>")}
{self._tr("cookie_help_step6_app_intro", "<li>In this application:<ul>")}
{self._tr("cookie_help_step6a_checkbox", "<li>Ensure checkbox...</li>")}
{self._tr("cookie_help_step6b_browse", "<li>Click browse...</li>")}
{self._tr("cookie_help_step6c_select", "<li>Select file...</li></ul></li>")}
</ol>
{self._tr("cookie_help_alternative_paste", "<p>Alternatively, paste...</p>")}
"""
self.info_label.setText(instruction_html)
if self.offer_download_without_option:
self.download_without_button.setText(self._tr("cookie_help_proceed_without_button", "Download without Cookies"))
self.cancel_button.setText(self._tr("cookie_help_cancel_download_button", "Cancel Download"))
else:
self.ok_button.setText(self._tr("ok_button", "OK"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme())
def _proceed_without_cookies(self):
"""Handles the user choice to proceed without using cookies."""
self.user_choice = self.CHOICE_PROCEED_WITHOUT_COOKIES
self.accept()
def _cancel_download(self):
"""Handles the user choice to cancel the download."""
self.user_choice = self.CHOICE_CANCEL_DOWNLOAD
self.reject()
def _ok_info_only(self):
"""Handles the acknowledgment when the dialog is purely informational."""
self.user_choice = self.CHOICE_OK_INFO_ONLY
self.accept()

View File

@@ -0,0 +1,183 @@
# --- Standard Library Imports ---
from collections import defaultdict
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
)
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object
class DownloadExtractedLinksDialog(QDialog):
"""
A dialog to select and initiate the download for extracted, supported links
from external cloud services like Mega, Google Drive, and Dropbox.
"""
# Signal emitted with a list of selected link information dictionaries
download_requested = pyqtSignal(list)
def __init__(self, links_data, parent_app, parent=None):
"""
Initializes the dialog.
Args:
links_data (list): A list of dictionaries, each containing info about an extracted link.
parent_app (DownloaderApp): A reference to the main application window for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.links_data = links_data
self.parent_app = parent_app
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically based on the parent window's size
if parent:
parent_width = parent.width()
parent_height = parent.height()
# Use a scaling factor for different screen resolutions
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 500, 400
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
max(int(parent_height * 0.7 * scale_factor), scaled_min_h))
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
layout = QVBoxLayout(self)
self.main_info_label = QLabel()
self.main_info_label.setAlignment(Qt.AlignHCenter | Qt.AlignTop)
self.main_info_label.setWordWrap(True)
layout.addWidget(self.main_info_label)
self.links_list_widget = QListWidget()
self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list()
layout.addWidget(self.links_list_widget)
# --- Control Buttons ---
button_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked))
button_layout.addWidget(self.select_all_button)
self.deselect_all_button = QPushButton()
self.deselect_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Unchecked))
button_layout.addWidget(self.deselect_all_button)
button_layout.addStretch()
self.download_button = QPushButton()
self.download_button.clicked.connect(self._handle_download_selected)
self.download_button.setDefault(True)
button_layout.addWidget(self.download_button)
self.cancel_button = QPushButton()
self.cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_button)
layout.addLayout(button_layout)
def _populate_list(self):
"""Populates the list widget with the provided links, grouped by post title."""
grouped_links = defaultdict(list)
for link_info_item in self.links_data:
post_title_for_group = link_info_item.get('title', 'Untitled Post')
grouped_links[post_title_for_group].append(link_info_item)
sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
for post_title_key in sorted_post_titles:
# Add a non-selectable header for each post
header_item = QListWidgetItem(f"{post_title_key}")
header_item.setFlags(Qt.NoItemFlags)
font = header_item.font()
font.setBold(True)
font.setPointSize(font.pointSize() + 1)
header_item.setFont(font)
self.links_list_widget.addItem(header_item)
# Add checkable items for each link within that post
for link_info_data in grouped_links[post_title_key]:
platform_display = link_info_data.get('platform', 'unknown').upper()
display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})"
item = QListWidgetItem(display_text)
item.setData(Qt.UserRole, link_info_data)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
self.links_list_widget.addItem(item)
def _tr(self, key, default_text=""):
"""Helper to get translation based on current app language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("download_external_links_dialog_title", "Download Selected External Links"))
self.main_info_label.setText(self._tr("download_external_links_dialog_main_label", "Found {count} supported link(s)...").format(count=len(self.links_data)))
self.select_all_button.setText(self._tr("select_all_button_text", "Select All"))
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
self.download_button.setText(self._tr("download_selected_button_text", "Download Selected"))
self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
is_dark_theme = self.parent() and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark"
if is_dark_theme and hasattr(self.parent_app, 'get_dark_theme'):
self.setStyleSheet(self.parent_app.get_dark_theme())
# Set header text color based on theme
header_color = Qt.cyan if is_dark_theme else Qt.blue
for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i)
# Headers are not checkable
if not item.flags() & Qt.ItemIsUserCheckable:
item.setForeground(header_color)
def _set_all_items_checked(self, check_state):
"""Sets the checked state for all checkable items in the list."""
for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i)
if item.flags() & Qt.ItemIsUserCheckable:
item.setCheckState(check_state)
def _handle_download_selected(self):
"""Gathers selected links and emits the download_requested signal."""
selected_links = []
for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i)
if item.flags() & Qt.ItemIsUserCheckable and item.checkState() == Qt.Checked and item.data(Qt.UserRole) is not None:
selected_links.append(item.data(Qt.UserRole))
if selected_links:
self.download_requested.emit(selected_links)
self.accept()
else:
QMessageBox.information(
self,
self._tr("no_selection_title", "No Selection"),
self._tr("no_selection_message_links", "Please select at least one link to download.")
)

View File

@@ -0,0 +1,219 @@
# --- Standard Library Imports ---
import os
import time
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
QFileDialog, QMessageBox
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
class DownloadHistoryDialog(QDialog):
"""
Dialog to display download history, showing the last few downloaded files
and the first posts processed in the current session. It also allows
exporting this history to a text file.
"""
def __init__(self, last_downloaded_entries, first_processed_entries, parent_app, parent=None):
"""
Initializes the dialog.
Args:
last_downloaded_entries (list): A list of dicts for the last few files.
first_processed_entries (list): A list of dicts for the first few posts.
parent_app (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app
self.last_3_downloaded_entries = last_downloaded_entries
self.first_processed_entries = first_processed_entries
self.setModal(True)
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 1080.0
base_min_w, base_min_h = 600, 450
scaled_min_w = int(base_min_w * 1.5 * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
self.resize(scaled_min_w, scaled_min_h + 100) # Give it a bit more height
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
dialog_layout = QVBoxLayout(self)
self.setLayout(dialog_layout)
self.main_splitter = QSplitter(Qt.Horizontal)
dialog_layout.addWidget(self.main_splitter)
# --- Left Pane (Last Downloaded Files) ---
left_pane_widget = self._create_history_pane(
self.last_3_downloaded_entries,
"history_last_downloaded_header", "Last 3 Files Downloaded:",
self._format_last_downloaded_entry
)
self.main_splitter.addWidget(left_pane_widget)
# --- Right Pane (First Processed Posts) ---
right_pane_widget = self._create_history_pane(
self.first_processed_entries,
"first_files_processed_header", "First {count} Posts Processed This Session:",
self._format_first_processed_entry,
count=len(self.first_processed_entries)
)
self.main_splitter.addWidget(right_pane_widget)
# --- Bottom Buttons ---
bottom_button_layout = QHBoxLayout()
self.save_history_button = QPushButton()
self.save_history_button.clicked.connect(self._save_history_to_txt)
bottom_button_layout.addStretch(1)
bottom_button_layout.addWidget(self.save_history_button)
dialog_layout.addLayout(bottom_button_layout)
# Set splitter sizes after the dialog is shown to ensure correct proportions
QTimer.singleShot(0, lambda: self.main_splitter.setSizes([self.width() // 2, self.width() // 2]))
def _create_history_pane(self, entries, header_key, header_default, formatter_func, **kwargs):
"""Creates a generic pane for displaying a list of history entries."""
pane_widget = QWidget()
layout = QVBoxLayout(pane_widget)
header_text = self._tr(header_key, header_default).format(**kwargs)
header_label = QLabel(header_text)
header_label.setAlignment(Qt.AlignCenter)
layout.addWidget(header_label)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_content_widget = QWidget()
scroll_layout = QVBoxLayout(scroll_content_widget)
if not entries:
no_history_label = QLabel(self._tr("no_download_history_header", "No History Yet"))
no_history_label.setAlignment(Qt.AlignCenter)
scroll_layout.addWidget(no_history_label)
else:
for entry in entries:
group_box, details_label = formatter_func(entry)
group_layout = QVBoxLayout(group_box)
group_layout.addWidget(details_label)
scroll_layout.addWidget(group_box)
scroll_area.setWidget(scroll_content_widget)
layout.addWidget(scroll_area)
return pane_widget
def _format_last_downloaded_entry(self, entry):
"""Formats a single entry for the 'Last Downloaded Files' pane."""
group_box = QGroupBox(f"{self._tr('history_file_label', 'File:')} {entry.get('disk_filename', 'N/A')}")
details_text = (
f"<b>{self._tr('history_from_post_label', 'From Post:')}</b> {entry.get('post_title', 'N/A')} (ID: {entry.get('post_id', 'N/A')})<br>"
f"<b>{self._tr('history_creator_series_label', 'Creator/Series:')}</b> {entry.get('creator_display_name', 'N/A')}<br>"
f"<b>{self._tr('history_post_uploaded_label', 'Post Uploaded:')}</b> {entry.get('upload_date_str', 'N/A')}<br>"
f"<b>{self._tr('history_file_downloaded_label', 'File Downloaded:')}</b> {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entry.get('download_timestamp', 0)))}<br>"
f"<b>{self._tr('history_saved_in_folder_label', 'Saved In Folder:')}</b> {entry.get('download_path', 'N/A')}"
)
details_label = QLabel(details_text)
details_label.setWordWrap(True)
details_label.setTextFormat(Qt.RichText)
return group_box, details_label
def _format_first_processed_entry(self, entry):
"""Formats a single entry for the 'First Processed Posts' pane."""
group_box = QGroupBox(f"{self._tr('history_post_label', 'Post:')} {entry.get('post_title', 'N/A')} (ID: {entry.get('post_id', 'N/A')})")
details_text = (
f"<b>{self._tr('history_creator_label', 'Creator:')}</b> {entry.get('creator_name', 'N/A')}<br>"
f"<b>{self._tr('history_top_file_label', 'Top File:')}</b> {entry.get('top_file_name', 'N/A')}<br>"
f"<b>{self._tr('history_num_files_label', 'Num Files in Post:')}</b> {entry.get('num_files', 0)}<br>"
f"<b>{self._tr('history_post_uploaded_label', 'Post Uploaded:')}</b> {entry.get('upload_date_str', 'N/A')}<br>"
f"<b>{self._tr('history_processed_on_label', 'Processed On:')}</b> {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entry.get('download_date_timestamp', 0)))}<br>"
f"<b>{self._tr('history_saved_to_folder_label', 'Saved To Folder:')}</b> {entry.get('download_location', 'N/A')}"
)
details_label = QLabel(details_text)
details_label.setWordWrap(True)
details_label.setTextFormat(Qt.RichText)
return group_box, details_label
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("download_history_dialog_title_combined", "Download History"))
self.save_history_button.setText(self._tr("history_save_button_text", "Save History to .txt"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme())
def _save_history_to_txt(self):
"""Saves the displayed history content to a user-selected text file."""
if not self.last_3_downloaded_entries and not self.first_processed_entries:
QMessageBox.information(
self,
self._tr("no_download_history_header", "No History Yet"),
self._tr("history_nothing_to_save_message", "There is no history to save.")
)
return
# Suggest saving in the main download directory or Documents as a fallback
main_download_dir = self.parent_app.dir_input.text().strip()
default_save_dir = ""
if main_download_dir and os.path.isdir(main_download_dir):
default_save_dir = main_download_dir
else:
default_save_dir = QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation) or self.parent_app.app_base_dir
default_filepath = os.path.join(default_save_dir, "download_history.txt")
filepath, _ = QFileDialog.getSaveFileName(
self,
self._tr("history_save_dialog_title", "Save Download History"),
default_filepath,
"Text Files (*.txt);;All Files (*)"
)
if not filepath:
return
# Build the text content
history_content = []
# ... logic for formatting the text content would go here ...
try:
with open(filepath, 'w', encoding='utf-8') as f:
f.write("\n".join(history_content))
QMessageBox.information(
self,
self._tr("history_export_success_title", "History Export Successful"),
self._tr("history_export_success_message", "Successfully exported to:\n{filepath}").format(filepath=filepath)
)
except Exception as e:
QMessageBox.critical(
self,
self._tr("history_export_error_title", "History Export Error"),
self._tr("history_export_error_message", "Could not export: {error}").format(error=str(e))
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,230 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView, QFileDialog
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..assets import get_app_icon_object
# Corrected Import: The filename uses PascalCase.
from .ExportOptionsDialog import ExportOptionsDialog
class ErrorFilesDialog(QDialog):
"""
Dialog to display files that were skipped due to errors and
allows the user to retry downloading them or export the list of URLs.
"""
# Signal emitted with a list of file info dictionaries to retry
retry_selected_signal = pyqtSignal(list)
def __init__(self, error_files_info_list, parent_app, parent=None):
"""
Initializes the dialog.
Args:
error_files_info_list (list): A list of dictionaries, each containing
info about a failed file.
parent_app (DownloaderApp): A reference to the main application window
for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app
self.setModal(True)
self.error_files = error_files_info_list
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 1080.0
base_min_w, base_min_h = 500, 300
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
self.info_label = QLabel()
self.info_label.setWordWrap(True)
main_layout.addWidget(self.info_label)
if self.error_files:
self.files_list_widget = QListWidget()
self.files_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list()
main_layout.addWidget(self.files_list_widget)
# --- Control Buttons ---
buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items)
buttons_layout.addWidget(self.select_all_button)
self.retry_button = QPushButton()
self.retry_button.clicked.connect(self._handle_retry_selected)
buttons_layout.addWidget(self.retry_button)
self.export_button = QPushButton()
self.export_button.clicked.connect(self._handle_export_errors_to_txt)
buttons_layout.addWidget(self.export_button)
buttons_layout.addStretch(1)
self.ok_button = QPushButton()
self.ok_button.clicked.connect(self.accept)
self.ok_button.setDefault(True)
buttons_layout.addWidget(self.ok_button)
main_layout.addLayout(buttons_layout)
# Enable/disable buttons based on whether there are errors
has_errors = bool(self.error_files)
self.select_all_button.setEnabled(has_errors)
self.retry_button.setEnabled(has_errors)
self.export_button.setEnabled(has_errors)
def _populate_list(self):
"""Populates the list widget with details of the failed files."""
for error_info in self.error_files:
filename = error_info.get('forced_filename_override',
error_info.get('file_info', {}).get('name', 'Unknown Filename'))
post_title = error_info.get('post_title', 'Unknown Post')
post_id = error_info.get('original_post_id_for_log', 'N/A')
item_text = f"File: {filename}\nFrom Post: '{post_title}' (ID: {post_id})"
list_item = QListWidgetItem(item_text)
list_item.setData(Qt.UserRole, error_info)
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
list_item.setCheckState(Qt.Unchecked)
self.files_list_widget.addItem(list_item)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("error_files_dialog_title", "Files Skipped Due to Errors"))
if not self.error_files:
self.info_label.setText(self._tr("error_files_no_errors_label", "No files were recorded as skipped..."))
else:
self.info_label.setText(self._tr("error_files_found_label", "The following {count} file(s)...").format(count=len(self.error_files)))
self.select_all_button.setText(self._tr("error_files_select_all_button", "Select All"))
self.retry_button.setText(self._tr("error_files_retry_selected_button", "Retry Selected"))
self.export_button.setText(self._tr("error_files_export_urls_button", "Export URLs to .txt"))
self.ok_button.setText(self._tr("ok_button", "OK"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
if hasattr(self.parent_app, 'get_dark_theme'):
self.setStyleSheet(self.parent_app.get_dark_theme())
def _select_all_items(self):
"""Checks all items in the list."""
if hasattr(self, 'files_list_widget'):
for i in range(self.files_list_widget.count()):
self.files_list_widget.item(i).setCheckState(Qt.Checked)
def _handle_retry_selected(self):
"""Gathers selected files and emits the retry signal."""
if not hasattr(self, 'files_list_widget'):
return
selected_files_for_retry = [
self.files_list_widget.item(i).data(Qt.UserRole)
for i in range(self.files_list_widget.count())
if self.files_list_widget.item(i).checkState() == Qt.Checked
]
if selected_files_for_retry:
self.retry_selected_signal.emit(selected_files_for_retry)
self.accept()
else:
QMessageBox.information(
self,
self._tr("fav_artists_no_selection_title", "No Selection"),
self._tr("error_files_no_selection_retry_message", "Please select at least one file to retry.")
)
def _handle_export_errors_to_txt(self):
"""Exports the URLs of failed files to a text file."""
if not self.error_files:
QMessageBox.information(
self,
self._tr("error_files_no_errors_export_title", "No Errors"),
self._tr("error_files_no_errors_export_message", "There are no error file URLs to export.")
)
return
options_dialog = ExportOptionsDialog(parent_app=self.parent_app, parent=self)
if not options_dialog.exec_() == QDialog.Accepted:
return
export_option = options_dialog.get_selected_option()
lines_to_export = []
for error_item in self.error_files:
file_info = error_item.get('file_info', {})
url = file_info.get('url')
if url:
if export_option == ExportOptionsDialog.EXPORT_MODE_WITH_DETAILS:
original_filename = file_info.get('name', 'Unknown Filename')
post_title = error_item.get('post_title', 'Unknown Post')
post_id = error_item.get('original_post_id_for_log', 'N/A')
details_string = f" [Post: '{post_title}' (ID: {post_id}), File: '{original_filename}']"
lines_to_export.append(f"{url}{details_string}")
else:
lines_to_export.append(url)
if not lines_to_export:
QMessageBox.information(
self,
self._tr("error_files_no_urls_found_export_title", "No URLs Found"),
self._tr("error_files_no_urls_found_export_message", "Could not extract any URLs...")
)
return
default_filename = "error_file_links.txt"
filepath, _ = QFileDialog.getSaveFileName(
self,
self._tr("error_files_save_dialog_title", "Save Error File URLs"),
default_filename,
"Text Files (*.txt);;All Files (*)"
)
if filepath:
try:
with open(filepath, 'w', encoding='utf-8') as f:
for line in lines_to_export:
f.write(f"{line}\n")
QMessageBox.information(
self,
self._tr("error_files_export_success_title", "Export Successful"),
self._tr("error_files_export_success_message", "Successfully exported...").format(
count=len(lines_to_export), filepath=filepath
)
)
except Exception as e:
QMessageBox.critical(
self,
self._tr("error_files_export_error_title", "Export Error"),
self._tr("error_files_export_error_message", "Could not export...").format(error=str(e))
)

View File

@@ -0,0 +1,118 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QRadioButton, QButtonGroup
)
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object
class ExportOptionsDialog(QDialog):
"""
Dialog to choose the export format for error file links.
It allows the user to select between exporting only the URLs or
exporting URLs with additional details.
"""
# Constants to define the export modes
EXPORT_MODE_LINK_ONLY = 1
EXPORT_MODE_WITH_DETAILS = 2
def __init__(self, parent_app, parent=None):
"""
Initializes the dialog.
Args:
parent_app (DownloaderApp): A reference to the main application window for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app
self.setModal(True)
# Default option
self.selected_option = self.EXPORT_MODE_LINK_ONLY
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w = 350
scaled_min_w = int(base_min_w * scale_factor)
self.setMinimumWidth(scaled_min_w)
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
layout = QVBoxLayout(self)
self.description_label = QLabel()
layout.addWidget(self.description_label)
self.radio_group = QButtonGroup(self)
self.radio_link_only = QRadioButton()
self.radio_link_only.setChecked(True)
self.radio_group.addButton(self.radio_link_only, self.EXPORT_MODE_LINK_ONLY)
layout.addWidget(self.radio_link_only)
self.radio_with_details = QRadioButton()
self.radio_group.addButton(self.radio_with_details, self.EXPORT_MODE_WITH_DETAILS)
layout.addWidget(self.radio_with_details)
# --- Action Buttons ---
button_layout = QHBoxLayout()
self.export_button = QPushButton()
self.export_button.clicked.connect(self._handle_export)
self.export_button.setDefault(True)
self.cancel_button = QPushButton()
self.cancel_button.clicked.connect(self.reject)
button_layout.addStretch(1)
button_layout.addWidget(self.export_button)
button_layout.addWidget(self.cancel_button)
layout.addLayout(button_layout)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("export_options_dialog_title", "Export Options"))
self.description_label.setText(self._tr("export_options_description_label", "Choose the format for exporting error file links:"))
self.radio_link_only.setText(self._tr("export_options_radio_link_only", "Link per line (URL only)"))
self.radio_link_only.setToolTip(self._tr("export_options_radio_link_only_tooltip", "Exports only the direct download URL..."))
self.radio_with_details.setText(self._tr("export_options_radio_with_details", "Export with details (URL [Post, File info])"))
self.radio_with_details.setToolTip(self._tr("export_options_radio_with_details_tooltip", "Exports the URL followed by details..."))
self.export_button.setText(self._tr("export_options_export_button", "Export"))
self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
if hasattr(self.parent_app, 'get_dark_theme'):
self.setStyleSheet(self.parent_app.get_dark_theme())
def _handle_export(self):
"""Sets the selected export option and accepts the dialog."""
self.selected_option = self.radio_group.checkedId()
self.accept()
def get_selected_option(self):
"""Returns the export mode chosen by the user."""
return self.selected_option

View File

@@ -0,0 +1,288 @@
# --- Standard Library Imports ---
import html
import re
# --- Third-Party Library Imports ---
import requests
from PyQt5.QtCore import QCoreApplication, Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
# Corrected Import: Get the icon from the new assets utility module
from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request
from .CookieHelpDialog import CookieHelpDialog
class FavoriteArtistsDialog (QDialog ):
"""Dialog to display and select favorite artists."""
def __init__ (self ,parent_app ,cookies_config ):
super ().__init__ (parent_app )
self .parent_app =parent_app
self .cookies_config =cookies_config
self .all_fetched_artists =[]
app_icon =get_app_icon_object ()
if not app_icon .isNull ():
self .setWindowIcon (app_icon )
self .selected_artist_urls =[]
self .setModal (True )
self .setMinimumSize (500 ,500 )
self ._init_ui ()
self ._fetch_favorite_artists ()
def _get_domain_for_service (self ,service_name ):
service_lower =service_name .lower ()
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans'}
if service_lower in coomer_primary_services :
return "coomer.su"
else :
return "kemono.su"
def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language."""
if callable (get_translation )and self .parent_app :
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text
def _retranslate_ui (self ):
self .setWindowTitle (self ._tr ("fav_artists_dialog_title","Favorite Artists"))
self .status_label .setText (self ._tr ("fav_artists_loading_status","Loading favorite artists..."))
self .search_input .setPlaceholderText (self ._tr ("fav_artists_search_placeholder","Search artists..."))
self .select_all_button .setText (self ._tr ("fav_artists_select_all_button","Select All"))
self .deselect_all_button .setText (self ._tr ("fav_artists_deselect_all_button","Deselect All"))
self .download_button .setText (self ._tr ("fav_artists_download_selected_button","Download Selected"))
self .cancel_button .setText (self ._tr ("fav_artists_cancel_button","Cancel"))
def _init_ui (self ):
main_layout =QVBoxLayout (self )
self .status_label =QLabel ()
self .status_label .setAlignment (Qt .AlignCenter )
main_layout .addWidget (self .status_label )
self .search_input =QLineEdit ()
self .search_input .textChanged .connect (self ._filter_artist_list_display )
main_layout .addWidget (self .search_input )
self .artist_list_widget =QListWidget ()
self .artist_list_widget .setStyleSheet ("""
QListWidget::item {
border-bottom: 1px solid #4A4A4A; /* Slightly softer line */
padding-top: 4px;
padding-bottom: 4px;
}""")
main_layout .addWidget (self .artist_list_widget )
self .artist_list_widget .setAlternatingRowColors (True )
self .search_input .setVisible (False )
self .artist_list_widget .setVisible (False )
combined_buttons_layout =QHBoxLayout ()
self .select_all_button =QPushButton ()
self .select_all_button .clicked .connect (self ._select_all_items )
combined_buttons_layout .addWidget (self .select_all_button )
self .deselect_all_button =QPushButton ()
self .deselect_all_button .clicked .connect (self ._deselect_all_items )
combined_buttons_layout .addWidget (self .deselect_all_button )
self .download_button =QPushButton ()
self .download_button .clicked .connect (self ._accept_selection_action )
self .download_button .setEnabled (False )
self .download_button .setDefault (True )
combined_buttons_layout .addWidget (self .download_button )
self .cancel_button =QPushButton ()
self .cancel_button .clicked .connect (self .reject )
combined_buttons_layout .addWidget (self .cancel_button )
combined_buttons_layout .addStretch (1 )
main_layout .addLayout (combined_buttons_layout )
self ._retranslate_ui ()
if hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
self .setStyleSheet (self .parent_app .get_dark_theme ())
def _logger (self ,message ):
"""Helper to log messages, either to parent app or console."""
if hasattr (self .parent_app ,'log_signal')and self .parent_app .log_signal :
self .parent_app .log_signal .emit (f"[FavArtistsDialog] {message }")
else :
print (f"[FavArtistsDialog] {message }")
def _show_content_elements (self ,show ):
"""Helper to show/hide content-related widgets."""
self .search_input .setVisible (show )
self .artist_list_widget .setVisible (show )
def _fetch_favorite_artists (self ):
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
self .all_fetched_artists =[]
fetched_any_successfully =False
errors_occurred =[]
any_cookies_loaded_successfully_for_any_source =False
api_sources =[
{"name":"Kemono.su","url":kemono_fav_url ,"domain":"kemono.su"},
{"name":"Coomer.su","url":coomer_fav_url ,"domain":"coomer.su"}
]
for source in api_sources :
self ._logger (f"Attempting to fetch favorite artists from: {source ['name']} ({source ['url']})")
self .status_label .setText (self ._tr ("fav_artists_loading_from_source_status","⏳ Loading favorites from {source_name}...").format (source_name =source ['name']))
QCoreApplication .processEvents ()
cookies_dict_for_source =None
if self .cookies_config ['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request (
True ,
self .cookies_config ['cookie_text'],
self .cookies_config ['selected_cookie_file'],
self .cookies_config ['app_base_dir'],
self ._logger ,
target_domain =source ['domain']
)
if cookies_dict_for_source :
any_cookies_loaded_successfully_for_any_source =True
else :
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
try :
headers ={'User-Agent':'Mozilla/5.0'}
response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
response .raise_for_status ()
artists_data_from_api =response .json ()
if not isinstance (artists_data_from_api ,list ):
error_msg =f"Error ({source ['name']}): API did not return a list of artists (got {type (artists_data_from_api )})."
self ._logger (error_msg )
errors_occurred .append (error_msg )
continue
processed_artists_from_source =0
for artist_entry in artists_data_from_api :
artist_id =artist_entry .get ("id")
artist_name =html .unescape (artist_entry .get ("name","Unknown Artist").strip ())
artist_service_platform =artist_entry .get ("service")
if artist_id and artist_name and artist_service_platform :
artist_page_domain =self ._get_domain_for_service (artist_service_platform )
full_url =f"https://{artist_page_domain }/{artist_service_platform }/user/{artist_id }"
self .all_fetched_artists .append ({
'name':artist_name ,
'url':full_url ,
'service':artist_service_platform ,
'id':artist_id ,
'_source_api':source ['name']
})
processed_artists_from_source +=1
else :
self ._logger (f"Warning ({source ['name']}): Skipping favorite artist entry due to missing data: {artist_entry }")
if processed_artists_from_source >0 :
fetched_any_successfully =True
self ._logger (f"Fetched {processed_artists_from_source } artists from {source ['name']}.")
except requests .exceptions .RequestException as e :
error_msg =f"Error fetching favorites from {source ['name']}: {e }"
self ._logger (error_msg )
errors_occurred .append (error_msg )
except Exception as e :
error_msg =f"An unexpected error occurred with {source ['name']}: {e }"
self ._logger (error_msg )
errors_occurred .append (error_msg )
if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
self .status_label .setText (self ._tr ("fav_artists_cookies_required_status","Error: Cookies enabled but could not be loaded for any source."))
self ._logger ("Error: Cookies enabled but no cookies loaded for any source. Showing help dialog.")
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog .exec_ ()
self .download_button .setEnabled (False )
if not fetched_any_successfully :
errors_occurred .append ("Cookies enabled but could not be loaded for any API source.")
unique_artists_map ={}
for artist in self .all_fetched_artists :
key =(artist ['service'].lower (),str (artist ['id']).lower ())
if key not in unique_artists_map :
unique_artists_map [key ]=artist
self .all_fetched_artists =list (unique_artists_map .values ())
self .all_fetched_artists .sort (key =lambda x :x ['name'].lower ())
self ._populate_artist_list_widget ()
if fetched_any_successfully and self .all_fetched_artists :
self .status_label .setText (self ._tr ("fav_artists_found_status","Found {count} total favorite artist(s).").format (count =len (self .all_fetched_artists )))
self ._show_content_elements (True )
self .download_button .setEnabled (True )
elif not fetched_any_successfully and not errors_occurred :
self .status_label .setText (self ._tr ("fav_artists_none_found_status","No favorite artists found on Kemono.su or Coomer.su."))
self ._show_content_elements (False )
self .download_button .setEnabled (False )
else :
final_error_message =self ._tr ("fav_artists_failed_status","Failed to fetch favorites.")
if errors_occurred :
final_error_message +=" Errors: "+"; ".join (errors_occurred )
self .status_label .setText (final_error_message )
self ._show_content_elements (False )
self .download_button .setEnabled (False )
if fetched_any_successfully and not self .all_fetched_artists :
self .status_label .setText (self ._tr ("fav_artists_no_favorites_after_processing","No favorite artists found after processing."))
def _populate_artist_list_widget (self ,artists_to_display =None ):
self .artist_list_widget .clear ()
source_list =artists_to_display if artists_to_display is not None else self .all_fetched_artists
for artist_data in source_list :
item =QListWidgetItem (f"{artist_data ['name']} ({artist_data .get ('service','N/A').capitalize ()})")
item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
item .setCheckState (Qt .Unchecked )
item .setData (Qt .UserRole ,artist_data )
self .artist_list_widget .addItem (item )
def _filter_artist_list_display (self ):
search_text =self .search_input .text ().lower ().strip ()
if not search_text :
self ._populate_artist_list_widget ()
return
filtered_artists =[
artist for artist in self .all_fetched_artists
if search_text in artist ['name'].lower ()or search_text in artist ['url'].lower ()
]
self ._populate_artist_list_widget (filtered_artists )
def _select_all_items (self ):
for i in range (self .artist_list_widget .count ()):
self .artist_list_widget .item (i ).setCheckState (Qt .Checked )
def _deselect_all_items (self ):
for i in range (self .artist_list_widget .count ()):
self .artist_list_widget .item (i ).setCheckState (Qt .Unchecked )
def _accept_selection_action (self ):
self .selected_artists_data =[]
for i in range (self .artist_list_widget .count ()):
item =self .artist_list_widget .item (i )
if item .checkState ()==Qt .Checked :
self .selected_artists_data .append (item .data (Qt .UserRole ))
if not self .selected_artists_data :
QMessageBox .information (self ,"No Selection","Please select at least one artist to download.")
return
self .accept ()
def get_selected_artists (self ):
return self .selected_artists_data

View File

@@ -0,0 +1,629 @@
# --- Standard Library Imports ---
import html
import os
import sys
import threading
import time
import traceback
import json
import re
from collections import defaultdict
# --- Third-Party Library Imports ---
import requests
from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar,
QWidget, QCheckBox
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request
# Corrected Import: Import CookieHelpDialog directly from its own module
from .CookieHelpDialog import CookieHelpDialog
from ...core.api_client import download_from_api
class FavoritePostsFetcherThread (QThread ):
"""Worker thread to fetch favorite posts and creator names."""
status_update =pyqtSignal (str )
progress_bar_update =pyqtSignal (int ,int )
finished =pyqtSignal (list ,str )
def __init__ (self ,cookies_config ,parent_logger_func ,target_domain_preference =None ):
super ().__init__ ()
self .cookies_config =cookies_config
self .parent_logger_func =parent_logger_func
self .target_domain_preference =target_domain_preference
self .cancellation_event =threading .Event ()
self .error_key_map ={
"Kemono.su":"kemono_su",
"Coomer.su":"coomer_su"
}
def _logger (self ,message ):
self .parent_logger_func (f"[FavPostsFetcherThread] {message }")
def run (self ):
kemono_fav_posts_url ="https://kemono.su/api/v1/account/favorites?type=post"
coomer_fav_posts_url ="https://coomer.su/api/v1/account/favorites?type=post"
all_fetched_posts_temp =[]
error_messages_for_summary =[]
fetched_any_successfully =False
any_cookies_loaded_successfully_for_any_source =False
self .status_update .emit ("key_fetching_fav_post_list_init")
self .progress_bar_update .emit (0 ,0 )
api_sources =[
{"name":"Kemono.su","url":kemono_fav_posts_url ,"domain":"kemono.su"},
{"name":"Coomer.su","url":coomer_fav_posts_url ,"domain":"coomer.su"}
]
api_sources_to_try =[]
if self .target_domain_preference :
self ._logger (f"Targeting specific domain for favorites: {self .target_domain_preference }")
for source_def in api_sources :
if source_def ["domain"]==self .target_domain_preference :
api_sources_to_try .append (source_def )
break
if not api_sources_to_try :
self ._logger (f"Warning: Preferred domain '{self .target_domain_preference }' not a recognized API source. Fetching from all.")
api_sources_to_try =api_sources
else :
self ._logger ("No specific domain preference, or both domains have cookies. Will attempt to fetch from all sources.")
api_sources_to_try =api_sources
for source in api_sources_to_try :
if self .cancellation_event .is_set ():
self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING")
return
cookies_dict_for_source =None
if self .cookies_config ['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request (
True ,
self .cookies_config ['cookie_text'],
self .cookies_config ['selected_cookie_file'],
self .cookies_config ['app_base_dir'],
self ._logger ,
target_domain =source ['domain']
)
if cookies_dict_for_source :
any_cookies_loaded_successfully_for_any_source =True
else :
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
self ._logger (f"Attempting to fetch favorite posts from: {source ['name']} ({source ['url']})")
source_key_part =self .error_key_map .get (source ['name'],source ['name'].lower ().replace ('.','_'))
self .status_update .emit (f"key_fetching_from_source_{source_key_part }")
QCoreApplication .processEvents ()
try :
headers ={'User-Agent':'Mozilla/5.0'}
response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
response .raise_for_status ()
posts_data_from_api =response .json ()
if not isinstance (posts_data_from_api ,list ):
err_detail =f"Error ({source ['name']}): API did not return a list of posts (got {type (posts_data_from_api )})."
self ._logger (err_detail )
error_messages_for_summary .append (err_detail )
continue
processed_posts_from_source =0
for post_entry in posts_data_from_api :
post_id =post_entry .get ("id")
post_title =html .unescape (post_entry .get ("title","Untitled Post").strip ())
service =post_entry .get ("service")
creator_id =post_entry .get ("user")
added_date_str =post_entry .get ("added",post_entry .get ("published",""))
if post_id and post_title and service and creator_id :
all_fetched_posts_temp .append ({
'post_id':post_id ,'title':post_title ,'service':service ,
'creator_id':creator_id ,'added_date':added_date_str ,
'_source_api':source ['name']
})
processed_posts_from_source +=1
else :
self ._logger (f"Warning ({source ['name']}): Skipping favorite post entry due to missing data: {post_entry }")
if processed_posts_from_source >0 :
fetched_any_successfully =True
self ._logger (f"Fetched {processed_posts_from_source } posts from {source ['name']}.")
except requests .exceptions .RequestException as e :
err_detail =f"Error fetching favorite posts from {source ['name']}: {e }"
self ._logger (err_detail )
error_messages_for_summary .append (err_detail )
if e .response is not None and e .response .status_code ==401 :
self .finished .emit ([],"KEY_AUTH_FAILED")
self ._logger (f"Authorization failed for {source ['name']}, emitting KEY_AUTH_FAILED.")
return
except Exception as e :
err_detail =f"An unexpected error occurred with {source ['name']}: {e }"
self ._logger (err_detail )
error_messages_for_summary .append (err_detail )
if self .cancellation_event .is_set ():
self .finished .emit ([],"KEY_FETCH_CANCELLED_AFTER")
return
if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
if self .target_domain_preference and not any_cookies_loaded_successfully_for_any_source :
domain_key_part =self .error_key_map .get (self .target_domain_preference ,self .target_domain_preference .lower ().replace ('.','_'))
self .finished .emit ([],f"KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_{domain_key_part }")
return
self .finished .emit ([],"KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC")
return
unique_posts_map ={}
for post in all_fetched_posts_temp :
key =(post ['service'].lower (),str (post ['creator_id']).lower (),str (post ['post_id']).lower ())
if key not in unique_posts_map :
unique_posts_map [key ]=post
all_fetched_posts_temp =list (unique_posts_map .values ())
all_fetched_posts_temp .sort (key =lambda x :(x .get ('_source_api','').lower (),x .get ('service','').lower (),str (x .get ('creator_id','')).lower (),(x .get ('added_date')or '')),reverse =False )
if error_messages_for_summary :
error_summary_str ="; ".join (error_messages_for_summary )
if not fetched_any_successfully :
self .finished .emit ([],f"KEY_FETCH_FAILED_GENERIC_{error_summary_str [:50 ]}")
else :
self .finished .emit (all_fetched_posts_temp ,f"KEY_FETCH_PARTIAL_SUCCESS_{error_summary_str [:50 ]}")
elif not all_fetched_posts_temp and not fetched_any_successfully and not self .target_domain_preference :
self .finished .emit ([],"KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS")
else :
self .finished .emit (all_fetched_posts_temp ,"KEY_FETCH_SUCCESS")
class PostListItemWidget (QWidget ):
"""Custom widget for displaying a single post in the FavoritePostsDialog list."""
def __init__ (self ,post_data_dict ,parent_dialog_ref ,parent =None ):
super ().__init__ (parent )
self .post_data =post_data_dict
self .parent_dialog =parent_dialog_ref
self .layout =QHBoxLayout (self )
self .layout .setContentsMargins (5 ,3 ,5 ,3 )
self .layout .setSpacing (10 )
self .checkbox =QCheckBox ()
self .layout .addWidget (self .checkbox )
self .info_label =QLabel ()
self .info_label .setWordWrap (True )
self .info_label .setTextFormat (Qt .RichText )
self .layout .addWidget (self .info_label ,1 )
self ._setup_display_text ()
def _setup_display_text (self ):
suffix_plain =self .post_data .get ('suffix_for_display',"")
title_plain =self .post_data .get ('title','Untitled Post')
escaped_suffix =html .escape (suffix_plain )
escaped_title =html .escape (title_plain )
p_style_paragraph ="font-size:10.5pt; margin:0; padding:0;"
title_span_style ="font-weight:bold; color:#E0E0E0;"
suffix_span_style ="color:#999999; font-weight:normal; font-size:9.5pt;"
if escaped_suffix :
display_html_content =f"<p style='{p_style_paragraph }'><span style='{title_span_style }'>{escaped_title }</span><span style='{suffix_span_style }'>{escaped_suffix }</span></p>"
else :
display_html_content =f"<p style='{p_style_paragraph }'><span style='{title_span_style }'>{escaped_title }</span></p>"
self .info_label .setText (display_html_content )
def isChecked (self ):return self .checkbox .isChecked ()
def setCheckState (self ,state ):self .checkbox .setCheckState (state )
def get_post_data (self ):return self .post_data
class FavoritePostsDialog (QDialog ):
"""Dialog to display and select favorite posts."""
def __init__ (self ,parent_app ,cookies_config ,known_names_list_ref ,target_domain_preference =None ):
super ().__init__ (parent_app )
self .parent_app =parent_app
self .cookies_config =cookies_config
self .all_fetched_posts =[]
self .selected_posts_data =[]
self .known_names_list_ref =known_names_list_ref
self .target_domain_preference_for_this_fetch =target_domain_preference
self .creator_name_cache ={}
self .displayable_grouped_posts ={}
self .fetcher_thread =None
app_icon =get_app_icon_object ()
if not app_icon .isNull ():
self .setWindowIcon (app_icon )
self .setModal (True )
self .setMinimumSize (600 ,600 )
if hasattr (self .parent_app ,'get_dark_theme'):
self .setStyleSheet (self .parent_app .get_dark_theme ())
self ._init_ui ()
self ._load_creator_names_from_file ()
self ._retranslate_ui ()
self ._start_fetching_favorite_posts ()
def _update_status_label_from_key (self ,status_key ):
"""Translates a status key and updates the status label."""
translated_status =self ._tr (status_key .lower (),status_key )
self .status_label .setText (translated_status )
def _init_ui (self ):
main_layout =QVBoxLayout (self )
self .status_label =QLabel ()
self .status_label .setAlignment (Qt .AlignCenter )
main_layout .addWidget (self .status_label )
self .progress_bar =QProgressBar ()
self .progress_bar .setTextVisible (False )
self .progress_bar .setVisible (False )
main_layout .addWidget (self .progress_bar )
self .search_input =QLineEdit ()
self .search_input .textChanged .connect (self ._filter_post_list_display )
main_layout .addWidget (self .search_input )
self .post_list_widget =QListWidget ()
self .post_list_widget .setStyleSheet ("""
QListWidget::item {
border-bottom: 1px solid #4A4A4A;
padding-top: 4px;
padding-bottom: 4px;
}""")
self .post_list_widget .setAlternatingRowColors (True )
main_layout .addWidget (self .post_list_widget )
combined_buttons_layout =QHBoxLayout ()
self .select_all_button =QPushButton ()
self .select_all_button .clicked .connect (self ._select_all_items )
combined_buttons_layout .addWidget (self .select_all_button )
self .deselect_all_button =QPushButton ()
self .deselect_all_button .clicked .connect (self ._deselect_all_items )
combined_buttons_layout .addWidget (self .deselect_all_button )
self .download_button =QPushButton ()
self .download_button .clicked .connect (self ._accept_selection_action )
self .download_button .setEnabled (False )
self .download_button .setDefault (True )
combined_buttons_layout .addWidget (self .download_button )
self .cancel_button =QPushButton ()
self .cancel_button .clicked .connect (self .reject )
combined_buttons_layout .addWidget (self .cancel_button )
combined_buttons_layout .addStretch (1 )
main_layout .addLayout (combined_buttons_layout )
def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language."""
if callable (get_translation )and self .parent_app :
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text
def _retranslate_ui (self ):
self .setWindowTitle (self ._tr ("fav_posts_dialog_title","Favorite Posts"))
self .status_label .setText (self ._tr ("fav_posts_loading_status","Loading favorite posts..."))
self .search_input .setPlaceholderText (self ._tr ("fav_posts_search_placeholder","Search posts (title, creator name, ID, service)..."))
self .select_all_button .setText (self ._tr ("fav_posts_select_all_button","Select All"))
self .deselect_all_button .setText (self ._tr ("fav_posts_deselect_all_button","Deselect All"))
self .download_button .setText (self ._tr ("fav_posts_download_selected_button","Download Selected"))
self .cancel_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
def _logger (self ,message ):
if hasattr (self .parent_app ,'log_signal')and self .parent_app .log_signal :
self .parent_app .log_signal .emit (f"[FavPostsDialog] {message }")
else :
print (f"[FavPostsDialog] {message }")
def _load_creator_names_from_file (self ):
"""Loads creator id-name-service mappings from creators.txt."""
self ._logger ("Attempting to load creators.json for Favorite Posts Dialog.")
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
base_path_for_creators =sys ._MEIPASS
self ._logger (f" Running bundled. Using _MEIPASS: {base_path_for_creators }")
else :
base_path_for_creators =self .parent_app .app_base_dir
self ._logger (f" Not bundled or _MEIPASS unavailable. Using app_base_dir: {base_path_for_creators }")
creators_file_path = os.path.join(base_path_for_creators, "data", "creators.json")
self ._logger (f"Full path to creators.json: {creators_file_path }")
if not os .path .exists (creators_file_path ):
self ._logger (f"Warning: 'creators.json' not found at {creators_file_path }. Creator names will not be displayed.")
return
try :
with open (creators_file_path ,'r',encoding ='utf-8')as f :
loaded_data =json .load (f )
if isinstance (loaded_data ,list )and len (loaded_data )>0 and isinstance (loaded_data [0 ],list ):
creators_list =loaded_data [0 ]
elif isinstance (loaded_data ,list )and all (isinstance (item ,dict )for item in loaded_data ):
creators_list =loaded_data
else :
self ._logger (f"Warning: 'creators.json' has an unexpected format. Expected a list of lists or a flat list of creator objects.")
return
for creator_data in creators_list :
creator_id =creator_data .get ("id")
name =creator_data .get ("name")
service =creator_data .get ("service")
if creator_id and name and service :
self .creator_name_cache [(service .lower (),str (creator_id ))]=name
self ._logger (f"Successfully loaded {len (self .creator_name_cache )} creator names from 'creators.json'.")
except Exception as e :
self ._logger (f"Error loading 'creators.json': {e }")
def _start_fetching_favorite_posts (self ):
self .download_button .setEnabled (False )
self .status_label .setText ("Initializing favorite posts fetch...")
self .fetcher_thread =FavoritePostsFetcherThread (
self .cookies_config ,
self .parent_app .log_signal .emit ,
target_domain_preference =self .target_domain_preference_for_this_fetch
)
self .fetcher_thread .status_update .connect (self ._update_status_label_from_key )
self .fetcher_thread .finished .connect (self ._on_fetch_completed )
self .fetcher_thread .progress_bar_update .connect (self ._set_progress_bar_value )
self .progress_bar .setVisible (True )
self .fetcher_thread .start ()
def _set_progress_bar_value (self ,value ,maximum ):
if maximum ==0 :
self .progress_bar .setRange (0 ,0 )
self .progress_bar .setValue (0 )
else :
self .progress_bar .setRange (0 ,maximum )
self .progress_bar .setValue (value )
def _on_fetch_completed (self ,fetched_posts_list ,status_key ):
self .progress_bar .setVisible (False )
proceed_to_display_posts =False
show_error_message_box =False
message_box_title_key ="fav_posts_fetch_error_title"
message_box_text_key ="fav_posts_fetch_error_message"
message_box_params ={'domain':self .target_domain_preference_for_this_fetch or "platform",'error_message_part':""}
status_label_text_key =None
if status_key =="KEY_FETCH_SUCCESS":
proceed_to_display_posts =True
elif status_key and status_key .startswith ("KEY_FETCH_PARTIAL_SUCCESS_")and fetched_posts_list :
displayable_detail =status_key .replace ("KEY_FETCH_PARTIAL_SUCCESS_","").replace ("_"," ")
self ._logger (f"Partial success with posts: {status_key } -> {displayable_detail }")
proceed_to_display_posts =True
elif status_key :
specific_domain_msg_part =f" for {self .target_domain_preference_for_this_fetch }"if self .target_domain_preference_for_this_fetch else ""
if status_key .startswith ("KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_")or status_key =="KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC":
status_label_text_key ="fav_posts_cookies_required_error"
self ._logger (f"Cookie error: {status_key }. Showing help dialog.")
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog .exec_ ()
elif status_key =="KEY_AUTH_FAILED":
status_label_text_key ="fav_posts_auth_failed_title"
self ._logger (f"Auth error: {status_key }. Showing help dialog.")
QMessageBox .warning (self ,self ._tr ("fav_posts_auth_failed_title","Authorization Failed (Posts)"),
self ._tr ("fav_posts_auth_failed_message_generic","...").format (domain_specific_part =specific_domain_msg_part ))
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog .exec_ ()
elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS":
status_label_text_key ="fav_posts_no_posts_found_status"
self ._logger (status_key )
elif status_key .startswith ("KEY_FETCH_CANCELLED"):
status_label_text_key ="fav_posts_fetch_cancelled_status"
self ._logger (status_key )
else :
displayable_error_detail =status_key
if status_key .startswith ("KEY_FETCH_FAILED_GENERIC_"):
displayable_error_detail =status_key .replace ("KEY_FETCH_FAILED_GENERIC_","").replace ("_"," ")
elif status_key .startswith ("KEY_FETCH_PARTIAL_SUCCESS_"):
displayable_error_detail =status_key .replace ("KEY_FETCH_PARTIAL_SUCCESS_","Partial success but no posts: ").replace ("_"," ")
message_box_params ['error_message_part']=f":\n\n{displayable_error_detail }"if displayable_error_detail else ""
status_label_text_key ="fav_posts_fetch_error_message"
show_error_message_box =True
self ._logger (f"Fetch error: {status_key } -> {displayable_error_detail }")
if status_label_text_key :
self .status_label .setText (self ._tr (status_label_text_key ,status_label_text_key ).format (**message_box_params ))
if show_error_message_box :
QMessageBox .critical (self ,self ._tr (message_box_title_key ),self ._tr (message_box_text_key ).format (**message_box_params ))
self .download_button .setEnabled (False )
return
if not proceed_to_display_posts :
if not status_label_text_key :
self .status_label .setText (self ._tr ("fav_posts_cookies_required_error","Error: Cookies are required for favorite posts but could not be loaded."))
self .download_button .setEnabled (False )
return
if not self .creator_name_cache :
self ._logger ("Warning: Creator name cache is empty. Names will not be resolved from creators.json. Displaying IDs instead.")
else :
self ._logger (f"Creator name cache has {len (self .creator_name_cache )} entries. Attempting to resolve names...")
sample_keys =list (self .creator_name_cache .keys ())[:3 ]
if sample_keys :
self ._logger (f"Sample keys from creator_name_cache: {sample_keys }")
processed_one_missing_log =False
for post_entry in fetched_posts_list :
service_from_post =post_entry .get ('service','')
creator_id_from_post =post_entry .get ('creator_id','')
lookup_key_service =service_from_post .lower ()
lookup_key_id =str (creator_id_from_post )
lookup_key_tuple =(lookup_key_service ,lookup_key_id )
resolved_name =self .creator_name_cache .get (lookup_key_tuple )
if resolved_name :
post_entry ['creator_name_resolved']=resolved_name
else :
post_entry ['creator_name_resolved']=str (creator_id_from_post )
if not processed_one_missing_log and self .creator_name_cache :
self ._logger (f"Debug: Name not found for key {lookup_key_tuple }. Using ID '{creator_id_from_post }'.")
processed_one_missing_log =True
self .all_fetched_posts =fetched_posts_list
if not self .all_fetched_posts :
self .status_label .setText (self ._tr ("fav_posts_no_posts_found_status","No favorite posts found."))
self .download_button .setEnabled (False )
return
try :
self ._populate_post_list_widget ()
self .status_label .setText (self ._tr ("fav_posts_found_status","{count} favorite post(s) found.").format (count =len (self .all_fetched_posts )))
self .download_button .setEnabled (True )
except Exception as e :
self .status_label .setText (self ._tr ("fav_posts_display_error_status","Error displaying posts: {error}").format (error =str (e )))
self ._logger (f"Error during _populate_post_list_widget: {e }\n{traceback .format_exc (limit =3 )}")
QMessageBox .critical (self ,self ._tr ("fav_posts_ui_error_title","UI Error"),self ._tr ("fav_posts_ui_error_message","Could not display favorite posts: {error}").format (error =str (e )))
self .download_button .setEnabled (False )
def _find_best_known_name_match_in_title (self ,title_raw ):
if not title_raw or not self .known_names_list_ref :
return None
title_lower =title_raw .lower ()
best_match_known_name_primary =None
longest_match_len =0
for known_entry in self .known_names_list_ref :
aliases_to_check =set ()
for alias_val in known_entry .get ("aliases",[]):
aliases_to_check .add (alias_val )
if not known_entry .get ("is_group",False ):
aliases_to_check .add (known_entry ["name"])
sorted_aliases_for_entry =sorted (list (aliases_to_check ),key =len ,reverse =True )
for alias in sorted_aliases_for_entry :
alias_lower =alias .lower ()
if not alias_lower :
continue
if re .search (r'\b'+re .escape (alias_lower )+r'\b',title_lower ):
if len (alias_lower )>longest_match_len :
longest_match_len =len (alias_lower )
best_match_known_name_primary =known_entry ["name"]
break
return best_match_known_name_primary
def _populate_post_list_widget (self ,posts_to_display =None ):
self .post_list_widget .clear ()
source_list_for_grouping =posts_to_display if posts_to_display is not None else self .all_fetched_posts
grouped_posts ={}
for post in source_list_for_grouping :
service =post .get ('service','unknown_service')
creator_id =post .get ('creator_id','unknown_id')
group_key =(service ,creator_id )
if group_key not in grouped_posts :
grouped_posts [group_key ]=[]
grouped_posts [group_key ].append (post )
sorted_group_keys =sorted (grouped_posts .keys (),key =lambda x :(x [0 ].lower (),x [1 ].lower ()))
self .displayable_grouped_posts ={
key :sorted (grouped_posts [key ],key =lambda p :(p .get ('added_date')or ''),reverse =True )
for key in sorted_group_keys
}
for service ,creator_id_val in sorted_group_keys :
creator_name_display =self .creator_name_cache .get (
(service .lower (),str (creator_id_val )),
str (creator_id_val )
)
artist_header_display_text =f"{creator_name_display } ({service .capitalize ()} / {creator_id_val })"
artist_header_item =QListWidgetItem (f"🎨 {artist_header_display_text }")
artist_header_item .setFlags (Qt .NoItemFlags )
font =artist_header_item .font ()
font .setBold (True )
font .setPointSize (font .pointSize ()+1 )
artist_header_item .setFont (font )
artist_header_item .setForeground (Qt .cyan )
self .post_list_widget .addItem (artist_header_item )
for post_data in self .displayable_grouped_posts [(service ,creator_id_val )]:
post_title_raw =post_data .get ('title','Untitled Post')
found_known_name_primary =self ._find_best_known_name_match_in_title (post_title_raw )
plain_text_title_for_list_item =post_title_raw
if found_known_name_primary :
suffix_text =f" [Known - {found_known_name_primary }]"
post_data ['suffix_for_display']=suffix_text
plain_text_title_for_list_item =post_title_raw +suffix_text
else :
post_data .pop ('suffix_for_display',None )
list_item =QListWidgetItem (self .post_list_widget )
list_item .setText (plain_text_title_for_list_item )
list_item .setFlags (list_item .flags ()|Qt .ItemIsUserCheckable )
list_item .setCheckState (Qt .Unchecked )
list_item .setData (Qt .UserRole ,post_data )
self .post_list_widget .addItem (list_item )
def _filter_post_list_display (self ):
search_text =self .search_input .text ().lower ().strip ()
if not search_text :
self ._populate_post_list_widget (self .all_fetched_posts )
return
filtered_posts_to_group =[]
for post in self .all_fetched_posts :
matches_post_title =search_text in post .get ('title','').lower ()
matches_creator_name =search_text in post .get ('creator_name_resolved','').lower ()
matches_creator_id =search_text in post .get ('creator_id','').lower ()
matches_service =search_text in post ['service'].lower ()
if matches_post_title or matches_creator_name or matches_creator_id or matches_service :
filtered_posts_to_group .append (post )
self ._populate_post_list_widget (filtered_posts_to_group )
def _select_all_items (self ):
for i in range (self .post_list_widget .count ()):
item =self .post_list_widget .item (i )
if item and item .flags ()&Qt .ItemIsUserCheckable :
item .setCheckState (Qt .Checked )
def _deselect_all_items (self ):
for i in range (self .post_list_widget .count ()):
item =self .post_list_widget .item (i )
if item and item .flags ()&Qt .ItemIsUserCheckable :
item .setCheckState (Qt .Unchecked )
def _accept_selection_action (self ):
self .selected_posts_data =[]
for i in range (self .post_list_widget .count ()):
item =self .post_list_widget .item (i )
if item and item .checkState ()==Qt .Checked :
post_data_for_download =item .data (Qt .UserRole )
self .selected_posts_data .append (post_data_for_download )
if not self .selected_posts_data :
QMessageBox .information (self ,self ._tr ("fav_posts_no_selection_title","No Selection"),self ._tr ("fav_posts_no_selection_message","Please select at least one post to download."))
return
self .accept ()
def get_selected_posts (self ):
return self .selected_posts_data

View File

@@ -0,0 +1,202 @@
# --- Standard Library Imports ---
import os
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox
)
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY
)
class FutureSettingsDialog(QDialog):
"""
A dialog for managing application-wide settings like theme, language,
and saving the default download path.
"""
def __init__(self, parent_app_ref, parent=None):
"""
Initializes the dialog.
Args:
parent_app_ref (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app_ref
self.setModal(True)
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 380, 250
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
layout = QVBoxLayout(self)
# --- Appearance Settings ---
self.appearance_group_box = QGroupBox()
appearance_layout = QVBoxLayout(self.appearance_group_box)
self.theme_toggle_button = QPushButton()
self.theme_toggle_button.clicked.connect(self._toggle_theme)
appearance_layout.addWidget(self.theme_toggle_button)
layout.addWidget(self.appearance_group_box)
# --- Language Settings ---
self.language_group_box = QGroupBox()
language_group_layout = QVBoxLayout(self.language_group_box)
self.language_selection_layout = QHBoxLayout()
self.language_label = QLabel()
self.language_selection_layout.addWidget(self.language_label)
self.language_combo_box = QComboBox()
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
self.language_selection_layout.addWidget(self.language_combo_box, 1)
language_group_layout.addLayout(self.language_selection_layout)
layout.addWidget(self.language_group_box)
# --- Download Settings ---
self.download_settings_group_box = QGroupBox()
download_settings_layout = QVBoxLayout(self.download_settings_group_box)
self.save_path_button = QPushButton()
self.save_path_button.clicked.connect(self._save_download_path)
download_settings_layout.addWidget(self.save_path_button)
layout.addWidget(self.download_settings_group_box)
layout.addStretch(1)
# --- OK Button ---
self.ok_button = QPushButton()
self.ok_button.clicked.connect(self.accept)
layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
self.appearance_group_box.setTitle(self._tr("appearance_group_title", "Appearance"))
self.language_group_box.setTitle(self._tr("language_group_title", "Language Settings"))
self.download_settings_group_box.setTitle(self._tr("settings_download_group_title", "Download Settings"))
self.language_label.setText(self._tr("language_label", "Language:"))
self._update_theme_toggle_button_text()
self._populate_language_combo_box()
self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path"))
self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions."))
self.ok_button.setText(self._tr("ok_button", "OK"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme())
else:
self.setStyleSheet("")
def _update_theme_toggle_button_text(self):
"""Updates the theme button text and tooltip based on the current theme."""
if self.parent_app.current_theme == "dark":
self.theme_toggle_button.setText(self._tr("theme_toggle_light", "Switch to Light Mode"))
self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_light", "Change the application appearance to light."))
else:
self.theme_toggle_button.setText(self._tr("theme_toggle_dark", "Switch to Dark Mode"))
self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_dark", "Change the application appearance to dark."))
def _toggle_theme(self):
"""Toggles the application theme and updates the UI."""
new_theme = "light" if self.parent_app.current_theme == "dark" else "dark"
self.parent_app.apply_theme(new_theme)
self._retranslate_ui()
self._apply_theme()
def _populate_language_combo_box(self):
"""Populates the language dropdown with available languages."""
self.language_combo_box.blockSignals(True)
self.language_combo_box.clear()
languages = [
("en","English"),
("ja","日本語 (Japanese)"),
("fr","Français (French)"),
("de","Deutsch (German)"),
("es","Español (Spanish)"),
("pt","Português (Portuguese)"),
("ru","Русский (Russian)"),
("zh_CN","简体中文 (Simplified Chinese)"),
("zh_TW","繁體中文 (Traditional Chinese)"),
("ko","한국어 (Korean)")
]
for lang_code, lang_name in languages:
self.language_combo_box.addItem(lang_name, lang_code)
if self.parent_app.current_selected_language == lang_code:
self.language_combo_box.setCurrentIndex(self.language_combo_box.count() - 1)
self.language_combo_box.blockSignals(False)
def _language_selection_changed(self, index):
"""Handles the user selecting a new language."""
selected_lang_code = self.language_combo_box.itemData(index)
if selected_lang_code and selected_lang_code != self.parent_app.current_selected_language:
self.parent_app.current_selected_language = selected_lang_code
self.parent_app.settings.setValue(LANGUAGE_KEY, selected_lang_code)
self.parent_app.settings.sync()
self._retranslate_ui()
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(self._tr("language_change_title", "Language Changed"))
msg_box.setText(self._tr("language_change_message", "A restart is required..."))
msg_box.setInformativeText(self._tr("language_change_informative", "Would you like to restart now?"))
restart_button = msg_box.addButton(self._tr("restart_now_button", "Restart Now"), QMessageBox.ApplyRole)
ok_button = msg_box.addButton(self._tr("ok_button", "OK"), QMessageBox.AcceptRole)
msg_box.setDefaultButton(ok_button)
msg_box.exec_()
if msg_box.clickedButton() == restart_button:
self.parent_app._request_restart_application()
def _save_download_path(self):
"""Saves the current download path from the main window to settings."""
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
current_path = self.parent_app.dir_input.text().strip()
if current_path:
if os.path.isdir(current_path):
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
self.parent_app.settings.sync()
QMessageBox.information(self,
self._tr("settings_save_path_success_title", "Path Saved"),
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
else:
QMessageBox.warning(self,
self._tr("settings_save_path_invalid_title", "Invalid Path"),
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
else:
QMessageBox.warning(self,
self._tr("settings_save_path_empty_title", "Empty Path"),
self._tr("settings_save_path_empty_message", "Download location cannot be empty."))
else:
QMessageBox.critical(self, "Error", "Could not access download path input from main application.")

View File

@@ -0,0 +1,192 @@
# --- Standard Library Imports ---
import os
import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import QUrl, QSize, Qt
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
class TourStepWidget(QWidget):
"""
A custom widget representing a single step or page in the feature guide.
It neatly formats a title and its corresponding content.
"""
def __init__(self, title_text, content_text, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
layout.addWidget(title_label)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setStyleSheet("background-color: transparent;")
content_label = QLabel(content_text)
content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True) # Allow opening links in the content
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1)
class HelpGuideDialog (QDialog ):
"""A multi-page dialog for displaying the feature guide."""
def __init__ (self ,steps_data ,parent_app ,parent =None ):
super ().__init__ (parent )
self .current_step =0
self .steps_data =steps_data
self .parent_app =parent_app
app_icon =get_app_icon_object ()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
self .setModal (True )
self .setFixedSize (650 ,600 )
current_theme_style =""
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
if hasattr (self .parent_app ,'get_dark_theme'):
current_theme_style =self .parent_app .get_dark_theme ()
self .setStyleSheet (current_theme_style if current_theme_style else """
QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
QLabel { color: #E0E0E0; }
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
QPushButton:hover { background-color: #656565; }
QPushButton:pressed { background-color: #4A4A4A; }
""")
self ._init_ui ()
if self .parent_app :
self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language."""
if callable (get_translation )and self .parent_app :
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text
def _init_ui (self ):
main_layout =QVBoxLayout (self )
main_layout .setContentsMargins (0 ,0 ,0 ,0 )
main_layout .setSpacing (0 )
self .stacked_widget =QStackedWidget ()
main_layout .addWidget (self .stacked_widget ,1 )
self .tour_steps_widgets =[]
for title ,content in self .steps_data :
step_widget =TourStepWidget (title ,content )
self .tour_steps_widgets .append (step_widget )
self .stacked_widget .addWidget (step_widget )
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
buttons_layout =QHBoxLayout ()
buttons_layout .setContentsMargins (15 ,10 ,15 ,15 )
buttons_layout .setSpacing (10 )
self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back"))
self .back_button .clicked .connect (self ._previous_step )
self .back_button .setEnabled (False )
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
assets_base_dir =sys ._MEIPASS
else :
# Go up three levels from this file's directory (src/ui/dialogs) to the project root
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
instagram_icon_path =os .path .join (assets_base_dir ,"assets","instagram.png")
discord_icon_path =os .path .join (assets_base_dir ,"assets","discord.png")
self .github_button =QPushButton (QIcon (github_icon_path ),"")
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
icon_size =QSize (24 ,24 )
self .github_button .setIconSize (icon_size )
self .instagram_button .setIconSize (icon_size )
self .Discord_button .setIconSize (icon_size )
self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next"))
self .next_button .clicked .connect (self ._next_step_action )
self .next_button .setDefault (True )
self .github_button .clicked .connect (self ._open_github_link )
self .instagram_button .clicked .connect (self ._open_instagram_link )
self .Discord_button .clicked .connect (self ._open_Discord_link )
self .github_button .setToolTip (self ._tr ("help_guide_github_tooltip","Visit project's GitHub page (Opens in browser)"))
self .instagram_button .setToolTip (self ._tr ("help_guide_instagram_tooltip","Visit our Instagram page (Opens in browser)"))
self .Discord_button .setToolTip (self ._tr ("help_guide_discord_tooltip","Visit our Discord community (Opens in browser)"))
social_layout =QHBoxLayout ()
social_layout .setSpacing (10 )
social_layout .addWidget (self .github_button )
social_layout .addWidget (self .instagram_button )
social_layout .addWidget (self .Discord_button )
while buttons_layout .count ():
item =buttons_layout .takeAt (0 )
if item .widget ():
item .widget ().setParent (None )
elif item .layout ():
pass
buttons_layout .addLayout (social_layout )
buttons_layout .addStretch (1 )
buttons_layout .addWidget (self .back_button )
buttons_layout .addWidget (self .next_button )
main_layout .addLayout (buttons_layout )
self ._update_button_states ()
def _next_step_action (self ):
if self .current_step <len (self .tour_steps_widgets )-1 :
self .current_step +=1
self .stacked_widget .setCurrentIndex (self .current_step )
else :
self .accept ()
self ._update_button_states ()
def _previous_step (self ):
if self .current_step >0 :
self .current_step -=1
self .stacked_widget .setCurrentIndex (self .current_step )
self ._update_button_states ()
def _update_button_states (self ):
if self .current_step ==len (self .tour_steps_widgets )-1 :
self .next_button .setText (self ._tr ("tour_dialog_finish_button","Finish"))
else :
self .next_button .setText (self ._tr ("tour_dialog_next_button","Next"))
self .back_button .setEnabled (self .current_step >0 )
def _open_github_link (self ):
QDesktopServices .openUrl (QUrl ("https://github.com/Yuvi9587"))
def _open_instagram_link (self ):
QDesktopServices .openUrl (QUrl ("https://www.instagram.com/uvi.arts/"))
def _open_Discord_link (self ):
QDesktopServices .openUrl (QUrl ("https://discord.gg/BqP64XTdJN"))

View File

@@ -0,0 +1,150 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QPushButton, QVBoxLayout
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
class KnownNamesFilterDialog(QDialog):
"""
A dialog to select names from the Known.txt list to add to the main
character filter input field. This provides a convenient way for users
to reuse their saved names and groups for filtering downloads.
"""
def __init__(self, known_names_list, parent_app_ref, parent=None):
"""
Initializes the dialog.
Args:
known_names_list (list): A list of known name objects (dicts) from Known.txt.
parent_app_ref (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app_ref
self.setModal(True)
self.all_known_name_entries = sorted(known_names_list, key=lambda x: x['name'].lower())
self.selected_entries_to_return = []
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_geometry = QApplication.primaryScreen().availableGeometry()
base_width, base_height = 460, 450
scale_factor_h = screen_geometry.height() / 1080.0
effective_scale_factor = max(0.75, min(scale_factor_h, 1.5))
self.setMinimumSize(int(base_width * effective_scale_factor), int(base_height * effective_scale_factor))
self.resize(int(base_width * effective_scale_factor * 1.1), int(base_height * effective_scale_factor * 1.1))
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
self.search_input = QLineEdit()
self.search_input.textChanged.connect(self._filter_list_display)
main_layout.addWidget(self.search_input)
self.names_list_widget = QListWidget()
self._populate_list_widget()
main_layout.addWidget(self.names_list_widget)
# --- Control Buttons ---
buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items)
buttons_layout.addWidget(self.select_all_button)
self.deselect_all_button = QPushButton()
self.deselect_all_button.clicked.connect(self._deselect_all_items)
buttons_layout.addWidget(self.deselect_all_button)
buttons_layout.addStretch(1)
self.add_button = QPushButton()
self.add_button.clicked.connect(self._accept_selection_action)
self.add_button.setDefault(True)
buttons_layout.addWidget(self.add_button)
self.cancel_button = QPushButton()
self.cancel_button.clicked.connect(self.reject)
buttons_layout.addWidget(self.cancel_button)
main_layout.addLayout(buttons_layout)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("known_names_filter_dialog_title", "Add Known Names to Filter"))
self.search_input.setPlaceholderText(self._tr("known_names_filter_search_placeholder", "Search names..."))
self.select_all_button.setText(self._tr("known_names_filter_select_all_button", "Select All"))
self.deselect_all_button.setText(self._tr("known_names_filter_deselect_all_button", "Deselect All"))
self.add_button.setText(self._tr("known_names_filter_add_selected_button", "Add Selected"))
self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme())
def _populate_list_widget(self):
"""Populates the list widget with the known names."""
self.names_list_widget.clear()
for entry_obj in self.all_known_name_entries:
item = QListWidgetItem(entry_obj['name'])
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked)
item.setData(Qt.UserRole, entry_obj)
self.names_list_widget.addItem(item)
def _filter_list_display(self):
"""Filters the displayed list based on the search input text."""
search_text_lower = self.search_input.text().lower()
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
entry_obj = item.data(Qt.UserRole)
matches_search = not search_text_lower or search_text_lower in entry_obj['name'].lower()
item.setHidden(not matches_search)
def _select_all_items(self):
"""Checks all visible items in the list widget."""
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
if not item.isHidden():
item.setCheckState(Qt.Checked)
def _deselect_all_items(self):
"""Unchecks all items in the list widget."""
for i in range(self.names_list_widget.count()):
self.names_list_widget.item(i).setCheckState(Qt.Unchecked)
def _accept_selection_action(self):
"""Gathers the selected entries and accepts the dialog."""
self.selected_entries_to_return = []
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
if item.checkState() == Qt.Checked:
self.selected_entries_to_return.append(item.data(Qt.UserRole))
self.accept()
def get_selected_entries(self):
"""Returns the list of known name entries selected by the user."""
return self.selected_entries_to_return

View File

@@ -0,0 +1,217 @@
# --- Standard Library Imports ---
import os
import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...config.constants import (
CONFIG_ORGANIZATION_NAME
)
class TourStepWidget(QWidget):
"""
A custom widget representing a single step or page in the feature tour.
It neatly formats a title and its corresponding content.
"""
def __init__(self, title_text, content_text, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
layout.addWidget(title_label)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setStyleSheet("background-color: transparent;")
content_label = QLabel(content_text)
content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True)
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1)
class TourDialog(QDialog):
"""
A dialog that shows a multi-page tour to the user on first launch.
Includes a "Never show again" checkbox and uses QSettings to remember this preference.
"""
tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal()
# Constants for QSettings
CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV19"
def __init__(self, parent_app, parent=None):
"""
Initializes the dialog.
Args:
parent_app (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, self.CONFIG_APP_NAME_TOUR)
self.current_step = 0
self.parent_app = parent_app
self.setWindowIcon(get_app_icon_object())
self.setModal(True)
self.setFixedSize(600, 620)
self._init_ui()
self._apply_theme()
self._center_on_screen()
def _tr(self, key, default_text=""):
"""Helper for translation."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _init_ui(self):
"""Initializes all UI components and layouts."""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1)
# Load content for each step
steps_content = [
("tour_dialog_step1_title", "tour_dialog_step1_content"),
("tour_dialog_step2_title", "tour_dialog_step2_content"),
("tour_dialog_step3_title", "tour_dialog_step3_content"),
("tour_dialog_step4_title", "tour_dialog_step4_content"),
("tour_dialog_step5_title", "tour_dialog_step5_content"),
("tour_dialog_step6_title", "tour_dialog_step6_content"),
("tour_dialog_step7_title", "tour_dialog_step7_content"),
("tour_dialog_step8_title", "tour_dialog_step8_content"),
]
self.tour_steps_widgets = []
for title_key, content_key in steps_content:
title = self._tr(title_key, title_key)
content = self._tr(content_key, "Content not found.")
step_widget = TourStepWidget(title, content)
self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget)
self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
# --- Bottom Controls ---
bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
bottom_controls_layout.setSpacing(12)
self.never_show_again_checkbox = QCheckBox(self._tr("tour_dialog_never_show_checkbox", "Never show this tour again"))
bottom_controls_layout.addWidget(self.never_show_again_checkbox, 0, Qt.AlignLeft)
buttons_layout = QHBoxLayout()
buttons_layout.setSpacing(10)
self.skip_button = QPushButton(self._tr("tour_dialog_skip_button", "Skip Tour"))
self.skip_button.clicked.connect(self._skip_tour_action)
self.back_button = QPushButton(self._tr("tour_dialog_back_button", "Back"))
self.back_button.clicked.connect(self._previous_step)
self.next_button = QPushButton(self._tr("tour_dialog_next_button", "Next"))
self.next_button.clicked.connect(self._next_step_action)
self.next_button.setDefault(True)
buttons_layout.addWidget(self.skip_button)
buttons_layout.addStretch(1)
buttons_layout.addWidget(self.back_button)
buttons_layout.addWidget(self.next_button)
bottom_controls_layout.addLayout(buttons_layout)
main_layout.addLayout(bottom_controls_layout)
self._update_button_states()
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme())
else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }")
def _center_on_screen(self):
"""Centers the dialog on the screen."""
try:
screen_geo = QApplication.primaryScreen().availableGeometry()
self.move(screen_geo.center() - self.rect().center())
except Exception as e:
print(f"[TourDialog] Error centering dialog: {e}")
def _next_step_action(self):
"""Moves to the next step or finishes the tour."""
if self.current_step < len(self.tour_steps_widgets) - 1:
self.current_step += 1
self.stacked_widget.setCurrentIndex(self.current_step)
else:
self._finish_tour_action()
self._update_button_states()
def _previous_step(self):
"""Moves to the previous step."""
if self.current_step > 0:
self.current_step -= 1
self.stacked_widget.setCurrentIndex(self.current_step)
self._update_button_states()
def _update_button_states(self):
"""Updates the state and text of navigation buttons."""
is_last_step = self.current_step == len(self.tour_steps_widgets) - 1
self.next_button.setText(self._tr("tour_dialog_finish_button", "Finish") if is_last_step else self._tr("tour_dialog_next_button", "Next"))
self.back_button.setEnabled(self.current_step > 0)
def _skip_tour_action(self):
"""Handles the action when the tour is skipped."""
self._save_settings_if_checked()
self.tour_skipped.emit()
self.reject()
def _finish_tour_action(self):
"""Handles the action when the tour is finished normally."""
self._save_settings_if_checked()
self.tour_finished_normally.emit()
self.accept()
def _save_settings_if_checked(self):
"""Saves the 'never show again' preference to QSettings."""
self.settings.setValue(self.TOUR_SHOWN_KEY, self.never_show_again_checkbox.isChecked())
self.settings.sync()
@staticmethod
def should_show_tour():
"""Checks QSettings to see if the tour should be shown on startup."""
settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR)
never_show = settings.value(TourDialog.TOUR_SHOWN_KEY, False, type=bool)
return not never_show
CONFIG_ORGANIZATION_NAME = CONFIG_ORGANIZATION_NAME
def closeEvent(self, event):
"""Ensures settings are saved if the dialog is closed via the 'X' button."""
self._skip_tour_action()
super().closeEvent(event)

View File

@@ -0,0 +1 @@
# ...existing code...