diff --git a/abogen/book_handler.py b/abogen/book_handler.py deleted file mode 100644 index bf565fd..0000000 --- a/abogen/book_handler.py +++ /dev/null @@ -1,1549 +0,0 @@ -<<<<<<< HEAD -import re -import base64 -from bs4 import BeautifulSoup, NavigableString -from PyQt6.QtGui import QMovie -from PyQt6.QtWidgets import ( - QDialog, - QTreeWidget, - QTreeWidgetItem, - QTextEdit, - QPushButton, - QVBoxLayout, - QHBoxLayout, - QDialogButtonBox, - QSplitter, - QWidget, - """Backwards-compatible re-export of the PyQt book handler. - - The actual implementation lives in abogen.pyqt.book_handler. - """ - - from __future__ import annotations - - from abogen.pyqt.book_handler import * # noqa: F401, F403 - from abogen.pyqt.book_handler import HandlerDialog - - __all__ = ["HandlerDialog"] - - # Build treeview - self.treeWidget = QTreeWidget(self) - self.treeWidget.setHeaderHidden(True) - self.treeWidget.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection) - self.treeWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.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 = {} - # Also maintain refs for structure - self.processed_nav_structure = [] - - # Add a placeholder "Information" item so the tree isn't empty immediately - info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) - info_item.setData(0, Qt.ItemDataRole.UserRole, "info:bookinfo") - info_item.setFlags(info_item.flags() & ~Qt.ItemFlag.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.AlignmentFlag.AlignVCenter) - h.addWidget(text_label, 0, Qt.AlignmentFlag.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, file type, and replace_single_newlines setting - try: - mod_time = os.path.getmtime(self.book_path) - except Exception: - mod_time = 0 - - # Include replace_single_newlines in cache key since it affects text cleaning - from abogen.utils import load_config - - cfg = load_config() - replace_single_newlines = cfg.get("replace_single_newlines", True) - - cache_key = (self.book_path, mod_time, self.parser.file_type, replace_single_newlines) - - # 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 "processed_nav_structure" in cached_data: - self.processed_nav_structure = cached_data["processed_nav_structure"] - if "book_metadata" in cached_data: - self.book_metadata = cached_data["book_metadata"] - - # Apply to parser so it stays in sync if used elsewhere - self.parser.content_texts = self.content_texts - self.parser.content_lengths = self.content_lengths - self.parser.processed_nav_structure = self.processed_nav_structure - self.parser.book_metadata = self.book_metadata - - logging.info(f"Using cached content for {os.path.basename(self.book_path)}") - return - - # Process content if not cached - try: - self.parser.process_content(replace_single_newlines=replace_single_newlines) - self.content_texts = self.parser.content_texts - self.content_lengths = self.parser.content_lengths - self.processed_nav_structure = self.parser.processed_nav_structure - self.book_metadata = self.parser.get_metadata() - except Exception as e: - logging.error(f"Error processing content: {e}", exc_info=True) - # Handle empty/failure case - self.content_texts = {} - self.content_lengths = {} - - # Cache the processed content - cache_data = { - "content_texts": self.content_texts, - "content_lengths": self.content_lengths, - "processed_nav_structure": self.processed_nav_structure, - "book_metadata": self.book_metadata, - } - - HandlerDialog._content_cache[cache_key] = cache_data - logging.info(f"Cached content for {os.path.basename(self.book_path)}") - - - def _build_tree(self): - self.treeWidget.clear() - - info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) - info_item.setData(0, Qt.ItemDataRole.UserRole, "info:bookinfo") - info_item.setFlags(info_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - font = info_item.font(0) - font.setBold(True) - info_item.setFont(0, font) - - # TODO look into why we need this here and not just rely on logic in PDFParser - if self.parser.file_type == "pdf": - self._build_pdf_tree() - elif self.processed_nav_structure: - self._build_tree_from_nav( - self.processed_nav_structure, self.treeWidget - ) - else: - # If no structure found but content exists (rare fallback), list flat - for ch_id, ch_len in self.content_lengths.items(): - # Simple flat list - item = QTreeWidgetItem(self.treeWidget, [ch_id]) - item.setData(0, Qt.ItemDataRole.UserRole, ch_id) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) - if self.content_texts.get(ch_id): - item.setCheckState(0, Qt.CheckState.Checked if ch_id in self.checked_chapters else Qt.CheckState.Unchecked) - - has_parents = False - iterator = QTreeWidgetItemIterator( - self.treeWidget, QTreeWidgetItemIterator.IteratorFlag.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_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.ItemDataRole.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.ItemFlag.ItemIsUserCheckable) - is_checked = src in self.checked_chapters - item.setCheckState( - 0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked - ) - elif is_duplicate: - # Mark as duplicate and remove checkbox - item.setText(0, f"{title} (Duplicate)") - item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - elif children: - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) - item.setCheckState(0, Qt.CheckState.Unchecked) - else: - item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - - if children: - self._build_tree_from_nav(children, item, seen_content_hashes) - - - # TODO look into why we need this here and not just rely on logic in PDFParser - def _build_pdf_tree(self): - pdf_doc = getattr(self.parser, "pdf_doc", None) - if not pdf_doc: - return - - outline = 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 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 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.ItemDataRole.UserRole, page_id) - child.setFlags(child.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - continue - bookmark_item = QTreeWidgetItem(parent_item, [title]) - bookmark_item.setData(0, Qt.ItemDataRole.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.ItemFlag.ItemIsUserCheckable - ) - bookmark_item.setCheckState( - 0, - ( - Qt.CheckState.Checked - if page_id in self.checked_chapters - else Qt.CheckState.Unchecked - ), - ) - else: - bookmark_item.setFlags( - bookmark_item.flags() & ~Qt.ItemFlag.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(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.ItemDataRole.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.ItemFlag.ItemIsUserCheckable - ) - page_item.setCheckState( - 0, - ( - Qt.CheckState.Checked - if page_id in self.checked_chapters - else Qt.CheckState.Unchecked - ), - ) - else: - page_item.setFlags( - page_item.flags() & ~Qt.ItemFlag.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(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.ItemDataRole.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.ItemFlag.ItemIsUserCheckable) - page_item.setCheckState( - 0, - ( - Qt.CheckState.Checked - if page_id in self.checked_chapters - else Qt.CheckState.Unchecked - ), - ) - else: - page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - - def _build_pdf_pages_tree(self): - pdf_doc = getattr(self.parser, "pdf_doc", None) - if not pdf_doc: - return - - pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"]) - pages_item.setFlags(pages_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - font = pages_item.font(0) - font.setBold(True) - pages_item.setFont(0, font) - - for page_num in range(len(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.ItemDataRole.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.ItemFlag.ItemIsUserCheckable) - page_item.setCheckState( - 0, - ( - Qt.CheckState.Checked - if page_id in self.checked_chapters - else Qt.CheckState.Unchecked - ), - ) - else: - page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.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.ItemFlag.ItemIsUserCheckable: - identifier = item.data(0, Qt.ItemDataRole.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.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, - self, - ) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - - item_type = "chapters" if self.parser.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.parser.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.Orientation.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.parser.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.parser.file_type in ["epub", "markdown"]: - iterator = QTreeWidgetItemIterator(self.treeWidget) - while iterator.value(): - item = iterator.value() - if ( - item.flags() & Qt.ItemFlag.ItemIsUserCheckable - and item.checkState(0) == Qt.CheckState.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.ItemFlag.ItemIsUserCheckable - and item.checkState(0) == Qt.CheckState.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.ItemFlag.ItemIsUserCheckable: - item.setCheckState(0, Qt.CheckState.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.ItemFlag.ItemIsUserCheckable: - item.setCheckState(0, Qt.CheckState.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.ItemFlag.ItemIsUserCheckable and item.childCount() > 0: - item.setCheckState(0, Qt.CheckState.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.ItemFlag.ItemIsUserCheckable and item.childCount() > 0: - item.setCheckState(0, Qt.CheckState.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.parser.file_type == "epub": - self._run_epub_auto_check() - elif self.parser.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.ItemFlag.ItemIsUserCheckable): - iterator += 1 - continue - - src = item.data(0, Qt.ItemDataRole.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.CheckState.Checked) - if is_parent: - for i in range(item.childCount()): - child = item.child(i) - if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: - child_src = child.data(0, Qt.ItemDataRole.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.CheckState.Checked) - else: - item.setCheckState(0, Qt.CheckState.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.ItemFlag.ItemIsUserCheckable): - iterator += 1 - continue - - identifier = item.data(0, Qt.ItemDataRole.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.CheckState.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.ItemFlag.ItemIsUserCheckable: - child_identifier = child.data(0, Qt.ItemDataRole.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.CheckState.Checked) - else: - item.setCheckState(0, Qt.CheckState.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.ItemFlag.ItemIsUserCheckable: - item.setCheckState(0, Qt.CheckState.Checked) - iterator += 1 - return - - iterator = QTreeWidgetItemIterator(self.treeWidget) - while iterator.value(): - item = iterator.value() - if not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable): - iterator += 1 - continue - - identifier = item.data(0, Qt.ItemDataRole.UserRole) - - if not identifier: - iterator += 1 - continue - - if ( - not identifier.startswith("page_") - or self.content_lengths.get(identifier, 0) > 0 - ): - item.setCheckState(0, Qt.CheckState.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.CheckState.Checked: - identifier = item.data(0, Qt.ItemDataRole.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.ItemFlag.ItemIsUserCheckable: - for i in range(item.childCount()): - child = item.child(i) - if child.flags() & Qt.ItemFlag.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.ItemFlag.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.CheckState.Unchecked - if item.checkState(0) == Qt.CheckState.Checked - else Qt.CheckState.Checked - ) - item.setCheckState(0, new_state) - - def update_preview(self, current): - if not current: - self.previewEdit.clear() - return - - identifier = current.data(0, Qt.ItemDataRole.UserRole) - - if identifier == "info:bookinfo": - self._display_book_info() - return - - text = None - if self.parser.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: - # Apply clean_text to preview so replace_single_newlines setting is respected - cleaned_text = clean_text(text) - self.previewEdit.setPlainText(cleaned_text) - - def _display_book_info(self): - self.previewEdit.clear() - html_content = "" - - cover_image = self.book_metadata.get("cover_image") - if cover_image: - try: - image_data = base64.b64encode(cover_image).decode("utf-8") - - image_type = "jpeg" - if cover_image.startswith(b"\x89PNG"): - image_type = "png" - elif cover_image.startswith(b"GIF"): - image_type = "gif" - - html_content += ( - f"
" - ) - html_content += ( - f"Error displaying cover image: {str(e)}

" - - title = self.book_metadata.get("title") - if title: - html_content += ( - f"

{title}

" - ) - - authors = self.book_metadata.get("authors") - if authors: - authors_text = ", ".join(authors) - html_content += f"

By {authors_text}

" - - publisher = self.book_metadata.get("publisher") - pub_year = self.book_metadata.get("publication_year") - - if publisher or pub_year: - pub_info = [] - if publisher: - pub_info.append(f"Published by {publisher}") - if pub_year: - pub_info.append(f"Year: {pub_year}") - html_content += f"

{' | '.join(pub_info)}

" - - html_content += "
" - - description = self.book_metadata.get("description") - if description: - # Use pre-compiled pattern for better performance - desc = _HTML_TAG_PATTERN.sub("", description) - html_content += f"

Description:

{desc}

" - - if self.parser.file_type == "pdf": - # Access pdf_doc from parser if available - pdf_doc = getattr(self.parser, "pdf_doc", None) - page_count = len(pdf_doc) if pdf_doc else 0 - html_content += f"

File type: PDF
Page count: {page_count}

" - - html_content += "" - 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.parser.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.parser.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.parser.file_type == "epub": - return self._get_epub_selected_text() - elif self.parser.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 - from abogen.utils import get_user_cache_path - - 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.parser.file_type == 'epub' else 'Pages'}" - ) - - # Handle cover image - cover_tag = "" - if metadata.get("cover_image"): - try: - import uuid - - cache_dir = get_user_cache_path() - cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg") - cover_path = os.path.normpath(cover_path) - with open(cover_path, "wb") as f: - f.write(metadata["cover_image"]) - cover_tag = f"<>" - except Exception as e: - logging.warning(f"Failed to save cover image: {e}") - - # Format metadata tags - metadata_tags = [ - f"<>", - f"<>", - f"<>", - f"<>", - f"<>", - f"<>", - f"<>", - ] - - if cover_tag: - metadata_tags.append(cover_tag) - - 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.CheckState.Checked: - identifier = item.data(0, Qt.ItemDataRole.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 using pre-compiled pattern - title = _LEADING_DASH_PATTERN.sub("", title).strip() - marker = f"<>" - 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.CheckState.Checked: - identifier = item.data(0, Qt.ItemDataRole.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) - # Use pre-compiled pattern for better performance - title = _LEADING_DASH_PATTERN.sub("", title).strip() - marker = f"<>" - 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.CheckState.Checked: - identifier = item.data(0, Qt.ItemDataRole.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.CheckState.Checked - parent_id = item.data(0, Qt.ItemDataRole.UserRole) - parent_title = item.text(0) - checked_children = [] - for i in range(item.childCount()): - child = item.child(i) - child_id = child.data(0, Qt.ItemDataRole.UserRole) - if ( - child.checkState(0) == Qt.CheckState.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(): - # Use pre-compiled pattern for better performance - title = _LEADING_SIMPLE_DASH_PATTERN.sub( - "", parent_title - ).strip() - marker = f"<>" - section_titles.append((title, marker + "\n" + combined_text)) - included_text_ids.add(parent_id) - elif not parent_checked and checked_children: - # Use pre-compiled pattern for better performance - title = _LEADING_SIMPLE_DASH_PATTERN.sub("", parent_title).strip() - marker = f"<>" - 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.ItemFlag.ItemIsUserCheckable: - identifier = item.data(0, Qt.ItemDataRole.UserRole) - if ( - identifier - and identifier not in included_text_ids - and item.checkState(0) == Qt.CheckState.Checked - ): - text = self.content_texts.get(identifier, "") - if text: - title = item.text(0) - # Use pre-compiled pattern for better performance - title = _LEADING_SIMPLE_DASH_PATTERN.sub("", title).strip() - marker = f"<>" - 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.ItemFlag.ItemIsUserCheckable: - item.setCheckState( - 0, Qt.CheckState.Checked if state else Qt.CheckState.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.ItemFlag.ItemIsUserCheckable) - ): - return - - menu = QMenu(self) - checked = item.checkState(0) == Qt.CheckState.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.CheckState.Unchecked if checked else Qt.CheckState.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)) - - -======= -__all__ = ["HandlerDialog"] ->>>>>>> main diff --git a/abogen/book_parser.py b/abogen/book_parser.py index cebecc1..028d100 100644 --- a/abogen/book_parser.py +++ b/abogen/book_parser.py @@ -110,41 +110,158 @@ class PdfParser(BaseBookParser): logging.error(f"Error loading PDF {self.book_path}: {e}") raise + def _extract_book_metadata(self): + # PDF metadata extraction can be added here if needed + # For now, base class metadata is empty dict + pass + def process_content(self, replace_single_newlines=True): if not self.pdf_doc: self.load() + # 1. Extract text from all pages first for page_num in range(len(self.pdf_doc)): text = clean_text(self.pdf_doc[page_num].get_text()) - + # Clean up common PDF artifacts: - # - Remove bracketed numbers often used for citations [1] text = _BRACKETED_NUMBERS_PATTERN.sub("", text) - # - Remove standalone page numbers often found in headers/footers text = _STANDALONE_PAGE_NUMBERS_PATTERN.sub("", text) - # - Remove page numbers at end of lines text = _PAGE_NUMBERS_AT_END_PATTERN.sub("", text) - # - Remove page numbers with dashes - 4 - text = _PAGE_NUMBERS_WITH_DASH_PATTERN.sub("", text) page_id = f"page_{page_num + 1}" self.content_texts[page_id] = text self.content_lengths[page_id] = calculate_text_length(text) + # 2. Build Navigation Structure + toc = self.pdf_doc.get_toc() + + if not toc: + # Fallback: Flat list of pages if no TOC + self.processed_nav_structure = [] + pages_node = { + "title": "Pages", + "src": None, + "children": [], + "has_content": False + } + # Add all pages as children + for page_num in range(len(self.pdf_doc)): + page_id = f"page_{page_num + 1}" + title = self._get_page_title(page_num, self.content_texts.get(page_id, "")) + pages_node["children"].append({ + "title": title, + "src": page_id, + "children": [], + "has_content": True + }) + self.processed_nav_structure.append(pages_node) + else: + self.processed_nav_structure = self._build_structure_from_toc(toc) + return self.content_texts, self.content_lengths - def _extract_book_metadata(self): - # PDF metadata extraction can be added here if needed - # For now, base class metadata is empty dict - pass + def _get_page_title(self, page_num, text): + title = f"Page {page_num + 1}" + if text: + first_line = text.split("\n", 1)[0].strip() + if first_line and len(first_line) < 100: + title += f" - {first_line}" + return title - def get_chapters(self): - # PDF specific implementation because it doesn't use nav structure - chapters = [] - if self.pdf_doc: - for i in range(len(self.pdf_doc)): - chapters.append((f"page_{i+1}", f"Page {i+1}")) - return chapters + def _build_structure_from_toc(self, toc): + # 1. Flatten TOC to easier list (page_num, title, level) + # fitz TOC is [[lvl, title, page, dest], ...] + + bookmarks = [] + for entry in toc: + lvl, title, page = entry[:3] + if isinstance(page, int): + page_idx = page - 1 + else: + # Handle potential complex destinations if necessary, but usually simple int + # PyMuPDF docs say int. + page_idx = -1 + + if page_idx >= 0: + bookmarks.append({"level": lvl, "title": title, "page": page_idx}) + + + root_children = [] + stack = [] # Stack of (level, list_to_append_to) + stack.append((0, root_children)) + + # Step 1: Build the Skeleton Tree from TOC + # And keep a flat list of these nodes to associate with pages. + + processed_nodes = [] # List of (page_idx, node_dict) + + for entry in bookmarks: + node = { + "title": entry["title"], + "src": f"page_{entry['page'] + 1}", + "children": [], + "has_content": True + } + + # Find parent + level = entry["level"] + + # Adjust stack + while stack and stack[-1][0] >= level: + stack.pop() + + parent_list = stack[-1][1] + parent_list.append(node) + + stack.append((level, node["children"])) + processed_nodes.append((entry["page"], node)) + + # Step 3: Add gap pages. + # Sort processed_nodes by page index to find ranges. + sorted_bookmarks = sorted(processed_nodes, key=lambda x: x[0]) + + # Set of pages that are "bookmarks" + bookmarked_pages = set(p for p, n in sorted_bookmarks) + + current_node = None + # We need a way to look up bookmarks starting at p + bookmarks_by_page = {} + for p, node in processed_nodes: + if p not in bookmarks_by_page: + bookmarks_by_page[p] = [] + bookmarks_by_page[p].append(node) + + + # Let's iterate. + for page_num in range(len(self.pdf_doc)): + page_id = f"page_{page_num + 1}" + + # Check if this page STARTS bookmarks + if page_num in bookmarks_by_page: + + starts = bookmarks_by_page[page_num] + current_node = starts[-1] + + continue + + # If page is NOT a bookmark, it's a "gap page". + # Add as child to current_node + title = self._get_page_title(page_num, self.content_texts.get(page_id, "")) + page_node = { + "title": title, + "src": page_id, + "children": [], + "has_content": True + } + + if current_node: + current_node["children"].append(page_node) + else: + # No preceding bookmark. Add to root. + root_children.append(page_node) + + return root_children class MarkdownParser(BaseBookParser): diff --git a/abogen/pyqt/book_handler.py b/abogen/pyqt/book_handler.py index 4fa63ba..fc1d806 100644 --- a/abogen/pyqt/book_handler.py +++ b/abogen/pyqt/book_handler.py @@ -394,10 +394,7 @@ class HandlerDialog(QDialog): font.setBold(True) info_item.setFont(0, font) - # TODO look into why we need this here and not just rely on logic in PDFParser - if self.parser.file_type == "pdf": - self._build_pdf_tree() - elif self.processed_nav_structure: + if self.processed_nav_structure: self._build_tree_from_nav( self.processed_nav_structure, self.treeWidget ) @@ -471,200 +468,6 @@ class HandlerDialog(QDialog): self._build_tree_from_nav(children, item, seen_content_hashes) - # TODO look into why we need this here and not just rely on logic in PDFParser - def _build_pdf_tree(self): - pdf_doc = getattr(self.parser, "pdf_doc", None) - if not pdf_doc: - return - - outline = 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 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 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.ItemDataRole.UserRole, page_id) - child.setFlags(child.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - continue - bookmark_item = QTreeWidgetItem(parent_item, [title]) - bookmark_item.setData(0, Qt.ItemDataRole.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.ItemFlag.ItemIsUserCheckable - ) - bookmark_item.setCheckState( - 0, - ( - Qt.CheckState.Checked - if page_id in self.checked_chapters - else Qt.CheckState.Unchecked - ), - ) - else: - bookmark_item.setFlags( - bookmark_item.flags() & ~Qt.ItemFlag.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(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.ItemDataRole.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.ItemFlag.ItemIsUserCheckable - ) - page_item.setCheckState( - 0, - ( - Qt.CheckState.Checked - if page_id in self.checked_chapters - else Qt.CheckState.Unchecked - ), - ) - else: - page_item.setFlags( - page_item.flags() & ~Qt.ItemFlag.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(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.ItemDataRole.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.ItemFlag.ItemIsUserCheckable) - page_item.setCheckState( - 0, - ( - Qt.CheckState.Checked - if page_id in self.checked_chapters - else Qt.CheckState.Unchecked - ), - ) - else: - page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - - def _build_pdf_pages_tree(self): - pdf_doc = getattr(self.parser, "pdf_doc", None) - if not pdf_doc: - return - - pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"]) - pages_item.setFlags(pages_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - font = pages_item.font(0) - font.setBold(True) - pages_item.setFont(0, font) - - for page_num in range(len(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.ItemDataRole.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.ItemFlag.ItemIsUserCheckable) - page_item.setCheckState( - 0, - ( - Qt.CheckState.Checked - if page_id in self.checked_chapters - else Qt.CheckState.Unchecked - ), - ) - else: - page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) - def _are_provided_checks_relevant(self): if not self.checked_chapters: return False @@ -989,15 +792,6 @@ class HandlerDialog(QDialog): 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.ItemFlag.ItemIsUserCheckable: - item.setCheckState(0, Qt.CheckState.Checked) - iterator += 1 - return - iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -1006,16 +800,13 @@ class HandlerDialog(QDialog): continue identifier = item.data(0, Qt.ItemDataRole.UserRole) - if not identifier: - iterator += 1 - continue - - if ( - not identifier.startswith("page_") - or self.content_lengths.get(identifier, 0) > 0 - ): - item.setCheckState(0, Qt.CheckState.Checked) + iterator += 1 + continue + + # Logic: Check item if it has content (already handled by ItemIsUserCheckable flag really) + # But duplicate logic from previous implementation: + item.setCheckState(0, Qt.CheckState.Checked) iterator += 1 diff --git a/tests/test_book_handler_regression.py b/tests/test_book_handler_regression.py index a2fd87d..eccfdc6 100644 --- a/tests/test_book_handler_regression.py +++ b/tests/test_book_handler_regression.py @@ -8,7 +8,7 @@ from PyQt6.QtWidgets import QApplication # Ensure we can import the module sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from abogen.book_handler import HandlerDialog +from abogen.pyqt.book_handler import HandlerDialog from ebooklib import epub # We need a QApplication instance for QWriter/QDialog diff --git a/tests/test_book_parser.py b/tests/test_book_parser.py index cff9d44..c8842fa 100644 --- a/tests/test_book_parser.py +++ b/tests/test_book_parser.py @@ -175,10 +175,11 @@ class TestBookParser(unittest.TestCase): """Test get_chapters returns correct list for different parsers.""" # PDF parser_pdf = get_book_parser(self.sample_pdf_path) + parser_pdf.process_content() chapters = parser_pdf.get_chapters() self.assertEqual(len(chapters), 2) - self.assertEqual(chapters[0], ("page_1", "Page 1")) - + self.assertEqual(chapters[0], ("page_1", "Page 1 - Page 1 content")) + # MD parser_md = get_book_parser(self.sample_md_path) parser_md.process_content() # Must process to get structure diff --git a/tests/test_pdf_structure.py b/tests/test_pdf_structure.py new file mode 100644 index 0000000..ce8dbee --- /dev/null +++ b/tests/test_pdf_structure.py @@ -0,0 +1,117 @@ +import unittest +import os +import shutil +import fitz # PyMuPDF +from abogen.book_parser import PdfParser + +class TestPdfStructure(unittest.TestCase): + + def setUp(self): + self.test_dir = "tests/test_data_pdf" + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + os.makedirs(self.test_dir) + self.pdf_path = os.path.join(self.test_dir, "structure_test.pdf") + + def tearDown(self): + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def test_pdf_structure_with_toc(self): + # Create PDF + doc = fitz.open() + p1 = doc.new_page() + p1.insert_text((50,50), "Page 1 Content") + p2 = doc.new_page() + p2.insert_text((50,50), "Page 2 Content") + p3 = doc.new_page() # Chapter 2 start + p3.insert_text((50,50), "Page 3 Content") + p4 = doc.new_page() + p4.insert_text((50,50), "Page 4 Content") + + # Add TOC: + # 1. "Chap 1" -> Page 1 + # 2. "Chap 2" -> Page 3 + doc.set_toc([[1, "Chap 1", 1], [1, "Chap 2", 3]]) + doc.save(self.pdf_path) + doc.close() + + parser = PdfParser(self.pdf_path) + parser.process_content() + + nav = parser.processed_nav_structure + + # Expect 2 top level items + self.assertEqual(len(nav), 2) + self.assertEqual(nav[0]['title'], "Chap 1") + self.assertEqual(nav[0]['src'], "page_1") + self.assertEqual(nav[1]['title'], "Chap 2") + self.assertEqual(nav[1]['src'], "page_3") + + # Check children of Chap 1 (Page 2 should be there) + children_c1 = nav[0]['children'] + self.assertEqual(len(children_c1), 1) + # The child should likely be titled "Page 2 - Page 2 Content" or similar + self.assertIn("Page 2", children_c1[0]['title']) + self.assertEqual(children_c1[0]['src'], "page_2") + + # Check children of Chap 2 (Page 4 should be there) + children_c2 = nav[1]['children'] + self.assertEqual(len(children_c2), 1) + self.assertIn("Page 4", children_c2[0]['title']) + self.assertEqual(children_c2[0]['src'], "page_4") + + def test_pdf_structure_without_toc(self): + # Create PDF without TOC + doc = fitz.open() + p1 = doc.new_page() + p1.insert_text((50,50), "Start") + p2 = doc.new_page() + p2.insert_text((50,50), "End") + doc.save(self.pdf_path) + doc.close() + + parser = PdfParser(self.pdf_path) + parser.process_content() + + nav = parser.processed_nav_structure + + # Expect 1 top level item (Pages) + self.assertEqual(len(nav), 1) + self.assertEqual(nav[0]['title'], "Pages") + + # Check children (all pages) + children = nav[0]['children'] + self.assertEqual(len(children), 2) + self.assertIn("Page 1", children[0]['title']) + self.assertIn("Page 2", children[1]['title']) + + def test_pdf_structure_nested_toc(self): + # Create PDF + doc = fitz.open() + p1 = doc.new_page() # Chap 1 + p2 = doc.new_page() # Sec 1.1 + p3 = doc.new_page() # Chap 2 + + doc.set_toc([ + [1, "Chap 1", 1], + [2, "Sec 1.1", 2], + [1, "Chap 2", 3] + ]) + doc.save(self.pdf_path) + doc.close() + + parser = PdfParser(self.pdf_path) + parser.process_content() + nav = parser.processed_nav_structure + + self.assertEqual(len(nav), 2) # Chap 1, Chap 2 + self.assertEqual(nav[0]['title'], "Chap 1") + + # Chap 1 should have child Sec 1.1 + self.assertEqual(len(nav[0]['children']), 1) + self.assertEqual(nav[0]['children'][0]['title'], "Sec 1.1") + self.assertEqual(nav[0]['children'][0]['src'], "page_2") + +if __name__ == "__main__": + unittest.main()