5551f507df
Made-with: Cursor
336 lines
14 KiB
Python
336 lines
14 KiB
Python
"""
|
||
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 rule’s 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 doesn’t 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)
|
||
header.setSortIndicator(sort_section, sort_order)
|
||
|
||
# Numbering uses the table’s 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()
|