1# Copyright (C) 2012-2013, 2017-2018 Kai Willadsen <kai.willadsen@gmail.com> 2# 3# This program is free software: you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation, either version 2 of the License, or (at 6# your option) any later version. 7# 8# This program is distributed in the hope that it will be useful, but 9# WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 11# General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program. If not, see <http://www.gnu.org/licenses/>. 15 16""" 17Recent files integration for Meld's multi-element comparisons 18 19The GTK+ recent files mechanism is designed to take only single files with a 20limited set of metadata. In Meld, we almost always need to enter pairs or 21triples of files or directories, along with some information about the 22comparison type. The solution provided by this module is to create fake 23single-file registers for multi-file comparisons, and tell the recent files 24infrastructure that that's actually what we opened. 25""" 26 27import configparser 28import enum 29import os 30import sys 31import tempfile 32 33from gi.repository import Gio 34from gi.repository import GLib 35from gi.repository import Gtk 36 37import meld.misc 38from meld.conf import _ 39 40 41class RecentType(enum.Enum): 42 File = "File" 43 Folder = "Folder" 44 VersionControl = "Version control" 45 Merge = "Merge" 46 47 48class RecentFiles: 49 50 mime_type = "application/x-meld-comparison" 51 recent_path = os.path.join(GLib.get_user_data_dir(), "meld") 52 recent_suffix = ".meldcmp" 53 54 # Recent data 55 app_name = "Meld" 56 57 def __init__(self): 58 self.recent_manager = Gtk.RecentManager.get_default() 59 self.recent_filter = Gtk.RecentFilter() 60 self.recent_filter.add_mime_type(self.mime_type) 61 self._stored_comparisons = {} 62 self.app_exec = os.path.abspath(sys.argv[0]) 63 64 if not os.path.exists(self.recent_path): 65 os.makedirs(self.recent_path) 66 67 self._clean_recent_files() 68 self._update_recent_files() 69 self.recent_manager.connect("changed", self._update_recent_files) 70 71 def add(self, tab, flags=None): 72 """Add a tab to our recently-used comparison list 73 74 The passed flags are currently ignored. In the future these are to be 75 used for extra initialisation not captured by the tab itself. 76 """ 77 recent_type, gfiles = tab.get_comparison() 78 79 # While Meld handles comparisons including None, recording these as 80 # recently-used comparisons just isn't that sane. 81 if None in gfiles: 82 return 83 84 uris = [f.get_uri() for f in gfiles] 85 names = [f.get_parse_name() for f in gfiles] 86 87 # If a (type, uris) comparison is already registered, then re-add 88 # the corresponding comparison file 89 comparison_key = (recent_type, tuple(uris)) 90 if comparison_key in self._stored_comparisons: 91 gfile = Gio.File.new_for_uri( 92 self._stored_comparisons[comparison_key]) 93 else: 94 recent_path = self._write_recent_file(recent_type, uris) 95 gfile = Gio.File.new_for_path(recent_path) 96 97 if len(uris) > 1: 98 display_name = " : ".join(meld.misc.shorten_names(*names)) 99 else: 100 display_path = names[0] 101 userhome = os.path.expanduser("~") 102 if display_path.startswith(userhome): 103 # FIXME: What should we show on Windows? 104 display_path = "~" + display_path[len(userhome):] 105 display_name = _("Version control:") + " " + display_path 106 # FIXME: Should this be translatable? It's not actually used anywhere. 107 description = "{} comparison\n{}".format( 108 recent_type.value, ", ".join(uris)) 109 110 recent_metadata = Gtk.RecentData() 111 recent_metadata.mime_type = self.mime_type 112 recent_metadata.app_name = self.app_name 113 recent_metadata.app_exec = "%s --comparison-file %%u" % self.app_exec 114 recent_metadata.display_name = display_name 115 recent_metadata.description = description 116 recent_metadata.is_private = True 117 self.recent_manager.add_full(gfile.get_uri(), recent_metadata) 118 119 def read(self, uri): 120 """Read stored comparison from URI 121 122 Returns the comparison type, the URIs involved and the comparison 123 flags. 124 """ 125 comp_gfile = Gio.File.new_for_uri(uri) 126 comp_path = comp_gfile.get_path() 127 if not comp_gfile.query_exists(None) or not comp_path: 128 raise IOError("Recent comparison file does not exist") 129 130 # TODO: remove reading paths in next release 131 try: 132 config = configparser.RawConfigParser() 133 config.read(comp_path) 134 assert (config.has_section("Comparison") and 135 config.has_option("Comparison", "type") and 136 (config.has_option("Comparison", "paths") or 137 config.has_option("Comparison", "uris"))) 138 except (configparser.Error, AssertionError): 139 raise ValueError("Invalid recent comparison file") 140 141 try: 142 recent_type = RecentType(config.get("Comparison", "type")) 143 except ValueError: 144 raise ValueError("Invalid recent comparison file") 145 146 if config.has_option("Comparison", "uris"): 147 uris = config.get("Comparison", "uris").split(";") 148 gfiles = tuple(Gio.File.new_for_uri(u) for u in uris) 149 else: 150 paths = config.get("Comparison", "paths").split(";") 151 gfiles = tuple(Gio.File.new_for_path(p) for p in paths) 152 flags = tuple() 153 154 return recent_type, gfiles, flags 155 156 def _write_recent_file(self, recent_type: RecentType, uris): 157 # TODO: Use GKeyFile instead, and return a Gio.File. This is why we're 158 # using ';' to join comparison paths. 159 with tempfile.NamedTemporaryFile( 160 mode='w+t', prefix='recent-', suffix=self.recent_suffix, 161 dir=self.recent_path, delete=False) as f: 162 config = configparser.RawConfigParser() 163 config.add_section("Comparison") 164 config.set("Comparison", "type", recent_type.value) 165 config.set("Comparison", "uris", ";".join(uris)) 166 config.write(f) 167 name = f.name 168 return name 169 170 def _clean_recent_files(self): 171 # Remove from RecentManager any comparisons with no existing file 172 meld_items = self._filter_items(self.recent_filter, 173 self.recent_manager.get_items()) 174 for item in meld_items: 175 if not item.exists(): 176 self.recent_manager.remove_item(item.get_uri()) 177 178 meld_items = [item for item in meld_items if item.exists()] 179 180 # Remove any comparison files that are not listed by RecentManager 181 item_uris = [item.get_uri() for item in meld_items] 182 item_paths = [ 183 Gio.File.new_for_uri(uri).get_path() for uri in item_uris] 184 stored = [p for p in os.listdir(self.recent_path) 185 if p.endswith(self.recent_suffix)] 186 for path in stored: 187 file_path = os.path.abspath(os.path.join(self.recent_path, path)) 188 if file_path not in item_paths: 189 try: 190 os.remove(file_path) 191 except OSError: 192 pass 193 194 def _update_recent_files(self, *args): 195 meld_items = self._filter_items(self.recent_filter, 196 self.recent_manager.get_items()) 197 item_uris = [item.get_uri() for item in meld_items if item.exists()] 198 self._stored_comparisons = {} 199 for item_uri in item_uris: 200 try: 201 recent_type, gfiles, flags = self.read(item_uri) 202 except (IOError, ValueError): 203 continue 204 # Store and look up comparisons by type and paths, ignoring flags 205 gfile_uris = tuple(gfile.get_uri() for gfile in gfiles) 206 self._stored_comparisons[recent_type, gfile_uris] = item_uri 207 208 def _filter_items(self, recent_filter, items): 209 getters = {Gtk.RecentFilterFlags.URI: "uri", 210 Gtk.RecentFilterFlags.DISPLAY_NAME: "display_name", 211 Gtk.RecentFilterFlags.MIME_TYPE: "mime_type", 212 Gtk.RecentFilterFlags.APPLICATION: "applications", 213 Gtk.RecentFilterFlags.GROUP: "groups", 214 Gtk.RecentFilterFlags.AGE: "age"} 215 needed = recent_filter.get_needed() 216 attrs = [v for k, v in getters.items() if needed & k] 217 218 filtered_items = [] 219 for i in items: 220 filter_data = {} 221 for attr in attrs: 222 filter_data[attr] = getattr(i, "get_" + attr)() 223 filter_info = Gtk.RecentFilterInfo() 224 filter_info.contains = recent_filter.get_needed() 225 for f, v in filter_data.items(): 226 # https://bugzilla.gnome.org/show_bug.cgi?id=695970 227 if isinstance(v, list): 228 continue 229 setattr(filter_info, f, v) 230 if recent_filter.filter(filter_info): 231 filtered_items.append(i) 232 return filtered_items 233 234 def __str__(self): 235 items = self.recent_manager.get_items() 236 descriptions = [] 237 for i in self._filter_items(self.recent_filter, items): 238 descriptions.append("%s\n%s\n" % (i.get_display_name(), 239 i.get_uri_display())) 240 return "\n".join(descriptions) 241 242 243recent_comparisons = RecentFiles() 244