From bcd1734c9419d9bd8f8a39cbe336dd5c474a7ef1 Mon Sep 17 00:00:00 2001 From: rmsitz Date: Mon, 24 Aug 2026 11:45:52 -0400 Subject: [PATCH] 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 --- README.md | 6 +++- offlineu_core.py | 74 ++++++++++++++++++++++++++++++++++------- templates/unsorted.html | 37 ++++++++++++++++++--- 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 03f0170..bd1b9c8 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,11 @@ silently stay blank instead of erroring. for when files were added/removed directly on disk. - *Bulk Rename*: find & replace across every course/folder name in the 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 touching anything on disk (hiding a folder hides everything inside it; bulk-select elsewhere to hide or queue several at once), rename a diff --git a/offlineu_core.py b/offlineu_core.py index b948d63..083be81 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -3433,18 +3433,62 @@ def _iter_all_directories(directory: Path, hidden_set: set) -> Iterator[Path]: yield from _iter_all_directories(entry, hidden_set) -def find_bulk_rename_matches(library_root: str, pattern: str, replacement: str) -> List[Dict[str, str]]: - """Every directory (course or folder) in the library whose name contains `pattern`.""" +def _wildcard_to_regex(pattern: str) -> str: + """ + 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()) - return [ - { - 'path': str(directory), - 'old_name': directory.name, - 'new_name': directory.name.replace(pattern, replacement) - } - for directory in _iter_all_directories(Path(library_root), hidden_set) - if pattern in directory.name - ] + directories = _iter_all_directories(Path(library_root), hidden_set) + + if mode == 'plain': + return [ + {'path': str(directory), 'old_name': directory.name, + 'new_name': directory.name.replace(pattern, replacement)} + for directory in directories + 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') @@ -3452,11 +3496,17 @@ def bulk_rename_preview_api(): """Preview a find/replace rename across the whole library, without touching disk.""" pattern = request.args.get('pattern', '') replacement = request.args.get('replacement', '') + mode = request.args.get('mode', 'plain') if not pattern: 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()) - 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}) diff --git a/templates/unsorted.html b/templates/unsorted.html index 535d9a0..0ef9f00 100644 --- a/templates/unsorted.html +++ b/templates/unsorted.html @@ -117,6 +117,12 @@ font-size: 0.92em; margin: 0 0 14px; } + .section-desc code { + background: var(--bg-tertiary); + border-radius: 4px; + padding: 1px 5px; + font-family: monospace; + } .text-input { flex: 1; min-width: 160px; @@ -515,9 +521,14 @@

{{ icons.icon('pencil', 18) }} Bulk Rename

-

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.

+

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. .BOOKWARE* catching .BOOKWARE-GETH, .BOOKWARE-BOOKTIME, .BOOKWARE-BLZiSO, etc. at once.

+ @@ -824,9 +835,21 @@ // ---- Bulk rename: find/replace across every course/folder name ---- 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() { const pattern = document.getElementById('bulk-rename-find').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 preview = document.getElementById('bulk-rename-preview'); preview.innerHTML = ''; @@ -837,9 +860,15 @@ } status.style.color = 'var(--text-muted)'; status.textContent = 'Searching...'; - fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`) - .then(r => r.json()) - .then(data => { + fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}&mode=${encodeURIComponent(mode)}`) + .then(r => r.json().then(data => ({ ok: r.ok, data }))) + .then(({ ok, data }) => { + if (!ok) { + status.style.color = 'var(--error)'; + status.textContent = data.error || 'Invalid pattern.'; + bulkRenameMatches = []; + return; + } bulkRenameMatches = data.matches || []; renderBulkRenamePreview(); })