diff --git a/VERSION b/VERSION index 238d6e8..b0f3d96 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.7 +1.0.8 diff --git a/engine/episode_match.py b/engine/episode_match.py index d92d68c..65c7fb2 100644 --- a/engine/episode_match.py +++ b/engine/episode_match.py @@ -6,6 +6,7 @@ from __future__ import annotations import re from dataclasses import dataclass from difflib import SequenceMatcher +from pathlib import Path from typing import Optional from .tvdb_client import TvdbEpisode @@ -201,7 +202,8 @@ def match_filenames_to_episodes( file_entries: list[tuple[str, str, str, dict]] = [] 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) if not parsed or not parsed["title"]: continue @@ -247,7 +249,11 @@ def match_filenames_to_episodes( name for name in filenames if name not in mapping 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, ) ] diff --git a/engine/file_scan.py b/engine/file_scan.py new file mode 100644 index 0000000..3940286 --- /dev/null +++ b/engine/file_scan.py @@ -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) diff --git a/engine/pipeline.py b/engine/pipeline.py index f81c1a4..431c073 100644 --- a/engine/pipeline.py +++ b/engine/pipeline.py @@ -25,13 +25,24 @@ def apply_pipeline( index: int, total: int, ) -> str: - """Apply all enabled rules to a single filename (no path). Returns new filename.""" - stem, ext = _split_name(name) + """Apply all enabled rules to a filename or relative path. Returns new name/path.""" + 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: if not r.enabled: continue 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( @@ -77,11 +88,12 @@ def perform_renames( 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(): + new_path = base / new_name + 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 - temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}" - temp_map.append((old_path, base / temp_name, base / new_name)) + temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}" + temp_map.append((old_path, temp_path, new_path)) if dry_run: for old_p, temp_p, new_p in temp_map: diff --git a/gui/main_window.py b/gui/main_window.py index 4bed955..79058b6 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -22,11 +22,14 @@ from PyQt6.QtWidgets import ( QHeaderView, QAbstractItemView, QFrame, + QCheckBox, + QComboBox, ) from PyQt6.QtCore import Qt, QDir, QItemSelectionModel from PyQt6.QtGui import QFont, QColor 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 .rule_widgets import ( ReplaceRuleWidget, @@ -70,6 +73,22 @@ class MainWindow(QMainWindow): dir_layout.addWidget(browse_btn) 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 = QSplitter(Qt.Orientation.Horizontal) @@ -160,6 +179,14 @@ class MainWindow(QMainWindow): if 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): path = self.dir_edit.text().strip() if not path or not os.path.isdir(path): @@ -167,10 +194,19 @@ class MainWindow(QMainWindow): 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._reload_files() + return + 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() def _on_rule_changed(self): @@ -189,9 +225,7 @@ class MainWindow(QMainWindow): 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 "" + return file_extension(filename) def _on_preview_selection_changed(self): """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.setStyleSheet("color: #c00;") 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("") def _apply_renames(self): @@ -317,7 +352,7 @@ class MainWindow(QMainWindow): else: save_undo_log(self._base_dir, renames) QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).") - self._on_dir_changed() + self._reload_files() def _undo_renames(self): if not self._base_dir: @@ -349,4 +384,4 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "Undo errors", msg) else: QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).") - self._on_dir_changed() + self._reload_files() diff --git a/packaging/release-stamp-1.0.8.txt b/packaging/release-stamp-1.0.8.txt new file mode 100644 index 0000000..7f18769 --- /dev/null +++ b/packaging/release-stamp-1.0.8.txt @@ -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