Initial commit: Bulk Renamer with AppImage build and Gitea push notes
Made-with: Cursor
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# PyInstaller / packaging
|
||||||
|
*.spec
|
||||||
|
appdir/
|
||||||
|
Bulk_Renamer*.AppImage
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Push to your Gitea
|
||||||
|
|
||||||
|
1. **Create a new repository** on your Gitea instance (e.g. `bulk-renamer`, empty, no README).
|
||||||
|
|
||||||
|
2. **Add the remote and push** (replace with your Gitea URL and username):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/home/jorg/Documents/Cursor Projects/Bulk Renamer"
|
||||||
|
git remote add origin https://your-gitea.example.com/your-username/bulk-renamer.git
|
||||||
|
# or SSH:
|
||||||
|
# git remote add origin git@your-gitea.example.com:your-username/bulk-renamer.git
|
||||||
|
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
3. If Gitea asks for auth, use your normal login (or a token for HTTPS).
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Bulk Renamer
|
||||||
|
|
||||||
|
A **native Linux** GUI for mass renaming files. Inspired by [Bulk Rename Utility](https://www.bulkrenameutility.co.uk/#features) (Windows). Rename hundreds of media files with flexible rules, **preview before applying**, and support for **episode renumbering** without losing episode titles.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **GUI** – Structure renames with rule panels; no command line needed.
|
||||||
|
- **Preview** – See “Original → New name” for every file before committing.
|
||||||
|
- **Multiple rules** – Combine rules (order: Replace → Regex → Insert → Remove → Case → Numbering → Episode renumber → Prefix/Suffix). Enable only the rules you need.
|
||||||
|
- **Episode renumbering** – Replace episode numbers (e.g. `S01E05 - Title` → `S01E01 - Title`) while keeping season and title. Start number and zero-padding configurable.
|
||||||
|
- **Replace / Regex** – Plain text find/replace or full regex with capture groups.
|
||||||
|
- **Insert / Remove** – Insert text at start/end/position; remove text, digits, or first/last N characters.
|
||||||
|
- **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.
|
||||||
|
- **Safe renames** – Two-pass rename to avoid overwrites; collision detection in preview.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- PyQt6
|
||||||
|
|
||||||
|
## Install and run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/home/jorg/Documents/Cursor Projects/Bulk Renamer"
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with a virtual environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Browse** to a folder (only files in that directory are listed; hidden files are skipped).
|
||||||
|
2. Pick a **rule type** from the dropdown and check **“Use this rule”**.
|
||||||
|
3. Configure the rule. You can enable several rules; they run in a fixed order.
|
||||||
|
4. Check the **Preview** table (Original name → New name). Resolve any duplicate-name warnings.
|
||||||
|
5. Click **Apply renames** and confirm.
|
||||||
|
|
||||||
|
### Episode renumbering example
|
||||||
|
|
||||||
|
- Files: `S01E12 - Pilot.mkv`, `S01E07 - Second.mkv`, …
|
||||||
|
- Enable **Episode renumber**, set **First episode number** to `1`, **Step** to `1`, **Zero-pad width** to `2`.
|
||||||
|
- Preview: `S01E12 - Pilot.mkv` → `S01E01 - Pilot.mkv`, `S01E07 - Second.mkv` → `S01E02 - Second.mkv`, etc. Titles are preserved.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## AppImage (distribution)
|
||||||
|
|
||||||
|
To build a portable AppImage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt -r requirements-build.txt
|
||||||
|
./build_appimage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
You need [appimagetool](https://github.com/AppImage/AppImageKit/releases) installed to produce the final `.AppImage`; if missing, the script still creates the AppDir and prints the exact command. The output is `BulkRenamer.AppImage` (or `BulkRenamer-dev.AppImage`).
|
||||||
|
|
||||||
|
## Push to Gitea
|
||||||
|
|
||||||
|
See [PUSH_TO_GITEA.md](PUSH_TO_GITEA.md) for adding this repo to your Gitea and pushing.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build Bulk Renamer as an AppImage.
|
||||||
|
# Requires: pip install -r requirements.txt -r requirements-build.txt
|
||||||
|
# Optional: appimagetool from https://github.com/AppImage/AppImageKit/releases (for building the final .AppImage)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
APP_NAME="BulkRenamer"
|
||||||
|
APPDIR="${APP_NAME}.AppDir"
|
||||||
|
EXE_NAME="$APP_NAME"
|
||||||
|
|
||||||
|
echo "=== Installing build deps ==="
|
||||||
|
pip install -q -r requirements.txt -r requirements-build.txt
|
||||||
|
|
||||||
|
echo "=== Running PyInstaller ==="
|
||||||
|
pyinstaller --noconfirm --onedir --windowed \
|
||||||
|
-n "$EXE_NAME" \
|
||||||
|
--hidden-import=engine \
|
||||||
|
--hidden-import=engine.rules \
|
||||||
|
--hidden-import=engine.pipeline \
|
||||||
|
--hidden-import=gui \
|
||||||
|
--hidden-import=gui.main_window \
|
||||||
|
--hidden-import=gui.rule_widgets \
|
||||||
|
main.py
|
||||||
|
|
||||||
|
echo "=== Creating AppDir ==="
|
||||||
|
rm -rf "$APPDIR"
|
||||||
|
mkdir -p "$APPDIR/usr/bin"
|
||||||
|
cp -a "dist/$EXE_NAME" "$APPDIR/usr/bin/"
|
||||||
|
|
||||||
|
cat > "$APPDIR/AppRun" << 'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
SELF=$(readlink -f "$0")
|
||||||
|
HERE=${SELF%/*}
|
||||||
|
exec "$HERE/usr/bin/BulkRenamer/BulkRenamer" "$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$APPDIR/AppRun"
|
||||||
|
|
||||||
|
cat > "$APPDIR/bulk-renamer.desktop" << 'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=Bulk Renamer
|
||||||
|
Comment=Mass rename files with preview and flexible rules
|
||||||
|
Exec=BulkRenamer
|
||||||
|
Icon=bulk-renamer
|
||||||
|
Type=Application
|
||||||
|
Categories=Utility;FileTools;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "=== Building AppImage (requires appimagetool) ==="
|
||||||
|
if command -v appimagetool &>/dev/null; then
|
||||||
|
appimagetool "$APPDIR" "${APP_NAME}-${VERSION:-dev}.AppImage" 2>/dev/null || \
|
||||||
|
appimagetool "$APPDIR" "${APP_NAME}.AppImage"
|
||||||
|
echo "Done: ${APP_NAME}.AppImage (or ${APP_NAME}-dev.AppImage)"
|
||||||
|
else
|
||||||
|
echo "AppDir is ready at $APPDIR/"
|
||||||
|
echo "To create the .AppImage, install appimagetool and run:"
|
||||||
|
echo " appimagetool $APPDIR ${APP_NAME}.AppImage"
|
||||||
|
echo " # Get it from: https://github.com/AppImage/AppImageKit/releases"
|
||||||
|
fi
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# GUI package
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""
|
||||||
|
Main window: directory picker, file list, rule selector + stack, preview table, Apply.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QMainWindow,
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QSplitter,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QLineEdit,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QFileDialog,
|
||||||
|
QScrollArea,
|
||||||
|
QGroupBox,
|
||||||
|
QMessageBox,
|
||||||
|
QHeaderView,
|
||||||
|
QAbstractItemView,
|
||||||
|
QFrame,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel
|
||||||
|
from PyQt6.QtGui import QFont, QColor
|
||||||
|
|
||||||
|
from engine.pipeline import compute_preview, perform_renames
|
||||||
|
from engine.rules import Rule
|
||||||
|
from .rule_widgets import (
|
||||||
|
ReplaceRuleWidget,
|
||||||
|
RegexRuleWidget,
|
||||||
|
InsertRuleWidget,
|
||||||
|
RemoveRuleWidget,
|
||||||
|
CaseRuleWidget,
|
||||||
|
NumberingRuleWidget,
|
||||||
|
EpisodeRenumberRuleWidget,
|
||||||
|
PrefixSuffixRuleWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("Bulk Renamer")
|
||||||
|
self.resize(1000, 700)
|
||||||
|
self._base_dir = ""
|
||||||
|
self._file_names: list[str] = []
|
||||||
|
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
|
||||||
|
self._setup_ui()
|
||||||
|
self._refresh_preview()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
layout = QVBoxLayout(central)
|
||||||
|
|
||||||
|
# Top: directory bar
|
||||||
|
dir_layout = QHBoxLayout()
|
||||||
|
self.dir_edit = QLineEdit()
|
||||||
|
self.dir_edit.setPlaceholderText("Select a folder or enter path...")
|
||||||
|
self.dir_edit.textChanged.connect(self._on_dir_changed)
|
||||||
|
browse_btn = QPushButton("Browse…")
|
||||||
|
browse_btn.clicked.connect(self._browse)
|
||||||
|
dir_layout.addWidget(QLabel("Folder:"))
|
||||||
|
dir_layout.addWidget(self.dir_edit, 1)
|
||||||
|
dir_layout.addWidget(browse_btn)
|
||||||
|
layout.addLayout(dir_layout)
|
||||||
|
|
||||||
|
# Split: left = rules, right = file list + preview
|
||||||
|
split = QSplitter(Qt.Orientation.Horizontal)
|
||||||
|
|
||||||
|
# Left: all rule panels at once (enable and configure multiple rules)
|
||||||
|
left = QWidget()
|
||||||
|
left_layout = QVBoxLayout(left)
|
||||||
|
left_layout.addWidget(QLabel("Rename rules (applied in order; enable and configure any combination):"))
|
||||||
|
|
||||||
|
self._rule_widgets = [
|
||||||
|
ReplaceRuleWidget(),
|
||||||
|
RegexRuleWidget(),
|
||||||
|
InsertRuleWidget(),
|
||||||
|
RemoveRuleWidget(),
|
||||||
|
CaseRuleWidget(),
|
||||||
|
NumberingRuleWidget(),
|
||||||
|
EpisodeRenumberRuleWidget(),
|
||||||
|
PrefixSuffixRuleWidget(),
|
||||||
|
]
|
||||||
|
rule_titles = [
|
||||||
|
"1. Replace",
|
||||||
|
"2. Regex",
|
||||||
|
"3. Insert",
|
||||||
|
"4. Remove",
|
||||||
|
"5. Case",
|
||||||
|
"6. Numbering",
|
||||||
|
"7. Episode renumber",
|
||||||
|
"8. Prefix / Suffix",
|
||||||
|
]
|
||||||
|
for title, w in zip(rule_titles, self._rule_widgets):
|
||||||
|
w.enabled_cb.toggled.connect(self._refresh_preview) # always refresh when checkbox toggled
|
||||||
|
w.ruleChanged.connect(self._on_rule_changed) # refresh only when enabled rule’s options change
|
||||||
|
g = QGroupBox(title)
|
||||||
|
g_layout = QVBoxLayout(g)
|
||||||
|
g_layout.setContentsMargins(8, 12, 8, 8)
|
||||||
|
g_layout.addWidget(w)
|
||||||
|
left_layout.addWidget(g)
|
||||||
|
left_layout.addStretch(1)
|
||||||
|
|
||||||
|
scroll_left = QScrollArea()
|
||||||
|
scroll_left.setWidget(left)
|
||||||
|
scroll_left.setWidgetResizable(True)
|
||||||
|
scroll_left.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
scroll_left.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
|
split.addWidget(scroll_left)
|
||||||
|
|
||||||
|
# Right: file list + preview table
|
||||||
|
right = QWidget()
|
||||||
|
right_layout = QVBoxLayout(right)
|
||||||
|
right_layout.addWidget(QLabel("Preview (Original → New name):"))
|
||||||
|
self.preview_table = QTableWidget()
|
||||||
|
self.preview_table.setColumnCount(3)
|
||||||
|
self.preview_table.setHorizontalHeaderLabels(["Original name", "New name", "File type"])
|
||||||
|
self.preview_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||||
|
self.preview_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.preview_table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
|
self.preview_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
self.preview_table.setSortingEnabled(True)
|
||||||
|
self.preview_table.itemSelectionChanged.connect(self._on_preview_selection_changed)
|
||||||
|
right_layout.addWidget(self.preview_table, 1)
|
||||||
|
self.preview_status = QLabel("Add a folder to see files.")
|
||||||
|
right_layout.addWidget(self.preview_status)
|
||||||
|
|
||||||
|
apply_btn = QPushButton("Apply renames")
|
||||||
|
apply_btn.setMinimumHeight(36)
|
||||||
|
apply_btn.clicked.connect(self._apply_renames)
|
||||||
|
right_layout.addWidget(apply_btn)
|
||||||
|
split.addWidget(right)
|
||||||
|
|
||||||
|
split.setSizes([320, 680])
|
||||||
|
layout.addWidget(split, 1)
|
||||||
|
|
||||||
|
def _browse(self):
|
||||||
|
path = QFileDialog.getExistingDirectory(self, "Select folder")
|
||||||
|
if path:
|
||||||
|
self.dir_edit.setText(path)
|
||||||
|
|
||||||
|
def _on_dir_changed(self):
|
||||||
|
path = self.dir_edit.text().strip()
|
||||||
|
if not path or not os.path.isdir(path):
|
||||||
|
self._base_dir = ""
|
||||||
|
self._file_names = []
|
||||||
|
else:
|
||||||
|
self._base_dir = path
|
||||||
|
self._file_names = sorted(
|
||||||
|
f for f in os.listdir(path)
|
||||||
|
if os.path.isfile(os.path.join(path, f)) and not f.startswith(".")
|
||||||
|
)
|
||||||
|
self._refresh_preview()
|
||||||
|
|
||||||
|
def _on_rule_changed(self):
|
||||||
|
"""Refresh preview only if the changed rule has 'Use this rule' checked (no preview from unchecked rules)."""
|
||||||
|
sender = self.sender()
|
||||||
|
if sender is None:
|
||||||
|
self._refresh_preview()
|
||||||
|
return
|
||||||
|
if sender in self._rule_widgets and sender.enabled_cb.isChecked():
|
||||||
|
self._refresh_preview()
|
||||||
|
# else: change was in an unchecked rule (e.g. mouse wheel on spinbox) — do not refresh
|
||||||
|
|
||||||
|
def _get_rules(self) -> list[Rule]:
|
||||||
|
"""All rules in fixed order; only enabled rules are applied."""
|
||||||
|
return [w.getRule() for w in self._rule_widgets]
|
||||||
|
|
||||||
|
def _ext_for(self, filename: str) -> str:
|
||||||
|
"""Return file extension with leading dot, or empty string if none."""
|
||||||
|
if "." in filename and not filename.startswith("."):
|
||||||
|
return "." + filename.rsplit(".", 1)[-1].lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _on_preview_selection_changed(self):
|
||||||
|
"""Show preview (new name) only in selected rows; others show original."""
|
||||||
|
if not self._preview_by_orig:
|
||||||
|
return
|
||||||
|
selected_rows = {idx.row() for idx in self.preview_table.selectionModel().selectedRows()}
|
||||||
|
for row in range(self.preview_table.rowCount()):
|
||||||
|
orig_item = self.preview_table.item(row, 0)
|
||||||
|
if not orig_item:
|
||||||
|
continue
|
||||||
|
orig = orig_item.text()
|
||||||
|
display_name = self._preview_by_orig.get(orig, orig) if row in selected_rows else orig
|
||||||
|
self.preview_table.setItem(row, 1, QTableWidgetItem(display_name))
|
||||||
|
|
||||||
|
def _refresh_preview(self):
|
||||||
|
rules = self._get_rules()
|
||||||
|
if not self._file_names:
|
||||||
|
self._preview_by_orig = {}
|
||||||
|
self.preview_table.setSortingEnabled(False)
|
||||||
|
self.preview_table.setRowCount(0)
|
||||||
|
self.preview_status.setText("Add a folder to see files.")
|
||||||
|
return
|
||||||
|
# Preserve sort and selection so checking a rule doesn’t reorder or clear selection
|
||||||
|
header = self.preview_table.horizontalHeader()
|
||||||
|
sort_section = header.sortIndicatorSection()
|
||||||
|
sort_order = header.sortIndicatorOrder()
|
||||||
|
selected_orig = set()
|
||||||
|
for idx in self.preview_table.selectionModel().selectedRows():
|
||||||
|
item = self.preview_table.item(idx.row(), 0)
|
||||||
|
if item:
|
||||||
|
selected_orig.add(item.text())
|
||||||
|
|
||||||
|
self.preview_table.setSortingEnabled(False)
|
||||||
|
self.preview_table.setRowCount(len(self._file_names))
|
||||||
|
for row, orig in enumerate(self._file_names):
|
||||||
|
self.preview_table.setItem(row, 0, QTableWidgetItem(orig))
|
||||||
|
self.preview_table.setItem(row, 1, QTableWidgetItem(orig)) # show original until selected
|
||||||
|
self.preview_table.setItem(row, 2, QTableWidgetItem(self._ext_for(orig)))
|
||||||
|
self.preview_table.setSortingEnabled(True)
|
||||||
|
header.setSortIndicator(sort_section, sort_order)
|
||||||
|
|
||||||
|
# Numbering uses the table’s current (sorted) order, not load order
|
||||||
|
order = [self.preview_table.item(r, 0).text() for r in range(self.preview_table.rowCount())]
|
||||||
|
preview = compute_preview(order, rules)
|
||||||
|
self._preview_by_orig = {orig: new_name for orig, new_name in preview}
|
||||||
|
new_names = [p[1] for p in preview]
|
||||||
|
has_collision = len(new_names) != len(set(new_names))
|
||||||
|
|
||||||
|
# Restore selection by original name (multi-row)
|
||||||
|
sm = self.preview_table.selectionModel()
|
||||||
|
sm.clear()
|
||||||
|
for row in range(self.preview_table.rowCount()):
|
||||||
|
item = self.preview_table.item(row, 0)
|
||||||
|
if item and item.text() in selected_orig:
|
||||||
|
idx = self.preview_table.model().index(row, 0)
|
||||||
|
sm.select(idx, QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows)
|
||||||
|
self._on_preview_selection_changed()
|
||||||
|
if has_collision:
|
||||||
|
self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.")
|
||||||
|
self.preview_status.setStyleSheet("color: #c00;")
|
||||||
|
else:
|
||||||
|
self.preview_status.setText(f"{len(preview)} file(s). Ready to rename.")
|
||||||
|
self.preview_status.setStyleSheet("")
|
||||||
|
|
||||||
|
def _apply_renames(self):
|
||||||
|
if not self._base_dir or not self._file_names:
|
||||||
|
QMessageBox.warning(self, "No files", "Select a folder with files first.")
|
||||||
|
return
|
||||||
|
rules = self._get_rules()
|
||||||
|
# Use current table order (after user sort) so numbering/episode renumber follow visible order
|
||||||
|
if self.preview_table.rowCount() > 0:
|
||||||
|
order = [self.preview_table.item(r, 0).text() for r in range(self.preview_table.rowCount())]
|
||||||
|
else:
|
||||||
|
order = self._file_names
|
||||||
|
full_preview = compute_preview(order, rules)
|
||||||
|
# Only rename selected rows; if nothing selected, rename all that would change
|
||||||
|
selected_orig = {
|
||||||
|
self.preview_table.item(idx.row(), 0).text()
|
||||||
|
for idx in self.preview_table.selectionModel().selectedRows()
|
||||||
|
if self.preview_table.item(idx.row(), 0)
|
||||||
|
}
|
||||||
|
if selected_orig:
|
||||||
|
renames = [(a, b) for a, b in full_preview if a in selected_orig and a != b]
|
||||||
|
else:
|
||||||
|
renames = [(a, b) for a, b in full_preview if a != b]
|
||||||
|
if not renames:
|
||||||
|
QMessageBox.information(self, "Nothing to do", "No names would change with current rules.")
|
||||||
|
return
|
||||||
|
new_names = [b for _, b in renames]
|
||||||
|
if len(new_names) != len(set(new_names)):
|
||||||
|
QMessageBox.critical(
|
||||||
|
self,
|
||||||
|
"Collision",
|
||||||
|
"Some new names are duplicated. Change your rules to avoid overwriting files.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ok = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Confirm",
|
||||||
|
f"Rename {len(renames)} file(s) in\n{self._base_dir}?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if ok != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
results = perform_renames(self._base_dir, renames, dry_run=False)
|
||||||
|
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, "Rename errors", msg)
|
||||||
|
else:
|
||||||
|
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
|
||||||
|
self._on_dir_changed()
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""
|
||||||
|
Rule configuration widgets: each rule type has a small form that updates the Rule model.
|
||||||
|
"""
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QSpinBox,
|
||||||
|
QComboBox,
|
||||||
|
QCheckBox,
|
||||||
|
QGroupBox,
|
||||||
|
QFormLayout,
|
||||||
|
QStackedWidget,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import pyqtSignal
|
||||||
|
|
||||||
|
from engine.rules import (
|
||||||
|
ReplaceRule,
|
||||||
|
InsertRule,
|
||||||
|
RemoveRule,
|
||||||
|
CaseRule,
|
||||||
|
NumberingRule,
|
||||||
|
EpisodeRenumberRule,
|
||||||
|
RegexRule,
|
||||||
|
PrefixSuffixRule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReplaceRuleWidget(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)
|
||||||
|
self.find = QLineEdit()
|
||||||
|
self.find.setPlaceholderText("Text to find")
|
||||||
|
self.find.textChanged.connect(self._emit)
|
||||||
|
self.replace = QLineEdit()
|
||||||
|
self.replace.setPlaceholderText("Replace with")
|
||||||
|
self.replace.textChanged.connect(self._emit)
|
||||||
|
self.caseSensitive = QCheckBox("Case sensitive")
|
||||||
|
self.caseSensitive.toggled.connect(self._emit)
|
||||||
|
self.wholeWord = QCheckBox("Whole word only")
|
||||||
|
self.wholeWord.toggled.connect(self._emit)
|
||||||
|
layout.addRow("Find:", self.find)
|
||||||
|
layout.addRow("Replace with:", self.replace)
|
||||||
|
layout.addRow(self.caseSensitive)
|
||||||
|
layout.addRow(self.wholeWord)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
|
def getRule(self) -> ReplaceRule:
|
||||||
|
r = ReplaceRule(
|
||||||
|
find=self.find.text(),
|
||||||
|
replace=self.replace.text(),
|
||||||
|
case_sensitive=self.caseSensitive.isChecked(),
|
||||||
|
whole_word=self.wholeWord.isChecked(),
|
||||||
|
)
|
||||||
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class RegexRuleWidget(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)
|
||||||
|
self.pattern = QLineEdit()
|
||||||
|
self.pattern.setPlaceholderText(r"e.g. S(\d+)E(\d+)")
|
||||||
|
self.pattern.textChanged.connect(self._emit)
|
||||||
|
self.replacement = QLineEdit()
|
||||||
|
self.replacement.setPlaceholderText(r"e.g. S\1E\2")
|
||||||
|
self.replacement.textChanged.connect(self._emit)
|
||||||
|
layout.addRow("Regex pattern:", self.pattern)
|
||||||
|
layout.addRow("Replacement:", self.replacement)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
|
def getRule(self) -> RegexRule:
|
||||||
|
r = RegexRule(pattern=self.pattern.text(), replacement=self.replacement.text())
|
||||||
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class InsertRuleWidget(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)
|
||||||
|
self.text = QLineEdit()
|
||||||
|
self.text.setPlaceholderText("Text to insert")
|
||||||
|
self.text.textChanged.connect(self._emit)
|
||||||
|
self.position = QComboBox()
|
||||||
|
self.position.addItems(["At start", "At end", "At position..."])
|
||||||
|
self.position.setCurrentIndex(0)
|
||||||
|
self.position.currentIndexChanged.connect(self._emit)
|
||||||
|
self.positionSpin = QSpinBox()
|
||||||
|
self.positionSpin.setMinimum(0)
|
||||||
|
self.positionSpin.setMaximum(9999)
|
||||||
|
self.positionSpin.valueChanged.connect(self._emit)
|
||||||
|
layout.addRow("Text:", self.text)
|
||||||
|
layout.addRow("Position:", self.position)
|
||||||
|
layout.addRow("Index:", self.positionSpin)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
|
def getRule(self) -> InsertRule:
|
||||||
|
idx = self.position.currentIndex()
|
||||||
|
if idx == 0:
|
||||||
|
pos = 0
|
||||||
|
elif idx == 1:
|
||||||
|
pos = -1
|
||||||
|
else:
|
||||||
|
pos = self.positionSpin.value()
|
||||||
|
r = InsertRule(text=self.text.text(), position=pos)
|
||||||
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveRuleWidget(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)
|
||||||
|
self.removeType = QComboBox()
|
||||||
|
self.removeType.addItems([
|
||||||
|
"Remove text",
|
||||||
|
"Remove all digits",
|
||||||
|
"Remove first N characters",
|
||||||
|
"Remove last N characters",
|
||||||
|
])
|
||||||
|
self.removeType.currentIndexChanged.connect(self._emit)
|
||||||
|
self.value = QLineEdit()
|
||||||
|
self.value.setPlaceholderText("Text or number")
|
||||||
|
self.value.textChanged.connect(self._emit)
|
||||||
|
layout.addRow("Type:", self.removeType)
|
||||||
|
layout.addRow("Value:", self.value)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
|
def getRule(self) -> RemoveRule:
|
||||||
|
idx = self.removeType.currentIndex()
|
||||||
|
types = ["chars", "digits", "first_n", "last_n"]
|
||||||
|
r = RemoveRule(remove_type=types[idx], value=self.value.text())
|
||||||
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class CaseRuleWidget(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)
|
||||||
|
self.caseType = QComboBox()
|
||||||
|
self.caseType.addItems(["Title Case", "UPPER", "lower", "Sentence case"])
|
||||||
|
self.caseType.currentIndexChanged.connect(self._emit)
|
||||||
|
layout.addRow("Case:", self.caseType)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
|
def getRule(self) -> CaseRule:
|
||||||
|
types = ["title", "upper", "lower", "sentence"]
|
||||||
|
r = CaseRule(case_type=types[self.caseType.currentIndex()])
|
||||||
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class NumberingRuleWidget(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)
|
||||||
|
self.start = QSpinBox()
|
||||||
|
self.start.setMinimum(-9999)
|
||||||
|
self.start.setMaximum(99999)
|
||||||
|
self.start.setValue(1)
|
||||||
|
self.start.valueChanged.connect(self._emit)
|
||||||
|
self.step = QSpinBox()
|
||||||
|
self.step.setMinimum(1)
|
||||||
|
self.step.setMaximum(100)
|
||||||
|
self.step.setValue(1)
|
||||||
|
self.step.valueChanged.connect(self._emit)
|
||||||
|
self.padding = QSpinBox()
|
||||||
|
self.padding.setMinimum(1)
|
||||||
|
self.padding.setMaximum(10)
|
||||||
|
self.padding.setValue(2)
|
||||||
|
self.padding.valueChanged.connect(self._emit)
|
||||||
|
self.where = QComboBox()
|
||||||
|
self.where.addItems(["Prefix", "Suffix", "Insert at index"])
|
||||||
|
self.where.currentIndexChanged.connect(self._emit)
|
||||||
|
self.separator = QLineEdit()
|
||||||
|
self.separator.setText(" ")
|
||||||
|
self.separator.textChanged.connect(self._emit)
|
||||||
|
self.insertAt = QSpinBox()
|
||||||
|
self.insertAt.setMinimum(0)
|
||||||
|
self.insertAt.setMaximum(9999)
|
||||||
|
self.insertAt.valueChanged.connect(self._emit)
|
||||||
|
layout.addRow("Start:", self.start)
|
||||||
|
layout.addRow("Step:", self.step)
|
||||||
|
layout.addRow("Padding:", self.padding)
|
||||||
|
layout.addRow("Where:", self.where)
|
||||||
|
layout.addRow("Separator:", self.separator)
|
||||||
|
layout.addRow("Insert at index:", self.insertAt)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
|
def getRule(self) -> NumberingRule:
|
||||||
|
where_map = ["prefix", "suffix", "insert_at"]
|
||||||
|
r = NumberingRule(
|
||||||
|
start=self.start.value(),
|
||||||
|
step=self.step.value(),
|
||||||
|
padding=self.padding.value(),
|
||||||
|
where=where_map[self.where.currentIndex()],
|
||||||
|
insert_at=self.insertAt.value(),
|
||||||
|
separator=self.separator.text() or " ",
|
||||||
|
)
|
||||||
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeRenumberRuleWidget(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)
|
||||||
|
self.start = QSpinBox()
|
||||||
|
self.start.setMinimum(1)
|
||||||
|
self.start.setMaximum(9999)
|
||||||
|
self.start.setValue(1)
|
||||||
|
self.start.valueChanged.connect(self._emit)
|
||||||
|
self.step = QSpinBox()
|
||||||
|
self.step.setMinimum(1)
|
||||||
|
self.step.setValue(1)
|
||||||
|
self.step.valueChanged.connect(self._emit)
|
||||||
|
self.padding = QSpinBox()
|
||||||
|
self.padding.setMinimum(1)
|
||||||
|
self.padding.setMaximum(3)
|
||||||
|
self.padding.setValue(2)
|
||||||
|
self.padding.valueChanged.connect(self._emit)
|
||||||
|
layout.addRow("First episode number:", self.start)
|
||||||
|
layout.addRow("Step:", self.step)
|
||||||
|
layout.addRow("Zero-pad width:", self.padding)
|
||||||
|
info = QLabel("Matches patterns like S01E05 - Title or Show 1x03 - Title. Episode number is replaced; title is kept.")
|
||||||
|
info.setWordWrap(True)
|
||||||
|
layout.addRow(info)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
|
def getRule(self) -> EpisodeRenumberRule:
|
||||||
|
r = EpisodeRenumberRule(
|
||||||
|
start=self.start.value(),
|
||||||
|
step=self.step.value(),
|
||||||
|
padding=self.padding.value(),
|
||||||
|
)
|
||||||
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class PrefixSuffixRuleWidget(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)
|
||||||
|
self.prefix = QLineEdit()
|
||||||
|
self.prefix.setPlaceholderText("Prefix")
|
||||||
|
self.prefix.textChanged.connect(self._emit)
|
||||||
|
self.suffix = QLineEdit()
|
||||||
|
self.suffix.setPlaceholderText("Suffix")
|
||||||
|
self.suffix.textChanged.connect(self._emit)
|
||||||
|
layout.addRow("Prefix:", self.prefix)
|
||||||
|
layout.addRow("Suffix:", self.suffix)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
|
def getRule(self) -> PrefixSuffixRule:
|
||||||
|
r = PrefixSuffixRule(prefix=self.prefix.text(), suffix=self.suffix.text())
|
||||||
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
return r
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Bulk Renamer - Native Linux GUI for mass renaming files.
|
||||||
|
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure project root is on path when run as script or module
|
||||||
|
_root = Path(__file__).resolve().parent
|
||||||
|
if str(_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_root))
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from gui.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||||
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
|
)
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("Bulk Renamer")
|
||||||
|
win = MainWindow()
|
||||||
|
win.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# For building the AppImage (install in addition to requirements.txt)
|
||||||
|
PyInstaller>=6.0.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PyQt6>=6.4.0
|
||||||
Reference in New Issue
Block a user