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 = '
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(options.template) + ''
+ + 'Scope: ' + escapeHTML(scopeLabel) + ''
+ + 'Target: ' + escapeHTML(targetLabel) + ''
+ + '{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
+