Release v1.0.8: recursive folder scan and file type filters.

Adds subfolder inclusion and extension presets so the preview list can target nested media without loading everything.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Bulk Renamer
2026-07-03 17:46:58 -05:00
co-authored by Cursor
parent 1c475ee856
commit 85dc873e16
6 changed files with 163 additions and 20 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.7 1.0.8
+8 -2
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from difflib import SequenceMatcher from difflib import SequenceMatcher
from pathlib import Path
from typing import Optional from typing import Optional
from .tvdb_client import TvdbEpisode from .tvdb_client import TvdbEpisode
@@ -201,7 +202,8 @@ def match_filenames_to_episodes(
file_entries: list[tuple[str, str, str, dict]] = [] file_entries: list[tuple[str, str, str, dict]] = []
for name in filenames: for name in filenames:
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name base = Path(name).name
stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base
parsed = parse_episode_stem(stem, pattern) parsed = parse_episode_stem(stem, pattern)
if not parsed or not parsed["title"]: if not parsed or not parsed["title"]:
continue continue
@@ -247,7 +249,11 @@ def match_filenames_to_episodes(
name for name in filenames name for name in filenames
if name not in mapping if name not in mapping
and parse_episode_stem( and parse_episode_stem(
name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name, (
Path(name).name.rsplit(".", 1)[0]
if "." in Path(name).name and not Path(name).name.startswith(".")
else Path(name).name
),
pattern, pattern,
) )
] ]
+86
View File
@@ -0,0 +1,86 @@
"""Scan folders for rename candidates with optional recursion and extension filters."""
from __future__ import annotations
import os
import re
from pathlib import Path
from .pipeline import UNDO_FILENAME
FILE_TYPE_PRESETS: dict[str, str] = {
"All files": "",
"Video": ".mkv,.mp4,.avi,.webm,.m4v,.mov",
"Audio": ".mp3,.flac,.wav,.ogg,.m4a,.aac",
"Images": ".jpg,.jpeg,.png,.gif,.webp,.bmp",
}
def parse_extension_filter(spec: str) -> frozenset[str] | None:
"""
Parse a user extension filter into a set of lowercase extensions with a leading dot.
Returns None when empty (match all files).
"""
spec = spec.strip()
if not spec:
return None
exts: set[str] = set()
for part in re.split(r"[,;\s]+", spec):
part = part.strip().lower()
if not part:
continue
if part.startswith("*."):
part = part[1:]
if not part.startswith("."):
part = "." + part
exts.add(part)
return frozenset(exts) if exts else None
def file_extension(rel_path: str) -> str:
"""Return the lowercase extension (with dot) of the basename, or '' if none."""
name = Path(rel_path).name
if "." in name and not name.startswith("."):
return "." + name.rsplit(".", 1)[-1].lower()
return ""
def matches_extension(rel_path: str, extensions: frozenset[str] | None) -> bool:
if extensions is None:
return True
return file_extension(rel_path) in extensions
def list_files(
base_dir: str,
*,
recursive: bool = False,
extensions: frozenset[str] | None = None,
) -> list[str]:
"""Return sorted paths relative to base_dir."""
base = Path(base_dir)
if not base.is_dir():
return []
found: list[str] = []
def consider(path: Path) -> None:
if not path.is_file():
return
if path.name.startswith("."):
return
if path.name == UNDO_FILENAME:
return
rel = path.relative_to(base).as_posix()
if matches_extension(rel, extensions):
found.append(rel)
if recursive:
for dirpath, dirnames, filenames in os.walk(base):
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
for fn in sorted(filenames):
consider(Path(dirpath) / fn)
else:
for entry in sorted(base.iterdir(), key=lambda p: p.name.lower()):
consider(entry)
return sorted(found, key=str.lower)
+19 -7
View File
@@ -25,13 +25,24 @@ def apply_pipeline(
index: int, index: int,
total: int, total: int,
) -> str: ) -> str:
"""Apply all enabled rules to a single filename (no path). Returns new filename.""" """Apply all enabled rules to a filename or relative path. Returns new name/path."""
stem, ext = _split_name(name) rel = Path(name)
if len(rel.parts) > 1:
parent = rel.parent
file_name = rel.name
else:
parent = None
file_name = name
stem, ext = _split_name(file_name)
for r in rules: for r in rules:
if not r.enabled: if not r.enabled:
continue continue
stem, ext = r.apply(stem, ext, index, total, original_name=name) stem, ext = r.apply(stem, ext, index, total, original_name=name)
return stem + ext new_name = stem + ext
if parent is not None:
return str(parent / new_name)
return new_name
def compute_preview( def compute_preview(
@@ -77,11 +88,12 @@ def perform_renames(
final_names = {n for _, n in step1} final_names = {n for _, n in step1}
temp_map = [] temp_map = []
for i, (old_path, new_name) in enumerate(step1): for i, (old_path, new_name) in enumerate(step1):
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}" new_path = base / new_name
while temp_name in final_names or (base / temp_name).exists(): temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
while temp_path.name in final_names or temp_path.exists() or temp_path == new_path:
i += 1 i += 1
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}" temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
temp_map.append((old_path, base / temp_name, base / new_name)) temp_map.append((old_path, temp_path, new_path))
if dry_run: if dry_run:
for old_p, temp_p, new_p in temp_map: for old_p, temp_p, new_p in temp_map:
+45 -10
View File
@@ -22,11 +22,14 @@ from PyQt6.QtWidgets import (
QHeaderView, QHeaderView,
QAbstractItemView, QAbstractItemView,
QFrame, QFrame,
QCheckBox,
QComboBox,
) )
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel from PyQt6.QtCore import Qt, QDir, QItemSelectionModel
from PyQt6.QtGui import QFont, QColor from PyQt6.QtGui import QFont, QColor
from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo
from engine.file_scan import FILE_TYPE_PRESETS, list_files, parse_extension_filter, file_extension
from engine.rules import Rule from engine.rules import Rule
from .rule_widgets import ( from .rule_widgets import (
ReplaceRuleWidget, ReplaceRuleWidget,
@@ -70,6 +73,22 @@ class MainWindow(QMainWindow):
dir_layout.addWidget(browse_btn) dir_layout.addWidget(browse_btn)
layout.addLayout(dir_layout) layout.addLayout(dir_layout)
filter_layout = QHBoxLayout()
self.recursive_cb = QCheckBox("Include subfolders")
self.recursive_cb.toggled.connect(self._reload_files)
filter_layout.addWidget(self.recursive_cb)
filter_layout.addWidget(QLabel("File types:"))
self.types_preset = QComboBox()
self.types_preset.addItems(list(FILE_TYPE_PRESETS.keys()) + ["Custom"])
self.types_preset.currentTextChanged.connect(self._on_types_preset_changed)
filter_layout.addWidget(self.types_preset)
self.types_edit = QLineEdit()
self.types_edit.setPlaceholderText("e.g. .mkv,.mp4 or *.avi (empty = all types)")
self.types_edit.setReadOnly(True)
self.types_edit.editingFinished.connect(self._reload_files)
filter_layout.addWidget(self.types_edit, 1)
layout.addLayout(filter_layout)
# Split: left = rules, right = file list + preview # Split: left = rules, right = file list + preview
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
@@ -160,6 +179,14 @@ class MainWindow(QMainWindow):
if path: if path:
self.dir_edit.setText(path) self.dir_edit.setText(path)
def _on_types_preset_changed(self, preset: str):
if preset != "Custom":
self.types_edit.setText(FILE_TYPE_PRESETS.get(preset, ""))
self.types_edit.setReadOnly(True)
else:
self.types_edit.setReadOnly(False)
self._reload_files()
def _on_dir_changed(self): def _on_dir_changed(self):
path = self.dir_edit.text().strip() path = self.dir_edit.text().strip()
if not path or not os.path.isdir(path): if not path or not os.path.isdir(path):
@@ -167,10 +194,19 @@ class MainWindow(QMainWindow):
self._file_names = [] self._file_names = []
else: else:
self._base_dir = path self._base_dir = path
self._file_names = sorted( self._reload_files()
f for f in os.listdir(path) return
if os.path.isfile(os.path.join(path, f)) and not f.startswith(".") self._refresh_preview()
)
def _reload_files(self):
if not self._base_dir:
return
extensions = parse_extension_filter(self.types_edit.text())
self._file_names = list_files(
self._base_dir,
recursive=self.recursive_cb.isChecked(),
extensions=extensions,
)
self._refresh_preview() self._refresh_preview()
def _on_rule_changed(self): def _on_rule_changed(self):
@@ -189,9 +225,7 @@ class MainWindow(QMainWindow):
def _ext_for(self, filename: str) -> str: def _ext_for(self, filename: str) -> str:
"""Return file extension with leading dot, or empty string if none.""" """Return file extension with leading dot, or empty string if none."""
if "." in filename and not filename.startswith("."): return file_extension(filename)
return "." + filename.rsplit(".", 1)[-1].lower()
return ""
def _on_preview_selection_changed(self): def _on_preview_selection_changed(self):
"""Show preview (new name) only in selected rows; others show original.""" """Show preview (new name) only in selected rows; others show original."""
@@ -263,7 +297,8 @@ class MainWindow(QMainWindow):
self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.") self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.")
self.preview_status.setStyleSheet("color: #c00;") self.preview_status.setStyleSheet("color: #c00;")
else: else:
self.preview_status.setText(f"{len(preview)} file(s). Ready to rename.") scope = "including subfolders" if self.recursive_cb.isChecked() else "top folder only"
self.preview_status.setText(f"{len(preview)} file(s) ({scope}). Ready to rename.")
self.preview_status.setStyleSheet("") self.preview_status.setStyleSheet("")
def _apply_renames(self): def _apply_renames(self):
@@ -317,7 +352,7 @@ class MainWindow(QMainWindow):
else: else:
save_undo_log(self._base_dir, renames) save_undo_log(self._base_dir, renames)
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).") QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
self._on_dir_changed() self._reload_files()
def _undo_renames(self): def _undo_renames(self):
if not self._base_dir: if not self._base_dir:
@@ -349,4 +384,4 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "Undo errors", msg) QMessageBox.warning(self, "Undo errors", msg)
else: else:
QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).") QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).")
self._on_dir_changed() self._reload_files()
+4
View File
@@ -0,0 +1,4 @@
HSRename 1.0.8
- Include subfolders option for recursive file listing
- File type filter with Video, Audio, Images presets and custom extensions
- Rename rules preserve subfolder paths; nested apply/undo supported