Initial commit: Bulk Renamer with AppImage build and Gitea push notes

Made-with: Cursor
This commit is contained in:
Bulk Renamer
2026-03-03 21:58:28 -06:00
commit 22501fe0b5
13 changed files with 1135 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# GUI package
+293
View File
@@ -0,0 +1,293 @@
"""
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
from engine.rules import Rule
from .rule_widgets import (
ReplaceRuleWidget,
RegexRuleWidget,
InsertRuleWidget,
RemoveRuleWidget,
CaseRuleWidget,
NumberingRuleWidget,
EpisodeRenumberRuleWidget,
PrefixSuffixRuleWidget,
)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Bulk Renamer")
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(),
]
rule_titles = [
"1. Replace",
"2. Regex",
"3. Insert",
"4. Remove",
"5. Case",
"6. Numbering",
"7. Episode renumber",
"8. Prefix / Suffix",
]
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)
apply_btn = QPushButton("Apply renames")
apply_btn.setMinimumHeight(36)
apply_btn.clicked.connect(self._apply_renames)
right_layout.addWidget(apply_btn)
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)
header.setSortIndicator(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:
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
self._on_dir_changed()
+327
View File
@@ -0,0 +1,327 @@
"""
Rule configuration widgets: each rule type has a small form that updates the Rule model.
"""
from PyQt6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QSpinBox,
QComboBox,
QCheckBox,
QGroupBox,
QFormLayout,
QStackedWidget,
)
from PyQt6.QtCore import pyqtSignal
from engine.rules import (
ReplaceRule,
InsertRule,
RemoveRule,
CaseRule,
NumberingRule,
EpisodeRenumberRule,
RegexRule,
PrefixSuffixRule,
)
class ReplaceRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.find = QLineEdit()
self.find.setPlaceholderText("Text to find")
self.find.textChanged.connect(self._emit)
self.replace = QLineEdit()
self.replace.setPlaceholderText("Replace with")
self.replace.textChanged.connect(self._emit)
self.caseSensitive = QCheckBox("Case sensitive")
self.caseSensitive.toggled.connect(self._emit)
self.wholeWord = QCheckBox("Whole word only")
self.wholeWord.toggled.connect(self._emit)
layout.addRow("Find:", self.find)
layout.addRow("Replace with:", self.replace)
layout.addRow(self.caseSensitive)
layout.addRow(self.wholeWord)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> ReplaceRule:
r = ReplaceRule(
find=self.find.text(),
replace=self.replace.text(),
case_sensitive=self.caseSensitive.isChecked(),
whole_word=self.wholeWord.isChecked(),
)
r.enabled = self.enabled_cb.isChecked()
return r
class RegexRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.pattern = QLineEdit()
self.pattern.setPlaceholderText(r"e.g. S(\d+)E(\d+)")
self.pattern.textChanged.connect(self._emit)
self.replacement = QLineEdit()
self.replacement.setPlaceholderText(r"e.g. S\1E\2")
self.replacement.textChanged.connect(self._emit)
layout.addRow("Regex pattern:", self.pattern)
layout.addRow("Replacement:", self.replacement)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> RegexRule:
r = RegexRule(pattern=self.pattern.text(), replacement=self.replacement.text())
r.enabled = self.enabled_cb.isChecked()
return r
class InsertRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.text = QLineEdit()
self.text.setPlaceholderText("Text to insert")
self.text.textChanged.connect(self._emit)
self.position = QComboBox()
self.position.addItems(["At start", "At end", "At position..."])
self.position.setCurrentIndex(0)
self.position.currentIndexChanged.connect(self._emit)
self.positionSpin = QSpinBox()
self.positionSpin.setMinimum(0)
self.positionSpin.setMaximum(9999)
self.positionSpin.valueChanged.connect(self._emit)
layout.addRow("Text:", self.text)
layout.addRow("Position:", self.position)
layout.addRow("Index:", self.positionSpin)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> InsertRule:
idx = self.position.currentIndex()
if idx == 0:
pos = 0
elif idx == 1:
pos = -1
else:
pos = self.positionSpin.value()
r = InsertRule(text=self.text.text(), position=pos)
r.enabled = self.enabled_cb.isChecked()
return r
class RemoveRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.removeType = QComboBox()
self.removeType.addItems([
"Remove text",
"Remove all digits",
"Remove first N characters",
"Remove last N characters",
])
self.removeType.currentIndexChanged.connect(self._emit)
self.value = QLineEdit()
self.value.setPlaceholderText("Text or number")
self.value.textChanged.connect(self._emit)
layout.addRow("Type:", self.removeType)
layout.addRow("Value:", self.value)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> RemoveRule:
idx = self.removeType.currentIndex()
types = ["chars", "digits", "first_n", "last_n"]
r = RemoveRule(remove_type=types[idx], value=self.value.text())
r.enabled = self.enabled_cb.isChecked()
return r
class CaseRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.caseType = QComboBox()
self.caseType.addItems(["Title Case", "UPPER", "lower", "Sentence case"])
self.caseType.currentIndexChanged.connect(self._emit)
layout.addRow("Case:", self.caseType)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> CaseRule:
types = ["title", "upper", "lower", "sentence"]
r = CaseRule(case_type=types[self.caseType.currentIndex()])
r.enabled = self.enabled_cb.isChecked()
return r
class NumberingRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.start = QSpinBox()
self.start.setMinimum(-9999)
self.start.setMaximum(99999)
self.start.setValue(1)
self.start.valueChanged.connect(self._emit)
self.step = QSpinBox()
self.step.setMinimum(1)
self.step.setMaximum(100)
self.step.setValue(1)
self.step.valueChanged.connect(self._emit)
self.padding = QSpinBox()
self.padding.setMinimum(1)
self.padding.setMaximum(10)
self.padding.setValue(2)
self.padding.valueChanged.connect(self._emit)
self.where = QComboBox()
self.where.addItems(["Prefix", "Suffix", "Insert at index"])
self.where.currentIndexChanged.connect(self._emit)
self.separator = QLineEdit()
self.separator.setText(" ")
self.separator.textChanged.connect(self._emit)
self.insertAt = QSpinBox()
self.insertAt.setMinimum(0)
self.insertAt.setMaximum(9999)
self.insertAt.valueChanged.connect(self._emit)
layout.addRow("Start:", self.start)
layout.addRow("Step:", self.step)
layout.addRow("Padding:", self.padding)
layout.addRow("Where:", self.where)
layout.addRow("Separator:", self.separator)
layout.addRow("Insert at index:", self.insertAt)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> NumberingRule:
where_map = ["prefix", "suffix", "insert_at"]
r = NumberingRule(
start=self.start.value(),
step=self.step.value(),
padding=self.padding.value(),
where=where_map[self.where.currentIndex()],
insert_at=self.insertAt.value(),
separator=self.separator.text() or " ",
)
r.enabled = self.enabled_cb.isChecked()
return r
class EpisodeRenumberRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.start = QSpinBox()
self.start.setMinimum(1)
self.start.setMaximum(9999)
self.start.setValue(1)
self.start.valueChanged.connect(self._emit)
self.step = QSpinBox()
self.step.setMinimum(1)
self.step.setValue(1)
self.step.valueChanged.connect(self._emit)
self.padding = QSpinBox()
self.padding.setMinimum(1)
self.padding.setMaximum(3)
self.padding.setValue(2)
self.padding.valueChanged.connect(self._emit)
layout.addRow("First episode number:", self.start)
layout.addRow("Step:", self.step)
layout.addRow("Zero-pad width:", self.padding)
info = QLabel("Matches patterns like S01E05 - Title or Show 1x03 - Title. Episode number is replaced; title is kept.")
info.setWordWrap(True)
layout.addRow(info)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> EpisodeRenumberRule:
r = EpisodeRenumberRule(
start=self.start.value(),
step=self.step.value(),
padding=self.padding.value(),
)
r.enabled = self.enabled_cb.isChecked()
return r
class PrefixSuffixRuleWidget(QWidget):
ruleChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QFormLayout(self)
self.enabled_cb = QCheckBox("Use this rule")
self.enabled_cb.setChecked(False)
self.enabled_cb.toggled.connect(self._emit)
layout.addRow(self.enabled_cb)
self.prefix = QLineEdit()
self.prefix.setPlaceholderText("Prefix")
self.prefix.textChanged.connect(self._emit)
self.suffix = QLineEdit()
self.suffix.setPlaceholderText("Suffix")
self.suffix.textChanged.connect(self._emit)
layout.addRow("Prefix:", self.prefix)
layout.addRow("Suffix:", self.suffix)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> PrefixSuffixRule:
r = PrefixSuffixRule(prefix=self.prefix.text(), suffix=self.suffix.text())
r.enabled = self.enabled_cb.isChecked()
return r