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