diff -Nru bulky-1.7+klbkbionic/debian/changelog bulky-1.8+klbkbionic/debian/changelog --- bulky-1.7+klbkbionic/debian/changelog 2021-07-05 20:50:58.000000000 +0000 +++ bulky-1.8+klbkbionic/debian/changelog 2021-10-15 20:55:33.000000000 +0000 @@ -1,8 +1,25 @@ -bulky (1.7+klbkbionic) bionic; urgency=medium +bulky (1.8+klbkbionic) bionic; urgency=medium - * No changes, release 1.7 for PPA. + * No changes, release 1.8 for PPA. - -- Gökhan Gökkaya Mon, 05 Jul 2021 23:50:58 +0300 + -- Gökhan Gökkaya Fri, 15 Oct 2021 23:55:33 +0300 + +bulky (1.8) uma; urgency=medium + + [ Michael Webster ] + * file picker: Don't lose a multi-selection when enter is pressed. + * file picker: Handle file-activated again. + * Use uris instead of paths internally, to allow files on remote filesystems to be renamed. + * Remove leftover path reference from previous commit. + * Rename files before directories, and children before parents. + + [ Vincent Vermeulen ] + * preserve whitespace in arguments + + [ Michael Webster ] + * Update names in the correct rows after renaming. + + -- Clement Lefebvre Fri, 15 Oct 2021 17:26:02 +0100 bulky (1.7) uma; urgency=medium diff -Nru bulky-1.7+klbkbionic/usr/bin/bulky bulky-1.8+klbkbionic/usr/bin/bulky --- bulky-1.7+klbkbionic/usr/bin/bulky 2021-05-19 11:10:18.000000000 +0000 +++ bulky-1.8+klbkbionic/usr/bin/bulky 2021-10-15 16:26:32.000000000 +0000 @@ -1,2 +1,2 @@ #!/bin/bash -/usr/lib/bulky/bulky.py $@ & +/usr/lib/bulky/bulky.py "$@" & diff -Nru bulky-1.7+klbkbionic/usr/lib/bulky/bulky.py bulky-1.8+klbkbionic/usr/lib/bulky/bulky.py --- bulky-1.7+klbkbionic/usr/lib/bulky/bulky.py 2021-07-02 09:32:28.000000000 +0000 +++ bulky-1.8+klbkbionic/usr/lib/bulky/bulky.py 2021-10-15 16:26:32.000000000 +0000 @@ -9,6 +9,7 @@ import subprocess import warnings import sys +import functools # Suppress GTK deprecation warnings warnings.filterwarnings("ignore") @@ -31,29 +32,120 @@ SCOPE_EXTENSION_ONLY = "extension" SCOPE_ALL = "all" +class FolderFileChooserDialog(Gtk.Dialog): + def __init__(self, window_title, transient_parent, starting_location): + super(FolderFileChooserDialog, self).__init__(title=window_title, + parent=transient_parent, + default_width=750, + default_height=500) + + self.add_buttons(_("Cancel"), Gtk.ResponseType.CANCEL, + _("Add"), Gtk.ResponseType.OK) + + self.chooser = Gtk.FileChooserWidget(action=Gtk.FileChooserAction.OPEN, select_multiple=True) + self.chooser.set_current_folder_file(starting_location) + self.chooser.connect("file-activated", lambda chooser: self.response(Gtk.ResponseType.OK)) + self.chooser.show_all() + + self.get_content_area().add(self.chooser) + self.get_content_area().set_border_width(0) + self.get_uris = self.chooser.get_uris + self.get_current_folder_file = self.chooser.get_current_folder_file + self.connect("key-press-event", self.on_button_press) + + def on_button_press(self, widget, event, data=None): + multi = len(self.chooser.get_uris()) != 1 + if event.keyval in (Gdk.KEY_KP_Enter, Gdk.KEY_Return) and multi: + self.response(Gtk.ResponseType.OK) + return Gdk.EVENT_STOP + + return Gdk.EVENT_PROPAGATE + # This is a data structure representing # the file object class FileObject(): - def __init__(self, path): - self.path = os.path.abspath(path) - self.parent_path, self.name = os.path.split(self.path) - if os.path.isdir(self.path): - self.icon = "folder" + def __init__(self, path_or_uri): + if "://" in path_or_uri: + self.gfile = Gio.File.new_for_uri(path_or_uri) else: - self.icon = "text-x-generic" - try: - icon_theme = Gtk.IconTheme.get_default() - mimetype = magic.from_file(self.path, mime=True) - for name in Gio.content_type_get_icon(mimetype).get_names(): - if icon_theme.has_icon(name): - self.icon = name - break - except Exception as e: - print(e) + self.gfile = Gio.File.new_for_path(path_or_uri) + + self._update_info() + + def _update_info(self): + self.info = None + self.uri = self.gfile.get_uri() + self.name = self.gfile.get_basename() # temp in case query_info fails to get edit-name + self.icon = Gio.ThemedIcon.new("text-x-generic") + + try: + self.info = self.gfile.query_info("standard::type,standard::icon,standard::edit-name,access::can-write", + Gio.FileQueryInfoFlags.NONE, None) + + self.name = self.info.get_edit_name() + + if self.info.get_file_type() == Gio.FileType.DIRECTORY: + self.icon = Gio.ThemedIcon.new("folder") + else: + info_icon = self.info.get_icon() + + if info_icon: + self.icon = info_icon + else: + self.icon = Gio.ThemedIcon.new("text-x-generic") + except GLib.Error as e: + if e.code == Gio.IOErrorEnum.NOT_FOUND: + print("file %s does not exist" % self.uri) + else: + print(e.message) + self.is_valid = False + return self.is_valid = True + def rename(self, new_name): + # this can fail, our caller will catch + new_gfile = self.gfile.set_display_name(new_name, None) + + self.gfile = new_gfile + self._update_info() + + return True + + def get_pending_uri(self, new_name): + parent = self.gfile.get_parent() + return parent.get_child(new_name).get_uri() + + def get_path_or_uri_for_display(self): + if self.uri.startswith("file://"): + return self.gfile.get_path().replace(os.path.expanduser("~"), "~") + else: + return self.name + + def get_parent_path_or_uri_for_display(self): + parent = self.gfile.get_parent() + uri = parent.get_uri() + if uri.startswith("file://"): + return parent.get_path().replace(os.path.expanduser("~"), "~") + else: + return parent.get_basename() + + def writable(self): + return self.info.get_attribute_boolean("access::can-write") + + def parent_writable(self): + parent = self.gfile.get_parent() + + if parent.equal(self.gfile): + return False + + parent_fileobj = FileObject(parent.get_uri()) + return parent_fileobj.writable() + + def is_a_dir(self): + return self.info.get_file_type() == Gio.FileType.DIRECTORY + class MyApplication(Gtk.Application): # Main initialization routine def __init__(self, application_id, flags): @@ -81,8 +173,8 @@ self.operation_function = self.replace_text self.scope = SCOPE_NAME_ONLY # used to prevent collisions - self.paths = [] - self.renamed_paths = [] + self.uris = [] + self.renamed_uris = [] self.last_chooser_location = Gio.File.new_for_path(GLib.get_home_dir()) # Set the Glade file @@ -140,10 +232,10 @@ column = Gtk.TreeViewColumn() column.set_title(_("Name")) column.set_spacing(6) - column.set_cell_data_func(renderer_pixbuf, self.data_func_surface) + # column.set_cell_data_func(renderer_pixbuf, self.data_func_surface) column.pack_start(renderer_pixbuf, False) column.pack_start(renderer_text, True) - column.add_attribute(renderer_pixbuf, "pixbuf", COL_ICON) + column.add_attribute(renderer_pixbuf, "gicon", COL_ICON) column.add_attribute(renderer_text, "text", COL_NAME) column.set_sort_column_id(COL_NAME) column.set_expand(True) @@ -154,7 +246,7 @@ self.treeview.append_column(column) self.treeview.show() - self.model = Gtk.TreeStore(GdkPixbuf.Pixbuf, str, str, object) # icon, name, new_name, file + self.model = Gtk.TreeStore(Gio.Icon, str, str, object) # icon, name, new_name, file self.model.set_sort_column_id(COL_NAME, Gtk.SortType.ASCENDING) self.treeview.set_model(self.model) self.treeview.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) @@ -275,30 +367,21 @@ # since removing changes the paths iters.append(self.model.get_iter(path)) for iter in iters: - file_path = self.model.get_value(iter, COL_FILE).path - self.paths.remove(file_path) + file_uri = self.model.get_value(iter, COL_FILE).uri + self.uris.remove(file_uri) self.model.remove(iter) + self.preview_changes() def on_add_button(self, widget): - dialog = Gtk.Dialog(title=_("Add files"), parent=self.window, default_width=750, default_height=500) - dialog.add_buttons(_("Cancel"), Gtk.ResponseType.CANCEL, - _("Add"), Gtk.ResponseType.OK) - - chooser = Gtk.FileChooserWidget(action=Gtk.FileChooserAction.OPEN, select_multiple=True) - chooser.set_current_folder_file(self.last_chooser_location) - chooser.connect("file-activated", lambda chooser: dialog.response(Gtk.ResponseType.OK)) + dialog = FolderFileChooserDialog(_("Add files"), self.window, self.last_chooser_location) def update_last_location(dialog, response_id, data=None): if response_id != Gtk.ResponseType.OK: return - self.last_chooser_location = chooser.get_current_folder_file() + self.last_chooser_location = dialog.get_current_folder_file() dialog.connect("response", update_last_location) - chooser.show_all() - dialog.get_content_area().add(chooser) - dialog.get_content_area().set_border_width(0) - dialog.get_uris = chooser.get_uris response = dialog.run() if response == Gtk.ResponseType.OK: for uri in dialog.get_uris(): @@ -308,7 +391,7 @@ def on_clear_button(self, widget): self.model.clear() - self.paths.clear() + self.uris.clear() def on_close_button(self, widget): self.application.quit() @@ -322,24 +405,58 @@ # We're modifying the model here, so we iterate # through our own list of iters rather than the model # itself + rename_list = [] for iter in iters: try: file_obj = self.model.get_value(iter, COL_FILE) name = self.model.get_value(iter, COL_NAME) new_name = self.model.get_value(iter, COL_NEW_NAME) + rename_list.append((iter, file_obj, name, new_name)) + except Exception as e: + print(e) + + rename_list = self.sort_list_by_depth(rename_list) + + # for i in rename_list: + # print(i[0].gfile.get_uri()) + + for tup in rename_list: + try: + iter, file_obj, name, new_name = tup if new_name != name: - new_path = os.path.join(file_obj.parent_path, new_name) - os.rename(file_obj.path, new_path) - self.paths.remove(file_obj.path) - self.paths.append(new_path) - file_obj.path = new_path - file_obj.name = new_name - self.model.set_value(iter, COL_NAME, new_name) - print("Renamed %s --> %s" % (name, new_name)) + old_uri = file_obj.uri + if file_obj.rename(new_name): + self.uris.remove(old_uri) + self.uris.append(file_obj.uri) + self.model.set_value(iter, COL_NAME, new_name) + print("Renamed %s --> %s" % (name, new_name)) except Exception as e: print(e) + self.rename_button.set_sensitive(False) + def sort_list_by_depth(self, rename_list): + # Rename files first, followed by directories from deep to shallow. + def file_cmp(tup_a, tup_b): + fo_a = tup_a[1] + fo_b = tup_b[1] + + if fo_a.is_a_dir() and (not fo_b.is_a_dir()): + return 1 + elif fo_b.is_a_dir() and (not fo_a.is_a_dir()): + return -1 + + if fo_a.gfile.has_prefix(fo_b.gfile): + return -1 + elif fo_b.gfile.has_prefix(fo_a.gfile): + return 1 + + return GLib.utf8_collate(fo_a.uri, fo_b.uri) + + rename_list.sort(key=lambda tup: tup[1].gfile.get_uri_scheme()) + rename_list.sort(key=functools.cmp_to_key(file_cmp)) + return rename_list + def load_files(self): # Clear treeview and selection self.model.clear() @@ -352,26 +469,21 @@ self.builder.get_object("headerbar").set_title(_("File Renamer")) self.builder.get_object("headerbar").set_subtitle(_("Rename files and directories")) - def add_file(self, path): - if "://" in path: - # we're dealing with a URI, only accept file:// - if not path.startswith("file://"): + self.preview_changes() + + def add_file(self, uri_or_path): + file_obj = FileObject(uri_or_path) + + if file_obj.is_valid: + if file_obj.uri in self.uris: + print("%s is already loaded, ignoring" % file_obj.uri) return - f = Gio.File.new_for_uri(path) - path = f.get_path() - if os.path.exists(path): - file_obj = FileObject(path) - if file_obj.is_valid: - if file_obj.path in self.paths: - print("%s is already loaded, ignoring" % file_obj.path) - return - self.paths.append(file_obj.path) - pixbuf = self.icon_theme.load_icon(file_obj.icon, 22 * self.window.get_scale_factor(), 0) - iter = self.model.insert_before(None, None) - self.model.set_value(iter, COL_ICON, pixbuf) - self.model.set_value(iter, COL_NAME, file_obj.name) - self.model.set_value(iter, COL_NEW_NAME, file_obj.name) - self.model.set_value(iter, COL_FILE, file_obj) + self.uris.append(file_obj.uri) + iter = self.model.insert_before(None, None) + self.model.set_value(iter, COL_ICON, file_obj.icon) + self.model.set_value(iter, COL_NAME, file_obj.name) + self.model.set_value(iter, COL_NEW_NAME, file_obj.name) + self.model.set_value(iter, COL_FILE, file_obj) def on_operation_changed(self, widget): operation_id = widget.get_active_id() @@ -397,7 +509,7 @@ self.preview_changes() def preview_changes(self): - self.renamed_paths = [] + self.renamed_uris = [] self.infobar.hide() self.rename_button.set_sensitive(True) iter = self.model.get_iter_first() @@ -418,20 +530,20 @@ ext = self.operation_function(index, ext) new_name = name + ('.' if ext else '') + ext self.model.set_value(iter, COL_NEW_NAME, new_name) - renamed_path = os.path.join(file_obj.parent_path, new_name) - if renamed_path in self.renamed_paths: + renamed_uri = file_obj.get_pending_uri(new_name) + if renamed_uri in self.renamed_uris: self.infobar.show() - self.error_label.set_text(_("Name collision on '%s'.") % renamed_path.replace(os.path.expanduser("~"), "~")) + self.error_label.set_text(_("Name collision on '%s'.") % file_obj.get_path_or_uri_for_display()) self.rename_button.set_sensitive(False) - elif not os.access(file_obj.parent_path, os.W_OK): + elif not file_obj.parent_writable(): self.infobar.show() - self.error_label.set_text(_("'%s' is not writeable.") % file_obj.parent_path.replace(os.path.expanduser("~"), "~")) + self.error_label.set_text(_("'%s' is not writeable.") % file_obj.get_parent_path_or_uri_for_display()) self.rename_button.set_sensitive(False) - elif not os.access(file_obj.path, os.W_OK): + elif not file_obj.writable(): self.infobar.show() - self.error_label.set_text(_("'%s' is not writeable.") % file_obj.path.replace(os.path.expanduser("~"), "~")) + self.error_label.set_text(_("'%s' is not writeable.") % file_obj.get_path_or_uri_for_display()) self.rename_button.set_sensitive(False) - self.renamed_paths.append(renamed_path) + self.renamed_uris.append(renamed_uri) iter = self.model.iter_next(iter) index += 1 except Exception as e: