Switch to unified view

a b/unity_recoll_daemon.py
1
#! /usr/bin/python3
2
# -*- mode: python; python-indent: 2 -*-
3
#
4
# Copyright 2012 Canonical Ltd.  2013 Jean-Francois Dockes
5
#
6
# Contact: Jean-Francois Dockes <jfd@recoll.org>
7
#
8
# GPLv3
9
#
10
11
import os
12
import gettext
13
import locale
14
import sys
15
import time
16
import urllib.parse
17
import hashlib
18
import subprocess
19
20
from gi.repository import GLib, GObject, Gio
21
from gi.repository import Accounts, Signon
22
from gi.repository import GData
23
from gi.repository import Unity
24
25
try:
26
  from recoll import rclconfig
27
  hasrclconfig = True
28
except:
29
  hasrclconfig = False
30
# As a temporary measure, we also look for rclconfig as a bare
31
# module. This is so that the intermediate releases of the lens can
32
# ship and use rclconfig.py with the lens code
33
if not hasrclconfig:
34
  try:
35
    import rclconfig
36
    hasrclconfig = True
37
  except:
38
    pass
39
    
40
try:
41
  from recoll import recoll
42
  from recoll import rclextract
43
  hasextract = True
44
except:
45
  import recoll
46
  hasextract = False
47
48
APP_NAME = "unity-scope-recoll"
49
LOCAL_PATH = "/usr/share/locale/"
50
gettext.bindtextdomain(APP_NAME, LOCAL_PATH)
51
gettext.textdomain(APP_NAME)
52
_ = gettext.gettext
53
54
GROUP_NAME = 'org.recoll.Unity.Scope.File.Recoll'
55
UNIQUE_PATH = '/org/recoll/unity/scope/file/recoll'
56
SEARCH_URI = ''
57
SEARCH_HINT = _('Search Recoll index')
58
NO_RESULTS_HINT = _('Sorry, there are no documents in the Recoll index that match your search.')
59
PROVIDER_CREDITS = _('Powered by Recoll')
60
SVG_DIR = '/usr/share/icons/unity-icon-theme/places/svg/'
61
PROVIDER_ICON = SVG_DIR+'service-recoll.svg'
62
DEFAULT_RESULT_ICON = 'recoll'
63
DEFAULT_RESULT_TYPE = Unity.ResultType.PERSONAL
64
65
c0 = {'id': 'global',
66
      'name': _('File & Folders'),
67
      'icon': SVG_DIR + 'group-installed.svg',
68
      'renderer': Unity.CategoryRenderer.VERTICAL_TILE}
69
c1 = {'id': 'recent',
70
      'name': _('Recent'),
71
      'icon': SVG_DIR + 'group-installed.svg',
72
      'renderer': Unity.CategoryRenderer.VERTICAL_TILE}
73
c2 = {'id': 'download',
74
      'name': _('Download'),
75
      'icon': SVG_DIR + 'group-folders.svg',
76
      'renderer': Unity.CategoryRenderer.VERTICAL_TILE}
77
c3 = {'id': 'folders',
78
      'name': _('Folders'),
79
      'icon': SVG_DIR + 'group-folders.svg',
80
      'renderer': Unity.CategoryRenderer.VERTICAL_TILE}
81
CATEGORIES = [c0, c1, c2, c3]
82
83
FILTERS = []
84
85
EXTRA_METADATA = []
86
87
UNITY_TYPE_TO_RECOLL_CLAUSE = {
88
    "documents" : "rclcat:message rclcat:spreadsheet rclcat:text",
89
    "folders" : "mime:inode/directory", 
90
    "images" : "rclcat:media", 
91
    "audio":"rclcat:media", 
92
    "videos":"rclcat:media",
93
    "presentations" : "rclcat:presentation",
94
    "other":"rclcat:other",
95
    }
96
97
# Icon names for some recoll mime types which don't have standard icon
98
# by the normal method. Some keys are actually icon names, not mime types
99
SPEC_MIME_ICONS = {'application/x-fsdirectory' : 'gnome-fs-directory',
100
                   'inode/directory' : 'gnome-fs-directory',
101
                   'message/rfc822' : 'emblem-mail',
102
                   'message-rfc822' : 'emblem-mail',
103
                   'application/x-recoll' : 'recoll'}
104
105
# Truncate results here:
106
MAX_RESULTS = 30
107
108
# Where the thumbnails live:
109
XDGCACHE = os.getenv('XDG_CACHE_DIR', os.path.expanduser("~/.cache"))
110
THUMBDIRS = [os.path.join(XDGCACHE, "thumbnails"),
111
             os.path.expanduser("~/.thumbnails")]
112
113
def url_encode_for_thumb(in_s, offs):
114
  h = b"0123456789ABCDEF"
115
  out = in_s[:offs]
116
  for i in range(offs, len(in_s)):
117
    c = in_s[i]
118
    if c <= 0x20 or c >= 0x7f or in_s[i] in b'"#%;<>?[\\]^`{|}':
119
      out += bytes('%', 'ascii');
120
      out += bytes(chr(h[(c >> 4) & 0xf]), 'ascii')
121
      out += bytes(chr(h[c & 0xf]), 'ascii')
122
    else:
123
      out += bytes(chr(c), 'ascii')
124
      pass
125
  return out
126
127
def _get_thumbnail_path(url):
128
    """Look for a thumbnail for the input url, according to the
129
    freedesktop thumbnail storage standard. The input 'url' always
130
    begins with file:// and is binary. We encode it properly
131
    and compute the path inside the thumbnail storage
132
    directory. We return the path only if the thumbnail does exist
133
    (no generation performed)"""
134
    global THUMBDIRS
135
136
    # Compute the thumbnail file name by encoding and hashing the url string
137
    try:
138
      url = url_encode_for_thumb(url, 7)
139
    except Exception as msg:
140
        print("_get_thumbnail_path: url encode failed: %s" % msg, 
141
              file=sys.stderr)
142
        return ""
143
    #print("_get_thumbnail: encoded path: [%s]" % url, file=sys.stderr)
144
    thumbname = hashlib.md5(url).hexdigest() + ".png"
145
146
    # If the "new style" directory exists, we should stop looking in
147
    # the "old style" one (there might be interesting files in there,
148
    # but they may be stale, so it's best to not touch them). We do
149
    # this semi-dynamically so that we catch things up if the
150
    # directory gets created while we are running.
151
    if os.path.exists(THUMBDIRS[0]):
152
        THUMBDIRS = THUMBDIRS[0:1]
153
154
    # Check in appropriate directories to see if the thumbnail file exists
155
    #print("_get_thumbnail: thumbname: [%s]" % thumbname, file=sys.stderr)
156
    for topdir in THUMBDIRS:
157
        for dir in ("large", "normal"): 
158
            tpath = os.path.join(topdir, dir, thumbname)
159
            #print("Testing [%s]" % (tpath,), file=sys.stderr)
160
            if os.path.exists(tpath):
161
                return tpath
162
163
    return ""
164
165
166
class RecollScopePreviewer(Unity.ResultPreviewer):
167
  def do_run(self):
168
    icon = Gio.ThemedIcon.new(self.result.icon_hint)
169
    preview = Unity.GenericPreview.new(self.result.title, 
170
                                       self.result.comment.strip(), icon)
171
    view_action = Unity.PreviewAction.new("open", _("Open"), None)
172
    preview.add_action(view_action)
173
    show_action = Unity.PreviewAction.new("show", _("Show in Folder"), None)
174
    preview.add_action(show_action)
175
    return preview
176
177
class RecollScope(Unity.AbstractScope):
178
  __g_type_name__ = "RecollScope"
179
180
  def __init__(self):
181
    super(RecollScope, self).__init__()
182
    self.search_in_global = True;
183
    lng, self.localecharset = locale.getdefaultlocale()
184
185
  def do_get_search_hint (self):
186
    return SEARCH_HINT
187
188
  def do_get_schema (self):
189
    #print("RecollScope: do_get_schema", file=sys.stderr)
190
    schema = Unity.Schema.new ()
191
    if EXTRA_METADATA:
192
      for m in EXTRA_METADATA:
193
        schema.add_field(m['id'], m['type'], m['field'])
194
    #FIXME should be REQUIRED for credits
195
    schema.add_field('provider_credits', 's', 
196
                     Unity.SchemaFieldType.OPTIONAL)
197
    return schema
198
199
  def do_get_categories(self):
200
    #print("RecollScope: do_get_categories", file=sys.stderr)
201
    cs = Unity.CategorySet.new ()
202
    if CATEGORIES:
203
      for c in CATEGORIES:
204
        cat = Unity.Category.new (c['id'], c['name'],
205
                                  Gio.ThemedIcon.new(c['icon']),
206
                                  c['renderer'])
207
        cs.add (cat)
208
    return cs
209
210
  def do_get_filters(self):
211
    #print("RecollScope: do_get_filters", file=sys.stderr)
212
    filters = Unity.FilterSet.new()
213
    f = Unity.RadioOptionFilter.new(
214
      "modified", _("Last modified"),     
215
      Gio.ThemedIcon.new("input-keyboard-symbolic"), False)
216
    f.add_option ("last-7-days", _("Last 7 days"), None)
217
    f.add_option ("last-30-days", _("Last 30 days"), None)
218
    f.add_option ("last-year", _("Last year"), None);
219
    filters.add(f)
220
221
    f2 = Unity.CheckOptionFilter.new (
222
      "type", _("Type"), Gio.ThemedIcon.new("input-keyboard-symbolic"), False)
223
    f2.add_option ("documents", _("Documents"), None)
224
    f2.add_option ("folders", _("Folders"), None)
225
    f2.add_option ("images", _("Images"), None)
226
    f2.add_option ("audio", _("Audio"), None)
227
    f2.add_option ("videos", _("Videos"), None)
228
    f2.add_option ("presentations", _("Presentations"), None)
229
    f2.add_option ("other", _("Other"), None)
230
    filters.add (f2)
231
232
    f3 = Unity.MultiRangeFilter.new (
233
      "size", _("Size"), Gio.ThemedIcon.new("input-keyboard-symbolic"), False)
234
    f3.add_option ("1kb", _("1KB"), None)
235
    f3.add_option ("100kb", _("100KB"), None)
236
    f3.add_option ("1mb", _("1MB"), None)
237
    f3.add_option ("10mb", _("10MB"), None)
238
    f3.add_option ("100mb", _("100MB"), None)
239
    f3.add_option ("1gb", _("1GB"), None)
240
    f3.add_option (">1gb", _(">1GB"), None)
241
    filters.add (f3)
242
243
    return filters
244
245
  def do_get_group_name(self):
246
    return GROUP_NAME
247
248
  def do_get_unique_name(self):
249
    return UNIQUE_PATH
250
251
  def do_create_search_for_query(self, search_context):
252
    #print("RecollScope: do_create_search_for query", file=sys.stderr)
253
    return RecollScopeSearch(search_context)
254
255
  def do_activate(self, result, metadata, id):
256
    print("RecollScope: do_activate. id [%s] uri [%s]" % (id, result.uri), 
257
          file=sys.stderr)
258
    if id == 'show':
259
      os.system("nautilus '%s'" % str(result.uri))
260
      return Unity.ActivationResponse(handled=Unity.HandledType.HIDE_DASH,
261
                                      goto_uri=None)
262
    else:
263
      uri = result.uri
264
      # Pass all uri without fragments to the desktop handler
265
      if uri.find("#") == -1:
266
        return Unity.ActivationResponse(handled=Unity.HandledType.NOT_HANDLED,
267
                                         goto_uri=uri)
268
      # Pass all others to recoll
269
      proc = subprocess.Popen(["recoll", uri])
270
      ret = Unity.ActivationResponse(handled=Unity.HandledType.HIDE_DASH,
271
                                     goto_uri=None)
272
      return ret
273
274
  def do_create_previewer(self, result, metadata):
275
    #print("RecollScope: do_create_previewer", file=sys.stderr)
276
    previewer = RecollScopePreviewer()
277
    previewer.set_scope_result(result)
278
    previewer.set_search_metadata(metadata)
279
    return previewer
280
281
282
class RecollScopeSearch(Unity.ScopeSearchBase):
283
  __g_type_name__ = "RecollScopeSearch"
284
285
  def __init__(self, search_context):
286
    super(RecollScopeSearch, self).__init__()
287
    self.set_search_context(search_context)
288
    self.max_results = MAX_RESULTS
289
    if hasrclconfig:
290
      self.config = rclconfig.RclConfig()
291
      try:
292
        self.max_results = int(self.config.getConfParam("unityscopemaxresults"))
293
      except:
294
        pass
295
296
  def connect_db(self):
297
    #print("RecollScopeSearch: connect_db", file=sys.stderr)
298
    self.db = None
299
    dblist = []
300
    if hasrclconfig:
301
      extradbs = rclconfig.RclExtraDbs(self.config)
302
      dblist = extradbs.getActDbs()
303
    try:
304
      self.db = recoll.connect(extra_dbs=dblist)
305
      self.db.setAbstractParams(maxchars=200, contextwords=4)
306
    except Exception as s:
307
      print("RecollScope: Error connecting to db: %s" % s, file=sys.stderr)
308
      return
309
310
  def do_run(self):
311
    #print("RecollScopeSearch: do_run", file=sys.stderr)
312
    context = self.search_context
313
    filters = context.filter_state
314
    search_string = context.search_query.strip()
315
    if not search_string or search_string is None:
316
      return
317
    result_set = context.result_set
318
    
319
    # Get the list of documents
320
    is_global = context.search_type == Unity.SearchType.GLOBAL
321
    self.connect_db()
322
323
    # We do not want filters to effect global results
324
    catgf = ""
325
    datef = ""
326
    if not is_global:
327
      catgf = self.catg_filter(filters)
328
      datef = self.date_filter(filters)
329
      sizef = self.size_filter(filters)
330
      search_string = " ".join((search_string, catgf, datef, sizef))
331
    else:
332
      print("RecollScopeSearch: GLOBAL", file=sys.stderr)
333
334
    # Do the recoll thing
335
    try:
336
      query = self.db.query()
337
      nres = query.execute(search_string)
338
    except Exception as msg:
339
      print("recoll query execute error: %s" % msg, file=sys.stderr)
340
      return
341
342
    print("RecollScopeSearch::do_run: [%s] -> %d results" % 
343
          (search_string, nres), file=sys.stderr)
344
345
    actual_results = 0
346
    for i in range(nres):
347
      try:
348
        doc = query.fetchone()
349
      except:
350
        break
351
352
      titleorfilename = doc.title
353
      if titleorfilename is None or titleorfilename == "":
354
        titleorfilename = doc.filename
355
      if titleorfilename is None:
356
        titleorfilename = "doc.title and doc.filename are none !"
357
358
      url, mimetype, iconname = self.icon_for_type (doc)
359
360
      try:
361
        abstract = self.db.makeDocAbstract(doc, query)
362
      except:
363
        pass
364
365
      # Ok, I don't understand this category thing for now...
366
      category = 0
367
      #print({"uri":url,"icon":iconname,"category":category,
368
       #      "mimetype":mimetype, "title":titleorfilename,
369
        #     "comment":abstract,
370
         #    "dnd_uri":doc.url}, file=sys.stderr)
371
372
      result_set.add_result(
373
        uri=url,
374
        icon=iconname,
375
        category=category,
376
        result_type=Unity.ResultType.PERSONAL,
377
        mimetype=mimetype,
378
        title=titleorfilename,
379
        comment=abstract,
380
        dnd_uri=doc.url)
381
382
      actual_results += 1
383
      if actual_results >= self.max_results:
384
        break
385
386
  def date_filter (self, filters):
387
    print("RecollScopeSearch: date_filter", file=sys.stderr)
388
    dateopt = ""
389
    f = filters.get_filter_by_id("modified")
390
    if f != None:
391
      o = f.get_active_option()
392
      if o != None:
393
        if o.props.id == "last-year":
394
          dateopt="date:P1Y/"
395
        elif o.props.id == "last-30-days":
396
          dateopt = "date:P1M/"
397
        elif o.props.id == "last-7-days":
398
          dateopt = "date:P7D/"
399
    #print("RecollScopeSearch::date_filter:[%s]" % dateopt, file=sys.stderr)
400
    return dateopt
401
402
  def catg_filter(self, filters):
403
    print("RecollScopeSearch::catg_filter", file=sys.stderr)
404
    f = filters.get_filter_by_id("type")
405
    if not f: return ""
406
    if not f.props.filtering:
407
      return ""
408
    ss = ""
409
    for fopt in f.options:
410
      if fopt.props.active:
411
        if fopt.props.id in UNITY_TYPE_TO_RECOLL_CLAUSE:
412
          ss += " " + UNITY_TYPE_TO_RECOLL_CLAUSE[fopt.props.id]
413
    #print("RecollScopSearch::catg_filter:[%s]" % ss, file=sys.stderr)
414
    return ss
415
416
  def size_filter(self, filters):
417
    print("RecollScopeSearch::size_filter", file=sys.stderr)
418
    f = filters.get_filter_by_id("size")
419
    if not f: return ""
420
    if not f.props.filtering:
421
      return ""
422
    min = f.get_first_active()
423
    max = f.get_last_active()
424
    if min.props.id == max.props.id:
425
      # Take it as < except if it's >1gb
426
      if max.props.id == ">1gb":
427
        ss = " size>1g"
428
      else:
429
        ss = " size<" + min.props.id
430
    else:
431
      if max.props.id == ">1gb":
432
        ss = "size>" + min.props.id
433
      else:
434
        ss = " size>" + min.props.id + " size<" + max.props.id
435
    #print("RecollScopeSearch::size_filter: [%s]" % ss, file=sys.stderr)
436
    return ss
437
438
  # Send back a useful icon depending on the document type
439
  def icon_for_type (self, doc):
440
    iconname = "text-x-preview"
441
    # Results with an ipath get a special mime type so that they
442
    # get opened by starting a recoll instance.
443
    thumbnail = ""
444
    if doc.ipath != "":
445
      mimetype = "application/x-recoll"
446
      url = doc.url + "#" + doc.ipath
447
    else:
448
      mimetype = doc.mimetype
449
      url = doc.url
450
      # doc.url is a unicode string which is badly wrong. 
451
      # Retrieve the binary path for thumbnail access.
452
      thumbnail = _get_thumbnail_path(doc.getbinurl())
453
454
    iconname = ""
455
    if thumbnail:
456
      iconname = thumbnail
457
    else:
458
      content_type = Gio.content_type_from_mime_type(doc.mimetype)
459
      icon = Gio.content_type_get_icon(content_type)
460
      if icon:
461
        # At least on Quantal, get_names() sometimes returns
462
        # a list with '(null)' in the first position...
463
        for iname in icon.get_names():
464
          if iname != '(null)':
465
            iconname = iname
466
            break
467
      if iconname in SPEC_MIME_ICONS:
468
          iconname = SPEC_MIME_ICONS[iconname]
469
      if iconname == "":
470
        if doc.mimetype in SPEC_MIME_ICONS:
471
          iconname = SPEC_MIME_ICONS[doc.mimetype]
472
473
    return (url, mimetype, iconname);
474
475
476
def load_scope():
477
  return RecollScope()