Files
2026-03-05 23:23:08 -06:00

340 lines
14 KiB
Python
Raw Permalink 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.
"""
Main window: directory picker, file list, rule selector + stack, preview table, Apply.
"""
import os
from pathlib import Path
from PyQt6.QtWidgets import (
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QSplitter,
QLabel,
QPushButton,
QLineEdit,
QTableWidget,
QTableWidgetItem,
QFileDialog,
QScrollArea,
QGroupBox,
QMessageBox,
QHeaderView,
QAbstractItemView,
QFrame,
)
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel
from PyQt6.QtGui import QFont, QColor
from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo
from engine.rules import Rule
from .rule_widgets import (
ReplaceRuleWidget,
RegexRuleWidget,
InsertRuleWidget,
RemoveRuleWidget,
CaseRuleWidget,
NumberingRuleWidget,
EpisodeRenumberRuleWidget,
PrefixSuffixRuleWidget,
CsvMappingRuleWidget,
)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("HSRename")
self.resize(1000, 700)
self._base_dir = ""
self._file_names: list[str] = []
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
self._setup_ui()
self._refresh_preview()
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
# Top: directory bar
dir_layout = QHBoxLayout()
self.dir_edit = QLineEdit()
self.dir_edit.setPlaceholderText("Select a folder or enter path...")
self.dir_edit.textChanged.connect(self._on_dir_changed)
browse_btn = QPushButton("Browse…")
browse_btn.clicked.connect(self._browse)
dir_layout.addWidget(QLabel("Folder:"))
dir_layout.addWidget(self.dir_edit, 1)
dir_layout.addWidget(browse_btn)
layout.addLayout(dir_layout)
# Split: left = rules, right = file list + preview
split = QSplitter(Qt.Orientation.Horizontal)
# Left: all rule panels at once (enable and configure multiple rules)
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.addWidget(QLabel("Rename rules (applied in order; enable and configure any combination):"))
self._rule_widgets = [
ReplaceRuleWidget(),
RegexRuleWidget(),
InsertRuleWidget(),
RemoveRuleWidget(),
CaseRuleWidget(),
NumberingRuleWidget(),
EpisodeRenumberRuleWidget(),
PrefixSuffixRuleWidget(),
CsvMappingRuleWidget(),
]
rule_titles = [
"1. Replace",
"2. Regex",
"3. Insert",
"4. Remove",
"5. Case",
"6. Numbering",
"7. Episode renumber",
"8. Prefix / Suffix",
"9. CSV mapping",
]
for title, w in zip(rule_titles, self._rule_widgets):
w.enabled_cb.toggled.connect(self._refresh_preview) # always refresh when checkbox toggled
w.ruleChanged.connect(self._on_rule_changed) # refresh only when enabled rules options change
g = QGroupBox(title)
g_layout = QVBoxLayout(g)
g_layout.setContentsMargins(8, 12, 8, 8)
g_layout.addWidget(w)
left_layout.addWidget(g)
left_layout.addStretch(1)
scroll_left = QScrollArea()
scroll_left.setWidget(left)
scroll_left.setWidgetResizable(True)
scroll_left.setFrameShape(QFrame.Shape.NoFrame)
scroll_left.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
split.addWidget(scroll_left)
# Right: file list + preview table
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.addWidget(QLabel("Preview (Original → New name):"))
self.preview_table = QTableWidget()
self.preview_table.setColumnCount(3)
self.preview_table.setHorizontalHeaderLabels(["Original name", "New name", "File type"])
self.preview_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.preview_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.preview_table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.preview_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.preview_table.setSortingEnabled(True)
self.preview_table.itemSelectionChanged.connect(self._on_preview_selection_changed)
right_layout.addWidget(self.preview_table, 1)
self.preview_status = QLabel("Add a folder to see files.")
right_layout.addWidget(self.preview_status)
btn_layout = QHBoxLayout()
apply_btn = QPushButton("Apply renames")
apply_btn.setMinimumHeight(36)
apply_btn.clicked.connect(self._apply_renames)
undo_btn = QPushButton("Undo last rename")
undo_btn.setMinimumHeight(36)
undo_btn.clicked.connect(self._undo_renames)
btn_layout.addWidget(apply_btn)
btn_layout.addWidget(undo_btn)
right_layout.addLayout(btn_layout)
split.addWidget(right)
split.setSizes([320, 680])
layout.addWidget(split, 1)
def _browse(self):
path = QFileDialog.getExistingDirectory(self, "Select folder")
if path:
self.dir_edit.setText(path)
def _on_dir_changed(self):
path = self.dir_edit.text().strip()
if not path or not os.path.isdir(path):
self._base_dir = ""
self._file_names = []
else:
self._base_dir = path
self._file_names = sorted(
f for f in os.listdir(path)
if os.path.isfile(os.path.join(path, f)) and not f.startswith(".")
)
self._refresh_preview()
def _on_rule_changed(self):
"""Refresh preview only if the changed rule has 'Use this rule' checked (no preview from unchecked rules)."""
sender = self.sender()
if sender is None:
self._refresh_preview()
return
if sender in self._rule_widgets and sender.enabled_cb.isChecked():
self._refresh_preview()
# else: change was in an unchecked rule (e.g. mouse wheel on spinbox) — do not refresh
def _get_rules(self) -> list[Rule]:
"""All rules in fixed order; only enabled rules are applied."""
return [w.getRule() for w in self._rule_widgets]
def _ext_for(self, filename: str) -> str:
"""Return file extension with leading dot, or empty string if none."""
if "." in filename and not filename.startswith("."):
return "." + filename.rsplit(".", 1)[-1].lower()
return ""
def _on_preview_selection_changed(self):
"""Show preview (new name) only in selected rows; others show original."""
if not self._preview_by_orig:
return
selected_rows = {idx.row() for idx in self.preview_table.selectionModel().selectedRows()}
for row in range(self.preview_table.rowCount()):
orig_item = self.preview_table.item(row, 0)
if not orig_item:
continue
orig = orig_item.text()
display_name = self._preview_by_orig.get(orig, orig) if row in selected_rows else orig
self.preview_table.setItem(row, 1, QTableWidgetItem(display_name))
def _refresh_preview(self):
rules = self._get_rules()
if not self._file_names:
self._preview_by_orig = {}
self.preview_table.setSortingEnabled(False)
self.preview_table.setRowCount(0)
self.preview_status.setText("Add a folder to see files.")
return
# Preserve sort and selection so checking a rule doesnt reorder or clear selection
header = self.preview_table.horizontalHeader()
sort_section = header.sortIndicatorSection()
sort_order = header.sortIndicatorOrder()
selected_orig = set()
for idx in self.preview_table.selectionModel().selectedRows():
item = self.preview_table.item(idx.row(), 0)
if item:
selected_orig.add(item.text())
self.preview_table.setSortingEnabled(False)
self.preview_table.setRowCount(len(self._file_names))
for row, orig in enumerate(self._file_names):
self.preview_table.setItem(row, 0, QTableWidgetItem(orig))
self.preview_table.setItem(row, 1, QTableWidgetItem(orig)) # show original until selected
self.preview_table.setItem(row, 2, QTableWidgetItem(self._ext_for(orig)))
self.preview_table.setSortingEnabled(True)
# Actually sort the table (setSortIndicator alone doesnt); default to File type so episode renumber doesnt skip
if sort_section < 0:
sort_section = 2
sort_order = Qt.SortOrder.AscendingOrder
self.preview_table.sortByColumn(sort_section, sort_order)
# Numbering uses the tables current (sorted) order, not load order
order = [self.preview_table.item(r, 0).text() for r in range(self.preview_table.rowCount())]
preview = compute_preview(order, rules)
self._preview_by_orig = {orig: new_name for orig, new_name in preview}
new_names = [p[1] for p in preview]
has_collision = len(new_names) != len(set(new_names))
# Restore selection by original name (multi-row)
sm = self.preview_table.selectionModel()
sm.clear()
for row in range(self.preview_table.rowCount()):
item = self.preview_table.item(row, 0)
if item and item.text() in selected_orig:
idx = self.preview_table.model().index(row, 0)
sm.select(idx, QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows)
self._on_preview_selection_changed()
if has_collision:
self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.")
self.preview_status.setStyleSheet("color: #c00;")
else:
self.preview_status.setText(f"{len(preview)} file(s). Ready to rename.")
self.preview_status.setStyleSheet("")
def _apply_renames(self):
if not self._base_dir or not self._file_names:
QMessageBox.warning(self, "No files", "Select a folder with files first.")
return
rules = self._get_rules()
# Use current table order (after user sort) so numbering/episode renumber follow visible order
if self.preview_table.rowCount() > 0:
order = [self.preview_table.item(r, 0).text() for r in range(self.preview_table.rowCount())]
else:
order = self._file_names
full_preview = compute_preview(order, rules)
# Only rename selected rows; if nothing selected, rename all that would change
selected_orig = {
self.preview_table.item(idx.row(), 0).text()
for idx in self.preview_table.selectionModel().selectedRows()
if self.preview_table.item(idx.row(), 0)
}
if selected_orig:
renames = [(a, b) for a, b in full_preview if a in selected_orig and a != b]
else:
renames = [(a, b) for a, b in full_preview if a != b]
if not renames:
QMessageBox.information(self, "Nothing to do", "No names would change with current rules.")
return
new_names = [b for _, b in renames]
if len(new_names) != len(set(new_names)):
QMessageBox.critical(
self,
"Collision",
"Some new names are duplicated. Change your rules to avoid overwriting files.",
)
return
ok = QMessageBox.question(
self,
"Confirm",
f"Rename {len(renames)} file(s) in\n{self._base_dir}?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if ok != QMessageBox.StandardButton.Yes:
return
results = perform_renames(self._base_dir, renames, dry_run=False)
errors = [r for r in results if r[2]]
if errors:
msg = "\n".join(f"{r[0]}: {r[2]}" for r in errors[:10])
if len(errors) > 10:
msg += f"\n... and {len(errors) - 10} more."
QMessageBox.warning(self, "Rename errors", msg)
else:
save_undo_log(self._base_dir, renames)
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
self._on_dir_changed()
def _undo_renames(self):
if not self._base_dir:
QMessageBox.warning(self, "No folder", "Select a folder first.")
return
renames = load_undo_log(self._base_dir)
if not renames:
QMessageBox.information(
self,
"No undo data",
f"No undo data for this folder.\n\nUndo is available after you run \"Apply renames\" here.",
)
return
ok = QMessageBox.question(
self,
"Undo last rename",
f"Revert {len(renames)} file(s) in\n{self._base_dir}\nback to their previous names?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if ok != QMessageBox.StandardButton.Yes:
return
results = perform_undo(self._base_dir)
errors = [r for r in results if r[2]]
if errors:
msg = "\n".join(f"{r[0]}: {r[2]}" for r in errors[:10])
if len(errors) > 10:
msg += f"\n... and {len(errors) - 10} more."
QMessageBox.warning(self, "Undo errors", msg)
else:
QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).")
self._on_dir_changed()