Initial commit: Bulk Renamer with AppImage build and Gitea push notes
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from .rules import Rule, ReplaceRule, InsertRule, RemoveRule, CaseRule, NumberingRule, EpisodeRenumberRule, RegexRule, PrefixSuffixRule
|
||||
from .pipeline import apply_pipeline, compute_preview
|
||||
|
||||
__all__ = [
|
||||
"Rule",
|
||||
"ReplaceRule",
|
||||
"InsertRule",
|
||||
"RemoveRule",
|
||||
"CaseRule",
|
||||
"NumberingRule",
|
||||
"EpisodeRenumberRule",
|
||||
"RegexRule",
|
||||
"PrefixSuffixRule",
|
||||
"apply_pipeline",
|
||||
"compute_preview",
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Apply a list of rules to a list of filenames; produce preview and perform renames.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from .rules import Rule
|
||||
|
||||
|
||||
def _split_name(name: str) -> tuple[str, str]:
|
||||
if "." in name and not name.startswith("."):
|
||||
idx = name.rfind(".")
|
||||
return name[:idx], name[idx:]
|
||||
return name, ""
|
||||
|
||||
|
||||
def apply_pipeline(
|
||||
name: str,
|
||||
rules: List[Rule],
|
||||
index: int,
|
||||
total: int,
|
||||
) -> str:
|
||||
"""Apply all enabled rules to a single filename (no path). Returns new filename."""
|
||||
stem, ext = _split_name(name)
|
||||
for r in rules:
|
||||
if not r.enabled:
|
||||
continue
|
||||
stem, ext = r.apply(stem, ext, index, total)
|
||||
return stem + ext
|
||||
|
||||
|
||||
def compute_preview(
|
||||
names: List[str],
|
||||
rules: List[Rule],
|
||||
) -> List[tuple[str, str]]:
|
||||
"""
|
||||
For each name (filename only), compute the new name after rules.
|
||||
Returns list of (original_name, new_name). Detects collisions.
|
||||
"""
|
||||
result = []
|
||||
for i, name in enumerate(names):
|
||||
new_name = apply_pipeline(name, rules, i, len(names))
|
||||
result.append((name, new_name))
|
||||
return result
|
||||
|
||||
|
||||
def perform_renames(
|
||||
base_dir: str,
|
||||
renames: List[tuple[str, str]],
|
||||
dry_run: bool = False,
|
||||
) -> List[tuple[str, str, Optional[str]]]:
|
||||
"""
|
||||
Perform renames in base_dir. Each item is (old_name, new_name).
|
||||
Returns list of (old_path, new_path, error_message or None).
|
||||
Uses two-pass: first rename to temp names to avoid collisions.
|
||||
"""
|
||||
base = Path(base_dir)
|
||||
results = []
|
||||
# Two-pass to avoid overwriting: first move to temporary names, then to final
|
||||
temp_suffix = ".bru_tmp"
|
||||
step1 = []
|
||||
for old_name, new_name in renames:
|
||||
if old_name == new_name:
|
||||
continue
|
||||
old_path = base / old_name
|
||||
if not old_path.exists():
|
||||
results.append((str(old_path), "", "File not found"))
|
||||
continue
|
||||
step1.append((old_path, new_name))
|
||||
|
||||
# Assign temp names that don't clash with each other or final names
|
||||
final_names = {n for _, n in step1}
|
||||
temp_map = []
|
||||
for i, (old_path, new_name) in enumerate(step1):
|
||||
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||
while temp_name in final_names or (base / temp_name).exists():
|
||||
i += 1
|
||||
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||
temp_map.append((old_path, base / temp_name, base / new_name))
|
||||
|
||||
if dry_run:
|
||||
for old_p, temp_p, new_p in temp_map:
|
||||
results.append((str(old_p), str(new_p), None))
|
||||
return results
|
||||
|
||||
# Execute: first to temp, then to final
|
||||
for old_p, temp_p, new_p in temp_map:
|
||||
try:
|
||||
old_p.rename(temp_p)
|
||||
except OSError as e:
|
||||
results.append((str(old_p), str(new_p), str(e)))
|
||||
for old_p, temp_p, new_p in temp_map:
|
||||
if not temp_p.exists():
|
||||
continue
|
||||
try:
|
||||
temp_p.rename(new_p)
|
||||
results.append((str(old_p), str(new_p), None))
|
||||
except OSError as e:
|
||||
results.append((str(old_p), str(new_p), str(e)))
|
||||
try:
|
||||
temp_p.rename(old_p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return results
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
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 re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
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."""
|
||||
pass
|
||||
|
||||
def _split_name(self, name: str) -> tuple[str, str]:
|
||||
if "." in name and not name.startswith("."):
|
||||
idx = name.rfind(".")
|
||||
return name[:idx], name[idx:]
|
||||
return name, ""
|
||||
|
||||
|
||||
# --- Text rules ---
|
||||
|
||||
@dataclass
|
||||
class ReplaceRule(Rule):
|
||||
find: str = ""
|
||||
replace: str = ""
|
||||
case_sensitive: bool = False
|
||||
whole_word: bool = False
|
||||
|
||||
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]:
|
||||
if not self.find:
|
||||
return stem, ext
|
||||
flags = 0 if self.case_sensitive else re.IGNORECASE
|
||||
if self.whole_word:
|
||||
pattern = r"\b" + re.escape(self.find) + r"\b"
|
||||
else:
|
||||
pattern = re.escape(self.find)
|
||||
new_stem = re.sub(pattern, self.replace, stem, flags=flags)
|
||||
return new_stem, ext
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegexRule(Rule):
|
||||
pattern: str = ""
|
||||
replacement: str = ""
|
||||
|
||||
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]:
|
||||
if not self.pattern:
|
||||
return stem, ext
|
||||
try:
|
||||
new_stem = re.sub(self.pattern, self.replacement, stem)
|
||||
except re.error:
|
||||
return stem, ext
|
||||
return new_stem, ext
|
||||
|
||||
|
||||
@dataclass
|
||||
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]:
|
||||
if not self.text:
|
||||
return stem, ext
|
||||
if self.position <= 0:
|
||||
return self.text + stem, ext # start
|
||||
if self.position >= len(stem) or self.position == -1:
|
||||
return stem + self.text, ext # end
|
||||
return stem[: self.position] + self.text + stem[self.position :], ext
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoveRule(Rule):
|
||||
remove_type: str = "chars" # chars, digits, first_n, last_n, from_start, from_end
|
||||
value: str = "" # chars to remove, or n for first_n/last_n
|
||||
from_start: str = ""
|
||||
from_end: str = ""
|
||||
|
||||
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]:
|
||||
if self.remove_type == "chars" and self.value:
|
||||
return stem.replace(self.value, ""), ext
|
||||
if self.remove_type == "digits":
|
||||
return re.sub(r"\d+", "", stem), ext
|
||||
if self.remove_type == "first_n":
|
||||
try:
|
||||
n = int(self.value)
|
||||
return stem[n:], ext
|
||||
except ValueError:
|
||||
return stem, ext
|
||||
if self.remove_type == "last_n":
|
||||
try:
|
||||
n = int(self.value)
|
||||
return stem[:-n] if n > 0 else stem, ext
|
||||
except ValueError:
|
||||
return stem, ext
|
||||
if self.remove_type == "from_start" and self.from_start:
|
||||
idx = stem.find(self.from_start)
|
||||
if idx >= 0:
|
||||
return stem[idx + len(self.from_start) :], ext
|
||||
if self.remove_type == "from_end" and self.from_end:
|
||||
idx = stem.rfind(self.from_end)
|
||||
if idx >= 0:
|
||||
return stem[:idx], ext
|
||||
return stem, ext
|
||||
|
||||
|
||||
@dataclass
|
||||
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]:
|
||||
if self.case_type == "upper":
|
||||
return stem.upper(), ext
|
||||
if self.case_type == "lower":
|
||||
return stem.lower(), ext
|
||||
if self.case_type == "title":
|
||||
return stem.title(), ext
|
||||
if self.case_type == "sentence":
|
||||
if stem:
|
||||
return stem[0].upper() + stem[1:].lower(), ext
|
||||
return stem, ext
|
||||
return stem, ext
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberingRule(Rule):
|
||||
start: int = 1
|
||||
step: int = 1
|
||||
padding: int = 2 # zero-pad width
|
||||
where: str = "prefix" # prefix, suffix, insert_at
|
||||
insert_at: int = 0
|
||||
separator: str = " "
|
||||
|
||||
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]:
|
||||
num = self.start + index * self.step
|
||||
num_str = str(num).zfill(max(1, self.padding))
|
||||
if self.where == "prefix":
|
||||
return f"{num_str}{self.separator}{stem}", ext
|
||||
if self.where == "suffix":
|
||||
return f"{stem}{self.separator}{num_str}", ext
|
||||
if self.where == "insert_at":
|
||||
pos = max(0, min(self.insert_at, len(stem)))
|
||||
return stem[:pos] + num_str + self.separator + stem[pos:], ext
|
||||
return stem, ext
|
||||
|
||||
|
||||
# Episode renumber: match SxxExx or similar, replace episode number only, keep title
|
||||
@dataclass
|
||||
class EpisodeRenumberRule(Rule):
|
||||
start: int = 1
|
||||
step: int = 1
|
||||
padding: int = 2
|
||||
# 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]:
|
||||
try:
|
||||
m = re.match(self.pattern, stem)
|
||||
except re.error:
|
||||
return stem, ext
|
||||
if not m:
|
||||
return stem, ext
|
||||
prefix, _old_ep, rest = m.group(1), m.group(2), m.group(3)
|
||||
ep_num = self.start + index * self.step
|
||||
ep_str = str(ep_num).zfill(max(1, self.padding))
|
||||
new_stem = f"{prefix}{ep_str}{rest}"
|
||||
return new_stem, ext
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrefixSuffixRule(Rule):
|
||||
prefix: str = ""
|
||||
suffix: str = ""
|
||||
|
||||
def apply(self, stem: str, ext: str, index: int, total: int) -> tuple[str, str]:
|
||||
return (self.prefix + stem + self.suffix), ext
|
||||
Reference in New Issue
Block a user