diff options
| author | jvoisin | 2018-07-23 23:39:06 +0200 |
|---|---|---|
| committer | jvoisin | 2018-07-23 23:39:06 +0200 |
| commit | e9200835590641c277784159f25ab4464c515a17 (patch) | |
| tree | 1a3b91862cc2d7710bba40aedfa47673eb83a963 /nautilus | |
| parent | 71b1ced84246f940ee46b89352d958edb5408f2a (diff) | |
The Nautilus extension is now working
Diffstat (limited to 'nautilus')
| -rw-r--r-- | nautilus/nautilus_mat2.py | 192 |
1 files changed, 144 insertions, 48 deletions
diff --git a/nautilus/nautilus_mat2.py b/nautilus/nautilus_mat2.py index 9e8d4db..8828989 100644 --- a/nautilus/nautilus_mat2.py +++ b/nautilus/nautilus_mat2.py | |||
| @@ -1,68 +1,97 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | # TODO: | 3 | # pylint: disable=unused-argument,len-as-condition,arguments-differ |
| 4 | # - Test with a large amount of files. | 4 | |
| 5 | # - Show a progression bar when the removal takes time. | 5 | """ |
| 6 | # - Improve the MessageDialog list for failed items. | 6 | Because writing GUI is non-trivial (cf. https://0xacab.org/jvoisin/mat2/issues/3), |
| 7 | we decided to write a Nautilus extensions instead | ||
| 8 | (cf. https://0xacab.org/jvoisin/mat2/issues/2). | ||
| 9 | |||
| 10 | The code is a little bit convoluted because Gtk isn't thread-safe, | ||
| 11 | so we're not allowed to call anything Gtk-related outside of the main | ||
| 12 | thread, so we'll have to resort to using a `queue` to pass "messages" around. | ||
| 13 | """ | ||
| 7 | 14 | ||
| 8 | import os | 15 | import os |
| 16 | import queue | ||
| 17 | import threading | ||
| 9 | from urllib.parse import unquote | 18 | from urllib.parse import unquote |
| 10 | 19 | ||
| 11 | import gi | 20 | import gi |
| 12 | gi.require_version('Nautilus', '3.0') | 21 | gi.require_version('Nautilus', '3.0') |
| 13 | gi.require_version('Gtk', '3.0') | 22 | gi.require_version('Gtk', '3.0') |
| 14 | from gi.repository import Nautilus, GObject, Gtk, Gio | 23 | from gi.repository import Nautilus, GObject, Gtk, Gio, GLib |
| 15 | 24 | ||
| 16 | from libmat2 import parser_factory | 25 | from libmat2 import parser_factory |
| 17 | 26 | ||
| 18 | class Mat2Wrapper(): | 27 | def _remove_metadata(fpath): |
| 19 | def __init__(self, filepath): | 28 | """ This is a simple wrapper around libmat2, because it's |
| 20 | self.__filepath = filepath | 29 | easier and cleaner this way. |
| 21 | 30 | """ | |
| 22 | def remove_metadata(self): | 31 | parser, mtype = parser_factory.get_parser(fpath) |
| 23 | parser, mtype = parser_factory.get_parser(self.__filepath) | 32 | if parser is None: |
| 24 | if parser is None: | 33 | return False, mtype |
| 25 | return False, mtype | 34 | return parser.remove_all(), mtype |
| 26 | return parser.remove_all(), mtype | ||
| 27 | 35 | ||
| 28 | class ColumnExtension(GObject.GObject, Nautilus.MenuProvider, Nautilus.LocationWidgetProvider): | 36 | class ColumnExtension(GObject.GObject, Nautilus.MenuProvider, Nautilus.LocationWidgetProvider): |
| 29 | def notify(self): | 37 | """ This class adds an item to the right-clic menu in Nautilus. """ |
| 30 | self.infobar_msg.set_text("Failed to clean some items") | 38 | def __init__(self): |
| 31 | self.infobar.show_all() | 39 | super().__init__() |
| 40 | self.infobar_hbox = None | ||
| 41 | self.infobar = None | ||
| 42 | self.failed_items = list() | ||
| 32 | 43 | ||
| 33 | def get_widget(self, uri, window): | 44 | def __infobar_failure(self): |
| 34 | self.infobar = Gtk.InfoBar() | 45 | """ Add an hbox to the `infobar` warning about the fact that we didn't |
| 35 | self.infobar.set_message_type(Gtk.MessageType.ERROR) | 46 | manage to remove the metadata from every single file. |
| 47 | """ | ||
| 36 | self.infobar.set_show_close_button(True) | 48 | self.infobar.set_show_close_button(True) |
| 37 | self.infobar.connect("response", self.__cb_infobar_response) | 49 | self.infobar_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) |
| 38 | |||
| 39 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) | ||
| 40 | self.infobar.get_content_area().pack_start(hbox, False, False, 0) | ||
| 41 | 50 | ||
| 42 | btn = Gtk.Button("Show") | 51 | btn = Gtk.Button("Show") |
| 43 | btn.connect("clicked", self.__cb_show_failed) | 52 | btn.connect("clicked", self.__cb_show_failed) |
| 44 | self.infobar.get_content_area().pack_end(btn, False, False, 0) | 53 | self.infobar_hbox.pack_end(btn, False, False, 0) |
| 45 | 54 | ||
| 46 | self.infobar_msg = Gtk.Label() | 55 | infobar_msg = Gtk.Label("Failed to clean some items") |
| 47 | hbox.pack_start(self.infobar_msg, False, False, 0) | 56 | self.infobar_hbox.pack_start(infobar_msg, False, False, 0) |
| 57 | |||
| 58 | self.infobar.get_content_area().pack_start(self.infobar_hbox, True, True, 0) | ||
| 59 | self.infobar.show_all() | ||
| 60 | |||
| 61 | def get_widget(self, uri, window): | ||
| 62 | """ This is the method that we have to implement (because we're | ||
| 63 | a LocationWidgetProvider) in order to show our infobar. | ||
| 64 | """ | ||
| 65 | self.infobar = Gtk.InfoBar() | ||
| 66 | self.infobar.set_message_type(Gtk.MessageType.ERROR) | ||
| 67 | self.infobar.connect("response", self.__cb_infobar_response) | ||
| 48 | 68 | ||
| 49 | return self.infobar | 69 | return self.infobar |
| 50 | 70 | ||
| 51 | def __cb_infobar_response(self, infobar, response): | 71 | def __cb_infobar_response(self, infobar, response): |
| 72 | """ Callback for the infobar close button. | ||
| 73 | """ | ||
| 52 | if response == Gtk.ResponseType.CLOSE: | 74 | if response == Gtk.ResponseType.CLOSE: |
| 75 | self.infobar_hbox.destroy() | ||
| 53 | self.infobar.hide() | 76 | self.infobar.hide() |
| 54 | 77 | ||
| 55 | def __cb_show_failed(self, button): | 78 | def __cb_show_failed(self, button): |
| 79 | """ Callback to show a popup containing a list of files | ||
| 80 | that we didn't manage to clean. | ||
| 81 | """ | ||
| 82 | |||
| 83 | # FIXME this should be done only once the window is destroyed | ||
| 84 | self.infobar_hbox.destroy() | ||
| 56 | self.infobar.hide() | 85 | self.infobar.hide() |
| 57 | 86 | ||
| 58 | window = Gtk.Window() | 87 | window = Gtk.Window() |
| 59 | hb = Gtk.HeaderBar() | 88 | headerbar = Gtk.HeaderBar() |
| 60 | window.set_titlebar(hb) | 89 | window.set_titlebar(headerbar) |
| 61 | hb.props.title = "Metadata removal failed" | 90 | headerbar.props.title = "Metadata removal failed" |
| 62 | 91 | ||
| 63 | exit_buton = Gtk.Button("Exit") | 92 | exit_buton = Gtk.Button("Exit") |
| 64 | exit_buton.connect("clicked", lambda _: window.close()) | 93 | exit_buton.connect("clicked", lambda _: window.close()) |
| 65 | hb.pack_end(exit_buton) | 94 | headerbar.pack_end(exit_buton) |
| 66 | 95 | ||
| 67 | box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) | 96 | box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) |
| 68 | window.add(box) | 97 | window.add(box) |
| @@ -71,7 +100,7 @@ class ColumnExtension(GObject.GObject, Nautilus.MenuProvider, Nautilus.LocationW | |||
| 71 | listbox.set_selection_mode(Gtk.SelectionMode.NONE) | 100 | listbox.set_selection_mode(Gtk.SelectionMode.NONE) |
| 72 | box.pack_start(listbox, True, True, 0) | 101 | box.pack_start(listbox, True, True, 0) |
| 73 | 102 | ||
| 74 | for i, mtype in self.failed_items: | 103 | for fname, mtype in self.failed_items: |
| 75 | row = Gtk.ListBoxRow() | 104 | row = Gtk.ListBoxRow() |
| 76 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) | 105 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) |
| 77 | row.add(hbox) | 106 | row.add(hbox) |
| @@ -80,7 +109,7 @@ class ColumnExtension(GObject.GObject, Nautilus.MenuProvider, Nautilus.LocationW | |||
| 80 | select_image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON) | 109 | select_image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON) |
| 81 | hbox.pack_start(select_image, False, False, 0) | 110 | hbox.pack_start(select_image, False, False, 0) |
| 82 | 111 | ||
| 83 | label = Gtk.Label(os.path.basename(i)) | 112 | label = Gtk.Label(os.path.basename(fname)) |
| 84 | hbox.pack_start(label, True, False, 0) | 113 | hbox.pack_start(label, True, False, 0) |
| 85 | 114 | ||
| 86 | listbox.add(row) | 115 | listbox.add(row) |
| @@ -90,37 +119,104 @@ class ColumnExtension(GObject.GObject, Nautilus.MenuProvider, Nautilus.LocationW | |||
| 90 | 119 | ||
| 91 | 120 | ||
| 92 | @staticmethod | 121 | @staticmethod |
| 93 | def __validate(f): | 122 | def __validate(fileinfo): |
| 94 | if f.get_uri_scheme() != "file" or f.is_directory(): | 123 | """ Validate if a given file FileInfo `fileinfo` can be processed.""" |
| 124 | if fileinfo.get_uri_scheme() != "file" or fileinfo.is_directory(): | ||
| 95 | return False | 125 | return False |
| 96 | elif not f.can_write(): | 126 | elif not fileinfo.can_write(): |
| 97 | return False | 127 | return False |
| 98 | return True | 128 | return True |
| 99 | 129 | ||
| 130 | def __create_progressbar(self): | ||
| 131 | """ Create the progressbar used to notify that files are currently | ||
| 132 | being processed. | ||
| 133 | """ | ||
| 134 | self.infobar.set_show_close_button(False) | ||
| 135 | self.infobar.set_message_type(Gtk.MessageType.INFO) | ||
| 136 | self.infobar_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) | ||
| 137 | |||
| 138 | progressbar = Gtk.ProgressBar() | ||
| 139 | self.infobar_hbox.pack_start(progressbar, True, True, 0) | ||
| 140 | progressbar.set_show_text(True) | ||
| 141 | |||
| 142 | self.infobar.get_content_area().pack_start(self.infobar_hbox, True, True, 0) | ||
| 143 | self.infobar.show_all() | ||
| 144 | |||
| 145 | return progressbar | ||
| 146 | |||
| 147 | def __update_progressbar(self, processing_queue, progressbar): | ||
| 148 | """ This method is run via `Glib.add_idle` to update the progressbar.""" | ||
| 149 | try: | ||
| 150 | fname = processing_queue.get(block=False) | ||
| 151 | except queue.Empty: | ||
| 152 | return True | ||
| 153 | |||
| 154 | # `None` is the marker put in the queue to signal that every selected | ||
| 155 | # file was processed. | ||
| 156 | if fname is None: | ||
| 157 | self.infobar_hbox.destroy() | ||
| 158 | self.infobar.hide() | ||
| 159 | if len(self.failed_items): | ||
| 160 | self.__infobar_failure() | ||
| 161 | if not processing_queue.empty(): | ||
| 162 | print("Something went wrong, the queue isn't empty :/") | ||
| 163 | return False | ||
| 164 | |||
| 165 | progressbar.pulse() | ||
| 166 | progressbar.set_text("Cleaning %s" % fname) | ||
| 167 | progressbar.show_all() | ||
| 168 | self.infobar_hbox.show_all() | ||
| 169 | self.infobar.show_all() | ||
| 170 | return True | ||
| 171 | |||
| 172 | def __clean_files(self, files, processing_queue): | ||
| 173 | """ This method is threaded in order to avoid blocking the GUI | ||
| 174 | while cleaning up the files. | ||
| 175 | """ | ||
| 176 | for fileinfo in files: | ||
| 177 | fname = fileinfo.get_name() | ||
| 178 | processing_queue.put(fname) | ||
| 179 | |||
| 180 | if not self.__validate(fileinfo): | ||
| 181 | self.failed_items.append((fname, None)) | ||
| 182 | continue | ||
| 183 | |||
| 184 | fpath = unquote(fileinfo.get_uri()[7:]) # `len('file://') = 7` | ||
| 185 | success, mtype = _remove_metadata(fpath) | ||
| 186 | if not success: | ||
| 187 | self.failed_items.append((fname, mtype)) | ||
| 188 | processing_queue.put(None) # signal that we processed all the files | ||
| 189 | return True | ||
| 190 | |||
| 191 | |||
| 100 | def __cb_menu_activate(self, menu, files): | 192 | def __cb_menu_activate(self, menu, files): |
| 193 | """ This method is called when the user clicked the "clean metadata" | ||
| 194 | menu item. | ||
| 195 | """ | ||
| 101 | self.failed_items = list() | 196 | self.failed_items = list() |
| 102 | for f in files: | 197 | progressbar = self.__create_progressbar() |
| 103 | if not self.__validate(f): | 198 | progressbar.set_pulse_step = 1.0 / len(files) |
| 104 | self.failed_items.append((f.get_name(), None)) | 199 | self.infobar.show_all() |
| 105 | continue | 200 | |
| 201 | processing_queue = queue.Queue() | ||
| 202 | GLib.idle_add(self.__update_progressbar, processing_queue, progressbar) | ||
| 106 | 203 | ||
| 107 | fname = unquote(f.get_uri()[7:]) | 204 | thread = threading.Thread(target=self.__clean_files, args=(files, processing_queue)) |
| 108 | ret, mtype = Mat2Wrapper(fname).remove_metadata() | 205 | thread.daemon = True |
| 109 | if not ret: | 206 | thread.start() |
| 110 | self.failed_items.append((f.get_name(), mtype)) | ||
| 111 | 207 | ||
| 112 | if len(self.failed_items): | ||
| 113 | self.notify() | ||
| 114 | 208 | ||
| 115 | def get_background_items(self, window, file): | 209 | def get_background_items(self, window, file): |
| 116 | """ https://bugzilla.gnome.org/show_bug.cgi?id=784278 """ | 210 | """ https://bugzilla.gnome.org/show_bug.cgi?id=784278 """ |
| 117 | return None | 211 | return None |
| 118 | 212 | ||
| 119 | def get_file_items(self, window, files): | 213 | def get_file_items(self, window, files): |
| 214 | """ This method is the one allowing us to create a menu item. | ||
| 215 | """ | ||
| 120 | # Do not show the menu item if not a single file has a chance to be | 216 | # Do not show the menu item if not a single file has a chance to be |
| 121 | # processed by mat2. | 217 | # processed by mat2. |
| 122 | if not any(map(self.__validate, files)): | 218 | if not any(map(self.__validate, files)): |
| 123 | return | 219 | return None |
| 124 | 220 | ||
| 125 | item = Nautilus.MenuItem( | 221 | item = Nautilus.MenuItem( |
| 126 | name="MAT2::Remove_metadata", | 222 | name="MAT2::Remove_metadata", |
| @@ -129,4 +225,4 @@ class ColumnExtension(GObject.GObject, Nautilus.MenuProvider, Nautilus.LocationW | |||
| 129 | ) | 225 | ) |
| 130 | item.connect('activate', self.__cb_menu_activate, files) | 226 | item.connect('activate', self.__cb_menu_activate, files) |
| 131 | 227 | ||
| 132 | return [item] | 228 | return [item, ] |
