Add wildcard and regex modes to Bulk Rename
Plain-text substring matching couldn't handle a batch of slightly-different patterns (e.g. several release-group suffixes) in one pass. Add a match-type selector: wildcard (shell-style */?) for the common case, and full regex (with backreferences in the replacement) for anything more precise. Invalid regex is caught and reported inline instead of failing the request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -116,7 +116,11 @@ silently stay blank instead of erroring.
|
|||||||
for when files were added/removed directly on disk.
|
for when files were added/removed directly on disk.
|
||||||
- *Bulk Rename*: find & replace across every course/folder name in the
|
- *Bulk Rename*: find & replace across every course/folder name in the
|
||||||
library at once, with a per-match preview and the ability to drop
|
library at once, with a per-match preview and the ability to drop
|
||||||
individual matches before applying.
|
individual matches before applying. Three match modes: plain text
|
||||||
|
(default, literal substring), wildcard (shell-style `*`/`?`, e.g.
|
||||||
|
`.BOOKWARE*` catches `.BOOKWARE-GETH`, `.BOOKWARE-BOOKTIME`,
|
||||||
|
`.BOOKWARE-BLZiSO`, etc. in one pass), or full regex (with
|
||||||
|
backreferences in the replacement, e.g. `\1`).
|
||||||
- *Manage Library*: hide courses/folders from the browser without
|
- *Manage Library*: hide courses/folders from the browser without
|
||||||
touching anything on disk (hiding a folder hides everything inside it;
|
touching anything on disk (hiding a folder hides everything inside it;
|
||||||
bulk-select elsewhere to hide or queue several at once), rename a
|
bulk-select elsewhere to hide or queue several at once), rename a
|
||||||
|
|||||||
+59
-9
@@ -3433,30 +3433,80 @@ def _iter_all_directories(directory: Path, hidden_set: set) -> Iterator[Path]:
|
|||||||
yield from _iter_all_directories(entry, hidden_set)
|
yield from _iter_all_directories(entry, hidden_set)
|
||||||
|
|
||||||
|
|
||||||
def find_bulk_rename_matches(library_root: str, pattern: str, replacement: str) -> List[Dict[str, str]]:
|
def _wildcard_to_regex(pattern: str) -> str:
|
||||||
"""Every directory (course or folder) in the library whose name contains `pattern`."""
|
"""
|
||||||
|
Translate a shell-style wildcard pattern into a regex fragment for
|
||||||
|
*substring* matching (not a full-name match, unlike fnmatch) - '*'
|
||||||
|
matches any run of characters, '?' matches exactly one, everything
|
||||||
|
else is matched literally. Lets ".BOOKWARE*" catch
|
||||||
|
".BOOKWARE-GETH", ".BOOKWARE-BOOKTIME", ".BOOKWARE-BLZiSO", etc. in
|
||||||
|
one pattern instead of one bulk rename per exact suffix.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for ch in pattern:
|
||||||
|
if ch == '*':
|
||||||
|
parts.append('.*')
|
||||||
|
elif ch == '?':
|
||||||
|
parts.append('.')
|
||||||
|
else:
|
||||||
|
parts.append(re.escape(ch))
|
||||||
|
return ''.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def find_bulk_rename_matches(library_root: str, pattern: str, replacement: str,
|
||||||
|
mode: str = 'plain') -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Every directory (course or folder) in the library whose name matches
|
||||||
|
`pattern`, paired with what it would become. Three modes:
|
||||||
|
- 'plain' (default): `pattern` is matched/replaced as a literal
|
||||||
|
substring, same as before.
|
||||||
|
- 'wildcard': `pattern` uses shell-style '*'/'?' wildcards (see
|
||||||
|
_wildcard_to_regex); `replacement` is literal text.
|
||||||
|
- 'regex': `pattern` is a Python regex, matched with re.search and
|
||||||
|
replaced with re.sub - `replacement` may use backreferences like
|
||||||
|
\\1. Raises re.error if `pattern` isn't valid.
|
||||||
|
All three replace every match in the name, not just the first, mirroring
|
||||||
|
how plain mode's str.replace() already behaved.
|
||||||
|
"""
|
||||||
hidden_set = set(get_hidden_paths())
|
hidden_set = set(get_hidden_paths())
|
||||||
|
directories = _iter_all_directories(Path(library_root), hidden_set)
|
||||||
|
|
||||||
|
if mode == 'plain':
|
||||||
return [
|
return [
|
||||||
{
|
{'path': str(directory), 'old_name': directory.name,
|
||||||
'path': str(directory),
|
'new_name': directory.name.replace(pattern, replacement)}
|
||||||
'old_name': directory.name,
|
for directory in directories
|
||||||
'new_name': directory.name.replace(pattern, replacement)
|
|
||||||
}
|
|
||||||
for directory in _iter_all_directories(Path(library_root), hidden_set)
|
|
||||||
if pattern in directory.name
|
if pattern in directory.name
|
||||||
]
|
]
|
||||||
|
|
||||||
|
regex = re.compile(pattern if mode == 'regex' else _wildcard_to_regex(pattern))
|
||||||
|
matches = []
|
||||||
|
for directory in directories:
|
||||||
|
if regex.search(directory.name):
|
||||||
|
matches.append({
|
||||||
|
'path': str(directory),
|
||||||
|
'old_name': directory.name,
|
||||||
|
'new_name': regex.sub(replacement, directory.name),
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/bulk-rename/preview')
|
@app.route('/api/bulk-rename/preview')
|
||||||
def bulk_rename_preview_api():
|
def bulk_rename_preview_api():
|
||||||
"""Preview a find/replace rename across the whole library, without touching disk."""
|
"""Preview a find/replace rename across the whole library, without touching disk."""
|
||||||
pattern = request.args.get('pattern', '')
|
pattern = request.args.get('pattern', '')
|
||||||
replacement = request.args.get('replacement', '')
|
replacement = request.args.get('replacement', '')
|
||||||
|
mode = request.args.get('mode', 'plain')
|
||||||
if not pattern:
|
if not pattern:
|
||||||
return jsonify({'error': 'pattern is required'}), 400
|
return jsonify({'error': 'pattern is required'}), 400
|
||||||
|
if mode not in ('plain', 'wildcard', 'regex'):
|
||||||
|
return jsonify({'error': 'Invalid mode'}), 400
|
||||||
|
|
||||||
library_root = os.path.abspath(get_library_root())
|
library_root = os.path.abspath(get_library_root())
|
||||||
matches = find_bulk_rename_matches(library_root, pattern, replacement)
|
try:
|
||||||
|
matches = find_bulk_rename_matches(library_root, pattern, replacement, mode)
|
||||||
|
except re.error as e:
|
||||||
|
return jsonify({'error': f'Invalid pattern: {e}'}), 400
|
||||||
return jsonify({'matches': matches})
|
return jsonify({'matches': matches})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+33
-4
@@ -117,6 +117,12 @@
|
|||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
}
|
}
|
||||||
|
.section-desc code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
.text-input {
|
.text-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
@@ -515,9 +521,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title">{{ icons.icon('pencil', 18) }} Bulk Rename</h2>
|
<h2 class="section-title">{{ icons.icon('pencil', 18) }} Bulk Rename</h2>
|
||||||
<p class="section-desc">Find and replace text across every course/folder name in the library at once - handy for stripping a release-group suffix off a batch of downloads.</p>
|
<p class="section-desc">Find and replace text across every course/folder name in the library at once - handy for stripping a release-group suffix off a batch of downloads. Wildcard or regex mode can match several slightly-different patterns in one pass, e.g. <code>.BOOKWARE*</code> catching <code>.BOOKWARE-GETH</code>, <code>.BOOKWARE-BOOKTIME</code>, <code>.BOOKWARE-BLZiSO</code>, etc. at once.</p>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="toolbar" style="margin-bottom: 10px;">
|
<div class="toolbar" style="margin-bottom: 10px;">
|
||||||
|
<select id="bulk-rename-mode" class="text-input" style="flex: 0 0 auto; min-width: 130px;" onchange="updateBulkRenamePlaceholder()">
|
||||||
|
<option value="plain">Plain text</option>
|
||||||
|
<option value="wildcard">Wildcard (*, ?)</option>
|
||||||
|
<option value="regex">Regex</option>
|
||||||
|
</select>
|
||||||
<input type="text" id="bulk-rename-find" class="text-input" placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
|
<input type="text" id="bulk-rename-find" class="text-input" placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
|
||||||
<input type="text" id="bulk-rename-replace" class="text-input" placeholder="Replace with (blank to remove)">
|
<input type="text" id="bulk-rename-replace" class="text-input" placeholder="Replace with (blank to remove)">
|
||||||
<button class="btn" onclick="previewBulkRename()">Preview</button>
|
<button class="btn" onclick="previewBulkRename()">Preview</button>
|
||||||
@@ -824,9 +835,21 @@
|
|||||||
// ---- Bulk rename: find/replace across every course/folder name ----
|
// ---- Bulk rename: find/replace across every course/folder name ----
|
||||||
let bulkRenameMatches = [];
|
let bulkRenameMatches = [];
|
||||||
|
|
||||||
|
const BULK_RENAME_PLACEHOLDERS = {
|
||||||
|
plain: 'Find (e.g. .BOOKWARE-LERNSTUF)',
|
||||||
|
wildcard: 'Find (e.g. .BOOKWARE*)',
|
||||||
|
regex: String.raw`Find (e.g. \.BOOKWARE-\w+)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateBulkRenamePlaceholder() {
|
||||||
|
const mode = document.getElementById('bulk-rename-mode').value;
|
||||||
|
document.getElementById('bulk-rename-find').placeholder = BULK_RENAME_PLACEHOLDERS[mode] || BULK_RENAME_PLACEHOLDERS.plain;
|
||||||
|
}
|
||||||
|
|
||||||
function previewBulkRename() {
|
function previewBulkRename() {
|
||||||
const pattern = document.getElementById('bulk-rename-find').value;
|
const pattern = document.getElementById('bulk-rename-find').value;
|
||||||
const replacement = document.getElementById('bulk-rename-replace').value;
|
const replacement = document.getElementById('bulk-rename-replace').value;
|
||||||
|
const mode = document.getElementById('bulk-rename-mode').value;
|
||||||
const status = document.getElementById('bulk-rename-status');
|
const status = document.getElementById('bulk-rename-status');
|
||||||
const preview = document.getElementById('bulk-rename-preview');
|
const preview = document.getElementById('bulk-rename-preview');
|
||||||
preview.innerHTML = '';
|
preview.innerHTML = '';
|
||||||
@@ -837,9 +860,15 @@
|
|||||||
}
|
}
|
||||||
status.style.color = 'var(--text-muted)';
|
status.style.color = 'var(--text-muted)';
|
||||||
status.textContent = 'Searching...';
|
status.textContent = 'Searching...';
|
||||||
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`)
|
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}&mode=${encodeURIComponent(mode)}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||||
.then(data => {
|
.then(({ ok, data }) => {
|
||||||
|
if (!ok) {
|
||||||
|
status.style.color = 'var(--error)';
|
||||||
|
status.textContent = data.error || 'Invalid pattern.';
|
||||||
|
bulkRenameMatches = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
bulkRenameMatches = data.matches || [];
|
bulkRenameMatches = data.matches || [];
|
||||||
renderBulkRenamePreview();
|
renderBulkRenamePreview();
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user