From 67dbb0a8fbd3473247fe9b0d4fd51f2109d71dca Mon Sep 17 00:00:00 2001 From: Xia Dong Date: Tue, 2 Sep 2025 15:14:13 +0800 Subject: [PATCH] feat: Add Markdown File Support for Audiobook Generation --- abogen/book_handler.py | 403 +++++++++++++++++++++++++++++++++-------- abogen/gui.py | 189 ++++++++++--------- 2 files changed, 438 insertions(+), 154 deletions(-) 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"<>" + 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 = [] @@ -1753,7 +2012,7 @@ class HandlerDialog(QDialog): metadata_tags = self._format_metadata_tags() pdf_has_no_bookmarks = ( - hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks + hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks ) iterator = QTreeWidgetItemIterator(self.treeWidget) @@ -1793,9 +2052,9 @@ class HandlerDialog(QDialog): 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 + 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: @@ -1824,9 +2083,9 @@ class HandlerDialog(QDialog): 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 + identifier + and identifier not in included_text_ids + and item.checkState(0) == Qt.Checked ): text = self.content_texts.get(identifier, "") if text: @@ -1896,9 +2155,9 @@ class HandlerDialog(QDialog): return if ( - not item - or item.childCount() == 0 - or not (item.flags() & Qt.ItemIsUserCheckable) + not item + or item.childCount() == 0 + or not (item.flags() & Qt.ItemIsUserCheckable) ): return diff --git a/abogen/gui.py b/abogen/gui.py index dac5ce0..14e6e8e 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -158,7 +158,7 @@ class InputBox(QLabel): self.setAlignment(Qt.AlignCenter) self.setAcceptDrops(True) self.setText( - "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf)" + "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md)" ) self.setStyleSheet( f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" @@ -251,9 +251,11 @@ class InputBox(QLabel): return str(n) if ( - file_path.lower().endswith(".epub") or file_path.lower().endswith(".pdf") + file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown")) ) and hasattr(self.window(), "selected_chapters"): - # EPUB or PDF: sum character counts for selected chapters + # EPUB, PDF, or Markdown: sum character counts for selected chapters try: book_path = file_path @@ -311,8 +313,8 @@ class InputBox(QLabel): # For epub/pdf files, show edit if we have a selected_file (temp txt) if ( - self.window().selected_file_type in ["epub", "pdf"] - and self.window().selected_file + self.window().selected_file_type in ["epub", "pdf"] + and self.window().selected_file ): should_show_edit = True @@ -345,7 +347,7 @@ class InputBox(QLabel): None # Reset the displayed file path when clearing input ) self.setText( - "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf)" + "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md)" ) self.setStyleSheet( f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" @@ -381,9 +383,10 @@ class InputBox(QLabel): if urls: ext = urls[0].toLocalFile().lower() if ( - ext.endswith(".txt") - or ext.endswith(".epub") - or ext.endswith(".pdf") + ext.endswith(".txt") + or ext.endswith(".epub") + or ext.endswith(".pdf") + or ext.endswith((".md", ".markdown")) ): event.acceptProposedAction() # Set hover style based on current state @@ -433,20 +436,26 @@ class InputBox(QLabel): ) self.set_file_info(file_path) event.acceptProposedAction() - elif file_path.lower().endswith(".epub") or file_path.lower().endswith( - ".pdf" - ): + elif (file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown"))): + # Determine file type + if file_path.lower().endswith(".epub"): + file_type = "epub" + elif file_path.lower().endswith(".pdf"): + file_type = "pdf" + else: + file_type = "markdown" + # Just store the file path but don't set the file info yet - win.selected_file_type = ( - "epub" if file_path.lower().endswith(".epub") else "pdf" - ) + win.selected_file_type = file_type win.selected_book_path = file_path win.open_book_file( file_path # This will handle the dialog and setting file info ) event.acceptProposedAction() else: - self.set_error("Please drop a .txt, .epub, or .pdf file.") + self.set_error("Please drop a .txt, .epub, .pdf, or .md file.") event.ignore() else: event.ignore() @@ -478,9 +487,9 @@ class InputBox(QLabel): file_to_check = win.selected_file if ( - file_to_check - and os.path.exists(file_to_check) - and os.path.isfile(file_to_check) + file_to_check + and os.path.exists(file_to_check) + and os.path.isfile(file_to_check) ): folder_path = os.path.dirname(file_to_check) QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) @@ -599,8 +608,8 @@ class TextboxDialog(QDialog): # This gives a better filename instead of the cache file path main_window = self.parent() if ( - hasattr(main_window, "displayed_file_path") - and main_window.displayed_file_path + hasattr(main_window, "displayed_file_path") + and main_window.displayed_file_path ): if main_window.selected_file_type in ["epub", "pdf"]: # Use the base name of the displayed file but change extension to .txt @@ -1155,16 +1164,21 @@ class abogen(QWidget): return try: file_path, _ = QFileDialog.getOpenFileName( - self, "Select File", "", "Supported Files (*.txt *.epub *.pdf)" + self, "Select File", "", "Supported Files (*.txt *.epub *.pdf *.md)" ) if not file_path: return - if file_path.lower().endswith(".epub") or file_path.lower().endswith( - ".pdf" - ): - self.selected_file_type = ( - "epub" if file_path.lower().endswith(".epub") else "pdf" - ) + if (file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown"))): + # Determine file type + if file_path.lower().endswith(".epub"): + self.selected_file_type = "epub" + elif file_path.lower().endswith(".pdf"): + self.selected_file_type = "pdf" + else: + self.selected_file_type = "markdown" + self.selected_book_path = file_path # Don't set file info immediately, open_book_file will handle it after dialog is accepted if not self.open_book_file(file_path): @@ -1183,14 +1197,17 @@ class abogen(QWidget): def open_book_file(self, book_path): # Clear selected chapters if this is a different book than the last one if ( - not hasattr(self, "last_opened_book_path") - or self.last_opened_book_path != book_path + not hasattr(self, "last_opened_book_path") + or self.last_opened_book_path != book_path ): self.selected_chapters = set() self.last_opened_book_path = book_path dialog = HandlerDialog( - book_path, checked_chapters=self.selected_chapters, parent=self + book_path, + file_type=getattr(self, 'selected_file_type', None), + checked_chapters=self.selected_chapters, + parent=self ) dialog.setWindowModality(Qt.NonModal) dialog.setModal(False) @@ -1201,10 +1218,18 @@ class abogen(QWidget): return False chapters_text, all_checked_hrefs = dialog.get_selected_text() if not all_checked_hrefs: - file_type = "pdf" if book_path.lower().endswith(".pdf") else "epub" - error_msg = ( - f"No {'pages' if file_type == 'pdf' else 'chapters'} selected." - ) + # Determine file type for error message + if book_path.lower().endswith(".pdf"): + file_type = "pdf" + item_type = "pages" + elif book_path.lower().endswith((".md", ".markdown")): + file_type = "markdown" + item_type = "chapters" + else: + file_type = "epub" + item_type = "chapters" + + error_msg = f"No {item_type} selected." self._show_error_message_box(f"{file_type.upper()} Error", error_msg) return False self.selected_chapters = all_checked_hrefs @@ -1322,9 +1347,9 @@ class abogen(QWidget): is_cache_file = get_user_cache_path() in file_path # Otherwise use selected_file if it's a txt file elif ( - self.selected_file_type == "txt" - and self.selected_file - and os.path.exists(self.selected_file) + self.selected_file_type == "txt" + and self.selected_file + and os.path.exists(self.selected_file) ): editing = True edit_file = self.selected_file @@ -1535,9 +1560,9 @@ class abogen(QWidget): def _get_queue_progress_format(self, value=None): """Return the progress bar format string for queue mode.""" if ( - hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") ): N = self.current_queue_index + 1 M = len(self.queued_items) @@ -1554,9 +1579,9 @@ class abogen(QWidget): self.progress_bar.setValue(value) # Show queue progress if in queue mode if ( - hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") ): N = self.current_queue_index + 1 M = len(self.queued_items) @@ -1646,16 +1671,16 @@ class abogen(QWidget): # Prevent adding duplicate items to the queue for queued_item in self.queued_items: if ( - queued_item.file_name == item_queue.file_name - and queued_item.lang_code == item_queue.lang_code - and queued_item.speed == item_queue.speed - and queued_item.voice == item_queue.voice - and queued_item.save_option == item_queue.save_option - and queued_item.output_folder == item_queue.output_folder - and queued_item.subtitle_mode == item_queue.subtitle_mode - and queued_item.output_format == item_queue.output_format - and getattr(queued_item, "replace_single_newlines", False) - == item_queue.replace_single_newlines + queued_item.file_name == item_queue.file_name + and queued_item.lang_code == item_queue.lang_code + and queued_item.speed == item_queue.speed + and queued_item.voice == item_queue.voice + and queued_item.save_option == item_queue.save_option + and queued_item.output_folder == item_queue.output_folder + and queued_item.subtitle_mode == item_queue.subtitle_mode + and queued_item.output_format == item_queue.output_format + and getattr(queued_item, "replace_single_newlines", False) + == item_queue.replace_single_newlines ): QMessageBox.warning( self, "Duplicate Item", "This item is already in the queue." @@ -1764,10 +1789,10 @@ class abogen(QWidget): self.progress_bar.setValue(0) # Show queue progress if in queue mode if ( - from_queue - and hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") + from_queue + and hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") ): N = self.current_queue_index + 1 M = len(self.queued_items) @@ -1851,7 +1876,7 @@ class abogen(QWidget): ) # Pass chapter count for EPUB or PDF files if self.selected_file_type in ["epub", "pdf"] and hasattr( - self, "selected_chapters" + self, "selected_chapters" ): self.conversion_thread.chapter_count = len(self.selected_chapters) # Pass save_chapters_separately flag if available @@ -1994,8 +2019,8 @@ class abogen(QWidget): # Only show finish_widget if queue is done if ( - self.current_queue_index + 1 >= len(self.queued_items) - or not self.queued_items + self.current_queue_index + 1 >= len(self.queued_items) + or not self.queued_items ): # Queue finished, show finish screen self.controls_widget.hide() @@ -2360,14 +2385,14 @@ class abogen(QWidget): def cleanup(): # Only remove if not from cache AND it's a temp file from VoicePreviewThread if ( - not from_cache - and hasattr(self, "preview_thread") - and hasattr(self.preview_thread, "temp_wav") - and self.preview_thread.temp_wav == temp_wav + not from_cache + and hasattr(self, "preview_thread") + and hasattr(self.preview_thread, "temp_wav") + and self.preview_thread.temp_wav == temp_wav ): try: if os.path.exists( - temp_wav + temp_wav ): # Ensure it exists before trying to remove os.remove(temp_wav) except Exception: @@ -2441,8 +2466,8 @@ class abogen(QWidget): return try: if ( - hasattr(self, "conversion_thread") - and self.conversion_thread.isRunning() + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() ): if not hasattr(self, "_conversion_lock"): self._conversion_lock = threading.Lock() @@ -2529,9 +2554,9 @@ class abogen(QWidget): def cleanup_conversion_thread(self): # Stop conversion thread if ( - hasattr(self, "conversion_thread") - and self.conversion_thread is not None - and self.conversion_thread.isRunning() + hasattr(self, "conversion_thread") + and self.conversion_thread is not None + and self.conversion_thread.isRunning() ): self.conversion_thread.cancel() self.conversion_thread.wait() @@ -2566,8 +2591,8 @@ class abogen(QWidget): if dialog.exec_() == QDialog.Accepted: options = dialog.get_options() if ( - hasattr(self, "conversion_thread") - and self.conversion_thread.isRunning() + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() ): self.conversion_thread.set_chapter_options(options) else: @@ -2587,8 +2612,8 @@ class abogen(QWidget): import winreg with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", ) as key: value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") return value == 0 @@ -2684,7 +2709,7 @@ class abogen(QWidget): # Main logic dark_mode = theme == "dark" or ( - theme == "system" and is_windows and is_windows_dark_mode() + theme == "system" and is_windows and is_windows_dark_mode() ) if dark_mode: app.setStyle("Fusion") @@ -2720,7 +2745,7 @@ class abogen(QWidget): def get_dark_mode(): return theme == "dark" or ( - theme == "system" and is_windows and is_windows_dark_mode() + theme == "system" and is_windows and is_windows_dark_mode() ) app._dark_titlebar_event_filter = DarkTitleBarEventFilter( @@ -3255,8 +3280,8 @@ Categories=AudioVideo;Audio;Utility; # Reset flag to track if we should show "no updates" message show_result = ( - hasattr(self, "_show_update_check_result") - and self._show_update_check_result + hasattr(self, "_show_update_check_result") + and self._show_update_check_result ) self._show_update_check_result = False @@ -3383,9 +3408,9 @@ Categories=AudioVideo;Audio;Utility; # If currently selected file is in the cache directory, clear the UI if ( - self.selected_file - and os.path.dirname(self.selected_file) == cache_dir - and self.selected_file.endswith(".txt") + self.selected_file + and os.path.dirname(self.selected_file) == cache_dir + and self.selected_file.endswith(".txt") ): self.input_box.clear_input()