Files
abogen/abogen/book_handler.py
T

2417 lines
99 KiB
Python

import re
import ebooklib
import base64
import fitz # PyMuPDF for PDF support
from ebooklib import epub
from bs4 import BeautifulSoup, NavigableString
from PyQt5.QtWidgets import (
QDialog,
QTreeWidget,
QTreeWidgetItem,
QDialogButtonBox,
QVBoxLayout,
QHBoxLayout,
QTextEdit,
QTreeWidgetItemIterator,
QSplitter,
QWidget,
QPushButton,
QCheckBox,
QMenu,
QLabel,
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSize
from PyQt5.QtGui import QMovie
from abogen.utils import clean_text, calculate_text_length, detect_encoding, get_resource_path
import os
import logging # Add logging
import urllib.parse
import markdown
import textwrap
# Setup logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
class HandlerDialog(QDialog):
# Class variables to remember checkbox states between dialog instances
_save_chapters_separately = False
_merge_chapters_at_end = True
_save_as_project = False # New class variable for save_as_project option
# Cache for processed book content to avoid reprocessing
# Key: (book_path, modification_time, file_type)
# Value: dict with content_texts, content_lengths, doc_content (for epub), markdown_toc (for markdown)
_content_cache = {}
class _LoaderThread(QThread):
"""Minimal QThread that runs a callable and emits an error string on exception."""
error = pyqtSignal(str)
def __init__(self, target_callable):
super().__init__()
self._target = target_callable
def run(self):
try:
self._target()
except Exception as e:
self.error.emit(str(e))
@classmethod
def clear_content_cache(cls, book_path=None):
"""Clear the content cache. If book_path is provided, only clear that book's cache."""
if book_path is None:
cls._content_cache.clear()
logging.info("Cleared all content cache")
else:
keys_to_remove = [key for key in cls._content_cache.keys() if key[0] == book_path]
for key in keys_to_remove:
del cls._content_cache[key]
if keys_to_remove:
logging.info(f"Cleared content cache for {os.path.basename(book_path)}")
def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None):
super().__init__(parent)
# Determine file type if not explicitly provided
if file_type:
self.file_type = file_type
elif book_path.lower().endswith(".pdf"):
self.file_type = "pdf"
elif book_path.lower().endswith((".md", ".markdown")):
self.file_type = "markdown"
else:
self.file_type = "epub"
self.book_path = book_path
# Extract book name from file path
book_name = os.path.splitext(os.path.basename(book_path))[0]
# Set window title based on file type and book name
item_type = "Chapters" if self.file_type in ["epub", "markdown"] else "Pages"
self.setWindowTitle(f'Select {item_type} - {book_name}')
self.resize(1200, 900)
self._block_signals = False # Flag to prevent recursive signals
# Configure window: remove help button and allow resizing
self.setWindowFlags(
Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint
)
self.setWindowModality(Qt.NonModal)
# Initialize save chapters flags from class variables
self.save_chapters_separately = HandlerDialog._save_chapters_separately
self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end
self.save_as_project = HandlerDialog._save_as_project
# Load the book based on file type
try:
if self.file_type == "epub":
self.book = epub.read_epub(book_path)
elif self.file_type == "markdown":
self.book = None # Markdown doesn't use ebooklib
else:
self.book = None
except KeyError as e:
logging.error(
f"EPUB file is missing a referenced file: {e}. Skipping missing file."
)
# Try to patch ebooklib to skip missing files (monkey-patch read_file)
import types
orig_read_file = None
try:
from ebooklib import epub as _epub_module
reader_class = _epub_module.EpubReader
orig_read_file = reader_class.read_file
def safe_read_file(self, name):
try:
return orig_read_file(self, name)
except KeyError:
logging.warning(
f"Missing file in EPUB: {name}. Returning empty bytes."
)
return b""
reader_class.read_file = safe_read_file
self.book = epub.read_epub(book_path)
reader_class.read_file = orig_read_file # Restore
except Exception as patch_e:
logging.error(f"Failed to patch ebooklib for missing files: {patch_e}")
raise e
self.pdf_doc = fitz.open(book_path) if self.file_type == "pdf" else None
self.markdown_text = None
if self.file_type == "markdown":
try:
encoding = detect_encoding(book_path)
with open(book_path, "r", encoding=encoding, errors="replace") as f:
self.markdown_text = f.read()
except Exception as e:
logging.error(f"Error reading markdown file: {e}")
self.markdown_text = ""
self.markdown_toc = [] # For storing parsed markdown TOC
# Extract book metadata
self.book_metadata = self._extract_book_metadata()
# Initialize UI elements that are used in other methods
self.save_chapters_checkbox = None
self.merge_chapters_checkbox = None
# Build treeview
self.treeWidget = QTreeWidget(self)
self.treeWidget.setHeaderHidden(True)
self.treeWidget.setSelectionMode(QTreeWidget.ExtendedSelection)
self.treeWidget.setContextMenuPolicy(Qt.CustomContextMenu)
self.treeWidget.customContextMenuRequested.connect(self.on_tree_context_menu)
# Initialize checked_chapters set
self.checked_chapters = set(checked_chapters) if checked_chapters else set()
# For storing content and lengths (will be filled by background loader)
self.content_texts = {}
self.content_lengths = {}
# Add a placeholder "Information" item so the tree isn't empty immediately
info_item = QTreeWidgetItem(self.treeWidget, ["Information"])
info_item.setData(0, Qt.UserRole, "info:bookinfo")
info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable)
font = info_item.font(0)
font.setBold(True)
info_item.setFont(0, font)
# Setup UI now so dialog appears immediately
self._setup_ui()
# Create a centered loading overlay and show it while background load runs
self._create_loading_overlay()
# Hide the main UI so only the overlay is visible initially
if getattr(self, 'splitter', None) is not None:
self.splitter.setVisible(False)
self._show_loading_overlay("Loading...")
# Start background loading of book content so the dialog opens immediately
self._start_background_load()
# Hide expand/collapse decoration if there are no parent items
has_parents = False
for i in range(self.treeWidget.topLevelItemCount()):
if self.treeWidget.topLevelItem(i).childCount() > 0:
has_parents = True
break
self.treeWidget.setRootIsDecorated(has_parents)
def _create_loading_overlay(self):
"""Create a centered loading indicator with a GIF on the left and text on the right.
The indicator is added to the dialog's main layout above the splitter so
when the splitter is hidden only the indicator is visible.
"""
try:
# Container to hold gif + text and allow centering via stretches
container = QWidget(self)
container.setVisible(False)
h = QHBoxLayout(container)
h.setContentsMargins(0, 8, 0, 8)
h.setSpacing(10)
# Left: GIF label (animated)
gif_label = QLabel(container)
gif_label.setVisible(False)
loading_gif_path = get_resource_path("abogen.assets", "loading.gif")
movie = None
if loading_gif_path:
try:
movie = QMovie(loading_gif_path)
# Make GIF smaller so it doesn't dominate the text
movie.setScaledSize(QSize(25, 25))
gif_label.setMovie(movie)
gif_label.setFixedSize(25, 25)
gif_label.setVisible(True)
except Exception:
movie = None
# Right: Text label
text_label = QLabel(container)
text_label.setStyleSheet("font-size: 14pt;")
# Add stretches to center the content horizontally
h.addStretch(1)
h.addWidget(gif_label, 0, Qt.AlignVCenter)
h.addWidget(text_label, 0, Qt.AlignVCenter)
h.addStretch(1)
# Insert at top of main layout if present, otherwise keep as child
try:
layout = self.layout()
if layout is not None:
layout.insertWidget(0, container)
except Exception:
pass
# Store refs
self._loading_container = container
self._loading_gif_label = gif_label
self._loading_text_label = text_label
self._loading_movie = movie
except Exception:
self._loading_container = None
self._loading_gif_label = None
self._loading_text_label = None
self._loading_movie = None
def _show_loading_overlay(self, text: str):
container = getattr(self, '_loading_container', None)
text_lbl = getattr(self, '_loading_text_label', None)
movie = getattr(self, '_loading_movie', None)
gif_lbl = getattr(self, '_loading_gif_label', None)
if container is None or text_lbl is None:
return
text_lbl.setText(text)
if movie is not None and gif_lbl is not None:
try:
movie.start()
gif_lbl.setVisible(True)
except Exception:
pass
container.setVisible(True)
def _hide_loading_overlay(self):
container = getattr(self, '_loading_container', None)
movie = getattr(self, '_loading_movie', None)
if container is None:
return
if movie is not None:
try:
movie.stop()
except Exception:
pass
container.setVisible(False)
def _start_background_load(self):
"""Start a QThread that runs the preprocessing in background."""
# Start a minimal QThread which executes _preprocess_content
self._loader_thread = HandlerDialog._LoaderThread(self._preprocess_content)
self._loader_thread.finished.connect(self._on_load_finished)
self._loader_thread.error.connect(self._on_load_error)
# ensure thread instance is deleted when done
self._loader_thread.finished.connect(self._loader_thread.deleteLater)
self._loader_thread.start()
def _on_load_error(self, err_msg):
logging.error(f"Error loading book in background: {err_msg}")
if getattr(self, 'previewEdit', None) is not None:
self.previewEdit.setPlainText(f"Error loading book: {err_msg}")
if getattr(self, 'splitter', None) is not None:
self.splitter.setVisible(True)
self._hide_loading_overlay()
def _on_load_finished(self):
"""Called in the main thread when background loading finished."""
# Build the tree now that content_texts/content_lengths/etc. are ready
try:
# Rebuild tree based on file type
self._build_tree()
# Run auto-check if no provided checks are relevant
if not self._are_provided_checks_relevant():
self._run_auto_check()
# Connect signals (after tree exists)
self.treeWidget.currentItemChanged.connect(self.update_preview)
self.treeWidget.itemChanged.connect(self.handle_item_check)
self.treeWidget.itemChanged.connect(lambda _: self._update_checkbox_states())
self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click)
# Expand and select first item
self.treeWidget.expandAll()
if self.treeWidget.topLevelItemCount() > 0:
self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0))
self.treeWidget.setFocus()
# Update checkbox states
self._update_checkbox_states()
# Update preview for the current selection
current = self.treeWidget.currentItem()
self.update_preview(current)
except Exception as e:
logging.error(f"Error finalizing book load: {e}")
# Show the main UI and hide loading text
if getattr(self, 'splitter', None) is not None:
self.splitter.setVisible(True)
self._hide_loading_overlay()
def _preprocess_content(self):
"""Pre-process content from the document"""
# Create cache key from file path, modification time, and file type
try:
mod_time = os.path.getmtime(self.book_path)
except Exception:
mod_time = 0
cache_key = (self.book_path, mod_time, self.file_type)
# Check if content is already cached
if cache_key in HandlerDialog._content_cache:
cached_data = HandlerDialog._content_cache[cache_key]
self.content_texts = cached_data['content_texts']
self.content_lengths = cached_data['content_lengths']
if 'doc_content' in cached_data:
self.doc_content = cached_data['doc_content']
if 'markdown_toc' in cached_data:
self.markdown_toc = cached_data['markdown_toc']
logging.info(f"Using cached content for {os.path.basename(self.book_path)}")
return
# Process content if not cached
if self.file_type == "epub":
try:
self._process_epub_content_nav() # Use the new navigation-based method
except Exception as e:
logging.error(
f"Error processing EPUB with navigation: {e}. Falling back to TOC/spine.",
exc_info=True,
)
# Fallback to a simpler spine-based processing if nav fails
self._process_epub_content_spine_fallback()
elif self.file_type == "markdown":
self._preprocess_markdown_content()
else:
self._preprocess_pdf_content()
# Cache the processed content
cache_data = {
'content_texts': self.content_texts,
'content_lengths': self.content_lengths,
}
if hasattr(self, 'doc_content'):
cache_data['doc_content'] = self.doc_content
if hasattr(self, 'markdown_toc'):
cache_data['markdown_toc'] = self.markdown_toc
HandlerDialog._content_cache[cache_key] = cache_data
logging.info(f"Cached content for {os.path.basename(self.book_path)}")
def _preprocess_pdf_content(self):
"""Pre-process all page contents from PDF document"""
for page_num in range(len(self.pdf_doc)):
text = clean_text(self.pdf_doc[page_num].get_text())
# Remove bracketed numbers (citations, footnotes)
text = re.sub(r"\[\s*\d+\s*\]", "", text)
# Remove standalone page numbers (numbers alone on a line)
text = re.sub(r"^\s*\d+\s*$", "", text, flags=re.MULTILINE)
# Remove page numbers at the end of paragraphs
# This pattern looks for digits surrounded by whitespace at the end of paragraphs
text = re.sub(r"\s+\d+\s*$", "", text, flags=re.MULTILINE)
# Also remove page numbers followed by a hyphen or dash at paragraph end
# (common in headers/footers like "- 42 -")
text = re.sub(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", "", text, flags=re.MULTILINE)
page_id = f"page_{page_num + 1}"
self.content_texts[page_id] = text
self.content_lengths[page_id] = calculate_text_length(text)
def _preprocess_markdown_content(self):
if not self.markdown_text:
return
# Generate TOC from the original (dedented) markdown BEFORE cleaning,
# so header ids/anchors are preserved for reliable position detection.
original_text = textwrap.dedent(self.markdown_text)
md = markdown.Markdown(extensions=['toc', 'fenced_code'])
html = md.convert(original_text)
self.markdown_toc = md.toc_tokens
# Use cleaned text for stored content/length calculations
cleaned_full_text = clean_text(original_text)
soup = BeautifulSoup(html, 'html.parser')
self.content_texts = {}
self.content_lengths = {}
if not self.markdown_toc:
chapter_id = "markdown_content"
self.content_texts[chapter_id] = cleaned_full_text
self.content_lengths[chapter_id] = calculate_text_length(cleaned_full_text)
return
all_headers = []
def flatten_toc(toc_list):
for header in toc_list:
all_headers.append(header)
if header.get('children'):
flatten_toc(header['children'])
flatten_toc(self.markdown_toc)
header_positions = []
for header in all_headers:
header_id = header['id']
id_pattern = f'id="{header_id}"'
pos = html.find(id_pattern)
if pos != -1:
tag_start = html.rfind('<', 0, pos)
header_positions.append({
'id': header_id,
'start': tag_start,
'name': header['name']
})
header_positions.sort(key=lambda x: x['start'])
for i, header_pos in enumerate(header_positions):
header_id = header_pos['id']
header_name = header_pos['name']
content_start = header_pos['start']
content_end = header_positions[i + 1]['start'] if i + 1 < len(header_positions) else len(html)
section_html = html[content_start:content_end]
section_soup = BeautifulSoup(section_html, 'html.parser')
header_tag = section_soup.find(attrs={'id': header_id})
if header_tag:
header_tag.decompose()
# Clean section text for storage/lengths
section_text = clean_text(section_soup.get_text()).strip()
chapter_id = header_id
if section_text:
full_content = f"{header_name}\n\n{section_text}"
self.content_texts[chapter_id] = full_content
self.content_lengths[chapter_id] = calculate_text_length(full_content)
else:
self.content_texts[chapter_id] = header_name
self.content_lengths[chapter_id] = calculate_text_length(header_name)
def _process_epub_content_spine_fallback(self):
"""Fallback EPUB processing based purely on spine order."""
logging.info("Using spine fallback for EPUB processing.")
self.doc_content = {}
spine_docs = []
for spine_item_tuple in self.book.spine:
item_id = spine_item_tuple[0]
item = self.book.get_item_with_id(item_id)
if item:
spine_docs.append(item.get_name())
else:
logging.warning(f"Spine item with id '{item_id}' not found.")
# Cache content
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
href = item.get_name()
if href in spine_docs:
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
self.doc_content[href] = html_content
except Exception as e:
logging.error(f"Error decoding content for {href}: {e}")
self.doc_content[href] = ""
# Create a simple TOC based on spine order
synthetic_toc = []
self.content_texts = {}
self.content_lengths = {}
for i, doc_href in enumerate(spine_docs):
html_content = self.doc_content.get(doc_href, "")
if html_content:
soup = BeautifulSoup(html_content, "html.parser")
# Handle ordered lists by prepending numbers to list items
for ol in soup.find_all("ol"):
# Get start attribute or default to 1
start = int(ol.get("start", 1))
for i, li in enumerate(ol.find_all("li", recursive=False)):
# Insert the number at the beginning of the list item
number_text = f"{start + i}) "
if li.string:
li.string.replace_with(number_text + li.string)
else:
li.insert(0, NavigableString(number_text))
# Remove sup and sub tags
for tag in soup.find_all(["sup", "sub"]):
tag.decompose()
text = clean_text(soup.get_text()).strip()
if text:
self.content_texts[doc_href] = text
self.content_lengths[doc_href] = len(text)
title = None
if soup.title and soup.title.string:
title = soup.title.string.strip()
elif (h1 := soup.find("h1")) and h1.get_text(strip=True):
title = h1.get_text(strip=True)
if not title:
title = f"Untitled Chapter {i + 1}"
synthetic_toc.append(
(epub.Link(doc_href, title, doc_href), [])
) # Wrap in tuple and empty list for compatibility
# Replace book.toc with the synthetic one if it was empty or fallback was triggered
if not self.book.toc or not hasattr(
self, "processed_nav_structure"
): # Check if nav processing failed
self.book.toc = synthetic_toc
logging.info(f"Generated synthetic TOC with {len(synthetic_toc)} entries.")
def _process_epub_content_nav(self):
"""
Process EPUB content using ITEM_NAVIGATION (NAV HTML) or ITEM_NCX.
Globally orders navigation entries and slices content between them.
"""
logging.info(
"Attempting to process EPUB using navigation document (NAV/NCX)..."
)
nav_item = None
nav_type = None
# 1. Check ITEM_NAVIGATION for actual NAV HTML (.xhtml/.html)
nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION))
if nav_items:
# Prefer files explicitly named 'nav.xhtml' or similar
preferred_nav = next(
(
item
for item in nav_items
if "nav" in item.get_name().lower()
and item.get_name().lower().endswith((".xhtml", ".html"))
),
None,
)
if preferred_nav:
nav_item = preferred_nav
nav_type = "html"
logging.info(f"Found preferred NAV HTML item: {nav_item.get_name()}")
else:
# Check if any ITEM_NAVIGATION is actually HTML
html_nav = next(
(
item
for item in nav_items
if item.get_name().lower().endswith((".xhtml", ".html"))
),
None,
)
if html_nav:
nav_item = html_nav
nav_type = "html"
logging.info(
f"Found NAV HTML item in ITEM_NAVIGATION: {html_nav.get_name()}"
)
# 2. If no NAV HTML found via ITEM_NAVIGATION, check if ITEM_NAVIGATION points to NCX
if not nav_item and nav_items:
ncx_in_nav = next(
(
item
for item in nav_items
if item.get_name().lower().endswith(".ncx")
),
None,
)
if ncx_in_nav:
nav_item = ncx_in_nav
nav_type = "ncx"
logging.info(
f"Found NCX item via ITEM_NAVIGATION: {ncx_in_nav.get_name()}"
)
# 3. If still no nav_item, check for NCX or fallback to NAV HTML in all ITEM_DOCUMENTs
ncx_constant = getattr(epub, "ITEM_NCX", None)
if not nav_item and ncx_constant is not None:
ncx_items = list(self.book.get_items_of_type(ncx_constant))
if ncx_items:
nav_item = ncx_items[0]
nav_type = "ncx"
logging.info(f"Found NCX item via ITEM_NCX: {nav_item.get_name()}")
# Fallback: search all ITEM_DOCUMENTs for a NAV HTML with <nav epub:type="toc">
if not nav_item:
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
if "<nav" in html_content and 'epub:type="toc"' in html_content:
soup = BeautifulSoup(html_content, "html.parser")
nav_tag = soup.find("nav", attrs={"epub:type": "toc"})
if nav_tag:
nav_item = item
nav_type = "html"
logging.info(
f"Found NAV HTML with TOC in: {item.get_name()}"
)
break
except Exception as e:
continue
# 4. If no navigation item found by any method, trigger fallback
if not nav_item or not nav_type:
logging.warning(
"No suitable EPUB navigation document (NAV HTML or NCX) found. Falling back."
)
raise ValueError("No navigation document found") # Trigger fallback
# Determine parser based on the confirmed nav_type
parser_type = "html.parser" if nav_type == "html" else "xml"
logging.info(f"Using parser: '{parser_type}' for {nav_item.get_name()}")
try:
nav_content = nav_item.get_content().decode("utf-8", errors="ignore")
nav_soup = BeautifulSoup(nav_content, parser_type)
except Exception as e:
logging.error(
f"Failed to parse navigation content ({nav_item.get_name()}) using {parser_type}: {e}",
exc_info=True,
)
raise ValueError(
f"Failed to parse navigation content: {e}"
) # Trigger fallback
# --- Rest of the processing logic ---
# 1. Cache all document HTML and determine spine order (no changes needed here)
self.doc_content = {}
spine_docs = []
for spine_item_tuple in self.book.spine:
item_id = spine_item_tuple[0]
item = self.book.get_item_with_id(item_id)
if item:
spine_docs.append(item.get_name())
else:
logging.warning(f"Spine item with id '{item_id}' not found.")
doc_order = {href: i for i, href in enumerate(spine_docs)}
# Add a mapping for unquoted (decoded) hrefs as well
doc_order_decoded = {
urllib.parse.unquote(href): i for href, i in doc_order.items()
}
# Clear previous content/lengths before processing
self.content_texts = {}
self.content_lengths = {}
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
href = item.get_name()
if href in doc_order or any(
href in nav_point.get("src", "")
for nav_point in nav_soup.find_all(["content", "a"])
):
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
self.doc_content[href] = html_content
except Exception as e:
logging.error(f"Error decoding content for {href}: {e}")
self.doc_content[href] = ""
# 2. Extract and order navigation entries globally
ordered_nav_entries = []
# Define find_position locally or ensure self._find_position_robust is used correctly
# Using self._find_position_robust is preferred as it's a method of the class
find_position_func = self._find_position_robust
# Store the parsed structure for tree building later
self.processed_nav_structure = []
# Call the correct parsing function based on confirmed nav_type
parse_successful = False
if nav_type == "ncx":
nav_map = nav_soup.find("navMap")
if nav_map:
logging.info("Parsing NCX <navMap>...")
for nav_point in nav_map.find_all("navPoint", recursive=False):
self._parse_ncx_navpoint(
nav_point,
ordered_nav_entries,
doc_order,
doc_order_decoded,
self.processed_nav_structure,
find_position_func,
)
parse_successful = bool(
ordered_nav_entries
) # Success if entries were added
else:
logging.warning("Could not find <navMap> in NCX file.")
elif nav_type == "html":
logging.info("Parsing NAV HTML...")
toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"})
if not toc_nav:
# Fallback: look for any <nav> element containing an <ol>
all_navs = nav_soup.find_all("nav")
for nav in all_navs:
if nav.find("ol"):
toc_nav = nav
logging.info("Found fallback TOC structure in <nav> with <ol>.")
break
if toc_nav:
top_ol = toc_nav.find("ol", recursive=False)
if top_ol:
for li in top_ol.find_all("li", recursive=False):
self._parse_html_nav_li(
li,
ordered_nav_entries,
doc_order,
doc_order_decoded,
self.processed_nav_structure,
find_position_func,
)
parse_successful = bool(
ordered_nav_entries
) # Success if entries were added
else:
logging.warning("Found <nav> for TOC but no top-level <ol> inside.")
else:
logging.warning(
"Could not find TOC structure (<nav epub:type='toc'> or <nav><ol>) in NAV HTML."
)
# Handle case where parsing ran but found no valid entries OR parsing failed
if not parse_successful:
logging.warning(
"Navigation parsing completed but found no valid entries, or parsing failed. Falling back."
)
raise ValueError("No valid navigation entries found after parsing")
# Sort entries globally by document order and position within the document
ordered_nav_entries.sort(key=lambda x: (x["doc_order"], x["position"]))
logging.info(f"Sorted {len(ordered_nav_entries)} navigation entries.")
# 3. Slice content ONLY between sorted TOC entries
num_entries = len(ordered_nav_entries)
for i in range(num_entries):
current_entry = ordered_nav_entries[i]
current_src = current_entry["src"]
current_doc = current_entry["doc_href"]
current_pos = current_entry["position"]
current_doc_html = self.doc_content.get(current_doc, "")
start_slice_pos = current_pos
slice_html = ""
next_entry = ordered_nav_entries[i + 1] if (i + 1) < num_entries else None
if next_entry:
next_doc = next_entry["doc_href"]
next_pos = next_entry["position"]
# Always include all content from current position to next position, even if next_doc is before current_doc
if current_doc == next_doc:
slice_html = current_doc_html[start_slice_pos:next_pos]
else:
# Collect all content from current_doc (from start_slice_pos to end),
# then all intermediate docs (in spine order),
# then up to next_pos in next_doc (even if next_doc is before current_doc in spine)
slice_html = current_doc_html[start_slice_pos:]
docs_between = []
try:
idx_current = spine_docs.index(current_doc)
idx_next = spine_docs.index(next_doc)
if idx_current < idx_next:
for doc_idx in range(idx_current + 1, idx_next):
docs_between.append(spine_docs[doc_idx])
elif idx_current > idx_next:
for doc_idx in range(idx_current + 1, len(spine_docs)):
docs_between.append(spine_docs[doc_idx])
for doc_idx in range(0, idx_next):
docs_between.append(spine_docs[doc_idx])
except Exception:
pass
for doc_href in docs_between:
slice_html += self.doc_content.get(doc_href, "")
next_doc_html = self.doc_content.get(next_doc, "")
slice_html += next_doc_html[:next_pos]
else:
# Last TOC entry: include all content from current position to end of book
slice_html = current_doc_html[start_slice_pos:]
try:
idx_current = spine_docs.index(current_doc)
for doc_idx in range(idx_current + 1, len(spine_docs)):
intermediate_doc_href = spine_docs[doc_idx]
slice_html += self.doc_content.get(intermediate_doc_href, "")
except Exception:
pass
# Fallback: if slice_html is empty, try to get the whole file's text
if not slice_html.strip() and current_doc_html:
logging.warning(
f"No content found for src '{current_src}', using full file as fallback."
)
slice_html = current_doc_html
if slice_html.strip():
slice_soup = BeautifulSoup(slice_html, "html.parser")
# Add line breaks after paragraphs and divs
for tag in slice_soup.find_all(["p", "div"]):
tag.append("\n\n")
# Handle ordered lists by prepending numbers to list items
for ol in slice_soup.find_all("ol"):
# Get start attribute or default to 1
start = int(ol.get("start", 1))
for i, li in enumerate(ol.find_all("li", recursive=False)):
# Insert the number at the beginning of the list item
number_text = f"{start + i}) "
if li.string:
li.string.replace_with(number_text + li.string)
else:
li.insert(0, NavigableString(number_text))
# Remove sup and sub tags that might contain footnotes
for tag in slice_soup.find_all(["sup", "sub"]):
tag.decompose()
text = clean_text(slice_soup.get_text()).strip()
if text:
self.content_texts[current_src] = text
self.content_lengths[current_src] = len(text)
else:
self.content_texts[current_src] = ""
self.content_lengths[current_src] = 0
else:
self.content_texts[current_src] = ""
self.content_lengths[current_src] = 0
# 4. Extract text and store using the original TOC entry src as the key
if ordered_nav_entries:
first_entry = ordered_nav_entries[0]
first_doc_href = first_entry["doc_href"]
first_pos = first_entry["position"]
first_doc_order = first_entry["doc_order"]
prefix_html = ""
for doc_idx in range(first_doc_order):
if doc_idx < len(spine_docs):
intermediate_doc_href = spine_docs[doc_idx]
prefix_html += self.doc_content.get(intermediate_doc_href, "")
else:
logging.warning(
f"Document index {doc_idx} out of bounds for spine (length {len(spine_docs)})."
)
first_doc_html = self.doc_content.get(first_doc_href, "")
prefix_html += first_doc_html[:first_pos]
if prefix_html.strip():
prefix_soup = BeautifulSoup(prefix_html, "html.parser")
for tag in prefix_soup.find_all(["sup", "sub"]):
tag.decompose()
prefix_text = clean_text(prefix_soup.get_text()).strip()
if prefix_text:
prefix_chapter_src = "internal:prefix_content"
self.content_texts[prefix_chapter_src] = prefix_text
self.content_lengths[prefix_chapter_src] = len(prefix_text)
self.processed_nav_structure.insert(
0,
{
"src": prefix_chapter_src,
"title": "Introduction",
"children": [],
},
)
logging.info(
f"Added prefix content chapter '{prefix_chapter_src}'."
)
logging.info(
f"Finished processing EPUB navigation. Found {len(self.content_texts)} content sections linked to TOC."
)
def _find_doc_key(self, base_href, doc_order, doc_order_decoded):
"""Find the best matching doc_key for a given base_href using robust matching."""
candidates = [
base_href,
urllib.parse.unquote(base_href),
]
base_name = os.path.basename(base_href).lower()
for k in list(doc_order.keys()) + list(doc_order_decoded.keys()):
if os.path.basename(k).lower() == base_name:
candidates.append(k)
for candidate in candidates:
if candidate in doc_order:
return candidate, doc_order[candidate]
elif candidate in doc_order_decoded:
return candidate, doc_order_decoded[candidate]
return None, None
def _parse_ncx_navpoint(
self,
nav_point,
ordered_entries,
doc_order,
doc_order_decoded,
tree_structure_list,
find_position_func,
):
nav_label = nav_point.find("navLabel")
content = nav_point.find("content")
title = (
nav_label.find("text").get_text(strip=True)
if nav_label and nav_label.find("text")
else "Untitled Section"
)
src = content["src"] if content and "src" in content.attrs else None
current_entry_node = {"title": title, "src": src, "children": []}
if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
doc_key, doc_idx = self._find_doc_key(
base_href, doc_order, doc_order_decoded
)
if not doc_key:
logging.warning(
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list (even after basename fallback)."
)
current_entry_node["has_content"] = False
else:
position = find_position_func(doc_key, fragment)
entry_data = {
"src": src,
"title": title,
"doc_href": doc_key,
"position": position,
"doc_order": doc_idx,
}
ordered_entries.append(entry_data)
current_entry_node["has_content"] = True
else:
logging.warning(f"Navigation entry '{title}' has no 'src' attribute.")
current_entry_node["has_content"] = False
child_navpoints = nav_point.find_all("navPoint", recursive=False)
if child_navpoints:
for child_np in child_navpoints:
# Pass find_position_func down recursively
self._parse_ncx_navpoint(
child_np,
ordered_entries,
doc_order,
doc_order_decoded,
current_entry_node["children"],
find_position_func,
)
if title and (
current_entry_node.get("has_content", False)
or current_entry_node["children"]
):
tree_structure_list.append(current_entry_node)
def _parse_html_nav_li(
self,
li_element,
ordered_entries,
doc_order,
doc_order_decoded,
tree_structure_list,
find_position_func,
):
link = li_element.find("a", recursive=False)
span_text = li_element.find("span", recursive=False)
title = "Untitled Section"
src = None
current_entry_node = {"children": []}
if link and "href" in link.attrs:
src = link["href"]
title = link.get_text(strip=True) or title
if not title.strip() and span_text:
title = span_text.get_text(strip=True) or title
if not title.strip():
li_text = "".join(
t for t in li_element.contents if isinstance(t, NavigableString)
).strip()
title = li_text or title
elif span_text:
title = span_text.get_text(strip=True) or title
if not title.strip():
li_text = "".join(
t for t in li_element.contents if isinstance(t, NavigableString)
).strip()
title = li_text or title
else:
li_text = "".join(
t for t in li_element.contents if isinstance(t, NavigableString)
).strip()
title = li_text or title
current_entry_node["title"] = title
current_entry_node["src"] = src
doc_key = None
doc_idx = None
position = 0
fragment = None
if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
doc_key, doc_idx = self._find_doc_key(
base_href, doc_order, doc_order_decoded
)
if doc_key is not None:
position = find_position_func(doc_key, fragment)
entry_data = {
"src": src,
"title": title,
"doc_href": doc_key,
"position": position,
"doc_order": doc_idx,
}
ordered_entries.append(entry_data)
current_entry_node["has_content"] = True
else:
logging.warning(
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list (even after basename fallback)."
)
current_entry_node["has_content"] = False
else:
current_entry_node["has_content"] = False
for child_ol in li_element.find_all("ol", recursive=False):
for child_li in child_ol.find_all("li", recursive=False):
self._parse_html_nav_li(
child_li,
ordered_entries,
doc_order,
doc_order_decoded,
current_entry_node["children"],
find_position_func,
)
tree_structure_list.append(current_entry_node)
def _find_position_robust(self, doc_href, fragment_id):
if doc_href not in self.doc_content:
logging.warning(f"Document '{doc_href}' not found in cached content.")
return 0
html_content = self.doc_content[doc_href]
if not fragment_id:
return 0
try:
temp_soup = BeautifulSoup(f"<div>{html_content}</div>", "html.parser")
target_element = temp_soup.find(id=fragment_id)
if target_element:
tag_str = str(target_element)
pos = html_content.find(tag_str[: min(len(tag_str), 200)])
if pos != -1:
logging.debug(
f"Found position for id='{fragment_id}' in {doc_href} using BeautifulSoup: {pos}"
)
return pos
except Exception as e:
logging.warning(
f"BeautifulSoup failed to find id='{fragment_id}' in {doc_href}: {e}"
)
safe_fragment_id = re.escape(fragment_id)
id_name_pattern = re.compile(
f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']", re.IGNORECASE
)
match = id_name_pattern.search(html_content)
if match:
pos = match.start()
logging.debug(
f"Found position for id/name='{fragment_id}' in {doc_href} using regex: {pos}"
)
return pos
id_match_str = f'id="{fragment_id}"'
name_match_str = f'name="{fragment_id}"'
id_pos = html_content.find(id_match_str)
name_pos = html_content.find(name_match_str)
pos = -1
if id_pos != -1 and name_pos != -1:
pos = min(id_pos, name_pos)
elif id_pos != -1:
pos = id_pos
elif name_pos != -1:
pos = name_pos
if pos != -1:
tag_start_pos = html_content.rfind("<", 0, pos)
final_pos = tag_start_pos if tag_start_pos != -1 else 0
logging.debug(
f"Found position for id/name='{fragment_id}' in {doc_href} using string search: {final_pos}"
)
return final_pos
logging.warning(
f"Anchor '{fragment_id}' not found in {doc_href}. Defaulting to position 0."
)
return 0
def _build_tree(self):
self.treeWidget.clear()
info_item = QTreeWidgetItem(self.treeWidget, ["Information"])
info_item.setData(0, Qt.UserRole, "info:bookinfo")
info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable)
font = info_item.font(0)
font.setBold(True)
info_item.setFont(0, font)
if self.file_type == "epub":
if (
hasattr(self, "processed_nav_structure")
and self.processed_nav_structure
):
self._build_epub_tree_from_nav(
self.processed_nav_structure, self.treeWidget
)
else:
logging.warning("Building EPUB tree using fallback book.toc.")
self._build_epub_tree_fallback(self.book.toc, self.treeWidget)
elif self.file_type == "markdown":
self._build_markdown_tree()
else:
self._build_pdf_tree()
has_parents = False
iterator = QTreeWidgetItemIterator(
self.treeWidget, QTreeWidgetItemIterator.HasChildren
)
if iterator.value():
has_parents = True
self.treeWidget.setRootIsDecorated(has_parents)
def _update_checkbox_states(self):
"""Update the checkbox states based on the current checked chapters."""
for i in range(self.treeWidget.topLevelItemCount()):
item = self.treeWidget.topLevelItem(i)
self._update_item_checkbox_state(item)
def _build_epub_tree_from_nav(
self, nav_nodes, parent_item, seen_content_hashes=None
):
if seen_content_hashes is None:
seen_content_hashes = set()
for node in nav_nodes:
title = node.get("title", "Unknown")
src = node.get("src")
children = node.get("children", [])
item = QTreeWidgetItem(parent_item, [title])
item.setData(0, Qt.UserRole, src)
is_empty = (
src
and (src in self.content_texts)
and (not self.content_texts[src].strip())
)
is_duplicate = False
if src and src in self.content_texts and self.content_texts[src].strip():
content_hash = hash(self.content_texts[src])
if content_hash in seen_content_hashes:
is_duplicate = True
else:
seen_content_hashes.add(content_hash)
if src and not is_empty and not is_duplicate:
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
is_checked = src in self.checked_chapters
item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked)
elif is_duplicate:
# Mark as duplicate and remove checkbox
item.setText(0, f"{title} (Duplicate)")
item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable)
elif children:
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(0, Qt.Unchecked)
else:
item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable)
if children:
self._build_epub_tree_from_nav(children, item, seen_content_hashes)
def _build_epub_tree_fallback(self, toc_entries, parent_item):
for entry in toc_entries:
href, title, children = None, "Unknown", []
entry_obj = None
if isinstance(entry, ebooklib.epub.Link):
href, title = entry.href, entry.title or entry.href
entry_obj = entry
elif isinstance(entry, tuple) and len(entry) >= 1:
section_or_link = entry[0]
entry_obj = section_or_link
if isinstance(section_or_link, ebooklib.epub.Section):
title = section_or_link.title
href = getattr(section_or_link, "href", None)
elif isinstance(section_or_link, ebooklib.epub.Link):
href, title = (
section_or_link.href,
section_or_link.title or section_or_link.href,
)
if len(entry) > 1 and isinstance(entry[1], list):
children = entry[1]
else:
continue
item = QTreeWidgetItem(parent_item, [title])
item.setData(0, Qt.UserRole, href)
has_content = (
href and href in self.content_texts and self.content_texts[href].strip()
)
if has_content or children:
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
is_checked = href and href in self.checked_chapters
item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked)
else:
item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable)
if children:
self._build_epub_tree_fallback(children, item)
def _build_pdf_tree(self):
outline = self.pdf_doc.get_toc()
self.has_pdf_bookmarks = bool(outline)
self.bookmark_items_map = {}
if not outline:
self._build_pdf_pages_tree()
return
bookmark_pages = []
page_to_bookmark = {}
next_page_boundaries = {}
added_pages = set()
def extract_page_numbers(entries):
for entry in entries:
if len(entry) >= 3:
_, title, page = entry[:3]
page_num = (
page - 1
if isinstance(page, int)
else self.pdf_doc.resolve_link(page)[0]
)
bookmark_pages.append((page_num, title))
if len(entry) > 3 and isinstance(entry[3], list):
extract_page_numbers(entry[3])
extract_page_numbers(outline)
bookmark_pages.sort()
for i, (page_num, title) in enumerate(bookmark_pages):
if i < len(bookmark_pages) - 1:
next_page_boundaries[page_num] = bookmark_pages[i + 1][0]
page_to_bookmark[page_num] = title
def build_outline_tree(entries, parent_item):
for entry in entries:
if len(entry) >= 3:
entry_level, title, page = entry[:3]
page_num = (
page - 1
if isinstance(page, int)
else self.pdf_doc.resolve_link(page)[0]
)
page_id = f"page_{page_num + 1}"
# attach chapters on same page under original
if page_num in self.bookmark_items_map:
orig = self.bookmark_items_map[page_num]
child = QTreeWidgetItem(orig, [f"{title} (Same page)"])
child.setData(0, Qt.UserRole, page_id)
child.setFlags(child.flags() & ~Qt.ItemIsUserCheckable)
continue
bookmark_item = QTreeWidgetItem(parent_item, [title])
bookmark_item.setData(0, Qt.UserRole, page_id)
# only allow checking if this chapter has content
if self.content_lengths.get(page_id, 0) > 0:
bookmark_item.setFlags(
bookmark_item.flags() | Qt.ItemIsUserCheckable
)
bookmark_item.setCheckState(
0,
(
Qt.Checked
if page_id in self.checked_chapters
else Qt.Unchecked
),
)
else:
bookmark_item.setFlags(
bookmark_item.flags() & ~Qt.ItemIsUserCheckable
)
# map for uncategorized pages
self.bookmark_items_map[page_num] = bookmark_item
added_pages.add(page_num)
next_page = next_page_boundaries.get(page_num, len(self.pdf_doc))
for sub_page_num in range(page_num + 1, next_page):
if (
sub_page_num in page_to_bookmark
or sub_page_num in added_pages
):
continue
page_id = f"page_{sub_page_num + 1}"
page_title = f"Page {sub_page_num + 1}"
page_text = self.content_texts.get(page_id, "").strip()
if page_text:
first_line = page_text.split("\n", 1)[0].strip()
if first_line and len(first_line) < 100:
page_title += f" - {first_line}"
page_item = QTreeWidgetItem(bookmark_item, [page_title])
page_item.setData(0, Qt.UserRole, page_id)
# only allow checking if this sub-page has content
if self.content_lengths.get(page_id, 0) > 0:
page_item.setFlags(
page_item.flags() | Qt.ItemIsUserCheckable
)
page_item.setCheckState(
0,
(
Qt.Checked
if page_id in self.checked_chapters
else Qt.Unchecked
),
)
else:
page_item.setFlags(
page_item.flags() & ~Qt.ItemIsUserCheckable
)
added_pages.add(sub_page_num)
build_outline_tree(outline, self.treeWidget)
covered_pages = set(added_pages)
# attach any pages without direct bookmarks under nearest preceding chapter
uncategorized_pages = [
i for i in range(len(self.pdf_doc)) if i not in covered_pages
]
for page_num in uncategorized_pages:
# find nearest previous bookmark
prev_nums = [n for n in sorted(self.bookmark_items_map) if n < page_num]
parent_item = (
self.bookmark_items_map[prev_nums[-1]] if prev_nums else self.treeWidget
)
page_id = f"page_{page_num + 1}"
title = f"Page {page_num + 1}"
text = self.content_texts.get(page_id, "").strip()
if text:
first = text.split("\n", 1)[0].strip()
if first and len(first) < 100:
title += f" - {first}"
page_item = QTreeWidgetItem(parent_item, [title])
page_item.setData(0, Qt.UserRole, page_id)
# only allow checking if uncategorized page has content
if self.content_lengths.get(page_id, 0) > 0:
page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable)
page_item.setCheckState(
0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked
)
else:
page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable)
def _build_markdown_tree(self):
"""Build tree structure for markdown file based on parsed TOC."""
if not self.markdown_text:
return
if not self.markdown_toc:
# Handle case with no headers (single content block)
if self.content_texts:
chapter_id = list(self.content_texts.keys())[0]
title = "Content"
item = QTreeWidgetItem(self.treeWidget, [title])
item.setData(0, Qt.UserRole, chapter_id)
if self.content_lengths.get(chapter_id, 0) > 0:
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
is_checked = chapter_id in self.checked_chapters
item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked)
else:
item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable)
return
def build_from_toc(toc_list, parent_item):
for header in toc_list:
title = header['name']
chapter_id = header['id']
item = QTreeWidgetItem(parent_item, [title])
item.setData(0, Qt.UserRole, chapter_id)
has_content = self.content_lengths.get(chapter_id, 0) > 0
has_children = bool(header.get('children'))
if has_content or has_children:
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
is_checked = chapter_id in self.checked_chapters
item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked)
else:
item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable)
if has_children:
build_from_toc(header['children'], item)
build_from_toc(self.markdown_toc, self.treeWidget)
def _build_pdf_pages_tree(self):
pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"])
pages_item.setFlags(pages_item.flags() & ~Qt.ItemIsUserCheckable)
font = pages_item.font(0)
font.setBold(True)
pages_item.setFont(0, font)
for page_num in range(len(self.pdf_doc)):
page_id = f"page_{page_num + 1}"
page_title = f"Page {page_num + 1}"
page_text = self.content_texts.get(page_id, "").strip()
if page_text:
first_line = page_text.split("\n", 1)[0].strip()
if first_line and len(first_line) < 100:
page_title += f" - {first_line}"
page_item = QTreeWidgetItem(pages_item, [page_title])
page_item.setData(0, Qt.UserRole, page_id)
# only allow checking if standalone page has content
if self.content_lengths.get(page_id, 0) > 0:
page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable)
page_item.setCheckState(
0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked
)
else:
page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable)
def _are_provided_checks_relevant(self):
if not self.checked_chapters:
return False
all_identifiers = set()
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.flags() & Qt.ItemIsUserCheckable:
identifier = item.data(0, Qt.UserRole)
if identifier:
all_identifiers.add(identifier)
iterator += 1
return bool(self.checked_chapters.intersection(all_identifiers))
def _setup_ui(self):
self.previewEdit = QTextEdit(self)
self.previewEdit.setReadOnly(True)
self.previewEdit.setMinimumWidth(300)
self.previewEdit.setStyleSheet("QTextEdit { border: none; }")
self.previewInfoLabel = QLabel(
'*Note: You can modify the content later using the "Edit" button in the input box or by accessing the temporary files directory through settings (if not saved in a project folder).',
self,
)
self.previewInfoLabel.setWordWrap(True)
self.previewInfoLabel.setStyleSheet(
"QLabel { color: #666; font-style: italic; }"
)
previewLayout = QVBoxLayout()
previewLayout.setContentsMargins(0, 0, 0, 0)
previewLayout.addWidget(self.previewEdit, 1)
previewLayout.addWidget(self.previewInfoLabel, 0)
rightWidget = QWidget()
rightWidget.setLayout(previewLayout)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
item_type = "chapters" if self.file_type in ["epub", "markdown"] else "pages"
self.auto_select_btn = QPushButton(f"Auto-select {item_type}", self)
self.auto_select_btn.clicked.connect(self.auto_select_chapters)
self.auto_select_btn.setToolTip(f"Automatically select main {item_type}")
buttons_layout = QVBoxLayout()
buttons_layout.setContentsMargins(0, 0, 0, 0)
buttons_layout.setSpacing(10)
auto_select_layout = QHBoxLayout()
auto_select_layout.addWidget(self.auto_select_btn)
buttons_layout.addLayout(auto_select_layout)
select_layout = QHBoxLayout()
self.select_all_btn = QPushButton("Select all", self)
self.select_all_btn.clicked.connect(self.select_all_chapters)
self.deselect_all_btn = QPushButton("Clear all", self)
self.deselect_all_btn.clicked.connect(self.deselect_all_chapters)
select_layout.addWidget(self.select_all_btn)
select_layout.addWidget(self.deselect_all_btn)
buttons_layout.addLayout(select_layout)
parent_layout = QHBoxLayout()
self.select_parents_btn = QPushButton("Select parents", self)
self.select_parents_btn.clicked.connect(self.select_parent_chapters)
self.deselect_parents_btn = QPushButton("Unselect parents", self)
self.deselect_parents_btn.clicked.connect(self.deselect_parent_chapters)
parent_layout.addWidget(self.select_parents_btn)
parent_layout.addWidget(self.deselect_parents_btn)
buttons_layout.addLayout(parent_layout)
expand_layout = QHBoxLayout()
self.expand_all_btn = QPushButton("Expand All", self)
self.expand_all_btn.clicked.connect(self.treeWidget.expandAll)
self.collapse_all_btn = QPushButton("Collapse All", self)
self.collapse_all_btn.clicked.connect(self.treeWidget.collapseAll)
expand_layout.addWidget(self.expand_all_btn)
expand_layout.addWidget(self.collapse_all_btn)
buttons_layout.addLayout(expand_layout)
leftLayout = QVBoxLayout()
leftLayout.setContentsMargins(0, 0, 5, 0)
leftLayout.addLayout(buttons_layout)
leftLayout.addWidget(self.treeWidget)
checkbox_text = (
"Save each chapter separately"
if self.file_type in ["epub", "markdown"]
else "Save each page separately"
)
self.save_chapters_checkbox = QCheckBox(checkbox_text, self)
self.save_chapters_checkbox.setChecked(self.save_chapters_separately)
self.save_chapters_checkbox.stateChanged.connect(self.on_save_chapters_changed)
leftLayout.addWidget(self.save_chapters_checkbox)
self.merge_chapters_checkbox = QCheckBox(
"Create a merged version at the end", self
)
self.merge_chapters_checkbox.setChecked(self.merge_chapters_at_end)
self.merge_chapters_checkbox.stateChanged.connect(
self.on_merge_chapters_changed
)
leftLayout.addWidget(self.merge_chapters_checkbox)
self.save_as_project_checkbox = QCheckBox(
"Save in a project folder with metadata", self
)
self.save_as_project_checkbox.setToolTip(
"Save the converted item in a project folder with metadata files. "
"(Useful if you want to work with converted items in the future.)"
)
self.save_as_project_checkbox.setChecked(self.save_as_project)
self.save_as_project_checkbox.stateChanged.connect(
self.on_save_as_project_changed
)
leftLayout.addWidget(self.save_as_project_checkbox)
leftLayout.addWidget(buttons)
leftWidget = QWidget()
leftWidget.setLayout(leftLayout)
self.splitter = QSplitter(Qt.Horizontal)
self.splitter.addWidget(leftWidget)
self.splitter.addWidget(rightWidget)
self.splitter.setSizes([280, 420])
mainLayout = QVBoxLayout(self)
mainLayout.addWidget(self.splitter)
self.setLayout(mainLayout)
def _update_checkbox_states(self):
if (
not hasattr(self, "save_chapters_checkbox")
or not self.save_chapters_checkbox
):
return
if (
self.file_type == "pdf"
and hasattr(self, "has_pdf_bookmarks")
and not self.has_pdf_bookmarks
):
self.save_chapters_checkbox.setEnabled(False)
self.merge_chapters_checkbox.setEnabled(False)
return
checked_count = 0
if self.file_type in ["epub", "markdown"]:
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if (
item.flags() & Qt.ItemIsUserCheckable
and item.checkState(0) == Qt.Checked
):
checked_count += 1
if checked_count >= 2:
break
iterator += 1
else:
parent_groups = set()
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if (
item.flags() & Qt.ItemIsUserCheckable
and item.checkState(0) == Qt.Checked
):
parent = item.parent()
if parent and parent != self.treeWidget.invisibleRootItem():
parent_groups.add(id(parent))
else:
parent_groups.add(id(item))
iterator += 1
checked_count = len(parent_groups)
min_groups_required = 2
self.save_chapters_checkbox.setEnabled(checked_count >= min_groups_required)
self.merge_chapters_checkbox.setEnabled(
self.save_chapters_checkbox.isEnabled()
and self.save_chapters_checkbox.isChecked()
)
def select_all_chapters(self):
self._block_signals = True
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.flags() & Qt.ItemIsUserCheckable:
item.setCheckState(0, Qt.Checked)
iterator += 1
self._block_signals = False
self._update_checked_set_from_tree()
def deselect_all_chapters(self):
self._block_signals = True
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.flags() & Qt.ItemIsUserCheckable:
item.setCheckState(0, Qt.Unchecked)
iterator += 1
self._block_signals = False
self._update_checked_set_from_tree()
def select_parent_chapters(self):
self._block_signals = True
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.flags() & Qt.ItemIsUserCheckable and item.childCount() > 0:
item.setCheckState(0, Qt.Checked)
iterator += 1
self._block_signals = False
self._update_checked_set_from_tree()
def deselect_parent_chapters(self):
self._block_signals = True
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.flags() & Qt.ItemIsUserCheckable and item.childCount() > 0:
item.setCheckState(0, Qt.Unchecked)
iterator += 1
self._block_signals = False
self._update_checked_set_from_tree()
def auto_select_chapters(self):
self._run_auto_check()
def _run_auto_check(self):
self._block_signals = True
if self.file_type == "epub":
self._run_epub_auto_check()
elif self.file_type == "markdown":
self._run_markdown_auto_check()
else:
self._run_pdf_auto_check()
self._block_signals = False
self._update_checked_set_from_tree()
def _run_epub_auto_check(self):
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if not (item.flags() & Qt.ItemIsUserCheckable):
iterator += 1
continue
src = item.data(0, Qt.UserRole)
has_significant_content = src and self.content_lengths.get(src, 0) > 1000
is_parent = item.childCount() > 0
if has_significant_content or is_parent:
item.setCheckState(0, Qt.Checked)
if is_parent:
for i in range(item.childCount()):
child = item.child(i)
if child.flags() & Qt.ItemIsUserCheckable:
child_src = child.data(0, Qt.UserRole)
child_has_content = (
child_src and self.content_lengths.get(child_src, 0) > 0
)
child_is_parent = child.childCount() > 0
if child_has_content or child_is_parent:
child.setCheckState(0, Qt.Checked)
else:
item.setCheckState(0, Qt.Unchecked)
iterator += 1
def _run_markdown_auto_check(self):
"""Auto-select markdown chapters with significant content"""
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if not (item.flags() & Qt.ItemIsUserCheckable):
iterator += 1
continue
identifier = item.data(0, Qt.UserRole)
# Select chapters with content > 500 characters or parent items
has_significant_content = identifier and self.content_lengths.get(identifier, 0) > 500
is_parent = item.childCount() > 0
if has_significant_content or is_parent:
item.setCheckState(0, Qt.Checked)
# Also check children if this is a parent
if is_parent:
for i in range(item.childCount()):
child = item.child(i)
if child.flags() & Qt.ItemIsUserCheckable:
child_identifier = child.data(0, Qt.UserRole)
child_has_content = (
child_identifier and self.content_lengths.get(child_identifier, 0) > 0
)
child_is_parent = child.childCount() > 0
if child_has_content or child_is_parent:
child.setCheckState(0, Qt.Checked)
else:
item.setCheckState(0, Qt.Unchecked)
iterator += 1
def _run_pdf_auto_check(self):
if hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks:
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.flags() & Qt.ItemIsUserCheckable:
item.setCheckState(0, Qt.Checked)
iterator += 1
return
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if not (item.flags() & Qt.ItemIsUserCheckable):
iterator += 1
continue
identifier = item.data(0, Qt.UserRole)
if not identifier:
iterator += 1
continue
if (
not identifier.startswith("page_")
or self.content_lengths.get(identifier, 0) > 0
):
item.setCheckState(0, Qt.Checked)
iterator += 1
def _update_checked_set_from_tree(self):
self.checked_chapters.clear()
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.checkState(0) == Qt.Checked:
identifier = item.data(0, Qt.UserRole)
if identifier:
self.checked_chapters.add(identifier)
iterator += 1
if hasattr(self, "save_chapters_checkbox") and self.save_chapters_checkbox:
self._update_checkbox_states()
def handle_item_check(self, item):
if self._block_signals:
return
self._block_signals = True
if item.flags() & Qt.ItemIsUserCheckable:
for i in range(item.childCount()):
child = item.child(i)
if child.flags() & Qt.ItemIsUserCheckable:
child.setCheckState(0, item.checkState(0))
self._block_signals = False
self._update_checked_set_from_tree()
def handle_item_double_click(self, item, column=0):
if item.flags() & Qt.ItemIsUserCheckable and item.childCount() == 0:
rect = self.treeWidget.visualItemRect(item)
checkbox_width = 20
mouse_pos = self.treeWidget.mapFromGlobal(self.treeWidget.cursor().pos())
if mouse_pos.x() > rect.x() + checkbox_width:
new_state = (
Qt.Unchecked if item.checkState(0) == Qt.Checked else Qt.Checked
)
item.setCheckState(0, new_state)
def update_preview(self, current):
if not current:
self.previewEdit.clear()
return
identifier = current.data(0, Qt.UserRole)
if identifier == "info:bookinfo":
self._display_book_info()
return
text = None
if self.file_type == "epub":
text = self.content_texts.get(identifier)
else:
text = self.content_texts.get(identifier)
if text is None:
title = current.text(0)
self.previewEdit.setPlainText(
f"{title}\n\n(No content available for this item)"
)
elif not text.strip():
title = current.text(0)
self.previewEdit.setPlainText(f"{title}\n\n(This item is empty)")
else:
self.previewEdit.setPlainText(text)
def _display_book_info(self):
self.previewEdit.clear()
html_content = "<html><body style='font-family: Arial, sans-serif;'>"
if self.book_metadata["cover_image"]:
try:
image_data = base64.b64encode(self.book_metadata["cover_image"]).decode(
"utf-8"
)
image_type = "jpeg"
if self.book_metadata["cover_image"].startswith(b"\x89PNG"):
image_type = "png"
elif self.book_metadata["cover_image"].startswith(b"GIF"):
image_type = "gif"
html_content += (
f"<div style='text-align: center; margin-bottom: 20px;'>"
)
html_content += (
f"<img src='data:image/{image_type};base64,{image_data}' "
)
html_content += f"width='300' style='object-fit: contain;' /></div>"
except Exception as e:
html_content += f"<p>Error displaying cover image: {str(e)}</p>"
if self.book_metadata["title"]:
html_content += (
f"<h2 style='text-align: center;'>{self.book_metadata['title']}</h2>"
)
if self.book_metadata["authors"]:
authors_text = ", ".join(self.book_metadata["authors"])
html_content += f"<p style='text-align: center; font-style: italic;'>By {authors_text}</p>"
if self.book_metadata["publisher"] or self.book_metadata.get(
"publication_year"
):
pub_info = []
if self.book_metadata["publisher"]:
pub_info.append(f"Published by {self.book_metadata['publisher']}")
if self.book_metadata.get("publication_year"):
pub_info.append(f"Year: {self.book_metadata['publication_year']}")
html_content += f"<p style='text-align: center;'>{' | '.join(pub_info)}</p>"
html_content += "<hr/>"
if self.book_metadata["description"]:
desc = re.sub(r"<[^>]+>", "", self.book_metadata["description"])
html_content += f"<h3>Description:</h3><p>{desc}</p>"
if self.file_type == "pdf":
page_count = len(self.pdf_doc) if self.pdf_doc else 0
html_content += f"<p>File type: PDF<br>Page count: {page_count}</p>"
html_content += "</body></html>"
self.previewEdit.setHtml(html_content)
def _extract_book_metadata(self):
metadata = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
if self.file_type == "epub":
try:
title_items = self.book.get_metadata("DC", "title")
if title_items and len(title_items) > 0:
metadata["title"] = title_items[0][0]
except Exception as e:
logging.warning(f"Error extracting title metadata: {e}")
try:
author_items = self.book.get_metadata("DC", "creator")
if author_items:
metadata["authors"] = [
author[0] for author in author_items if len(author) > 0
]
except Exception as e:
logging.warning(f"Error extracting author metadata: {e}")
try:
desc_items = self.book.get_metadata("DC", "description")
if desc_items and len(desc_items) > 0:
metadata["description"] = desc_items[0][0]
except Exception as e:
logging.warning(f"Error extracting description metadata: {e}")
try:
publisher_items = self.book.get_metadata("DC", "publisher")
if publisher_items and len(publisher_items) > 0:
metadata["publisher"] = publisher_items[0][0]
except Exception as e:
logging.warning(f"Error extracting publisher metadata: {e}")
# Try to extract publication year
try:
date_items = self.book.get_metadata("DC", "date")
if date_items and len(date_items) > 0:
date_str = date_items[0][0]
# Try to extract just the year from the date string
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
else:
metadata["publication_year"] = date_str
except Exception as e:
logging.warning(f"Error extracting publication date metadata: {e}")
for item in self.book.get_items_of_type(ebooklib.ITEM_COVER):
metadata["cover_image"] = item.get_content()
break
if not metadata["cover_image"]:
for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE):
if "cover" in item.get_name().lower():
metadata["cover_image"] = item.get_content()
break
elif self.file_type == "markdown":
# Extract metadata from markdown frontmatter or first heading
if self.markdown_text:
# Try to extract YAML frontmatter
frontmatter_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', self.markdown_text, re.DOTALL)
if frontmatter_match:
try:
frontmatter = frontmatter_match.group(1)
# Simple YAML-like parsing for common fields
title_match = re.search(r'^title:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE)
if title_match:
metadata["title"] = title_match.group(1).strip().strip('"\'')
author_match = re.search(r'^author:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE)
if author_match:
metadata["authors"] = [author_match.group(1).strip().strip('"\'')]
desc_match = re.search(r'^description:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE)
if desc_match:
metadata["description"] = desc_match.group(1).strip().strip('"\'')
date_match = re.search(r'^date:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE)
if date_match:
date_str = date_match.group(1).strip().strip('"\'')
year_match = re.search(r'\b(19|20)\d{2}\b', date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
except Exception as e:
logging.warning(f"Error parsing markdown frontmatter: {e}")
# Fallback: use first H1 header as title if no frontmatter title
if not metadata["title"] and self.markdown_toc:
# Find the first level 1 header
first_h1 = next((h for h in self.markdown_toc if h['level'] == 1), None)
if first_h1:
metadata["title"] = first_h1['name']
else:
pdf_info = self.pdf_doc.metadata
if pdf_info:
metadata["title"] = pdf_info.get("title", None)
author = pdf_info.get("author", None)
if author:
metadata["authors"] = [author]
metadata["description"] = pdf_info.get("subject", None)
keywords = pdf_info.get("keywords", None)
if keywords:
if metadata["description"]:
metadata["description"] += f"\n\nKeywords: {keywords}"
else:
metadata["description"] = f"Keywords: {keywords}"
metadata["publisher"] = pdf_info.get("creator", None)
# Try to extract publication date from PDF metadata
if "creationDate" in pdf_info:
date_str = pdf_info["creationDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
elif "modDate" in pdf_info:
date_str = pdf_info["modDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
if len(self.pdf_doc) > 0:
try:
pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
metadata["cover_image"] = pix.tobytes("png")
except Exception:
pass
return metadata
def get_selected_text(self):
# If a background loader thread is running, wait for it to finish to
# preserve compatibility with callers that expect content to be ready
# when they create a HandlerDialog and immediately request selected text.
try:
if hasattr(self, '_loader_thread') and getattr(self, '_loader_thread') is not None:
# Wait for thread to finish (blocks until done)
if self._loader_thread.isRunning():
self._loader_thread.wait()
except Exception:
pass
if self.file_type == "epub":
return self._get_epub_selected_text()
elif self.file_type == "markdown":
return self._get_markdown_selected_text()
else:
return self._get_pdf_selected_text()
def _format_metadata_tags(self):
"""Format metadata tags for insertion at the beginning of the text"""
import datetime
metadata = self.book_metadata
filename = os.path.splitext(os.path.basename(self.book_path))[0]
current_year = str(datetime.datetime.now().year)
# Get values with fallbacks
title = metadata.get("title") or filename
authors = metadata.get("authors") or ["Unknown"]
authors_text = ", ".join(authors)
album_artist = authors_text or "Unknown"
year = (
metadata.get("publication_year") or current_year
) # Use publication year if available
# Count chapters/pages
total_chapters = len(self.checked_chapters)
chapter_text = (
f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}"
)
# Format metadata tags
metadata_tags = [
f"<<METADATA_TITLE:{title}>>",
f"<<METADATA_ARTIST:{authors_text}>>",
f"<<METADATA_ALBUM:{title} ({chapter_text})>>",
f"<<METADATA_YEAR:{year}>>",
f"<<METADATA_ALBUM_ARTIST:{album_artist}>>",
f"<<METADATA_COMPOSER:Narrator>>",
f"<<METADATA_GENRE:Audiobook>>",
]
return "\n".join(metadata_tags)
def _get_markdown_selected_text(self):
"""Get selected text from markdown chapters"""
all_checked_identifiers = set()
chapter_texts = []
# Add metadata tags at the beginning
metadata_tags = self._format_metadata_tags()
item_order_counter = 0
ordered_checked_items = []
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
item_order_counter += 1
if item.checkState(0) == Qt.Checked:
identifier = item.data(0, Qt.UserRole)
if identifier and identifier != "info:bookinfo":
all_checked_identifiers.add(identifier)
ordered_checked_items.append((item_order_counter, item, identifier))
iterator += 1
ordered_checked_items.sort(key=lambda x: x[0])
for order, item, identifier in ordered_checked_items:
text = self.content_texts.get(identifier)
if text and text.strip():
title = item.text(0)
# Remove leading dashes from title
title = re.sub(r"^\s*[-–—]\s*", "", title).strip()
marker = f"<<CHAPTER_MARKER:{title}>>"
chapter_texts.append(marker + "\n" + text)
full_text = metadata_tags + "\n\n" + "\n\n".join(chapter_texts)
return full_text, all_checked_identifiers
def _get_epub_selected_text(self):
all_checked_identifiers = set()
chapter_texts = []
# Add metadata tags at the beginning
metadata_tags = self._format_metadata_tags()
item_order_counter = 0
ordered_checked_items = []
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
item_order_counter += 1
if item.checkState(0) == Qt.Checked:
identifier = item.data(0, Qt.UserRole)
if identifier and identifier != "info:bookinfo":
all_checked_identifiers.add(identifier)
ordered_checked_items.append((item_order_counter, item, identifier))
iterator += 1
ordered_checked_items.sort(key=lambda x: x[0])
for order, item, identifier in ordered_checked_items:
text = self.content_texts.get(identifier)
if text and text.strip():
title = item.text(0)
title = re.sub(r"^\s*[-–—]\s*", "", title).strip()
marker = f"<<CHAPTER_MARKER:{title}>>"
chapter_texts.append(marker + "\n" + text)
full_text = metadata_tags + "\n\n" + "\n\n".join(chapter_texts)
return full_text, all_checked_identifiers
def _get_pdf_selected_text(self):
all_checked_identifiers = set()
included_text_ids = set()
section_titles = []
all_content = []
# Add metadata tags at the beginning
metadata_tags = self._format_metadata_tags()
pdf_has_no_bookmarks = (
hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks
)
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.checkState(0) == Qt.Checked:
identifier = item.data(0, Qt.UserRole)
if identifier:
all_checked_identifiers.add(identifier)
iterator += 1
if pdf_has_no_bookmarks:
sorted_page_ids = sorted(
[id for id in all_checked_identifiers if id.startswith("page_")],
key=lambda x: int(x.split("_")[1]) if x.split("_")[1].isdigit() else 0,
)
for page_id in sorted_page_ids:
if page_id not in included_text_ids:
text = self.content_texts.get(page_id, "")
if text:
all_content.append(text)
included_text_ids.add(page_id)
return (
metadata_tags + "\n\n" + "\n\n".join(all_content),
all_checked_identifiers,
)
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.childCount() > 0:
parent_checked = item.checkState(0) == Qt.Checked
parent_id = item.data(0, Qt.UserRole)
parent_title = item.text(0)
checked_children = []
for i in range(item.childCount()):
child = item.child(i)
child_id = child.data(0, Qt.UserRole)
if (
child.checkState(0) == Qt.Checked
and child_id
and child_id not in included_text_ids
):
checked_children.append((child, child_id))
if parent_checked and parent_id and parent_id not in included_text_ids:
combined_text = self.content_texts.get(parent_id, "")
for child, child_id in checked_children:
child_text = self.content_texts.get(child_id, "")
if child_text:
combined_text += "\n\n" + child_text
included_text_ids.add(child_id)
if combined_text.strip():
title = re.sub(r"^\s*-\s*", "", parent_title).strip()
marker = f"<<CHAPTER_MARKER:{title}>>"
section_titles.append((title, marker + "\n" + combined_text))
included_text_ids.add(parent_id)
elif not parent_checked and checked_children:
title = re.sub(r"^\s*-\s*", "", parent_title).strip()
marker = f"<<CHAPTER_MARKER:{title}>>"
for idx, (child, child_id) in enumerate(checked_children):
text = self.content_texts.get(child_id, "")
if text:
if idx == 0:
section_titles.append((title, marker + "\n" + text))
else:
section_titles.append((title, text))
included_text_ids.add(child_id)
elif item.flags() & Qt.ItemIsUserCheckable:
identifier = item.data(0, Qt.UserRole)
if (
identifier
and identifier not in included_text_ids
and item.checkState(0) == Qt.Checked
):
text = self.content_texts.get(identifier, "")
if text:
title = item.text(0)
title = re.sub(r"^\s*-\s*", "", title).strip()
marker = f"<<CHAPTER_MARKER:{title}>>"
section_titles.append((title, marker + "\n" + text))
included_text_ids.add(identifier)
iterator += 1
return (
metadata_tags + "\n\n" + "\n\n".join([t[1] for t in section_titles]),
all_checked_identifiers,
)
def on_save_chapters_changed(self, state):
self.save_chapters_separately = bool(state)
self.merge_chapters_checkbox.setEnabled(self.save_chapters_separately)
HandlerDialog._save_chapters_separately = self.save_chapters_separately
def on_merge_chapters_changed(self, state):
self.merge_chapters_at_end = bool(state)
HandlerDialog._merge_chapters_at_end = self.merge_chapters_at_end
def on_save_as_project_changed(self, state):
self.save_as_project = bool(state)
HandlerDialog._save_as_project = self.save_as_project
def get_save_chapters_separately(self):
return (
self.save_chapters_separately
if self.save_chapters_checkbox.isEnabled()
else False
)
def get_merge_chapters_at_end(self):
return self.merge_chapters_at_end
def get_save_as_project(self):
return self.save_as_project
def check_selected_items(self):
self.set_selected_items_checked(True)
def uncheck_selected_items(self):
self.set_selected_items_checked(False)
def set_selected_items_checked(self, state: bool):
print(f"Checking selected items: {state}")
self.treeWidget.blockSignals(True)
for item in self.treeWidget.selectedItems():
if item.flags() & Qt.ItemIsUserCheckable:
item.setCheckState(0, Qt.Checked if state else Qt.Unchecked)
self.treeWidget.blockSignals(False)
self._update_checked_set_from_tree()
def on_tree_context_menu(self, pos):
item = self.treeWidget.itemAt(pos)
# multi-select context menu
if self.treeWidget.selectedItems() and len(self.treeWidget.selectedItems()) > 1:
menu = QMenu(self)
action = menu.addAction("Select")
action.triggered.connect(self.check_selected_items)
action = menu.addAction("Clear")
action.triggered.connect(self.uncheck_selected_items)
menu.exec_(self.treeWidget.mapToGlobal(pos))
return
if (
not item
or item.childCount() == 0
or not (item.flags() & Qt.ItemIsUserCheckable)
):
return
menu = QMenu(self)
checked = item.checkState(0) == Qt.Checked
text = "Unselect only this" if checked else "Select only this"
action = menu.addAction(text)
def do_toggle():
self.treeWidget.blockSignals(True)
new_state = Qt.Unchecked if checked else Qt.Checked
item.setCheckState(0, new_state)
self.treeWidget.blockSignals(False)
self._update_checked_set_from_tree()
action.triggered.connect(do_toggle)
menu.exec_(self.treeWidget.mapToGlobal(pos))
def closeEvent(self, event):
if self.pdf_doc is not None:
try:
if hasattr(self.pdf_doc, "is_closed"):
if not self.pdf_doc.is_closed:
self.pdf_doc.close()
else:
# Fallback: try/except close
self.pdf_doc.close()
except Exception:
pass
event.accept()