1/*
2 * Copyright (C) 2015 Jérémy Munsch <jeremy.munsch@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 Jérémy Munsch <jeremy.munsch@gmail.com>
19 *
20 */
21
22namespace Synapse
23{
24  /**
25   * This plugin looks for zeal installed documentations
26   * then it allows you to make a query on a specified doc
27   * It supports queries like :
28   * zeal php:php
29   * or
30   * zeal php constant
31   * zeal php:constant
32   * php constant
33   * php magic constant
34   * php magic constant zeal
35   * php :  magic constant zeal
36   *
37   * and so it searches "php:magic constants" in zeal by opening it.
38   * It handles complicated doc names like Apache_HTTP_Server "apache license"
39   * "server license" "http license" these 3 result in "apache http server:license"
40   *
41   * An update would consist to aggregate the sqlite databases
42   * and show results directly or develop a CLI update to zeal
43   * directly.
44   */
45  public class ZealPlugin : Object, Activatable, ItemProvider
46  {
47    public bool enabled { get; set; default = true; }
48
49    Gee.List<ZealDoc> doclist;
50
51    public void activate ()
52    {
53      string docsets_path = "%s/Zeal/Zeal/docsets/".printf (Environment.get_user_data_dir ());
54      doclist = new Gee.ArrayList<ZealDoc>();
55
56      try
57      {
58        Dir dir = Dir.open (docsets_path, 0);
59        string? name = null;
60
61        while ((name = dir.read_name ()) != null)
62        {
63          string path = Path.build_filename (docsets_path, name);
64          string type = "";
65
66          if (FileUtils.test (path, FileTest.IS_REGULAR))
67            type += "| REGULAR ";
68          if (FileUtils.test (path, FileTest.IS_SYMLINK))
69            type += "| SYMLINK ";
70          if (FileUtils.test (path, FileTest.IS_DIR))
71            type += "| DIR ";
72          if (FileUtils.test (path, FileTest.IS_EXECUTABLE))
73            type += "| EXECUTABLE ";
74
75          if (path == "")
76            continue;
77
78          var zdoc = new ZealDoc (path);
79          doclist.add (zdoc);
80        }
81      }
82      catch (FileError e)
83      {
84        warning ("%s", e.message);
85      }
86    }
87
88    public void deactivate ()
89    {
90      doclist = null;
91    }
92
93    static construct
94    {
95      register_plugin ();
96    }
97
98    static void register_plugin ()
99    {
100      PluginRegistry.get_default ().register_plugin (
101        typeof (ZealPlugin),
102        _("Zeal"),
103        _("Zeal offline documentation (zealdocs.org)"),
104        "zeal",
105        register_plugin,
106        Environment.find_program_in_path ("zeal") != null,
107        _("zeal is not installed, please see zealdocs.org")
108      );
109    }
110
111    public bool handles_query (Query query)
112    {
113      return (QueryFlags.ACTIONS in query.query_type && doclist.size > 0);
114    }
115
116    public async ResultSet? search (Query q) throws SearchError
117    {
118      Idle.add (search.callback);
119      yield;
120      q.check_cancellable ();
121
122      q.query_string = q.query_string.down ().replace ("zeal", "").strip ();
123      var results = new ResultSet ();
124      var matchers = Query.get_matchers_for_query (q.query_string, 0, RegexCompileFlags.OPTIMIZE | RegexCompileFlags.CASELESS);
125
126      foreach (var doc in this.doclist)
127      {
128        foreach (var matcher in matchers)
129        {
130          if (matcher.key.match (doc.scf_bundle_name))
131          {
132            doc.update_title (q.query_string);
133            results.add (doc, matcher.value);
134          }
135        }
136
137        if (doc.regex.match (q.query_string))
138        {
139          foreach (var part in doc.scf_bundle_name.split_set ("_ "))
140            q.query_string = ZealDoc.replace_first_occurence (q.query_string, part, "");
141          doc.update_title (q.query_string.replace (":", "").strip ());
142          results.add (doc, MatchScore.AVERAGE);
143          break;
144        }
145      }
146
147      q.check_cancellable ();
148      return results;
149    }
150  }
151
152  public class ZealDoc : ActionMatch
153  {
154    public string doc_path { get; construct; }
155
156    string query = "";
157    string doc_name = "";
158    public string scf_bundle_identifier = "";
159    public string scf_bundle_name = "";
160    public Regex regex;
161    string version;
162
163    public ZealDoc (string doc_path)
164    {
165      Object (
166        title: "Zeal Doc",
167        description: _("Zeal documentation research"),
168        icon_name: "zeal",
169        has_thumbnail: false,
170        doc_path: doc_path
171      );
172    }
173
174    construct
175    {
176      parse_doc_name ();
177      parse_doc_bundle ();
178
179      try
180      {
181        regex = new Regex ("^(%s)[ :]+([a-z0-9 -_.:;,]*)".printf (string.joinv ("|", scf_bundle_name.split_set ("_ "))), RegexCompileFlags.OPTIMIZE);
182      }
183      catch (GLib.RegexError e)
184      {
185        regex = null;
186        warning ("regex error %s", e.message);
187      }
188    }
189
190    public override void do_action ()
191    {
192      try
193      {
194        AppInfo ai = AppInfo.create_from_commandline ("zeal \"%s:%s\"".printf (scf_bundle_name, query), "zeal", 0);
195        ai.launch (null, null);
196      }
197      catch (Error err)
198      {
199        warning ("Could not launch zeal %s", err.message);
200      }
201    }
202
203    public void update_title (string query)
204    {
205      this.query = query;
206      string version = this.version != null ? " (v%s)".printf (this.version) : "";
207      title = "Search for %s in %s%s".printf (query, doc_name, version);
208    }
209
210    public static string replace_first_occurence (string str, string search, string replace)
211    {
212      int pos = str.index_of (search);
213      if (pos < 0)
214        return str;
215      return str.substring (0, pos) + replace + str.substring (pos + search.length);
216    }
217
218    private void parse_doc_name ()
219    {
220      string data;
221
222      try
223      {
224        FileUtils.get_contents (doc_path + "/meta.json", out data);
225        Json.Parser parser = new Json.Parser ();
226
227        if (parser.load_from_data (data, -1))
228        {
229          Json.Node node = parser.get_root ();
230
231          if (node.get_node_type () != Json.NodeType.OBJECT)
232            throw new Json.ParserError.PARSE ("Unexpected element type %s", node.type_name ());
233
234          unowned Json.Object obj = node.get_object ();
235          foreach (unowned string name in obj.get_members ())
236          {
237            switch (name)
238            {
239              case "version":
240                unowned Json.Node item = obj.get_member (name);
241                if (item.get_node_type () != Json.NodeType.VALUE)
242                  throw new Json.ParserError.PARSE ("Unexpected element type %s", item.type_name ());
243                version = obj.get_string_member ("version");
244                break;
245
246              case "name":
247                unowned Json.Node item = obj.get_member (name);
248                if (item.get_node_type () != Json.NodeType.VALUE)
249                  throw new Json.ParserError.PARSE ("Unexpected element type %s", item.type_name ());
250                doc_name = obj.get_string_member ("name");
251                break;
252            }
253          }
254        }
255        else
256        {
257          throw new Json.ParserError.PARSE ("Unable to parse data form %s", doc_path + "/meta.json");
258        }
259      }
260      catch (Error e)
261      {
262        warning ("%s", e.message);
263      }
264    }
265
266    private void parse_doc_bundle ()
267    {
268      string contents;
269      Regex exp = /\<key\>([a-zA-Z0-9 _-]+)\<\/key\>[\n\t ]*\<string\>([a-zA-Z0-9\. _-]+)\<\/string\>/;
270
271      try
272      {
273        FileUtils.get_contents (doc_path + "/Contents/Info.plist", out contents, null);
274      }
275      catch (Error e)
276      {
277        warning ("Unable to read file: %s", e.message);
278      }
279
280      try
281      {
282        MatchInfo mi;
283        for (exp.match (contents, 0, out mi) ; mi.matches () ; mi.next ())
284        {
285          switch (mi.fetch (1))
286          {
287            case "CFBundleIdentifier":
288              scf_bundle_identifier = mi.fetch (2).down ();
289              break;
290            case "CFBundleName":
291              scf_bundle_name = mi.fetch (2).down ();
292              break;
293          }
294        }
295      }
296      catch (Error e)
297      {
298        warning ("Regex failed: %s", e.message);
299      }
300    }
301  }
302}
303