diff --git a/README.md b/README.md index 368ad9f..f26a6bd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ # BulkR +Bulk renaming utility written with python and Gtk. -Bulk renaming utility written with python and Gtk. \ No newline at end of file +# Notes + + +
Install Setup
+``` +sudo apt-get install python3.8 python3-setproctitle python3-gi +``` + +# TODO + + +# Images +![1 BulkR clean slate. ](images/pic1.png) +![2 BulkR loaded dir and some actions. ](images/pic2.png) diff --git a/images/pic1.png b/images/pic1.png new file mode 100644 index 0000000..5de477d Binary files /dev/null and b/images/pic1.png differ diff --git a/images/pic2.png b/images/pic2.png new file mode 100644 index 0000000..19c5f84 Binary files /dev/null and b/images/pic2.png differ diff --git a/src/Window.py b/src/Window.py new file mode 100644 index 0000000..c06b4f9 --- /dev/null +++ b/src/Window.py @@ -0,0 +1,29 @@ +#!/usr/bin/python3 + + +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from __builtins__ import Builtins +from controller import Controller + + +class Window(Gtk.Window, Builtins): + """docstring for Main.""" + + def __init__(self, args): + super(Window, self).__init__() + + self.add(Controller(args)) + self.connect("delete-event", Gtk.main_quit) + self.set_default_size(800, 600) + self.set_title(f"{app_name}") + self.set_icon_from_file("/usr/share/bulkr/bulkr.png") + self.set_gravity(5) # 5 = CENTER + self.set_position(3) # 4 = CENTER_ALWAYS + self.show_all() diff --git a/src/__builtins__.py b/src/__builtins__.py new file mode 100644 index 0000000..3ad41bc --- /dev/null +++ b/src/__builtins__.py @@ -0,0 +1,94 @@ +# Python imports +import builtins, os +from os import path + +# Lib imports + +# Application imports + + +class Builtins: + """ Create an pub/sub systems. """ + + def __init__(self): + self.USER_HOME = path.expanduser('~') + self.block_from_update = False + self.block_to_update = False + self.active_path = None + self.from_changes = [] + self.to_changes = [] + + # NOTE: The format used is list of [type, target, (data,)] Where: + # type is useful context for control flow, + # target is the method to call, + # data is the method parameters to give + # Where data may be any kind of data + self._gui_events = [] + self._module_events = [] + + + # Makeshift fake "events" type system FIFO + def _pop_gui_event(self): + if len(self._gui_events) > 0: + return self._gui_events.pop(0) + return None + + def _pop_module_event(self): + if len(self._module_events) > 0: + return self._module_events.pop(0) + return None + + + def set_active_path(self, _file): + if os.path.isdir(_file) : + self.from_changes.clear() + self.active_path = _file + for f in os.listdir(_file): + self.from_changes.append(f) + + self.to_changes = self.from_changes + event_system.push_gui_event(["update-from", None, ()]) + event_system.push_gui_event(["update-to", None, ()]) + + def reset_to_view(self): + self.to_changes = self.from_changes + event_system.push_gui_event(["update-to", None, ()]) + + def reset_from_view(self): + self.set_active_path(self.active_path) + + def push_gui_event(self, event): + if len(event) == 3: + self._gui_events.append(event) + return None + + raise Exception("Invald event format! Please do: [type, target, (data,)]") + + def push_module_event(self, event): + if len(event) == 3: + self._module_events.append(event) + return None + + raise Exception("Invald event format! Please do: [type, target, (data,)]") + + def read_gui_event(self): + return self._gui_events[0] + + def read_module_event(self): + return self._module_events[0] + + def consume_gui_event(self): + return self._pop_gui_event() + + def consume_module_event(self): + return self._pop_module_event() + + + +# NOTE: Just reminding myself we can add to builtins two different ways... +# __builtins__.update({"event_system": Builtins()}) +builtins.app_name = "Bulk-Renamer" +builtins.event_system = Builtins() +builtins.event_sleep_time = 0.1 +builtins.debug = False +builtins.trace_debug = False diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..db3945b --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,21 @@ +#!/usr/bin/python3 + + +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from __builtins__ import Builtins +from Window import Window +from controller import Controller + + +class Main(Window): + """docstring for Main.""" + + def __init__(self, args): + super(Main, self).__init__(args) diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..055337d --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,34 @@ +#!/usr/bin/python3 + + +# Python imports +import sys, argparse +from setproctitle import setproctitle + +# Gtk imports +import gi, faulthandler, signal +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from gi.repository import GLib + +# Application imports +from __init__ import Main + + + + +if __name__ == "__main__": + try: + setproctitle('Bulk-Renamer') + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, Gtk.main_quit) + faulthandler.enable() # For better debug info + parser = argparse.ArgumentParser() + # Add long and short arguments + parser.add_argument("--file", "-f", default="default", help="JUST SOME FILE ARG.") + + # Read arguments (If any...) + args = parser.parse_args() + main = Main(args) + Gtk.main() + except Exception as e: + print( repr(e) ) diff --git a/src/controller/ChangeView.py b/src/controller/ChangeView.py new file mode 100644 index 0000000..bce4e68 --- /dev/null +++ b/src/controller/ChangeView.py @@ -0,0 +1,57 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from mixins import CommonActionsMixin + + +class ChangeView(Gtk.Box, CommonActionsMixin): + def __init__(self): + super(ChangeView, self).__init__() + + from_container = Gtk.Box() + to_container = Gtk.Box() + from_scroll_vw, \ + self.from_store = self._create_treeview_widget(title="From:") + to_scroll_vw, \ + self.to_store = self._create_treeview_widget(title="To:") + + from_container.add(from_scroll_vw) + to_container.add(to_scroll_vw) + + from_container.set_orientation(1) + to_container.set_orientation(1) + + self.set_spacing(20) + self.set_border_width(2) + self.set_homogeneous(True) + self.add(from_container) + self.add(to_container) + self.show_all() + + + def update_from_list(self): + if event_system.block_from_update: + return + + print("Updating From List...") + if self.from_store: + self.from_store.clear() + + for i, change in enumerate(event_system.from_changes): + self.from_store.insert(i, [change]) + + def update_to_list(self): + if event_system.block_to_update: + return + + print("Updating To List...") + if self.to_store: + self.to_store.clear() + + for i, change in enumerate(event_system.to_changes): + self.to_store.insert(i, [change]) diff --git a/src/controller/Controller.py b/src/controller/Controller.py new file mode 100644 index 0000000..00ac263 --- /dev/null +++ b/src/controller/Controller.py @@ -0,0 +1,167 @@ +# Python imports +import os, sys, threading, time + +# lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from gi.repository import GLib + +# Application imports +from mixins import CommonActionsMixin +from . import ChangeView +from .widgets import * + + +def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() + return wrapper + + +class Controller(Gtk.Box, CommonActionsMixin): + def __init__(self, args): + super(Controller, self).__init__() + + # Add header + self.change_view = ChangeView() + action_bar = Gtk.Box() + file_choser = Gtk.FileChooserButton(title="Directory Chooser", action=2) # 2 = SELECT_FOLDER + file_filter = Gtk.FileFilter() + file_choser.show() + file_choser.set_filename(event_system.USER_HOME) + file_filter.add_mime_type("inode/directory") + file_choser.add_filter(file_filter) + + label = Gtk.Label(label="Bulk Action Type: ") + data = ["Insert", "Replace", "Remove", "Remove From / To", "Case"] + self.store, self.combo_box = self._create_combobox_widget(data) + + add_button = Gtk.Button(label="Add Action") + test_all_button = Gtk.Button(label="Preview") + reset_button = Gtk.Button(label="Reset") + run_button = Gtk.Button(label="Run") + + action_bar.add(label) + action_bar.add(self.combo_box) + action_bar.add(add_button) + action_bar.add(test_all_button) + action_bar.add(reset_button) + action_bar.set_homogeneous(True) + action_bar.set_spacing(20) + action_bar.show_all() + + run_button.connect("clicked", self._run_all) + add_button.connect("clicked", self._add_action) + test_all_button.connect("clicked", self._test_all) + reset_button.connect("clicked", self._reset_to_view) + file_choser.connect("file-set", self.update_dir_path) + + actions_scroll_label = Gtk.Label(label="Actions:") + actions_scroll_label.set_xalign(-20) + actions_scroll_view, self.actions_list_view = self._create_listBox_widget() + + self.set_spacing(20) + self.set_margin_top(5) + self.set_margin_bottom(10) + self.set_margin_left(15) + self.set_margin_right(15) + self.set_orientation(1) + + self.add(file_choser) + self.add(action_bar) + self.add(self.change_view) + self.add(actions_scroll_label) + self.add(actions_scroll_view) + self.add(run_button) + self.show_all() + + self.gui_event_observer() + self.action_collection = [] + + + @threaded + def gui_event_observer(self): + while True: + time.sleep(event_sleep_time) + event = event_system.consume_gui_event() + if event: + try: + type, target, data = event + if type: + method = getattr(self.__class__, "_handle_gui_event") + GLib.idle_add(method, *(self, type, target, data)) + else: + method = getattr(self.__class__, target) + GLib.idle_add(method, *(self, *data,)) + except Exception as e: + print(repr(e)) + + + def update_dir_path(self, widget): + path = widget.get_filename() + event_system.set_active_path(path) + + def _handle_gui_event(self, type, target, parameters): + if type == "update-from": + self.change_view.update_from_list() + return + + if type == "update-to": + self.change_view.update_to_list() + return + + for action in self.action_collection: + if action == target: + if type == "delete": + self.action_collection.remove(target) + target.delete() + if type == "run": + target.run() + + + def _add_action(self, widget): + itr = self.combo_box.get_active_iter() + text = self.store.get(itr, 0)[0] + widget = self._str_to_class( self._clean_text(text) ) + + print(f"Adding: {self._clean_text(text)}") + self.actions_list_view.add(widget) + self.action_collection.append(widget) + + def _test_all(self, widget=None): + event_system.block_to_update = True + event_system.reset_to_view() + for action in self.action_collection: + action.run() + + event_system.block_to_update = False + event_system.push_gui_event(["update-to", self, ()]) + + def _reset_to_view(self, widget): + event_system.reset_to_view() + + def _run_all(self, widget): + if not event_system.active_path: + print("No active path set. Returning...") + return + + self._test_all() + dir = event_system.active_path + for i, file in enumerate(event_system.from_changes): + fPath = f"{dir}/{file}" + tPath = f"{dir}/{event_system.to_changes[i]}" + if fPath != tPath: + try: + os.rename(fPath, tPath) + except Exception as e: + print(f"Cant Move: {fPath}\nTo File: {tPath}") + + event_system.reset_from_view() + + def _clean_text(self, text): + return text.replace(" ", "") \ + .replace("/", "") + + def _str_to_class(self, class_name): + return getattr(sys.modules[__name__], class_name)() diff --git a/src/controller/__init__.py b/src/controller/__init__.py new file mode 100644 index 0000000..b290d33 --- /dev/null +++ b/src/controller/__init__.py @@ -0,0 +1,2 @@ +from .ChangeView import ChangeView +from .Controller import Controller diff --git a/src/controller/widgets/Case.py b/src/controller/widgets/Case.py new file mode 100644 index 0000000..ddfe5f7 --- /dev/null +++ b/src/controller/widgets/Case.py @@ -0,0 +1,47 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from mixins import CommonActionsMixin + + +class Case(Gtk.Box, CommonActionsMixin): + def __init__(self): + super(Case, self).__init__() + + label = Gtk.Label(label="Case") + data = ["Title Case", "UPPER", "lower", "InVert CaSe --> iNvERT cAsE"] + self.store, self.combo_box = self._create_combobox_widget(data) + + label.set_hexpand(True) + + self.add_widgets([label, self.combo_box]) + self.set_spacing(20) + self.show_all() + + + def run(self): + new_collection = [] + itr = self.combo_box.get_active_iter() + type = self.store.get(itr, 0)[0] + + print(f"Changing Case...") + if type == "Title Case": + for name in event_system.to_changes: + new_collection.append(name.title()) + if type == "UPPER": + for name in event_system.to_changes: + new_collection.append(name.upper()) + if type == "lower": + for name in event_system.to_changes: + new_collection.append(name.lower()) + if type == "InVert CaSe --> iNvERT cAsE": + for name in event_system.to_changes: + new_collection.append(name.swapcase()) + + event_system.to_changes = new_collection + event_system.push_gui_event(["update-to", self, ()]) diff --git a/src/controller/widgets/Insert.py b/src/controller/widgets/Insert.py new file mode 100644 index 0000000..97afb68 --- /dev/null +++ b/src/controller/widgets/Insert.py @@ -0,0 +1,63 @@ +# Python imports +import pathlib + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from mixins import CommonActionsMixin + + +class Insert(Gtk.Box, CommonActionsMixin): + def __init__(self): + super(Insert, self).__init__() + + label = Gtk.Label(label="Insert: ") + self.insert_entry = Gtk.Entry() + self.insert_entry.set_hexpand(True) + self.insert_entry.set_placeholder_text("Insert...") + + data = ["Start", "End", "Position"] + self.store, self.combo_box = self._create_combobox_widget(data) + + self.spin_button = self._create_spinbutton_widget() + + self.add_widgets([label, self.insert_entry, self.combo_box, self.spin_button]) + self.set_spacing(20) + self.show_all() + + + def run(self): + new_collection = [] + insert_str = self.insert_entry.get_text() + itr = self.combo_box.get_active_iter() + type = self.store.get(itr, 0)[0] + + print(f"Inserting...") + if type == "Start": + for name in event_system.to_changes: + new_collection.append(f"{insert_str}{name}") + if type == "End": + for name in event_system.to_changes: + base, file_extension = self.get_file_parts() + new_collection.append(f"{base}{insert_str}{file_extension}") + if type == "Position": + position = self.spin_button.get_value_as_int() + for name in event_system.to_changes: + name = f"{name[:position]}{insert_str}{name[position:]}" + new_collection.append(f"{name}") + + event_system.to_changes = new_collection + event_system.push_gui_event(["update-to", self, ()]) + + + def _combo_box_changed(self, widget, eve=None): + itr = widget.get_active_iter() + type = self.store.get(itr, 0)[0] + + if type == "Position": + self.spin_button.set_sensitive(True) + else: + self.spin_button.set_sensitive(False) diff --git a/src/controller/widgets/Remove.py b/src/controller/widgets/Remove.py new file mode 100644 index 0000000..fe7b38b --- /dev/null +++ b/src/controller/widgets/Remove.py @@ -0,0 +1,55 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from mixins import CommonActionsMixin + + +class Remove(Gtk.Box, CommonActionsMixin): + def __init__(self): + super(Remove, self).__init__() + + label = Gtk.Label(label="Remove: ") + self.entry_from = Gtk.Entry() + + data = ["All", "Word Start", "Word End", "First Instance", "Last Instance", "RegEx"] + self.store, self.combo_box = self._create_combobox_widget(data) + + self.entry_from.set_hexpand(True) + self.entry_from.set_placeholder_text("Remove...") + + self.add_widgets([label, self.entry_from, self.combo_box]) + self.set_spacing(20) + self.show_all() + + + def run(self): + from_str = self.entry_from.get_text() + if from_str: + new_collection = [] + itr = self.combo_box.get_active_iter() + type = self.store.get(itr, 0)[0] + print(f"To Remove: {from_str}") + + if type == "All": + for name in event_system.to_changes: + new_collection.append(name.replace(from_str, '')) + if type == "Word Start": + print("Stub...") + if type == "Word End": + print("Stub...") + if type == "First Instance": + for name in event_system.to_changes: + new_collection.append( name.replace(from_str, "", 1) ) + if type == "Last Instance": + for name in event_system.to_changes: + new_collection.append( self._replace_last(name, from_str, "") ) + if type == "RegEx": + print("Stub...") + + event_system.to_changes = new_collection + event_system.push_gui_event(["update-to", self, ()]) diff --git a/src/controller/widgets/RemoveFromTo.py b/src/controller/widgets/RemoveFromTo.py new file mode 100644 index 0000000..ebd1fbb --- /dev/null +++ b/src/controller/widgets/RemoveFromTo.py @@ -0,0 +1,45 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from mixins import CommonActionsMixin + + +class RemoveFromTo(Gtk.Box, CommonActionsMixin): + def __init__(self): + super(RemoveFromTo, self).__init__() + + label = Gtk.Label(label="Remove From / To: ") + self.entry_from = Gtk.Entry() + self.entry_to = Gtk.Entry() + + self.entry_from.set_hexpand(True) + self.entry_to.set_hexpand(True) + self.entry_from.set_placeholder_text("From...") + self.entry_to.set_placeholder_text("To...") + + self.add_widgets([label, self.entry_from, self.entry_to]) + + self.set_spacing(20) + self.show_all() + + + def run(self): + fsub = self.entry_from.get_text() + tsub = self.entry_to.get_text() + + if fsub and tsub: + new_collection = [] + print(f"From: {fsub}\nTo: {tsub}") + for name in event_system.to_changes: + startIndex = name.index(fsub) + 1 + endIndex = name.index(tsub) + toRemove = name[startIndex:endIndex] + new_collection.append(name.replace(toRemove, '')) + + event_system.to_changes = new_collection + event_system.push_gui_event(["update-to", self, ()]) diff --git a/src/controller/widgets/Replace.py b/src/controller/widgets/Replace.py new file mode 100644 index 0000000..821d131 --- /dev/null +++ b/src/controller/widgets/Replace.py @@ -0,0 +1,41 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from mixins import CommonActionsMixin + + +class Replace(Gtk.Box, CommonActionsMixin): + def __init__(self): + super(Replace, self).__init__() + + label = Gtk.Label(label="Replace With: ") + self.entry_from = Gtk.Entry() + self.entry_to = Gtk.Entry() + + self.entry_from.set_hexpand(True) + self.entry_to.set_hexpand(True) + self.entry_from.set_placeholder_text("From...") + self.entry_to.set_placeholder_text("To...") + + self.add_widgets([label, self.entry_from, self.entry_to]) + + self.set_spacing(20) + self.show_all() + + + def run(self): + fsub = self.entry_from.get_text() + tsub = self.entry_to.get_text() + if fsub and tsub: + new_collection = [] + print(f"From: {fsub}\nTo: {tsub}") + for name in event_system.to_changes: + new_collection.append(name.replace(fsub, tsub)) + + event_system.to_changes = new_collection + event_system.push_gui_event(["update-to", self, ()]) diff --git a/src/controller/widgets/__init__.py b/src/controller/widgets/__init__.py new file mode 100644 index 0000000..4688b2e --- /dev/null +++ b/src/controller/widgets/__init__.py @@ -0,0 +1,5 @@ +from .Insert import Insert +from .Replace import Replace +from .Remove import Remove +from .RemoveFromTo import RemoveFromTo +from .Case import Case diff --git a/src/mixins/CommonActionsMixin.py b/src/mixins/CommonActionsMixin.py new file mode 100644 index 0000000..f8afbee --- /dev/null +++ b/src/mixins/CommonActionsMixin.py @@ -0,0 +1,124 @@ +# Python imports +import pathlib + +# lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports + + +class CommonActionsMixin: + def add_widgets(self, widgets): + for widget in widgets: + self.add(widget) + + remove_button = Gtk.Button(label="X") + test_button = Gtk.Button(label="Test") + + remove_button.connect("clicked", self._remove_self) + test_button.connect("clicked", self._do_run) + + remove_button.set_size_request(120, 32) + test_button.set_size_request(120, 32) + + self.add(test_button) + self.add(remove_button) + + def delete(self): + self.get_parent().destroy() + + + + + def get_file_parts(self, name): + file_extension = pathlib.Path(name).suffix + base = name.split(file_extension)[0] + return base, file_extension + + def _has_method(self, obj, name): + ''' Checks if a given method exists. ''' + return callable(getattr(obj, name, None)) + + def _replace_last(self, string, find, replace): + reversed = string[::-1] + replaced = reversed.replace(find[::-1], replace[::-1], 1) + return replaced[::-1] + + + + + def _remove_self(self, widget): + event_system.push_gui_event(["delete", self, ()]) + + def _do_run(self, widget): + event_system.push_gui_event(["run", self, ()]) + + + def _create_spinbutton_widget(self): + spin_button = Gtk.SpinButton() + spin_button.set_numeric(True) + spin_button.set_wrap(True) + spin_button.set_digits(0) + spin_button.set_increments(1.0, 1.0) + spin_button.set_range(1, 1000000) + spin_button.set_sensitive(False) + + return spin_button + + def _create_combobox_widget(self, data): + cell = Gtk.CellRendererText() + store = Gtk.ListStore(str) + + for row in data: + store.append([row]) + + combo_box = Gtk.ComboBox() + combo_box.set_model(store) + combo_box.pack_start(cell, True) + combo_box.add_attribute(cell, 'text', 0) + combo_box.set_active(0) + + if self._has_method(self, "_combo_box_changed"): + combo_box.connect("changed", self._combo_box_changed) + + return store, combo_box + + def _create_treeview_widget(self, title = "Not Set"): + scroll = Gtk.ScrolledWindow() + grid = Gtk.TreeView() + store = Gtk.ListStore(str) + column = Gtk.TreeViewColumn(title) + name = Gtk.CellRendererText() + selec = grid.get_selection() + + grid.set_model(store) + selec.set_mode(2) + + column.pack_start(name, True) + column.add_attribute(name, "text", 0) + column.set_expand(False) + + grid.append_column(column) + grid.set_search_column(0) + grid.set_headers_visible(True) + grid.set_enable_tree_lines(False) + + grid.show_all() + scroll.add(grid) + grid.columns_autosize() + scroll.set_size_request(360, 240) + return scroll, store + + def _create_listBox_widget(self,): + scroll = Gtk.ScrolledWindow() + grid = Gtk.ListBox() + viewport = Gtk.Viewport() + + grid.show_all() + viewport.add(grid) + scroll.add(viewport) + + scroll.set_size_request(360, 200) + return scroll, grid diff --git a/src/mixins/__init__.py b/src/mixins/__init__.py new file mode 100644 index 0000000..55cf9b4 --- /dev/null +++ b/src/mixins/__init__.py @@ -0,0 +1 @@ +from .CommonActionsMixin import CommonActionsMixin diff --git a/user_config/usr/share/bulkr/bulkr.png b/user_config/usr/share/bulkr/bulkr.png new file mode 100644 index 0000000..c8f652f Binary files /dev/null and b/user_config/usr/share/bulkr/bulkr.png differ