Add CSV mapping rule and Undo last rename feature

Made-with: Cursor
This commit is contained in:
Bulk Renamer
2026-03-03 22:42:57 -06:00
parent 94fa790d48
commit d1a13115a4
6 changed files with 285 additions and 14 deletions
+9
View File
@@ -13,7 +13,9 @@ A **native Linux** GUI for mass renaming files. Inspired by [Bulk Rename Utility
- **Case** Title Case, UPPER, lower, Sentence case. - **Case** Title Case, UPPER, lower, Sentence case.
- **Numbering** Add a running number (prefix/suffix/insert) with start, step, and padding. - **Numbering** Add a running number (prefix/suffix/insert) with start, step, and padding.
- **Prefix / Suffix** Add fixed text to the beginning or end of every name. - **Prefix / Suffix** Add fixed text to the beginning or end of every name.
- **CSV mapping** Rename from a CSV with columns **Original Name** and **Target Name** (one row per file; lookup by current filename).
- **Safe renames** Two-pass rename to avoid overwrites; collision detection in preview. - **Safe renames** Two-pass rename to avoid overwrites; collision detection in preview.
- **Undo** After applying renames, use **Undo last rename** in the same folder to revert (undo log is stored as a hidden file in that folder).
## Requirements ## Requirements
@@ -53,6 +55,13 @@ python main.py
Sort order is the current list order (alphabetical by filename). Reorder files in the table (e.g. by dragging) if you add that later; for now, use the order given after opening the folder. Sort order is the current list order (alphabetical by filename). Reorder files in the table (e.g. by dragging) if you add that later; for now, use the order given after opening the folder.
### CSV mapping
1. Create a CSV with header row: **Original Name**, **Target Name**.
2. One data row per file: first column = current filename, second column = desired new name.
3. Enable **9. CSV mapping**, click **Browse…** and select the CSV.
4. Only rows that match a current filename in the folder are renamed; others are unchanged. You can combine with other rules (CSV is applied in rule order).
## AppImage (distribution) ## AppImage (distribution)
To build a portable AppImage: To build a portable AppImage:
+2 -1
View File
@@ -1,4 +1,4 @@
from .rules import Rule, ReplaceRule, InsertRule, RemoveRule, CaseRule, NumberingRule, EpisodeRenumberRule, RegexRule, PrefixSuffixRule from .rules import Rule, ReplaceRule, InsertRule, RemoveRule, CaseRule, NumberingRule, EpisodeRenumberRule, RegexRule, PrefixSuffixRule, CsvMappingRule
from .pipeline import apply_pipeline, compute_preview from .pipeline import apply_pipeline, compute_preview
__all__ = [ __all__ = [
@@ -11,6 +11,7 @@ __all__ = [
"EpisodeRenumberRule", "EpisodeRenumberRule",
"RegexRule", "RegexRule",
"PrefixSuffixRule", "PrefixSuffixRule",
"CsvMappingRule",
"apply_pipeline", "apply_pipeline",
"compute_preview", "compute_preview",
] ]
+57 -1
View File
@@ -1,12 +1,16 @@
""" """
Apply a list of rules to a list of filenames; produce preview and perform renames. Apply a list of rules to a list of filenames; produce preview and perform renames.
Undo: save a log after renames; undo reverses renames from that log.
""" """
import json
import os import os
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from .rules import Rule from .rules import Rule
UNDO_FILENAME = ".bulk-renamer-undo.json"
def _split_name(name: str) -> tuple[str, str]: def _split_name(name: str) -> tuple[str, str]:
if "." in name and not name.startswith("."): if "." in name and not name.startswith("."):
@@ -26,7 +30,7 @@ def apply_pipeline(
for r in rules: for r in rules:
if not r.enabled: if not r.enabled:
continue continue
stem, ext = r.apply(stem, ext, index, total) stem, ext = r.apply(stem, ext, index, total, original_name=name)
return stem + ext return stem + ext
@@ -104,3 +108,55 @@ def perform_renames(
pass pass
return results return results
def undo_log_path(base_dir: str) -> Path:
return Path(base_dir) / UNDO_FILENAME
def save_undo_log(base_dir: str, renames: List[tuple[str, str]]) -> None:
"""Save (old_name, new_name) list so undo can reverse it. Overwrites any existing log for this folder."""
path = undo_log_path(base_dir)
data = {"renames": renames}
try:
path.write_text(json.dumps(data, indent=0), encoding="utf-8")
except OSError:
pass
def load_undo_log(base_dir: str) -> Optional[List[tuple[str, str]]]:
"""Load the last rename log for this folder. Returns list of (old_name, new_name) or None."""
path = undo_log_path(base_dir)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data.get("renames")
except (OSError, json.JSONDecodeError, TypeError):
return None
def remove_undo_log(base_dir: str) -> None:
"""Remove the undo log file for this folder."""
path = undo_log_path(base_dir)
try:
path.unlink()
except OSError:
pass
def perform_undo(base_dir: str) -> List[tuple[str, str, Optional[str]]]:
"""
Undo the last rename in base_dir using the saved log.
Returns same format as perform_renames. Does not remove the log on failure.
"""
renames = load_undo_log(base_dir)
if not renames:
return []
# Reverse: rename current (new) back to original (old). Order must be reversed for chains.
undo_list = [(new_name, old_name) for (old_name, new_name) in renames]
undo_list.reverse()
results = perform_renames(base_dir, undo_list, dry_run=False)
if not any(r[2] for r in results):
remove_undo_log(base_dir)
return results
+128 -10
View File
@@ -2,9 +2,11 @@
Rename rules: each rule transforms a filename (stem + extension) and returns a new name. Rename rules: each rule transforms a filename (stem + extension) and returns a new name.
Rules are applied in sequence; extension can be changed by a rule that includes it. Rules are applied in sequence; extension can be changed by a rule that includes it.
""" """
import csv
import re import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -13,8 +15,15 @@ class Rule(ABC):
enabled: bool = True enabled: bool = True
@abstractmethod @abstractmethod
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
"""Return (new_stem, new_ext). Index is 0-based.""" self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
"""Return (new_stem, new_ext). Index is 0-based. original_name is the filename at pipeline start (for CSV lookup)."""
pass pass
def _split_name(self, name: str) -> tuple[str, str]: def _split_name(self, name: str) -> tuple[str, str]:
@@ -33,7 +42,14 @@ class ReplaceRule(Rule):
case_sensitive: bool = False case_sensitive: bool = False
whole_word: bool = False whole_word: bool = False
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
if not self.find: if not self.find:
return stem, ext return stem, ext
flags = 0 if self.case_sensitive else re.IGNORECASE flags = 0 if self.case_sensitive else re.IGNORECASE
@@ -50,7 +66,14 @@ class RegexRule(Rule):
pattern: str = "" pattern: str = ""
replacement: str = "" replacement: str = ""
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
if not self.pattern: if not self.pattern:
return stem, ext return stem, ext
try: try:
@@ -65,7 +88,14 @@ class InsertRule(Rule):
text: str = "" text: str = ""
position: int = 0 # 0 = start, -1 = end, or character index position: int = 0 # 0 = start, -1 = end, or character index
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
if not self.text: if not self.text:
return stem, ext return stem, ext
if self.position <= 0: if self.position <= 0:
@@ -82,7 +112,14 @@ class RemoveRule(Rule):
from_start: str = "" from_start: str = ""
from_end: str = "" from_end: str = ""
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
if self.remove_type == "chars" and self.value: if self.remove_type == "chars" and self.value:
return stem.replace(self.value, ""), ext return stem.replace(self.value, ""), ext
if self.remove_type == "digits": if self.remove_type == "digits":
@@ -114,7 +151,14 @@ class RemoveRule(Rule):
class CaseRule(Rule): class CaseRule(Rule):
case_type: str = "title" # upper, lower, title, sentence case_type: str = "title" # upper, lower, title, sentence
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
if self.case_type == "upper": if self.case_type == "upper":
return stem.upper(), ext return stem.upper(), ext
if self.case_type == "lower": if self.case_type == "lower":
@@ -137,7 +181,14 @@ class NumberingRule(Rule):
insert_at: int = 0 insert_at: int = 0
separator: str = " " separator: str = " "
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
num = self.start + index * self.step num = self.start + index * self.step
num_str = str(num).zfill(max(1, self.padding)) num_str = str(num).zfill(max(1, self.padding))
if self.where == "prefix": if self.where == "prefix":
@@ -159,7 +210,14 @@ class EpisodeRenumberRule(Rule):
# Pattern: group 1 = prefix (e.g. "S01E"), group 2 = episode digits, group 3 = rest (title) # Pattern: group 1 = prefix (e.g. "S01E"), group 2 = episode digits, group 3 = rest (title)
pattern: str = r"(.*?[Ss]\d+[Ee])(\d+)(.*)" pattern: str = r"(.*?[Ss]\d+[Ee])(\d+)(.*)"
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
try: try:
m = re.match(self.pattern, stem) m = re.match(self.pattern, stem)
except re.error: except re.error:
@@ -178,5 +236,65 @@ class PrefixSuffixRule(Rule):
prefix: str = "" prefix: str = ""
suffix: str = "" suffix: str = ""
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
return (self.prefix + stem + self.suffix), ext return (self.prefix + stem + self.suffix), ext
@dataclass
class CsvMappingRule(Rule):
"""Rename using a CSV with columns 'Original Name' and 'Target Name'. Lookup by original filename (as on disk)."""
csv_path: str = ""
_mapping: dict = field(default_factory=dict, repr=False) # original_name -> target_name, loaded on first use
def _load_mapping(self) -> dict:
if getattr(self, "_mapping_loaded", False):
return self._mapping
self._mapping = {}
path = Path(self.csv_path)
if not path.is_file():
setattr(self, "_mapping_loaded", True)
return self._mapping
try:
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
if not reader.fieldnames:
setattr(self, "_mapping_loaded", True)
return self._mapping
# Normalize header: strip spaces, accept "Original Name" and "Target Name"
orig_key = next((k for k in reader.fieldnames if k.strip().lower() == "original name"), None)
target_key = next((k for k in reader.fieldnames if k.strip().lower() == "target name"), None)
if orig_key is None or target_key is None:
setattr(self, "_mapping_loaded", True)
return self._mapping
for row in reader:
orig = row.get(orig_key, "").strip()
target = row.get(target_key, "").strip()
if orig:
self._mapping[orig] = target
except (OSError, csv.Error):
pass
setattr(self, "_mapping_loaded", True)
return self._mapping
def apply(
self,
stem: str,
ext: str,
index: int,
total: int,
original_name: Optional[str] = None,
) -> tuple[str, str]:
if not self.csv_path or original_name is None:
return stem, ext
mapping = self._load_mapping()
target = mapping.get(original_name)
if target is None:
return stem, ext
return self._split_name(target)
+44 -2
View File
@@ -26,7 +26,7 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel from PyQt6.QtCore import Qt, QDir, QItemSelectionModel
from PyQt6.QtGui import QFont, QColor from PyQt6.QtGui import QFont, QColor
from engine.pipeline import compute_preview, perform_renames from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo
from engine.rules import Rule from engine.rules import Rule
from .rule_widgets import ( from .rule_widgets import (
ReplaceRuleWidget, ReplaceRuleWidget,
@@ -37,6 +37,7 @@ from .rule_widgets import (
NumberingRuleWidget, NumberingRuleWidget,
EpisodeRenumberRuleWidget, EpisodeRenumberRuleWidget,
PrefixSuffixRuleWidget, PrefixSuffixRuleWidget,
CsvMappingRuleWidget,
) )
@@ -85,6 +86,7 @@ class MainWindow(QMainWindow):
NumberingRuleWidget(), NumberingRuleWidget(),
EpisodeRenumberRuleWidget(), EpisodeRenumberRuleWidget(),
PrefixSuffixRuleWidget(), PrefixSuffixRuleWidget(),
CsvMappingRuleWidget(),
] ]
rule_titles = [ rule_titles = [
"1. Replace", "1. Replace",
@@ -95,6 +97,7 @@ class MainWindow(QMainWindow):
"6. Numbering", "6. Numbering",
"7. Episode renumber", "7. Episode renumber",
"8. Prefix / Suffix", "8. Prefix / Suffix",
"9. CSV mapping",
] ]
for title, w in zip(rule_titles, self._rule_widgets): for title, w in zip(rule_titles, self._rule_widgets):
w.enabled_cb.toggled.connect(self._refresh_preview) # always refresh when checkbox toggled w.enabled_cb.toggled.connect(self._refresh_preview) # always refresh when checkbox toggled
@@ -130,10 +133,16 @@ class MainWindow(QMainWindow):
self.preview_status = QLabel("Add a folder to see files.") self.preview_status = QLabel("Add a folder to see files.")
right_layout.addWidget(self.preview_status) right_layout.addWidget(self.preview_status)
btn_layout = QHBoxLayout()
apply_btn = QPushButton("Apply renames") apply_btn = QPushButton("Apply renames")
apply_btn.setMinimumHeight(36) apply_btn.setMinimumHeight(36)
apply_btn.clicked.connect(self._apply_renames) apply_btn.clicked.connect(self._apply_renames)
right_layout.addWidget(apply_btn) 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.addWidget(right)
split.setSizes([320, 680]) split.setSizes([320, 680])
@@ -289,5 +298,38 @@ class MainWindow(QMainWindow):
msg += f"\n... and {len(errors) - 10} more." msg += f"\n... and {len(errors) - 10} more."
QMessageBox.warning(self, "Rename errors", msg) QMessageBox.warning(self, "Rename errors", msg)
else: else:
save_undo_log(self._base_dir, renames)
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).") QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
self._on_dir_changed() 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()
+45
View File
@@ -13,6 +13,8 @@ from PyQt6.QtWidgets import (
QGroupBox, QGroupBox,
QFormLayout, QFormLayout,
QStackedWidget, QStackedWidget,
QPushButton,
QFileDialog,
) )
from PyQt6.QtCore import pyqtSignal from PyQt6.QtCore import pyqtSignal
@@ -25,6 +27,7 @@ from engine.rules import (
EpisodeRenumberRule, EpisodeRenumberRule,
RegexRule, RegexRule,
PrefixSuffixRule, PrefixSuffixRule,
CsvMappingRule,
) )
@@ -325,3 +328,45 @@ class PrefixSuffixRuleWidget(QWidget):
r = PrefixSuffixRule(prefix=self.prefix.text(), suffix=self.suffix.text()) r = PrefixSuffixRule(prefix=self.prefix.text(), suffix=self.suffix.text())
r.enabled = self.enabled_cb.isChecked() r.enabled = self.enabled_cb.isChecked()
return r return r
class CsvMappingRuleWidget(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)
path_row = QHBoxLayout()
self.path_edit = QLineEdit()
self.path_edit.setPlaceholderText("Path to CSV file…")
self.path_edit.textChanged.connect(self._emit)
browse_btn = QPushButton("Browse…")
browse_btn.clicked.connect(self._browse)
path_row.addWidget(self.path_edit, 1)
path_row.addWidget(browse_btn)
layout.addRow("CSV file:", path_row)
info = QLabel('CSV must have columns "Original Name" and "Target Name". Lookup is by current filename.')
info.setWordWrap(True)
layout.addRow(info)
def _browse(self):
path, _ = QFileDialog.getOpenFileName(
self,
"Select CSV",
"",
"CSV (*.csv);;All files (*)",
)
if path:
self.path_edit.setText(path)
def _emit(self):
self.ruleChanged.emit()
def getRule(self) -> CsvMappingRule:
r = CsvMappingRule(csv_path=self.path_edit.text().strip())
r.enabled = self.enabled_cb.isChecked()
return r