Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ec7746b8 |
+2
-6
@@ -39,12 +39,8 @@ if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then
|
|||||||
fi
|
fi
|
||||||
# Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped
|
# Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped
|
||||||
# padding keeps each release a unique download size without affecting the app.
|
# padding keeps each release a unique download size without affecting the app.
|
||||||
VER_MAJOR="${VERSION%%.*}"
|
IFS=. read -r VER_MAJOR VER_MINOR VER_PATCH <<< "${VERSION}.0.0"
|
||||||
VER_REST="${VERSION#*.}"
|
PAD_KB=$((300 + VER_MAJOR * 100 + VER_MINOR * 20 + VER_PATCH * 10))
|
||||||
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))
|
|
||||||
dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.release-pad" bs=1024 count="$PAD_KB" status=none
|
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)"
|
echo "Release padding: ${PAD_KB} KiB (Gear Lever update detection)"
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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
|
||||||
@@ -202,8 +201,7 @@ 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:
|
||||||
base = Path(name).name
|
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else 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
|
||||||
@@ -249,11 +247,7 @@ 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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
index: int,
|
||||||
total: int,
|
total: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Apply all enabled rules to a filename or relative path. Returns new name/path."""
|
"""Apply all enabled rules to a single filename (no path). Returns new filename."""
|
||||||
rel = Path(name)
|
stem, ext = _split_name(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)
|
||||||
new_name = stem + ext
|
return stem + ext
|
||||||
if parent is not None:
|
|
||||||
return str(parent / new_name)
|
|
||||||
return new_name
|
|
||||||
|
|
||||||
|
|
||||||
def compute_preview(
|
def compute_preview(
|
||||||
@@ -88,12 +77,11 @@ 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):
|
||||||
new_path = base / new_name
|
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||||
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
|
while temp_name in final_names or (base / temp_name).exists():
|
||||||
while temp_path.name in final_names or temp_path.exists() or temp_path == new_path:
|
|
||||||
i += 1
|
i += 1
|
||||||
temp_path = old_path.parent / f"__temp_{i}_{old_path.name}{temp_suffix}"
|
temp_name = f"__temp_{i}_{old_path.name}{temp_suffix}"
|
||||||
temp_map.append((old_path, temp_path, new_path))
|
temp_map.append((old_path, base / temp_name, base / new_name))
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
+9
-44
@@ -22,14 +22,11 @@ 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,
|
||||||
@@ -73,22 +70,6 @@ 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)
|
||||||
|
|
||||||
@@ -179,14 +160,6 @@ 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):
|
||||||
@@ -194,18 +167,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._file_names = []
|
self._file_names = []
|
||||||
else:
|
else:
|
||||||
self._base_dir = path
|
self._base_dir = path
|
||||||
self._reload_files()
|
self._file_names = sorted(
|
||||||
return
|
f for f in os.listdir(path)
|
||||||
self._refresh_preview()
|
if os.path.isfile(os.path.join(path, f)) and not f.startswith(".")
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
@@ -225,7 +189,9 @@ 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."""
|
||||||
return file_extension(filename)
|
if "." in filename and not filename.startswith("."):
|
||||||
|
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."""
|
||||||
@@ -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.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:
|
||||||
scope = "including subfolders" if self.recursive_cb.isChecked() else "top folder only"
|
self.preview_status.setText(f"{len(preview)} file(s). Ready to rename.")
|
||||||
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):
|
||||||
@@ -352,7 +317,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._reload_files()
|
self._on_dir_changed()
|
||||||
|
|
||||||
def _undo_renames(self):
|
def _undo_renames(self):
|
||||||
if not self._base_dir:
|
if not self._base_dir:
|
||||||
@@ -384,4 +349,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._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