DazedTL/gui/csv_tab.py
DazedAnon 65a7ee51c2 feat(csv): add opt-in skip for already-translated batches
- Skip candidate batches when every target is translated
- Keep mixed batches and use global BATCHSIZE for checks
- Add CSV setting checkbox and unit coverage
2026-07-21 16:12:20 -05:00

560 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
CSV Tab - Configuration for CSV translation settings
"""
from pathlib import Path
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QCheckBox,
QPushButton, QLabel, QMessageBox, QSpinBox, QFrame, QComboBox
)
from PyQt5.QtCore import Qt, pyqtSignal
try:
from .config_integration import ConfigIntegration
except ImportError:
ConfigIntegration = None
def create_section_label(text):
"""Create a section label for grouping settings."""
from gui.qt_icons import make_section_header
return make_section_header(
text,
"QLabel {"
"font-size: 12px;"
"font-weight: bold;"
"color: #007acc;"
"padding: 5px 0px 3px 0px;"
"background-color: transparent;"
"}",
)
def create_horizontal_line():
"""Create a horizontal separator line."""
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setStyleSheet("QFrame { color: #555555; margin: 5px 0px; }")
return line
class CSVTab(QWidget):
"""CSV configuration tab for managing CSV translation settings."""
# Default configuration values (0-indexed internally)
DEFAULT_CONFIG = {
# Delimiter
"CSV_DELIMITER": ",",
# Column settings (stored as 0-indexed)
"SOURCE_COLUMN": 0,
"TARGET_COLUMN": 1,
"SPEAKER_COLUMN": -1, # -1 means no speaker column
# Row settings
"SKIP_HEADER_ROW": True,
"USE_TARGET_IF_NOT_EMPTY": False,
"SKIP_IF_TARGET_TRANSLATED": False,
# Output settings
"WRITE_TO_NEXT_COLUMN": False,
# Special parsing
"PARSE_NAME_TAGS": False,
"PARSE_M_MARKERS": False,
"REMOVE_FURIGANA": False,
"SKIP_COMMENT_ROWS": False,
}
config_changed = pyqtSignal()
def __init__(self):
super().__init__()
self.config_integration = ConfigIntegration() if ConfigIntegration else None
self.init_ui()
# Load configuration from module
try:
self.disconnect_auto_apply()
loaded_config = None
if self.config_integration:
module_path = Path("modules") / "csv.py"
loaded_config = self.read_csv_config(module_path)
if loaded_config:
self.set_config(loaded_config)
else:
self.set_config(self.DEFAULT_CONFIG)
except Exception:
self.set_config(self.DEFAULT_CONFIG)
self.connect_auto_apply()
def init_ui(self):
"""Initialize the user interface."""
main_layout = QVBoxLayout()
main_layout.setContentsMargins(15, 15, 15, 15)
main_layout.setSpacing(10)
# Title and description
title_label = QLabel("CSV Translation Settings")
title_label.setStyleSheet("font-size: 15px; font-weight: bold; color: #007acc;")
main_layout.addWidget(title_label)
description_label = QLabel(
"Configure how CSV files are parsed and translated. These settings replace the old format presets."
)
description_label.setWordWrap(True)
description_label.setStyleSheet("color: #888888; font-size: 10px; margin-bottom: 5px;")
main_layout.addWidget(description_label)
# Quick Presets at the top, spaced equally
main_layout.addWidget(create_section_label("⚡ Quick Presets"))
preset_layout = QHBoxLayout()
preset_layout.setSpacing(15)
preset_tpp = QPushButton("Translator++")
preset_tpp.setToolTip("Source col 1, Target col 2, skip header, use target if not empty")
preset_tpp.clicked.connect(self.apply_preset_tpp)
preset_layout.addWidget(preset_tpp, 1) # stretch factor 1 for equal spacing
preset_simple = QPushButton("Simple")
preset_simple.setToolTip("Source col 1, Target col 2, no special processing")
preset_simple.clicked.connect(self.apply_preset_simple)
preset_layout.addWidget(preset_simple, 1)
preset_speaker = QPushButton("Speaker & Text")
preset_speaker.setToolTip("Speaker col 3, Text col 10, with furigana removal")
preset_speaker.clicked.connect(self.apply_preset_speaker)
preset_layout.addWidget(preset_speaker, 1)
main_layout.addLayout(preset_layout)
main_layout.addWidget(create_horizontal_line())
# Two-column layout
columns_layout = QHBoxLayout()
columns_layout.setSpacing(40)
# LEFT COLUMN
left_column = QVBoxLayout()
left_column.setSpacing(5)
# Column Settings
left_column.addWidget(create_section_label("📊 Column Settings"))
column_form = QFormLayout()
column_form.setSpacing(8)
column_form.setContentsMargins(0, 0, 0, 10)
# Source Column
source_label = QLabel("Source Column:")
source_label.setToolTip("Which column contains the source text to translate (1 = first column)")
self.source_column_spin = QSpinBox()
self.source_column_spin.setRange(1, 100)
self.source_column_spin.setValue(1)
self.source_column_spin.setMinimumWidth(100)
column_form.addRow(source_label, self.source_column_spin)
# Target Column
target_label = QLabel("Target Column:")
target_label.setToolTip("Which column to write translations to (1 = first column)")
self.target_column_spin = QSpinBox()
self.target_column_spin.setRange(1, 100)
self.target_column_spin.setValue(2)
self.target_column_spin.setMinimumWidth(100)
column_form.addRow(target_label, self.target_column_spin)
# Speaker Column
speaker_label = QLabel("Speaker Column:")
speaker_label.setToolTip("Which column contains speaker names (None = disabled)")
self.speaker_column_spin = QSpinBox()
self.speaker_column_spin.setRange(0, 100)
self.speaker_column_spin.setValue(0)
self.speaker_column_spin.setSpecialValueText("None")
self.speaker_column_spin.setMinimumWidth(100)
column_form.addRow(speaker_label, self.speaker_column_spin)
left_column.addLayout(column_form)
left_column.addWidget(create_horizontal_line())
# Row Settings
left_column.addWidget(create_section_label("📝 Row Settings"))
self.skip_header_cb = QCheckBox("Skip Header Row")
self.skip_header_cb.setToolTip("Skip the first row (usually contains column headers)")
left_column.addWidget(self.skip_header_cb)
self.use_target_if_not_empty_cb = QCheckBox("Use Target if Not Empty")
self.use_target_if_not_empty_cb.setToolTip("If target column already has text, use that instead of source (T++ style)")
left_column.addWidget(self.use_target_if_not_empty_cb)
self.skip_if_target_translated_cb = QCheckBox("Skip if Target Translated")
self.skip_if_target_translated_cb.setToolTip(
"Check candidate rows in batches matching the global Batch Size setting. "
"Skip a batch only if every target is already translated (non-empty, no Japanese). "
"If any row still needs work, the whole batch is translated."
)
left_column.addWidget(self.skip_if_target_translated_cb)
left_column.addStretch()
# RIGHT COLUMN
right_column = QVBoxLayout()
right_column.setSpacing(5)
# Output Settings
right_column.addWidget(create_section_label("📤 Output Settings"))
self.write_next_column_cb = QCheckBox("Write to Next Column")
self.write_next_column_cb.setToolTip("Write translation to the column after target instead of overwriting")
right_column.addWidget(self.write_next_column_cb)
# CSV Delimiter
delim_layout = QHBoxLayout()
delim_label = QLabel("Delimiter:")
delim_label.setToolTip("Character used to separate columns in the CSV file")
self.csv_delimiter_combo = QComboBox()
self.csv_delimiter_combo.addItems([",", ";", "Tab"])
self.csv_delimiter_combo.setMinimumWidth(80)
delim_layout.addWidget(delim_label)
delim_layout.addWidget(self.csv_delimiter_combo)
delim_layout.addStretch()
right_column.addLayout(delim_layout)
right_column.addWidget(create_horizontal_line())
# Special Parsing
right_column.addWidget(create_section_label("🔧 Special Parsing"))
self.parse_name_tags_cb = QCheckBox("Parse :name[] Tags")
self.parse_name_tags_cb.setToolTip("Extract speaker names from :name[Speaker] tags in text")
right_column.addWidget(self.parse_name_tags_cb)
self.parse_m_markers_cb = QCheckBox("Parse \\M Markers")
self.parse_m_markers_cb.setToolTip("Handle \\M escape sequences in text")
right_column.addWidget(self.parse_m_markers_cb)
self.remove_furigana_cb = QCheckBox("Remove Furigana ")
self.remove_furigana_cb.setToolTip("Remove furigana annotations like <漢字=かんじ>")
right_column.addWidget(self.remove_furigana_cb)
self.skip_comment_rows_cb = QCheckBox("Skip Comment Rows")
self.skip_comment_rows_cb.setToolTip("Skip rows where first column contains 'comment'")
right_column.addWidget(self.skip_comment_rows_cb)
right_column.addStretch()
# Add columns
columns_layout.addLayout(left_column, 1)
columns_layout.addLayout(right_column, 1)
main_layout.addLayout(columns_layout)
# Bottom buttons
main_layout.addSpacing(12)
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
from gui.qt_icons import apply_button_icon
self.reset_button = QPushButton()
apply_button_icon(self.reset_button, "🔄 Reset to Defaults", color="#cccccc")
self.reset_button.clicked.connect(self.reset_to_defaults_with_message)
self.reset_button.setMaximumWidth(180)
self.reset_button.setMinimumHeight(32)
button_layout.addWidget(self.reset_button)
button_layout.addStretch()
main_layout.addLayout(button_layout)
self.setLayout(main_layout)
def apply_preset_tpp(self):
"""Apply Translator++ preset."""
self.disconnect_auto_apply()
self.source_column_spin.setValue(1) # Display as 1-based
self.target_column_spin.setValue(2)
self.speaker_column_spin.setValue(0) # 0 = None
self.skip_header_cb.setChecked(True)
self.use_target_if_not_empty_cb.setChecked(True)
self.skip_if_target_translated_cb.setChecked(False)
self.write_next_column_cb.setChecked(False)
self.parse_name_tags_cb.setChecked(False)
self.parse_m_markers_cb.setChecked(False)
self.remove_furigana_cb.setChecked(False)
self.skip_comment_rows_cb.setChecked(False)
self.connect_auto_apply()
self.apply_to_module(show_messages=False)
def apply_preset_simple(self):
"""Apply simple two-column preset."""
self.disconnect_auto_apply()
self.source_column_spin.setValue(1) # Display as 1-based
self.target_column_spin.setValue(2)
self.speaker_column_spin.setValue(0) # 0 = None
self.skip_header_cb.setChecked(False)
self.use_target_if_not_empty_cb.setChecked(False)
self.skip_if_target_translated_cb.setChecked(False)
self.write_next_column_cb.setChecked(False)
self.parse_name_tags_cb.setChecked(False)
self.parse_m_markers_cb.setChecked(False)
self.remove_furigana_cb.setChecked(False)
self.skip_comment_rows_cb.setChecked(False)
self.connect_auto_apply()
self.apply_to_module(show_messages=False)
def apply_preset_speaker(self):
"""Apply speaker & text preset (like old format 4)."""
self.disconnect_auto_apply()
self.source_column_spin.setValue(10) # Display as 1-based (was 9)
self.target_column_spin.setValue(10)
self.speaker_column_spin.setValue(3) # Display as 1-based (was 2, 0=None so 3=col2)
self.skip_header_cb.setChecked(False)
self.use_target_if_not_empty_cb.setChecked(False)
self.skip_if_target_translated_cb.setChecked(False)
self.write_next_column_cb.setChecked(False)
self.parse_name_tags_cb.setChecked(False)
self.parse_m_markers_cb.setChecked(False)
self.remove_furigana_cb.setChecked(True)
self.skip_comment_rows_cb.setChecked(False)
self.connect_auto_apply()
self.apply_to_module(show_messages=False)
def reset_to_defaults(self):
"""Reset all settings to default values."""
self.disconnect_auto_apply()
self.set_config(self.DEFAULT_CONFIG)
self.connect_auto_apply()
self.apply_to_module(show_messages=False)
def reset_to_defaults_with_message(self):
"""Reset to defaults and show confirmation."""
self.reset_to_defaults()
QMessageBox.information(
self,
"Reset Complete",
"All CSV settings have been reset to their default values."
)
def disconnect_auto_apply(self):
"""Disconnect all widgets from auto-apply."""
try:
self.source_column_spin.valueChanged.disconnect()
self.target_column_spin.valueChanged.disconnect()
self.speaker_column_spin.valueChanged.disconnect()
self.csv_delimiter_combo.currentIndexChanged.disconnect()
self.skip_header_cb.stateChanged.disconnect()
self.use_target_if_not_empty_cb.stateChanged.disconnect()
self.skip_if_target_translated_cb.stateChanged.disconnect()
self.write_next_column_cb.stateChanged.disconnect()
self.parse_name_tags_cb.stateChanged.disconnect()
self.parse_m_markers_cb.stateChanged.disconnect()
self.remove_furigana_cb.stateChanged.disconnect()
self.skip_comment_rows_cb.stateChanged.disconnect()
except TypeError:
pass
def connect_auto_apply(self):
"""Connect all widgets to auto-apply changes."""
self.source_column_spin.valueChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.target_column_spin.valueChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.speaker_column_spin.valueChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.csv_delimiter_combo.currentIndexChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.skip_header_cb.stateChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.use_target_if_not_empty_cb.stateChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.skip_if_target_translated_cb.stateChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.write_next_column_cb.stateChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.parse_name_tags_cb.stateChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.parse_m_markers_cb.stateChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.remove_furigana_cb.stateChanged.connect(lambda: self.apply_to_module(show_messages=False))
self.skip_comment_rows_cb.stateChanged.connect(lambda: self.apply_to_module(show_messages=False))
def get_config(self):
"""Get current configuration as dictionary (converts UI 1-based to internal 0-based)."""
# Convert from 1-based UI to 0-based internal
# Source/Target: UI shows 1-100, internal uses 0-99
# Speaker: UI shows 0 (None) or 1-100, internal uses -1 (None) or 0-99
speaker_ui = self.speaker_column_spin.value()
speaker_internal = speaker_ui - 1 if speaker_ui > 0 else -1
# Get delimiter value
delim_index = self.csv_delimiter_combo.currentIndex()
delim_values = [",", ";", "\t"]
delimiter = delim_values[delim_index] if delim_index < len(delim_values) else ","
return {
"SOURCE_COLUMN": self.source_column_spin.value() - 1,
"TARGET_COLUMN": self.target_column_spin.value() - 1,
"SPEAKER_COLUMN": speaker_internal,
"CSV_DELIMITER": delimiter,
"SKIP_HEADER_ROW": self.skip_header_cb.isChecked(),
"USE_TARGET_IF_NOT_EMPTY": self.use_target_if_not_empty_cb.isChecked(),
"SKIP_IF_TARGET_TRANSLATED": self.skip_if_target_translated_cb.isChecked(),
"WRITE_TO_NEXT_COLUMN": self.write_next_column_cb.isChecked(),
"PARSE_NAME_TAGS": self.parse_name_tags_cb.isChecked(),
"PARSE_M_MARKERS": self.parse_m_markers_cb.isChecked(),
"REMOVE_FURIGANA": self.remove_furigana_cb.isChecked(),
"SKIP_COMMENT_ROWS": self.skip_comment_rows_cb.isChecked(),
}
def set_config(self, config):
"""Set configuration from dictionary (converts internal 0-based to UI 1-based)."""
# Convert from 0-based internal to 1-based UI
# Source/Target: internal uses 0-99, UI shows 1-100
# Speaker: internal uses -1 (None) or 0-99, UI shows 0 (None) or 1-100
speaker_internal = config.get("SPEAKER_COLUMN", -1)
speaker_ui = speaker_internal + 1 if speaker_internal >= 0 else 0
self.source_column_spin.setValue(config.get("SOURCE_COLUMN", 0) + 1)
self.target_column_spin.setValue(config.get("TARGET_COLUMN", 1) + 1)
self.speaker_column_spin.setValue(speaker_ui)
# Set delimiter
delimiter = config.get("CSV_DELIMITER", ",")
if delimiter == "\t":
self.csv_delimiter_combo.setCurrentIndex(2)
elif delimiter == ";":
self.csv_delimiter_combo.setCurrentIndex(1)
else:
self.csv_delimiter_combo.setCurrentIndex(0)
self.skip_header_cb.setChecked(config.get("SKIP_HEADER_ROW", True))
self.use_target_if_not_empty_cb.setChecked(config.get("USE_TARGET_IF_NOT_EMPTY", False))
self.skip_if_target_translated_cb.setChecked(config.get("SKIP_IF_TARGET_TRANSLATED", False))
self.write_next_column_cb.setChecked(config.get("WRITE_TO_NEXT_COLUMN", False))
self.parse_name_tags_cb.setChecked(config.get("PARSE_NAME_TAGS", False))
self.parse_m_markers_cb.setChecked(config.get("PARSE_M_MARKERS", False))
self.remove_furigana_cb.setChecked(config.get("REMOVE_FURIGANA", False))
self.skip_comment_rows_cb.setChecked(config.get("SKIP_COMMENT_ROWS", False))
def read_csv_config(self, module_path: Path) -> dict:
"""Read current configuration from csv.py module file."""
if not module_path.exists():
return {}
config = {}
try:
with open(module_path, 'r', encoding='utf-8') as f:
content = f.read()
import re
# Read integer values
int_patterns = {
"SOURCE_COLUMN": r'^SOURCE_COLUMN\s*=\s*(-?\d+)',
"TARGET_COLUMN": r'^TARGET_COLUMN\s*=\s*(-?\d+)',
"SPEAKER_COLUMN": r'^SPEAKER_COLUMN\s*=\s*(-?\d+)',
}
# Read string values (delimiter)
string_patterns = {
"CSV_DELIMITER": r'^CSV_DELIMITER\s*=\s*["\'](.+?)["\']',
}
# Read boolean values
bool_patterns = {
"SKIP_HEADER_ROW": r'^SKIP_HEADER_ROW\s*=\s*(True|False)',
"USE_TARGET_IF_NOT_EMPTY": r'^USE_TARGET_IF_NOT_EMPTY\s*=\s*(True|False)',
"SKIP_IF_TARGET_TRANSLATED": r'^SKIP_IF_TARGET_TRANSLATED\s*=\s*(True|False)',
"WRITE_TO_NEXT_COLUMN": r'^WRITE_TO_NEXT_COLUMN\s*=\s*(True|False)',
"PARSE_NAME_TAGS": r'^PARSE_NAME_TAGS\s*=\s*(True|False)',
"PARSE_M_MARKERS": r'^PARSE_M_MARKERS\s*=\s*(True|False)',
"REMOVE_FURIGANA": r'^REMOVE_FURIGANA\s*=\s*(True|False)',
"SKIP_COMMENT_ROWS": r'^SKIP_COMMENT_ROWS\s*=\s*(True|False)',
}
for line in content.split('\n'):
line = line.strip()
for key, pattern in int_patterns.items():
match = re.match(pattern, line)
if match:
config[key] = int(match.group(1))
for key, pattern in string_patterns.items():
match = re.match(pattern, line)
if match:
config[key] = match.group(1)
for key, pattern in bool_patterns.items():
match = re.match(pattern, line)
if match:
config[key] = match.group(1) == 'True'
except Exception as e:
print(f"Error reading CSV config: {e}")
return config
def apply_to_module(self, show_messages=True):
"""Apply current configuration to the csv.py module file."""
if not self.config_integration:
if show_messages:
QMessageBox.warning(self, "Error", "Config integration not available")
return
module_path = Path("modules") / "csv.py"
if not module_path.exists():
if show_messages:
QMessageBox.warning(self, "Error", f"Module file not found: {module_path}")
return
try:
config = self.get_config()
with open(module_path, 'r', encoding='utf-8') as f:
content = f.read()
# Update configuration values
updated_content = self._update_csv_config(content, config)
with open(module_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
self.config_changed.emit()
if show_messages:
QMessageBox.information(self, "Success", "CSV configuration applied successfully!")
except Exception as e:
if show_messages:
QMessageBox.critical(self, "Error", f"Failed to apply configuration:\n{str(e)}")
def _update_csv_config(self, content: str, config: dict) -> str:
"""Update configuration values in the module content."""
import re
lines = content.split('\n')
updated_lines = []
for line in lines:
updated_line = line
for key, value in config.items():
# Match lines like: CONFIG_NAME = value # comment
pattern = rf'^({re.escape(key)})\s*=\s*.*?(#.*)?$'
match = re.match(pattern, line.strip())
if match:
comment = match.group(2) if match.group(2) else ""
if comment:
comment = " " + comment
# Format value appropriately
if isinstance(value, bool):
value_str = str(value)
elif isinstance(value, str):
# String values need quotes
value_str = f'"{value}"'
else:
value_str = str(value)
updated_line = f"{key} = {value_str}{comment}"
break
updated_lines.append(updated_line)
return '\n'.join(updated_lines)