From ac84efa16999ff416c19eacd165d6384cf5c42f1 Mon Sep 17 00:00:00 2001 From: zzz1999 Date: Sat, 23 May 2026 16:49:50 +0800 Subject: [PATCH] Enhance batch group rename preview --- plugins.json | 6 +- plugins/batch_group_rename/about.md | 226 ++- .../batch_group_rename/batch_group_rename.js | 1404 +++++++++++++++-- 3 files changed, 1471 insertions(+), 165 deletions(-) diff --git a/plugins.json b/plugins.json index 532d23e9..0b76e7ef 100644 --- a/plugins.json +++ b/plugins.json @@ -1469,16 +1469,16 @@ "title": "Batch Group Rename", "author": "zzz1999", "icon": "drive_file_rename_outline", - "version": "1.0.0", + "version": "1.3.0", "variant": "both", - "description": "Batch rename all child groups under a selected group with hierarchical numbering (e.g. arm1, arm2, arm2_1, arm2_1_1).", + "description": "Batch rename child groups with selectable previews, animation name syncing, presets, templates, filters, and conflict handling.", "tags": ["Utility"], "min_version": "4.8.0", "creation_date": "2026-03-20", "has_changelog": false, "repository": "https://github.com/zzz1999/batch_group_rename", "bug_tracker": "https://github.com/zzz1999/batch_group_rename/issues" - }, + }, "menu_icon_exporter": { "title": "Menu Icon Exporter", "author": "NET", diff --git a/plugins/batch_group_rename/about.md b/plugins/batch_group_rename/about.md index 86ed12d9..4739a6ff 100644 --- a/plugins/batch_group_rename/about.md +++ b/plugins/batch_group_rename/about.md @@ -1,52 +1,174 @@ -Batch rename all child groups under a selected group using a clear, hierarchical numbering system. - -## How to Use - -1. Select a group in the **Outliner** -2. Go to **Tools > Batch Rename Child Groups**, or right-click the group and select **Batch Rename Child Groups** -3. Configure the options in the dialog, then click **Confirm** - -## Naming Convention - -The plugin names child groups based on their depth relative to the selected root group: - -| Depth | Naming Pattern | Example | -|---|---|---| -| 1st level | `base` + number | `arm1`, `arm2`, `arm3` | -| 2nd level | parent name + `_` + number | `arm2_1`, `arm2_2` | -| 3rd level | parent name + `_` + number | `arm2_2_1`, `arm2_2_2` | -| ... | continues recursively | ... | - -## Options - -- **Base Name** — The prefix used for naming. Defaults to the selected group's current name. -- **Rename Scope** — Choose between renaming all descendant groups recursively, or only the direct children of the selected group. -- **Also Rename Root Group** — When enabled, the selected root group itself will also be renamed to the base name you entered. - -## Example - -Given this group hierarchy: - -``` -body -├── group_a -│ ├── some_group -│ └── another_group -├── group_b -└── group_c - └── inner -``` - -After running the plugin on `body` with base name `body`: - -``` -body -├── body1 -│ ├── body1_1 -│ └── body1_2 -├── body2 -└── body3 - └── body3_1 -``` - -All rename operations are registered as a single undo step, so you can revert with **Ctrl+Z**. \ No newline at end of file +Batch rename child groups under a selected group with an Aseprite-style naming template. + +Instead of only applying one fixed numbering pattern, this plugin lets you build names from tokens such as `{base}`, `{old}`, `{parent}`, `{index}`, and `{tree}`. This keeps the original quick hierarchy renaming workflow while allowing much more custom naming rules. + +## How to Use + +1. Select a group in the **Outliner**. +2. Go to **Tools > Batch Rename Child Groups**, or right-click the group and select **Batch Rename Child Groups**. +3. Pick a naming preset, or choose **Custom Template**. +4. Adjust targeting, numbering, text processing, animation syncing, and safety options as needed. +5. Review the live preview in the same dialog. +6. Uncheck any preview rows that should keep their current group name, then apply the rename. + +## Template Naming + +The default template is: + +```text +{base}{tree} +``` + +With base name `arm`, this keeps the original behavior: + +```text +arm1 +arm2 +arm2_1 +arm2_2 +arm2_2_1 +``` + +This approach is inspired by Aseprite's filename-format style, where the final name is composed from a format string and tokens. For group names, the useful tokens are about hierarchy, old names, parent names, and sibling indexes. + +## Tokens + +| Token | Meaning | Example | +|---|---|---| +| `{base}` | The Base Name field | `arm` | +| `{old}` | The group's current name before renaming | `left_upper` | +| `{parent}` | The parent group's new name when available | `arm2` | +| `{parent_old}` | The parent group's original name | `group_b` | +| `{index}` | Sibling index using Index Start and Index Padding | `1`, `01`, `002` | +| `{raw_index}` | Sibling index without padding | `1` | +| `{zero_index}` | Zero-based sibling index using padding | `0`, `01` | +| `{tree}` | Full hierarchical index path | `2_1`, `02_01` | +| `{depth}` | Depth below the selected root group | `1`, `2`, `3` | +| `{path}` | Original path below the selected root group | `group_b/inner` | +| `{count}` | Number of sibling groups at the same level | `3` | +| `{root}` | The selected root group's original name | `body` | + +Unknown tokens are left unchanged, so a typo is easy to spot in the preview. + +## Useful Templates + +| Goal | Template | Result Example | +|---|---|---| +| Original hierarchy numbering | `{base}{tree}` | `body1`, `body2_1` | +| Parent-based hierarchy naming | `{parent}_{index}` | `body_1`, `body_1_1` | +| Preserve old names with a prefix | `{base}_{old}` | `armor_left_arm` | +| Add depth to every group | `{base}_d{depth}_{index}` | `body_d2_1` | +| Use original path as a name source | `{base}_{path}` | `body_group_b/inner` | +| Simple copy suffix | `{old}_copy` | `left_arm_copy` | + +## Dialog Layout + +The rename dialog is organized into several sections: + +- **Naming rule** - Choose a preset or custom token template. +- **Targeting** - Decide which groups inside the selected root should be renamed. +- **Numbering** - Control index order, starting number, padding, and tree separators. +- **Text processing** - Apply find/replace and final case conversion. +- **Safety** - Handle duplicates, empty results, unchanged names, and root-group renaming. +- **Animation references** - Choose whether matching animation animator names should be updated when a group is renamed. +- **Live preview** - Shows the current rule summary, affected count, skipped count, conflicts, and exact generated names in a selectable tree. + +Every control includes a short description in the dialog, so you can see what each option changes without opening this page. + +## Presets + +| Preset | Template Used | Best For | +|---|---|---| +| Hierarchy Numbering | `{base}{tree}` | Original numbered hierarchy names | +| Parent Name + Index | `{parent}_{index}` | Names that follow the generated parent name | +| Base Name + Old Name | `{base}_{old}` | Keeping recognizable old names with a shared prefix | +| Old Name + Base Name | `{old}_{base}` | Adding a shared suffix/category | +| Find/Replace Existing Names | `{old}` | Cleaning up current names without rebuilding them | +| Custom Template | user-defined | Advanced token-based naming | + +## Options + +- **Base Name** - Main text used by `{base}`. Defaults to the selected group's current name. +- **Name Template** - A token-based pattern used to generate every renamed child group. +- **Rename Scope** - Rename all descendant groups recursively, or only direct children. +- **Target Groups** - Rename all scoped groups, only groups matching a filter, leaf groups only, or parent groups only. +- **Target Filter** - Matches each group's original name and path. It can be plain text or a regular expression. +- **Max Depth** - Limits how deep below the selected root the rename can apply. `0` means no limit. +- **Index Order** - Assign indexes in Outliner order, reverse order, name A-Z, or name Z-A. This does not reorder the model. +- **Index Start** - First number used for sibling indexes. Use `0` for zero-based numbering. +- **Index Padding** - Adds leading zeroes to `{index}`, `{zero_index}`, and `{tree}`. For example, padding `2` creates `01`, `02`, `01_01`. +- **Tree Separator** - Separator used inside `{tree}`. The default is `_`. +- **Name Transform** - Optionally convert the generated result to lowercase, uppercase, `snake_case`, or `kebab-case`. +- **Find Text / Replace With** - Optional replacement applied after the template is rendered. +- **Find Text Is RegExp** - Treat Find Text as a JavaScript regular expression. Capture groups can be used in Replace With. +- **Case Sensitive Find** - Controls whether find/replace respects letter case. +- **Duplicate Name Handling** - Warn before applying, auto-append a numeric suffix, or skip duplicates. +- **Duplicate Suffix Separator** - Separator used when auto-appending duplicate suffixes, for example `name_2`. +- **Empty Result Handling** - Keep the old name, use Base + Index, or skip the group if a rule produces an empty name. +- **Skip Unchanged Names** - Avoid touching groups whose generated name is identical to the current name. +- **Also Rename Root Group** - Renames the selected root group to the Base Name after the same transform/replacement rules. +- **Also Update Animation Names** - Updates animation animators that refer to renamed groups by UUID or by the old group name. Keep it enabled when animations should continue following the renamed groups; turn it off for model-only cleanup. +- **Require Extra Apply Confirmation** - Shows a final confirmation dialog even when the live preview has no warnings. Duplicate conflicts always request confirmation. + +## Live Preview + +The preview area updates whenever you change a field. It shows: + +- How many groups will be renamed. +- How many groups will be skipped. +- Whether duplicate sibling names would be created. +- The active preset, template, scope, and target mode. +- A tree-style list of the selected group's descendants. +- Each row shows whether that group will be renamed, skipped, or left unchanged. +- Renamed rows show the exact `current_name -> generated_name` result before you apply. +- Every renameable row has a checkbox. Uncheck a row to keep that group unchanged while still allowing other checked groups to rename. +- If a parent group is unchecked, child previews recalculate with the parent's actual kept name, so `{parent}` results stay accurate. +- Large hierarchies are shortened at first and include a **Show more groups** button in the preview. + +If the rule creates duplicate sibling names, the preview marks the conflict. You can then switch **Duplicate Name Handling** to auto-append suffixes or skip duplicates before applying. + +## Animation Syncing + +When **Also Update Animation Names** is enabled, the plugin checks project animations during the same undo step. It updates matching animator names when an animator is tied to the renamed group UUID, the group object, or the old group name. + +This is useful when exports or animation tools still read the animator's stored name. The model group rename and animation-name update are committed together, so **Ctrl+Z** reverts both at once. + +## Example + +Given this group hierarchy: + +```text +body +|-- group_a +| |-- some_group +| `-- another_group +|-- group_b +`-- group_c + `-- inner +``` + +Using base name `body` and template `{base}{tree}`: + +```text +body +|-- body1 +| |-- body1_1 +| `-- body1_2 +|-- body2 +`-- body3 + `-- body3_1 +``` + +Using base name `part`, template `{parent}_{index}`, Index Padding `2`, and recursive scope: + +```text +body +|-- body_01 +| |-- body_01_01 +| `-- body_01_02 +|-- body_02 +`-- body_03 + `-- body_03_01 +``` + +All rename operations are registered as a single undo step, so you can revert the whole batch rename with **Ctrl+Z**. diff --git a/plugins/batch_group_rename/batch_group_rename.js b/plugins/batch_group_rename/batch_group_rename.js index d1fe86b8..37119a59 100644 --- a/plugins/batch_group_rename/batch_group_rename.js +++ b/plugins/batch_group_rename/batch_group_rename.js @@ -1,123 +1,1307 @@ -(function () { - let action; - - const PLUGIN_ID = 'batch_group_rename'; - - function collectDescendantGroups(group) { - const result = []; - (group.children || []).forEach(child => { - if (child instanceof Group) { - result.push(child); - result.push(...collectDescendantGroups(child)); - } - }); - return result; - } - - function renameChildGroups(group, prefix, isRoot, recursive) { - const childGroups = (group.children || []).filter(child => child instanceof Group); - if (childGroups.length === 0) return; - - childGroups.forEach((child, index) => { - const num = index + 1; - const newName = isRoot - ? prefix + num - : prefix + '_' + num; - - child.name = newName; - - if (recursive) { - renameChildGroups(child, newName, false, true); - } - }); - } - - function openRenameDialog(targetGroup) { - const baseName = targetGroup.name; - const directChildren = (targetGroup.children || []).filter(c => c instanceof Group); - const allDescendants = collectDescendantGroups(targetGroup); - - if (allDescendants.length === 0) { - Blockbench.showQuickMessage('No child groups found under "' + baseName + '"', 2000); +(function () { + let action; + + const PLUGIN_ID = 'batch_group_rename'; + const DEFAULT_TEMPLATE = '{base}{tree}'; + const DEFAULT_SEPARATOR = '_'; + const TREE_PREVIEW_LIMIT = 120; + const SKIP_REASON_UNCHECKED = 'unchecked in preview'; + const GROUP_PREVIEW_KEYS = new WeakMap(); + let nextGroupPreviewKey = 1; + const RENAME_PRESETS = { + hierarchy: { + label: 'Hierarchy Numbering', + template: '{base}{tree}', + }, + parent_index: { + label: 'Parent Name + Index', + template: '{parent}_{index}', + }, + old_prefix: { + label: 'Base Name + Old Name', + template: '{base}_{old}', + }, + old_suffix: { + label: 'Old Name + Base Name', + template: '{old}_{base}', + }, + find_replace: { + label: 'Find/Replace Existing Names', + template: '{old}', + }, + custom: { + label: 'Custom Template', + template: DEFAULT_TEMPLATE, + }, + }; + + function getChildGroups(group) { + return (group.children || []).filter(child => child instanceof Group); + } + + function getGroupPreviewKey(group) { + if (!group) return ''; + if (group.uuid) return group.uuid; + if (!GROUP_PREVIEW_KEYS.has(group)) { + GROUP_PREVIEW_KEYS.set(group, 'group_' + nextGroupPreviewKey++); + } + return GROUP_PREVIEW_KEYS.get(group); + } + + function isGroupExcluded(group, options) { + return !!( + group + && options + && options.excludedGroupKeys + && options.excludedGroupKeys.has + && options.excludedGroupKeys.has(getGroupPreviewKey(group)) + ); + } + + function collectDescendantGroups(group) { + const result = []; + getChildGroups(group).forEach(child => { + result.push(child); + result.push(...collectDescendantGroups(child)); + }); + return result; + } + + function sortGroups(groups, sortMode) { + const sorted = groups.slice(); + + switch (sortMode) { + case 'reverse': + return sorted.reverse(); + case 'name_asc': + return sorted.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + case 'name_desc': + return sorted.sort((a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'base' })); + default: + return sorted; + } + } + + function targetMatches(child, namePath, depth, isLeaf, options) { + if (options.maxDepth > 0 && depth > options.maxDepth) return false; + + switch (options.targetMode) { + case 'name_filter': { + const path = namePath.join('/'); + if (!options.targetPattern) return true; + options.targetPattern.lastIndex = 0; + if (options.targetPattern.test(child.name)) return true; + options.targetPattern.lastIndex = 0; + return options.targetPattern.test(path); + } + case 'leaf': + return isLeaf; + case 'branch': + return !isLeaf; + default: + return true; + } + } + + function collectRenameItems(group, options, indexPath, namePath, depth, result) { + const childGroups = sortGroups(getChildGroups(group), options.sortMode); + + childGroups.forEach((child, offset) => { + const index = options.startIndex + offset; + const childIndexPath = indexPath.concat(index); + const childNamePath = namePath.concat(child.name); + const isLeaf = getChildGroups(child).length === 0; + + if (targetMatches(child, childNamePath, depth, isLeaf, options)) { + result.push({ + group: child, + parentGroup: group, + oldName: child.name, + parentOldName: group.name, + index, + zeroIndex: offset, + siblingCount: childGroups.length, + indexPath: childIndexPath, + namePath: childNamePath, + depth, + isLeaf, + }); + } + + if (options.recursive) { + collectRenameItems(child, options, childIndexPath, childNamePath, depth + 1, result); + } + }); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function escapeHTML(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function clampInteger(value, fallback, min, max) { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, parsed)); + } + + function formatNumber(value, padding) { + return String(value).padStart(padding, '0'); + } + + function toSeparatedCase(value, separator) { + let result = String(value) + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[^\p{L}\p{N}]+/gu, separator) + .replace(new RegExp(escapeRegExp(separator) + '+', 'g'), separator) + .replace(new RegExp('^' + escapeRegExp(separator) + '|' + escapeRegExp(separator) + '$', 'g'), ''); + + return result.toLowerCase(); + } + + function renderTemplate(template, tokens) { + return template.replace(/\{([a-z0-9_]+)\}/gi, (match, token) => { + const key = token.toLowerCase(); + return Object.prototype.hasOwnProperty.call(tokens, key) ? tokens[key] : match; + }); + } + + function getPresetTemplate(formData) { + const presetId = formData.naming_preset || 'hierarchy'; + if (presetId === 'custom') { + return String(formData.name_template || '').trim() || DEFAULT_TEMPLATE; + } + return (RENAME_PRESETS[presetId] || RENAME_PRESETS.hierarchy).template; + } + + function normalizeOptions(formData, fallbackBaseName) { + const findText = String(formData.find_text || ''); + const useRegex = !!formData.use_regex; + const targetText = String(formData.target_filter || ''); + const targetUsesRegex = !!formData.target_regex; + let findPattern = null; + let targetPattern = null; + + if (findText) { + try { + findPattern = new RegExp(useRegex ? findText : escapeRegExp(findText), formData.case_sensitive ? 'g' : 'gi'); + } catch (error) { + return { + error: 'Invalid regular expression: ' + error.message, + }; + } + } + + if (targetText) { + try { + targetPattern = new RegExp(targetUsesRegex ? targetText : escapeRegExp(targetText), formData.filter_case_sensitive ? 'g' : 'gi'); + } catch (error) { + return { + error: 'Invalid target filter regular expression: ' + error.message, + }; + } + } + + return { + baseName: String(formData.base_name || '').trim() || fallbackBaseName, + template: getPresetTemplate(formData), + namingPreset: formData.naming_preset || 'hierarchy', + recursive: formData.depth_mode !== 'direct', + renameRoot: !!formData.rename_root, + updateAnimations: formData.update_animations !== false, + confirmBeforeApply: !!formData.confirm_before_apply, + startIndex: clampInteger(formData.start_index, 1, 0, 999999), + padding: clampInteger(formData.index_padding, 0, 0, 8), + treeSeparator: formData.tree_separator === undefined || formData.tree_separator === null + ? DEFAULT_SEPARATOR + : String(formData.tree_separator), + caseTransform: formData.case_transform || 'none', + targetMode: formData.target_mode || 'all', + targetPattern, + sortMode: formData.sort_mode || 'outliner', + maxDepth: clampInteger(formData.max_depth, 0, 0, 99), + duplicateStrategy: formData.duplicate_strategy || 'warn', + duplicateSeparator: formData.duplicate_separator === undefined || formData.duplicate_separator === null + ? '_' + : String(formData.duplicate_separator), + emptyNameStrategy: formData.empty_name_strategy || 'old', + skipUnchanged: formData.skip_unchanged !== false, + findPattern, + useRegex, + replaceText: String(formData.replace_text || ''), + }; + } + + function applyPostProcessing(name, options) { + let result = String(name); + + if (options.findPattern) { + result = options.useRegex + ? result.replace(options.findPattern, options.replaceText) + : result.replace(options.findPattern, () => options.replaceText); + } + + switch (options.caseTransform) { + case 'lower': + return result.toLowerCase(); + case 'upper': + return result.toUpperCase(); + case 'snake': + return toSeparatedCase(result, '_'); + case 'kebab': + return toSeparatedCase(result, '-'); + default: + return result; + } + } + + function buildRenamePlan(targetGroup, options) { + const items = []; + const changes = []; + const newNames = new Map(); + const rootExcluded = isGroupExcluded(targetGroup, options); + const generatedRootName = options.renameRoot + ? applyPostProcessing(options.baseName, options) + : targetGroup.name; + const rootName = options.renameRoot && !rootExcluded + ? generatedRootName + : targetGroup.name; + + collectRenameItems(targetGroup, options, [], [], 1, items); + + items.forEach(item => { + const parentName = item.parentGroup === targetGroup + ? rootName + : newNames.get(item.parentGroup) || item.parentGroup.name; + const tree = item.indexPath + .map(index => formatNumber(index, options.padding)) + .join(options.treeSeparator); + const index = formatNumber(item.index, options.padding); + const zeroIndex = formatNumber(item.zeroIndex, options.padding); + + const tokens = { + base: options.baseName, + root: targetGroup.name, + old: item.oldName, + parent: parentName, + parent_old: item.parentOldName, + index, + raw_index: String(item.index), + zero_index: zeroIndex, + tree, + depth: String(item.depth), + path: item.namePath.join('/'), + count: String(item.siblingCount), + }; + let newName = applyPostProcessing(renderTemplate(options.template, tokens), options); + let skipReason = ''; + + if (!newName.trim()) { + if (options.emptyNameStrategy === 'skip') { + skipReason = 'empty result'; + } else if (options.emptyNameStrategy === 'generated') { + newName = applyPostProcessing(options.baseName + (tree || index), options); + } else { + newName = item.oldName; + } + } + + newNames.set(item.group, isGroupExcluded(item.group, options) ? item.oldName : newName); + changes.push({ + group: item.group, + parentGroup: item.parentGroup, + oldName: item.oldName, + newName, + skipReason, + path: item.namePath.join('/'), + depth: item.depth, + isLeaf: item.isLeaf, + }); + }); + + return { + rootChange: options.renameRoot + ? { + group: targetGroup, + oldName: targetGroup.name, + newName: generatedRootName, + path: '(selected root)', + depth: 0, + } + : null, + changes, + }; + } + + function getUniqueName(name, usedNames, separator) { + let counter = 2; + let candidate = name + separator + counter; + + while (usedNames.has(candidate)) { + counter++; + candidate = name + separator + counter; + } + + return candidate; + } + + function finalizeRenamePlan(targetGroup, rawPlan, options) { + const skippedChanges = []; + const conflictNames = []; + const changeGroups = new Set(rawPlan.changes + .filter(change => !isGroupExcluded(change.group, options)) + .filter(change => !change.skipReason) + .filter(change => !(options.skipUnchanged && change.oldName === change.newName)) + .map(change => change.group)); + const changesByParent = new Map(); + let rootChange = rawPlan.rootChange; + + if (rootChange && isGroupExcluded(rootChange.group, options)) { + skippedChanges.push(Object.assign({}, rootChange, { skipReason: SKIP_REASON_UNCHECKED })); + rootChange = null; + } else if (rootChange && options.skipUnchanged && rootChange.oldName === rootChange.newName) { + skippedChanges.push({ + group: targetGroup, + oldName: rootChange.oldName, + newName: rootChange.newName, + skipReason: 'unchanged', + }); + rootChange = null; + } + + rawPlan.changes.forEach(change => { + if (!changesByParent.has(change.parentGroup)) { + changesByParent.set(change.parentGroup, []); + } + changesByParent.get(change.parentGroup).push(change); + }); + + const finalChanges = []; + changesByParent.forEach((changes, parentGroup) => { + const usedNames = new Set(); + + getChildGroups(parentGroup).forEach(child => { + if (!changeGroups.has(child)) usedNames.add(child.name); + }); + + changes.forEach(change => { + if (isGroupExcluded(change.group, options)) { + skippedChanges.push(Object.assign({}, change, { skipReason: SKIP_REASON_UNCHECKED })); + usedNames.add(change.oldName); + return; + } + if (change.skipReason) { + skippedChanges.push(change); + usedNames.add(change.oldName); + return; + } + if (options.skipUnchanged && change.oldName === change.newName) { + skippedChanges.push(Object.assign({}, change, { skipReason: 'unchanged' })); + usedNames.add(change.oldName); + return; + } + + if (usedNames.has(change.newName)) { + if (options.duplicateStrategy === 'append') { + change.newName = getUniqueName(change.newName, usedNames, options.duplicateSeparator); + } else if (options.duplicateStrategy === 'skip') { + skippedChanges.push(Object.assign({}, change, { skipReason: 'duplicate name' })); + usedNames.add(change.oldName); + return; + } else if (!conflictNames.includes(change.newName)) { + conflictNames.push(change.newName); + } + } + + usedNames.add(change.newName); + finalChanges.push(change); + }); + }); + + return { + rootChange, + changes: finalChanges, + skippedChanges, + conflictNames, + total: finalChanges.length + (rootChange ? 1 : 0), + }; + } + + function getPlanRows(plan) { + const previewChanges = []; + if (plan.rootChange) previewChanges.push(plan.rootChange); + previewChanges.push(...plan.changes); + return previewChanges; + } + + function getTreePreviewHTML(targetGroup, plan, previewState) { + const changeMap = new Map(); + const skippedMap = new Map(); + const expanded = !!(previewState && previewState.expanded); + const limit = expanded ? Infinity : TREE_PREVIEW_LIMIT; + let rendered = 0; + let hidden = 0; + let html = '
'; + + if (plan.rootChange) changeMap.set(plan.rootChange.group, plan.rootChange); + plan.changes.forEach(change => changeMap.set(change.group, change)); + plan.skippedChanges.forEach(change => { + if (change.group) skippedMap.set(change.group, change); + }); + + function renderRow(group, depth, rootContext) { + if (rendered >= limit) { + hidden++; + return; + } + + rendered++; + + const change = changeMap.get(group); + const skipped = skippedMap.get(group); + const groupKey = getGroupPreviewKey(group); + const excluded = previewState + && previewState.excludedGroupKeys + && previewState.excludedGroupKeys.has(groupKey); + const actionable = !!change || (skipped && skipped.skipReason === SKIP_REASON_UNCHECKED); + const indent = Math.min(depth, 10) * 16; + let className = 'bgr_tree_row'; + let statusHTML = ''; + let nameHTML = escapeHTML(group.name); + const checkboxHTML = ''; + + if (change) { + className += ' bgr_tree_changed'; + nameHTML = '' + escapeHTML(change.oldName) + '' + + '->' + + '' + escapeHTML(change.newName) + ''; + statusHTML = 'will rename'; + } else if (skipped) { + className += ' bgr_tree_skipped'; + if (skipped.oldName !== skipped.newName) { + nameHTML = '' + escapeHTML(skipped.oldName) + '' + + '->' + + '' + escapeHTML(skipped.newName) + ''; + } + statusHTML = 'skipped: ' + escapeHTML(skipped.skipReason) + ''; + } else { + className += ' bgr_tree_unchanged'; + statusHTML = '' + (rootContext ? 'selected root' : 'not changed') + ''; + } + + html += '
' + + '' + checkboxHTML + '' + + '' + (depth > 0 ? '|--' : '') + '' + + '' + nameHTML + '' + + statusHTML + + '
'; + } + + function walk(group, depth) { + renderRow(group, depth, group === targetGroup); + getChildGroups(group).forEach(child => walk(child, depth + 1)); + } + + walk(targetGroup, 0); + html += '
'; + + if (hidden > 0) { + html += '
'; + } else if (expanded && rendered > TREE_PREVIEW_LIMIT) { + html += '
'; + } + + return html; + } + + function getPreviewMessage(plan) { + const previewChanges = getPlanRows(plan); + + const lines = previewChanges.slice(0, 10).map(change => change.oldName + ' -> ' + change.newName); + const hidden = previewChanges.length - lines.length; + let message = '

Review the first rename results before applying:

' + + '
'
+			+ escapeHTML(lines.join('\n'));
+
+		if (hidden > 0) {
+			message += escapeHTML('\n... and ' + hidden + ' more');
+		}
+
+		message += '
'; + + if (plan.conflictNames.length) { + message += '

Warning: This rule creates duplicate names for sibling groups: ' + + escapeHTML(plan.conflictNames.slice(0, 5).join(', ')) + + (plan.conflictNames.length > 5 ? ', ...' : '') + + '

'; + } + + if (plan.skippedChanges.length) { + message += '

Skipped ' + plan.skippedChanges.length + ' group(s).

'; + } + + return message; + } + + function addUniqueAnimation(animations, animation) { + if (animation && !animations.includes(animation)) { + animations.push(animation); + } + } + + function collectProjectAnimations() { + const animations = []; + const addFromList = list => { + if (Array.isArray(list)) { + list.forEach(animation => addUniqueAnimation(animations, animation)); + } + }; + + if (typeof Animation !== 'undefined') addFromList(Animation.all); + if (typeof Animator !== 'undefined') addFromList(Animator.animations); + if (typeof AnimationItem !== 'undefined') addFromList(AnimationItem.all); + + return animations; + } + + function getAnimationRenameRows(plan) { + return getPlanRows(plan).filter(change => change.group && change.oldName !== change.newName); + } + + function updateAnimationGroupMaps(animation, renameRows) { + if (!animation.groups || typeof animation.groups !== 'object') return 0; + + let updated = 0; + renameRows.forEach(change => { + if (!Object.prototype.hasOwnProperty.call(animation.groups, change.oldName)) return; + + if (!Object.prototype.hasOwnProperty.call(animation.groups, change.newName)) { + animation.groups[change.newName] = animation.groups[change.oldName]; + delete animation.groups[change.oldName]; + updated++; + } + }); + return updated; + } + + function updateAnimationReferences(plan) { + const renameRows = getAnimationRenameRows(plan); + if (!renameRows.length) return 0; + + const byUuid = new Map(); + const byOldName = new Map(); + const byGroup = new Map(); + let updated = 0; + + renameRows.forEach(change => { + byGroup.set(change.group, change); + byOldName.set(change.oldName, change); + if (change.group.uuid) { + byUuid.set(change.group.uuid, change); + } + }); + + collectProjectAnimations().forEach(animation => { + const animators = animation && animation.animators; + if (!animators || typeof animators !== 'object') return; + + let animationTouched = false; + Object.keys(animators).forEach(key => { + const animator = animators[key]; + let change = null; + let matchedByGroupIdentity = false; + let keyMatchedUuid = false; + + if (byUuid.has(key)) { + change = byUuid.get(key); + matchedByGroupIdentity = true; + keyMatchedUuid = true; + } else if (animator && animator.uuid && byUuid.has(animator.uuid)) { + change = byUuid.get(animator.uuid); + matchedByGroupIdentity = true; + } else if (animator && animator.element && byGroup.has(animator.element)) { + change = byGroup.get(animator.element); + matchedByGroupIdentity = true; + } else if (animator && animator.name && byOldName.has(animator.name)) { + change = byOldName.get(animator.name); + } else if (byOldName.has(key)) { + change = byOldName.get(key); + } + + if (!change) return; + + let touched = false; + if (animator && typeof animator.name === 'string' && (animator.name === change.oldName || matchedByGroupIdentity)) { + if (animator.name !== change.newName) { + animator.name = change.newName; + touched = true; + } + } + + if (!keyMatchedUuid && key === change.oldName && key !== change.newName) { + if (!Object.prototype.hasOwnProperty.call(animators, change.newName)) { + animators[change.newName] = animator; + delete animators[key]; + touched = true; + } else if (animators[change.newName] === animator) { + delete animators[key]; + touched = true; + } + } + + if (touched) { + animationTouched = true; + updated++; + } + }); + + const groupMapUpdates = updateAnimationGroupMaps(animation, renameRows); + if (groupMapUpdates) { + animationTouched = true; + updated += groupMapUpdates; + } + + if (animationTouched && Object.prototype.hasOwnProperty.call(animation, 'saved')) { + animation.saved = false; + } + }); + + return updated; + } + + function applyRenamePlan(targetGroup, plan, options) { + const animations = options.updateAnimations ? collectProjectAnimations() : []; + const undoData = { elements: [], outliner: true }; + if (animations.length) { + undoData.animations = animations; + } + + Undo.initEdit(undoData); + const animationUpdates = options.updateAnimations ? updateAnimationReferences(plan) : 0; + + if (plan.rootChange) { + targetGroup.name = plan.rootChange.newName; + } + + plan.changes.forEach(change => { + change.group.name = change.newName; + }); + + Undo.finishEdit('Batch rename groups'); + Canvas.updateAll(); + Blockbench.showQuickMessage( + 'Renamed ' + plan.total + ' group(s)' + (animationUpdates ? ', updated ' + animationUpdates + ' animation reference(s)' : ''), + 2000 + ); + } + + function previewOrApply(targetGroup, plan, options, dialog, forcePreview) { + if (plan.total === 0) { + Blockbench.showQuickMessage('No matching groups to rename', 2000); + return false; + } + + if (!forcePreview && plan.conflictNames.length === 0) { + applyRenamePlan(targetGroup, plan, options); + dialog.hide(); + return true; + } + + Blockbench.showMessageBox({ + title: plan.conflictNames.length ? 'Batch Rename Warning' : 'Batch Rename Preview', + icon: plan.conflictNames.length ? 'warning' : 'drive_file_rename_outline', + message: getPreviewMessage(plan), + buttons: ['Apply', 'Cancel'], + confirm: 0, + cancel: 1, + }, buttonIndex => { + if (buttonIndex === 0) { + applyRenamePlan(targetGroup, plan, options); + dialog.hide(); + } + }); + return false; + } + + function createPlanResult(targetGroup, formData, baseName, previewState) { + const options = normalizeOptions(formData, baseName); + if (options.error) return { error: options.error }; + options.excludedGroupKeys = previewState && previewState.excludedGroupKeys + ? previewState.excludedGroupKeys + : new Set(); + + return { + options, + plan: finalizeRenamePlan(targetGroup, buildRenamePlan(targetGroup, options), options), + }; + } + + function getPreviewHTML(targetGroup, result, previewState) { + if (result.error) { + return '
' + escapeHTML(result.error) + '
'; + } + + const plan = result.plan; + const options = result.options; + const rows = getPlanRows(plan); + const visibleRows = rows.length + plan.skippedChanges.length; + const presetLabel = (RENAME_PRESETS[options.namingPreset] || RENAME_PRESETS.hierarchy).label; + const scopeLabel = options.recursive ? 'Recursive descendants' : 'Direct children only'; + const targetLabel = { + all: 'All matching groups', + name_filter: 'Filtered by name/path', + leaf: 'Leaf groups only', + branch: 'Parent groups only', + }[options.targetMode] || 'All matching groups'; + let html = '
' + + 'Will rename: ' + plan.total + '' + + 'Skipped: ' + plan.skippedChanges.length + '' + + 'Conflicts: ' + plan.conflictNames.length + '' + + 'Animations: ' + (options.updateAnimations ? 'sync names' : 'model only') + '' + + '
'; + html += '
' + + 'Preset: ' + escapeHTML(presetLabel) + '' + + 'Template: ' + escapeHTML(options.template) + '' + + 'Scope: ' + escapeHTML(scopeLabel) + '' + + 'Target: ' + escapeHTML(targetLabel) + '' + + '
'; + + if (plan.conflictNames.length) { + html += '
Duplicate sibling names: ' + + escapeHTML(plan.conflictNames.slice(0, 6).join(', ')) + + (plan.conflictNames.length > 6 ? ', ...' : '') + + '
'; + } + + if (visibleRows === 0) { + html += '
No groups match the current target settings.
'; + } else { + html += '
Exact group name preview
'; + } + + html += getTreePreviewHTML(targetGroup, plan, previewState); + + if (plan.skippedChanges.length) { + const skipped = plan.skippedChanges.slice(0, 5) + .map(change => change.oldName + ' (' + change.skipReason + ')') + .join(', '); + html += '
Skipped: ' + escapeHTML(skipped) + + (plan.skippedChanges.length > 5 ? ', ...' : '') + + '
'; + } + + return html; + } + + function updateDialogPreview(targetGroup, formData, baseName, previewState) { + if (typeof document === 'undefined') return; + + const previewNode = document.getElementById('batch_group_rename_preview'); + if (!previewNode) return; + + previewNode.innerHTML = getPreviewHTML(targetGroup, createPlanResult(targetGroup, formData, baseName, previewState), previewState); + } + + function refreshPreviewFromDialog(dialog, targetGroup, baseName, previewState) { + if (dialog && dialog.getFormResult) { + updateDialogPreview(targetGroup, dialog.getFormResult(), baseName, previewState); + } + } + + function bindPreviewControls(dialog, targetGroup, baseName, previewState) { + if (typeof document === 'undefined') return; + + const previewNode = document.getElementById('batch_group_rename_preview'); + if (!previewNode) return; + + previewNode.addEventListener('change', event => { + const target = event.target; + const checkbox = target && target.closest ? target.closest('.bgr_tree_toggle') : null; + if (!checkbox) return; + + const groupKey = checkbox.getAttribute('data-group-key'); + if (!groupKey) return; + + if (checkbox.checked) { + previewState.excludedGroupKeys.delete(groupKey); + } else { + previewState.excludedGroupKeys.add(groupKey); + } + refreshPreviewFromDialog(dialog, targetGroup, baseName, previewState); + }); + + previewNode.addEventListener('click', event => { + const target = event.target; + const button = target && target.closest ? target.closest('.bgr_preview_expand') : null; + if (!button) return; + + previewState.expanded = button.getAttribute('data-preview-action') === 'expand'; + refreshPreviewFromDialog(dialog, targetGroup, baseName, previewState); + }); + } + + function openRenameDialog(targetGroup) { + const baseName = targetGroup.name; + const directChildren = getChildGroups(targetGroup); + const allDescendants = collectDescendantGroups(targetGroup); + const previewState = { + excludedGroupKeys: new Set(), + expanded: false, + }; + + if (allDescendants.length === 0) { + Blockbench.showQuickMessage('No child groups found under "' + baseName + '"', 2000); return; } - const dialog = new Dialog({ - id: 'batch_group_rename_dialog', - title: 'Batch Rename Groups', - form: { - info: { - type: 'info', - text: 'Direct children: ' + directChildren.length + ' | All descendants: ' + allDescendants.length - + '\nExample: ' + baseName + '1, ' + baseName + '2 > ' + baseName + '2_1, ' + baseName + '2_2 > ' + baseName + '2_2_1 ...', - }, - base_name: { - label: 'Base Name', - type: 'text', - value: baseName, - }, - depth_mode: { - label: 'Rename Scope', - type: 'select', - value: 'all', - options: { - all: 'All Descendants (Recursive)', - direct: 'Direct Children Only', - }, - }, - rename_root: { - label: 'Also Rename Root Group', - type: 'checkbox', - value: false, - }, - }, - onConfirm(formData) { - const finalBase = formData.base_name.trim() || baseName; - const recursive = formData.depth_mode === 'all'; - - Undo.initEdit({ elements: [], outliner: true }); - - if (formData.rename_root) { - targetGroup.name = finalBase; - } - - renameChildGroups(targetGroup, finalBase, true, recursive); - - const affected = recursive - ? allDescendants.length - : directChildren.length; - - Undo.finishEdit('Batch rename groups'); - Canvas.updateAll(); - Blockbench.showQuickMessage('Renamed ' + affected + ' group(s)', 2000); - dialog.hide(); - }, - }); - dialog.show(); - } + const dialog = new Dialog({ + id: 'batch_group_rename_dialog', + title: 'Batch Rename Groups', + width: 680, + form_first: true, + lines: [` + +
Preview updates as you edit. Use the row checkboxes to keep specific groups unchanged; Custom Template exposes the full token format.
+
+
+ Token reference and workflow notes +
+ {base} Base Name field + {old} original group name + {parent} generated parent name + {index} sibling number + {tree} hierarchy number path + {depth} depth below selected root + {path} original path + {count} sibling count +
+
+ `], + form: { + info: { + type: 'info', + text: 'Direct children: ' + directChildren.length + ' | All descendants: ' + allDescendants.length + + '\nTemplate tokens: {base}, {old}, {parent}, {index}, {tree}, {depth}, {path}' + + '\nExamples: {base}{tree}, {parent}_{index}, {old}_copy', + }, + naming_section: { + type: 'info', + text: 'Naming rule: choose a preset for common workflows, or switch to Custom Template for Aseprite-style token naming.', + }, + naming_preset: { + label: 'Naming Preset', + type: 'select', + value: 'hierarchy', + options: { + hierarchy: RENAME_PRESETS.hierarchy.label, + parent_index: RENAME_PRESETS.parent_index.label, + old_prefix: RENAME_PRESETS.old_prefix.label, + old_suffix: RENAME_PRESETS.old_suffix.label, + find_replace: RENAME_PRESETS.find_replace.label, + custom: RENAME_PRESETS.custom.label, + }, + description: 'Controls the main name-building strategy. Presets fill the template for you; Custom Template lets you type any token pattern.', + }, + base_name: { + label: 'Base Name', + type: 'text', + value: baseName, + description: 'Main reusable text for the rule. It is inserted wherever the template uses {base}.', + }, + name_template: { + label: 'Name Template', + type: 'text', + value: DEFAULT_TEMPLATE, + condition: formData => formData.naming_preset === 'custom', + description: 'A format string assembled from tokens. Example: {base}_{parent}_{index}.', + }, + target_section: { + type: 'info', + text: 'Targeting: narrow down which child groups are renamed before the template is applied.', + }, + depth_mode: { + label: 'Rename Scope', + type: 'select', + value: 'all', + options: { + all: 'All Descendants (Recursive)', + direct: 'Direct Children Only', + }, + description: 'Recursive mode walks the full group tree. Direct mode only renames the selected group\'s immediate child groups.', + }, + target_mode: { + label: 'Target Groups', + type: 'select', + value: 'all', + options: { + all: 'All Groups in Scope', + name_filter: 'Names/Paths Matching Filter', + leaf: 'Leaf Groups Only', + branch: 'Parent Groups Only', + }, + description: 'Selects which groups inside the scope are eligible for renaming.', + }, + target_filter: { + label: 'Target Filter', + type: 'text', + value: '', + condition: formData => formData.target_mode === 'name_filter', + description: 'Matches against each group name and its original path under the selected root.', + }, + target_regex: { + label: 'Target Filter Is RegExp', + type: 'checkbox', + value: false, + condition: formData => formData.target_mode === 'name_filter', + description: 'Use JavaScript regular expression syntax for advanced path/name matching.', + }, + filter_case_sensitive: { + label: 'Case Sensitive Target Filter', + type: 'checkbox', + value: false, + condition: formData => formData.target_mode === 'name_filter', + description: 'When enabled, the target filter treats upper and lower case as different characters.', + }, + max_depth: { + label: 'Max Depth (0 = No Limit)', + type: 'number', + value: 0, + min: 0, + max: 99, + step: 1, + description: 'Limits how deep below the selected root groups can be renamed. 1 means only first-level child groups.', + }, + numbering_section: { + type: 'info', + text: 'Numbering: controls how {index}, {zero_index}, and {tree} are produced.', + }, + sort_mode: { + label: 'Index Order', + type: 'select', + value: 'outliner', + options: { + outliner: 'Outliner Order', + reverse: 'Reverse Outliner Order', + name_asc: 'Name A-Z', + name_desc: 'Name Z-A', + }, + description: 'Defines the order used to assign index numbers. This does not reorder groups in the Outliner.', + }, + start_index: { + label: 'Index Start', + type: 'number', + value: 1, + min: 0, + step: 1, + description: 'First number used by {index} and each level of {tree}. Use 0 for zero-based numbering.', + }, + index_padding: { + label: 'Index Padding', + type: 'number', + value: 0, + min: 0, + max: 8, + step: 1, + description: 'Adds leading zeroes. Padding 2 turns 1 into 01 and tree 1_2 into 01_02.', + }, + tree_separator: { + label: 'Tree Separator', + type: 'text', + value: DEFAULT_SEPARATOR, + description: 'Separator inserted between levels inside {tree}. Default is underscore.', + }, + transform_section: { + type: 'info', + text: 'Text processing: after the template generates a name, optional replace and transform rules are applied.', + }, + case_transform: { + label: 'Name Transform', + type: 'select', + value: 'none', + options: { + none: 'No Change', + lower: 'lowercase', + upper: 'UPPERCASE', + snake: 'snake_case', + kebab: 'kebab-case', + }, + description: 'Final text style conversion applied after template and find/replace.', + }, + find_text: { + label: 'Find Text', + type: 'text', + value: '', + description: 'Optional text to find in the generated result. Leave blank to skip replacement.', + }, + replace_text: { + label: 'Replace With', + type: 'text', + value: '', + description: 'Replacement text. With RegExp enabled, JavaScript replacement groups like $1 can be used.', + }, + use_regex: { + label: 'Find Text Is RegExp', + type: 'checkbox', + value: false, + description: 'Treats Find Text as a regular expression instead of plain text.', + }, + case_sensitive: { + label: 'Case Sensitive Find', + type: 'checkbox', + value: true, + description: 'Controls whether Find Text distinguishes upper and lower case.', + }, + safety_section: { + type: 'info', + text: 'Safety: decide how the plugin handles duplicate names, empty generated names, unchanged names, and root-group renaming.', + }, + duplicate_strategy: { + label: 'Duplicate Name Handling', + type: 'select', + value: 'warn', + options: { + warn: 'Warn Before Applying', + append: 'Auto-Append Number', + skip: 'Skip Duplicates', + }, + description: 'Applies when the result would create duplicate sibling group names.', + }, + duplicate_separator: { + label: 'Duplicate Suffix Separator', + type: 'text', + value: '_', + condition: formData => formData.duplicate_strategy === 'append', + description: 'Used when auto-appending duplicate suffixes, for example name_2 or name-2.', + }, + empty_name_strategy: { + label: 'Empty Result Handling', + type: 'select', + value: 'old', + options: { + old: 'Keep Old Name', + generated: 'Use Base + Index', + skip: 'Skip Group', + }, + description: 'Protects against rules that accidentally produce an empty group name.', + }, + skip_unchanged: { + label: 'Skip Unchanged Names', + type: 'checkbox', + value: true, + description: 'Avoids touching groups whose final name would be identical to the current name.', + }, + rename_root: { + label: 'Also Rename Root Group', + type: 'checkbox', + value: false, + description: 'Renames the selected group itself to Base Name, then uses that generated root name for child {parent} values.', + }, + animation_section: { + type: 'info', + text: 'Animation references: when group names change, matching bone/group animator names can be updated at the same time.', + }, + update_animations: { + label: 'Also Update Animation Names', + type: 'checkbox', + value: true, + description: 'Updates animation animators that refer to renamed groups by UUID or by the old group name. Turn this off if you only want to rename the model hierarchy.', + }, + confirm_before_apply: { + label: 'Require Extra Apply Confirmation', + type: 'checkbox', + value: false, + description: 'Shows a final confirmation dialog even when there are no conflicts. Conflicts always request confirmation.', + }, + }, + onFormChange(formData) { + updateDialogPreview(targetGroup, formData, baseName, previewState); + }, + onConfirm(formData) { + const result = createPlanResult(targetGroup, formData, baseName, previewState); + if (result.error) { + Blockbench.showMessageBox({ + title: 'Invalid Rename Rule', + icon: 'error', + message: result.error, + }); + return false; + } + + return previewOrApply(targetGroup, result.plan, result.options, dialog, result.options.confirmBeforeApply); + }, + }); + dialog.show(); + setTimeout(() => { + bindPreviewControls(dialog, targetGroup, baseName, previewState); + refreshPreviewFromDialog(dialog, targetGroup, baseName, previewState); + }, 0); + } BBPlugin.register(PLUGIN_ID, { - title: 'Batch Group Rename', - author: 'zzz1999', - icon: 'drive_file_rename_outline', - description: 'Batch rename all child groups under a selected group with hierarchical numbering (e.g. arm1, arm2, arm2_1, arm2_1_1).', - version: '1.0.0', - min_version: '4.8.0', - variant: 'both', - tags: ['Utility'], + title: 'Batch Group Rename', + author: 'zzz1999', + icon: 'drive_file_rename_outline', + description: 'Batch rename child groups with selectable previews, animation name syncing, presets, templates, filters, and conflict handling.', + version: '1.3.0', + min_version: '4.8.0', + variant: 'both', + tags: ['Utility'], creation_date: '2026-03-20', has_changelog: false, repository: 'https://github.com/zzz1999/batch_group_rename', bug_tracker: 'https://github.com/zzz1999/batch_group_rename/issues', onload() { - action = new Action('batch_group_rename_action', { - name: 'Batch Rename Child Groups', - icon: 'drive_file_rename_outline', - description: 'Recursively rename all child groups under the selected group', - click() { - const targetGroup = Group.selected[0]; - if (!targetGroup) { + action = new Action('batch_group_rename_action', { + name: 'Batch Rename Child Groups', + icon: 'drive_file_rename_outline', + description: 'Rename child groups under the selected group with a customizable preview', + click() { + const targetGroup = Group.selected[0]; + if (!targetGroup) { Blockbench.showQuickMessage('Please select a group in the Outliner first', 2000); return; } @@ -140,4 +1324,4 @@ if (action) action.delete(); }, }); -})(); \ No newline at end of file +})();