498 lines
21 KiB
Python
498 lines
21 KiB
Python
"""
|
||
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."""
|
||
label = QLabel(text)
|
||
label.setStyleSheet("""
|
||
QLabel {
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
color: #007acc;
|
||
padding: 5px 0px 3px 0px;
|
||
background-color: transparent;
|
||
}
|
||
""")
|
||
return label
|
||
|
||
|
||
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 = {
|
||
# 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,
|
||
|
||
# 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)
|
||
|
||
# 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.setFixedWidth(80)
|
||
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.setFixedWidth(80)
|
||
column_form.addRow(target_label, self.target_column_spin)
|
||
|
||
# Speaker Column
|
||
speaker_label = QLabel("Speaker Column:")
|
||
speaker_label.setToolTip("Which column contains speaker names (0 = none)")
|
||
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.setFixedWidth(80)
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
# Preset buttons
|
||
main_layout.addWidget(create_horizontal_line())
|
||
main_layout.addWidget(create_section_label("⚡ Quick Presets"))
|
||
|
||
preset_layout = QHBoxLayout()
|
||
preset_layout.setSpacing(10)
|
||
|
||
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_tpp.setMaximumWidth(120)
|
||
preset_layout.addWidget(preset_tpp)
|
||
|
||
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_simple.setMaximumWidth(120)
|
||
preset_layout.addWidget(preset_simple)
|
||
|
||
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_speaker.setMaximumWidth(120)
|
||
preset_layout.addWidget(preset_speaker)
|
||
|
||
preset_layout.addStretch()
|
||
main_layout.addLayout(preset_layout)
|
||
|
||
# Bottom buttons
|
||
main_layout.addSpacing(12)
|
||
button_layout = QHBoxLayout()
|
||
button_layout.setSpacing(10)
|
||
|
||
self.reset_button = QPushButton("🔄 Reset to Defaults")
|
||
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.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.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.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.skip_header_cb.stateChanged.disconnect()
|
||
self.use_target_if_not_empty_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.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.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
|
||
|
||
return {
|
||
"SOURCE_COLUMN": self.source_column_spin.value() - 1,
|
||
"TARGET_COLUMN": self.target_column_spin.value() - 1,
|
||
"SPEAKER_COLUMN": speaker_internal,
|
||
"SKIP_HEADER_ROW": self.skip_header_cb.isChecked(),
|
||
"USE_TARGET_IF_NOT_EMPTY": self.use_target_if_not_empty_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)
|
||
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.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 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)',
|
||
"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 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)
|
||
else:
|
||
value_str = str(value)
|
||
|
||
updated_line = f"{key} = {value_str}{comment}"
|
||
break
|
||
|
||
updated_lines.append(updated_line)
|
||
|
||
return '\n'.join(updated_lines)
|