From 22501fe0b55098e27fd6d67d899239a0cb555b29 Mon Sep 17 00:00:00 2001 From: Bulk Renamer Date: Tue, 3 Mar 2026 21:58:28 -0600 Subject: [PATCH] Initial commit: Bulk Renamer with AppImage build and Gitea push notes Made-with: Cursor --- .gitignore | 27 ++++ PUSH_TO_GITEA.md | 17 +++ README.md | 73 +++++++++ build_appimage.sh | 59 ++++++++ engine/__init__.py | 16 ++ engine/pipeline.py | 106 +++++++++++++ engine/rules.py | 182 +++++++++++++++++++++++ gui/__init__.py | 1 + gui/main_window.py | 293 ++++++++++++++++++++++++++++++++++++ gui/rule_widgets.py | 327 +++++++++++++++++++++++++++++++++++++++++ main.py | 31 ++++ requirements-build.txt | 2 + requirements.txt | 1 + 13 files changed, 1135 insertions(+) create mode 100644 .gitignore create mode 100644 PUSH_TO_GITEA.md create mode 100644 README.md create mode 100644 build_appimage.sh create mode 100644 engine/__init__.py create mode 100644 engine/pipeline.py create mode 100644 engine/rules.py create mode 100644 gui/__init__.py create mode 100644 gui/main_window.py create mode 100644 gui/rule_widgets.py create mode 100644 main.py create mode 100644 requirements-build.txt create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff49935 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/PUSH_TO_GITEA.md b/PUSH_TO_GITEA.md new file mode 100644 index 0000000..080f719 --- /dev/null +++ b/PUSH_TO_GITEA.md @@ -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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a0aa38 --- /dev/null +++ b/README.md @@ -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. diff --git a/build_appimage.sh b/build_appimage.sh new file mode 100644 index 0000000..76fa9c4 --- /dev/null +++ b/build_appimage.sh @@ -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 diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..4c2a825 --- /dev/null +++ b/engine/__init__.py @@ -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", +] diff --git a/engine/pipeline.py b/engine/pipeline.py new file mode 100644 index 0000000..23201d9 --- /dev/null +++ b/engine/pipeline.py @@ -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 diff --git a/engine/rules.py b/engine/rules.py new file mode 100644 index 0000000..c1a8eb1 --- /dev/null +++ b/engine/rules.py @@ -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 diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..f4df915 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1 @@ +# GUI package diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..ecfc42d --- /dev/null +++ b/gui/main_window.py @@ -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() diff --git a/gui/rule_widgets.py b/gui/rule_widgets.py new file mode 100644 index 0000000..eacc317 --- /dev/null +++ b/gui/rule_widgets.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..1546789 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..361635f --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,2 @@ +# For building the AppImage (install in addition to requirements.txt) +PyInstaller>=6.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f565544 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyQt6>=6.4.0