1/*
2 * Copyright (C) 2010 Michal Hruby <michal.mhr@gmail.com>
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 2 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program; if not, write to the Free Software
16 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA.
17 *
18 * Authored by Michal Hruby <michal.mhr@gmail.com>
19 *
20 */
21
22namespace Synapse
23{
24  public class DesktopFilePlugin : Object, Activatable, ItemProvider, ActionProvider
25  {
26    public bool enabled { get; set; default = true; }
27
28    public void activate ()
29    {
30
31    }
32
33    public void deactivate ()
34    {
35
36    }
37
38    private class DesktopFileMatch : ApplicationMatch
39    {
40      public DesktopFileInfo desktop_info { get; construct; }
41      public string title_folded { get; construct; }
42      public string title_unaccented { get; construct; }
43      public string desktop_id { get; construct; }
44      public string exec { get; construct; }
45
46      public DesktopFileMatch (DesktopFileInfo info)
47      {
48        Object (desktop_info : info);
49      }
50
51      construct
52      {
53        filename = desktop_info.filename;
54        title = desktop_info.name;
55        description = desktop_info.comment;
56        icon_name = desktop_info.icon_name;
57        exec = desktop_info.exec;
58        needs_terminal = desktop_info.needs_terminal;
59        title_folded = desktop_info.get_name_folded () ?? title.casefold ();
60        title_unaccented = Utils.remove_accents (title_folded);
61        desktop_id = "application://" + desktop_info.desktop_id;
62      }
63    }
64
65    static void register_plugin ()
66    {
67      PluginRegistry.get_default ().register_plugin (
68        typeof (DesktopFilePlugin),
69        "Application Search",
70        _("Search for and run applications on your computer."),
71        "system-run",
72        register_plugin
73      );
74    }
75
76    static construct
77    {
78      register_plugin ();
79    }
80
81    private Gee.List<DesktopFileMatch> desktop_files;
82
83    construct
84    {
85      desktop_files = new Gee.ArrayList<DesktopFileMatch> ();
86      mimetype_map = new Gee.HashMap<string, Gee.List<OpenWithAction>> ();
87      actions_map = new Gee.HashMap<string, Gee.List<OpenAppAction>> ();
88
89      var dfs = DesktopFileService.get_default ();
90      dfs.reload_started.connect (() => {
91        loading_in_progress = true;
92      });
93      dfs.reload_done.connect (() => {
94        mimetype_map.clear ();
95        desktop_files.clear ();
96        load_all_desktop_files.begin ();
97      });
98
99      load_all_desktop_files.begin ();
100    }
101
102    public signal void load_complete ();
103    private bool loading_in_progress = false;
104
105    private async void load_all_desktop_files ()
106    {
107      loading_in_progress = true;
108      Idle.add_full (Priority.LOW, load_all_desktop_files.callback);
109      yield;
110
111      var dfs = DesktopFileService.get_default ();
112
113      foreach (DesktopFileInfo dfi in dfs.get_desktop_files ())
114      {
115        desktop_files.add (new DesktopFileMatch (dfi));
116      }
117
118      loading_in_progress = false;
119      load_complete ();
120    }
121
122    private int compute_relevancy (DesktopFileMatch dfm, int base_relevancy)
123    {
124      var rs = RelevancyService.get_default ();
125      float popularity = rs.get_application_popularity (dfm.desktop_id);
126
127      int r = RelevancyService.compute_relevancy (base_relevancy, popularity);
128      debug ("relevancy for %s: %d", dfm.desktop_id, r);
129
130      return r;
131    }
132
133    private void full_search (Query q, ResultSet results,
134                              MatcherFlags flags = 0)
135    {
136      // try to match against global matchers and if those fail, try also exec
137      var matchers = Query.get_matchers_for_query (q.query_string_folded,
138                                                   flags);
139
140      foreach (var dfm in desktop_files)
141      {
142        unowned string folded_title = dfm.title_folded;
143        unowned string unaccented_title = dfm.title_unaccented;
144        bool matched = false;
145        // FIXME: we need to do much smarter relevancy computation in fuzzy re
146        // "sysmon" matching "System Monitor" is very good as opposed to
147        // "seto" matching "System Monitor"
148        foreach (var matcher in matchers)
149        {
150          if (matcher.key.match (folded_title))
151          {
152            results.add (dfm, compute_relevancy (dfm, matcher.value));
153            matched = true;
154            break;
155          }
156          else if (unaccented_title != null && matcher.key.match (unaccented_title))
157          {
158            results.add (dfm, compute_relevancy (dfm, matcher.value - MatchScore.INCREMENT_SMALL));
159            matched = true;
160            break;
161          }
162        }
163        if (!matched && dfm.exec.has_prefix (q.query_string))
164        {
165          results.add (dfm, compute_relevancy (dfm, dfm.exec == q.query_string ?
166            MatchScore.VERY_GOOD : MatchScore.AVERAGE - MatchScore.INCREMENT_SMALL));
167        }
168      }
169    }
170
171    public bool handles_query (Query q)
172    {
173      // we only search for applications
174      if (!(QueryFlags.APPLICATIONS in q.query_type)) return false;
175      if (q.query_string.strip () == "") return false;
176
177      return true;
178    }
179
180    public async ResultSet? search (Query q) throws SearchError
181    {
182      if (loading_in_progress)
183      {
184        // wait
185        ulong signal_id = this.load_complete.connect (() => {
186          search.callback ();
187        });
188        yield;
189        SignalHandler.disconnect (this, signal_id);
190      }
191      else
192      {
193        // we'll do this so other plugins can send their DBus requests etc.
194        // and they don't have to wait for our blocking (though fast) search
195        // to finish
196        Idle.add_full (Priority.HIGH_IDLE, search.callback);
197        yield;
198      }
199
200      q.check_cancellable ();
201
202      // FIXME: spawn new thread and do the search there?
203      var result = new ResultSet ();
204
205      if (q.query_string.char_count () == 1)
206      {
207        var flags = MatcherFlags.NO_SUBSTRING | MatcherFlags.NO_PARTIAL |
208                    MatcherFlags.NO_FUZZY;
209        full_search (q, result, flags);
210      }
211      else
212      {
213        full_search (q, result);
214      }
215
216      q.check_cancellable ();
217
218      return result;
219    }
220
221    private class OpenWithAction : Action
222    {
223      public DesktopFileInfo desktop_info { get; construct; }
224
225      public OpenWithAction (DesktopFileInfo info)
226      {
227        Object (desktop_info : info);
228      }
229
230      construct
231      {
232        title = _("Open with %s").printf (desktop_info.name);
233        icon_name = desktop_info.icon_name;
234        description = _("Opens current selection using %s").printf (desktop_info.name);
235      }
236
237      public override void do_execute (Match match, Match? target = null)
238      {
239        unowned UriMatch? uri_match = match as UriMatch;
240        return_if_fail (uri_match != null);
241
242        var f = File.new_for_uri (uri_match.uri);
243        try
244        {
245          var app_info = new DesktopAppInfo.from_filename (desktop_info.filename);
246          List<File> files = new List<File> ();
247          files.prepend (f);
248          app_info.launch (files, Gdk.Display.get_default ().get_app_launch_context ());
249        }
250        catch (Error err)
251        {
252          warning ("%s", err.message);
253        }
254      }
255
256      public override bool valid_for_match (Match match)
257      {
258        return (match is UriMatch);
259      }
260    }
261
262    private class OpenAppAction : Action
263    {
264      public DesktopFileInfo desktop_info { get; construct; }
265      public string action { get; construct; }
266
267      DesktopAppInfo app_info;
268
269      public OpenAppAction (DesktopFileInfo info, string action)
270      {
271        Object (desktop_info : info, action : action);
272      }
273
274      construct
275      {
276        app_info = new DesktopAppInfo.from_filename (desktop_info.filename);
277        var display_action = app_info.get_action_name (action);
278        title = display_action;
279        icon_name = desktop_info.icon_name;
280        description = _("Launch action '%s'").printf (display_action);
281      }
282
283      public override void do_execute (Match match, Match? target = null)
284      {
285          app_info.launch_action (action, Gdk.Display.get_default ().get_app_launch_context ());
286          RelevancyService.get_default ().application_launched (app_info);
287      }
288
289      public override bool valid_for_match (Match match)
290      {
291        return (match is DesktopFileMatch);
292      }
293    }
294
295    private Gee.Map<string, Gee.List<OpenWithAction>> mimetype_map;
296    private Gee.Map<string, Gee.List<OpenAppAction>> actions_map;
297
298    public ResultSet? find_for_match (ref Query query, Match match)
299    {
300      unowned UriMatch? uri_match = null;
301      unowned DesktopFileMatch? app_match = null;
302      Gee.List<Action>? any_list = null;
303
304      if ((uri_match = match as UriMatch) != null)
305      {
306        var dfs = DesktopFileService.get_default ();
307        var list_for_mimetype = dfs.get_desktop_files_for_type (uri_match.mime_type);
308        /* If there's more than one application, fill the ow list */
309        if (list_for_mimetype.size > 1)
310        {
311          /* Query DesktopFileService only if is necessary */
312          Gee.List<OpenWithAction>? ow_list = mimetype_map[uri_match.mime_type];
313          if (ow_list == null)
314          {
315            ow_list = new Gee.LinkedList<OpenWithAction> ();
316            mimetype_map[uri_match.mime_type] = ow_list;
317
318            foreach (var entry in list_for_mimetype)
319            {
320              ow_list.add (new OpenWithAction (entry));
321            }
322          }
323
324          any_list = ow_list;
325        }
326      }
327      else if ((app_match = match as DesktopFileMatch) != null)
328      {
329        Gee.List<OpenAppAction>? oa_list = actions_map[app_match.filename];
330        if (oa_list == null)
331        {
332          oa_list = new Gee.LinkedList<OpenAppAction> ();
333          actions_map[app_match.filename] = oa_list;
334
335          var dfs = DesktopFileService.get_default ();
336          var desktop_file_info = dfs.get_desktop_file_for_id (Path.get_basename (app_match.filename));
337
338          /* There should at a result here */
339          if (desktop_file_info != null)
340          {
341            foreach (var action in desktop_file_info.actions)
342            {
343              oa_list.add (new OpenAppAction (desktop_file_info, action));
344            }
345          }
346          else
347          {
348            warning ("No DesktopInfoFile for %s", app_match.filename);
349          }
350        }
351
352        any_list = oa_list;
353      }
354
355      if (any_list == null || any_list.size == 0) return null;
356
357      var rs = new ResultSet ();
358
359      if (query.query_string == "")
360      {
361        foreach (var action in any_list)
362        {
363          rs.add (action, MatchScore.POOR);
364        }
365      }
366      else
367      {
368        var matchers = Query.get_matchers_for_query (query.query_string, 0,
369          RegexCompileFlags.OPTIMIZE | RegexCompileFlags.CASELESS);
370        foreach (var action in any_list)
371        {
372          foreach (var matcher in matchers)
373          {
374            if (matcher.key.match (action.title))
375            {
376              rs.add (action, matcher.value);
377              break;
378            }
379          }
380        }
381      }
382
383      return rs;
384    }
385  }
386}
387