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
+182
View File
@@ -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