mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
4 Commits
v6.1.0
...
5d8737b59e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d8737b59e | ||
|
|
d54b013bbc | ||
|
|
2785fc1121 | ||
|
|
fbdae61b80 |
24
LICENSE
24
LICENSE
@@ -1,11 +1,21 @@
|
||||
Custom License - No Commercial Use
|
||||
MIT License
|
||||
|
||||
Copyright [Yuvi9587] [2025]
|
||||
Copyright (c) [2025] [Yuvi9587]
|
||||
|
||||
Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, and distribute the Software for **non-commercial purposes only**, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
2. Proper credit must be given to the original author in any public use, distribution, or derivative works.
|
||||
3. Commercial use, resale, or sublicensing of the Software or any derivative works is strictly prohibited without explicit written permission.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND...
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -59,6 +59,7 @@ LANGUAGE_KEY = "currentLanguageV1"
|
||||
DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
|
||||
RESOLUTION_KEY = "window_resolution"
|
||||
UI_SCALE_KEY = "ui_scale_factor"
|
||||
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
|
||||
|
||||
# --- UI Constants and Identifiers ---
|
||||
HTML_PREFIX = "<!HTML!>"
|
||||
|
||||
@@ -3,8 +3,6 @@ import traceback
|
||||
from urllib.parse import urlparse
|
||||
import json # Ensure json is imported
|
||||
import requests
|
||||
|
||||
# (Keep the rest of your imports)
|
||||
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||
from ..config.constants import (
|
||||
STYLE_DATE_POST_TITLE
|
||||
@@ -25,9 +23,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
raise RuntimeError("Fetch operation cancelled by user while paused.")
|
||||
time.sleep(0.5)
|
||||
logger(" Post fetching resumed.")
|
||||
|
||||
# --- MODIFICATION: Added `fields` to the URL to request only metadata ---
|
||||
# This prevents the large 'content' field from being included in the list, avoiding timeouts.
|
||||
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
|
||||
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
|
||||
|
||||
@@ -44,7 +39,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
logger(log_message)
|
||||
|
||||
try:
|
||||
# We can now remove the streaming logic as the response will be small and fast.
|
||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -80,7 +74,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
|
||||
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
|
||||
logger(f" Fetching full content for post ID {post_id}...")
|
||||
try:
|
||||
# Use streaming here as a precaution for single posts that are still very large.
|
||||
with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
response_body = b""
|
||||
@@ -88,7 +81,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
|
||||
response_body += chunk
|
||||
|
||||
full_post_data = json.loads(response_body)
|
||||
# The API sometimes wraps the post in a list, handle that.
|
||||
if isinstance(full_post_data, list) and full_post_data:
|
||||
return full_post_data[0]
|
||||
return full_post_data
|
||||
@@ -134,14 +126,10 @@ def download_from_api(
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
# --- ADD THIS BLOCK ---
|
||||
# Ensure processed_post_ids is a set for fast lookups
|
||||
if processed_post_ids is None:
|
||||
processed_post_ids = set()
|
||||
else:
|
||||
processed_post_ids = set(processed_post_ids)
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
service, user_id, target_post_id = extract_post_info(api_url_input)
|
||||
|
||||
@@ -158,11 +146,9 @@ def download_from_api(
|
||||
if use_cookie and app_base_dir:
|
||||
cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain)
|
||||
if target_post_id:
|
||||
# --- ADD THIS CHECK FOR RESTORE ---
|
||||
if target_post_id in processed_post_ids:
|
||||
logger(f" Skipping already processed target post ID: {target_post_id}")
|
||||
return
|
||||
# --- END OF ADDITION ---
|
||||
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
|
||||
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
|
||||
try:
|
||||
@@ -248,14 +234,12 @@ def download_from_api(
|
||||
break
|
||||
if cancellation_event and cancellation_event.is_set(): return
|
||||
if all_posts_for_manga_mode:
|
||||
# --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE ---
|
||||
if processed_post_ids:
|
||||
original_count = len(all_posts_for_manga_mode)
|
||||
all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(all_posts_for_manga_mode)
|
||||
if skipped_count > 0:
|
||||
logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
|
||||
def sort_key_tuple(post):
|
||||
@@ -326,15 +310,12 @@ def download_from_api(
|
||||
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
# --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE ---
|
||||
if processed_post_ids:
|
||||
original_count = len(posts_batch)
|
||||
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(posts_batch)
|
||||
if skipped_count > 0:
|
||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
if not posts_batch:
|
||||
if target_post_id and not processed_target_post_flag:
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
# --- Standard Library Imports ---
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# These imports reflect the new, organized project structure.
|
||||
from .api_client import download_from_api
|
||||
from .workers import PostProcessorWorker, DownloadThread
|
||||
from ..config.constants import (
|
||||
@@ -36,8 +32,6 @@ class DownloadManager:
|
||||
self.progress_queue = progress_queue
|
||||
self.thread_pool = None
|
||||
self.active_futures = []
|
||||
|
||||
# --- Session State ---
|
||||
self.cancellation_event = threading.Event()
|
||||
self.pause_event = threading.Event()
|
||||
self.is_running = False
|
||||
@@ -47,6 +41,9 @@ class DownloadManager:
|
||||
self.total_downloads = 0
|
||||
self.total_skips = 0
|
||||
self.all_kept_original_filenames = []
|
||||
self.creator_profiles_dir = None
|
||||
self.current_creator_name_for_profile = None
|
||||
self.current_creator_profile_path = None
|
||||
|
||||
def _log(self, message):
|
||||
"""Puts a progress message into the queue for the UI."""
|
||||
@@ -64,8 +61,13 @@ class DownloadManager:
|
||||
if self.is_running:
|
||||
self._log("❌ Cannot start a new session: A session is already in progress.")
|
||||
return
|
||||
|
||||
creator_profile_data = self._setup_creator_profile(config)
|
||||
creator_profile_data['settings'] = config
|
||||
creator_profile_data.setdefault('processed_post_ids', [])
|
||||
self._save_creator_profile(creator_profile_data)
|
||||
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.")
|
||||
|
||||
# --- Reset state for the new session ---
|
||||
self.is_running = True
|
||||
self.cancellation_event.clear()
|
||||
self.pause_event.clear()
|
||||
@@ -75,33 +77,25 @@ class DownloadManager:
|
||||
self.total_downloads = 0
|
||||
self.total_skips = 0
|
||||
self.all_kept_original_filenames = []
|
||||
|
||||
# --- Decide execution strategy (multi-threaded vs. single-threaded) ---
|
||||
is_single_post = bool(config.get('target_post_id_from_initial_url'))
|
||||
use_multithreading = config.get('use_multithreading', True)
|
||||
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||
|
||||
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
|
||||
|
||||
|
||||
if should_use_multithreading_for_posts:
|
||||
# Start a separate thread to manage fetching and queuing to the thread pool
|
||||
fetcher_thread = threading.Thread(
|
||||
target=self._fetch_and_queue_posts_for_pool,
|
||||
args=(config, restore_data),
|
||||
args=(config, restore_data, creator_profile_data), # Add argument here
|
||||
daemon=True
|
||||
)
|
||||
fetcher_thread.start()
|
||||
else:
|
||||
# For single posts or sequential manga mode, use a single worker thread
|
||||
# which is simpler and ensures order.
|
||||
self._start_single_threaded_session(config)
|
||||
|
||||
def _start_single_threaded_session(self, config):
|
||||
"""Handles downloads that are best processed by a single worker thread."""
|
||||
self._log("ℹ️ Initializing single-threaded download process...")
|
||||
|
||||
# The original DownloadThread is now a pure Python thread, not a QThread.
|
||||
# We run its `run` method in a standard Python thread.
|
||||
self.worker_thread = threading.Thread(
|
||||
target=self._run_single_worker,
|
||||
args=(config,),
|
||||
@@ -112,7 +106,6 @@ class DownloadManager:
|
||||
def _run_single_worker(self, config):
|
||||
"""Target function for the single-worker thread."""
|
||||
try:
|
||||
# Pass the queue directly to the worker for it to send updates
|
||||
worker = DownloadThread(config, self.progress_queue)
|
||||
worker.run() # This is the main blocking call for this thread
|
||||
except Exception as e:
|
||||
@@ -129,9 +122,11 @@ class DownloadManager:
|
||||
try:
|
||||
num_workers = min(config.get('num_threads', 4), MAX_THREADS)
|
||||
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
|
||||
|
||||
# Fetch posts
|
||||
# In a real implementation, this would call `api_client.download_from_api`
|
||||
|
||||
session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set()
|
||||
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
||||
processed_ids = session_processed_ids.union(profile_processed_ids)
|
||||
|
||||
if restore_data:
|
||||
all_posts = restore_data['all_posts_data']
|
||||
processed_ids = set(restore_data['processed_post_ids'])
|
||||
@@ -149,12 +144,9 @@ class DownloadManager:
|
||||
if not posts_to_process:
|
||||
self._log("✅ No new posts to process.")
|
||||
return
|
||||
|
||||
# Submit tasks to the pool
|
||||
for post_data in posts_to_process:
|
||||
if self.cancellation_event.is_set():
|
||||
break
|
||||
# Each PostProcessorWorker gets the queue to send its own updates
|
||||
worker = PostProcessorWorker(post_data, config, self.progress_queue)
|
||||
future = self.thread_pool.submit(worker.process)
|
||||
future.add_done_callback(self._handle_future_result)
|
||||
@@ -164,27 +156,32 @@ class DownloadManager:
|
||||
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
|
||||
self._log(traceback.format_exc())
|
||||
finally:
|
||||
# Wait for all submitted tasks to complete before shutting down
|
||||
if self.thread_pool:
|
||||
self.thread_pool.shutdown(wait=True)
|
||||
self.is_running = False
|
||||
self._log("🏁 All processing tasks have completed.")
|
||||
# Emit final signal
|
||||
self.progress_queue.put({
|
||||
'type': 'finished',
|
||||
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
|
||||
})
|
||||
|
||||
|
||||
def _get_all_posts(self, config):
|
||||
"""Helper to fetch all posts using the API client."""
|
||||
all_posts = []
|
||||
# This generator yields batches of posts
|
||||
post_generator = download_from_api(
|
||||
api_url_input=config['api_url'],
|
||||
logger=self._log,
|
||||
# ... pass other relevant config keys ...
|
||||
start_page=config.get('start_page'),
|
||||
end_page=config.get('end_page'),
|
||||
manga_mode=config.get('manga_mode_active', False),
|
||||
cancellation_event=self.cancellation_event,
|
||||
pause_event=self.pause_event
|
||||
pause_event=self.pause_event,
|
||||
use_cookie=config.get('use_cookie', False),
|
||||
cookie_text=config.get('cookie_text', ''),
|
||||
selected_cookie_file=config.get('selected_cookie_file'),
|
||||
app_base_dir=config.get('app_base_dir'),
|
||||
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
|
||||
processed_post_ids=config.get('processed_post_ids', [])
|
||||
)
|
||||
for batch in post_generator:
|
||||
all_posts.extend(batch)
|
||||
@@ -203,39 +200,70 @@ class DownloadManager:
|
||||
self.total_skips += 1
|
||||
else:
|
||||
result = future.result()
|
||||
# Unpack result tuple from the worker
|
||||
(dl_count, skip_count, kept_originals,
|
||||
retryable, permanent, history) = result
|
||||
self.total_downloads += dl_count
|
||||
self.total_skips += skip_count
|
||||
self.all_kept_original_filenames.extend(kept_originals)
|
||||
|
||||
# Queue up results for UI to handle
|
||||
if retryable:
|
||||
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
|
||||
if permanent:
|
||||
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
|
||||
if history:
|
||||
self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
|
||||
post_id = history.get('post_id')
|
||||
if post_id and self.current_creator_profile_path:
|
||||
profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path})
|
||||
if post_id not in profile_data.get('processed_post_ids', []):
|
||||
profile_data.setdefault('processed_post_ids', []).append(post_id)
|
||||
self._save_creator_profile(profile_data)
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"❌ Worker task resulted in an exception: {e}")
|
||||
self.total_skips += 1 # Count errored posts as skipped
|
||||
|
||||
# Update overall progress
|
||||
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
||||
|
||||
def _setup_creator_profile(self, config):
|
||||
"""Prepares the path and loads data for the current creator's profile."""
|
||||
self.current_creator_name_for_profile = config.get('creator_name_for_profile')
|
||||
if not self.current_creator_name_for_profile:
|
||||
self._log("⚠️ Cannot create creator profile: Name not provided in config.")
|
||||
return {}
|
||||
|
||||
appdata_dir = os.path.dirname(config.get('session_file_path', '.'))
|
||||
self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
||||
os.makedirs(self.creator_profiles_dir, exist_ok=True)
|
||||
|
||||
safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json"
|
||||
self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename)
|
||||
|
||||
if os.path.exists(self.current_creator_profile_path):
|
||||
try:
|
||||
with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
|
||||
return {}
|
||||
|
||||
def _save_creator_profile(self, data):
|
||||
"""Saves the provided data to the current creator's profile file."""
|
||||
if not self.current_creator_profile_path:
|
||||
return
|
||||
try:
|
||||
temp_path = self.current_creator_profile_path + ".tmp"
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(temp_path, self.current_creator_profile_path)
|
||||
except OSError as e:
|
||||
self._log(f"❌ Error saving creator profile to '{self.current_creator_profile_path}': {e}")
|
||||
|
||||
def cancel_session(self):
|
||||
"""Cancels the current running session."""
|
||||
if not self.is_running:
|
||||
return
|
||||
self._log("⚠️ Cancellation requested by user...")
|
||||
self.cancellation_event.set()
|
||||
|
||||
# For single thread mode, the worker checks the event
|
||||
# For multi-thread mode, shut down the pool
|
||||
if self.thread_pool:
|
||||
# Don't wait, just cancel pending futures and let the fetcher thread exit
|
||||
self.thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
self.is_running = False
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
@@ -15,15 +14,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError,
|
||||
from io import BytesIO
|
||||
from urllib .parse import urlparse
|
||||
import requests
|
||||
# --- Third-Party Library Imports ---
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None
|
||||
#
|
||||
try:
|
||||
from fpdf import FPDF
|
||||
# Add a simple class to handle the header/footer for stories
|
||||
class PDF(FPDF):
|
||||
def header(self):
|
||||
pass # No header
|
||||
@@ -39,16 +35,12 @@ try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
Document = None
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess
|
||||
# --- Local Application Imports ---
|
||||
from .api_client import download_from_api, fetch_post_comments
|
||||
from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE
|
||||
from ..services.drive_downloader import (
|
||||
download_mega_file, download_gdrive_file, download_dropbox_file
|
||||
)
|
||||
# Corrected Imports:
|
||||
from ..utils.file_utils import (
|
||||
is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES,
|
||||
clean_filename, clean_folder_name
|
||||
@@ -246,13 +238,24 @@ class PostProcessorWorker:
|
||||
|
||||
if self.manga_mode_active:
|
||||
if self.manga_filename_style == STYLE_ORIGINAL_NAME:
|
||||
filename_to_save_in_main_path = cleaned_original_api_filename
|
||||
if self.manga_date_prefix and self.manga_date_prefix.strip():
|
||||
cleaned_prefix = clean_filename(self.manga_date_prefix.strip())
|
||||
if cleaned_prefix:
|
||||
filename_to_save_in_main_path = f"{cleaned_prefix} {filename_to_save_in_main_path}"
|
||||
else:
|
||||
self.logger(f"⚠️ Manga Original Name Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using original name only.")
|
||||
# Get the post's publication or added date
|
||||
published_date_str = self.post.get('published')
|
||||
added_date_str = self.post.get('added')
|
||||
formatted_date_str = "nodate" # Fallback if no date is found
|
||||
|
||||
date_to_use_str = published_date_str or added_date_str
|
||||
|
||||
if date_to_use_str:
|
||||
try:
|
||||
# Extract just the YYYY-MM-DD part from the timestamp
|
||||
formatted_date_str = date_to_use_str.split('T')[0]
|
||||
except Exception:
|
||||
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
|
||||
else:
|
||||
self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.")
|
||||
|
||||
# Combine the date with the cleaned original filename
|
||||
filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
|
||||
was_original_name_kept_flag = True
|
||||
elif self.manga_filename_style == STYLE_POST_TITLE:
|
||||
if post_title and post_title.strip():
|
||||
@@ -567,10 +570,8 @@ class PostProcessorWorker:
|
||||
with self.downloaded_hash_counts_lock:
|
||||
current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
|
||||
|
||||
# Default to not skipping
|
||||
decision_to_skip = False
|
||||
|
||||
# Apply logic based on mode
|
||||
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
||||
if current_count >= 1:
|
||||
decision_to_skip = True
|
||||
@@ -581,12 +582,10 @@ class PostProcessorWorker:
|
||||
decision_to_skip = True
|
||||
self.logger(f" -> Skip (Duplicate Limit Reached): Limit of {self.keep_duplicates_limit} for this file content has been met. Discarding.")
|
||||
|
||||
# If we are NOT skipping this file, we MUST increment the count.
|
||||
if not decision_to_skip:
|
||||
self.downloaded_hash_counts[calculated_file_hash] = current_count + 1
|
||||
|
||||
should_skip = decision_to_skip
|
||||
# --- End of Final Corrected Logic ---
|
||||
|
||||
if should_skip:
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
@@ -678,9 +677,14 @@ class PostProcessorWorker:
|
||||
else:
|
||||
self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.")
|
||||
details_for_failure = {
|
||||
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers,
|
||||
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post
|
||||
'file_info': file_info,
|
||||
'target_folder_path': target_folder_path,
|
||||
'headers': headers,
|
||||
'original_post_id_for_log': original_post_id_for_log,
|
||||
'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post,
|
||||
'num_files_in_this_post': num_files_in_this_post,
|
||||
'forced_filename_override': filename_to_save_in_main_path
|
||||
}
|
||||
if is_permanent_error:
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, details_for_failure
|
||||
@@ -1040,7 +1044,9 @@ class PostProcessorWorker:
|
||||
return result_tuple
|
||||
|
||||
raw_text_content = ""
|
||||
comments_data = []
|
||||
final_post_data = post_data
|
||||
|
||||
if self.text_only_scope == 'content' and 'content' not in final_post_data:
|
||||
self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
|
||||
parsed_url = urlparse(self.api_url_input)
|
||||
@@ -1050,6 +1056,7 @@ class PostProcessorWorker:
|
||||
full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies)
|
||||
if full_data:
|
||||
final_post_data = full_data
|
||||
|
||||
if self.text_only_scope == 'content':
|
||||
raw_text_content = final_post_data.get('content', '')
|
||||
elif self.text_only_scope == 'comments':
|
||||
@@ -1060,46 +1067,46 @@ class PostProcessorWorker:
|
||||
if comments_data:
|
||||
comment_texts = []
|
||||
for comment in comments_data:
|
||||
user = comment.get('user', {}).get('name', 'Unknown User')
|
||||
timestamp = comment.get('updated', 'No Date')
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n")
|
||||
raw_text_content = "\n".join(comment_texts)
|
||||
else:
|
||||
raw_text_content = ""
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Error fetching comments for text-only mode: {e}")
|
||||
|
||||
if not raw_text_content or not raw_text_content.strip():
|
||||
cleaned_text = ""
|
||||
if self.text_only_scope == 'content':
|
||||
if not raw_text_content:
|
||||
cleaned_text = ""
|
||||
else:
|
||||
text_with_newlines = re.sub(r'(?i)</p>|<br\s*/?>', '\n', raw_text_content)
|
||||
just_text = re.sub(r'<.*?>', '', text_with_newlines)
|
||||
cleaned_text = html.unescape(just_text).strip()
|
||||
else:
|
||||
cleaned_text = raw_text_content
|
||||
|
||||
cleaned_text = cleaned_text.replace('…', '...')
|
||||
|
||||
if not cleaned_text.strip():
|
||||
self.logger(" -> Skip Saving Text: No content/comments found or fetched.")
|
||||
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
return result_tuple
|
||||
|
||||
paragraph_pattern = re.compile(r'<p.*?>(.*?)</p>', re.IGNORECASE | re.DOTALL)
|
||||
html_paragraphs = paragraph_pattern.findall(raw_text_content)
|
||||
cleaned_text = ""
|
||||
if not html_paragraphs:
|
||||
self.logger(" ⚠️ No <p> tags found. Falling back to basic HTML cleaning for the whole block.")
|
||||
text_with_br = re.sub(r'<br\s*/?>', '\n', raw_text_content, flags=re.IGNORECASE)
|
||||
cleaned_text = re.sub(r'<.*?>', '', text_with_br)
|
||||
else:
|
||||
cleaned_paragraphs_list = []
|
||||
for p_content in html_paragraphs:
|
||||
p_with_br = re.sub(r'<br\s*/?>', '\n', p_content, flags=re.IGNORECASE)
|
||||
p_cleaned = re.sub(r'<.*?>', '', p_with_br)
|
||||
p_final = html.unescape(p_cleaned).strip()
|
||||
if p_final:
|
||||
cleaned_paragraphs_list.append(p_final)
|
||||
cleaned_text = '\n\n'.join(cleaned_paragraphs_list)
|
||||
cleaned_text = cleaned_text.replace('…', '...')
|
||||
|
||||
if self.single_pdf_mode:
|
||||
if not cleaned_text:
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
return result_tuple
|
||||
content_data = {
|
||||
'title': post_title,
|
||||
'content': cleaned_text,
|
||||
'published': self.post.get('published') or self.post.get('added')
|
||||
}
|
||||
if self.text_only_scope == 'comments':
|
||||
if not comments_data: return (0, 0, [], [], [], None, None)
|
||||
content_data['comments'] = comments_data
|
||||
else:
|
||||
if not cleaned_text.strip(): return (0, 0, [], [], [], None, None)
|
||||
content_data['content'] = cleaned_text
|
||||
|
||||
temp_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json"
|
||||
@@ -1107,13 +1114,11 @@ class PostProcessorWorker:
|
||||
try:
|
||||
with open(temp_filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(content_data, f, indent=2)
|
||||
self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.")
|
||||
result_tuple = (0, 0, [], [], [], None, temp_filepath)
|
||||
return result_tuple
|
||||
self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
|
||||
return (0, 0, [], [], [], None, temp_filepath)
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Failed to write temporary file for single PDF: {e}")
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
return result_tuple
|
||||
return (0, 0, [], [], [], None, None)
|
||||
else:
|
||||
file_extension = self.text_export_format
|
||||
txt_filename = clean_filename(post_title) + f".{file_extension}"
|
||||
@@ -1125,27 +1130,63 @@ class PostProcessorWorker:
|
||||
while os.path.exists(final_save_path):
|
||||
final_save_path = f"{base}_{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
if file_extension == 'pdf':
|
||||
if FPDF:
|
||||
self.logger(f" Converting to PDF...")
|
||||
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
|
||||
pdf = PDF()
|
||||
font_path = ""
|
||||
bold_font_path = ""
|
||||
if self.project_root_dir:
|
||||
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
bold_font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.set_font('DejaVu', '', 12)
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
default_font_family = 'DejaVu'
|
||||
except Exception as font_error:
|
||||
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
pdf.set_font('Arial', '', 12)
|
||||
default_font_family = 'Arial'
|
||||
|
||||
pdf.add_page()
|
||||
pdf.multi_cell(0, 5, cleaned_text)
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(0, 10, post_title)
|
||||
pdf.ln(10)
|
||||
|
||||
if self.text_only_scope == 'comments':
|
||||
if not comments_data:
|
||||
self.logger(" -> Skip PDF Creation: No comments to process.")
|
||||
return (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
for i, comment in enumerate(comments_data):
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
pdf.set_font(default_font_family, 'B', 10)
|
||||
pdf.write(8, user)
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(0, 7, body)
|
||||
if i < len(comments_data) - 1:
|
||||
pdf.ln(5)
|
||||
pdf.cell(0, 0, '', border='T')
|
||||
pdf.ln(5)
|
||||
else:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(0, 7, cleaned_text)
|
||||
|
||||
pdf.output(final_save_path)
|
||||
else:
|
||||
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.")
|
||||
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
||||
|
||||
elif file_extension == 'docx':
|
||||
if Document:
|
||||
self.logger(f" Converting to DOCX...")
|
||||
@@ -1156,12 +1197,15 @@ class PostProcessorWorker:
|
||||
self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.")
|
||||
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
||||
else:
|
||||
|
||||
else: # TXT file
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f:
|
||||
f.write(cleaned_text)
|
||||
|
||||
self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'")
|
||||
result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
|
||||
return result_tuple
|
||||
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}")
|
||||
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
@@ -1263,7 +1307,6 @@ class PostProcessorWorker:
|
||||
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
||||
unique_files_by_url = {}
|
||||
for file_info in all_files_from_post_api:
|
||||
# Use the file URL as a unique key to avoid processing the same file multiple times
|
||||
file_url = file_info.get('url')
|
||||
if file_url and file_url not in unique_files_by_url:
|
||||
unique_files_by_url[file_url] = file_info
|
||||
@@ -1353,7 +1396,17 @@ class PostProcessorWorker:
|
||||
|
||||
if not all_files_from_post_api:
|
||||
self.logger(f" No files found to download for post {post_id}.")
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
history_data_for_no_files_post = {
|
||||
'post_title': post_title,
|
||||
'post_id': post_id,
|
||||
'service': self.service,
|
||||
'user_id': self.user_id,
|
||||
'top_file_name': "N/A (No Files)",
|
||||
'num_files': 0,
|
||||
'upload_date_str': post_data.get('published') or post_data.get('added') or "Unknown",
|
||||
'download_location': determined_post_save_path_for_history
|
||||
}
|
||||
result_tuple = (0, 0, [], [], [], history_data_for_no_files_post, None)
|
||||
return result_tuple
|
||||
|
||||
files_to_download_info_list = []
|
||||
@@ -1734,7 +1787,6 @@ class DownloadThread(QThread):
|
||||
|
||||
worker_signals_obj = PostProcessorSignals()
|
||||
try:
|
||||
# Connect signals
|
||||
worker_signals_obj.progress_signal.connect(self.progress_signal)
|
||||
worker_signals_obj.file_download_status_signal.connect(self.file_download_status_signal)
|
||||
worker_signals_obj.file_progress_signal.connect(self.file_progress_signal)
|
||||
@@ -1771,8 +1823,6 @@ class DownloadThread(QThread):
|
||||
was_process_cancelled = True
|
||||
break
|
||||
|
||||
# --- START OF FIX: Explicitly build the arguments dictionary ---
|
||||
# This robustly maps all thread attributes to the correct worker parameters.
|
||||
worker_args = {
|
||||
'post_data': individual_post_data,
|
||||
'emitter': worker_signals_obj,
|
||||
@@ -1833,7 +1883,6 @@ class DownloadThread(QThread):
|
||||
'single_pdf_mode': self.single_pdf_mode,
|
||||
'project_root_dir': self.project_root_dir,
|
||||
}
|
||||
# --- END OF FIX ---
|
||||
|
||||
post_processing_worker = PostProcessorWorker(**worker_args)
|
||||
|
||||
@@ -1860,6 +1909,7 @@ class DownloadThread(QThread):
|
||||
if not was_process_cancelled and not self.isInterruptionRequested():
|
||||
self.logger("✅ All posts processed or end of content reached by DownloadThread.")
|
||||
|
||||
|
||||
except Exception as main_thread_err:
|
||||
self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}")
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -15,9 +15,9 @@ except ImportError:
|
||||
|
||||
try:
|
||||
import gdown
|
||||
GDOWN_AVAILABLE = True
|
||||
GDRIVE_AVAILABLE = True
|
||||
except ImportError:
|
||||
GDOWN_AVAILABLE = False
|
||||
GDRIVE_AVAILABLE = False
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
@@ -46,75 +46,76 @@ def _get_filename_from_headers(headers):
|
||||
|
||||
# --- Main Service Downloader Functions ---
|
||||
|
||||
def download_mega_file(mega_link, download_path=".", logger_func=print):
|
||||
def download_mega_file(mega_url, download_path, logger_func=print):
|
||||
"""
|
||||
Downloads a file from a public Mega.nz link.
|
||||
|
||||
Args:
|
||||
mega_link (str): The public Mega.nz link to the file.
|
||||
download_path (str): The directory to save the downloaded file.
|
||||
logger_func (callable): Function to use for logging.
|
||||
Downloads a file from a Mega.nz URL.
|
||||
Handles both public links and links that include a decryption key.
|
||||
"""
|
||||
if not MEGA_AVAILABLE:
|
||||
logger_func("❌ Error: mega.py library is not installed. Cannot download from Mega.")
|
||||
logger_func(" Please install it: pip install mega.py")
|
||||
raise ImportError("mega.py library not found.")
|
||||
logger_func("❌ Mega download failed: 'mega.py' library is not installed.")
|
||||
return
|
||||
|
||||
logger_func(f" [Mega] Initializing Mega client...")
|
||||
try:
|
||||
mega_client = Mega()
|
||||
m = mega_client.login()
|
||||
logger_func(f" [Mega] Attempting to download from: {mega_link}")
|
||||
|
||||
if not os.path.exists(download_path):
|
||||
os.makedirs(download_path, exist_ok=True)
|
||||
logger_func(f" [Mega] Created download directory: {download_path}")
|
||||
|
||||
# The download_url method handles file info fetching and saving internally.
|
||||
downloaded_file_path = m.download_url(mega_link, dest_path=download_path)
|
||||
mega = Mega()
|
||||
# Anonymous login is sufficient for public links
|
||||
m = mega.login()
|
||||
|
||||
if downloaded_file_path and os.path.exists(downloaded_file_path):
|
||||
logger_func(f" [Mega] ✅ File downloaded successfully! Saved as: {downloaded_file_path}")
|
||||
else:
|
||||
raise Exception(f"Mega download failed or file not found. Returned: {downloaded_file_path}")
|
||||
# --- MODIFIED PART: Added error handling for invalid links ---
|
||||
try:
|
||||
file_details = m.find(mega_url)
|
||||
if file_details is None:
|
||||
logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}")
|
||||
return
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
# This block catches the "Expecting value" error
|
||||
logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
# Catch other potential errors from the mega.py library
|
||||
logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}")
|
||||
return
|
||||
# --- END OF MODIFIED PART ---
|
||||
|
||||
filename = file_details[1]['a']['n']
|
||||
logger_func(f" [Mega] File found: '{filename}'. Starting download...")
|
||||
|
||||
# Sanitize filename before saving
|
||||
safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip()
|
||||
final_path = os.path.join(download_path, safe_filename)
|
||||
|
||||
# Check if file already exists
|
||||
if os.path.exists(final_path):
|
||||
logger_func(f" [Mega] ℹ️ File '{safe_filename}' already exists. Skipping download.")
|
||||
return
|
||||
|
||||
# Start the download
|
||||
m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename)
|
||||
logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'")
|
||||
|
||||
except Exception as e:
|
||||
logger_func(f" [Mega] ❌ An unexpected error occurred during Mega download: {e}")
|
||||
traceback.print_exc(limit=2)
|
||||
raise # Re-raise the exception to be handled by the calling worker
|
||||
logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}")
|
||||
|
||||
def download_gdrive_file(gdrive_link, download_path=".", logger_func=print):
|
||||
"""
|
||||
Downloads a file from a public Google Drive link using the gdown library.
|
||||
|
||||
Args:
|
||||
gdrive_link (str): The public Google Drive link to the file.
|
||||
download_path (str): The directory to save the downloaded file.
|
||||
logger_func (callable): Function to use for logging.
|
||||
"""
|
||||
if not GDOWN_AVAILABLE:
|
||||
logger_func("❌ Error: gdown library is not installed. Cannot download from Google Drive.")
|
||||
logger_func(" Please install it: pip install gdown")
|
||||
raise ImportError("gdown library not found.")
|
||||
|
||||
logger_func(f" [GDrive] Attempting to download: {gdrive_link}")
|
||||
def download_gdrive_file(url, download_path, logger_func=print):
|
||||
"""Downloads a file from a Google Drive link."""
|
||||
if not GDRIVE_AVAILABLE:
|
||||
logger_func("❌ Google Drive download failed: 'gdown' library is not installed.")
|
||||
return
|
||||
try:
|
||||
if not os.path.exists(download_path):
|
||||
os.makedirs(download_path, exist_ok=True)
|
||||
logger_func(f" [GDrive] Created download directory: {download_path}")
|
||||
|
||||
# gdown handles finding the file ID and downloading. 'fuzzy=True' helps with various URL formats.
|
||||
output_file_path = gdown.download(gdrive_link, output=download_path, quiet=False, fuzzy=True)
|
||||
|
||||
if output_file_path and os.path.exists(output_file_path):
|
||||
logger_func(f" [GDrive] ✅ Google Drive file downloaded successfully: {output_file_path}")
|
||||
logger_func(f" [G-Drive] Starting download for: {url}")
|
||||
# --- MODIFIED PART: Added a message and set quiet=True ---
|
||||
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
|
||||
|
||||
# By setting quiet=True, the progress bar will no longer be printed to the terminal.
|
||||
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True)
|
||||
# --- END OF MODIFIED PART ---
|
||||
|
||||
if output_path and os.path.exists(output_path):
|
||||
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
|
||||
else:
|
||||
raise Exception(f"gdown download failed or file not found. Returned: {output_file_path}")
|
||||
|
||||
logger_func(f" [G-Drive] ❌ Download failed. The file may have been moved, deleted, or is otherwise inaccessible.")
|
||||
except Exception as e:
|
||||
logger_func(f" [GDrive] ❌ An error occurred during Google Drive download: {e}")
|
||||
traceback.print_exc(limit=2)
|
||||
raise
|
||||
logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}")
|
||||
|
||||
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtGui import QIcon
|
||||
|
||||
_app_icon_cache = None
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
# --- 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
|
||||
@@ -38,23 +30,16 @@ class ConfirmAddAllDialog(QDialog):
|
||||
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()
|
||||
@@ -70,8 +55,6 @@ class ConfirmAddAllDialog(QDialog):
|
||||
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)
|
||||
@@ -82,8 +65,6 @@ class ConfirmAddAllDialog(QDialog):
|
||||
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)
|
||||
@@ -171,7 +152,6 @@ class ConfirmAddAllDialog(QDialog):
|
||||
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
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
# --- 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 ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
@@ -18,8 +13,6 @@ 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):
|
||||
@@ -34,23 +27,13 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
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)
|
||||
|
||||
# --- START OF FIX ---
|
||||
# Get the user-defined scale factor from the parent application.
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
# Define base dimensions and apply the correct scale factor.
|
||||
base_width, base_height = 600, 450
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
# --- END OF FIX ---
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
self._retranslate_ui()
|
||||
self._apply_theme()
|
||||
@@ -68,8 +51,6 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
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))
|
||||
@@ -100,7 +81,6 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
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()
|
||||
@@ -108,8 +88,6 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
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']})"
|
||||
@@ -139,19 +117,13 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark"
|
||||
|
||||
if is_dark_theme:
|
||||
# Get the scale factor from the parent app
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
# Call the imported function with the correct scale
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
else:
|
||||
# Explicitly set a blank stylesheet for light mode
|
||||
self.setStyleSheet("")
|
||||
|
||||
# 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 (they have no checkable flag)
|
||||
if not item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setForeground(header_color)
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
# --- 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
|
||||
from ...utils.resolution import get_dark_theme
|
||||
@@ -25,17 +21,14 @@ class DownloadHistoryDialog (QDialog ):
|
||||
self .first_processed_entries =first_processed_entries
|
||||
self .setModal (True )
|
||||
self._apply_theme()
|
||||
# Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available
|
||||
creator_name_cache = getattr(parent_app, 'creator_name_cache', None)
|
||||
if creator_name_cache:
|
||||
# Patch left pane (files)
|
||||
for entry in self.last_3_downloaded_entries:
|
||||
if not entry.get('creator_display_name'):
|
||||
service = entry.get('service', '').lower()
|
||||
user_id = str(entry.get('user_id', ''))
|
||||
key = (service, user_id)
|
||||
entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series'))
|
||||
# Patch right pane (posts)
|
||||
for entry in self.first_processed_entries:
|
||||
if not entry.get('creator_name'):
|
||||
service = entry.get('service', '').lower()
|
||||
|
||||
@@ -13,7 +13,7 @@ from PyQt5.QtCore import pyqtSignal, QCoreApplication, QSize, QThread, QTimer, Q
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
||||
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView,
|
||||
QSplitter, QProgressBar, QWidget
|
||||
QSplitter, QProgressBar, QWidget, QFileDialog
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
@@ -151,6 +151,8 @@ class EmptyPopupDialog (QDialog ):
|
||||
app_icon =get_app_icon_object ()
|
||||
if app_icon and not app_icon .isNull ():
|
||||
self .setWindowIcon (app_icon )
|
||||
self.update_profile_data = None
|
||||
self.update_creator_name = None
|
||||
self .selected_creators_for_queue =[]
|
||||
self .globally_selected_creators ={}
|
||||
self .fetched_posts_data ={}
|
||||
@@ -205,6 +207,9 @@ class EmptyPopupDialog (QDialog ):
|
||||
self .scope_button .clicked .connect (self ._toggle_scope_mode )
|
||||
left_bottom_buttons_layout .addWidget (self .scope_button )
|
||||
left_pane_layout .addLayout (left_bottom_buttons_layout )
|
||||
self.update_button = QPushButton()
|
||||
self.update_button.clicked.connect(self._handle_update_check)
|
||||
left_bottom_buttons_layout.addWidget(self.update_button)
|
||||
|
||||
|
||||
self .right_pane_widget =QWidget ()
|
||||
@@ -315,6 +320,31 @@ class EmptyPopupDialog (QDialog ):
|
||||
except AttributeError :
|
||||
pass
|
||||
|
||||
def _handle_update_check(self):
|
||||
"""Opens a dialog to select a creator profile and loads it for an update session."""
|
||||
appdata_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
||||
|
||||
if not os.path.isdir(profiles_dir):
|
||||
QMessageBox.warning(self, "Directory Not Found", f"The creator profiles directory does not exist yet.\n\nPath: {profiles_dir}")
|
||||
return
|
||||
|
||||
filepath, _ = QFileDialog.getOpenFileName(self, "Select Creator Profile for Update", profiles_dir, "JSON Files (*.json)")
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if 'creator_url' not in data or 'processed_post_ids' not in data:
|
||||
raise ValueError("Invalid profile format.")
|
||||
|
||||
self.update_profile_data = data
|
||||
self.update_creator_name = os.path.basename(filepath).replace('.json', '')
|
||||
self.accept() # Close the dialog and signal success
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error Loading Profile", f"Could not load or parse the selected profile file:\n\n{e}")
|
||||
|
||||
def _handle_fetch_posts_click (self ):
|
||||
selected_creators =list (self .globally_selected_creators .values ())
|
||||
print(f"[DEBUG] Selected creators for fetch: {selected_creators}")
|
||||
@@ -370,6 +400,7 @@ class EmptyPopupDialog (QDialog ):
|
||||
self .add_selected_button .setText (self ._tr ("creator_popup_add_selected_button","Add Selected"))
|
||||
self .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts"))
|
||||
self ._update_scope_button_text_and_tooltip ()
|
||||
self.update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
|
||||
|
||||
self .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title..."))
|
||||
|
||||
@@ -1003,4 +1034,4 @@ class EmptyPopupDialog (QDialog ):
|
||||
else :
|
||||
if unique_key in self .globally_selected_creators :
|
||||
del self .globally_selected_creators [unique_key ]
|
||||
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
|
||||
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
|
||||
@@ -42,11 +42,15 @@ class ErrorFilesDialog(QDialog):
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# --- START OF FIX ---
|
||||
# Get the user-defined scale factor from the parent application.
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
# Define base dimensions and apply the correct scale factor.
|
||||
base_width, base_height = 550, 400
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
# --- END OF FIX ---
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# --- Standard Library Imports ---
|
||||
import html
|
||||
import os
|
||||
import sys
|
||||
@@ -8,8 +7,6 @@ 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 (
|
||||
@@ -17,12 +14,9 @@ from PyQt5.QtWidgets import (
|
||||
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
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
from PyQt5.QtCore import Qt, QStandardPaths
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
@@ -15,7 +15,7 @@ from ...utils.resolution import get_dark_theme
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...config.constants import (
|
||||
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
|
||||
RESOLUTION_KEY, UI_SCALE_KEY
|
||||
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class FutureSettingsDialog(QDialog):
|
||||
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
||||
scale_factor = screen_height / 800.0
|
||||
base_min_w, base_min_h = 420, 320 # Adjusted height for new layout
|
||||
base_min_w, base_min_h = 420, 360 # Adjusted height for new layout
|
||||
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)
|
||||
@@ -93,6 +93,11 @@ class FutureSettingsDialog(QDialog):
|
||||
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
||||
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
||||
|
||||
# Save Creator.json Checkbox
|
||||
self.save_creator_json_checkbox = QCheckBox()
|
||||
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
|
||||
download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2)
|
||||
|
||||
main_layout.addWidget(self.download_window_group_box)
|
||||
|
||||
main_layout.addStretch(1)
|
||||
@@ -102,6 +107,20 @@ class FutureSettingsDialog(QDialog):
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
|
||||
|
||||
def _load_checkbox_states(self):
|
||||
"""Loads the initial state for all checkboxes from settings."""
|
||||
self.save_creator_json_checkbox.blockSignals(True)
|
||||
# Default to True so the feature is on by default for users
|
||||
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
||||
self.save_creator_json_checkbox.setChecked(should_save)
|
||||
self.save_creator_json_checkbox.blockSignals(False)
|
||||
|
||||
def _creator_json_setting_changed(self, state):
|
||||
"""Saves the state of the 'Save Creator.json' checkbox."""
|
||||
is_checked = state == Qt.Checked
|
||||
self.parent_app.settings.setValue(SAVE_CREATOR_JSON_KEY, is_checked)
|
||||
self.parent_app.settings.sync()
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
@@ -122,6 +141,7 @@ class FutureSettingsDialog(QDialog):
|
||||
# Download & Window Group Labels
|
||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
|
||||
|
||||
# Buttons and Controls
|
||||
self._update_theme_toggle_button_text()
|
||||
@@ -132,6 +152,7 @@ class FutureSettingsDialog(QDialog):
|
||||
# Populate dropdowns
|
||||
self._populate_display_combo_boxes()
|
||||
self._populate_language_combo_box()
|
||||
self._load_checkbox_states()
|
||||
|
||||
def _apply_theme(self):
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import QUrl, QSize, Qt
|
||||
from PyQt5.QtGui import QIcon, QDesktopServices
|
||||
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
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
# KeepDuplicatesDialog.py
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
|
||||
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
|
||||
)
|
||||
from PyQt5.QtGui import QIntValidator
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
|
||||
|
||||
@@ -25,8 +20,6 @@ class KeepDuplicatesDialog(QDialog):
|
||||
|
||||
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
|
||||
self.parent_app._apply_theme_to_widget(self)
|
||||
|
||||
# Set the initial state based on current settings
|
||||
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
|
||||
self.radio_keep_everything.setChecked(True)
|
||||
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
|
||||
@@ -44,13 +37,9 @@ class KeepDuplicatesDialog(QDialog):
|
||||
options_group = QGroupBox()
|
||||
options_layout = QVBoxLayout(options_group)
|
||||
self.button_group = QButtonGroup(self)
|
||||
|
||||
# --- Skip by Hash Option ---
|
||||
self.radio_skip_by_hash = QRadioButton()
|
||||
self.button_group.addButton(self.radio_skip_by_hash)
|
||||
options_layout.addWidget(self.radio_skip_by_hash)
|
||||
|
||||
# --- Keep Everything Option with Limit Input ---
|
||||
keep_everything_layout = QHBoxLayout()
|
||||
self.radio_keep_everything = QRadioButton()
|
||||
self.button_group.addButton(self.radio_keep_everything)
|
||||
@@ -66,8 +55,6 @@ class KeepDuplicatesDialog(QDialog):
|
||||
options_layout.addLayout(keep_everything_layout)
|
||||
|
||||
main_layout.addWidget(options_group)
|
||||
|
||||
# --- OK and Cancel buttons ---
|
||||
button_layout = QHBoxLayout()
|
||||
self.ok_button = QPushButton()
|
||||
self.cancel_button = QPushButton()
|
||||
@@ -75,8 +62,6 @@ class KeepDuplicatesDialog(QDialog):
|
||||
button_layout.addWidget(self.ok_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
|
||||
# --- Connections ---
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
|
||||
|
||||
@@ -37,12 +37,16 @@ class KnownNamesFilterDialog(QDialog):
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically
|
||||
screen_geometry = QApplication.primaryScreen().availableGeometry()
|
||||
# --- START OF FIX ---
|
||||
# Get the user-defined scale factor from the parent application
|
||||
# instead of calculating an independent one.
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
# Define base size and apply the correct scale factor
|
||||
base_width, base_height = 460, 450
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
# --- END OF FIX ---
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
# SinglePDF.py
|
||||
|
||||
import os
|
||||
import re
|
||||
try:
|
||||
from fpdf import FPDF
|
||||
FPDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
FPDF_AVAILABLE = False
|
||||
|
||||
def strip_html_tags(text):
|
||||
if not text:
|
||||
return ""
|
||||
clean = re.compile('<.*?>')
|
||||
return re.sub(clean, '', text)
|
||||
|
||||
class PDF(FPDF):
|
||||
"""Custom PDF class to handle headers and footers."""
|
||||
def header(self):
|
||||
# No header
|
||||
pass
|
||||
pass
|
||||
|
||||
def footer(self):
|
||||
# Position at 1.5 cm from bottom
|
||||
self.set_y(-15)
|
||||
self.set_font('DejaVu', '', 8)
|
||||
# Page number
|
||||
if self.font_family:
|
||||
self.set_font(self.font_family, '', 8)
|
||||
else:
|
||||
self.set_font('Arial', '', 8)
|
||||
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
||||
|
||||
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
|
||||
"""
|
||||
Creates a single PDF from a list of post titles and content.
|
||||
|
||||
Args:
|
||||
posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys.
|
||||
output_filename (str): The full path for the output PDF file.
|
||||
font_path (str): Path to the DejaVuSans.ttf font file.
|
||||
logger (function, optional): A function to log progress and errors. Defaults to print.
|
||||
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
|
||||
"""
|
||||
if not FPDF_AVAILABLE:
|
||||
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
|
||||
@@ -39,34 +38,66 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return False
|
||||
|
||||
pdf = PDF()
|
||||
default_font_family = 'DejaVu'
|
||||
|
||||
bold_font_path = ""
|
||||
if font_path:
|
||||
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path):
|
||||
raise RuntimeError("Font file not found.")
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}")
|
||||
logger(" PDF may not support all characters. Falling back to default Arial font.")
|
||||
pdf.set_font('Arial', '', 12)
|
||||
pdf.set_font('Arial', 'B', 16)
|
||||
|
||||
logger(f" Starting PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for post in posts_data:
|
||||
pdf.add_page()
|
||||
# Post Title
|
||||
pdf.set_font('DejaVu', 'B', 16)
|
||||
|
||||
# vvv THIS LINE IS CORRECTED vvv
|
||||
# We explicitly set align='L' and remove the incorrect positional arguments.
|
||||
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
||||
|
||||
pdf.ln(5) # Add a little space after the title
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
default_font_family = 'Arial'
|
||||
|
||||
pdf.add_page()
|
||||
|
||||
# Post Content
|
||||
pdf.set_font('DejaVu', '', 12)
|
||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for i, post in enumerate(posts_data):
|
||||
if i > 0:
|
||||
if 'content' in post:
|
||||
pdf.add_page()
|
||||
elif 'comments' in post:
|
||||
pdf.ln(10)
|
||||
pdf.cell(0, 0, '', border='T')
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.ln(5)
|
||||
|
||||
if 'comments' in post and post['comments']:
|
||||
comments_list = post['comments']
|
||||
for comment_index, comment in enumerate(comments_list):
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
if user is not None:
|
||||
pdf.set_font(default_font_family, 'B', 10)
|
||||
pdf.write(8, str(user))
|
||||
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(0, 7, body)
|
||||
|
||||
if comment_index < len(comments_list) - 1:
|
||||
pdf.ln(3)
|
||||
pdf.cell(w=0, h=0, border='T')
|
||||
pdf.ln(3)
|
||||
elif 'content' in post:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||
|
||||
try:
|
||||
pdf.output(output_filename)
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
# --- 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 ...utils.resolution import get_dark_theme
|
||||
@@ -58,8 +53,6 @@ class TourDialog(QDialog):
|
||||
"""
|
||||
tour_finished_normally = pyqtSignal()
|
||||
tour_skipped = pyqtSignal()
|
||||
|
||||
# Constants for QSettings
|
||||
CONFIG_APP_NAME_TOUR = "ApplicationTour"
|
||||
TOUR_SHOWN_KEY = "neverShowTourAgainV19"
|
||||
|
||||
@@ -98,8 +91,6 @@ class TourDialog(QDialog):
|
||||
|
||||
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"),
|
||||
@@ -120,8 +111,6 @@ class TourDialog(QDialog):
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -239,16 +239,23 @@ def setup_ui(main_app):
|
||||
checkboxes_group_layout.addWidget(advanced_settings_label)
|
||||
advanced_row1_layout = QHBoxLayout()
|
||||
advanced_row1_layout.setSpacing(10)
|
||||
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
|
||||
main_app.use_subfolders_checkbox.setChecked(True)
|
||||
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
|
||||
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
|
||||
|
||||
# --- REORDERED CHECKBOXES ---
|
||||
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
|
||||
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
|
||||
main_app.use_subfolder_per_post_checkbox.setChecked(True)
|
||||
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
|
||||
|
||||
main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
|
||||
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
|
||||
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
|
||||
|
||||
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
|
||||
main_app.use_subfolders_checkbox.setChecked(False)
|
||||
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
|
||||
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
|
||||
# --- END REORDER ---
|
||||
|
||||
main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
|
||||
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
|
||||
main_app.cookie_text_input = QLineEdit()
|
||||
@@ -380,7 +387,7 @@ def setup_ui(main_app):
|
||||
main_app.link_search_input.setPlaceholderText("Search Links...")
|
||||
main_app.link_search_input.setVisible(False)
|
||||
log_title_layout.addWidget(main_app.link_search_input)
|
||||
main_app.link_search_button = QPushButton("🔍")
|
||||
main_app.link_search_button = QPushButton("<EFBFBD>")
|
||||
main_app.link_search_button.setVisible(False)
|
||||
main_app.link_search_button.setFixedWidth(int(30 * scale))
|
||||
log_title_layout.addWidget(main_app.link_search_button)
|
||||
|
||||
Reference in New Issue
Block a user