WM: batch rename operator

Matches rename active, supports objects, bones, sequence strips & nodes.

Support chaining actions, these can be extended, initially support:

- set/prefix/suffix
- search replace
- stripping characters
- capitalization.
This commit is contained in:
Campbell Barton 2019-09-01 03:16:58 +10:00
parent 284e4d18be
commit a5b1231de7
3 changed files with 295 additions and 0 deletions

@ -409,6 +409,7 @@ def km_window(params):
items.extend([
("wm.doc_view_manual_ui_context", {"type": 'F1', "value": 'PRESS'}, None),
op_panel("TOPBAR_PT_name", {"type": 'F2', "value": 'PRESS'}, [("keep_open", False)]),
("wm.batch_rename", {"type": 'F2', "value": 'PRESS', "alt": True}, None),
("wm.search_menu", {"type": 'F3', "value": 'PRESS'}, None),
op_menu("TOPBAR_MT_file_context_menu", {"type": 'F4', "value": 'PRESS'}),
])

@ -25,6 +25,7 @@ from bpy.types import (
)
from bpy.props import (
BoolProperty,
CollectionProperty,
EnumProperty,
FloatProperty,
IntProperty,
@ -1174,6 +1175,7 @@ rna_vector_subtype_items = (
('QUATERNION', "Quaternion Rotation", "Quaternion rotation (affects NLA blending)"),
)
class WM_OT_properties_edit(Operator):
bl_idname = "wm.properties_edit"
bl_label = "Edit Property"
@ -1763,6 +1765,294 @@ class WM_OT_toolbar(Operator):
return {'FINISHED'}
class BatchRenameAction(bpy.types.PropertyGroup):
# category: StringProperty()
type: EnumProperty(
name="Operation",
items=(
('SET', "Set Name", "Set a new name or prefix/suffix the existing one"),
('STRIP', "Strip Characters", "Strip leading/trailing text from the name"),
('REPLACE', "Find/Replace", "Replace text in the name"),
('CASE', "Change Case", "Change case of each name"),
),
)
# We could split these into sub-properties, however it's not so important.
# type: 'SET'.
set_name: StringProperty(name="Name")
set_method: EnumProperty(
name="Method",
items=(
('NEW', "New", ""),
('PREFIX', "Prefix", ""),
('SUFFIX', "Suffix", ""),
),
default='SUFFIX',
)
# type: 'STRIP'.
strip_chars: EnumProperty(
name="Strip Characters",
options={'ENUM_FLAG'},
items=(
('SPACE', "Spaces", ""),
('DIGIT', "Digits", ""),
('PUNCT', "Punctuation", ""),
),
)
# type: 'STRIP'.
strip_part: EnumProperty(
name="Strip Part",
options={'ENUM_FLAG'},
items=(
('START', "Start", ""),
('END', "End", ""),
),
)
# type: 'REPLACE'.
replace_src: StringProperty(name="Find")
replace_dst: StringProperty(name="Replace")
replace_match_case: BoolProperty(name="Match Case")
# type: 'CASE'.
case_method: EnumProperty(
name="Case",
items=(
('UPPER', "Upper Case", ""),
('LOWER', "Lower Case", ""),
('TITLE', "Title Caps", ""),
),
)
# Weak, add/remove as properties.
op_add: BoolProperty()
op_remove: BoolProperty()
class WM_OT_batch_rename(Operator):
bl_idname = "wm.batch_rename"
bl_label = "Batch Rename"
bl_options = {'UNDO', 'INTERNAL'}
data_source: EnumProperty(
name="Source",
items=(
('SELECT', "Selected", ""),
('ALL', "All", ""),
),
)
actions: CollectionProperty(type=BatchRenameAction)
@staticmethod
def _data_from_context(context, only_selected):
mode = context.mode
scene = context.scene
space = context.space_data
space_type = None if (space is None) else space.type
data = None
if space_type == 'SEQUENCE_EDITOR':
data = (
# TODO, we don't have access to seqbasep, this won't work when inside metas.
[seq for seq in context.scene.sequence_editor.sequences_all if seq.select]
if only_selected else
context.scene.sequence_editor.sequences_all,
"name",
"Strip(s)",
)
elif space_type == 'NODE_EDITOR':
data = (
context.selected_nodes
if only_selected else
list(space.node_tree.nodes),
"name",
"Node(s)",
)
else:
if mode == 'POSE' or (mode == 'WEIGHT_PAINT' and context.pose_object):
data = (
[pchan.bone for pchan in context.selected_pose_bones]
if only_selected else
[pchan.bone for ob in context.objects_in_mode_unique_data for pbone in ob.pose.bones],
"name",
"Bone(s)",
)
elif mode == 'EDIT_ARMATURE':
data = (
context.selected_editable_bones
if only_selected else
[ebone for ob in context.objects_in_mode_unique_data for ebone in ob.data.edit_bones],
"name",
"Edit Bone(s)",
)
else:
data = (
context.selected_editable_objects
if only_selected else
[ob for ob in bpy.data.objects if ob.library is None],
"name",
"Object(s)",
)
return data
@staticmethod
def _apply_actions(actions, name):
import string
import re
for action in actions:
ty = action.type
if ty == 'SET':
text = action.set_name
method = action.set_method
if method == 'NEW':
name = text
elif method == 'PREFIX':
name = text + name
elif method == 'SUFFIX':
name = name + text
else:
assert(0)
elif ty == 'STRIP':
chars = action.strip_chars
chars_strip = (
"{:s}{:s}{:s}"
).format(
string.punctuation if 'PUNCT' in chars else "",
string.digits if 'DIGIT' in chars else "",
" " if 'SPACE' in chars else "",
)
part = action.strip_part
if 'START' in part:
name = name.lstrip(chars_strip)
if 'END' in part:
name = name.rstrip(chars_strip)
elif ty == 'REPLACE':
if action.replace_match_case:
name = name.replace(
action.replace_src,
action.replace_dst,
)
else:
name = re.sub(
re.escape(action.replace_src),
re.escape(action.replace_dst),
name,
flags=re.IGNORECASE,
)
elif ty == 'CASE':
method = action.case_method
if method == 'UPPER':
name = name.upper()
elif method == 'LOWER':
name = name.lower()
elif method == 'TITLE':
name = name.title()
else:
assert(0)
else:
assert(0)
return name
def draw(self, context):
layout = self.layout
row = layout.row()
row.label(text="Rename {:d} {:s}".format(len(self._data[0]), self._data[2]))
row.prop(self, "data_source", expand=True)
for action in self.actions:
box = layout.box()
row = box.row(align=True)
row.prop(action, "type", text="")
row.prop(action, "op_add", text="", icon='ADD')
row.prop(action, "op_remove", text="", icon='REMOVE')
ty = action.type
if ty == 'SET':
box.prop(action, "set_method")
box.prop(action, "set_name")
elif ty == 'STRIP':
box.row().prop(action, "strip_chars")
box.row().prop(action, "strip_part")
elif ty == 'REPLACE':
box.row().prop(action, "replace_src")
box.row().prop(action, "replace_dst")
box.row().prop(action, "replace_match_case")
elif ty == 'CASE':
box.row().prop(action, "case_method", expand=True)
def check(self, context):
changed = False
for i, action in enumerate(self.actions):
if action.op_add:
action.op_add = False
self.actions.add()
if i + 2 != len(self.actions):
self.actions.move(len(self.actions) - 1, i + 1)
changed = True
break
if action.op_remove:
action.op_remove = False
if len(self.actions) > 1:
self.actions.remove(i)
changed = True
break
if self._data_source_prev != self.data_source:
only_selected = self.data_source == 'SELECT'
self._data = self._data_from_context(context, only_selected)
self._data_source_prev = self.data_source
changed = True
return changed
def execute(self, context):
seq, attr, descr = self._data
actions = self.actions
total_len = 0
change_len = 0
for item in seq:
name_src = getattr(item, attr)
name_dst = self._apply_actions(actions, name_src)
if name_src != name_dst:
setattr(item, attr, name_dst)
change_len += 1
total_len += 1
self.report({'INFO'}, "Renamed {:d} of {:d} {:s}".format(total_len, change_len, descr))
return {'FINISHED'}
def invoke(self, context, event):
only_selected = self.data_source == 'SELECT'
data = self._data_from_context(context, only_selected)
self._data_source_prev = self.data_source
if data is None:
self.report({'ERROR'}, "No usable items in this context")
return {'CANCELLED'}
self._data = data
if not self.actions:
self.actions.add()
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
class WM_MT_splash(Menu):
bl_label = "Splash"
@ -1979,5 +2269,7 @@ classes = (
WM_OT_tool_set_by_id,
WM_OT_tool_set_by_index,
WM_OT_toolbar,
BatchRenameAction,
WM_OT_batch_rename,
WM_MT_splash,
)

@ -505,6 +505,8 @@ class TOPBAR_MT_edit(Menu):
props.name = "TOPBAR_PT_name"
props.keep_open = False
layout.operator("wm.batch_rename")
layout.separator()
# Should move elsewhere (impacts outliner & 3D view).