Add CSV mapping rule and Undo last rename feature
Made-with: Cursor
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user