diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 975bcb8..4bb4a3c 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -43,18 +43,22 @@ class HandlerDialog(QDialog): super().__init__(parent) # Determine file type if not explicitly provided - self.file_type = file_type or ( - "pdf" if book_path.lower().endswith(".pdf") else "epub" - ) + 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 - self.setWindowTitle( - f'Select {"Chapters" if self.file_type == "epub" else "Pages"} - {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 @@ -69,7 +73,12 @@ class HandlerDialog(QDialog): # Load the book based on file type try: - self.book = epub.read_epub(book_path) if self.file_type == "epub" else None + 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." @@ -100,6 +109,15 @@ class HandlerDialog(QDialog): 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 = self._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 = "" # Extract book metadata self.book_metadata = self._extract_book_metadata() @@ -166,6 +184,17 @@ class HandlerDialog(QDialog): # Update checkbox states self._update_checkbox_states() + def _detect_encoding(self, file_path): + """Detect encoding of a text file""" + with open(file_path, "rb") as f: + raw_data = f.read() + try: + import chardet + result = chardet.detect(raw_data) + return result.get("encoding", "utf-8") + except ImportError: + return "utf-8" + def _preprocess_content(self): """Pre-process content from the document""" if self.file_type == "epub": @@ -178,6 +207,8 @@ class HandlerDialog(QDialog): ) # 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() @@ -199,10 +230,70 @@ class HandlerDialog(QDialog): # (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}" + 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): + """Pre-process markdown content and extract chapters/sections""" + if not self.markdown_text: + return + + # Split markdown by headers to create chapters + import re + + # Clean the text first + text = clean_text(self.markdown_text) + + # Find all markdown headers (# ## ### etc.) + header_pattern = r'^(#{1,6})\s+(.+)$' + headers = list(re.finditer(header_pattern, text, re.MULTILINE)) + + self.content_texts = {} + self.content_lengths = {} + + if not headers: + # No headers found, treat entire content as single chapter + chapter_id = "markdown_content" + self.content_texts[chapter_id] = text + self.content_lengths[chapter_id] = calculate_text_length(text) + return + + # Process headers and extract content between them + for i, header_match in enumerate(headers): + header_level = len(header_match.group(1)) # Number of # symbols + header_text = header_match.group(2).strip() + header_start = header_match.start() + + # Find content between this header and the next one of same or higher level + content_start = header_match.end() + content_end = len(text) + + # Look for next header of same or higher level (fewer # symbols) + for j in range(i + 1, len(headers)): + next_header = headers[j] + next_level = len(next_header.group(1)) + if next_level <= header_level: + content_end = next_header.start() + break + + # Extract content for this chapter + chapter_content = text[content_start:content_end].strip() + + # Create unique identifier for this chapter + chapter_id = f"markdown_chapter_{i + 1}" + + # Store the chapter content + if chapter_content: + # Include the header in the content + full_content = f"{header_text}\n\n{chapter_content}" + self.content_texts[chapter_id] = full_content + self.content_lengths[chapter_id] = calculate_text_length(full_content) + else: + # Even if no content, store the header + self.content_texts[chapter_id] = header_text + self.content_lengths[chapter_id] = calculate_text_length(header_text) + def _process_epub_content_spine_fallback(self): """Fallback EPUB processing based purely on spine order.""" logging.info("Using spine fallback for EPUB processing.") @@ -250,14 +341,14 @@ class HandlerDialog(QDialog): title = h1.get_text(strip=True) if not title: - title = f"Untitled Chapter {i+1}" + 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" + 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.") @@ -282,7 +373,7 @@ class HandlerDialog(QDialog): item for item in nav_items if "nav" in item.get_name().lower() - and item.get_name().lower().endswith((".xhtml", ".html")) + and item.get_name().lower().endswith((".xhtml", ".html")) ), None, ) @@ -395,8 +486,8 @@ class HandlerDialog(QDialog): 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"]) + 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") @@ -620,13 +711,13 @@ class HandlerDialog(QDialog): return None, None def _parse_ncx_navpoint( - self, - nav_point, - ordered_entries, - doc_order, - doc_order_decoded, - tree_structure_list, - find_position_func, + 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") @@ -678,19 +769,19 @@ class HandlerDialog(QDialog): ) if title and ( - current_entry_node.get("has_content", False) - or current_entry_node["children"] + 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, + 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) @@ -838,8 +929,8 @@ class HandlerDialog(QDialog): if self.file_type == "epub": if ( - hasattr(self, "processed_nav_structure") - and self.processed_nav_structure + hasattr(self, "processed_nav_structure") + and self.processed_nav_structure ): self._build_epub_tree_from_nav( self.processed_nav_structure, self.treeWidget @@ -847,6 +938,8 @@ class HandlerDialog(QDialog): 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() @@ -865,7 +958,7 @@ class HandlerDialog(QDialog): self._update_item_checkbox_state(item) def _build_epub_tree_from_nav( - self, nav_nodes, parent_item, seen_content_hashes=None + self, nav_nodes, parent_item, seen_content_hashes=None ): if seen_content_hashes is None: seen_content_hashes = set() @@ -878,9 +971,9 @@ class HandlerDialog(QDialog): item.setData(0, Qt.UserRole, src) is_empty = ( - src - and (src in self.content_texts) - and (not self.content_texts[src].strip()) + 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(): @@ -935,7 +1028,7 @@ class HandlerDialog(QDialog): item.setData(0, Qt.UserRole, href) has_content = ( - href and href in self.content_texts and self.content_texts[href].strip() + href and href in self.content_texts and self.content_texts[href].strip() ) if has_content or children: @@ -993,7 +1086,7 @@ class HandlerDialog(QDialog): if isinstance(page, int) else self.pdf_doc.resolve_link(page)[0] ) - page_id = f"page_{page_num+1}" + 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] @@ -1028,13 +1121,13 @@ class HandlerDialog(QDialog): 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 + 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_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: @@ -1077,8 +1170,8 @@ class HandlerDialog(QDialog): 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}" + 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() @@ -1095,6 +1188,63 @@ class HandlerDialog(QDialog): else: page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable) + def _build_markdown_tree(self): + """Build tree structure for markdown file based on headers""" + if not self.content_texts: + return + + # If only one content item (no headers), create simple structure + if len(self.content_texts) == 1: + 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 + + # Build hierarchical tree based on markdown headers + # First, parse the original markdown to get header structure + import re + header_pattern = r'^(#{1,6})\s+(.+)$' + headers = list(re.finditer(header_pattern, self.markdown_text, re.MULTILINE)) + + # Create tree items for each header + stack = [] # Stack to track parent items at different levels + + for i, header_match in enumerate(headers): + header_level = len(header_match.group(1)) + header_text = header_match.group(2).strip() + chapter_id = f"markdown_chapter_{i + 1}" + + # Pop stack until we find appropriate parent level + while stack and stack[-1][0] >= header_level: + stack.pop() + + # Determine parent item + parent_item = stack[-1][1] if stack else self.treeWidget + + # Create tree item + item = QTreeWidgetItem(parent_item, [header_text]) + item.setData(0, Qt.UserRole, chapter_id) + + # Set checkable state based on content + 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) + + # Add to stack for potential children + stack.append((header_level, item)) + def _build_pdf_pages_tree(self): pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"]) pages_item.setFlags(pages_item.flags() & ~Qt.ItemIsUserCheckable) @@ -1103,8 +1253,8 @@ class HandlerDialog(QDialog): 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_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: @@ -1166,7 +1316,7 @@ class HandlerDialog(QDialog): buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) - item_type = "chapters" if self.file_type == "epub" else "pages" + 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) @@ -1214,7 +1364,7 @@ class HandlerDialog(QDialog): checkbox_text = ( "Save each chapter separately" - if self.file_type == "epub" + if self.file_type in ["epub", "markdown"] else "Save each page separately" ) self.save_chapters_checkbox = QCheckBox(checkbox_text, self) @@ -1259,15 +1409,15 @@ class HandlerDialog(QDialog): def _update_checkbox_states(self): if ( - not hasattr(self, "save_chapters_checkbox") - or not self.save_chapters_checkbox + 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.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) @@ -1275,13 +1425,13 @@ class HandlerDialog(QDialog): checked_count = 0 - if self.file_type == "epub": + 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 + item.flags() & Qt.ItemIsUserCheckable + and item.checkState(0) == Qt.Checked ): checked_count += 1 if checked_count >= 2: @@ -1295,8 +1445,8 @@ class HandlerDialog(QDialog): while iterator.value(): item = iterator.value() if ( - item.flags() & Qt.ItemIsUserCheckable - and item.checkState(0) == Qt.Checked + item.flags() & Qt.ItemIsUserCheckable + and item.checkState(0) == Qt.Checked ): parent = item.parent() if parent and parent != self.treeWidget.invisibleRootItem(): @@ -1367,6 +1517,8 @@ class HandlerDialog(QDialog): 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() @@ -1394,7 +1546,41 @@ class HandlerDialog(QDialog): 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_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: @@ -1428,8 +1614,8 @@ class HandlerDialog(QDialog): continue if ( - not identifier.startswith("page_") - or self.content_lengths.get(identifier, 0) > 0 + not identifier.startswith("page_") + or self.content_lengths.get(identifier, 0) > 0 ): item.setCheckState(0, Qt.Checked) @@ -1540,7 +1726,7 @@ class HandlerDialog(QDialog): html_content += f"
By {authors_text}
" if self.book_metadata["publisher"] or self.book_metadata.get( - "publication_year" + "publication_year" ): pub_info = [] if self.book_metadata["publisher"]: @@ -1626,6 +1812,41 @@ class HandlerDialog(QDialog): 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"]: + first_h1_match = re.search(r'^#\s+(.+)$', self.markdown_text, re.MULTILINE) + if first_h1_match: + metadata["title"] = first_h1_match.group(1).strip() else: pdf_info = self.pdf_doc.metadata if pdf_info: @@ -1670,6 +1891,8 @@ class HandlerDialog(QDialog): def get_selected_text(self): 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() @@ -1687,7 +1910,7 @@ class HandlerDialog(QDialog): authors_text = ", ".join(authors) album_artist = authors_text or "Unknown" year = ( - metadata.get("publication_year") or current_year + metadata.get("publication_year") or current_year ) # Use publication year if available # Count chapters/pages @@ -1709,6 +1932,42 @@ class HandlerDialog(QDialog): 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"<