diff options
Diffstat (limited to 'nautilus/mat2.py')
| -rw-r--r-- | nautilus/mat2.py | 251 |
1 files changed, 251 insertions, 0 deletions
diff --git a/nautilus/mat2.py b/nautilus/mat2.py new file mode 100644 index 0000000..133c56d --- /dev/null +++ b/nautilus/mat2.py | |||
| @@ -0,0 +1,251 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | |||
| 3 | """ | ||
| 4 | Because writing GUI is non-trivial (cf. https://0xacab.org/jvoisin/mat2/issues/3), | ||
| 5 | we decided to write a Nautilus extensions instead | ||
| 6 | (cf. https://0xacab.org/jvoisin/mat2/issues/2). | ||
| 7 | |||
| 8 | The code is a little bit convoluted because Gtk isn't thread-safe, | ||
| 9 | so we're not allowed to call anything Gtk-related outside of the main | ||
| 10 | thread, so we'll have to resort to using a `queue` to pass "messages" around. | ||
| 11 | """ | ||
| 12 | |||
| 13 | # pylint: disable=no-name-in-module,unused-argument,no-self-use,import-error | ||
| 14 | |||
| 15 | import queue | ||
| 16 | import threading | ||
| 17 | from typing import Tuple | ||
| 18 | from urllib.parse import unquote | ||
| 19 | |||
| 20 | import gi | ||
| 21 | gi.require_version('Nautilus', '3.0') | ||
| 22 | gi.require_version('Gtk', '3.0') | ||
| 23 | gi.require_version('GdkPixbuf', '2.0') | ||
| 24 | from gi.repository import Nautilus, GObject, Gtk, Gio, GLib, GdkPixbuf | ||
| 25 | |||
| 26 | from libmat2 import parser_factory | ||
| 27 | |||
| 28 | # make pyflakes happy | ||
| 29 | assert Tuple | ||
| 30 | |||
| 31 | def _remove_metadata(fpath): | ||
| 32 | """ This is a simple wrapper around libmat2, because it's | ||
| 33 | easier and cleaner this way. | ||
| 34 | """ | ||
| 35 | parser, mtype = parser_factory.get_parser(fpath) | ||
| 36 | if parser is None: | ||
| 37 | return False, mtype | ||
| 38 | return parser.remove_all(), mtype | ||
| 39 | |||
| 40 | class ColumnExtension(GObject.GObject, Nautilus.MenuProvider, Nautilus.LocationWidgetProvider): | ||
| 41 | """ This class adds an item to the right-clic menu in Nautilus. """ | ||
| 42 | |||
| 43 | def __init__(self): | ||
| 44 | super().__init__() | ||
| 45 | self.infobar_hbox = None | ||
| 46 | self.infobar = None | ||
| 47 | self.failed_items = list() | ||
| 48 | |||
| 49 | def __infobar_failure(self): | ||
| 50 | """ Add an hbox to the `infobar` warning about the fact that we didn't | ||
| 51 | manage to remove the metadata from every single file. | ||
| 52 | """ | ||
| 53 | self.infobar.set_show_close_button(True) | ||
| 54 | self.infobar_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) | ||
| 55 | |||
| 56 | btn = Gtk.Button("Show") | ||
| 57 | btn.connect("clicked", self.__cb_show_failed) | ||
| 58 | self.infobar_hbox.pack_end(btn, False, False, 0) | ||
| 59 | |||
| 60 | infobar_msg = Gtk.Label("Failed to clean some items") | ||
| 61 | self.infobar_hbox.pack_start(infobar_msg, False, False, 0) | ||
| 62 | |||
| 63 | self.infobar.get_content_area().pack_start(self.infobar_hbox, True, True, 0) | ||
| 64 | self.infobar.show_all() | ||
| 65 | |||
| 66 | def get_widget(self, uri, window): | ||
| 67 | """ This is the method that we have to implement (because we're | ||
| 68 | a LocationWidgetProvider) in order to show our infobar. | ||
| 69 | """ | ||
| 70 | self.infobar = Gtk.InfoBar() | ||
| 71 | self.infobar.set_message_type(Gtk.MessageType.ERROR) | ||
| 72 | self.infobar.connect("response", self.__cb_infobar_response) | ||
| 73 | |||
| 74 | return self.infobar | ||
| 75 | |||
| 76 | def __cb_infobar_response(self, infobar, response): | ||
| 77 | """ Callback for the infobar close button. | ||
| 78 | """ | ||
| 79 | if response == Gtk.ResponseType.CLOSE: | ||
| 80 | self.infobar_hbox.destroy() | ||
| 81 | self.infobar.hide() | ||
| 82 | |||
| 83 | def __cb_show_failed(self, button): | ||
| 84 | """ Callback to show a popup containing a list of files | ||
| 85 | that we didn't manage to clean. | ||
| 86 | """ | ||
| 87 | |||
| 88 | # FIXME this should be done only once the window is destroyed | ||
| 89 | self.infobar_hbox.destroy() | ||
| 90 | self.infobar.hide() | ||
| 91 | |||
| 92 | window = Gtk.Window() | ||
| 93 | headerbar = Gtk.HeaderBar() | ||
| 94 | window.set_titlebar(headerbar) | ||
| 95 | headerbar.props.title = "Metadata removal failed" | ||
| 96 | |||
| 97 | close_buton = Gtk.Button("Close") | ||
| 98 | close_buton.connect("clicked", lambda _: window.close()) | ||
| 99 | headerbar.pack_end(close_buton) | ||
| 100 | |||
| 101 | box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) | ||
| 102 | window.add(box) | ||
| 103 | |||
| 104 | box.add(self.__create_treeview()) | ||
| 105 | window.show_all() | ||
| 106 | |||
| 107 | |||
| 108 | @staticmethod | ||
| 109 | def __validate(fileinfo) -> Tuple[bool, str]: | ||
| 110 | """ Validate if a given file FileInfo `fileinfo` can be processed. | ||
| 111 | Returns a boolean, and a textreason why""" | ||
| 112 | if fileinfo.get_uri_scheme() != "file" or fileinfo.is_directory(): | ||
| 113 | return False, "Not a file" | ||
| 114 | elif not fileinfo.can_write(): | ||
| 115 | return False, "Not writeable" | ||
| 116 | return True, "" | ||
| 117 | |||
| 118 | |||
| 119 | def __create_treeview(self) -> Gtk.TreeView: | ||
| 120 | liststore = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str) | ||
| 121 | treeview = Gtk.TreeView(model=liststore) | ||
| 122 | |||
| 123 | renderer_pixbuf = Gtk.CellRendererPixbuf() | ||
| 124 | column_pixbuf = Gtk.TreeViewColumn("Icon", renderer_pixbuf, pixbuf=0) | ||
| 125 | treeview.append_column(column_pixbuf) | ||
| 126 | |||
| 127 | for idx, name in enumerate(['File', 'Reason']): | ||
| 128 | renderer_text = Gtk.CellRendererText() | ||
| 129 | column_text = Gtk.TreeViewColumn(name, renderer_text, text=idx+1) | ||
| 130 | treeview.append_column(column_text) | ||
| 131 | |||
| 132 | for (fname, mtype, reason) in self.failed_items: | ||
| 133 | # This part is all about adding mimetype icons to the liststore | ||
| 134 | icon = Gio.content_type_get_icon('text/plain' if not mtype else mtype) | ||
| 135 | # in case we don't have the corresponding icon, | ||
| 136 | # we're adding `text/plain`, because we have this one for sure⢠| ||
| 137 | names = icon.get_names() + ['text/plain', ] | ||
| 138 | icon_theme = Gtk.IconTheme.get_default() | ||
| 139 | for name in names: | ||
| 140 | try: | ||
| 141 | img = icon_theme.load_icon(name, Gtk.IconSize.BUTTON, 0) | ||
| 142 | break | ||
| 143 | except GLib.GError: | ||
| 144 | pass | ||
| 145 | |||
| 146 | liststore.append([img, fname, reason]) | ||
| 147 | |||
| 148 | treeview.show_all() | ||
| 149 | return treeview | ||
| 150 | |||
| 151 | |||
| 152 | def __create_progressbar(self) -> Gtk.ProgressBar: | ||
| 153 | """ Create the progressbar used to notify that files are currently | ||
| 154 | being processed. | ||
| 155 | """ | ||
| 156 | self.infobar.set_show_close_button(False) | ||
| 157 | self.infobar.set_message_type(Gtk.MessageType.INFO) | ||
| 158 | self.infobar_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) | ||
| 159 | |||
| 160 | progressbar = Gtk.ProgressBar() | ||
| 161 | self.infobar_hbox.pack_start(progressbar, True, True, 0) | ||
| 162 | progressbar.set_show_text(True) | ||
| 163 | |||
| 164 | self.infobar.get_content_area().pack_start(self.infobar_hbox, True, True, 0) | ||
| 165 | self.infobar.show_all() | ||
| 166 | |||
| 167 | return progressbar | ||
| 168 | |||
| 169 | def __update_progressbar(self, processing_queue, progressbar) -> bool: | ||
| 170 | """ This method is run via `Glib.add_idle` to update the progressbar.""" | ||
| 171 | try: | ||
| 172 | fname = processing_queue.get(block=False) | ||
| 173 | except queue.Empty: | ||
| 174 | return True | ||
| 175 | |||
| 176 | # `None` is the marker put in the queue to signal that every selected | ||
| 177 | # file was processed. | ||
| 178 | if fname is None: | ||
| 179 | self.infobar_hbox.destroy() | ||
| 180 | self.infobar.hide() | ||
| 181 | if len(self.failed_items): | ||
| 182 | self.__infobar_failure() | ||
| 183 | if not processing_queue.empty(): | ||
| 184 | print("Something went wrong, the queue isn't empty :/") | ||
| 185 | return False | ||
| 186 | |||
| 187 | progressbar.pulse() | ||
| 188 | progressbar.set_text("Cleaning %s" % fname) | ||
| 189 | progressbar.show_all() | ||
| 190 | self.infobar_hbox.show_all() | ||
| 191 | self.infobar.show_all() | ||
| 192 | return True | ||
| 193 | |||
| 194 | def __clean_files(self, files: list, processing_queue: queue.Queue) -> bool: | ||
| 195 | """ This method is threaded in order to avoid blocking the GUI | ||
| 196 | while cleaning up the files. | ||
| 197 | """ | ||
| 198 | for fileinfo in files: | ||
| 199 | fname = fileinfo.get_name() | ||
| 200 | processing_queue.put(fname) | ||
| 201 | |||
| 202 | valid, reason = self.__validate(fileinfo) | ||
| 203 | if not valid: | ||
| 204 | self.failed_items.append((fname, None, reason)) | ||
| 205 | continue | ||
| 206 | |||
| 207 | fpath = unquote(fileinfo.get_uri()[7:]) # `len('file://') = 7` | ||
| 208 | success, mtype = _remove_metadata(fpath) | ||
| 209 | if not success: | ||
| 210 | self.failed_items.append((fname, mtype, 'Unsupported/invalid')) | ||
| 211 | processing_queue.put(None) # signal that we processed all the files | ||
| 212 | return True | ||
| 213 | |||
| 214 | |||
| 215 | def __cb_menu_activate(self, menu, files): | ||
| 216 | """ This method is called when the user clicked the "clean metadata" | ||
| 217 | menu item. | ||
| 218 | """ | ||
| 219 | self.failed_items = list() | ||
| 220 | progressbar = self.__create_progressbar() | ||
| 221 | progressbar.set_pulse_step = 1.0 / len(files) | ||
| 222 | self.infobar.show_all() | ||
| 223 | |||
| 224 | processing_queue = queue.Queue() | ||
| 225 | GLib.idle_add(self.__update_progressbar, processing_queue, progressbar) | ||
| 226 | |||
| 227 | thread = threading.Thread(target=self.__clean_files, args=(files, processing_queue)) | ||
| 228 | thread.daemon = True | ||
| 229 | thread.start() | ||
| 230 | |||
| 231 | |||
| 232 | def get_background_items(self, window, file): | ||
| 233 | """ https://bugzilla.gnome.org/show_bug.cgi?id=784278 """ | ||
| 234 | return None | ||
| 235 | |||
| 236 | def get_file_items(self, window, files): | ||
| 237 | """ This method is the one allowing us to create a menu item. | ||
| 238 | """ | ||
| 239 | # Do not show the menu item if not a single file has a chance to be | ||
| 240 | # processed by mat2. | ||
| 241 | if not any([is_valid for (is_valid, _) in map(self.__validate, files)]): | ||
| 242 | return None | ||
| 243 | |||
| 244 | item = Nautilus.MenuItem( | ||
| 245 | name="MAT2::Remove_metadata", | ||
| 246 | label="Remove metadata", | ||
| 247 | tip="Remove metadata" | ||
| 248 | ) | ||
| 249 | item.connect('activate', self.__cb_menu_activate, files) | ||
| 250 | |||
| 251 | return [item, ] | ||
