Reformat using black

This commit is contained in:
Deniz Şafak
2025-10-27 17:26:54 +03:00
parent 9d2e8bed00
commit 47fe6341b4
7 changed files with 701 additions and 381 deletions
+154 -77
View File
@@ -32,7 +32,12 @@ from PyQt6.QtCore import (
QPoint, QPoint,
QRect, QRect,
) )
from abogen.utils import clean_text, calculate_text_length, detect_encoding, get_resource_path from abogen.utils import (
clean_text,
calculate_text_length,
detect_encoding,
get_resource_path,
)
import os import os
import logging # Add logging import logging # Add logging
import urllib.parse import urllib.parse
@@ -58,6 +63,7 @@ class HandlerDialog(QDialog):
class _LoaderThread(QThread): class _LoaderThread(QThread):
"""Minimal QThread that runs a callable and emits an error string on exception.""" """Minimal QThread that runs a callable and emits an error string on exception."""
error = pyqtSignal(str) error = pyqtSignal(str)
def __init__(self, target_callable): def __init__(self, target_callable):
@@ -77,7 +83,9 @@ class HandlerDialog(QDialog):
cls._content_cache.clear() cls._content_cache.clear()
logging.info("Cleared all content cache") logging.info("Cleared all content cache")
else: else:
keys_to_remove = [key for key in cls._content_cache.keys() if key[0] == book_path] keys_to_remove = [
key for key in cls._content_cache.keys() if key[0] == book_path
]
for key in keys_to_remove: for key in keys_to_remove:
del cls._content_cache[key] del cls._content_cache[key]
if keys_to_remove: if keys_to_remove:
@@ -102,12 +110,14 @@ class HandlerDialog(QDialog):
# Set window title based on file type and book name # Set window title based on file type and book name
item_type = "Chapters" if self.file_type in ["epub", "markdown"] else "Pages" item_type = "Chapters" if self.file_type in ["epub", "markdown"] else "Pages"
self.setWindowTitle(f'Select {item_type} - {book_name}') self.setWindowTitle(f"Select {item_type} - {book_name}")
self.resize(1200, 900) self.resize(1200, 900)
self._block_signals = False # Flag to prevent recursive signals self._block_signals = False # Flag to prevent recursive signals
# Configure window: remove help button and allow resizing # Configure window: remove help button and allow resizing
self.setWindowFlags( self.setWindowFlags(
Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowMaximizeButtonHint Qt.WindowType.Window
| Qt.WindowType.WindowCloseButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
) )
self.setWindowModality(Qt.WindowModality.NonModal) self.setWindowModality(Qt.WindowModality.NonModal)
# Initialize save chapters flags from class variables # Initialize save chapters flags from class variables
@@ -199,7 +209,7 @@ class HandlerDialog(QDialog):
# Create a centered loading overlay and show it while background load runs # Create a centered loading overlay and show it while background load runs
self._create_loading_overlay() self._create_loading_overlay()
# Hide the main UI so only the overlay is visible initially # Hide the main UI so only the overlay is visible initially
if getattr(self, 'splitter', None) is not None: if getattr(self, "splitter", None) is not None:
self.splitter.setVisible(False) self.splitter.setVisible(False)
self._show_loading_overlay("Loading...") self._show_loading_overlay("Loading...")
@@ -214,8 +224,6 @@ class HandlerDialog(QDialog):
break break
self.treeWidget.setRootIsDecorated(has_parents) self.treeWidget.setRootIsDecorated(has_parents)
def _create_loading_overlay(self): def _create_loading_overlay(self):
"""Create a centered loading indicator with a GIF on the left and text on the right. """Create a centered loading indicator with a GIF on the left and text on the right.
@@ -277,10 +285,10 @@ class HandlerDialog(QDialog):
self._loading_movie = None self._loading_movie = None
def _show_loading_overlay(self, text: str): def _show_loading_overlay(self, text: str):
container = getattr(self, '_loading_container', None) container = getattr(self, "_loading_container", None)
text_lbl = getattr(self, '_loading_text_label', None) text_lbl = getattr(self, "_loading_text_label", None)
movie = getattr(self, '_loading_movie', None) movie = getattr(self, "_loading_movie", None)
gif_lbl = getattr(self, '_loading_gif_label', None) gif_lbl = getattr(self, "_loading_gif_label", None)
if container is None or text_lbl is None: if container is None or text_lbl is None:
return return
text_lbl.setText(text) text_lbl.setText(text)
@@ -293,8 +301,8 @@ class HandlerDialog(QDialog):
container.setVisible(True) container.setVisible(True)
def _hide_loading_overlay(self): def _hide_loading_overlay(self):
container = getattr(self, '_loading_container', None) container = getattr(self, "_loading_container", None)
movie = getattr(self, '_loading_movie', None) movie = getattr(self, "_loading_movie", None)
if container is None: if container is None:
return return
if movie is not None: if movie is not None:
@@ -304,7 +312,6 @@ class HandlerDialog(QDialog):
pass pass
container.setVisible(False) container.setVisible(False)
def _start_background_load(self): def _start_background_load(self):
"""Start a QThread that runs the preprocessing in background.""" """Start a QThread that runs the preprocessing in background."""
# Start a minimal QThread which executes _preprocess_content # Start a minimal QThread which executes _preprocess_content
@@ -317,9 +324,9 @@ class HandlerDialog(QDialog):
def _on_load_error(self, err_msg): def _on_load_error(self, err_msg):
logging.error(f"Error loading book in background: {err_msg}") logging.error(f"Error loading book in background: {err_msg}")
if getattr(self, 'previewEdit', None) is not None: if getattr(self, "previewEdit", None) is not None:
self.previewEdit.setPlainText(f"Error loading book: {err_msg}") self.previewEdit.setPlainText(f"Error loading book: {err_msg}")
if getattr(self, 'splitter', None) is not None: if getattr(self, "splitter", None) is not None:
self.splitter.setVisible(True) self.splitter.setVisible(True)
self._hide_loading_overlay() self._hide_loading_overlay()
@@ -337,7 +344,9 @@ class HandlerDialog(QDialog):
# Connect signals (after tree exists) # Connect signals (after tree exists)
self.treeWidget.currentItemChanged.connect(self.update_preview) self.treeWidget.currentItemChanged.connect(self.update_preview)
self.treeWidget.itemChanged.connect(self.handle_item_check) self.treeWidget.itemChanged.connect(self.handle_item_check)
self.treeWidget.itemChanged.connect(lambda _: self._update_checkbox_states()) self.treeWidget.itemChanged.connect(
lambda _: self._update_checkbox_states()
)
self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click) self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click)
# Expand and select first item # Expand and select first item
@@ -356,7 +365,7 @@ class HandlerDialog(QDialog):
except Exception as e: except Exception as e:
logging.error(f"Error finalizing book load: {e}") logging.error(f"Error finalizing book load: {e}")
# Show the main UI and hide loading text # Show the main UI and hide loading text
if getattr(self, 'splitter', None) is not None: if getattr(self, "splitter", None) is not None:
self.splitter.setVisible(True) self.splitter.setVisible(True)
self._hide_loading_overlay() self._hide_loading_overlay()
@@ -373,12 +382,12 @@ class HandlerDialog(QDialog):
# Check if content is already cached # Check if content is already cached
if cache_key in HandlerDialog._content_cache: if cache_key in HandlerDialog._content_cache:
cached_data = HandlerDialog._content_cache[cache_key] cached_data = HandlerDialog._content_cache[cache_key]
self.content_texts = cached_data['content_texts'] self.content_texts = cached_data["content_texts"]
self.content_lengths = cached_data['content_lengths'] self.content_lengths = cached_data["content_lengths"]
if 'doc_content' in cached_data: if "doc_content" in cached_data:
self.doc_content = cached_data['doc_content'] self.doc_content = cached_data["doc_content"]
if 'markdown_toc' in cached_data: if "markdown_toc" in cached_data:
self.markdown_toc = cached_data['markdown_toc'] self.markdown_toc = cached_data["markdown_toc"]
logging.info(f"Using cached content for {os.path.basename(self.book_path)}") logging.info(f"Using cached content for {os.path.basename(self.book_path)}")
return return
@@ -400,13 +409,13 @@ class HandlerDialog(QDialog):
# Cache the processed content # Cache the processed content
cache_data = { cache_data = {
'content_texts': self.content_texts, "content_texts": self.content_texts,
'content_lengths': self.content_lengths, "content_lengths": self.content_lengths,
} }
if hasattr(self, 'doc_content'): if hasattr(self, "doc_content"):
cache_data['doc_content'] = self.doc_content cache_data["doc_content"] = self.doc_content
if hasattr(self, 'markdown_toc'): if hasattr(self, "markdown_toc"):
cache_data['markdown_toc'] = self.markdown_toc cache_data["markdown_toc"] = self.markdown_toc
HandlerDialog._content_cache[cache_key] = cache_data HandlerDialog._content_cache[cache_key] = cache_data
logging.info(f"Cached content for {os.path.basename(self.book_path)}") logging.info(f"Cached content for {os.path.basename(self.book_path)}")
@@ -433,7 +442,6 @@ class HandlerDialog(QDialog):
self.content_texts[page_id] = text self.content_texts[page_id] = text
self.content_lengths[page_id] = calculate_text_length(text) self.content_lengths[page_id] = calculate_text_length(text)
def _preprocess_markdown_content(self): def _preprocess_markdown_content(self):
if not self.markdown_text: if not self.markdown_text:
return return
@@ -441,14 +449,14 @@ class HandlerDialog(QDialog):
# Generate TOC from the original (dedented) markdown BEFORE cleaning, # Generate TOC from the original (dedented) markdown BEFORE cleaning,
# so header ids/anchors are preserved for reliable position detection. # so header ids/anchors are preserved for reliable position detection.
original_text = textwrap.dedent(self.markdown_text) original_text = textwrap.dedent(self.markdown_text)
md = markdown.Markdown(extensions=['toc', 'fenced_code']) md = markdown.Markdown(extensions=["toc", "fenced_code"])
html = md.convert(original_text) html = md.convert(original_text)
self.markdown_toc = md.toc_tokens self.markdown_toc = md.toc_tokens
# Use cleaned text for stored content/length calculations # Use cleaned text for stored content/length calculations
cleaned_full_text = clean_text(original_text) cleaned_full_text = clean_text(original_text)
soup = BeautifulSoup(html, 'html.parser') soup = BeautifulSoup(html, "html.parser")
self.content_texts = {} self.content_texts = {}
self.content_lengths = {} self.content_lengths = {}
@@ -459,35 +467,39 @@ class HandlerDialog(QDialog):
return return
all_headers = [] all_headers = []
def flatten_toc(toc_list): def flatten_toc(toc_list):
for header in toc_list: for header in toc_list:
all_headers.append(header) all_headers.append(header)
if header.get('children'): if header.get("children"):
flatten_toc(header['children']) flatten_toc(header["children"])
flatten_toc(self.markdown_toc) flatten_toc(self.markdown_toc)
header_positions = [] header_positions = []
for header in all_headers: for header in all_headers:
header_id = header['id'] header_id = header["id"]
id_pattern = f'id="{header_id}"' id_pattern = f'id="{header_id}"'
pos = html.find(id_pattern) pos = html.find(id_pattern)
if pos != -1: if pos != -1:
tag_start = html.rfind('<', 0, pos) tag_start = html.rfind("<", 0, pos)
header_positions.append({ header_positions.append(
'id': header_id, {"id": header_id, "start": tag_start, "name": header["name"]}
'start': tag_start, )
'name': header['name'] header_positions.sort(key=lambda x: x["start"])
})
header_positions.sort(key=lambda x: x['start'])
for i, header_pos in enumerate(header_positions): for i, header_pos in enumerate(header_positions):
header_id = header_pos['id'] header_id = header_pos["id"]
header_name = header_pos['name'] header_name = header_pos["name"]
content_start = header_pos['start'] content_start = header_pos["start"]
content_end = header_positions[i + 1]['start'] if i + 1 < len(header_positions) else len(html) content_end = (
header_positions[i + 1]["start"]
if i + 1 < len(header_positions)
else len(html)
)
section_html = html[content_start:content_end] section_html = html[content_start:content_end]
section_soup = BeautifulSoup(section_html, 'html.parser') section_soup = BeautifulSoup(section_html, "html.parser")
header_tag = section_soup.find(attrs={'id': header_id}) header_tag = section_soup.find(attrs={"id": header_id})
if header_tag: if header_tag:
header_tag.decompose() header_tag.decompose()
# Clean section text for storage/lengths # Clean section text for storage/lengths
@@ -1223,7 +1235,9 @@ class HandlerDialog(QDialog):
if src and not is_empty and not is_duplicate: if src and not is_empty and not is_duplicate:
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
is_checked = src in self.checked_chapters is_checked = src in self.checked_chapters
item.setCheckState(0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked) item.setCheckState(
0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked
)
elif is_duplicate: elif is_duplicate:
# Mark as duplicate and remove checkbox # Mark as duplicate and remove checkbox
item.setText(0, f"{title} (Duplicate)") item.setText(0, f"{title} (Duplicate)")
@@ -1271,7 +1285,9 @@ class HandlerDialog(QDialog):
if has_content or children: if has_content or children:
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
is_checked = href and href in self.checked_chapters is_checked = href and href in self.checked_chapters
item.setCheckState(0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked) item.setCheckState(
0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked
)
else: else:
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
@@ -1420,7 +1436,12 @@ class HandlerDialog(QDialog):
if self.content_lengths.get(page_id, 0) > 0: if self.content_lengths.get(page_id, 0) > 0:
page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
page_item.setCheckState( page_item.setCheckState(
0, Qt.CheckState.Checked if page_id in self.checked_chapters else Qt.CheckState.Unchecked 0,
(
Qt.CheckState.Checked
if page_id in self.checked_chapters
else Qt.CheckState.Unchecked
),
) )
else: else:
page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
@@ -1440,31 +1461,45 @@ class HandlerDialog(QDialog):
if self.content_lengths.get(chapter_id, 0) > 0: if self.content_lengths.get(chapter_id, 0) > 0:
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
is_checked = chapter_id in self.checked_chapters is_checked = chapter_id in self.checked_chapters
item.setCheckState(0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked) item.setCheckState(
0,
(
Qt.CheckState.Checked
if is_checked
else Qt.CheckState.Unchecked
),
)
else: else:
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
return return
def build_from_toc(toc_list, parent_item): def build_from_toc(toc_list, parent_item):
for header in toc_list: for header in toc_list:
title = header['name'] title = header["name"]
chapter_id = header['id'] chapter_id = header["id"]
item = QTreeWidgetItem(parent_item, [title]) item = QTreeWidgetItem(parent_item, [title])
item.setData(0, Qt.ItemDataRole.UserRole, chapter_id) item.setData(0, Qt.ItemDataRole.UserRole, chapter_id)
has_content = self.content_lengths.get(chapter_id, 0) > 0 has_content = self.content_lengths.get(chapter_id, 0) > 0
has_children = bool(header.get('children')) has_children = bool(header.get("children"))
if has_content or has_children: if has_content or has_children:
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
is_checked = chapter_id in self.checked_chapters is_checked = chapter_id in self.checked_chapters
item.setCheckState(0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked) item.setCheckState(
0,
(
Qt.CheckState.Checked
if is_checked
else Qt.CheckState.Unchecked
),
)
else: else:
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
if has_children: if has_children:
build_from_toc(header['children'], item) build_from_toc(header["children"], item)
build_from_toc(self.markdown_toc, self.treeWidget) build_from_toc(self.markdown_toc, self.treeWidget)
@@ -1491,7 +1526,12 @@ class HandlerDialog(QDialog):
if self.content_lengths.get(page_id, 0) > 0: if self.content_lengths.get(page_id, 0) > 0:
page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
page_item.setCheckState( page_item.setCheckState(
0, Qt.CheckState.Checked if page_id in self.checked_chapters else Qt.CheckState.Unchecked 0,
(
Qt.CheckState.Checked
if page_id in self.checked_chapters
else Qt.CheckState.Unchecked
),
) )
else: else:
page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
@@ -1535,7 +1575,10 @@ class HandlerDialog(QDialog):
rightWidget = QWidget() rightWidget = QWidget()
rightWidget.setLayout(previewLayout) rightWidget.setLayout(previewLayout)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self) buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
self,
)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@@ -1791,7 +1834,9 @@ class HandlerDialog(QDialog):
identifier = item.data(0, Qt.ItemDataRole.UserRole) identifier = item.data(0, Qt.ItemDataRole.UserRole)
# Select chapters with content > 500 characters or parent items # Select chapters with content > 500 characters or parent items
has_significant_content = identifier and self.content_lengths.get(identifier, 0) > 500 has_significant_content = (
identifier and self.content_lengths.get(identifier, 0) > 500
)
is_parent = item.childCount() > 0 is_parent = item.childCount() > 0
if has_significant_content or is_parent: if has_significant_content or is_parent:
@@ -1803,7 +1848,8 @@ class HandlerDialog(QDialog):
if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: if child.flags() & Qt.ItemFlag.ItemIsUserCheckable:
child_identifier = child.data(0, Qt.ItemDataRole.UserRole) child_identifier = child.data(0, Qt.ItemDataRole.UserRole)
child_has_content = ( child_has_content = (
child_identifier and self.content_lengths.get(child_identifier, 0) > 0 child_identifier
and self.content_lengths.get(child_identifier, 0) > 0
) )
child_is_parent = child.childCount() > 0 child_is_parent = child.childCount() > 0
if child_has_content or child_is_parent: if child_has_content or child_is_parent:
@@ -1881,7 +1927,9 @@ class HandlerDialog(QDialog):
if mouse_pos.x() > rect.x() + checkbox_width: if mouse_pos.x() > rect.x() + checkbox_width:
new_state = ( new_state = (
Qt.CheckState.Unchecked if item.checkState(0) == Qt.CheckState.Checked else Qt.CheckState.Checked Qt.CheckState.Unchecked
if item.checkState(0) == Qt.CheckState.Checked
else Qt.CheckState.Checked
) )
item.setCheckState(0, new_state) item.setCheckState(0, new_state)
@@ -2039,27 +2087,49 @@ class HandlerDialog(QDialog):
# Extract metadata from markdown frontmatter or first heading # Extract metadata from markdown frontmatter or first heading
if self.markdown_text: if self.markdown_text:
# Try to extract YAML frontmatter # Try to extract YAML frontmatter
frontmatter_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', self.markdown_text, re.DOTALL) frontmatter_match = re.match(
r"^---\s*\n(.*?)\n---\s*\n", self.markdown_text, re.DOTALL
)
if frontmatter_match: if frontmatter_match:
try: try:
frontmatter = frontmatter_match.group(1) frontmatter = frontmatter_match.group(1)
# Simple YAML-like parsing for common fields # Simple YAML-like parsing for common fields
title_match = re.search(r'^title:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE) title_match = re.search(
r"^title:\s*(.+)$",
frontmatter,
re.MULTILINE | re.IGNORECASE,
)
if title_match: if title_match:
metadata["title"] = title_match.group(1).strip().strip('"\'') metadata["title"] = (
title_match.group(1).strip().strip("\"'")
)
author_match = re.search(r'^author:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE) author_match = re.search(
r"^author:\s*(.+)$",
frontmatter,
re.MULTILINE | re.IGNORECASE,
)
if author_match: if author_match:
metadata["authors"] = [author_match.group(1).strip().strip('"\'')] metadata["authors"] = [
author_match.group(1).strip().strip("\"'")
]
desc_match = re.search(r'^description:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE) desc_match = re.search(
r"^description:\s*(.+)$",
frontmatter,
re.MULTILINE | re.IGNORECASE,
)
if desc_match: if desc_match:
metadata["description"] = desc_match.group(1).strip().strip('"\'') metadata["description"] = (
desc_match.group(1).strip().strip("\"'")
)
date_match = re.search(r'^date:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE) date_match = re.search(
r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
)
if date_match: if date_match:
date_str = date_match.group(1).strip().strip('"\'') date_str = date_match.group(1).strip().strip("\"'")
year_match = re.search(r'\b(19|20)\d{2}\b', date_str) year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match: if year_match:
metadata["publication_year"] = year_match.group(0) metadata["publication_year"] = year_match.group(0)
except Exception as e: except Exception as e:
@@ -2068,9 +2138,11 @@ class HandlerDialog(QDialog):
# Fallback: use first H1 header as title if no frontmatter title # Fallback: use first H1 header as title if no frontmatter title
if not metadata["title"] and self.markdown_toc: if not metadata["title"] and self.markdown_toc:
# Find the first level 1 header # Find the first level 1 header
first_h1 = next((h for h in self.markdown_toc if h['level'] == 1), None) first_h1 = next(
(h for h in self.markdown_toc if h["level"] == 1), None
)
if first_h1: if first_h1:
metadata["title"] = first_h1['name'] metadata["title"] = first_h1["name"]
else: else:
pdf_info = self.pdf_doc.metadata pdf_info = self.pdf_doc.metadata
if pdf_info: if pdf_info:
@@ -2117,7 +2189,10 @@ class HandlerDialog(QDialog):
# preserve compatibility with callers that expect content to be ready # preserve compatibility with callers that expect content to be ready
# when they create a HandlerDialog and immediately request selected text. # when they create a HandlerDialog and immediately request selected text.
try: try:
if hasattr(self, '_loader_thread') and getattr(self, '_loader_thread') is not None: if (
hasattr(self, "_loader_thread")
and getattr(self, "_loader_thread") is not None
):
# Wait for thread to finish (blocks until done) # Wait for thread to finish (blocks until done)
if self._loader_thread.isRunning(): if self._loader_thread.isRunning():
self._loader_thread.wait() self._loader_thread.wait()
@@ -2374,7 +2449,9 @@ class HandlerDialog(QDialog):
self.treeWidget.blockSignals(True) self.treeWidget.blockSignals(True)
for item in self.treeWidget.selectedItems(): for item in self.treeWidget.selectedItems():
if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: if item.flags() & Qt.ItemFlag.ItemIsUserCheckable:
item.setCheckState(0, Qt.CheckState.Checked if state else Qt.CheckState.Unchecked) item.setCheckState(
0, Qt.CheckState.Checked if state else Qt.CheckState.Unchecked
)
self.treeWidget.blockSignals(False) self.treeWidget.blockSignals(False)
self._update_checked_set_from_tree() self._update_checked_set_from_tree()
+88 -35
View File
@@ -6,7 +6,12 @@ from platformdirs import user_desktop_dir
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import soundfile as sf import soundfile as sf
from abogen.utils import clean_text, create_process, get_user_cache_path, detect_encoding from abogen.utils import (
clean_text,
create_process,
get_user_cache_path,
detect_encoding,
)
from abogen.constants import ( from abogen.constants import (
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
SAMPLE_VOICE_TEXTS, SAMPLE_VOICE_TEXTS,
@@ -47,43 +52,45 @@ def sanitize_name_for_os(name, is_folder=True):
if system == "Windows": if system == "Windows":
# Windows illegal characters: < > : " / \ | ? * # Windows illegal characters: < > : " / \ | ? *
# Also can't end with space or dot # Also can't end with space or dot
sanitized = re.sub(r'[<>:"/\\|?*]', '_', name) sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
# Remove control characters (0-31) # Remove control characters (0-31)
sanitized = re.sub(r'[\x00-\x1f]', '_', sanitized) sanitized = re.sub(r"[\x00-\x1f]", "_", sanitized)
# Remove trailing spaces and dots # Remove trailing spaces and dots
sanitized = sanitized.rstrip('. ') sanitized = sanitized.rstrip(". ")
# Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) # Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
reserved = ['CON', 'PRN', 'AUX', 'NUL'] + \ reserved = (
[f'COM{i}' for i in range(1, 10)] + \ ["CON", "PRN", "AUX", "NUL"]
[f'LPT{i}' for i in range(1, 10)] + [f"COM{i}" for i in range(1, 10)]
if sanitized.upper() in reserved or sanitized.upper().split('.')[0] in reserved: + [f"LPT{i}" for i in range(1, 10)]
)
if sanitized.upper() in reserved or sanitized.upper().split(".")[0] in reserved:
sanitized = f"_{sanitized}" sanitized = f"_{sanitized}"
elif system == "Darwin": # macOS elif system == "Darwin": # macOS
# macOS illegal characters: : (colon is converted to / by the system) # macOS illegal characters: : (colon is converted to / by the system)
# Also can't start with dot (hidden file) for folders typically # Also can't start with dot (hidden file) for folders typically
sanitized = re.sub(r'[:]', '_', name) sanitized = re.sub(r"[:]", "_", name)
# Remove control characters # Remove control characters
sanitized = re.sub(r'[\x00-\x1f]', '_', sanitized) sanitized = re.sub(r"[\x00-\x1f]", "_", sanitized)
# Avoid leading dot for folders (creates hidden folders) # Avoid leading dot for folders (creates hidden folders)
if is_folder and sanitized.startswith('.'): if is_folder and sanitized.startswith("."):
sanitized = '_' + sanitized[1:] sanitized = "_" + sanitized[1:]
else: # Linux and others else: # Linux and others
# Linux illegal characters: / and null character # Linux illegal characters: / and null character
# Though / is illegal, most other chars are technically allowed # Though / is illegal, most other chars are technically allowed
sanitized = re.sub(r'[/\x00]', '_', name) sanitized = re.sub(r"[/\x00]", "_", name)
# Remove other control characters for safety # Remove other control characters for safety
sanitized = re.sub(r'[\x01-\x1f]', '_', sanitized) sanitized = re.sub(r"[\x01-\x1f]", "_", sanitized)
# Avoid leading dot for folders (creates hidden folders) # Avoid leading dot for folders (creates hidden folders)
if is_folder and sanitized.startswith('.'): if is_folder and sanitized.startswith("."):
sanitized = '_' + sanitized[1:] sanitized = "_" + sanitized[1:]
# Ensure the name is not empty after sanitization # Ensure the name is not empty after sanitization
if not sanitized or sanitized.strip() == '': if not sanitized or sanitized.strip() == "":
sanitized = "audiobook" sanitized = "audiobook"
# Limit length to 255 characters (common limit across filesystems) # Limit length to 255 characters (common limit across filesystems)
if len(sanitized) > 255: if len(sanitized) > 255:
sanitized = sanitized[:255].rstrip('. ') sanitized = sanitized[:255].rstrip(". ")
return sanitized return sanitized
@@ -310,7 +317,11 @@ class ConversionThread(QThread):
# Normalize paths for consistent display (fixes Windows path separator issues) # Normalize paths for consistent display (fixes Windows path separator issues)
input_file = os.path.normpath(input_file) if input_file else input_file input_file = os.path.normpath(input_file) if input_file else input_file
processing_file = os.path.normpath(processing_file) if processing_file else processing_file processing_file = (
os.path.normpath(processing_file)
if processing_file
else processing_file
)
self.log_updated.emit(f"- Input File: {input_file}") self.log_updated.emit(f"- Input File: {input_file}")
if input_file != processing_file: if input_file != processing_file:
@@ -318,7 +329,9 @@ class ConversionThread(QThread):
# Use file_name for logs if from_queue, otherwise use display_path if available # Use file_name for logs if from_queue, otherwise use display_path if available
if getattr(self, "from_queue", False): if getattr(self, "from_queue", False):
base_path = self.save_base_path or self.file_name # Use save_base_path if available base_path = (
self.save_base_path or self.file_name
) # Use save_base_path if available
else: else:
base_path = self.display_path if self.display_path else self.file_name base_path = self.display_path if self.display_path else self.file_name
@@ -476,7 +489,9 @@ class ConversionThread(QThread):
# Use file_name for logs if from_queue, otherwise use display_path if available # Use file_name for logs if from_queue, otherwise use display_path if available
if getattr(self, "from_queue", False): if getattr(self, "from_queue", False):
base_path = self.save_base_path or self.file_name # Use save_base_path if available base_path = (
self.save_base_path or self.file_name
) # Use save_base_path if available
else: else:
base_path = self.display_path if self.display_path else self.file_name base_path = self.display_path if self.display_path else self.file_name
@@ -528,7 +543,9 @@ class ConversionThread(QThread):
# Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True # Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True
if merge_chapters_at_end: if merge_chapters_at_end:
out_dir = parent_dir out_dir = parent_dir
base_filepath_no_ext = os.path.join(out_dir, f"{sanitized_base_name}{suffix}") base_filepath_no_ext = os.path.join(
out_dir, f"{sanitized_base_name}{suffix}"
)
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
subtitle_entries = [] subtitle_entries = []
current_time = 0.0 current_time = 0.0
@@ -642,8 +659,12 @@ class ConversionThread(QThread):
# Add style definitions for karaoke highlighting # Add style definitions for karaoke highlighting
if self.subtitle_mode == "Sentence + Highlighting": if self.subtitle_mode == "Sentence + Highlighting":
merged_subtitle_file.write("[V4+ Styles]\n") merged_subtitle_file.write("[V4+ Styles]\n")
merged_subtitle_file.write("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n") merged_subtitle_file.write(
merged_subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n") "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
)
merged_subtitle_file.write(
"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n"
)
merged_subtitle_file.write("[Events]\n") merged_subtitle_file.write("[Events]\n")
merged_subtitle_file.write( merged_subtitle_file.write(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
@@ -806,8 +827,12 @@ class ConversionThread(QThread):
# Add style definitions for karaoke highlighting # Add style definitions for karaoke highlighting
if self.subtitle_mode == "Sentence + Highlighting": if self.subtitle_mode == "Sentence + Highlighting":
chapter_subtitle_file.write("[V4+ Styles]\n") chapter_subtitle_file.write("[V4+ Styles]\n")
chapter_subtitle_file.write("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n") chapter_subtitle_file.write(
chapter_subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n") "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
)
chapter_subtitle_file.write(
"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n"
)
chapter_subtitle_file.write("[Events]\n") chapter_subtitle_file.write("[Events]\n")
chapter_subtitle_file.write( chapter_subtitle_file.write(
@@ -922,7 +947,12 @@ class ConversionThread(QThread):
start_time = self._ass_time(start) start_time = self._ass_time(start)
end_time = self._ass_time(end) end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode # Use karaoke effect for highlighting mode
effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else "" effect = (
"karaoke"
if self.subtitle_mode
== "Sentence + Highlighting"
else ""
)
merged_subtitle_file.write( merged_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n" f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
) )
@@ -951,7 +981,12 @@ class ConversionThread(QThread):
start_time = self._ass_time(start) start_time = self._ass_time(start)
end_time = self._ass_time(end) end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode # Use karaoke effect for highlighting mode
effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else "" effect = (
"karaoke"
if self.subtitle_mode
== "Sentence + Highlighting"
else ""
)
chapter_subtitle_file.write( chapter_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n" f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
) )
@@ -997,7 +1032,9 @@ class ConversionThread(QThread):
# Add silence between chapters for merged output (except after the last chapter) # Add silence between chapters for merged output (except after the last chapter)
if merge_chapters_at_end and chapter_idx < total_chapters: if merge_chapters_at_end and chapter_idx < total_chapters:
silence_samples = int(self.silence_duration * 24000) # Silence duration at 24,000 Hz silence_samples = int(
self.silence_duration * 24000
) # Silence duration at 24,000 Hz
silence_audio = self.np.zeros(silence_samples, dtype="float32") silence_audio = self.np.zeros(silence_samples, dtype="float32")
silence_bytes = silence_audio.tobytes() silence_bytes = silence_audio.tobytes()
@@ -1297,7 +1334,11 @@ class ConversionThread(QThread):
karaoke_text = "" karaoke_text = ""
for t in current_sentence: for t in current_sentence:
# Calculate duration in centiseconds # Calculate duration in centiseconds
duration = t["end"] - t["start"] if t["end"] and t["start"] else 0.5 duration = (
t["end"] - t["start"]
if t["end"] and t["start"]
else 0.5
)
duration_cs = int(duration * 100) duration_cs = int(duration * 100)
# Add karaoke effect - relies on style's SecondaryColour for highlighting # Add karaoke effect - relies on style's SecondaryColour for highlighting
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}" karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
@@ -1401,15 +1442,28 @@ class ConversionThread(QThread):
# Split after counting N spaces # Split after counting N spaces
if space_count >= word_count: if space_count >= word_count:
text = "".join(t["text"] + (t.get("whitespace", "") or "") for t in current_group) text = "".join(
subtitle_entries.append((current_group[0]["start"], current_group[-1]["end"], text.strip())) t["text"] + (t.get("whitespace", "") or "")
for t in current_group
)
subtitle_entries.append(
(
current_group[0]["start"],
current_group[-1]["end"],
text.strip(),
)
)
current_group = [] current_group = []
space_count = 0 space_count = 0
# Add any remaining tokens # Add any remaining tokens
if current_group: if current_group:
text = "".join(t["text"] + (t.get("whitespace", "") or "") for t in current_group) text = "".join(
subtitle_entries.append((current_group[0]["start"], current_group[-1]["end"], text.strip())) t["text"] + (t.get("whitespace", "") or "") for t in current_group
)
subtitle_entries.append(
(current_group[0]["start"], current_group[-1]["end"], text.strip())
)
# Fallback for last entry # Fallback for last entry
if subtitle_entries and fallback_end_time is not None: if subtitle_entries and fallback_end_time is not None:
@@ -1418,7 +1472,6 @@ class ConversionThread(QThread):
if end is None or end <= start or end <= 0: if end is None or end <= start or end <= 0:
subtitle_entries[-1] = (start, fallback_end_time, text) subtitle_entries[-1] = (start, fallback_end_time, text)
def cancel(self): def cancel(self):
self.cancel_requested = True self.cancel_requested = True
self.should_cancel = True self.should_cancel = True
+104 -37
View File
@@ -280,7 +280,9 @@ class InputBox(QLabel):
char_count = cached_char_count char_count = cached_char_count
elif char_source_path: elif char_source_path:
try: try:
with open(char_source_path, "r", encoding="utf-8", errors="ignore") as f: with open(
char_source_path, "r", encoding="utf-8", errors="ignore"
) as f:
text = f.read() text = f.read()
cleaned_text = clean_text(text) cleaned_text = clean_text(text)
char_count = calculate_text_length(cleaned_text) char_count = calculate_text_length(cleaned_text)
@@ -328,7 +330,8 @@ class InputBox(QLabel):
# For epub/pdf files, show edit if we have a selected_file (temp txt) # For epub/pdf files, show edit if we have a selected_file (temp txt)
if ( if (
window.selected_file_type in ["epub", "pdf", "md", "markdown", "md", "markdown"] window.selected_file_type
in ["epub", "pdf", "md", "markdown", "md", "markdown"]
and window.selected_file and window.selected_file
): ):
should_show_edit = True should_show_edit = True
@@ -454,9 +457,11 @@ class InputBox(QLabel):
) )
self.set_file_info(file_path) self.set_file_info(file_path)
event.acceptProposedAction() event.acceptProposedAction()
elif (file_path.lower().endswith(".epub") elif (
file_path.lower().endswith(".epub")
or file_path.lower().endswith(".pdf") or file_path.lower().endswith(".pdf")
or file_path.lower().endswith((".md", ".markdown"))): or file_path.lower().endswith((".md", ".markdown"))
):
# Determine file type # Determine file type
if file_path.lower().endswith(".epub"): if file_path.lower().endswith(".epub"):
file_type = "epub" file_type = "epub"
@@ -480,7 +485,10 @@ class InputBox(QLabel):
def on_chapters_clicked(self): def on_chapters_clicked(self):
win = self.window() win = self.window()
if win.selected_file_type in ["epub", "pdf", "md", "markdown"] and win.selected_book_path: if (
win.selected_file_type in ["epub", "pdf", "md", "markdown"]
and win.selected_book_path
):
# Call open_book_file which shows the dialog and updates selected_chapters # Call open_book_file which shows the dialog and updates selected_chapters
if win.open_book_file(win.selected_book_path): if win.open_book_file(win.selected_book_path):
# Refresh the info label and button text after dialog closes # Refresh the info label and button text after dialog closes
@@ -492,7 +500,10 @@ class InputBox(QLabel):
def on_edit_clicked(self): def on_edit_clicked(self):
win = self.window() win = self.window()
# For PDFs and EPUBs, use the temporary text file # For PDFs and EPUBs, use the temporary text file
if win.selected_file_type in ["epub", "pdf", "md", "markdown"] and win.selected_file: if (
win.selected_file_type in ["epub", "pdf", "md", "markdown"]
and win.selected_file
):
# Use the temporary .txt file that was generated # Use the temporary .txt file that was generated
win.open_textbox_dialog(win.selected_file) win.open_textbox_dialog(win.selected_file)
else: else:
@@ -520,9 +531,16 @@ class InputBox(QLabel):
and cache_dir and cache_dir
): ):
# Consider it cached when the file is under the cache directory and is a .txt # Consider it cached when the file is under the cache directory and is a .txt
if file_to_check.endswith('.txt') and os.path.commonpath([os.path.abspath(file_to_check), os.path.abspath(cache_dir)]) == os.path.abspath(cache_dir): if file_to_check.endswith(".txt") and os.path.commonpath(
[os.path.abspath(file_to_check), os.path.abspath(cache_dir)]
) == os.path.abspath(cache_dir):
# Only treat as document-cache when original type was a document # Only treat as document-cache when original type was a document
if getattr(win, 'selected_file_type', None) in ['epub', 'pdf', 'md', 'markdown']: if getattr(win, "selected_file_type", None) in [
"epub",
"pdf",
"md",
"markdown",
]:
is_cached_doc = True is_cached_doc = True
if is_cached_doc: if is_cached_doc:
@@ -538,8 +556,11 @@ class InputBox(QLabel):
act_input = QAction("Go to input file", self) act_input = QAction("Go to input file", self)
# Prefer displayed_file_path (original input path) then selected_book_path # Prefer displayed_file_path (original input path) then selected_book_path
input_path = getattr(win, 'displayed_file_path', None) or getattr(win, 'selected_book_path', None) input_path = getattr(win, "displayed_file_path", None) or getattr(
win, "selected_book_path", None
)
if input_path and os.path.exists(input_path): if input_path and os.path.exists(input_path):
def open_input(): def open_input():
folder_path = os.path.dirname(input_path) folder_path = os.path.dirname(input_path)
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
@@ -550,7 +571,11 @@ class InputBox(QLabel):
menu.addAction(act_input) menu.addAction(act_input)
# Show the menu anchored to the button # Show the menu anchored to the button
menu.exec(self.go_to_folder_btn.mapToGlobal(QPoint(0, self.go_to_folder_btn.height()))) menu.exec(
self.go_to_folder_btn.mapToGlobal(
QPoint(0, self.go_to_folder_btn.height())
)
)
else: else:
if ( if (
file_to_check file_to_check
@@ -568,7 +593,9 @@ class TextboxDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Enter Text") self.setWindowTitle("Enter Text")
self.setWindowFlags( self.setWindowFlags(
Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowMaximizeButtonHint Qt.WindowType.Window
| Qt.WindowType.WindowCloseButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
) )
self.resize(700, 500) self.resize(700, 500)
@@ -648,7 +675,9 @@ class TextboxDialog(QDialog):
f"You are about to overwrite the original file:\n{self.non_cache_file_path}" f"You are about to overwrite the original file:\n{self.non_cache_file_path}"
) )
msg_box.setInformativeText("Do you want to continue?") msg_box.setInformativeText("Do you want to continue?")
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) msg_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msg_box.setDefaultButton(QMessageBox.StandardButton.No) msg_box.setDefaultButton(QMessageBox.StandardButton.No)
if msg_box.exec() != QMessageBox.StandardButton.Yes: if msg_box.exec() != QMessageBox.StandardButton.Yes:
@@ -918,7 +947,9 @@ class abogen(QWidget):
"The first character represents the language:\n" "The first character represents the language:\n"
'"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female' '"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female'
) )
self.voice_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.voice_combo.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
voice_layout.addWidget(self.voice_combo) voice_layout.addWidget(self.voice_combo)
# Voice formula button # Voice formula button
self.btn_voice_formula_mixer = QPushButton(self) self.btn_voice_formula_mixer = QPushButton(self)
@@ -984,14 +1015,20 @@ class abogen(QWidget):
for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
) )
) )
subtitle_options = ["Disabled", "Line", "Sentence", "Sentence + Comma", "Sentence + Highlighting"] + [ subtitle_options = [
f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11) "Disabled",
] "Line",
"Sentence",
"Sentence + Comma",
"Sentence + Highlighting",
] + [f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11)]
self.subtitle_combo.addItems(subtitle_options) self.subtitle_combo.addItems(subtitle_options)
self.subtitle_combo.setStyleSheet( self.subtitle_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }" "QComboBox { min-height: 20px; padding: 6px 12px; }"
) )
self.subtitle_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.subtitle_combo.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.subtitle_combo.setCurrentText(self.subtitle_mode) self.subtitle_combo.setCurrentText(self.subtitle_mode)
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
# Enable/disable subtitle options based on selected language (profile or voice) # Enable/disable subtitle options based on selected language (profile or voice)
@@ -1009,7 +1046,9 @@ class abogen(QWidget):
self.format_combo.setStyleSheet( self.format_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }" "QComboBox { min-height: 20px; padding: 6px 12px; }"
) )
self.format_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.format_combo.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
# Add items with display labels and underlying keys # Add items with display labels and underlying keys
for key, label in [ for key, label in [
("wav", "wav"), ("wav", "wav"),
@@ -1055,7 +1094,10 @@ class abogen(QWidget):
# If subtitle mode requires highlighting, SRT is not supported. Disable SRT item # If subtitle mode requires highlighting, SRT is not supported. Disable SRT item
# and auto-switch to a compatible ASS format if SRT is currently selected. # and auto-switch to a compatible ASS format if SRT is currently selected.
try: try:
if hasattr(self, "subtitle_mode") and self.subtitle_mode == "Sentence + Highlighting": if (
hasattr(self, "subtitle_mode")
and self.subtitle_mode == "Sentence + Highlighting"
):
idx_srt = self.subtitle_format_combo.findData("srt") idx_srt = self.subtitle_format_combo.findData("srt")
if idx_srt >= 0: if idx_srt >= 0:
item = self.subtitle_format_combo.model().item(idx_srt) item = self.subtitle_format_combo.model().item(idx_srt)
@@ -1067,7 +1109,9 @@ class abogen(QWidget):
if new_idx >= 0: if new_idx >= 0:
self.subtitle_format_combo.setCurrentIndex(new_idx) self.subtitle_format_combo.setCurrentIndex(new_idx)
# Persist the change # Persist the change
self.set_subtitle_format(self.subtitle_format_combo.itemData(new_idx)) self.set_subtitle_format(
self.subtitle_format_combo.itemData(new_idx)
)
except Exception: except Exception:
# Fail-safe: don't crash UI if model manipulation isn't supported on some platforms # Fail-safe: don't crash UI if model manipulation isn't supported on some platforms
pass pass
@@ -1114,7 +1158,9 @@ class abogen(QWidget):
self.save_combo.setStyleSheet( self.save_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }" "QComboBox { min-height: 20px; padding: 6px 12px; }"
) )
self.save_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.save_combo.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.save_combo.setCurrentText(self.save_option) self.save_combo.setCurrentText(self.save_option)
self.save_combo.currentTextChanged.connect(self.on_save_option_changed) self.save_combo.currentTextChanged.connect(self.on_save_option_changed)
save_layout.addWidget(self.save_combo) save_layout.addWidget(self.save_combo)
@@ -1129,7 +1175,9 @@ class abogen(QWidget):
save_path_row.addWidget(selected_folder_label) save_path_row.addWidget(selected_folder_label)
self.save_path_label = QLabel("", self.save_path_row_widget) self.save_path_label = QLabel("", self.save_path_row_widget)
self.save_path_label.setStyleSheet(f"QLabel {{ color: {COLORS['GREEN']}; }}") self.save_path_label.setStyleSheet(f"QLabel {{ color: {COLORS['GREEN']}; }}")
self.save_path_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.save_path_label.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
save_path_row.addWidget(self.save_path_label) save_path_row.addWidget(self.save_path_label)
self.save_path_row_widget.hide() # Hide the whole row by default self.save_path_row_widget.hide() # Hide the whole row by default
controls_layout.addWidget(self.save_path_row_widget) controls_layout.addWidget(self.save_path_row_widget)
@@ -1175,7 +1223,9 @@ class abogen(QWidget):
# Add controls to a container widget # Add controls to a container widget
self.controls_widget = QWidget() self.controls_widget = QWidget()
self.controls_widget.setLayout(controls_layout) self.controls_widget.setLayout(controls_layout)
self.controls_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.controls_widget.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
)
container_layout.addWidget(self.controls_widget) container_layout.addWidget(self.controls_widget)
# Progress bar # Progress bar
self.progress_bar = QProgressBar(self) self.progress_bar = QProgressBar(self)
@@ -1241,9 +1291,11 @@ class abogen(QWidget):
) )
if not file_path: if not file_path:
return return
if (file_path.lower().endswith(".epub") if (
file_path.lower().endswith(".epub")
or file_path.lower().endswith(".pdf") or file_path.lower().endswith(".pdf")
or file_path.lower().endswith((".md", ".markdown"))): or file_path.lower().endswith((".md", ".markdown"))
):
# Determine file type # Determine file type
if file_path.lower().endswith(".epub"): if file_path.lower().endswith(".epub"):
self.selected_file_type = "epub" self.selected_file_type = "epub"
@@ -1279,9 +1331,9 @@ class abogen(QWidget):
# HandlerDialog uses internal caching to avoid reprocessing the same book # HandlerDialog uses internal caching to avoid reprocessing the same book
dialog = HandlerDialog( dialog = HandlerDialog(
book_path, book_path,
file_type=getattr(self, 'selected_file_type', None), file_type=getattr(self, "selected_file_type", None),
checked_chapters=self.selected_chapters, checked_chapters=self.selected_chapters,
parent=self parent=self,
) )
dialog.setWindowModality(Qt.WindowModality.NonModal) dialog.setWindowModality(Qt.WindowModality.NonModal)
dialog.setModal(False) dialog.setModal(False)
@@ -1724,7 +1776,9 @@ class abogen(QWidget):
if self.selected_file_type in ["epub", "pdf", "md", "markdown"]: if self.selected_file_type in ["epub", "pdf", "md", "markdown"]:
file_to_queue = self.selected_file file_to_queue = self.selected_file
# Use the original file path for save location # Use the original file path for save location
save_base_path = self.displayed_file_path if self.displayed_file_path else file_to_queue save_base_path = (
self.displayed_file_path if self.displayed_file_path else file_to_queue
)
else: else:
file_to_queue = ( file_to_queue = (
self.displayed_file_path self.displayed_file_path
@@ -1835,7 +1889,9 @@ class abogen(QWidget):
queued_item, "replace_single_newlines", False queued_item, "replace_single_newlines", False
) )
# Restore the original file path for save location # Restore the original file path for save location
self.displayed_file_path = queued_item.save_base_path or queued_item.file_name self.displayed_file_path = (
queued_item.save_base_path or queued_item.file_name
)
self.start_conversion(from_queue=True) self.start_conversion(from_queue=True)
else: else:
# Queue finished, reset index # Queue finished, reset index
@@ -2571,7 +2627,9 @@ class abogen(QWidget):
box.setText( box.setText(
"A conversion is currently running. Are you sure you want to cancel?" "A conversion is currently running. Are you sure you want to cancel?"
) )
box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
box.setDefaultButton(QMessageBox.StandardButton.No) box.setDefaultButton(QMessageBox.StandardButton.No)
if box.exec() != QMessageBox.StandardButton.Yes: if box.exec() != QMessageBox.StandardButton.Yes:
return return
@@ -2641,7 +2699,9 @@ class abogen(QWidget):
new_idx = self.subtitle_format_combo.findData("ass_centered_narrow") new_idx = self.subtitle_format_combo.findData("ass_centered_narrow")
if new_idx >= 0: if new_idx >= 0:
self.subtitle_format_combo.setCurrentIndex(new_idx) self.subtitle_format_combo.setCurrentIndex(new_idx)
self.set_subtitle_format(self.subtitle_format_combo.itemData(new_idx)) self.set_subtitle_format(
self.subtitle_format_combo.itemData(new_idx)
)
else: else:
# Re-enable SRT option when not in highlighting mode # Re-enable SRT option when not in highlighting mode
if idx_srt >= 0: if idx_srt >= 0:
@@ -2680,7 +2740,9 @@ class abogen(QWidget):
box.setText( box.setText(
"A conversion is currently running. Are you sure you want to exit?" "A conversion is currently running. Are you sure you want to exit?"
) )
box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
box.setDefaultButton(QMessageBox.StandardButton.No) box.setDefaultButton(QMessageBox.StandardButton.No)
if box.exec() == QMessageBox.StandardButton.Yes: if box.exec() == QMessageBox.StandardButton.Yes:
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
@@ -2712,7 +2774,6 @@ class abogen(QWidget):
def apply_theme(self, theme): def apply_theme(self, theme):
app = QApplication.instance() app = QApplication.instance()
is_windows = platform.system() == "Windows" is_windows = platform.system() == "Windows"
available_styles = [s.lower() for s in QStyleFactory.keys()] available_styles = [s.lower() for s in QStyleFactory.keys()]
@@ -3305,7 +3366,9 @@ Categories=AudioVideo;Audio;Utility;
# Create custom dialog # Create custom dialog
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle(f"About {PROGRAM_NAME}") dialog.setWindowTitle(f"About {PROGRAM_NAME}")
dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) dialog.setWindowFlags(
dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint
)
dialog.setFixedSize(400, 320) # Increased height for new button dialog.setFixedSize(400, 320) # Increased height for new button
layout = QVBoxLayout(dialog) layout = QVBoxLayout(dialog)
@@ -3384,7 +3447,9 @@ Categories=AudioVideo;Audio;Utility;
"Alternatively, visit the GitHub repository for more information. " "Alternatively, visit the GitHub repository for more information. "
"Would you like to view the changelog?" "Would you like to view the changelog?"
) )
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) msg_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
if msg_box.exec() == QMessageBox.StandardButton.Yes: if msg_box.exec() == QMessageBox.StandardButton.Yes:
try: try:
@@ -3487,7 +3552,9 @@ Categories=AudioVideo;Audio;Utility;
msg_box.setCheckBox(preview_cache_checkbox) msg_box.setCheckBox(preview_cache_checkbox)
# Add buttons # Add buttons
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) msg_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
if msg_box.exec() != QMessageBox.StandardButton.Yes: if msg_box.exec() != QMessageBox.StandardButton.Yes:
@@ -3588,7 +3655,6 @@ Categories=AudioVideo;Audio;Utility;
def set_silence_between_chapters(self): def set_silence_between_chapters(self):
"""Open a dialog to set the silence duration between chapters""" """Open a dialog to set the silence duration between chapters"""
current_value = self.config.get("silence_duration", 2.0) current_value = self.config.get("silence_duration", 2.0)
dlg = QInputDialog(self) dlg = QInputDialog(self)
@@ -3619,6 +3685,7 @@ Categories=AudioVideo;Audio;Utility;
"Setting Saved", "Setting Saved",
f"Silence duration between chapters set to {value:.1f} seconds.", f"Silence duration between chapters set to {value:.1f} seconds.",
) )
def set_separate_chapters_format(self, fmt): def set_separate_chapters_format(self, fmt):
"""Set the format for separate chapters audio files.""" """Set the format for separate chapters audio files."""
self.separate_chapters_format = fmt self.separate_chapters_format = fmt
+1
View File
@@ -31,6 +31,7 @@ if platform.system() == "Windows":
try: try:
from abogen.constants import PROGRAM_NAME, VERSION from abogen.constants import PROGRAM_NAME, VERSION
import ctypes import ctypes
app_id = f"{PROGRAM_NAME}.{VERSION}" app_id = f"{PROGRAM_NAME}.{VERSION}"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
except Exception as e: except Exception as e:
+128 -49
View File
@@ -36,7 +36,9 @@ class ElidedLabel(QLabel):
def resizeEvent(self, event): def resizeEvent(self, event):
metrics = QFontMetrics(self.font()) metrics = QFontMetrics(self.font())
elided = metrics.elidedText(self._full_text, Qt.TextElideMode.ElideRight, self.width()) elided = metrics.elidedText(
self._full_text, Qt.TextElideMode.ElideRight, self.width()
)
super().setText(elided) super().setText(elided)
super().resizeEvent(event) super().resizeEvent(event)
@@ -55,8 +57,12 @@ class QueueListItemWidget(QWidget):
name_label = ElidedLabel(os.path.basename(file_name)) name_label = ElidedLabel(os.path.basename(file_name))
char_label = QLabel(f"Chars: {char_count}") char_label = QLabel(f"Chars: {char_count}")
char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};") char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};")
char_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) char_label.setAlignment(
char_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
char_label.setSizePolicy(
QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred
)
layout.addWidget(name_label, 1) layout.addWidget(name_label, 1)
layout.addWidget(char_label, 0) layout.addWidget(char_label, 0)
self.setLayout(layout) self.setLayout(layout)
@@ -74,7 +80,9 @@ class DroppableQueueListWidget(QListWidget):
f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};" f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};"
) )
self.drag_overlay.setVisible(False) self.drag_overlay.setVisible(False)
self.drag_overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) self.drag_overlay.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
)
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
@@ -134,7 +142,9 @@ class QueueManager(QDialog):
layout.setSpacing(12) # set spacing between widgets in main layout layout.setSpacing(12) # set spacing between widgets in main layout
# list of queued items # list of queued items
self.listwidget = DroppableQueueListWidget(self) self.listwidget = DroppableQueueListWidget(self)
self.listwidget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.listwidget.setSelectionMode(
QAbstractItemView.SelectionMode.ExtendedSelection
)
self.listwidget.setAlternatingRowColors(True) self.listwidget.setAlternatingRowColors(True)
self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.listwidget.customContextMenuRequested.connect(self.show_context_menu) self.listwidget.customContextMenuRequested.connect(self.show_context_menu)
@@ -161,7 +171,9 @@ class QueueManager(QDialog):
f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;" f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;"
) )
self.empty_overlay.setWordWrap(True) self.empty_overlay.setWordWrap(True)
self.empty_overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) self.empty_overlay.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
)
self.empty_overlay.hide() self.empty_overlay.hide()
# add queue items to the list # add queue items to the list
self.process_queue() self.process_queue()
@@ -194,7 +206,10 @@ class QueueManager(QDialog):
self.listwidget.currentItemChanged.connect(self.update_button_states) self.listwidget.currentItemChanged.connect(self.update_button_states)
self.listwidget.itemSelectionChanged.connect(self.update_button_states) self.listwidget.itemSelectionChanged.connect(self.update_button_states)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self) buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
self,
)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@@ -225,8 +240,16 @@ class QueueManager(QDialog):
processing_file_path = item.file_name processing_file_path = item.file_name
# Normalize paths for consistent display (fixes Windows path separator issues) # Normalize paths for consistent display (fixes Windows path separator issues)
display_file_path = os.path.normpath(display_file_path) if display_file_path else display_file_path display_file_path = (
processing_file_path = os.path.normpath(processing_file_path) if processing_file_path else processing_file_path os.path.normpath(display_file_path)
if display_file_path
else display_file_path
)
processing_file_path = (
os.path.normpath(processing_file_path)
if processing_file_path
else processing_file_path
)
# Only show the file name, not the full path # Only show the file name, not the full path
display_name = display_file_path display_name = display_file_path
@@ -241,13 +264,19 @@ class QueueManager(QDialog):
# For plain .txt inputs we don't need to show a separate processing file # For plain .txt inputs we don't need to show a separate processing file
show_processing = True show_processing = True
try: try:
if isinstance(display_file_path, str) and display_file_path.lower().endswith('.txt'): if isinstance(
display_file_path, str
) and display_file_path.lower().endswith(".txt"):
show_processing = False show_processing = False
except Exception: except Exception:
show_processing = True show_processing = True
tooltip = f"<b>Input File:</b> {display_file_path}<br>" tooltip = f"<b>Input File:</b> {display_file_path}<br>"
if show_processing and processing_file_path and processing_file_path != display_file_path: if (
show_processing
and processing_file_path
and processing_file_path != display_file_path
):
tooltip += f"<b>Processing File:</b> {processing_file_path}<br>" tooltip += f"<b>Processing File:</b> {processing_file_path}<br>"
tooltip += ( tooltip += (
f"<b>Language:</b> {getattr(item, 'lang_code', '')}<br>" f"<b>Language:</b> {getattr(item, 'lang_code', '')}<br>"
@@ -264,8 +293,8 @@ class QueueManager(QDialog):
f"<b>Replace Single Newlines:</b> {getattr(item, 'replace_single_newlines', False)}" f"<b>Replace Single Newlines:</b> {getattr(item, 'replace_single_newlines', False)}"
) )
# Add book handler options if present # Add book handler options if present
save_chapters_separately = getattr(item, 'save_chapters_separately', None) save_chapters_separately = getattr(item, "save_chapters_separately", None)
merge_chapters_at_end = getattr(item, 'merge_chapters_at_end', None) merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None)
if save_chapters_separately is not None: if save_chapters_separately is not None:
tooltip += f"<br><b>Save chapters separately:</b> {'Yes' if save_chapters_separately else 'No'}" tooltip += f"<br><b>Save chapters separately:</b> {'Yes' if save_chapters_separately else 'No'}"
# Only show merge option if saving chapters separately # Only show merge option if saving chapters separately
@@ -274,10 +303,13 @@ class QueueManager(QDialog):
list_item.setToolTip(tooltip) list_item.setToolTip(tooltip)
list_item.setIcon(icon) list_item.setIcon(icon)
# Store both paths for context menu # Store both paths for context menu
list_item.setData(Qt.ItemDataRole.UserRole, { list_item.setData(
'display_path': display_file_path, Qt.ItemDataRole.UserRole,
'processing_path': processing_file_path {
}) "display_path": display_file_path,
"processing_path": processing_file_path,
},
)
# Use custom widget for display # Use custom widget for display
char_count = getattr(item, "total_char_count", 0) char_count = getattr(item, "total_char_count", 0)
widget = QueueListItemWidget(display_file_path, char_count) widget = QueueListItemWidget(display_file_path, char_count)
@@ -412,7 +444,9 @@ class QueueManager(QDialog):
item = QueueItem() item = QueueItem()
item.file_name = file_path item.file_name = file_path
item.save_base_path = file_path # For .txt files, processing and save paths are the same item.save_base_path = (
file_path # For .txt files, processing and save paths are the same
)
for attr, value in current_attrs.items(): for attr, value in current_attrs.items():
setattr(item, attr, value) setattr(item, attr, value)
# Read file content and calculate total_char_count using calculate_text_length # Read file content and calculate total_char_count using calculate_text_length
@@ -517,40 +551,54 @@ class QueueManager(QDialog):
item = selected_items[0] item = selected_items[0]
paths = item.data(Qt.ItemDataRole.UserRole) paths = item.data(Qt.ItemDataRole.UserRole)
if isinstance(paths, dict): if isinstance(paths, dict):
display_path = paths.get('display_path', '') display_path = paths.get("display_path", "")
processing_path = paths.get('processing_path', '') processing_path = paths.get("processing_path", "")
else: else:
display_path = paths display_path = paths
processing_path = paths processing_path = paths
doc_exts = ('.md', '.markdown', '.pdf', '.epub') doc_exts = (".md", ".markdown", ".pdf", ".epub")
is_document_input = ( is_document_input = (
isinstance(display_path, str) and display_path.lower().endswith(doc_exts) isinstance(display_path, str)
and display_path.lower().endswith(doc_exts)
) or ( ) or (
isinstance(processing_path, str) and processing_path.lower().endswith(doc_exts) isinstance(processing_path, str)
and processing_path.lower().endswith(doc_exts)
) )
# Add Open file action(s) # Add Open file action(s)
def open_file_by_path(path_label: str): def open_file_by_path(path_label: str):
from PyQt6.QtWidgets import QMessageBox from PyQt6.QtWidgets import QMessageBox
p = display_path if path_label == 'display' else processing_path p = display_path if path_label == "display" else processing_path
if not p: if not p:
QMessageBox.warning(self, "File Not Found", "Path is not available.") QMessageBox.warning(
self, "File Not Found", "Path is not available."
)
return return
# Find the queue item and resolve the target path # Find the queue item and resolve the target path
target_path = None target_path = None
for q in self.queue: for q in self.queue:
if getattr(q, 'save_base_path', None) == display_path or q.file_name == display_path: if (
if path_label == 'display': getattr(q, "save_base_path", None) == display_path
target_path = getattr(q, 'save_base_path', None) or q.file_name or q.file_name == display_path
):
if path_label == "display":
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
else: else:
target_path = q.file_name target_path = q.file_name
break break
if getattr(q, 'save_base_path', None) == processing_path or q.file_name == processing_path: if (
if path_label == 'display': getattr(q, "save_base_path", None) == processing_path
target_path = getattr(q, 'save_base_path', None) or q.file_name or q.file_name == processing_path
):
if path_label == "display":
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
else: else:
target_path = q.file_name target_path = q.file_name
break break
@@ -560,23 +608,29 @@ class QueueManager(QDialog):
target_path = p target_path = p
if not os.path.exists(target_path): if not os.path.exists(target_path):
QMessageBox.warning(self, "File Not Found", f"The file does not exist.") QMessageBox.warning(
self, "File Not Found", f"The file does not exist."
)
return return
QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) QDesktopServices.openUrl(QUrl.fromLocalFile(target_path))
if is_document_input: if is_document_input:
# For documents, show two open options # For documents, show two open options
open_processed_action = QAction("Open processed file", self) open_processed_action = QAction("Open processed file", self)
open_processed_action.triggered.connect(lambda: open_file_by_path('processing')) open_processed_action.triggered.connect(
lambda: open_file_by_path("processing")
)
menu.addAction(open_processed_action) menu.addAction(open_processed_action)
open_input_action = QAction("Open input file", self) open_input_action = QAction("Open input file", self)
open_input_action.triggered.connect(lambda: open_file_by_path('display')) open_input_action.triggered.connect(
lambda: open_file_by_path("display")
)
menu.addAction(open_input_action) menu.addAction(open_input_action)
else: else:
# For plain text files, show single open option # For plain text files, show single open option
open_file_action = QAction("Open file", self) open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(lambda: open_file_by_path('display')) open_file_action.triggered.connect(lambda: open_file_by_path("display"))
menu.addAction(open_file_action) menu.addAction(open_file_action)
# Add Go to folder action # Add Go to folder action
@@ -587,23 +641,35 @@ class QueueManager(QDialog):
def open_folder_for(path_label: str): def open_folder_for(path_label: str):
# path_label should be either 'display' or 'processing' # path_label should be either 'display' or 'processing'
p = display_path if path_label == 'display' else processing_path p = display_path if path_label == "display" else processing_path
if not p: if not p:
QMessageBox.warning(self, "File Not Found", "Path is not available.") QMessageBox.warning(
self, "File Not Found", "Path is not available."
)
return return
# If the stored path is the display path (original) but the actual file may be # If the stored path is the display path (original) but the actual file may be
# stored on the queue object differently, try to resolve via the queue entry. # stored on the queue object differently, try to resolve via the queue entry.
target_path = None target_path = None
for q in self.queue: for q in self.queue:
if getattr(q, 'save_base_path', None) == display_path or q.file_name == display_path: if (
if path_label == 'display': getattr(q, "save_base_path", None) == display_path
target_path = getattr(q, 'save_base_path', None) or q.file_name or q.file_name == display_path
):
if path_label == "display":
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
else: else:
target_path = q.file_name target_path = q.file_name
break break
if getattr(q, 'save_base_path', None) == processing_path or q.file_name == processing_path: if (
if path_label == 'display': getattr(q, "save_base_path", None) == processing_path
target_path = getattr(q, 'save_base_path', None) or q.file_name or q.file_name == processing_path
):
if path_label == "display":
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
else: else:
target_path = q.file_name target_path = q.file_name
break break
@@ -612,7 +678,11 @@ class QueueManager(QDialog):
target_path = p target_path = p
if not os.path.exists(target_path): if not os.path.exists(target_path):
QMessageBox.warning(self, "File Not Found", f"The file does not exist: {target_path}") QMessageBox.warning(
self,
"File Not Found",
f"The file does not exist: {target_path}",
)
return return
folder = os.path.dirname(target_path) folder = os.path.dirname(target_path)
if os.path.exists(folder): if os.path.exists(folder):
@@ -620,11 +690,13 @@ class QueueManager(QDialog):
if is_document_input: if is_document_input:
processed_action = QAction("Go to processed file", self) processed_action = QAction("Go to processed file", self)
processed_action.triggered.connect(lambda: open_folder_for('processing')) processed_action.triggered.connect(
lambda: open_folder_for("processing")
)
menu.addAction(processed_action) menu.addAction(processed_action)
input_action = QAction("Go to input file", self) input_action = QAction("Go to input file", self)
input_action.triggered.connect(lambda: open_folder_for('display')) input_action.triggered.connect(lambda: open_folder_for("display"))
menu.addAction(input_action) menu.addAction(input_action)
else: else:
# Default behavior for non-document inputs: single "Go to folder" action # Default behavior for non-document inputs: single "Go to folder" action
@@ -634,13 +706,20 @@ class QueueManager(QDialog):
item = selected_items[0] item = selected_items[0]
paths = item.data(Qt.ItemDataRole.UserRole) paths = item.data(Qt.ItemDataRole.UserRole)
if isinstance(paths, dict): if isinstance(paths, dict):
file_path = paths.get('display_path', paths.get('processing_path', '')) file_path = paths.get(
"display_path", paths.get("processing_path", "")
)
else: else:
file_path = paths # Fallback for old format file_path = paths # Fallback for old format
# Find the queue item # Find the queue item
for q in self.queue: for q in self.queue:
if (getattr(q, "save_base_path", None) == file_path or q.file_name == file_path): if (
target_path = getattr(q, "save_base_path", None) or q.file_name getattr(q, "save_base_path", None) == file_path
or q.file_name == file_path
):
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
if not os.path.exists(target_path): if not os.path.exists(target_path):
QMessageBox.warning( QMessageBox.warning(
self, "File Not Found", f"The file does not exist." self, "File Not Found", f"The file does not exist."
+2
View File
@@ -14,6 +14,7 @@ warnings.filterwarnings("ignore")
def detect_encoding(file_path): def detect_encoding(file_path):
import chardet import chardet
import charset_normalizer import charset_normalizer
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
raw_data = f.read() raw_data = f.read()
detected_encoding = None detected_encoding = None
@@ -28,6 +29,7 @@ def detect_encoding(file_path):
encoding = detected_encoding if detected_encoding else "utf-8" encoding = detected_encoding if detected_encoding else "utf-8"
return encoding.lower() return encoding.lower()
def get_resource_path(package, resource): def get_resource_path(package, resource):
""" """
Get the path to a resource file, with fallback to local file system. Get the path to a resource file, with fallback to local file system.
+58 -17
View File
@@ -131,10 +131,14 @@ class FlowLayout(QLayout):
for item in self._item_list: for item in self._item_list:
style = self.parentWidget().style() if self.parentWidget() else QStyle() style = self.parentWidget().style() if self.parentWidget() else QStyle()
layout_spacing_x = style.layoutSpacing( layout_spacing_x = style.layoutSpacing(
QSizePolicy.ControlType.PushButton, QSizePolicy.ControlType.PushButton, Qt.Orientation.Horizontal QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Horizontal,
) )
layout_spacing_y = style.layoutSpacing( layout_spacing_y = style.layoutSpacing(
QSizePolicy.ControlType.PushButton, QSizePolicy.ControlType.PushButton, Qt.Orientation.Vertical QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Vertical,
) )
space_x = spacing if spacing >= 0 else layout_spacing_x space_x = spacing if spacing >= 0 else layout_spacing_x
space_y = spacing if spacing >= 0 else layout_spacing_y space_y = spacing if spacing >= 0 else layout_spacing_y
@@ -180,7 +184,9 @@ class VoiceMixer(QWidget):
# Icons layout (flag and gender) # Icons layout (flag and gender)
icons_layout = QHBoxLayout() icons_layout = QHBoxLayout()
icons_layout.setSpacing(3) icons_layout.setSpacing(3)
icons_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) # Center the icons horizontally icons_layout.setAlignment(
Qt.AlignmentFlag.AlignCenter
) # Center the icons horizontally
# Flag icon # Flag icon
flag_icon_path = get_resource_path( flag_icon_path = get_resource_path(
@@ -193,11 +199,21 @@ class VoiceMixer(QWidget):
gender_label = QLabel() gender_label = QLabel()
flag_pixmap = QPixmap(flag_icon_path) flag_pixmap = QPixmap(flag_icon_path)
flag_label.setPixmap( flag_label.setPixmap(
flag_pixmap.scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) flag_pixmap.scaled(
16,
16,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
) )
gender_pixmap = QPixmap(gender_icon_path) gender_pixmap = QPixmap(gender_icon_path)
gender_label.setPixmap( gender_label.setPixmap(
gender_pixmap.scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) gender_pixmap.scaled(
16,
16,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
) )
icons_layout.addWidget(flag_label) icons_layout.addWidget(flag_label)
icons_layout.addWidget(gender_label) icons_layout.addWidget(gender_label)
@@ -221,7 +237,9 @@ class VoiceMixer(QWidget):
self.slider = QSlider(Qt.Orientation.Vertical) self.slider = QSlider(Qt.Orientation.Vertical)
self.slider.setRange(0, 100) self.slider.setRange(0, 100)
self.slider.setValue(int(initial_weight * 100)) self.slider.setValue(int(initial_weight * 100))
self.slider.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) self.slider.setSizePolicy(
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
)
self.slider.setFixedWidth(SLIDER_WIDTH) self.slider.setFixedWidth(SLIDER_WIDTH)
# Fix slider in Windows # Fix slider in Windows
@@ -251,7 +269,9 @@ class VoiceMixer(QWidget):
slider_layout.addWidget(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter)) slider_layout.addWidget(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter))
slider_center_layout = QHBoxLayout() slider_center_layout = QHBoxLayout()
slider_center_layout.addWidget(self.slider, alignment=Qt.AlignmentFlag.AlignHCenter) slider_center_layout.addWidget(
self.slider, alignment=Qt.AlignmentFlag.AlignHCenter
)
slider_center_layout.setContentsMargins(0, 0, 0, 0) slider_center_layout.setContentsMargins(0, 0, 0, 0)
slider_center_widget = QWidget() slider_center_widget = QWidget()
@@ -307,7 +327,9 @@ class HoverLabel(QLabel):
) )
# Make sure the entire button is clickable, not just the text # Make sure the entire button is clickable, not just the text
self.delete_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.delete_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.delete_button.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) self.delete_button.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, False
)
self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.delete_button.hide() self.delete_button.hide()
@@ -408,7 +430,9 @@ class VoiceFormulaDialog(QDialog):
self.setWindowTitle("Voice Mixer") self.setWindowTitle("Voice Mixer")
self.setWindowFlags( self.setWindowFlags(
Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowMaximizeButtonHint Qt.WindowType.Window
| Qt.WindowType.WindowCloseButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
) )
self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT)
@@ -473,8 +497,12 @@ class VoiceFormulaDialog(QDialog):
# Voice list scroll area # Voice list scroll area
self.scroll_area = QScrollArea() self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True) self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.scroll_area.setHorizontalScrollBarPolicy(
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.scroll_area.viewport().installEventFilter(self) self.scroll_area.viewport().installEventFilter(self)
self.voice_list_widget = QWidget() self.voice_list_widget = QWidget()
@@ -564,7 +592,9 @@ class VoiceFormulaDialog(QDialog):
self, self,
"Unsaved Changes", "Unsaved Changes",
msg, msg,
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save, QMessageBox.StandardButton.Save,
) )
if ret == QMessageBox.StandardButton.Save: if ret == QMessageBox.StandardButton.Save:
@@ -620,7 +650,9 @@ class VoiceFormulaDialog(QDialog):
"You have unsaved changes in your profile. Do you want to save the changes?" "You have unsaved changes in your profile. Do you want to save the changes?"
) )
box.setStandardButtons( box.setStandardButtons(
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel
) )
box.setDefaultButton(QMessageBox.StandardButton.Save) box.setDefaultButton(QMessageBox.StandardButton.Save)
ret = box.exec() ret = box.exec()
@@ -769,7 +801,9 @@ class VoiceFormulaDialog(QDialog):
f'<b><span style="color:{COLORS.get("BLUE")}">{name}: {percentage:.1f}%</span></b>', f'<b><span style="color:{COLORS.get("BLUE")}">{name}: {percentage:.1f}%</span></b>',
name, name,
) )
voice_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) voice_label.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred
)
voice_label.delete_button.clicked.connect( voice_label.delete_button.clicked.connect(
lambda _, vn=name: self.disable_voice_by_name(vn) lambda _, vn=name: self.disable_voice_by_name(vn)
) )
@@ -1138,7 +1172,10 @@ class VoiceFormulaDialog(QDialog):
msg += f"\nThis will overwrite an existing profile." msg += f"\nThis will overwrite an existing profile."
msg += "\nContinue?" msg += "\nContinue?"
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Import Profile", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No self,
"Import Profile",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
return return
@@ -1155,7 +1192,10 @@ class VoiceFormulaDialog(QDialog):
msg += f"\n{len(collisions)} profile(s) will be overwritten." msg += f"\n{len(collisions)} profile(s) will be overwritten."
msg += "\nContinue?" msg += "\nContinue?"
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Import Profiles", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No self,
"Import Profiles",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
return return
@@ -1394,7 +1434,8 @@ class VoiceFormulaDialog(QDialog):
item.setData(Qt.ItemDataRole.BackgroundRole, color) item.setData(Qt.ItemDataRole.BackgroundRole, color)
else: else:
item.setData( item.setData(
Qt.ItemDataRole.BackgroundRole, self.profile_list.palette().base().color() Qt.ItemDataRole.BackgroundRole,
self.profile_list.palette().base().color(),
) )
weights = profiles.get(name, {}).get("voices", []) weights = profiles.get(name, {}).get("voices", [])
total = 0 total = 0