diff --git a/README.md b/README.md index 3a0aa38..83182d0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ A **native Linux** GUI for mass renaming files. Inspired by [Bulk Rename Utility - **Case** – Title Case, UPPER, lower, Sentence case. - **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. +- **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. +- **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 @@ -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. +### 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) To build a portable AppImage: diff --git a/engine/__init__.py b/engine/__init__.py index 4c2a825..f76f33c 100644 --- a/engine/__init__.py +++ b/engine/__init__.py @@ -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 __all__ = [ @@ -11,6 +11,7 @@ __all__ = [ "EpisodeRenumberRule", "RegexRule", "PrefixSuffixRule", + "CsvMappingRule", "apply_pipeline", "compute_preview", ] diff --git a/engine/pipeline.py b/engine/pipeline.py index 23201d9..0d416aa 100644 --- a/engine/pipeline.py +++ b/engine/pipeline.py @@ -1,12 +1,16 @@ """ 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 from pathlib import Path from typing import List, Optional from .rules import Rule +UNDO_FILENAME = ".bulk-renamer-undo.json" + def _split_name(name: str) -> tuple[str, str]: if "." in name and not name.startswith("."): @@ -26,7 +30,7 @@ def apply_pipeline( for r in rules: if not r.enabled: continue - stem, ext = r.apply(stem, ext, index, total) + stem, ext = r.apply(stem, ext, index, total, original_name=name) return stem + ext @@ -104,3 +108,55 @@ def perform_renames( pass 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 diff --git a/engine/rules.py b/engine/rules.py index c1a8eb1..c69710f 100644 --- a/engine/rules.py +++ b/engine/rules.py @@ -2,9 +2,11 @@ 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. """ +import csv import re from abc import ABC, abstractmethod from dataclasses import dataclass, field +from pathlib import Path from typing import List, Optional @@ -13,8 +15,15 @@ class Rule(ABC): enabled: bool = True @abstractmethod - def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]: - """Return (new_stem, new_ext). Index is 0-based.""" + def apply( + 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 def _split_name(self, name: str) -> tuple[str, str]: @@ -33,7 +42,14 @@ class ReplaceRule(Rule): case_sensitive: 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: return stem, ext flags = 0 if self.case_sensitive else re.IGNORECASE @@ -50,7 +66,14 @@ class RegexRule(Rule): pattern: 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: return stem, ext try: @@ -65,7 +88,14 @@ class InsertRule(Rule): text: str = "" 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: return stem, ext if self.position <= 0: @@ -82,7 +112,14 @@ class RemoveRule(Rule): from_start: 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: return stem.replace(self.value, ""), ext if self.remove_type == "digits": @@ -114,7 +151,14 @@ class RemoveRule(Rule): class CaseRule(Rule): 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": return stem.upper(), ext if self.case_type == "lower": @@ -137,7 +181,14 @@ class NumberingRule(Rule): insert_at: int = 0 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_str = str(num).zfill(max(1, self.padding)) 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: 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: m = re.match(self.pattern, stem) except re.error: @@ -178,5 +236,65 @@ class PrefixSuffixRule(Rule): prefix: 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 + + +@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) diff --git a/gui/main_window.py b/gui/main_window.py index ecfc42d..ec1de1d 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -26,7 +26,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import Qt, QDir, QItemSelectionModel 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 .rule_widgets import ( ReplaceRuleWidget, @@ -37,6 +37,7 @@ from .rule_widgets import ( NumberingRuleWidget, EpisodeRenumberRuleWidget, PrefixSuffixRuleWidget, + CsvMappingRuleWidget, ) @@ -85,6 +86,7 @@ class MainWindow(QMainWindow): NumberingRuleWidget(), EpisodeRenumberRuleWidget(), PrefixSuffixRuleWidget(), + CsvMappingRuleWidget(), ] rule_titles = [ "1. Replace", @@ -95,6 +97,7 @@ class MainWindow(QMainWindow): "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 @@ -130,10 +133,16 @@ class MainWindow(QMainWindow): 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) - 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.setSizes([320, 680]) @@ -289,5 +298,38 @@ class MainWindow(QMainWindow): 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() diff --git a/gui/rule_widgets.py b/gui/rule_widgets.py index eacc317..1c13f09 100644 --- a/gui/rule_widgets.py +++ b/gui/rule_widgets.py @@ -13,6 +13,8 @@ from PyQt6.QtWidgets import ( QGroupBox, QFormLayout, QStackedWidget, + QPushButton, + QFileDialog, ) from PyQt6.QtCore import pyqtSignal @@ -25,6 +27,7 @@ from engine.rules import ( EpisodeRenumberRule, RegexRule, PrefixSuffixRule, + CsvMappingRule, ) @@ -325,3 +328,45 @@ class PrefixSuffixRuleWidget(QWidget): r = PrefixSuffixRule(prefix=self.prefix.text(), suffix=self.suffix.text()) r.enabled = self.enabled_cb.isChecked() 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