mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
8 Commits
v6.0.0
...
cfd869e05a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd869e05a | ||
|
|
b191776f65 | ||
|
|
f41f354737 | ||
|
|
6b57ee099d | ||
|
|
21ecb60cb5 | ||
|
|
ee00019f2e | ||
|
|
d49c739fe4 | ||
|
|
dbdf82a079 |
97
data/dejavu-sans/DejaVu Fonts License.txt
Normal file
97
data/dejavu-sans/DejaVu Fonts License.txt
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||||
|
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
|
||||||
|
|
||||||
|
Bitstream Vera Fonts Copyright
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
||||||
|
a trademark of Bitstream, Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of the fonts accompanying this license ("Fonts") and associated
|
||||||
|
documentation files (the "Font Software"), to reproduce and distribute the
|
||||||
|
Font Software, including without limitation the rights to use, copy, merge,
|
||||||
|
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||||
|
persons to whom the Font Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright and trademark notices and this permission notice shall
|
||||||
|
be included in all copies of one or more of the Font Software typefaces.
|
||||||
|
|
||||||
|
The Font Software may be modified, altered, or added to, and in particular
|
||||||
|
the designs of glyphs or characters in the Fonts may be modified and
|
||||||
|
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||||
|
are renamed to names not containing either the words "Bitstream" or the word
|
||||||
|
"Vera".
|
||||||
|
|
||||||
|
This License becomes null and void to the extent applicable to Fonts or Font
|
||||||
|
Software that has been modified and is distributed under the "Bitstream
|
||||||
|
Vera" names.
|
||||||
|
|
||||||
|
The Font Software may be sold as part of a larger software package but no
|
||||||
|
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||||
|
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||||
|
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||||
|
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||||
|
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||||
|
FONT SOFTWARE.
|
||||||
|
|
||||||
|
Except as contained in this notice, the names of Gnome, the Gnome
|
||||||
|
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||||
|
otherwise to promote the sale, use or other dealings in this Font Software
|
||||||
|
without prior written authorization from the Gnome Foundation or Bitstream
|
||||||
|
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||||
|
org.
|
||||||
|
|
||||||
|
Arev Fonts Copyright
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the fonts accompanying this license ("Fonts") and
|
||||||
|
associated documentation files (the "Font Software"), to reproduce
|
||||||
|
and distribute the modifications to the Bitstream Vera Font Software,
|
||||||
|
including without limitation the rights to use, copy, merge, publish,
|
||||||
|
distribute, and/or sell copies of the Font Software, and to permit
|
||||||
|
persons to whom the Font Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright and trademark notices and this permission notice
|
||||||
|
shall be included in all copies of one or more of the Font Software
|
||||||
|
typefaces.
|
||||||
|
|
||||||
|
The Font Software may be modified, altered, or added to, and in
|
||||||
|
particular the designs of glyphs or characters in the Fonts may be
|
||||||
|
modified and additional glyphs or characters may be added to the
|
||||||
|
Fonts, only if the fonts are renamed to names not containing either
|
||||||
|
the words "Tavmjong Bah" or the word "Arev".
|
||||||
|
|
||||||
|
This License becomes null and void to the extent applicable to Fonts
|
||||||
|
or Font Software that has been modified and is distributed under the
|
||||||
|
"Tavmjong Bah Arev" names.
|
||||||
|
|
||||||
|
The Font Software may be sold as part of a larger software package but
|
||||||
|
no copy of one or more of the Font Software typefaces may be sold by
|
||||||
|
itself.
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
||||||
|
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
|
Except as contained in this notice, the name of Tavmjong Bah shall not
|
||||||
|
be used in advertising or otherwise to promote the sale, use or other
|
||||||
|
dealings in this Font Software without prior written authorization
|
||||||
|
from Tavmjong Bah. For further information, contact: tavmjong @ free
|
||||||
|
. fr.
|
||||||
BIN
data/dejavu-sans/DejaVuSans-Bold.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-BoldOblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-ExtraLight.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-Oblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-Oblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-Bold.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-Bold.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed.ttf
Normal file
Binary file not shown.
5529
main_window_old.py
Normal file
5529
main_window_old.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
|||||||
# --- Standard Library Imports ---
|
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
import json # Ensure json is imported
|
||||||
# --- Third-Party Library Imports ---
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
# --- Local Application Imports ---
|
# (Keep the rest of your imports)
|
||||||
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||||
from ..config.constants import (
|
from ..config.constants import (
|
||||||
STYLE_DATE_POST_TITLE
|
STYLE_DATE_POST_TITLE
|
||||||
@@ -15,36 +13,24 @@ from ..config.constants import (
|
|||||||
|
|
||||||
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
||||||
"""
|
"""
|
||||||
Fetches a single page of posts from the API with retry logic.
|
Fetches a single page of posts from the API with robust retry logic.
|
||||||
|
NEW: Requests only essential fields to keep the response size small and reliable.
|
||||||
Args:
|
|
||||||
api_url_base (str): The base URL for the user's posts.
|
|
||||||
headers (dict): The request headers.
|
|
||||||
offset (int): The offset for pagination.
|
|
||||||
logger (callable): Function to log messages.
|
|
||||||
cancellation_event (threading.Event): Event to signal cancellation.
|
|
||||||
pause_event (threading.Event): Event to signal pause.
|
|
||||||
cookies_dict (dict): A dictionary of cookies to include in the request.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: A list of post data dictionaries from the API.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If the fetch fails after all retries or encounters a non-retryable error.
|
|
||||||
"""
|
"""
|
||||||
if cancellation_event and cancellation_event.is_set():
|
if cancellation_event and cancellation_event.is_set():
|
||||||
logger(" Fetch cancelled before request.")
|
|
||||||
raise RuntimeError("Fetch operation cancelled by user.")
|
raise RuntimeError("Fetch operation cancelled by user.")
|
||||||
if pause_event and pause_event.is_set():
|
if pause_event and pause_event.is_set():
|
||||||
logger(" Post fetching paused...")
|
logger(" Post fetching paused...")
|
||||||
while pause_event.is_set():
|
while pause_event.is_set():
|
||||||
if cancellation_event and cancellation_event.is_set():
|
if cancellation_event and cancellation_event.is_set():
|
||||||
logger(" Post fetching cancelled while paused.")
|
raise RuntimeError("Fetch operation cancelled by user while paused.")
|
||||||
raise RuntimeError("Fetch operation cancelled by user.")
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
logger(" Post fetching resumed.")
|
logger(" Post fetching resumed.")
|
||||||
|
|
||||||
paginated_url = f'{api_url_base}?o={offset}'
|
# --- 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}'
|
||||||
|
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
retry_delay = 5
|
retry_delay = 5
|
||||||
|
|
||||||
@@ -52,22 +38,18 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
|||||||
if cancellation_event and cancellation_event.is_set():
|
if cancellation_event and cancellation_event.is_set():
|
||||||
raise RuntimeError("Fetch operation cancelled by user during retry loop.")
|
raise RuntimeError("Fetch operation cancelled by user during retry loop.")
|
||||||
|
|
||||||
log_message = f" Fetching: {paginated_url} (Page approx. {offset // 50 + 1})"
|
log_message = f" Fetching post list: {api_url_base}?o={offset} (Page approx. {offset // 50 + 1})"
|
||||||
if attempt > 0:
|
if attempt > 0:
|
||||||
log_message += f" (Attempt {attempt + 1}/{max_retries})"
|
log_message += f" (Attempt {attempt + 1}/{max_retries})"
|
||||||
logger(log_message)
|
logger(log_message)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 90), cookies=cookies_dict)
|
# 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()
|
response.raise_for_status()
|
||||||
|
|
||||||
if 'application/json' not in response.headers.get('Content-Type', '').lower():
|
|
||||||
logger(f"⚠️ Unexpected content type from API: {response.headers.get('Content-Type')}. Body: {response.text[:200]}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}")
|
logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}")
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = retry_delay * (2 ** attempt)
|
delay = retry_delay * (2 ** attempt)
|
||||||
@@ -76,18 +58,46 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
|||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
logger(f" ❌ Failed to fetch page after {max_retries} attempts.")
|
logger(f" ❌ Failed to fetch page after {max_retries} attempts.")
|
||||||
raise RuntimeError(f"Timeout or connection error fetching offset {offset}")
|
raise RuntimeError(f"Network error fetching offset {offset}")
|
||||||
except requests.exceptions.RequestException as e:
|
except json.JSONDecodeError as e:
|
||||||
err_msg = f"Error fetching offset {offset}: {e}"
|
logger(f" ❌ Failed to decode JSON on page fetch (Attempt {attempt + 1}): {e}")
|
||||||
if e.response is not None:
|
if attempt < max_retries - 1:
|
||||||
err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})"
|
delay = retry_delay * (2 ** attempt)
|
||||||
raise RuntimeError(err_msg)
|
logger(f" Retrying in {delay} seconds...")
|
||||||
except ValueError as e: # JSON decode error
|
time.sleep(delay)
|
||||||
raise RuntimeError(f"Error decoding JSON from offset {offset}: {e}. Response: {response.text[:200]}")
|
continue
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"JSONDecodeError fetching offset {offset}")
|
||||||
|
|
||||||
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
|
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None):
|
||||||
|
"""
|
||||||
|
--- NEW FUNCTION ---
|
||||||
|
Fetches the full data, including the 'content' field, for a single post.
|
||||||
|
"""
|
||||||
|
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""
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
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
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
||||||
"""Fetches all comments for a specific post."""
|
"""Fetches all comments for a specific post."""
|
||||||
if cancellation_event and cancellation_event.is_set():
|
if cancellation_event and cancellation_event.is_set():
|
||||||
|
|||||||
1954
src/core/workers.py
1954
src/core/workers.py
File diff suppressed because it is too large
Load Diff
@@ -144,7 +144,7 @@ class EmptyPopupDialog (QDialog ):
|
|||||||
self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor ))
|
self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor ))
|
||||||
|
|
||||||
self .parent_app =parent_app_ref
|
self .parent_app =parent_app_ref
|
||||||
self .current_scope_mode =self .SCOPE_CHARACTERS
|
self.current_scope_mode = self.SCOPE_CREATORS
|
||||||
self .app_base_dir =app_base_dir
|
self .app_base_dir =app_base_dir
|
||||||
|
|
||||||
app_icon =get_app_icon_object ()
|
app_icon =get_app_icon_object ()
|
||||||
|
|||||||
@@ -126,6 +126,21 @@ class FavoriteArtistsDialog (QDialog ):
|
|||||||
self .artist_list_widget .setVisible (show )
|
self .artist_list_widget .setVisible (show )
|
||||||
|
|
||||||
def _fetch_favorite_artists (self ):
|
def _fetch_favorite_artists (self ):
|
||||||
|
|
||||||
|
if self.cookies_config['use_cookie']:
|
||||||
|
# Check if we can load cookies for at least one of the services.
|
||||||
|
kemono_cookies = 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="kemono.su")
|
||||||
|
coomer_cookies = 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="coomer.su")
|
||||||
|
|
||||||
|
if not kemono_cookies and not coomer_cookies:
|
||||||
|
# If cookies are enabled but none could be loaded, show help and stop.
|
||||||
|
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 valid cookies were loaded. Showing help dialog.")
|
||||||
|
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
|
||||||
|
cookie_help_dialog.exec_()
|
||||||
|
self.download_button.setEnabled(False)
|
||||||
|
return # Stop further execution
|
||||||
|
|
||||||
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
|
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
|
||||||
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
|
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
|
||||||
|
|
||||||
|
|||||||
83
src/ui/dialogs/MoreOptionsDialog.py
Normal file
83
src/ui/dialogs/MoreOptionsDialog.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox
|
||||||
|
)
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
class MoreOptionsDialog(QDialog):
|
||||||
|
"""
|
||||||
|
A dialog for selecting a scope, export format, and single PDF option.
|
||||||
|
"""
|
||||||
|
SCOPE_CONTENT = "content"
|
||||||
|
SCOPE_COMMENTS = "comments"
|
||||||
|
|
||||||
|
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("More Options")
|
||||||
|
self.setMinimumWidth(350)
|
||||||
|
|
||||||
|
# ... (Layout and other widgets remain the same) ...
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.description_label = QLabel("Please choose the scope for the action:")
|
||||||
|
layout.addWidget(self.description_label)
|
||||||
|
self.radio_button_group = QButtonGroup(self)
|
||||||
|
self.radio_content = QRadioButton("Description/Content")
|
||||||
|
self.radio_comments = QRadioButton("Comments")
|
||||||
|
self.radio_button_group.addButton(self.radio_content)
|
||||||
|
self.radio_button_group.addButton(self.radio_comments)
|
||||||
|
layout.addWidget(self.radio_content)
|
||||||
|
layout.addWidget(self.radio_comments)
|
||||||
|
|
||||||
|
if current_scope == self.SCOPE_COMMENTS:
|
||||||
|
self.radio_comments.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.radio_content.setChecked(True)
|
||||||
|
|
||||||
|
export_layout = QHBoxLayout()
|
||||||
|
export_label = QLabel("Export as:")
|
||||||
|
self.format_combo = QComboBox()
|
||||||
|
self.format_combo.addItems(["PDF", "DOCX", "TXT"])
|
||||||
|
|
||||||
|
if current_format and current_format.upper() in ["PDF", "DOCX", "TXT"]:
|
||||||
|
self.format_combo.setCurrentText(current_format.upper())
|
||||||
|
else:
|
||||||
|
self.format_combo.setCurrentText("PDF")
|
||||||
|
|
||||||
|
export_layout.addWidget(export_label)
|
||||||
|
export_layout.addWidget(self.format_combo)
|
||||||
|
export_layout.addStretch()
|
||||||
|
layout.addLayout(export_layout)
|
||||||
|
|
||||||
|
# --- UPDATED: Single PDF Checkbox ---
|
||||||
|
self.single_pdf_checkbox = QCheckBox("Single PDF")
|
||||||
|
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
|
||||||
|
self.single_pdf_checkbox.setChecked(single_pdf_checked)
|
||||||
|
layout.addWidget(self.single_pdf_checkbox)
|
||||||
|
|
||||||
|
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
|
||||||
|
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
|
||||||
|
|
||||||
|
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
self.button_box.accepted.connect(self.accept)
|
||||||
|
self.button_box.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(self.button_box)
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def update_single_pdf_checkbox_state(self, text):
|
||||||
|
"""Enable the Single PDF checkbox only if the format is PDF."""
|
||||||
|
is_pdf = (text.upper() == "PDF")
|
||||||
|
self.single_pdf_checkbox.setEnabled(is_pdf)
|
||||||
|
if not is_pdf:
|
||||||
|
self.single_pdf_checkbox.setChecked(False)
|
||||||
|
|
||||||
|
def get_selected_scope(self):
|
||||||
|
if self.radio_comments.isChecked():
|
||||||
|
return self.SCOPE_COMMENTS
|
||||||
|
return self.SCOPE_CONTENT
|
||||||
|
|
||||||
|
def get_selected_format(self):
|
||||||
|
return self.format_combo.currentText().lower()
|
||||||
|
|
||||||
|
def get_single_pdf_state(self):
|
||||||
|
"""Returns the state of the Single PDF checkbox."""
|
||||||
|
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
|
||||||
77
src/ui/dialogs/SinglePDF.py
Normal file
77
src/ui/dialogs/SinglePDF.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# SinglePDF.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
from fpdf import FPDF
|
||||||
|
FPDF_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
FPDF_AVAILABLE = False
|
||||||
|
|
||||||
|
class PDF(FPDF):
|
||||||
|
"""Custom PDF class to handle headers and footers."""
|
||||||
|
def header(self):
|
||||||
|
# No header
|
||||||
|
pass
|
||||||
|
|
||||||
|
def footer(self):
|
||||||
|
# Position at 1.5 cm from bottom
|
||||||
|
self.set_y(-15)
|
||||||
|
self.set_font('DejaVu', '', 8)
|
||||||
|
# Page number
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
if not FPDF_AVAILABLE:
|
||||||
|
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not posts_data:
|
||||||
|
logger(" No text content was collected to create a PDF.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
pdf = PDF()
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
pdf.ln(5) # Add a little space after the title
|
||||||
|
|
||||||
|
# Post Content
|
||||||
|
pdf.set_font('DejaVu', '', 12)
|
||||||
|
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf.output(output_filename)
|
||||||
|
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||||
|
return False
|
||||||
93
src/ui/flow_layout.py
Normal file
93
src/ui/flow_layout.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# src/ui/flow_layout.py
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import QLayout, QSizePolicy, QStyle
|
||||||
|
from PyQt5.QtCore import QPoint, QRect, QSize, Qt
|
||||||
|
|
||||||
|
class FlowLayout(QLayout):
|
||||||
|
"""A custom layout that arranges widgets in a flow, wrapping as necessary."""
|
||||||
|
def __init__(self, parent=None, margin=0, spacing=-1):
|
||||||
|
super(FlowLayout, self).__init__(parent)
|
||||||
|
|
||||||
|
if parent is not None:
|
||||||
|
self.setContentsMargins(margin, margin, margin, margin)
|
||||||
|
|
||||||
|
self.setSpacing(spacing)
|
||||||
|
self.itemList = []
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
item = self.takeAt(0)
|
||||||
|
while item:
|
||||||
|
item = self.takeAt(0)
|
||||||
|
|
||||||
|
def addItem(self, item):
|
||||||
|
self.itemList.append(item)
|
||||||
|
|
||||||
|
def count(self):
|
||||||
|
return len(self.itemList)
|
||||||
|
|
||||||
|
def itemAt(self, index):
|
||||||
|
if 0 <= index < len(self.itemList):
|
||||||
|
return self.itemList[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def takeAt(self, index):
|
||||||
|
if 0 <= index < len(self.itemList):
|
||||||
|
return self.itemList.pop(index)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def expandingDirections(self):
|
||||||
|
return Qt.Orientations(Qt.Orientation(0))
|
||||||
|
|
||||||
|
def hasHeightForWidth(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def heightForWidth(self, width):
|
||||||
|
return self._do_layout(QRect(0, 0, width, 0), True)
|
||||||
|
|
||||||
|
def setGeometry(self, rect):
|
||||||
|
super(FlowLayout, self).setGeometry(rect)
|
||||||
|
self._do_layout(rect, False)
|
||||||
|
|
||||||
|
def sizeHint(self):
|
||||||
|
return self.minimumSize()
|
||||||
|
|
||||||
|
def minimumSize(self):
|
||||||
|
size = QSize()
|
||||||
|
for item in self.itemList:
|
||||||
|
size = size.expandedTo(item.minimumSize())
|
||||||
|
|
||||||
|
margin, _, _, _ = self.getContentsMargins()
|
||||||
|
size += QSize(2 * margin, 2 * margin)
|
||||||
|
return size
|
||||||
|
|
||||||
|
def _do_layout(self, rect, test_only):
|
||||||
|
x = rect.x()
|
||||||
|
y = rect.y()
|
||||||
|
line_height = 0
|
||||||
|
|
||||||
|
space_x = self.spacing()
|
||||||
|
space_y = self.spacing()
|
||||||
|
if self.layout() is not None:
|
||||||
|
space_x = self.spacing()
|
||||||
|
space_y = self.spacing()
|
||||||
|
else:
|
||||||
|
space_x = self.spacing()
|
||||||
|
space_y = self.spacing()
|
||||||
|
|
||||||
|
|
||||||
|
for item in self.itemList:
|
||||||
|
wid = item.widget()
|
||||||
|
next_x = x + item.sizeHint().width() + space_x
|
||||||
|
if next_x - space_x > rect.right() and line_height > 0:
|
||||||
|
x = rect.x()
|
||||||
|
y = y + line_height + space_y
|
||||||
|
next_x = x + item.sizeHint().width() + space_x
|
||||||
|
line_height = 0
|
||||||
|
|
||||||
|
if not test_only:
|
||||||
|
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
||||||
|
|
||||||
|
x = next_x
|
||||||
|
line_height = max(line_height, item.sizeHint().height())
|
||||||
|
|
||||||
|
return y + line_height - rect.y()
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user