1#!/usr/bin/env python 2# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- 3# Catfish - a versatile file searching tool 4# Copyright (C) 2007-2012 Christian Dywan <christian@twotoasts.de> 5# Copyright (C) 2012-2020 Sean Davis <bluesabre@xfce.org> 6# 7# This program is free software: you can redistribute it and/or modify it 8# under the terms of the GNU General Public License version 2, as published 9# by the Free Software Foundation. 10# 11# This program is distributed in the hope that it will be useful, but 12# WITHOUT ANY WARRANTY; without even the implied warranties of 13# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR 14# PURPOSE. See the GNU General Public License for more details. 15# 16# You should have received a copy of the GNU General Public License along 17# with this program. If not, see <https://www.gnu.org/licenses/>. 18 19# pylint: disable=W0201 20# pylint: disable=C0103 21# pylint: disable=C0114 22# pylint: disable=C0116 23# pylint: disable=C0413 24 25 26import datetime 27import logging 28import mimetypes 29import os 30import subprocess 31import time 32import zipfile 33import tempfile 34from locale import gettext as _ 35from shutil import copy2, rmtree 36from xml.sax.saxutils import escape 37 38# Thunar Integration 39import urllib 40import dbus 41 42import pexpect 43import gi 44gi.require_version('GLib', '2.0') # noqa 45gi.require_version('GObject', '2.0') # noqa 46gi.require_version('Pango', '1.0') # noqa 47gi.require_version('Gdk', '3.0') # noqa 48gi.require_version('GdkPixbuf', '2.0') # noqa 49gi.require_version('Gtk', '3.0') # noqa 50from gi.repository import GLib, GObject, Pango, Gdk, GdkPixbuf, Gtk, Gio 51 52from catfish.CatfishPrefsDialog import CatfishPrefsDialog 53from catfish.CatfishSearchEngine import CatfishSearchEngine, get_keyword_list 54from catfish_lib import catfishconfig, helpers, get_about 55from catfish_lib import CatfishSettings, SudoDialog, Window 56from catfish_lib import Thumbnailer 57 58LOGGER = logging.getLogger('catfish') 59 60 61# Initialize Gtk, GObject, and mimetypes 62if not helpers.check_gobject_version(3, 9, 1): 63 GObject.threads_init() 64 GLib.threads_init() 65mimetypes.init() 66 67 68def long(value): 69 return int(value) 70 71 72def get_application_path(application_name): 73 for path in os.getenv('PATH').split(':'): 74 if os.path.isdir(path): 75 if application_name in os.listdir(path): 76 return os.path.join(path, application_name) 77 return None 78 79 80def application_in_PATH(application_name): 81 """Return True if the application name is found in PATH.""" 82 return get_application_path(application_name) is not None 83 84 85def is_file_hidden(folder, filename): 86 """Return TRUE if file is hidden or in a hidden directory.""" 87 folder = os.path.abspath(folder) 88 filename = os.path.abspath(filename) 89 relpath = os.path.relpath(filename, folder) 90 for piece in relpath.split(os.sep): 91 if piece.startswith("."): 92 return True 93 return False 94 95 96def surrogate_escape(text, replace=False): 97 """Replace non-UTF8 characters with something displayable. 98 If replace is True, display (invalid encoding) after the text.""" 99 try: 100 text.encode('utf-8') 101 except UnicodeEncodeError: 102 text = text.encode('utf-8', errors='surrogateescape').decode( 103 'utf-8', errors='replace') 104 if replace: 105 # Translators: this text is displayed next to 106 # a filename that is not utf-8 encoded. 107 text = _("%s (invalid encoding)") % text 108 except UnicodeDecodeError: 109 text = text.decode('utf-8', errors='replace') 110 return text 111 112 113# See catfish_lib.Window.py for more details about how this class works 114class CatfishWindow(Window): 115 116 """The application window.""" 117 __gtype_name__ = "CatfishWindow" 118 119 filter_timerange = (0.0, 9999999999.0) 120 start_date = datetime.datetime.now() 121 end_date = datetime.datetime.now() 122 123 filter_formats = {'documents': False, 'folders': False, 'images': False, 124 'music': False, 'videos': False, 'applications': False, 125 'other': False, 'exact': False, 'hidden': False, 126 'fulltext': False} 127 128 filter_custom_extensions = [] 129 filter_custom_use_mimetype = False 130 131 mimetypes = dict() 132 search_in_progress = False 133 134 def get_about_dialog(self): 135 about = get_about() 136 137 dlg = GObject.new(Gtk.AboutDialog, use_header_bar=True) 138 dlg.set_program_name(about['program_name']) 139 dlg.set_version(about['version']) 140 dlg.set_logo_icon_name(about['icon_name']) 141 dlg.set_website(about['website']) 142 dlg.set_comments(about['comments']) 143 dlg.set_license_type(Gtk.License.GPL_2_0) 144 dlg.set_copyright(about['copyright']) 145 dlg.set_authors(about['authors']) 146 dlg.set_artists(about['artists']) 147 dlg.set_translator_credits(_("translator-credits")) 148 dlg.set_transient_for(self) 149 150 # Cleanup duplicate buttons 151 hbar = dlg.get_header_bar() 152 for child in hbar.get_children(): 153 if type(child) in [Gtk.Button, Gtk.ToggleButton]: 154 child.destroy() 155 156 return dlg 157 158 def finish_initializing(self, builder): 159 """Set up the main window""" 160 super(CatfishWindow, self).finish_initializing(builder) 161 162 self.AboutDialog = self.get_about_dialog 163 164 self.settings = CatfishSettings.CatfishSettings() 165 166 # -- Folder Chooser Combobox -- # 167 self.folderchooser = builder.get_named_object("toolbar.folderchooser") 168 169 # -- Search Entry and Completion -- # 170 self.search_entry = builder.get_named_object("toolbar.search") 171 self.suggestions_engine = CatfishSearchEngine(['zeitgeist']) 172 completion = Gtk.EntryCompletion() 173 self.search_entry.set_completion(completion) 174 listmodel = Gtk.ListStore(str) 175 completion.set_model(listmodel) 176 completion.set_text_column(0) 177 178 # -- App Menu -- # 179 self.exact_match = builder.get_named_object("menus.application.exact") 180 self.hidden_files = builder.get_named_object( 181 "menus.application.hidden") 182 self.fulltext = builder.get_named_object("menus.application.fulltext") 183 self.sidebar_toggle_menu = builder.get_named_object( 184 "menus.application.advanced") 185 186 # -- Sidebar -- # 187 css = Gtk.CssProvider() 188 css.load_from_data(b".sidebar .view {background-color: transparent;}") 189 screen = Gdk.Screen.get_default() 190 style = Gtk.StyleContext() 191 style.add_provider_for_screen( 192 screen, css, Gtk.STYLE_PROVIDER_PRIORITY_SETTINGS) 193 self.button_time_custom = builder.get_named_object( 194 "sidebar.modified.options") 195 self.button_format_custom = builder.get_named_object( 196 "sidebar.filetype.options") 197 198 # -- Status Bar -- * 199 # Create a new GtkOverlay to hold the 200 # results list and Overlay Statusbar 201 overlay = Gtk.Overlay() 202 203 # Move the results list to the overlay and 204 # place the overlay in the window 205 scrolledwindow = builder.get_named_object("results.scrolled_window") 206 parent = scrolledwindow.get_parent() 207 parent.remove(scrolledwindow) 208 overlay.add(scrolledwindow) 209 parent.add(overlay) 210 overlay.show() 211 212 # Create the overlay statusbar 213 self.statusbar = Gtk.EventBox() 214 self.statusbar.set_margin_start(2) 215 self.statusbar.set_margin_end(3) 216 self.statusbar.set_margin_bottom(3) 217 self.statusbar.get_style_context().add_class("frame") 218 self.statusbar.get_style_context().add_class("background") 219 self.statusbar.get_style_context().add_class("floating-bar") 220 self.statusbar.connect("draw", self.on_floating_bar_draw) 221 self.statusbar.connect("enter-notify-event", 222 self.on_floating_bar_enter_notify) 223 self.statusbar.set_halign(Gtk.Align.END) 224 self.statusbar.set_valign(Gtk.Align.END) 225 226 # Put the statusbar in the overlay 227 overlay.add_overlay(self.statusbar) 228 229 # Pack the spinner and label 230 self.spinner = Gtk.Spinner() 231 self.spinner.start() 232 self.statusbar_label = Gtk.Label() 233 self.statusbar_label.show() 234 235 box = Gtk.Box() 236 box.set_orientation(Gtk.Orientation.HORIZONTAL) 237 box.pack_start(self.spinner, False, False, 0) 238 box.pack_start(self.statusbar_label, False, False, 0) 239 box.set_margin_start(8) 240 box.set_margin_top(3) 241 box.set_margin_end(8) 242 box.set_margin_bottom(3) 243 self.spinner.set_margin_end(3) 244 box.show() 245 246 self.statusbar.add(box) 247 self.statusbar.set_halign(Gtk.Align.END) 248 self.statusbar.hide() 249 250 self.list_toggle = builder.get_named_object("toolbar.view.list") 251 self.thumbnail_toggle = builder.get_named_object("toolbar.view.thumbs") 252 253 # -- Treeview -- # 254 self.treeview = builder.get_named_object("results.treeview") 255 self.treeview.enable_model_drag_source( 256 Gdk.ModifierType.BUTTON1_MASK, 257 [('text/plain', Gtk.TargetFlags.OTHER_APP, 0), 258 ('text/uri-list', Gtk.TargetFlags.OTHER_APP, 0)], 259 Gdk.DragAction.DEFAULT | Gdk.DragAction.COPY) 260 self.treeview.drag_source_add_text_targets() 261 self.file_menu = builder.get_named_object("menus.file.menu") 262 self.file_menu_save = builder.get_named_object("menus.file.save") 263 self.file_menu_delete = builder.get_named_object("menus.file.delete") 264 self.treeview_click_on = False 265 266 # -- Update Search Index Dialog -- # 267 menuitem = builder.get_named_object("menus.application.update") 268 if SudoDialog.check_dependencies(['locate', 'updatedb']): 269 self.update_index_dialog = \ 270 builder.get_named_object("dialogs.update.dialog") 271 self.update_index_database = \ 272 builder.get_named_object("dialogs.update.database_label") 273 self.update_index_modified = \ 274 builder.get_named_object("dialogs.update.modified_label") 275 self.update_index_infobar = \ 276 builder.get_named_object("dialogs.update.status_infobar") 277 self.update_index_icon = \ 278 builder.get_named_object("dialogs.update.status_icon") 279 self.update_index_label = \ 280 builder.get_named_object("dialogs.update.status_label") 281 self.update_index_close = \ 282 builder.get_named_object("dialogs.update.close_button") 283 self.update_index_unlock = \ 284 builder.get_named_object("dialogs.update.unlock_button") 285 self.update_index_active = False 286 287 self.last_modified = 0 288 289 now = datetime.datetime.now() 290 self.today = datetime.datetime(now.year, now.month, now.day) 291 locate_path, locate_date = self.check_locate()[1:3] 292 293 self.update_index_database.set_label("<tt>%s</tt>" % locate_path) 294 if not os.access(os.path.dirname(locate_path), os.R_OK): 295 modified = _("Unknown") 296 elif os.path.isfile(locate_path): 297 modified = locate_date.strftime("%x %X") 298 else: 299 modified = _("Never") 300 self.update_index_modified.set_label("<tt>%s</tt>" % modified) 301 302 if locate_date < self.today - datetime.timedelta(days=7): 303 infobar = builder.get_named_object("infobar.infobar") 304 infobar.show() 305 else: 306 menuitem.hide() 307 308 self.format_mimetype_box = \ 309 builder.get_named_object("dialogs.filetype.mimetypes.box") 310 self.extensions_entry = \ 311 builder.get_named_object("dialogs.filetype.extensions.entry") 312 313 self.search_engine = CatfishSearchEngine( 314 ['zeitgeist', 'locate', 'walk'], 315 self.settings.get_setting("exclude-paths")) 316 317 self.icon_theme = Gtk.IconTheme.get_default() 318 self.icon_theme.connect('changed', self.changed_icon) 319 self.changed_icon_theme = False 320 self.selected_filenames = [] 321 self.rows = [] 322 323 paned = builder.get_named_object("window.paned") 324 paned.set_property('height_request', 325 self.settings.get_setting('window-height')) 326 paned.set_property('width_request', 327 self.settings.get_setting('window-width')) 328 329 window_width = self.settings.get_setting('window-width') 330 window_height = self.settings.get_setting('window-height') 331 window_x = self.settings.get_setting('window-x') 332 window_y = self.settings.get_setting('window-y') 333 (screen_width, screen_height) = self.get_screen_size() 334 (display_width, display_height) = self.get_display_size() 335 336 if (screen_width, screen_height) == (-1, -1) or \ 337 (display_width, display_height) == (-1, -1): 338 # Failed detection, likely using Wayland, don't resize 339 pass 340 else: 341 if (window_height > screen_height or window_width > screen_width): 342 window_width = min(display_width, 650) 343 window_height = min(display_height, 470) 344 345 paned.set_property('height_request', window_height) 346 paned.set_property('width_request', window_width) 347 348 if (window_x >= 0 and window_y >= 0): 349 if (window_x + window_width <= screen_width) and \ 350 (window_y + window_height <= screen_height): 351 self.move(window_x, window_y) 352 353 self.refresh_search_entry() 354 355 filetype_filters = builder.get_object("filetype_options") 356 filetype_filters.connect( 357 "row-activated", self.on_file_filters_changed, builder) 358 359 modified_filters = builder.get_object("modified_options") 360 modified_filters.connect( 361 "row-activated", self.on_modified_filters_changed, builder) 362 363 self.popovers = dict() 364 365 extension_filter = builder.get_object("filter_extensions") 366 extension_filter.connect( 367 "search-changed", self.on_filter_extensions_changed) 368 369 start_calendar = self.builder.get_named_object( 370 "dialogs.date.start_calendar") 371 end_calendar = self.builder.get_named_object( 372 "dialogs.date.end_calendar") 373 start_calendar.connect("day-selected", self.on_calendar_day_changed) 374 end_calendar.connect("day-selected", self.on_calendar_day_changed) 375 376 self.app_menu_event = False 377 378 self.thumbnailer = Thumbnailer.Thumbnailer() 379 self.configure_welcome_area(builder) 380 self.add_mimetypes() 381 self.tmpdir = tempfile.TemporaryDirectory(prefix='catfish-') 382 self.toolbar_hotkeys() 383 384 def add_mimetypes(self): 385 """Copies MIME info generated by update-mime-database (from 386 shared-mime-info) to Python's mimetypes module enabling it 387 to match local MIME db results (xdg-mime from xdg-utils).""" 388 389 glob2 = '/usr/share/mime/globs2' 390 if not os.path.exists(glob2): 391 return 392 393 with open("/usr/share/mime/globs2") as f: 394 lines = f.readlines() 395 for line in reversed(lines): 396 try: 397 if ':*.' in line: 398 s = line[3:].strip().split(':*') 399 elif ':' in line: 400 s = line[3:].strip('.*/\n').split(':') 401 else: 402 continue 403 mimetypes.add_type(s[0], s[1], strict=True) 404 except IndexError: 405 continue 406 407 def configure_welcome_area(self, builder): 408 welcome_area = builder.get_object("welcome_area") 409 content = _("Enter your query above to find your files\n" 410 "or click the %s icon for more options.") 411 for line in content.split("\n"): 412 if "%s" in line: 413 row = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0) 414 parts = line.split("%s") 415 if len(parts[0].strip()) > 0: 416 label = Gtk.Label.new(parts[0]) 417 row.pack_start(label, False, False, 0) 418 image = Gtk.Image.new_from_icon_name("open-menu-symbolic", 419 Gtk.IconSize.BUTTON) 420 image.set_property("use-fallback", True) 421 row.pack_start(image, False, False, 0) 422 if len(parts[1].strip()) > 0: 423 label = Gtk.Label.new(parts[1]) 424 row.pack_start(label, False, False, 0) 425 else: 426 row = Gtk.Label.new(line) 427 row.set_halign(Gtk.Align.CENTER) 428 welcome_area.pack_start(row, False, False, 0) 429 welcome_area.show_all() 430 431 def get_screen_size(self): 432 screen = Gdk.Screen.get_default() 433 if screen is None: 434 return (-1, -1) 435 return (screen.width(), screen.height()) 436 437 def get_display_size(self): 438 display = Gdk.Display.get_default() 439 if display is None: 440 return (-1, -1) 441 window = Gdk.get_default_root_window() 442 mon = display.get_monitor_at_window(window) 443 monitor = mon.get_geometry() 444 return (monitor.width, monitor.height) 445 446 def on_calendar_day_changed(self, widget): # pylint: disable=W0613 447 start_calendar = self.builder.get_named_object( 448 "dialogs.date.start_calendar") 449 end_calendar = self.builder.get_named_object( 450 "dialogs.date.end_calendar") 451 452 start_date = start_calendar.get_date() 453 self.start_date = datetime.datetime(start_date[0], start_date[1] + 1, 454 start_date[2]) 455 456 end_date = end_calendar.get_date() 457 self.end_date = datetime.datetime(end_date[0], end_date[1] + 1, 458 end_date[2]) 459 self.end_date = self.end_date + datetime.timedelta(days=1, seconds=-1) 460 461 self.filter_timerange = (time.mktime(self.start_date.timetuple()), 462 time.mktime(self.end_date.timetuple())) 463 464 self.refilter() 465 466 def on_application_menu_row_activated(self, listbox, row): 467 self.app_menu_event = not self.app_menu_event 468 if not self.app_menu_event: 469 return 470 if listbox.get_row_at_index(8) == row: 471 listbox.get_parent().hide() 472 self.on_menu_update_index_activate(row) 473 if listbox.get_row_at_index(10) == row: 474 listbox.get_parent().hide() 475 self.on_menu_preferences_activate(row) 476 if listbox.get_row_at_index(11) == row: 477 listbox.get_parent().hide() 478 self.on_mnu_about_activate(row) 479 480 def on_file_filters_changed(self, treeview, path, column, builder): 481 model = treeview.get_model() 482 treeiter = model.get_iter(path) 483 row = model[treeiter] 484 showPopup = row[2] == "other" and row[5] == 0 485 if treeview.get_column(2) == column: 486 if row[5]: 487 popover = self.get_popover(row[2], builder) 488 popover.show_all() 489 return 490 row[3], row[4] = row[4], row[3] 491 else: 492 row[3], row[4] = row[4], row[3] 493 if row[2] == 'other' or row[2] == 'custom': 494 row[5] = row[3] 495 if showPopup and row[5]: 496 popover = self.get_popover(row[2], builder) 497 popover.show_all() 498 self.filter_formats[row[2]] = row[3] 499 self.refilter() 500 501 def get_popover(self, name, builder): 502 if name == "other": 503 popover_id = "filetype" 504 elif name == "custom": 505 popover_id = "modified" 506 else: 507 return False 508 if popover_id not in self.popovers.keys(): 509 builder.get_object(popover_id + "_popover") 510 popover = Gtk.Popover.new() 511 popover.connect("destroy", self.popover_content_destroy) 512 popover.add(builder.get_object(popover_id + "_popover")) 513 popover.set_relative_to(builder.get_object(name + "_helper")) 514 popover.set_position(Gtk.PositionType.BOTTOM) 515 self.popovers[popover_id] = popover 516 return self.popovers[popover_id] 517 518 def popover_content_destroy(self, widget): 519 widget.hide() 520 return False 521 522 def on_modified_filters_changed(self, treeview, path, column, builder): 523 model = treeview.get_model() 524 treeiter = model.get_iter(path) 525 selected = model[treeiter] 526 showPopup = selected[2] == "custom" and selected[5] == 0 527 treeiter = model.get_iter_first() 528 while treeiter: 529 row = model[treeiter] 530 row[3], row[4], row[5] = 0, 1, 0 531 treeiter = model.iter_next(treeiter) 532 selected[3], selected[4] = 1, 0 533 if selected[2] == "custom": 534 selected[5] = 1 535 if treeview.get_column(2) == column: 536 if selected[5]: 537 showPopup = True 538 if showPopup: 539 popover = self.get_popover(selected[2], builder) 540 popover.show_all() 541 self.set_modified_range(selected[2]) 542 self.refilter() 543 544 def on_update_infobar_response(self, widget, response_id): 545 if response_id == Gtk.ResponseType.OK: 546 self.on_menu_update_index_activate(widget) 547 widget.hide() 548 549 def on_floating_bar_enter_notify(self, widget, event): # pylint: disable=W0613 550 """Move the floating statusbar when hovered.""" 551 if widget.get_halign() == Gtk.Align.END: 552 widget.hide() 553 widget.set_halign(Gtk.Align.START) 554 widget.show() 555 556 else: 557 widget.hide() 558 widget.set_halign(Gtk.Align.END) 559 widget.show() 560 561 562 def on_floating_bar_draw(self, widget, cairo_t): 563 """Draw the floating statusbar.""" 564 context = widget.get_style_context() 565 566 context.save() 567 context.set_state(widget.get_state_flags()) 568 569 Gtk.render_background(context, cairo_t, 0, 0, 570 widget.get_allocated_width(), 571 widget.get_allocated_height()) 572 573 Gtk.render_frame(context, cairo_t, 0, 0, 574 widget.get_allocated_width(), 575 widget.get_allocated_height()) 576 577 context.restore() 578 579 return False 580 581 def get_path(self, arg): 582 realpath = os.path.realpath(arg) 583 if os.path.isdir(realpath): 584 return realpath 585 realpath = os.path.realpath(os.path.expanduser(arg)) 586 if os.path.isdir(realpath): 587 return realpath 588 return None 589 590 def parse_path_option(self, options, args): # pylint: disable=W0613 591 # Set the selected folder path. Allow legacy --path option. 592 path = None 593 594 # New format, first argument 595 if self.options.path is None: 596 if len(args) > 0: 597 path = self.get_path(args[0]) 598 if path: 599 args.pop(0) 600 601 # Old format, --path 602 else: 603 path = self.get_path(self.options.path) 604 605 # Try the user home directory 606 if path is None: 607 path = self.get_path("~") 608 609 # Once all options are exhausted, return the root 610 if path is None: 611 path = "/" 612 613 return path 614 615 def parse_options(self, options, args): 616 """Parse commandline arguments into Catfish runtime settings.""" 617 self.options = options 618 self.options.path = self.parse_path_option(options, args) 619 620 self.folderchooser.set_filename(self.options.path) 621 622 # Set non-flags as search keywords. 623 self.search_entry.set_text(' '.join(args)) 624 625 # Set the time display format. 626 if self.options.time_iso: 627 self.time_format = '%Y-%m-%d %H:%M' 628 else: 629 self.time_format = None 630 631 # Set search defaults. 632 self.exact_match.set_active( 633 self.options.exact or 634 self.settings.get_setting('match-results-exactly')) 635 self.hidden_files.set_active( 636 self.options.hidden or 637 self.settings.get_setting('show-hidden-files')) 638 self.fulltext.set_active( 639 self.options.fulltext or 640 self.settings.get_setting('search-file-contents')) 641 self.sidebar_toggle_menu.set_active( 642 self.settings.get_setting('show-sidebar')) 643 644 self.show_thumbnail = self.options.thumbnails 645 646 # Set the interface to standard or preview mode. 647 648 if self.options.icons_large: 649 self.show_thumbnail = False 650 self.setup_large_view() 651 self.list_toggle.set_active(True) 652 elif self.settings.get_setting('show-thumbnails'): 653 self.show_thumbnail = True 654 self.setup_large_view() 655 self.thumbnail_toggle.set_active(True) 656 else: 657 self.show_thumbnail = False 658 self.setup_small_view() 659 self.list_toggle.set_active(True) 660 661 if self.options.start: 662 self.on_search_entry_activate(self.search_entry) 663 664 def preview_cell_data_func(self, col, renderer, model, treeiter, data): # pylint: disable=W0613 665 """Cell Renderer Function for the preview.""" 666 icon_name = model[treeiter][0] 667 fullpath = os.path.join(model[treeiter][3], model[treeiter][1]) 668 emblem_icon = 'emblem-symbolic-link' 669 if os.path.isfile(icon_name): 670 # Load from thumbnail file. 671 if self.show_thumbnail: 672 pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_name) 673 renderer.set_property('pixbuf', pixbuf) 674 return 675 else: 676 mimetype = self.guess_mimetype(fullpath) 677 icon_name = self.get_file_icon(fullpath, mimetype) 678 679 if self.changed_icon_theme: 680 mimetype = self.guess_mimetype(fullpath) 681 icon_name = self.get_file_icon(fullpath, mimetype) 682 683 if os.path.islink(fullpath) and self.icon_theme.has_icon(emblem_icon): 684 pixbuf = self.create_symlink_icon(fullpath, icon_name, emblem_icon) 685 else: 686 pixbuf = self.get_icon_pixbuf(icon_name) 687 688 renderer.set_property('pixbuf', pixbuf) 689 690 def thumbnail_cell_data_func(self, col, renderer, model, treeiter, data): # pylint: disable=W0613 691 """Cell Renderer Function to Thumbnails View.""" 692 name, size, path, modified = model[treeiter][1:5] 693 name = escape(name) 694 size = self.format_size(size) 695 path = escape(path) 696 modified = self.get_date_string(modified) 697 displayed = '<b>%s</b> %s%s%s%s%s' % (name, size, os.linesep, path, 698 os.linesep, modified) 699 renderer.set_property('markup', displayed) 700 701 def load_symbolic_icon(self, icon_name, size, state=Gtk.StateFlags.ACTIVE): 702 """Return the symbolic version of icon_name, or the non-symbolic 703 fallback if unavailable.""" 704 context = self.sidebar.get_style_context() 705 try: 706 icon_lookup_flags = Gtk.IconLookupFlags.FORCE_SVG | \ 707 Gtk.IconLookupFlags.FORCE_SIZE 708 icon_info = self.icon_theme.choose_icon([icon_name + '-symbolic'], 709 size, 710 icon_lookup_flags) 711 color = context.get_color(state) 712 icon = icon_info.load_symbolic(color, color, color, color)[0] 713 except (AttributeError, GLib.GError): 714 icon_lookup_flags = Gtk.IconLookupFlags.FORCE_SVG | \ 715 Gtk.IconLookupFlags.USE_BUILTIN | \ 716 Gtk.IconLookupFlags.GENERIC_FALLBACK | \ 717 Gtk.IconLookupFlags.FORCE_SIZE 718 icon = self.icon_theme.load_icon( 719 icon_name, size, icon_lookup_flags) 720 return icon 721 722 def check_locate(self): 723 """Evaluate which locate binary is in use, its path, and modification 724 date. Return these values in a tuple.""" 725 path = get_application_path('locate') 726 if path is None: 727 return None 728 path = os.path.realpath(path) 729 locate = os.path.basename(path) 730 db = catfishconfig.get_locate_db_path() 731 if not os.access(os.path.dirname(db), os.R_OK): 732 modified = time.time() 733 elif os.path.isfile(db): 734 modified = os.path.getmtime(db) 735 else: 736 modified = 0 737 738 changed = self.last_modified != modified 739 self.last_modified = modified 740 741 item_date = datetime.datetime.fromtimestamp(modified) 742 return (locate, db, item_date, changed) 743 744 def on_filters_changed(self, box, row, user_data=None): # pylint: disable=W0613 745 if row.is_selected(): 746 box.unselect_row(row) 747 else: 748 box.select_row(row) 749 return True 750 751 # -- Update Search Index dialog -- # 752 def on_update_index_dialog_close(self, widget=None, event=None, # pylint: disable=W0613 753 user_data=None): # pylint: disable=W0613 754 """Close the Update Search Index dialog, resetting to default.""" 755 if not self.update_index_active: 756 self.update_index_dialog.hide() 757 758 # Restore Unlock button 759 self.update_index_unlock.show() 760 self.update_index_unlock.set_can_default(True) 761 self.update_index_unlock.set_receives_default(True) 762 self.update_index_unlock.grab_focus() 763 self.update_index_unlock.grab_default() 764 765 # Restore Cancel button 766 self.update_index_close.set_label(_("Cancel")) 767 self.update_index_close.set_can_default(False) 768 self.update_index_close.set_receives_default(False) 769 770 self.update_index_infobar.hide() 771 772 return True 773 774 def show_update_status_infobar(self, status_code): 775 """Display the update status infobar based on the status code.""" 776 # Error 777 if status_code in [1, 3, 127]: 778 icon = "dialog-error" 779 msg_type = Gtk.MessageType.WARNING 780 if status_code == 1: 781 status = _('An error occurred while updating the database.') 782 elif status_code in [3, 127]: 783 status = _("Authentication failed.") 784 785 # Warning 786 elif status_code in [2, 126]: 787 icon = "dialog-warning" 788 msg_type = Gtk.MessageType.WARNING 789 status = _("Authentication cancelled.") 790 791 # Info 792 else: 793 icon = "dialog-information" 794 msg_type = Gtk.MessageType.INFO 795 status = _('Search database updated successfully.') 796 797 self.update_index_infobar.set_message_type(msg_type) 798 self.update_index_icon.set_from_icon_name(icon, Gtk.IconSize.BUTTON) 799 self.update_index_label.set_label(status) 800 801 self.update_index_infobar.show() 802 803 def on_update_index_unlock_clicked(self, widget): # pylint: disable=W0613 804 """Unlock admin rights and perform 'updatedb' query.""" 805 self.update_index_active = True 806 807 # Get the password for sudo 808 if not SudoDialog.prefer_pkexec() and \ 809 not SudoDialog.passwordless_sudo(): 810 sudo_dialog = SudoDialog.SudoDialog( 811 parent=self.update_index_dialog, 812 icon='catfish', 813 name=get_about()['program_name'], 814 retries=3) 815 sudo_dialog.show_all() 816 response = sudo_dialog.run() 817 sudo_dialog.hide() 818 password = sudo_dialog.get_password() 819 sudo_dialog.destroy() 820 821 if response in [Gtk.ResponseType.NONE, Gtk.ResponseType.CANCEL]: 822 self.update_index_active = False 823 self.show_update_status_infobar(2) 824 return False 825 826 if response == Gtk.ResponseType.REJECT: 827 self.update_index_active = False 828 self.show_update_status_infobar(3) 829 return False 830 831 if not password: 832 self.update_index_active = False 833 self.show_update_status_infobar(2) 834 return False 835 836 # Subprocess to check if query has completed yet, runs at end of func. 837 def updatedb_subprocess(): 838 """Subprocess run for the updatedb command.""" 839 try: 840 self.updatedb_process.expect(pexpect.EOF) 841 done = True 842 except pexpect.TIMEOUT: 843 done = False 844 if done: 845 self.update_index_active = False 846 locate_date, changed = self.check_locate()[2:] 847 modified = locate_date.strftime("%x %X") 848 self.update_index_modified.set_label("<tt>%s</tt>" % modified) 849 850 # Hide the Unlock button 851 self.update_index_unlock.set_sensitive(True) 852 self.update_index_unlock.set_receives_default(False) 853 self.update_index_unlock.hide() 854 855 # Update the Cancel button to Close, make it default 856 self.update_index_close.set_label(_("Close")) 857 self.update_index_close.set_sensitive(True) 858 self.update_index_close.set_can_default(True) 859 self.update_index_close.set_receives_default(True) 860 self.update_index_close.grab_focus() 861 self.update_index_close.grab_default() 862 863 return_code = self.updatedb_process.exitstatus 864 if return_code not in [1, 2, 3, 126, 127] and not changed: 865 return_code = 1 866 self.show_update_status_infobar(return_code) 867 return not done 868 869 # Set the dialog status to running. 870 self.update_index_modified.set_label("<tt>%s</tt>" % _("Updating...")) 871 self.update_index_close.set_sensitive(False) 872 self.update_index_unlock.set_sensitive(False) 873 874 if SudoDialog.prefer_pkexec(): 875 if sys.platform.startswith('linux'): 876 self.updatedb_process = SudoDialog.env_spawn('pkexec updatedb', 1) 877 elif 'bsd' in sys.platform or sys.platform.startswith('dragonfly'): 878 self.updatedb_process = SudoDialog.env_spawn('pkexec /usr/libexec/locate.updatedb', 1) 879 else: 880 if sys.platform.startswith('linux'): 881 self.updatedb_process = SudoDialog.env_spawn('sudo updatedb', 1) 882 elif 'bsd' in sys.platform or sys.platform.startswith('dragonfly'): 883 self.updatedb_process = SudoDialog.env_spawn('sudo /usr/libexec/locate.updatedb', 1) 884 try: 885 # Check for password prompt or program exit. 886 self.updatedb_process.expect(".*ssword.*") 887 self.updatedb_process.sendline(password) 888 self.updatedb_process.expect(pexpect.EOF) 889 except pexpect.EOF: 890 # shell already has password, or its not needed 891 pass 892 except pexpect.TIMEOUT: 893 # Poll every 1 second for completion. 894 pass 895 GLib.timeout_add(1000, updatedb_subprocess) 896 897 # -- Search Entry -- # 898 def refresh_search_entry(self): 899 """Update the appearance of the search entry based on the application's 900 current state.""" 901 # Default Appearance, used for blank entry 902 query = None 903 icon_name = "edit-find-symbolic" 904 sensitive = True 905 button_tooltip_text = None 906 907 # Search running 908 if self.search_in_progress: 909 icon_name = "process-stop" 910 button_tooltip_text = _('Stop Search') 911 entry_tooltip_text = _("Search is in progress...\nPress the " 912 "cancel button or the Escape key to stop.") 913 914 # Search not running 915 else: 916 entry_text = self.search_entry.get_text() 917 entry_tooltip_text = None 918 # Search not running, value in terms 919 if len(entry_text) > 0: 920 button_tooltip_text = _('Begin Search') 921 query = entry_text 922 else: 923 sensitive = False 924 925 self.search_entry.set_icon_from_icon_name( 926 Gtk.EntryIconPosition.SECONDARY, icon_name) 927 self.search_entry.set_icon_tooltip_text( 928 Gtk.EntryIconPosition.SECONDARY, button_tooltip_text) 929 self.search_entry.set_tooltip_text(entry_tooltip_text) 930 self.search_entry.set_icon_activatable( 931 Gtk.EntryIconPosition.SECONDARY, sensitive) 932 self.search_entry.set_icon_sensitive( 933 Gtk.EntryIconPosition.SECONDARY, sensitive) 934 935 return query 936 937 def on_search_entry_activate(self, widget): 938 """If the search entry is not empty, and there is no ongoing search, perform the query.""" 939 if len(widget.get_text()) > 0: 940 941 # If a search is in progress, stop it 942 if self.search_in_progress: 943 self.stop_search = True 944 self.search_engine.stop() 945 946 self.statusbar.show() 947 948 # Store search start time for displaying friendly dates 949 now = datetime.datetime.now() 950 self.today = datetime.datetime(now.year, now.month, now.day) 951 self.yesterday = self.today - datetime.timedelta(days=1) 952 self.this_week = self.today - datetime.timedelta(days=6) 953 954 task = self.perform_query(widget.get_text()) 955 GLib.idle_add(next, task) 956 957 def on_search_entry_icon_press(self, widget, event, user_data): # pylint: disable=W0613 958 """If search in progress, stop the search, otherwise, start.""" 959 if not self.search_in_progress: 960 self.on_search_entry_activate(self.search_entry) 961 else: 962 self.stop_search = True 963 self.search_engine.stop() 964 965 def on_search_entry_changed(self, widget): # pylint: disable=W0613 966 """Update the search entry icon and run suggestions.""" 967 text = self.refresh_search_entry() 968 969 if text is None: 970 return 971 972 task = self.get_suggestions(text) 973 GLib.idle_add(next, task) 974 975 def get_suggestions(self, keywords): 976 """Load suggestions from the suggestions engine into the search entry 977 completion.""" 978 self.suggestions_engine.stop() 979 980 # Wait for an available thread. 981 while Gtk.events_pending(): 982 Gtk.main_iteration() 983 984 folder = self.folderchooser.get_filename() 985 show_hidden = self.filter_formats['hidden'] 986 987 # If the keywords start with a hidden character, show hidden files. 988 if len(keywords) != 0 and keywords[0] == '.': 989 show_hidden = True 990 991 completion = self.search_entry.get_completion() 992 if completion is not None: 993 model = completion.get_model() 994 model.clear() 995 results = [] 996 997 for filename in self.suggestions_engine.run(keywords, folder, 10): 998 if isinstance(filename, str): 999 name = os.path.split(filename)[1] 1000 if name not in results: 1001 try: 1002 # Determine if file is hidden 1003 hidden = is_file_hidden(folder, filename) 1004 1005 if not hidden or show_hidden: 1006 results.append(name) 1007 model.append([name]) 1008 except OSError: 1009 # file no longer exists 1010 pass 1011 yield True 1012 yield False 1013 1014 # -- Application Menu -- # 1015 def on_menu_exact_match_toggled(self, widget): 1016 """Toggle the exact match settings, and restart the search 1017 if a fulltext search was previously run.""" 1018 self.settings.set_setting('match-results-exactly', widget.get_active()) 1019 self.filter_format_toggled("exact", widget.get_active()) 1020 if self.filter_formats['fulltext']: 1021 self.on_search_entry_activate(self.search_entry) 1022 1023 def on_menu_hidden_files_toggled(self, widget): 1024 """Toggle the hidden files settings.""" 1025 active = widget.get_active() 1026 self.filter_format_toggled("hidden", active) 1027 self.settings.set_setting('show-hidden-files', active) 1028 1029 def open_file_dialog_ok(self, widget): 1030 fc_dialog = self.builder.get_object("fc_open_dialog") 1031 fc_toolbutton = self.builder.get_object("toolbar_folderchooser") 1032 fc_warning = self.builder.get_object("no_folder_warning") 1033 fc_warning_label = self.builder.get_object("no_folder_warning_label") 1034 fc_warning_hide = GLib.timeout_add_seconds (3, self.hide_fc_warning) 1035 folder = fc_dialog.get_filename() 1036 1037 if folder is None: 1038 fc_warning_label.set_text(_("No folder selected.")) 1039 fc_warning.show() 1040 fc_warning_hide 1041 return 1042 elif not os.path.isdir(folder): 1043 fc_warning_label.set_text(_("Folder not found.")) 1044 fc_warning.show() 1045 fc_warning_hide 1046 elif os.path.isdir(folder): 1047 fc_toolbutton.set_filename(folder) 1048 fc_dialog.close() 1049 1050 def hide_fc_warning(self): 1051 self.builder.get_object("no_folder_warning").hide() 1052 1053 def open_folder_dialog(self, widget): 1054 self.builder.get_object("fc_open_dialog").show() 1055 1056 def toolbar_hotkeys(self): 1057 window = self.builder.get_object("Catfish") 1058 search_entry = self.builder.get_object("toolbar_search") 1059 fc_toolbutton = self.builder.get_object("toolbar_folderchooser") 1060 1061 fc_toolbutton.connect('grab_focus', self.open_folder_dialog) 1062 1063 accelerators = Gtk.AccelGroup() 1064 window.add_accel_group(accelerators) 1065 1066 signal = 'grab_focus' 1067 fc_hotkeys = ('<Control>l', '<Control>o') 1068 1069 key, mod = Gtk.accelerator_parse('<Control>f') 1070 search_entry.add_accelerator(signal, accelerators, 1071 key, mod, Gtk.AccelFlags.VISIBLE) 1072 1073 for key in fc_hotkeys: 1074 key, mod = Gtk.accelerator_parse(key) 1075 fc_toolbutton.add_accelerator(signal, accelerators, 1076 key, mod, Gtk.AccelFlags.VISIBLE) 1077 1078 def on_menu_fulltext_toggled(self, widget): 1079 """Toggle the fulltext settings, and restart the search.""" 1080 self.settings.set_setting('search-file-contents', widget.get_active()) 1081 self.filter_format_toggled("fulltext", widget.get_active()) 1082 self.on_search_entry_activate(self.search_entry) 1083 1084 def on_menu_update_index_activate(self, widget): # pylint: disable=W0613 1085 """Show the Update Search Index dialog.""" 1086 self.update_index_dialog.show() 1087 1088 def on_menu_preferences_activate(self, widget): # pylint: disable=W0613 1089 dialog = CatfishPrefsDialog() 1090 dialog.set_transient_for(self) 1091 dialog.connect_settings(self.settings) 1092 dialog.run() 1093 changed_properties = dialog.changed_properties 1094 dialog.destroy() 1095 self.refresh_from_settings(changed_properties) 1096 1097 def refresh_from_settings(self, changed_properties): 1098 for prop in changed_properties: 1099 setting = self.settings.get_setting(prop) 1100 if prop == "show-hidden-files": 1101 self.hidden_files.set_active(setting) 1102 if prop == "show-sidebar": 1103 self.set_sidebar_active(setting) 1104 1105 # -- Sidebar -- # 1106 def set_sidebar_active(self, active): 1107 if self.sidebar_toggle_menu.get_active() != active: 1108 self.sidebar_toggle_menu.set_active(active) 1109 if self.sidebar.get_visible() != active: 1110 self.sidebar.set_visible(active) 1111 1112 def on_sidebar_toggle_toggled(self, widget): 1113 """Toggle visibility of the sidebar.""" 1114 if isinstance(widget, Gtk.CheckButton): 1115 active = widget.get_active() 1116 else: 1117 active = not self.settings.get_setting('show-sidebar') 1118 self.settings.set_setting('show-sidebar', active) 1119 self.set_sidebar_active(active) 1120 1121 def set_modified_range(self, value): 1122 if value == 'any': 1123 self.filter_timerange = (0.0, 9999999999.0) 1124 LOGGER.debug("Time Range: Beginning of time -> Eternity") 1125 elif value == 'today': 1126 now = datetime.datetime.now() 1127 today = time.mktime(( 1128 datetime.datetime(now.year, now.month, now.day, 0, 0) - 1129 datetime.timedelta(1)).timetuple()) 1130 self.filter_timerange = (today, 9999999999.0) 1131 LOGGER.debug( 1132 "Time Range: %s -> Eternity", 1133 time.strftime("%x %X", time.localtime(int(today)))) 1134 elif value == 'week': 1135 now = datetime.datetime.now() 1136 week = time.mktime(( 1137 datetime.datetime(now.year, now.month, now.day, 0, 0) - 1138 datetime.timedelta(7)).timetuple()) 1139 self.filter_timerange = (week, 9999999999.0) 1140 LOGGER.debug( 1141 "Time Range: %s -> Eternity", 1142 time.strftime("%x %X", time.localtime(int(week)))) 1143 elif value == 'month': 1144 now = datetime.datetime.now() 1145 month = time.mktime(( 1146 datetime.datetime(now.year, now.month, now.day, 0, 0) - 1147 datetime.timedelta(31)).timetuple()) 1148 self.filter_timerange = (month, 9999999999.0) 1149 LOGGER.debug( 1150 "Time Range: %s -> Eternity", 1151 time.strftime("%x %X", time.localtime(int(month)))) 1152 elif value == 'custom': 1153 self.filter_timerange = (time.mktime(self.start_date.timetuple()), 1154 time.mktime(self.end_date.timetuple())) 1155 LOGGER.debug( 1156 "Time Range: %s -> %s", 1157 time.strftime("%x %X", 1158 time.localtime(int(self.filter_timerange[0]))), 1159 time.strftime("%x %X", 1160 time.localtime(int(self.filter_timerange[1])))) 1161 self.refilter() 1162 1163 def on_calendar_today_button_clicked(self, calendar_widget): 1164 """Change the calendar widget selected date to today.""" 1165 today = datetime.datetime.now() 1166 calendar_widget.select_month(today.month - 1, today.year) 1167 calendar_widget.select_day(today.day) 1168 1169 # File Type toggles 1170 def filter_format_toggled(self, filter_format, enabled): 1171 """Update search filter when formats are modified.""" 1172 self.filter_formats[filter_format] = enabled 1173 LOGGER.debug("File type filters updated: %s", str(self.filter_formats)) 1174 self.refilter() 1175 1176 def on_filter_extensions_changed(self, widget): 1177 """Update the results when the extensions filter changed.""" 1178 self.filter_custom_extensions = [] 1179 extensions = widget.get_text().replace(',', ' ') 1180 for ext in extensions.split(): 1181 ext = ext.strip() 1182 if len(ext) > 0: 1183 if ext[0] != '.': 1184 ext = "." + ext 1185 self.filter_custom_extensions.append(ext) 1186 1187 # Reload the results filter. 1188 self.refilter() 1189 1190 def thunar_display_path(self, path): 1191 bus = dbus.SessionBus() 1192 obj = bus.get_object('org.xfce.Thunar', '/org/xfce/FileManager') 1193 iface = dbus.Interface(obj, 'org.xfce.FileManager') 1194 1195 method = iface.get_dbus_method('DisplayFolderAndSelect') 1196 dirname = os.path.dirname(path) 1197 filename = os.path.basename(path) 1198 return method(dirname, filename, '', '') 1199 1200 def get_exo_preferred_applications(self, filename): 1201 apps = {} 1202 if os.path.exists(filename): 1203 with open(filename, "r") as infile: 1204 for line in infile.readlines(): 1205 line = line.strip() 1206 if "=" in line: 1207 key, value = line.split("=", 2) 1208 if len(value) > 0: 1209 apps[key] = value 1210 return apps 1211 1212 def get_exo_preferred_file_manager(self): 1213 config = [GLib.get_user_config_dir()] + GLib.get_system_config_dirs() 1214 data_dir = GLib.get_user_data_dir() 1215 custFM = data_dir+"/xfce4/helpers/custom-FileManager.desktop" 1216 config_dirs = sorted(set(config), reverse=True) 1217 1218 for config_dir in config_dirs: 1219 cfg = "%s/xfce4/helpers.rc" % config_dir 1220 if os.path.exists(cfg): 1221 apps = self.get_exo_preferred_applications(cfg) 1222 if 'custom-FileManager' in apps['FileManager']: 1223 with open(custFM) as f: 1224 for line in f: 1225 CFM = line.replace('X-XFCE-Commands=', '').strip() 1226 if 'X-XFCE-Commands=' in line: 1227 return CFM 1228 1229 if 'FileManager' in apps: 1230 return apps['FileManager'] 1231 1232 return "Thunar" 1233 1234 def using_thunar_fm(self): 1235 if os.environ.get("XDG_CURRENT_DESKTOP", "").lower() == 'xfce': 1236 fm = self.get_exo_preferred_file_manager() 1237 return "thunar" in fm.lower() 1238 1239 fm = subprocess.check_output(['xdg-mime', 'query', 'default', 1240 'inode/directory']) 1241 fm = fm.decode("utf-8", errors="replace") 1242 if "thunar" in fm.lower(): 1243 return True 1244 1245 if "exo-file-manager" in fm.lower(): 1246 fm = self.get_exo_preferred_file_manager() 1247 return "thunar" in fm.lower() 1248 1249 return False 1250 1251 def open_file(self, filename): 1252 """Open the specified filename in its default application.""" 1253 LOGGER.debug("Opening %s" % filename) 1254 1255 if type(filename) is list: 1256 filename = filename[0] 1257 if filename.endswith('.AppImage') and os.access(filename, os.X_OK): 1258 command = [filename] 1259 elif os.path.isdir(filename) and \ 1260 os.environ.get("XDG_CURRENT_DESKTOP", "").lower() == 'xfce': 1261 command = ['exo-open', '--launch', 'FileManager', filename] 1262 else: 1263 command = ['xdg-open', filename] 1264 try: 1265 subprocess.Popen(command, shell=False) 1266 if self.settings.get_setting('close-after-select'): 1267 self.destroy() 1268 return 1269 except Exception as msg: 1270 LOGGER.debug('Exception encountered while opening %s.' + 1271 '\n Exception: %s' + 1272 filename, msg) 1273 self.get_error_dialog(_('\"%s\" could not be opened.') % 1274 os.path.basename(filename), str(msg)) 1275 1276 # -- File Popup Menu -- # 1277 def on_menu_open_activate(self, widget): # pylint: disable=W0613 1278 """Open the selected file in its default application.""" 1279 compressed_files = [] 1280 for filename in self.selected_filenames: 1281 if '//ARCHIVE//' in filename: 1282 compressed_files.append(filename) 1283 else: 1284 self.open_file(filename) 1285 self.open_compressed_files(compressed_files) 1286 1287 def open_compressed_files(self, compressed_files): 1288 for filename in compressed_files: 1289 archive = filename.split('//ARCHIVE//')[0] 1290 fname = filename.split('//ARCHIVE//')[1] 1291 if fname.endswith('/'): 1292 self.open_file(archive) 1293 continue 1294 with zipfile.ZipFile(archive) as z: 1295 fileinfo = z.getinfo(fname) 1296 fileinfo.filename = os.path.basename(fname) 1297 extract_dir = tempfile.mkdtemp(dir=self.tmpdir.name) 1298 tmpfile = z.extract(fname, path=extract_dir) 1299 self.open_file(tmpfile) 1300 1301 def on_menu_filemanager_activate(self, widget): # pylint: disable=W0613 1302 """Open the selected file in the default file manager.""" 1303 1304 file_manager = self.get_exo_preferred_file_manager().lower() 1305 files, dirs, nfiles = self.on_menu_filemanager_get_file_lists() 1306 num = len(files) 1307 1308 if 'nemo' or 'elementary' in file_manager: 1309 num = len(nfiles) 1310 1311 if 'thunar' in file_manager: 1312 for filename in files: 1313 self.thunar_display_path(filename) 1314 elif 'nautilus' in file_manager: 1315 for filename in files: 1316 subprocess.Popen([file_manager, '--select', filename]) 1317 elif 'nemo' in file_manager: 1318 for nfilename in nfiles: 1319 subprocess.Popen([file_manager, nfilename]) 1320 elif 'elementary' in file_manager: 1321 for nfilename in nfiles: 1322 subprocess.Popen([file_manager, '-n', nfilename]) 1323 else: 1324 for dirname in dirs: 1325 subprocess.Popen([file_manager, dirname]) 1326 1327 LOGGER.debug("Opening file manager for %i path(s)" % num) 1328 return 1329 1330 def on_menu_filemanager_get_file_lists(self): 1331 """Creates sets from selected files. Allows file managers to 1332 open and select file/folder when possible. If not it will open 1333 the parent folder of the file/folder. Sets prevent file manager 1334 from opening same location when multiple items are selected.""" 1335 1336 files = set() 1337 dirs = set() 1338 nfiles = set() 1339 1340 for filename in self.selected_filenames: 1341 if '//ARCHIVE//' in filename: 1342 filename = filename.split('//ARCHIVE//')[0] 1343 1344 files.add(filename) 1345 dirs.add(os.path.dirname(filename)) 1346 1347 if os.path.isfile(filename): 1348 nfiles.add(filename) 1349 elif os.path.isdir(filename): 1350 nfiles.add(os.path.dirname(filename)) 1351 return files, dirs, nfiles 1352 1353 def on_menu_copy_location_activate(self, widget): # pylint: disable=W0613 1354 """Copy the selected file name to the clipboard.""" 1355 clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) 1356 locations = [] 1357 for filename in self.selected_filenames: 1358 if "//ARCHIVE//" in filename: 1359 # If file is in archive, copies archive location 1360 archive = filename.split("//ARCHIVE//")[0] 1361 if archive in locations: 1362 continue 1363 locations.append(surrogate_escape(archive)) 1364 else: 1365 locations.append(surrogate_escape(filename)) 1366 text = str(os.linesep).join(locations) 1367 clipboard.set_text(text, -1) 1368 clipboard.store() 1369 LOGGER.debug("Copying %i filename(s) to the clipboard" % len(locations)) 1370 1371 def on_menu_save_activate(self, widget): # pylint: disable=W0613 1372 """Show a save dialog and possibly write the results to a file.""" 1373 selected_file = self.selected_filenames[0] 1374 if "//ARCHIVE//" in selected_file: 1375 archive = selected_file.split("//ARCHIVE//")[0] 1376 archive_filename = selected_file.split("//ARCHIVE//")[1] 1377 with zipfile.ZipFile(archive) as z: 1378 save_as = self.get_save_dialog(archive_filename) 1379 if save_as: 1380 saved_dir = os.path.dirname(save_as) 1381 saved_name = os.path.basename(save_as) 1382 archive_fileinfo = z.getinfo(archive_filename) 1383 archive_fileinfo.filename = saved_name 1384 z.extract(archive_filename, path=saved_dir) 1385 1386 else: 1387 filename = self.get_save_dialog( 1388 surrogate_escape(selected_file)) 1389 original = selected_file 1390 if filename: 1391 try: 1392 # Try to save the file. 1393 copy2(original, filename) 1394 1395 except Exception as msg: 1396 # If the file save fails, throw an error. 1397 LOGGER.debug('Exception encountered while saving %s.' + 1398 '\n Exception: %s', filename, msg) 1399 self.get_error_dialog(_('\"%s\" could not be saved.') % 1400 os.path.basename(filename), str(msg)) 1401 1402 def delete_file(self, filename): 1403 try: 1404 # Delete the file. 1405 if not os.path.exists(filename): 1406 return True 1407 if os.path.isdir(filename): 1408 rmtree(filename) 1409 else: 1410 os.remove(filename) 1411 return True 1412 except Exception as msg: 1413 # If the file cannot be deleted, throw an error. 1414 LOGGER.debug('Exception encountered while deleting %s.' + 1415 '\n Exception: %s', filename, msg) 1416 self.get_error_dialog(_("\"%s\" could not be deleted.") % 1417 os.path.basename(filename), 1418 str(msg)) 1419 return False 1420 1421 def remove_filenames_from_treeview(self, filenames): 1422 removed = [] 1423 model = self.treeview.get_model().get_model().get_model() 1424 treeiter = model.get_iter_first() 1425 while treeiter is not None: 1426 nextiter = model.iter_next(treeiter) 1427 row = model[treeiter] 1428 found = os.path.join(row[3], row[1]) 1429 if found in filenames: 1430 model.remove(treeiter) 1431 removed.append(found) 1432 if len(removed) == len(filenames): 1433 return True 1434 treeiter = nextiter 1435 return False 1436 1437 def on_menu_delete_activate(self, widget): # pylint: disable=W0613 1438 """Show a delete dialog and remove the file if accepted.""" 1439 filenames = [] 1440 if self.get_delete_dialog(self.selected_filenames): 1441 delete = sorted(self.selected_filenames) 1442 delete.reverse() 1443 for filename in delete: 1444 if self.delete_file(filename): 1445 filenames.append(filename) 1446 self.remove_filenames_from_treeview(filenames) 1447 self.refilter() 1448 1449 def get_save_dialog(self, filename): 1450 """Show the Save As FileChooserDialog. 1451 1452 Return the filename, or None if cancelled.""" 1453 basename = os.path.basename(filename) 1454 1455 dialog = Gtk.FileChooserDialog(title=_('Save "%s" as...') % basename, 1456 transient_for=self, 1457 action=Gtk.FileChooserAction.SAVE) 1458 dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, 1459 Gtk.STOCK_SAVE, Gtk.ResponseType.ACCEPT) 1460 dialog.set_default_response(Gtk.ResponseType.REJECT) 1461 dialog.set_current_name(basename.replace('�', '_')) 1462 dialog.set_do_overwrite_confirmation(True) 1463 response = dialog.run() 1464 save_as = dialog.get_filename() 1465 dialog.destroy() 1466 if response == Gtk.ResponseType.ACCEPT: 1467 return save_as 1468 return None 1469 1470 def get_error_dialog(self, primary, secondary): 1471 """Show an error dialog with the specified message.""" 1472 dialog_text = "<big><b>%s</b></big>\n\n%s" % (escape(primary), 1473 escape(secondary)) 1474 1475 dialog = Gtk.MessageDialog(transient_for=self, 1476 modal=True, 1477 destroy_with_parent=True, 1478 message_type=Gtk.MessageType.ERROR, 1479 buttons=Gtk.ButtonsType.OK, 1480 text="") 1481 1482 dialog.set_markup(dialog_text) 1483 dialog.set_default_response(Gtk.ResponseType.OK) 1484 dialog.run() 1485 dialog.destroy() 1486 1487 def get_delete_dialog(self, filenames): 1488 """Show a delete confirmation dialog. Return True if delete wanted.""" 1489 if len(filenames) == 1: 1490 primary = _("Are you sure that you want to \n" 1491 "permanently delete \"%s\"?") % \ 1492 escape(os.path.basename(filenames[0])) 1493 else: 1494 primary = _("Are you sure that you want to \n" 1495 "permanently delete the %i selected files?") % \ 1496 len(filenames) 1497 secondary = _("If you delete a file, it is permanently lost.") 1498 1499 dialog_text = "<big><b>%s</b></big>\n\n%s" % (primary, secondary) 1500 dialog = Gtk.MessageDialog(transient_for=self, 1501 modal=True, 1502 destroy_with_parent=True, 1503 message_type=Gtk.MessageType.QUESTION, 1504 buttons=Gtk.ButtonsType.NONE, 1505 text="") 1506 dialog.set_markup(surrogate_escape(dialog_text)) 1507 1508 dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.NO, 1509 Gtk.STOCK_DELETE, Gtk.ResponseType.YES) 1510 1511 dialog.set_default_response(Gtk.ResponseType.NO) 1512 response = dialog.run() 1513 dialog.destroy() 1514 return response == Gtk.ResponseType.YES 1515 1516 def setup_small_view(self): 1517 """Prepare the list view in the results pane.""" 1518 for column in self.treeview.get_columns(): 1519 self.treeview.remove_column(column) 1520 self.treeview.append_column(self.new_column(_('Filename'), 1, 1521 'icon', 1)) 1522 self.treeview.append_column(self.new_column(_('Size'), 2, 1523 'filesize')) 1524 self.treeview.append_column(self.new_column(_('Location'), 3, 1525 'ellipsize')) 1526 self.treeview.append_column(self.new_column(_('Modified'), 4, 1527 'date', 1)) 1528 self.icon_size = Gtk.IconSize.MENU 1529 1530 def setup_large_view(self): 1531 """Prepare the extended list view in the results pane.""" 1532 for column in self.treeview.get_columns(): 1533 self.treeview.remove_column(column) 1534 # Make the Preview Column 1535 cell = Gtk.CellRendererPixbuf() 1536 column = Gtk.TreeViewColumn(_('Preview'), cell) 1537 self.treeview.append_column(column) 1538 column.set_cell_data_func(cell, self.preview_cell_data_func, None) 1539 1540 # Make the Details Column 1541 cell = Gtk.CellRendererText() 1542 cell.set_property("ellipsize", Pango.EllipsizeMode.END) 1543 column = Gtk.TreeViewColumn(_("Details"), cell, markup=1) 1544 1545 column.set_sort_column_id(1) 1546 column.set_resizable(True) 1547 column.set_expand(True) 1548 1549 column.set_cell_data_func(cell, self.thumbnail_cell_data_func, None) 1550 self.treeview.append_column(column) 1551 self.icon_size = Gtk.IconSize.DIALOG 1552 1553 def on_treeview_list_toggled(self, widget): 1554 """Switch to the details view.""" 1555 if widget.get_active(): 1556 self.show_thumbnail = False 1557 self.settings.set_setting('list-toggle', True) 1558 self.settings.set_setting('show-thumbnails', False) 1559 if self.options.icons_large: 1560 self.setup_large_view() 1561 else: 1562 self.setup_small_view() 1563 1564 def on_treeview_thumbnails_toggled(self, widget): 1565 """Switch to the preview view.""" 1566 if widget.get_active(): 1567 self.show_thumbnail = True 1568 self.settings.set_setting('list-toggle', False) 1569 self.settings.set_setting('show-thumbnails', True) 1570 self.setup_large_view() 1571 1572 # -- Treeview -- # 1573 def on_treeview_row_activated(self, treeview, path, user_data): # pylint: disable=W0613 1574 """Catch row activations by keyboard or mouse double-click.""" 1575 # Get the filename from the row. 1576 model = treeview.get_model() 1577 file_path = self.treemodel_get_row_filename(model, path) 1578 self.selected_filenames = [file_path] 1579 # Open the selected file. 1580 if "//ARCHIVE//" in file_path: 1581 self.open_compressed_files([self.selected_filenames[0]]) 1582 else: 1583 self.open_file(self.selected_filenames[0]) 1584 1585 def on_treeview_drag_begin(self, treeview, context): # pylint: disable=W0613 1586 """Treeview DND Begin.""" 1587 if len(self.selected_filenames) > 1: 1588 treesel = treeview.get_selection() 1589 for row in self.rows: 1590 treesel.select_path(row) 1591 return True 1592 1593 def on_treeview_drag_data_get(self, treeview, context, selection, info, # pylint: disable=W0613 1594 timestamp): # pylint: disable=W0613 1595 """Treeview DND Get.""" 1596 # Checks for archives in selection, disables DND if present 1597 if any("//ARCHIVE//" in arch for arch in self.selected_filenames): 1598 return False 1599 else: 1600 text = str(os.linesep).join(self.selected_filenames) 1601 selection.set_text(text, -1) 1602 1603 uris = ['file://' + path for path in self.selected_filenames] 1604 selection.set_uris(uris) 1605 1606 return True 1607 1608 def treemodel_get_row_filename(self, model, row): 1609 """Get the filename from a specified row.""" 1610 if zipfile.is_zipfile(model[row][3]): 1611 filename = model[row][3] + "//ARCHIVE//" + model[row][1] 1612 else: 1613 filename = os.path.join(model[row][3], model[row][1]) 1614 return filename 1615 1616 def treeview_get_selected_rows(self, treeview): 1617 """Get the currently selected rows from the specified treeview.""" 1618 sel = treeview.get_selection() 1619 model, rows = sel.get_selected_rows() 1620 data = [] 1621 for row in rows: 1622 data.append(self.treemodel_get_row_filename(model, row)) 1623 return (model, rows, data) 1624 1625 def check_treeview_stats(self, treeview): 1626 if len(self.rows) == 0: 1627 return -1 1628 rows = self.treeview_get_selected_rows(treeview)[1] 1629 for row in rows: 1630 if row not in self.rows: 1631 return 2 1632 if self.rows != rows: 1633 return 1 1634 return 0 1635 1636 def update_treeview_stats(self, treeview, event=None): 1637 if event: 1638 self.treeview_set_cursor_if_unset(treeview, 1639 int(event.x), 1640 int(event.y)) 1641 self.rows, self.selected_filenames = \ 1642 self.treeview_get_selected_rows(treeview)[1:] 1643 1644 def maintain_treeview_stats(self, treeview, event=None): 1645 if len(self.selected_filenames) == 0: 1646 self.update_treeview_stats(treeview, event) 1647 elif self.check_treeview_stats(treeview) == 2: 1648 self.update_treeview_stats(treeview, event) 1649 else: 1650 treesel = treeview.get_selection() 1651 for row in self.rows: 1652 treesel.select_path(row) 1653 1654 def on_treeview_cursor_changed(self, treeview): 1655 if "Shift" in self.keys_pressed or "Control" in self.keys_pressed: 1656 self.update_treeview_stats(treeview) 1657 if "Up" in self.keys_pressed or "Down" in self.keys_pressed: 1658 self.update_treeview_stats(treeview) 1659 if len(self.selected_filenames) == 1: 1660 self.update_treeview_stats(treeview) 1661 1662 def treeview_set_cursor_if_unset(self, treeview, x=0, y=0): 1663 if treeview.get_selection().count_selected_rows() < 1: 1664 self.treeview_set_cursor_at_pos(treeview, x, y) 1665 1666 def treeview_set_cursor_at_pos(self, treeview, x, y): 1667 try: 1668 path = treeview.get_path_at_pos(int(x), int(y))[0] 1669 treeview.set_cursor(path) 1670 except TypeError: 1671 return False 1672 return True 1673 1674 def treeview_left_click(self, treeview, event=None): 1675 self.update_treeview_stats(treeview, event) 1676 return False 1677 1678 def treeview_middle_click(self, treeview, event=None): 1679 self.maintain_treeview_stats(treeview, event) 1680 self.treeview_set_cursor_if_unset(treeview, int(event.x), int(event.y)) 1681 for filename in self.selected_filenames: 1682 self.open_file(filename) 1683 return True 1684 1685 def treeview_right_click(self, treeview, event=None): 1686 self.maintain_treeview_stats(treeview, event) 1687 # Checks if archive 1688 directory = os.path.isdir(self.selected_filenames[0]) or \ 1689 self.selected_filenames[0].endswith("/") 1690 show_save_option = len(self.selected_filenames) == 1 and not \ 1691 directory 1692 self.file_menu_save.set_visible(show_save_option) 1693 writeable = True 1694 for filename in self.selected_filenames: 1695 if type(filename) is list: 1696 filename = os.path.join(filename[0], filename[1]) 1697 if not os.access(filename, os.W_OK): 1698 writeable = False 1699 self.file_menu_delete.set_sensitive(writeable) 1700 self.file_menu.popup_at_pointer() 1701 return True 1702 1703 def treeview_alt_clicked(self, treeview, event=None): 1704 self.update_treeview_stats(treeview, event) 1705 return False 1706 1707 def on_treeview_button_press_event(self, treeview, event): 1708 """Catch single mouse click events on the treeview and rows. 1709 1710 Left Click: Ignore. 1711 Middle Click: Open the selected file. 1712 Right Click: Show the popup menu.""" 1713 if "Shift" in self.keys_pressed or "Control" in self.keys_pressed: 1714 handled = self.treeview_alt_clicked(treeview, event) 1715 elif event.button == 1: 1716 handled = self.treeview_left_click(treeview, event) 1717 elif event.button == 2: 1718 handled = self.treeview_middle_click(treeview, event) 1719 elif event.button == 3: 1720 handled = self.treeview_right_click(treeview, event) 1721 else: 1722 handled = False 1723 return handled 1724 1725 def new_column(self, label, colid, special=None, markup=False): 1726 """New Column function for creating TreeView columns easily.""" 1727 if special == 'icon': 1728 column = Gtk.TreeViewColumn(label) 1729 cell = Gtk.CellRendererPixbuf() 1730 column.pack_start(cell, False) 1731 column.set_cell_data_func(cell, self.preview_cell_data_func, None) 1732 cell = Gtk.CellRendererText() 1733 column.pack_start(cell, False) 1734 column.add_attribute(cell, 'text', colid) 1735 else: 1736 cell = Gtk.CellRendererText() 1737 if markup: 1738 column = Gtk.TreeViewColumn(label, cell, markup=colid) 1739 else: 1740 column = Gtk.TreeViewColumn(label, cell, text=colid) 1741 if special == 'ellipsize': 1742 column.set_min_width(120) 1743 cell.set_property('ellipsize', Pango.EllipsizeMode.START) 1744 elif special == 'filesize': 1745 cell.set_property('xalign', 1.0) 1746 column.set_cell_data_func(cell, 1747 self.cell_data_func_filesize, colid) 1748 elif special == 'date': 1749 column.set_cell_data_func(cell, 1750 self.cell_data_func_modified, colid) 1751 column.set_sort_column_id(colid) 1752 column.set_resizable(True) 1753 if colid == 3: 1754 column.set_expand(True) 1755 return column 1756 1757 def cell_data_func_filesize(self, column, cell_renderer, # pylint: disable=W0613 1758 tree_model, tree_iter, cellid): 1759 """File size cell display function.""" 1760 size = long(tree_model.get_value(tree_iter, cellid)) 1761 1762 filesize = self.format_size(size) 1763 cell_renderer.set_property('text', filesize) 1764 1765 def cell_data_func_modified(self, column, cell_renderer, # pylint: disable=W0613 1766 tree_model, tree_iter, cellid): 1767 """Modification date cell display function.""" 1768 modification_int = int(tree_model.get_value(tree_iter, cellid)) 1769 modified = self.get_date_string(modification_int) 1770 1771 cell_renderer.set_property('text', modified) 1772 1773 def get_date_string(self, modification_int): 1774 """Return the date string in the preferred format.""" 1775 if self.time_format is not None: 1776 modified = time.strftime(self.time_format, 1777 time.localtime(modification_int)) 1778 else: 1779 item_date = datetime.datetime.fromtimestamp(modification_int) 1780 if item_date >= self.today: 1781 modified = _("Today") 1782 elif item_date >= self.yesterday: 1783 modified = _("Yesterday") 1784 elif item_date >= self.this_week: 1785 modified = time.strftime("%A", 1786 time.localtime(modification_int)) 1787 else: 1788 modified = time.strftime("%x", 1789 time.localtime(modification_int)) 1790 return modified 1791 1792 def results_filter_func(self, model, treeiter, user_data): # pylint: disable=W0613 1793 """Filter function for search results.""" 1794 # hidden 1795 if model[treeiter][6]: 1796 if not self.filter_formats['hidden']: 1797 return False 1798 1799 # exact 1800 if not self.filter_formats['fulltext']: 1801 if not model[treeiter][7]: 1802 if self.filter_formats['exact']: 1803 return False 1804 1805 # modified 1806 modified = model[treeiter][4] 1807 if modified < self.filter_timerange[0]: 1808 return False 1809 if modified > self.filter_timerange[1]: 1810 return False 1811 1812 # mimetype 1813 mimetype = model[treeiter][5] 1814 use_filters = False 1815 if self.filter_formats['folders']: 1816 use_filters = True 1817 if mimetype == 'inode/directory': 1818 return True 1819 if self.filter_formats['images']: 1820 use_filters = True 1821 if mimetype.startswith("image"): 1822 return True 1823 if self.filter_formats['music']: 1824 use_filters = True 1825 if mimetype.startswith("audio"): 1826 return True 1827 if self.filter_formats['videos']: 1828 use_filters = True 1829 if mimetype.startswith("video"): 1830 return True 1831 if self.filter_formats['documents']: 1832 use_filters = True 1833 if mimetype.startswith("text"): 1834 return True 1835 if self.filter_formats['applications']: 1836 use_filters = True 1837 if mimetype.startswith("application"): 1838 return True 1839 if self.filter_formats['other']: 1840 use_filters = True 1841 extension = os.path.splitext(model[treeiter][1])[1] 1842 if extension in self.filter_custom_extensions: 1843 return True 1844 1845 if use_filters: 1846 return False 1847 1848 return True 1849 1850 def refilter(self): 1851 """Reload the results filter, update the statusbar to reflect count.""" 1852 try: 1853 self.results_filter.refilter() 1854 n_results = len(self.treeview.get_model()) 1855 self.show_results(n_results) 1856 except AttributeError: 1857 pass 1858 1859 def show_results(self, count): 1860 if count == 0: 1861 self.builder.get_object("results_scrolledwindow").hide() 1862 self.builder.get_object("splash").show() 1863 self.builder.get_object( 1864 "splash_title").set_text(_("No files found.")) 1865 self.builder.get_object("splash_status").set_text( 1866 _("Try making your search less specific\n" 1867 "or try another directory.")) 1868 self.builder.get_object("splash_status").show() 1869 else: 1870 self.builder.get_object("splash").hide() 1871 self.builder.get_object("results_scrolledwindow").show() 1872 if count == 1: 1873 self.statusbar_label.set_label(_("1 file found.")) 1874 else: 1875 self.statusbar_label.set_label(_("%i files found.") % count) 1876 1877 def format_size(self, size, precision=1): 1878 """Make a file size human readable.""" 1879 if isinstance(size, str): 1880 size = int(size) 1881 suffixes = [_('bytes'), 'kB', 'MB', 'GB', 'TB'] 1882 suffixIndex = 0 1883 if size > 1024: 1884 while size > 1024: 1885 suffixIndex += 1 1886 size = size / 1024.0 1887 return "%.*f %s" % (precision, size, suffixes[suffixIndex]) 1888 return "%i %s" % (size, suffixes[0]) 1889 1890 def guess_mimetype(self, fullpath): 1891 """Guess the mimetype of the specified filename.""" 1892 filename = os.path.basename(fullpath) 1893 mimetype = mimetypes.guess_type(filename) 1894 sub = mimetype[1] 1895 guess = mimetype[0] 1896 if os.path.isdir(fullpath) or fullpath.endswith('/'): 1897 return 'inode/directory' 1898 if filename in ['INSTALL', 'AUTHORS', 'COPYING', 'CHANGELOG', 1899 'Makefile', 'Credits']: 1900 guess = 'text/x-%s' % filename.lower() 1901 if sub and 'x-tar' not in guess and sub in ['bzip2', 'gzip', 'xz']: 1902 guess = 'application/x-%s' % sub.strip('2') 1903 if guess is None: 1904 return 'text/plain' 1905 return guess 1906 1907 def changed_icon(self, widget): 1908 self.changed_icon_theme = True 1909 return 1910 1911 def create_symlink_icon(self, fullpath, icon_name, emblem_icon): 1912 """Creates a new transparent 22x21px image. Then centers and 1913 overlays/composites the mimetype icon. The emblem icon is then 1914 resized, offset and overlayed onto the 22x21px + mimetype image, 1915 creating the symbolic icon.""" 1916 1917 load = self.icon_theme.load_icon 1918 icon_size = Gtk.icon_size_lookup(self.icon_size)[1] 1919 1920 icon = load(icon_name, icon_size, Gtk.IconLookupFlags.FORCE_SIZE) 1921 emblem = load(emblem_icon, icon_size, 0) 1922 1923 # Set GdkPixbuf composite properties (icon sizes, offsets) 1924 filtr = GdkPixbuf.InterpType.BILINEAR 1925 if self.show_thumbnail is False: 1926 new_sizex, new_sizey = (22, 21) 1927 emb_resize = 12 1928 icon_destx, icon_desty, icon_offx, icon_offy = (3, 2, 3.0, 2.0) 1929 emb_destx, emb_desty, emb_offx, emb_offy = (10, 9, 10.0, 9.0) 1930 1931 else: 1932 new_sizex, new_sizey = (50, 50) 1933 emb_resize = 24 1934 icon_destx, icon_desty, icon_offx, icon_offy = (2, 2, 2.0, 2.0) 1935 emb_destx, emb_desty, emb_offx, emb_offy = (26, 26, 26.0, 26.0) 1936 1937 # Create new icon, overlay icon, scale emblem, overlay emblem on top 1938 new_icon = GdkPixbuf.Pixbuf.new( 1939 GdkPixbuf.Colorspace(0), True, 8, new_sizex, new_sizey) 1940 new_icon.fill(0) 1941 1942 GdkPixbuf.Pixbuf.composite( 1943 icon, new_icon, icon_destx, icon_desty, icon_size, 1944 icon_size, icon_offx, icon_offy, 1.0, 1.0, filtr, 255) 1945 1946 emb_scaled = GdkPixbuf.Pixbuf.scale_simple( 1947 emblem, emb_resize, emb_resize, filtr) 1948 1949 GdkPixbuf.Pixbuf.composite( 1950 emb_scaled, new_icon, emb_destx, emb_desty, emb_resize, 1951 emb_resize, emb_offx, emb_offy, 1.0, 1.0, filtr, 255) 1952 1953 return new_icon 1954 1955 def get_icon_pixbuf(self, name): 1956 """Return a pixbuf for the icon name from the default icon theme.""" 1957 icon_size = Gtk.icon_size_lookup(self.icon_size)[1] 1958 flags = Gtk.IconLookupFlags.FORCE_SIZE 1959 if self.icon_theme.has_icon(name): 1960 icon = self.icon_theme.load_icon(name, icon_size, flags) 1961 else: 1962 icon = self.icon_theme.load_icon('image-missing', icon_size, 1963 flags) 1964 return icon 1965 1966 def get_thumbnail(self, path, mime_type=None): 1967 """Try to fetch a thumbnail.""" 1968 thumb = self.thumbnailer.get_thumbnail(path, mime_type, 1969 self.show_thumbnail) 1970 if thumb: 1971 return thumb 1972 return self.get_file_icon(path, mime_type) 1973 1974 def get_file_icon(self, fullpath, mime_type=None): # pylint: disable=W0613 1975 """Retrieve the file icon.""" 1976 path = os.path.basename(fullpath) 1977 if mime_type: 1978 if mime_type == 'inode/directory' or path.endswith('/'): 1979 return 'folder' 1980 mime_type = mime_type.split('/') 1981 if mime_type is not None: 1982 # Get icon from mimetype 1983 media, subtype = mime_type 1984 1985 variations = ['%s-%s' % (media, subtype), 1986 '%s-x-%s' % (media, subtype), subtype] 1987 1988 variations.append('gnome-mime-%s-%s' % (media, subtype)) 1989 if media == "application": 1990 variations.append('application-x-executable') 1991 variations.append('%s-x-generic' % media) 1992 1993 for icon_name in variations: 1994 if self.icon_theme.has_icon(icon_name): 1995 return icon_name 1996 return "text-x-generic" 1997 1998 def size_sort_func(self, model, row1, row2, user_data): # pylint: disable=W0613 1999 """Sort function used in Python 3.""" 2000 sort_column = 2 2001 value1 = long(model.get_value(row1, sort_column)) 2002 value2 = long(model.get_value(row2, sort_column)) 2003 if value1 < value2: 2004 return -1 2005 if value1 == value2: 2006 return 0 2007 return 1 2008 2009 def perform_zip_query(self, filename, keywords, search_exact): 2010 for member, uncompressed_size, date_time in \ 2011 self.search_engine.search_zip(filename, keywords, search_exact): 2012 dt = datetime.datetime(*date_time).timestamp() 2013 mimetype = self.guess_mimetype(member) 2014 icon = self.get_file_icon(member, mimetype) 2015 displayed = surrogate_escape(member.rstrip("/"), True) 2016 zip_path = surrogate_escape(filename) 2017 exact = keywords in member 2018 yield [icon, displayed, uncompressed_size, 2019 zip_path, dt, mimetype, False, exact] 2020 2021 # -- Searching -- # 2022 def perform_query(self, keywords): # noqa 2023 """Run the search query with the specified keywords.""" 2024 self.stop_search = False 2025 2026 # Update the interface to Search Mode 2027 self.builder.get_object("results_scrolledwindow").hide() 2028 self.builder.get_object("splash").show() 2029 self.builder.get_object("splash_title").set_text(_("Searching...")) 2030 self.builder.get_object("splash_status").set_text( 2031 _("Results will be displayed as soon as they are found.")) 2032 self.builder.get_object("splash_status").show() 2033 self.builder.get_object("welcome_area").hide() 2034 show_results = False 2035 self.get_window().set_cursor(Gdk.Cursor.new_from_name( 2036 Gdk.Display.get_default(), "progress")) 2037 self.set_title(_("Searching for \"%s\"") % keywords) 2038 self.spinner.show() 2039 self.statusbar_label.set_label(_("Searching...")) 2040 2041 self.search_in_progress = True 2042 self.refresh_search_entry() 2043 2044 # Be thread friendly. 2045 while Gtk.events_pending(): 2046 Gtk.main_iteration() 2047 2048 # icon, name, size, path, modified, mimetype, hidden, exact 2049 model = Gtk.TreeStore(str, str, GObject.TYPE_INT64, 2050 str, float, str, bool, bool) 2051 2052 # Initialize the results filter. 2053 self.results_filter = model.filter_new() 2054 self.results_filter.set_visible_func(self.results_filter_func) 2055 sort = Gtk.TreeModelSort(model=self.results_filter) 2056 sort.set_sort_func(2, self.size_sort_func, None) 2057 self.treeview.set_model(sort) 2058 sort.get_model().get_model().clear() 2059 self.treeview.columns_autosize() 2060 2061 # Enable multiple-selection 2062 sel = self.treeview.get_selection() 2063 if sel is not None: 2064 sel.set_mode(Gtk.SelectionMode.MULTIPLE) 2065 2066 folder = self.folderchooser.get_filename() 2067 2068 results = [] 2069 2070 # Check if this is a fulltext query or standard query. 2071 if self.filter_formats['fulltext']: 2072 self.search_engine = \ 2073 CatfishSearchEngine(['fulltext'], 2074 self.settings.get_setting("exclude-paths")) 2075 self.search_engine.set_exact(self.filter_formats['exact']) 2076 else: 2077 self.search_engine = CatfishSearchEngine( 2078 ['zeitgeist', 'locate', 'walk'], 2079 self.settings.get_setting("exclude-paths") 2080 ) 2081 2082 search_zips = self.settings.get_setting('search-compressed-files') 2083 search_exact = self.settings.get_setting('match-results-exactly') 2084 2085 for filename in self.search_engine.run(keywords, folder, search_zips, regex=True): 2086 if self.stop_search: 2087 break 2088 if isinstance(filename, str) and filename not in results: 2089 try: 2090 path, name = os.path.split(filename) 2091 size = long(os.path.getsize(filename)) 2092 modified = os.path.getmtime(filename) 2093 2094 mimetype = self.guess_mimetype(filename) 2095 icon_name = self.get_thumbnail(filename, mimetype) 2096 2097 hidden = is_file_hidden(folder, filename) 2098 2099 exact = keywords in name 2100 2101 results.append(filename) 2102 2103 displayed = surrogate_escape(name, True) 2104 path = surrogate_escape(path) 2105 if zipfile.is_zipfile(filename): 2106 parent = None 2107 if not self.filter_formats['fulltext']: 2108 if self.search_engine.search_filenames(filename, keywords, search_exact): 2109 parent = model.append(None, [icon_name, displayed, size, path, modified, mimetype, hidden, search_exact]) 2110 if not search_zips: 2111 continue 2112 try: 2113 for row in self.perform_zip_query(filename, keywords, search_exact): 2114 if not parent: 2115 parent = model.append(None, [icon_name, displayed, size, path, modified, mimetype, hidden, search_exact]) 2116 model.append(parent, row) 2117 except zipfile.BadZipFile as e: 2118 LOGGER.debug(f'{e}: {path}') 2119 else: 2120 model.append(None, [icon_name, displayed, size, path, modified, 2121 mimetype, hidden, exact]) 2122 2123 if not show_results: 2124 if len(self.treeview.get_model()) > 0: 2125 show_results = True 2126 self.builder.get_object("splash").hide() 2127 self.builder.get_object( 2128 "results_scrolledwindow").show() 2129 2130 except OSError: 2131 # file no longer exists 2132 pass 2133 except Exception as e: 2134 LOGGER.error("Exception encountered: %s" % str(e)) 2135 2136 yield True 2137 continue 2138 2139 # Return to Non-Search Mode. 2140 window = self.get_window() 2141 if window is not None: 2142 window.set_cursor(None) 2143 self.set_title(_('Search results for \"%s\"') % keywords) 2144 self.spinner.hide() 2145 2146 n_results = 0 2147 if self.treeview.get_model() is not None: 2148 n_results = len(self.treeview.get_model()) 2149 self.show_results(n_results) 2150 2151 self.search_in_progress = False 2152 self.refresh_search_entry() 2153 2154 self.stop_search = False 2155 yield False 2156