Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ec7746b8 |
+2
-6
@@ -39,12 +39,8 @@ if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then
|
||||
fi
|
||||
# Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped
|
||||
# padding keeps each release a unique download size without affecting the app.
|
||||
VER_MAJOR="${VERSION%%.*}"
|
||||
VER_REST="${VERSION#*.}"
|
||||
VER_MINOR="${VER_REST%%.*}"
|
||||
VER_PATCH="${VER_REST#*.}"
|
||||
VER_PATCH="${VER_PATCH%%.*}"
|
||||
PAD_KB=$((300 + 10#${VER_MAJOR:-0} * 100 + 10#${VER_MINOR:-0} * 20 + 10#${VER_PATCH:-0} * 10))
|
||||
IFS=. read -r VER_MAJOR VER_MINOR VER_PATCH <<< "${VERSION}.0.0"
|
||||
PAD_KB=$((300 + VER_MAJOR * 100 + VER_MINOR * 20 + VER_PATCH * 10))
|
||||
dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.release-pad" bs=1024 count="$PAD_KB" status=none
|
||||
echo "Release padding: ${PAD_KB} KiB (Gear Lever update detection)"
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -202,8 +201,7 @@ def match_filenames_to_episodes(
|
||||
|
||||
file_entries: list[tuple[str, str, str, dict]] = []
|
||||
for name in filenames:
|
||||
base = Path(name).name
|
||||
stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base
|
||||
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name
|
||||
parsed = parse_episode_stem(stem, pattern)
|
||||
if not parsed or not parsed["title"]:
|
||||
continue
|
||||
@@ -249,11 +247,7 @@ def match_filenames_to_episodes(
|
||||
name for name in filenames
|
||||
if name not in mapping
|
||||
and parse_episode_stem(
|
||||
(
|
||||
Path(name).name.rsplit(".", 1)[0]
|
||||
if "." in Path(name).name and not Path(name).name.startswith(".")
|
||||
else Path(name).name
|
||||
),
|
||||
name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name,
|
||||
pattern,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""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)
|
||||
+7
-19
@@ -25,24 +25,13 @@ def apply_pipeline(
|
||||
index: int,
|
||||
total: int,
|
||||
) -> str:
|
||||
"""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)
|
||||
"""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, original_name=name)
|
||||
new_name = stem + ext
|
||||
if parent is not None:
|
||||
return str(parent / new_name)
|
||||
return new_name
|
||||
return stem + ext
|
||||
|
||||
|
||||
def compute_preview(
|
||||
@@ -88,12 +77,11 @@ def perform_renames(
|
||||
final_names = {n for _, n in step1}
|
||||
temp_map = []
|
||||
for i, (old_path, new_name) in enumerate(step1):
|
||||
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:
|
||||
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||
while temp_name in final_names or (base / temp_name).exists():
|
||||
i += 1
|
||||
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||
temp_map.append((old_path, temp_path, new_path))
|
||||
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:
|
||||
|
||||
+9
-44
@@ -22,14 +22,11 @@ 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,
|
||||
@@ -73,22 +70,6 @@ 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)
|
||||
|
||||
@@ -179,14 +160,6 @@ 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):
|
||||
@@ -194,18 +167,9 @@ class MainWindow(QMainWindow):
|
||||
self._file_names = []
|
||||
else:
|
||||
self._base_dir = path
|
||||
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._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()
|
||||
|
||||
@@ -225,7 +189,9 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _ext_for(self, filename: str) -> str:
|
||||
"""Return file extension with leading dot, or empty string if none."""
|
||||
return file_extension(filename)
|
||||
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."""
|
||||
@@ -297,8 +263,7 @@ 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:
|
||||
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.setText(f"{len(preview)} file(s). Ready to rename.")
|
||||
self.preview_status.setStyleSheet("")
|
||||
|
||||
def _apply_renames(self):
|
||||
@@ -352,7 +317,7 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
save_undo_log(self._base_dir, renames)
|
||||
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
|
||||
self._reload_files()
|
||||
self._on_dir_changed()
|
||||
|
||||
def _undo_renames(self):
|
||||
if not self._base_dir:
|
||||
@@ -384,4 +349,4 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.warning(self, "Undo errors", msg)
|
||||
else:
|
||||
QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).")
|
||||
self._reload_files()
|
||||
self._on_dir_changed()
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
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
|
||||
Reference in New Issue
Block a user