]> git.neil.brown.name Git - edlib.git/blob - python/module-notmuch.py
5039bd2eb1d92647db7d494d69bcf1b7d8d23609
[edlib.git] / python / module-notmuch.py
1 # -*- coding: utf-8 -*-
2 # Copyright Neil Brown ©2016-2023 <neil@brown.name>
3 # May be distributed under terms of GPLv2 - see file:COPYING
4 #
5 # edlib module for working with "notmuch" email.
6 #
7 # Two document types:
8 # - search list: list of saved searches with count of 'current', 'unread',
9 #   and 'new' messages
10 # - message list: provided by notmuch-search, though probably with enhanced threads
11 #
12 # Messages and composition is handled by a separte 'email' module.  This
13 # module will send messages to that module and provide services for composing
14 # and delivery.
15 #
16 # These can be all in with one pane, with sub-panes, or can sometimes
17 # have a pane to themselves.
18 #
19 # saved search are stored in config (file or database) as "query.foo"
20 # Some are special.
21 # "query.current" selects messages that have not been archived, and are not spam
22 # "query.unread" selects messages that should be highlighted. It is normally
23 # "tag:unread"
24 # "query.new" selects new messages. Normally "tag:new not tag:unread"
25 # "query.current-list" should be a conjunction of "query:" searches.  They are listed
26 #  in the "search list" together with a count of 'current' and 'current/new' messages.
27 # "query.misc-list" is a subset of current-list for which query:current should not
28 # be assumed.
29 # "query.from-list" is a list of other queries for which it is meaningful
30 #   to add "from:address" clauses for some address.
31 # "query.to-list" is a list of other queries for which it is meaningful
32 #   to add "to:address" clauses for some address.
33
34 import edlib
35
36 from subprocess import Popen, PIPE, DEVNULL, TimeoutExpired
37 import re
38 import tempfile
39 import os, fcntl
40 import json
41 import time
42 import mimetypes
43 import email.utils
44
45 def cvt_size(n):
46     if n < 1000:
47         return "%3db" % n
48     if n < 10000:
49         return "%3.1fK" % (float(n)/1000.0)
50     if n < 1000000:
51         return "%3dK" % (n/1000)
52     if n < 10000000:
53         return "%3.1fM" % (float(n)/1000000.0)
54     if n < 1000000000:
55         return "%3dM" % (n/1000000)
56     return "BIG!"
57
58 def notmuch_get_tags(msg=None,thread=None):
59     if msg:
60         query = "id:" + msg
61     elif thread:
62         query = "thread:" + thread
63     else:
64         query = '*'
65
66     p = Popen(["/usr/bin/notmuch","search","--output=tags",query],
67               stdout = PIPE, stderr = DEVNULL)
68     if not p:
69         return
70     try:
71         out,err = p.communicate(timeout=5)
72     except IOError:
73         return
74     except TimeoutExpired:
75         p.kill()
76         out,err = p.communicate()
77         return
78     return out.decode("utf-8","ignore").strip('\n').split('\n')
79
80 def notmuch_get_files(msg):
81     query = "id:" + msg
82
83     p = Popen(["/usr/bin/notmuch","search","--output=files", "--format=text0",
84                query],
85               stdout = PIPE, stderr = DEVNULL)
86     if not p:
87         return
88     try:
89         out,err = p.communicate(timeout=5)
90     except IOError:
91         return
92     except TimeoutExpired:
93         p.kill()
94         out,err = p.communicate()
95         return
96     return out.decode("utf-8","ignore").strip('\0').split('\0')
97
98 def notmuch_set_tags(msg=None, thread=None, add=None, remove=None):
99     if not add and not remove:
100         return
101     if msg:
102         query = "id:" + msg
103     elif thread:
104         query = "thread:" + thread
105     else:
106         return
107     argv = ["/usr/bin/notmuch","tag"]
108     if add:
109         for i in add:
110             argv.append("+" + i)
111     if remove:
112         for i in remove:
113             argv.append("-" + i)
114     argv.append(query)
115     p = Popen(argv, stdin = DEVNULL, stdout = DEVNULL, stderr = DEVNULL)
116     # FIXME I have to wait so that a subsequent 'get' works.
117     p.communicate()
118
119 def notmuch_start_load_thread(tid, query=None):
120     if query:
121         q = "thread:%s and (%s)" % (tid, query)
122     else:
123         q = "thread:%s" % (tid)
124     argv = ["/usr/bin/notmuch", "show", "--format=json", q]
125     p = Popen(argv, stdin = DEVNULL, stdout = PIPE, stderr = DEVNULL)
126     return p
127
128 def notmuch_load_thread(tid, query=None):
129     p = notmuch_start_load_thread(tid, query)
130     out,err = p.communicate()
131     if not out:
132         return None
133     # we sometimes sees "[[[[]]]]" as the list of threads,
134     # which isn't properly formatted. So add an extr check on
135     # r[0][0][0]
136     # This can happen when the file containing message cannot be found.
137     r = json.loads(out.decode("utf-8","ignore"))
138     if not r or type(r[0][0][0]) != dict:
139         return None
140     # r is a list of threads, we want just one thread.
141     return r[0]
142
143 class counter:
144     # manage a queue of queries that need to be counted.
145     def __init__(self, make_search, pane, cb):
146         self.make_search = make_search
147         self.pane = pane
148         self.cb = cb
149         self.queue = []
150         self.pending = None
151         self.p = None
152
153     def enqueue(self, q, priority = False):
154         if priority:
155             if q in self.queue:
156                 self.queue.remove(q)
157             self.queue.insert(0, q)
158         else:
159             if q in self.queue:
160                 return
161             self.queue.append(q)
162         self.next()
163
164     def is_pending(self, q):
165         if self.pending == q:
166             return 1 # first
167         if q in self.queue:
168             return 2 # later
169         return 0
170
171     def next(self):
172         if self.p:
173             return True
174         if not self.queue:
175             return False
176         q = self.queue.pop(0)
177         self.pending = q
178         self.p = Popen("/usr/bin/notmuch count --batch", shell=True, stdin=PIPE,
179                        stdout = PIPE, stderr = DEVNULL)
180         try:
181             self.p.stdin.write((self.make_search(q) + "\n").encode("utf-8"))
182             self.p.stdin.write((self.make_search(q, 'unread') + "\n").encode("utf-8"))
183             self.p.stdin.write((self.make_search(q, 'new') + "\n").encode("utf-8"))
184         except BrokenPipeError:
185             pass
186         self.p.stdin.close()
187         self.start = time.time()
188         self.pane.call("event:read", self.p.stdout.fileno(), self.ready)
189         return True
190
191     def ready(self, key, **a):
192         q = self.pending
193         count = 0; unread = 0; new = 0
194         slow = time.time() - self.start > 5
195         self.pending = None
196         try:
197             c = self.p.stdout.readline()
198             count = int(c)
199             u = self.p.stdout.readline()
200             unread = int(u)
201             nw = self.p.stdout.readline()
202             new = int(nw)
203         except:
204             pass
205         p = self.p
206         self.p = None
207         more = self.next()
208         self.cb(q, count, unread, new, slow, more)
209         p.wait()
210         # return False to tell event handler there is no more to read.
211         return edlib.Efalse
212
213 class searches:
214     # Manage the saved searches
215     # We read all searches from the config file and periodically
216     # update some stored counts.
217     #
218     # This is used to present the search-list document.
219     def __init__(self, pane, cb):
220         self.slist = {}
221         self.current = []
222         self.misc = []
223         self.count = {}
224         self.unread = {}
225         self.new = {}
226         self.tags = []
227         self.slow = {}
228         self.worker = counter(self.make_search, pane, self.updated)
229         self.slow_worker = counter(self.make_search, pane, self.updated)
230         self.cb = cb
231
232         if 'NOTMUCH_CONFIG' in os.environ:
233             self.path = os.environ['NOTMUCH_CONFIG']
234         elif 'HOME' in os.environ:
235             self.path = os.environ['HOME'] + "/.notmuch-config"
236         else:
237             self.path = ".notmuch-config"
238         self.mtime = 0
239         self.maxlen = 0
240
241     def set_tags(self, tags):
242         self.tags = list(tags)
243
244     def load(self, reload = False):
245         try:
246             stat = os.stat(self.path)
247             mtime = stat.st_mtime
248         except OSError:
249             mtime = 0
250         if not reload and mtime <= self.mtime:
251             return False
252
253         p = Popen("notmuch config list", shell=True, stdout=PIPE)
254         if not p:
255             return False
256         self.slist = {}
257         for line in p.stdout:
258             line = line.decode("utf-8", "ignore")
259             if not line.startswith('query.'):
260                 continue
261             w = line[6:].strip().split("=", 1)
262             self.slist[w[0]] = w[1]
263             if len(w[0]) > self.maxlen:
264                 self.maxlen = len(w[0])
265         try:
266             p.communicate()
267         except IOError:
268             pass
269         if "current-list" not in self.slist:
270             self.slist["current-list"] = "query:inbox query:unread"
271             if "current" not in self.slist:
272                 self.slist["misc-list"] = "query:inbox query:unread"
273             if "inbox" not in self.slist:
274                 self.slist["inbox"] = "tag:inbox"
275             if "unread" not in self.slist:
276                 self.slist["unread"] = "tag:inbox AND tag:unread"
277
278         if "misc-list" not in self.slist:
279             self.slist["misc-list"] = ""
280         if "unread" not in self.slist:
281             self.slist["unread"] = "tag:unread"
282         if "new" not in self.slist:
283             self.slist["new"] = "(tag:new AND tag:unread)"
284
285         self.current = self.searches_from("current-list")
286         self.misc = self.searches_from("misc-list")
287
288         self.slist["-ad hoc-"] = ""
289         self.current.append("-ad hoc-")
290         self.misc.append("-ad hoc-")
291
292         for t in self.tags:
293             tt = "tag:" + t
294             if tt not in self.slist:
295                 self.slist[tt] = tt
296                 self.current.append(tt)
297                 self.misc.append(tt)
298
299         for i in self.current:
300             if i not in self.count:
301                 self.count[i] = None
302                 self.unread[i] = None
303                 self.new[i] = None
304         self.mtime = mtime
305
306         return True
307
308     def is_pending(self, search):
309         return self.worker.is_pending(search) + self.slow_worker.is_pending(search)
310
311     def update(self):
312         for i in self.current:
313             if not self.slist[i]:
314                 # probably an empty -ad hoc-
315                 continue
316             if i in self.slow:
317                 self.slow_worker.enqueue(i)
318             else:
319                 self.worker.enqueue(i)
320         return self.worker.pending != None and self.slow_worker.pending != None
321
322     def update_one(self, search):
323         if not self.slist[search]:
324             return
325         if search in self.slow:
326             self.slow_worker.enqueue(search, True)
327         else:
328             self.worker.enqueue(search, True)
329
330     def updated(self, q, count, unread, new, slow, more):
331         changed = (self.count[q] != count or
332                    self.unread[q] != unread or
333                    self.new[q] != new)
334         self.count[q] = count
335         self.unread[q] = unread
336         self.new[q] = new
337         if slow:
338             self.slow[q] = slow
339         elif q in self.slow:
340             del self.slow[q]
341         self.cb(q, changed,
342                 self.worker.pending == None and
343                 self.slow_worker.pending == None)
344
345     patn = "\\bquery:([-_A-Za-z0-9]*)\\b"
346     def map_search(self, query):
347         m = re.search(self.patn, query)
348         while m:
349             s = m.group(1)
350             if s in self.slist:
351                 q = self.slist[s]
352                 query = re.sub('\\bquery:' + s + '\\b',
353                                '(' + q + ')', query)
354             else:
355                 query = re.sub('\\bquery:' + s + '\\b',
356                                'query-'+s, query)
357             m = re.search(self.patn, query)
358         return query
359
360     def make_search(self, name, extra = None):
361         s = '(' + self.slist[name] + ')'
362         if name not in self.misc:
363             s = s + " AND query:current"
364         if extra:
365             s = s + " AND query:" + extra
366         return self.map_search(s)
367
368     def searches_from(self, n):
369         ret = []
370         if n in self.slist:
371             for s in self.slist[n].split(" "):
372                 if s.startswith('query:'):
373                     ret.append(s[6:])
374         return ret
375
376 def make_composition(db, focus, which = "PopupTile", how = "MD3tsa", tag = None):
377     dir = db['config:database.path']
378     if not dir:
379         dir = "/tmp"
380     drafts = os.path.join(dir, "Drafts")
381     try:
382         os.mkdir(drafts)
383     except FileExistsError:
384         pass
385
386     fd, fname = tempfile.mkstemp(dir=drafts)
387     os.close(fd)
388     m = focus.call("doc:open", fname, -1, ret='pane')
389     m.call("doc:set-name", "*Unsent mail message*")
390     # always good to have a blank line, incase we add an attachment early.
391     m.call("doc:replace", "\n")
392     m['view-default'] = 'compose-email'
393     m['email-sent'] = 'no'
394     name = db['config:user.name']
395     mainfrom = db['config:user.primary_email']
396     altfrom = db['config:user.other_email']
397     altfrom2 = db['config:user.other_email_deprecated']
398     host_address = db['config:user.host_address']
399     if name:
400         m['email:name'] = name
401     if mainfrom:
402         m['email:from'] = mainfrom
403     if altfrom:
404         m['email:altfrom'] = altfrom
405     if altfrom2:
406         m['email:deprecated_from'] = altfrom2
407     if host_address:
408         m['email:host-address'] = host_address
409     set_tag = ""
410     if tag:
411         set_tag = "/usr/bin/notmuch %s;" % tag
412     m['email:sendmail'] = set_tag + "/usr/bin/notmuch insert --folder=sent --create-folder -new -unread +outbox"
413     # NOTE this cannot be in ThisPane, else the pane we want to copy
414     # content from will disappear.
415     # I think Popuptile is best, with maybe an option to expand it
416     # after the copy is done.
417     if which != "PopupTile":
418         how = None
419     p = focus.call(which, how, ret='pane')
420     if not p:
421         return edlib.Efail
422     v = m.call("doc:attach-view", p, 1, ret='pane')
423     if v:
424         v.take_focus()
425     return v
426
427 # There are two document types.
428 #   notmuch_main presents all the saved searches, and also represents the database
429 #      of all messages.  There is only one of these.
430 #   notmuch_query presents a single query as a number of threads, each with a number
431 #      of messages.
432
433 class notmuch_main(edlib.Doc):
434     # This is the document interface for the saved-search list.
435     # It contains the searches as items which have attributes
436     # providing name, count, unread-count
437     # Once activated it auto-updates every 5 minutes
438     # Updating first handled "counts" where the 3 counters for each
439     # saved search are checked, then "queries" which each current
440     # thread-list document is refreshed.
441     #
442     # We create a container pane to collect all these thread-list documents
443     # to hide them from the general *Documents* list.
444     #
445     # Only the 'offset' of doc-references is used.  It is an index
446     # into the list of saved searches.
447     #
448
449     def __init__(self, focus):
450         edlib.Doc.__init__(self, focus)
451         self.searches = searches(self, self.updated)
452         self.timer_set = False
453         self.updating = False
454         self.querying = False
455         self.container = edlib.Pane(self.root)
456         self.changed_queries = []
457
458     def handle_shares_ref(self, key, **a):
459         "handle:doc:shares-ref"
460         return 1
461
462     def handle_close(self, key, **a):
463         "handle:Close"
464         self.container.close()
465         return 1
466
467     def handle_val_mark(self, key, mark, mark2, **a):
468         "handle:debug:validate-marks"
469         if not mark or not mark2:
470             return edlib.Enoarg
471         if mark.pos == mark2.pos:
472             if mark.offset < mark2.offset:
473                 return 1
474             edlib.LOG("notmuch_main val_marks: same pos, bad offset:",
475                       mark.offset, mark2.offset)
476             return edlib.Efalse
477         if mark.pos is None:
478             edlib.LOG("notmuch_main val_mark: mark.pos is None")
479             return edlib.Efalse
480         if mark2.pos is None:
481             return 1
482         if mark.pos < mark2.pos:
483             return 1
484         edlib.LOG("notmuch_main val_mark: pos in wrong order:",
485                   mark.pos, mark2.pos)
486         return edlib.Efalse
487
488     def handle_set_ref(self, key, mark, num, **a):
489         "handle:doc:set-ref"
490         self.to_end(mark, num != 1)
491         if num == 1:
492             mark.pos = 0
493         else:
494             mark.pos = len(self.searches.current)
495         if mark.pos == len(self.searches.current):
496             mark.pos = None
497         mark.offset = 0
498         return 1
499
500     def handle_doc_char(self, key, focus, mark, num, num2, mark2, **a):
501         "handle:doc:char"
502         if not mark:
503             return edlib.Enoarg
504         end = mark2
505         steps = num
506         forward = 1 if steps > 0 else 0
507         if end and end == mark:
508             return 1
509         if end and (end < mark) != (steps < 0):
510             # can never cross 'end'
511             return edlib.Einval
512         ret = edlib.Einval
513         while steps and ret != edlib.WEOF and (not end or mark == end):
514             ret = self.handle_step(key, mark, forward, 1)
515             steps -= forward * 2 - 1
516         if end:
517             return 1 + (num - steps if forward else steps - num)
518         if ret == edlib.WEOF or num2 == 0:
519             return ret
520         if num and (num2 < 0) == (num > 0):
521             return ret
522         # want the next character
523         return self.handle_step(key, mark, 1 if num2 > 0 else 0, 0)
524
525     def handle_step(self, key, mark, num, num2):
526         forward = num
527         move = num2
528         ret = edlib.WEOF
529         target = mark
530         if mark.pos is None:
531             pos = len(self.searches.current)
532         else:
533             pos = mark.pos
534         if forward and not mark.pos is None:
535             ret = '\n'
536             if move:
537                 mark.step_sharesref(forward)
538                 mark.pos = pos + 1
539                 mark.offset = 0
540                 if mark.pos == len(self.searches.current):
541                     mark.pos = None
542         if not forward and pos > 0:
543             ret = '\n'
544             if move:
545                 mark.step_sharesref(forward)
546                 mark.pos = pos - 1
547                 mark.offset = 0
548         return ret
549
550     def handle_doc_get_attr(self, key, focus, mark, str, comm2, **a):
551         "handle:doc:get-attr"
552         # This must support the line-format used in notmuch_list_view
553
554         attr = str
555         o = mark.pos
556         val = None
557         if not o is None and o >= 0:
558             s = self.searches.current[o]
559             if attr == 'query':
560                 val = s
561             elif attr == 'fmt':
562                 if self.searches.new[s]:
563                     val = "bold,fg:red"
564                 elif self.searches.unread[s]:
565                     val = "bold,fg:blue"
566                 elif self.searches.count[s]:
567                     val = "fg:black"
568                 else:
569                     val = "fg:grey"
570                 if focus['qname'] == s:
571                     if focus['filter']:
572                         val = "bg:red+60,"+val
573                     else:
574                         val = "bg:yellow+20,"+val
575             elif attr == 'name':
576                 val = s
577             elif attr == 'count':
578                 c = self.searches.new[s]
579                 if not c:
580                     c = self.searches.unread[s]
581                 if not c:
582                     c = self.searches.count[s]
583                 if c is None:
584                     val = "%5s" % "?"
585                 elif c < 100000:
586                     val = "%5d" % c
587                 elif c < 10000000:
588                     val = "%4dK" % int(c/1000)
589                 else:
590                     val = "%4dM" % int(c/1000000)
591             elif attr == 'space':
592                 p = self.searches.is_pending(s)
593                 if p == 1:
594                     val = '*'
595                 elif p > 1:
596                     val = '?'
597                 elif s in self.searches.slow:
598                     val = '!'
599                 else:
600                     val = ' '
601         if val:
602             comm2("callback", focus, val, mark, str)
603             return 1
604         return edlib.Efallthrough
605
606     def handle_get_attr(self, key, focus, str, comm2, **a):
607         "handle:get-attr"
608         if not comm2 or not str:
609             return edlib.Enoarg
610         if str == "doc-type":
611             comm2("callback", focus, "notmuch")
612             return 1
613         if str == "notmuch:max-search-len":
614             comm2("callback", focus, "%d" % self.searches.maxlen)
615             return 1
616         if str.startswith('config:'):
617             p = Popen(['/usr/bin/notmuch', 'config', 'get', str[7:]],
618                       close_fds = True,
619                       stderr = PIPE, stdout = PIPE)
620             out,err = p.communicate()
621             p.wait()
622             if out:
623                 comm2("callback", focus,
624                       out.decode("utf-8","ignore").strip(), str)
625             return 1
626         return edlib.Efallthrough
627
628     def handle_request_notify(self, key, focus, **a):
629         "handle:doc:notmuch:request:Notify:Tag"
630         focus.add_notify(self, "Notify:Tag")
631         return 1
632
633     def handle_notmuch_update(self, key, **a):
634         "handle:doc:notmuch:update"
635         if not self.timer_set:
636             self.timer_set = True
637             self.call("event:timer", 5*60*1000, self.tick)
638         tags = notmuch_get_tags()
639         if tags:
640             self.searches.set_tags(tags)
641         self.tick('tick')
642         return 1
643
644     def handle_notmuch_update_one(self, key, str, **a):
645         "handle:doc:notmuch:update-one"
646         self.searches.update_one(str)
647         # update display of updating status flags
648         self.notify("doc:replaced")
649         return 1
650
651     def handle_notmuch_query(self, key, focus, str, comm2, **a):
652         "handle:doc:notmuch:query"
653         # Find or create a search-result document as a
654         # child of the collection document - it remains private
655         # and doesn't get registered in the global list
656         q = self.searches.make_search(str)
657         nm = None
658         it = self.container.children()
659         for child in it:
660             if child("doc:notmuch:same-search", str, q) == 1:
661                 nm = child
662                 break
663         if (nm and nm.notify("doc:notify-viewers") == 0 and
664             int(nm['last-refresh']) + 8*60*60 < int(time.time())):
665             # no-one is looking and they haven't for a long time,
666             # so just discard this one.
667             nm.close()
668             nm = None
669         if not nm:
670             nm = notmuch_query(self, str, q)
671             # FIXME This is a a bit ugly.  I should pass self.container
672             # as the parent, but notmuch_query needs to stash maindoc
673             # Also I should use an edlib call to get notmuch_query
674             nm.reparent(self.container)
675             nm.call("doc:set-name", str)
676         elif nm.notify("doc:notify-viewers") == 0 and nm['need-update']:
677             # no viewers, so trigger a full update to discard old content
678             nm.call("doc:notmuch:query:reload")
679         elif nm['need-update'] or int(nm['last-refresh']) + 60 < int(time.time()):
680             nm.call("doc:notmuch:query-refresh")
681         nm['background-update'] = "0"
682         if comm2:
683             comm2("callback", focus, nm)
684         return 1
685
686     def handle_notmuch_byid(self, key, focus, str1, str2, comm2, **a):
687         "handle:doc:notmuch:byid"
688         # Return a document for the email message.
689         # This is a global document.
690         fn = notmuch_get_files(str1)
691         if not fn:
692             return Efail
693         doc = focus.call("doc:open", "email:"+fn[0], -2, ret='pane')
694         if doc:
695             doc['notmuch:id'] = str1
696             doc['notmuch:tid'] = str2
697             for i in range(len(fn)):
698                 doc['notmuch:fn-%d' % i] = fn[i]
699             comm2("callback", doc)
700         return 1
701
702     def handle_notmuch_byid_tags(self, key, focus, num2, str, comm2, **a):
703         "handle:doc:notmuch:byid:tags"
704         # return a string with tags of message
705         t = notmuch_get_tags(msg = str)
706         if t is None:
707             return edlib.Efalse
708         tags = ",".join(t)
709         comm2("callback", focus, tags)
710         return 1
711
712     def handle_notmuch_bythread_tags(self, key, focus, str, comm2, **a):
713         "handle:doc:notmuch:bythread:tags"
714         # return a string with tags of all messages in thread
715         t = notmuch_get_tags(thread = str)
716         if t is None:
717             return edlib.Efalse
718         tags = ",".join(t)
719         comm2("callback", focus, tags)
720         return 1
721
722     def handle_notmuch_query_updated(self, key, **a):
723         "handle:doc:notmuch:query-updated"
724         # A child search document has finished updating.
725         self.next_query()
726         return 1
727
728     def handle_notmuch_mark_read(self, key, str, str2, **a):
729         "handle:doc:notmuch:mark-read"
730         notmuch_set_tags(msg=str2, remove = ["unread", "new"])
731         self.notify("Notify:Tag", str, str2)
732         return 1
733
734     def handle_notmuch_remove_tag(self, key, str, str2, **a):
735         "handle-prefix:doc:notmuch:tag-"
736         if key.startswith("doc:notmuch:tag-add-"):
737             add = True
738             tag = key[20:]
739         elif key.startswith("doc:notmuch:tag-remove-"):
740             add = False
741             tag = key[23:]
742         else:
743             return Enoarg
744
745         if str2:
746             # adjust a list of messages
747             for id in str2.split("\n"):
748                 if add:
749                     notmuch_set_tags(msg=id, add=[tag])
750                 else:
751                     notmuch_set_tags(msg=id, remove=[tag])
752                 self.notify("Notify:Tag", str, id)
753         else:
754             # adjust whole thread
755             if add:
756                 notmuch_set_tags(thread=str, add=[tag])
757             else:
758                 notmuch_set_tags(thread=str, remove=[tag])
759             self.notify("Notify:Tag", str)
760         # FIXME can I ever optimize out the Notify ??
761         return 1
762
763     def handle_set_adhoc(self, key, focus, str, **a):
764         "handle:doc:notmuch:set-adhoc"
765         if str:
766             self.searches.slist["-ad hoc-"] = str
767         else:
768             self.searches.slist["-ad hoc-"] = ""
769         return 1
770
771     def handle_get_query(self, key, focus, str1, comm2, **a):
772         "handle:doc:notmuch:get-query"
773         if str1 and str1 in self.searches.slist:
774             comm2("cb", focus, self.searches.slist[str1])
775         return 1
776
777     def handle_set_query(self, key, focus, str1, str2, **a):
778         "handle:doc:notmuch:set-query"
779         if not (str1 and str2):
780             return edlib.Enoarg
781         self.searches.slist[str1] = str2
782         p = Popen(["/usr/bin/notmuch", "config", "set",
783                    "query."+str1, str2],
784                   stdout = DEVNULL, stderr=DEVNULL, stdin=DEVNULL)
785         try:
786             p.communicate(timeout=5)
787         except TimeoutExpired:
788             p.kill()
789             p.communicate()
790             return edlib.Efalse
791         if p.returncode != 0:
792             return edlib.Efalse
793         return 1
794
795     def tick(self, key, **a):
796         if not self.updating:
797             self.searches.load(False)
798             self.updating = True
799             self.searches.update()
800             # updating status flags might have change
801             self.notify("doc:replaced")
802         for c in self.container.children():
803             if c.notify("doc:notify-viewers") == 0:
804                 # no point refreshing this, might be time to close it
805                 lr = c['last-refresh']
806                 if int(lr) + 8*60*60 < int(time.time()):
807                     c.call("doc:closed")
808         return 1
809
810     def updated(self, query, changed, finished):
811         if finished:
812             self.updating = False
813         if changed:
814             self.changed_queries.append(query)
815             if not self.querying:
816                 self.next_query()
817         # always trigger 'replaced' as scan-status symbols may change
818         self.notify("doc:replaced")
819
820     def next_query(self):
821         self.querying = False
822         while self.changed_queries:
823             q = self.changed_queries.pop(0)
824             for c in self.container.children():
825                 if c['qname'] == q:
826                     if c.notify("doc:notify-viewers") > 0:
827                         # there are viewers, so just do a refresh.
828                         self.querying = True
829                         c("doc:notmuch:query-refresh")
830                         # will get callback when time to continue
831                         return
832                     elif int(c['background-update']) == 0:
833                         # First update with no viewers - full refresh
834                         c.call("doc:set:background-update",
835                                "%d" % int(time.time()))
836                         self.querying = True
837                         c("doc:notmuch:query:reload")
838                         # will get callback when time to continue
839                         return
840                     elif int(time.time()) - int(c['background-update']) < 5*60:
841                         # less than 5 minutes, keep updating
842                         self.querying = True
843                         c("doc:notmuch:query-refresh")
844                         # will get callback when time to continue
845                         return
846                     else:
847                         # Just mark for refresh-on-visit
848                         c.call("doc:set:need-update", "true")
849
850 # notmuch_query document
851 # a mark.pos is a list of thread-id and message-id.
852
853 class notmuch_query(edlib.Doc):
854     def __init__(self, focus, qname, query):
855         edlib.Doc.__init__(self, focus)
856         self.maindoc = focus
857         self.query = query
858         self.filter = ""
859         self['qname'] = qname
860         self['query'] = query
861         self['filter'] = ""
862         self['last-refresh'] = "%d" % int(time.time())
863         self['need-update'] = ""
864         self.threadids = []
865         self.threads = {}
866         self.messageids = {}
867         self.threadinfo = {}
868         self["render-default"] = "notmuch:threads"
869         self["line-format"] = ("<%BG><%TM-hilite>%TM-date_relative</>" +
870                                "<tab:130> <fg:blue>%TM-authors</>" +
871                                "<tab:350>%TM-size%TM-threadinfo<%TM-hilite>" +
872                                "<fg:red,bold>%TM-flag</>" +
873                                "<wrap-tail:,wrap-nounderline,wrap-head:         ,wrap> </>" +
874                                "<wrap-margin><fg:#FF8C00-40,action-activate:notmuch:select-1>%TM-subject</></></>")
875         self.add_notify(self.maindoc, "Notify:Tag")
876         self.add_notify(self.maindoc, "Notify:Close")
877         self['doc-status'] = ""
878         self.p = None
879         self.marks_unstable = False
880
881         self.this_load = None
882         self.load_thread_active = False
883         self.thread_queue = []
884         self.load_full()
885
886     def open_email(self, key, focus, str1, str2, comm2, **a):
887         "handle:doc:notmuch:open"
888         if not str1 or not str2:
889             return edlib.Enoarg
890         if str2 not in self.threadinfo:
891             return edlib.Efalse
892         minfo = self.threadinfo[str2]
893         if str1 not in minfo:
894             return edlib.Efalse
895         try:
896             fn = minfo[str1][0][0]
897         except:
898             return edlib.Efalse
899         try:
900             # timestamp
901             ts = minfo[str1][1]
902         except:
903             ts = 0
904         doc = focus.call("doc:open", "email:"+fn, -2, ret='pane')
905         if doc:
906             doc['notmuch:id'] = str1
907             doc['notmuch:tid'] = str2
908             doc['notmuch:timestamp'] = "%d"%ts
909             for i in range(len(minfo[str1][0])):
910                 doc['notmuch:fn-%d' % i] = minfo[str1][0][i]
911             comm2("callback", doc)
912         return 1
913
914     def set_filter(self, key, focus, str, **a):
915         "handle:doc:notmuch:set-filter"
916         if not str:
917             str = ""
918         if self.filter == str:
919             return 1
920         self.filter = str
921         self['filter'] = str
922         self.load_full()
923         self.notify("doc:replaced")
924         self.maindoc.notify("doc:replaced", 1)
925         return 1
926
927     def handle_shares_ref(self, key, **a):
928         "handle:doc:shares-ref"
929         return 1
930
931     def handle_val_mark(self, key, mark, mark2, **a):
932         "handle:debug:validate-marks"
933         if not mark or not mark2:
934             return edlib.Enoarg
935         if mark.pos == mark2.pos:
936             if mark.offset < mark2.offset:
937                 return 1
938             edlib.LOG("notmuch_query val_marks: same pos, bad offset:",
939                       mark.offset, mark2.offset)
940             return edlib.Efalse
941         if mark.pos is None:
942             edlib.LOG("notmuch_query val_mark: mark.pos is None")
943             return edlib.Efalse
944         if mark2.pos is None:
945             return 1
946         t1,m1 = mark.pos
947         t2,m2 = mark2.pos
948         if t1 == t2:
949             if m1 is None:
950                 edlib.LOG("notmuch_query val_mark: m1 mid is None",
951                           mark.pos, mark2.pos)
952                 return edlib.Efalse
953             if m2 is None:
954                 return 1
955             if self.messageids[t1].index(m1) < self.messageids[t1].index(m2):
956                 return 1
957             edlib.LOG("notmuch_query val_mark: messages in wrong order",
958                       mark.pos, mark2.pos)
959             return edlib.Efalse
960         if self.marks_unstable:
961             return 1
962         if self.threadids.index(t1) < self.threadids.index(t2):
963             return 1
964         edlib.LOG("notmuch_query val_mark: pos in wrong order:",
965                   mark.pos, mark2.pos)
966         edlib.LOG_BT()
967         return edlib.Efalse
968
969     def setpos(self, mark, thread, msgnum = 0):
970         if thread is None:
971             mark.pos = None
972             return
973         if thread in self.messageids:
974             msg = self.messageids[thread][msgnum]
975         else:
976             msg = None
977         mark.pos = (thread, msg)
978         mark.offset = 0
979
980     def load_full(self):
981         if self.p:
982             # busy, don't reload just now
983             return
984         self.partial = False
985         self.age = 1
986
987         # mark all threads inactive, so any that remain that way
988         # can be pruned.
989         for id in self.threads:
990             self.threads[id]['total'] = 0
991         self.offset = 0
992         self.tindex = 0
993         self.pos = edlib.Mark(self)
994         self['need-update'] = ""
995         self.start_load()
996
997     def load_update(self):
998         if self.p:
999             # busy, don't reload just now
1000             return
1001
1002         self.partial = True
1003         self.age = None
1004
1005         self.offset = 0
1006         self.tindex = 0
1007         self.pos = edlib.Mark(self)
1008         self['need-update'] = ""
1009         self.start_load()
1010
1011     def start_load(self):
1012         self['last-refresh'] = "%d" % int(time.time())
1013         cmd = ["/usr/bin/notmuch", "search", "--output=summary",
1014                "--format=json", "--limit=100", "--offset=%d" % self.offset ]
1015         if self.partial:
1016             cmd += [ "date:-24hours.. AND " ]
1017         elif self.age:
1018             cmd += [ "date:-%ddays.. AND " % (self.age * 30)]
1019         if self.filter:
1020             cmd += [ "( %s ) AND " % self.filter ]
1021         cmd += [ "( %s )" % self.query ]
1022         self['doc-status'] = "Loading..."
1023         self.notify("doc:status-changed")
1024         self.p = Popen(cmd, shell=False, stdout=PIPE, stderr = DEVNULL)
1025         self.call("event:read", self.p.stdout.fileno(), self.get_threads)
1026
1027     def get_threads(self, key, **a):
1028         found = 0
1029         was_empty = not self.threadids
1030         try:
1031             tl = json.load(self.p.stdout)
1032         except:
1033             tl = []
1034         for j in tl:
1035             tid = j['thread']
1036             found += 1
1037             while (self.tindex < len(self.threadids) and
1038                    self.threads[self.threadids[self.tindex]]["timestamp"] > j["timestamp"]):
1039                 # Skip over this thread before inserting
1040                 tid2 = self.threadids[self.tindex]
1041                 self.tindex += 1
1042                 while self.pos.pos and self.pos.pos[0] == tid2:
1043                     self.call("doc:step-thread", self.pos, 1, 1)
1044             need_update = False
1045             if tid in self.threads and tid in self.messageids:
1046                 oj = self.threads[tid]
1047                 if  (oj['timestamp'] != j['timestamp'] or
1048                      (oj['total'] != 0 and oj['total'] != j['total']) or
1049                      oj['matched'] != j['matched']):
1050                     need_update = True
1051
1052             self.threads[tid] = j
1053             old = -1
1054             if self.tindex >= len(self.threadids) or self.threadids[self.tindex] != tid:
1055                 # need to insert and possibly move the old marks
1056                 try:
1057                     old = self.threadids.index(tid)
1058                 except ValueError:
1059                     pass
1060                 if old >= 0:
1061                     # debug:validate-marks looks in self.threadids
1062                     # which is about to become inconsistent
1063                     self.marks_unstable = True
1064                 self.threadids.insert(self.tindex, tid)
1065                 self.tindex += 1
1066             if old >= 0:
1067                 # move marks on tid to before self.pos
1068                 if old < self.tindex - 1:
1069                     m = self.first_mark()
1070                     self.tindex -= 1
1071                 else:
1072                     m = self.pos
1073                     old += 1
1074                 self.threadids.pop(old)
1075                 while (m and m.pos and m.pos[0] != tid and
1076                        self.threadids.index(m.pos[0]) < old):
1077                     m = m.next_any()
1078                 self.pos.step_sharesref(0)
1079                 mp = self.pos.prev_any()
1080                 if mp and mp.pos and mp.pos[0] == tid:
1081                     # All marks for tid are already immediately before self.pos
1082                     # so nothing to be moved.
1083                     m = None
1084                 while m and m.pos and m.pos[0] == tid:
1085                     m2 = m.next_any()
1086                     # m needs to be before pos
1087                     if m.seq > self.pos.seq:
1088                         m.to_mark_noref(self.pos)
1089                     elif self.pos.prev_any().seq != m.seq:
1090                         m.to_mark_noref(self.pos.prev_any())
1091                     m = m2
1092                 self.marks_unstable = False
1093                 self.notify("notmuch:thread-changed", tid, 1)
1094             if need_update:
1095                 if self.pos.pos and self.pos.pos[0] == tid:
1096                     self.load_thread(self.pos, sync=False)
1097                 else:
1098                     # might be previous thread
1099                     m = self.pos.dup()
1100                     self.prev(m)
1101                     self.call("doc:step-thread", m, 0, 1)
1102                     if m.pos and m.pos[0] == tid:
1103                         self.load_thread(m, sync=False)
1104
1105         tl = None
1106         if self.p:
1107             self.p.wait()
1108         self['doc-status'] = ""
1109         self.notify("doc:status-changed")
1110         self.p = None
1111         if was_empty and self.threadids:
1112             # first insertion, all marks other than self.pos must be at start
1113             m = self.first_mark()
1114             while m and m.pos is None:
1115                 m2 = m.next_any()
1116                 if m.seq != self.pos.seq:
1117                     m.step_sharesref(0)
1118                     self.setpos(m, self.threadids[0])
1119                 m = m2
1120         self.notify("doc:replaced")
1121         if found < 100 and self.age == None:
1122             # must have found them all
1123             self.pos = None
1124             if not self.partial:
1125                 self.prune()
1126             self.call("doc:notmuch:query-updated")
1127             return edlib.Efalse
1128         # request some more
1129         if found > 3:
1130             # allow for a little over-lap across successive calls
1131             self.offset += found - 3
1132         if found < 5:
1133             # stop worrying about age
1134             self.age = None
1135         if found < 100 and self.age:
1136             self.age += 1
1137         self.start_load()
1138         return edlib.Efalse
1139
1140     def prune(self):
1141         # remove any threads with a 'total' of zero.
1142         # Any marks on them must be moved later
1143         m = edlib.Mark(self)
1144         while m.pos != None:
1145             m2 = m.dup()
1146             tid = m.pos[0]
1147             self.call("doc:step-thread", 1, 1, m2)
1148             if self.threads[tid]['total'] == 0:
1149                 # notify viewers to close threads
1150                 self.notify("notmuch:thread-changed", tid)
1151                 m.step_sharesref(0)
1152                 while m < m2:
1153                     m.pos = m2.pos
1154                     m = m.next_any()
1155                 del self.threads[tid]
1156                 self.threadids.remove(tid)
1157             m = m2
1158
1159     def cvt_depth(self, depth):
1160         # depth is an array of int
1161         # 2 is top-level in the thread, normally only one of these
1162         # 1 at the end of the array means there are children
1163         # 1 before the end means there are more children at this depth
1164         # 0 means no more children at this depth
1165         ret = ""
1166
1167         for level in depth[:-2]:
1168             ret += u" │ "[level]
1169         ret += u"╰├─"[depth[-2]]
1170         ret += u"─┬"[depth[-1]]
1171
1172         return ret + "> "
1173
1174     def add_message(self, msg, lst, info, depth, old_ti):
1175         # Add the message ids in depth-first order into 'lst',
1176         # and for each message, place summary info info in info[mid]
1177         # particularly including a 'depth' description which is a
1178         # list of "1" if this message is not the last reply to the parent,
1179         # else "0".
1180         # If old_ti, then the thread is being viewed and messages mustn't
1181         # disappear - so preserve the 'matched' value.
1182         m = msg[0]
1183         mid = m['id']
1184         lst.append(mid)
1185         l = msg[1]
1186         was_matched = old_ti and mid in old_ti and old_ti[mid][2]
1187         info[mid] = (m['filename'], m['timestamp'],
1188                      m['match'] or was_matched,
1189                      depth + [1 if l else 0],
1190                      m['headers']["From"],
1191                      m['headers']["Subject"],
1192                      m['tags'], -1)
1193         if l:
1194             l.sort(key=lambda m:(m[0]['timestamp'],m[0]['headers']['Subject']))
1195             for m in l[:-1]:
1196                 self.add_message(m, lst, info, depth + [1], old_ti)
1197             self.add_message(l[-1], lst, info, depth + [0], old_ti)
1198
1199     def step_load_thread(self):
1200         # start thread loading, either current with query, or next
1201         if not self.this_load:
1202             if not self.thread_queue:
1203                 self.load_thread_active = False
1204                 return
1205             self.this_load = self.thread_queue.pop(0)
1206         tid, mid, m, query = self.this_load
1207         self.thread_text = b""
1208         self.load_thread_active = True
1209         self.thread_p = notmuch_start_load_thread(tid, query)
1210         fd = self.thread_p.stdout.fileno()
1211         fl = fcntl.fcntl(fd, fcntl.F_GETFL)
1212         fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
1213         self.call("event:read", fd, self.load_thread_read)
1214
1215     def load_thread_read(self, key, **a):
1216         try:
1217             b = os.read(self.thread_p.stdout.fileno(), 4096)
1218             while b:
1219                 self.thread_text += b
1220                 b = os.read(self.thread_p.stdout.fileno(), 4096)
1221         except IOError:
1222             # More to be read
1223             return 1
1224         self.thread_p.wait()
1225         self.thread_p = None
1226         # Must have read EOF to get here.
1227         th = json.loads(self.thread_text.decode("utf-8","ignore"))
1228         self.thread_text = None
1229         tid, mid, m, query = self.this_load
1230         if query and not th:
1231             # query must exclude everything
1232             self.this_load = (tid, mid, m, None)
1233         else:
1234             self.merge_thread(tid, mid, m, th[0])
1235             self.this_load = None
1236         self.step_load_thread()
1237         return edlib.Efalse
1238
1239     def load_thread(self, mark, sync):
1240         (tid, mid) = mark.pos
1241         if not sync:
1242             self.thread_queue.append((tid, mid, mark.dup(), self.query))
1243             if not self.load_thread_active:
1244                 self.step_load_thread()
1245             return
1246
1247         thread = notmuch_load_thread(tid, self.query)
1248         if not thread:
1249             thread = notmuch_load_thread(tid)
1250         if not thread:
1251             return
1252         self.merge_thread(tid, mid, mark, thread)
1253
1254     def thread_is_open(self, tid):
1255         return self.notify("notmuch:thread-open", tid) > 0
1256
1257     def merge_thread(self, tid, mid, mark, thread):
1258         # thread is a list of top-level messages
1259         # in each m[0] is the message as a dict
1260         thread.sort(key=lambda m:(m[0]['timestamp'],m[0]['headers']['Subject']))
1261         midlist = []
1262         minfo = {}
1263
1264         if tid in self.threadinfo and self.thread_is_open(tid):
1265             # need to preserve all messages currently visible
1266             old_ti = self.threadinfo[tid]
1267         else:
1268             old_ti = None
1269         for m in thread:
1270             self.add_message(m, midlist, minfo, [2], old_ti)
1271         self.messageids[tid] = midlist
1272         self.threadinfo[tid] = minfo
1273
1274         if mid is None:
1275             # need to update all marks at this location to hold mid
1276             m = mark
1277             pos = (tid, midlist[0])
1278             while m and m.pos and m.pos[0] == tid:
1279                 m.pos = pos
1280                 m = m.prev_any()
1281             m = mark.next_any()
1282             while m and m.pos and m.pos[0] == tid:
1283                 m.pos = pos
1284                 m = m.next_any()
1285         else:
1286             # Need to make sure all marks on this thread are properly
1287             # ordered.  If we find two marks out of order, the pos of
1288             # the second is changed to match the first.
1289             m = mark
1290             prev = m.prev_any()
1291             while prev and prev.pos and prev.pos[0] == tid:
1292                 m = prev
1293                 prev = m.prev_any()
1294             ind = 0
1295             midlist = self.messageids[tid]
1296             while m and m.pos and m.pos[0] == tid:
1297                 if m.pos[1] not in midlist:
1298                     self.setpos(m, tid, ind)
1299                 else:
1300                     mi = midlist.index(m.pos[1])
1301                     if mi < ind:
1302                         self.setpos(m, tid, ind)
1303                     else:
1304                         ind = mi
1305                 m = m.next_any()
1306             self.notify("notmuch:thread-changed", tid, 2)
1307
1308     def get_matched(self, key, focus, num, str, comm2, **a):
1309         "handle:doc:notmuch-query:matched-mids"
1310         if str not in self.threadinfo:
1311             return edlib.Efalse
1312         ti = self.threadinfo[str]
1313         ret = []
1314         for mid in ti:
1315             if num or ti[mid][2]:
1316                 # this message matches, or viewing all messages
1317                 ret.append(mid)
1318         comm2("cb", focus, '\n'.join(ret))
1319         return 1
1320
1321     def get_replies(self, key, focus, num, str, str2, comm2, **a):
1322         "handle:doc:notmuch-query:matched-replies"
1323         if str not in self.threadinfo:
1324             return edlib.Efalse
1325         ti = self.threadinfo[str]
1326         mi = self.messageids[str]
1327         if str2 not in mi:
1328             return edlib.Efalse
1329         i = mi.index(str2)
1330         d = ti[str2][3]
1331         dpos = len(d) - 1
1332         # d[ppos] will be 1 if there are more replies.
1333         ret = [str2]
1334         i += 1
1335         while i < len(mi) and dpos < len(d) and d[dpos]:
1336             mti = ti[mi[i]]
1337             if num or mti[2]:
1338                 # is a match
1339                 ret.append(mi[i])
1340             d = ti[mi[i]][3]
1341             i += 1
1342             while dpos < len(d) and d[dpos] == 0:
1343                 # no more children at this level, but maybe below
1344                 dpos += 1
1345
1346         comm2("cb", focus, '\n'.join(ret))
1347         return 1
1348
1349     def rel_date(self, sec):
1350         then = time.localtime(sec)
1351         now = time.localtime()
1352         nows = time.time()
1353         if sec < nows and sec > nows - 60:
1354             val = "%d secs. ago" % (nows - sec)
1355         elif sec < nows and sec > nows - 60*60:
1356             val = "%d mins. ago" % ((nows - sec)/60)
1357         elif sec < nows and sec > nows - 60*60*6:
1358             mn = (nows - sec) / 60
1359             hr = int(mn/60)
1360             mn -= 60*hr
1361             val = "%dh%dm ago" % (hr,mn)
1362         elif then[:3] == now[:3]:
1363             val = time.strftime("Today %H:%M", then)
1364         elif sec > nows:
1365             val = time.strftime("%Y-%b-%d!", then)
1366         elif sec > nows - 7 * 24 * 3600:
1367             val = time.strftime("%a %H:%M", then)
1368         elif then[0] == now[0]:
1369             val = time.strftime("%d/%b %H:%M", then)
1370         else:
1371             val = time.strftime("%Y-%b-%d", then)
1372         val = "              " + val
1373         val = val[-13:]
1374         return val
1375
1376     def step(self, mark, forward, move):
1377         if forward:
1378             if mark.pos is None:
1379                 return edlib.WEOF
1380             if not move:
1381                 return '\n'
1382             (tid,mid) = mark.pos
1383             mark.step_sharesref(1)
1384             i = self.threadids.index(tid)
1385             if mid:
1386                 j = self.messageids[tid].index(mid) + 1
1387             if mid and j < len(self.messageids[tid]):
1388                 self.setpos(mark, tid, j)
1389             elif i+1 < len(self.threadids):
1390                 tid = self.threadids[i+1]
1391                 self.setpos(mark, tid, 0)
1392             else:
1393                 self.setpos(mark, None)
1394             mark.step_sharesref(1)
1395             return '\n'
1396         else:
1397             j = 0
1398             if mark.pos == None:
1399                 i = len(self.threadids)
1400             else:
1401                 (tid,mid) = mark.pos
1402                 i = self.threadids.index(tid)
1403                 if mid:
1404                     j = self.messageids[tid].index(mid)
1405             if i == 0 and j == 0:
1406                 return edlib.WEOF
1407             if not move:
1408                 return '\n'
1409             mark.step_sharesref(0)
1410
1411             if j == 0:
1412                 i -= 1
1413                 tid = self.threadids[i]
1414                 if tid in self.messageids:
1415                     j = len(self.messageids[tid])
1416                 else:
1417                     j = 1
1418             j -= 1
1419             self.setpos(mark, tid, j)
1420             mark.step_sharesref(0)
1421
1422             return '\n'
1423
1424     def handle_notify_tag(self, key, str, str2, **a):
1425         "handle:Notify:Tag"
1426         if str2:
1427             # re-evaluate tags of a single message
1428             if str in self.threadinfo:
1429                 t = self.threadinfo[str]
1430                 if str2 in t:
1431                     tg = t[str2][6]
1432                     s = self.maindoc.call("doc:notmuch:byid:tags", str2, ret='str')
1433                     tg[:] = s.split(",")
1434
1435         if str in self.threads:
1436             t = self.threads[str]
1437             s = self.maindoc.call("doc:notmuch:bythread:tags", str, ret='str')
1438             t['tags'] = s.split(",")
1439         self.notify("doc:replaced")
1440         return 1
1441
1442     def handle_notify_close(self, key, focus, **a):
1443         "handle:Notify:Close"
1444         if focus == self.maindoc:
1445             # Main doc is closing, so must we
1446             self.close()
1447             return 1
1448         return edlib.Efallthrough
1449
1450     def handle_set_ref(self, key, mark, num, **a):
1451         "handle:doc:set-ref"
1452         self.to_end(mark, num != 1)
1453         mark.pos = None
1454         if num == 1 and len(self.threadids) > 0:
1455             self.setpos(mark, self.threadids[0], 0)
1456         mark.offset = 0
1457         return 1
1458
1459     def handle_doc_char(self, key, focus, mark, num, num2, mark2, **a):
1460         "handle:doc:char"
1461         if not mark:
1462             return edlib.Enoarg
1463         end = mark2
1464         steps = num
1465         forward = 1 if steps > 0 else 0
1466         if end and end == mark:
1467             return 1
1468         if end and (end < mark) != (steps < 0):
1469             # can never cross 'end'
1470             return edlib.Einval
1471         ret = edlib.Einval
1472         while steps and ret != edlib.WEOF and (not end or mark == end):
1473             ret = self.handle_step(key, mark, forward, 1)
1474             steps -= forward * 2 - 1
1475         if end:
1476             return 1 + (num - steps if forward else steps - num)
1477         if ret == edlib.WEOF or num2 == 0:
1478             return ret
1479         if num and (num2 < 0) == (num > 0):
1480             return ret
1481         # want the next character
1482         return self.handle_step(key, mark, 1 if num2 > 0 else 0, 0)
1483
1484     def handle_step(self, key, mark, num, num2):
1485         forward = num
1486         move = num2
1487         return self.step(mark, forward, move)
1488
1489     def handle_step_thread(self, key, mark, num, num2, **a):
1490         "handle:doc:step-thread"
1491         # Move to the start of the current thread, or the start
1492         # of the next one.
1493         forward = num
1494         move = num2
1495         if forward:
1496             if mark.pos == None:
1497                 return edlib.WEOF
1498             if not move:
1499                 return "\n"
1500             (tid,mid) = mark.pos
1501             m2 = mark.next_any()
1502             while m2 and m2.pos != None and m2.pos[0] == tid:
1503                 mark.to_mark(m2)
1504                 m2 = mark.next_any()
1505             i = self.threadids.index(tid) + 1
1506             if i < len(self.threadids):
1507                 self.setpos(mark, self.threadids[i], 0)
1508             else:
1509                 self.setpos(mark, None)
1510             return '\n'
1511         else:
1512             if mark.pos == None:
1513                 # EOF is not in a thread, so to move to the start
1514                 # we must stary where we are. Moving further would be in
1515                 # a different thread.
1516                 return self.prior(mark)
1517
1518             (tid,mid) = mark.pos
1519             m2 = mark.prev_any()
1520             while m2 and (m2.pos == None or m2.pos[0] == tid):
1521                 mark.to_mark(m2)
1522                 m2 = mark.prev_any()
1523             self.setpos(mark, tid, 0)
1524             return '\n'
1525
1526     def handle_step_matched(self, key, mark, num, num2, **a):
1527         "handle:doc:step-matched"
1528         # Move to the next/prev message which is matched
1529         # or to an unopened thread
1530         forward = num
1531         move = num2
1532         m = mark
1533         if not move:
1534             m = mark.dup()
1535         ret = self.step(m, forward, 1)
1536         while ret != edlib.WEOF and m.pos != None:
1537             (tid,mid) = m.pos
1538             if not mid:
1539                 break
1540             ms = self.threadinfo[tid][mid]
1541             if ms[2]:
1542                 break
1543             ret = self.step(m, forward, 1)
1544         return ret
1545
1546     def handle_to_thread(self, key, mark, str, **a):
1547         "handle:doc:notmuch:to-thread"
1548         # move to first message of given thread.
1549         if not mark or not str:
1550             return edlib.Enoarg
1551         if str not in self.threadids:
1552             return edlib.Efalse
1553         if not mark.pos or self.threadids.index(mark.pos[0]) > self.threadids.index(str):
1554             # step backward
1555             self.call("doc:step-thread", 0, 1, mark)
1556             while (self.prev(mark) and
1557                    self.call("doc:step-thread", 0, 1, mark, ret='char')  and
1558                    mark.pos and
1559                    self.threadids.index(mark.pos[0]) > self.threadids.index(str)):
1560                 # keep going
1561                 pass
1562             return 1
1563         elif self.threadids.index(mark.pos[0]) < self.threadids.index(str):
1564             # step forward
1565             while (self.call("doc:step-thread", 1, 1, mark, ret='char') and
1566                    mark.pos and
1567                    self.threadids.index(mark.pos[0]) < self.threadids.index(str)):
1568                 # keep going
1569                 pass
1570             return 1
1571         else:
1572             # start of thread
1573             self.call("doc:step-thread", 0, 1, mark)
1574         return 1
1575
1576     def handle_to_message(self, key, mark, str, **a):
1577         "handle:doc:notmuch:to-message"
1578         # move to given message in current thread
1579         if not mark or not str:
1580             return edlib.Enoarg
1581         if not mark.pos or mark.pos[0] not in self.messageids:
1582             return edlib.Efalse
1583         mlist = self.messageids[mark.pos[0]]
1584         if str not in mlist:
1585             return edlib.Efalse
1586         i = mlist.index(str)
1587         while mark.pos and mark.pos[1] and mlist.index(mark.pos[1]) > i and self.prev(mark):
1588             # keep going back
1589             pass
1590         while mark.pos and mark.pos[1] and mlist.index(mark.pos[1]) < i and self.next(mark):
1591             # keep going forward
1592             pass
1593         return 1 if mark.pos and mark.pos[1] == str else edlib.Efalse
1594
1595     def handle_doc_get_attr(self, key, mark, focus, str, comm2, **a):
1596         "handle:doc:get-attr"
1597         attr = str
1598         if not mark or mark.pos == None:
1599             # No attributes for EOF
1600             return 1
1601         (tid,mid) = mark.pos
1602         i = self.threadids.index(tid)
1603         j = 0
1604         if mid:
1605             j = self.messageids[tid].index(mid)
1606
1607         val = None
1608
1609         tid = self.threadids[i]
1610         t = self.threads[tid]
1611         if mid:
1612             m = self.threadinfo[tid][mid]
1613         else:
1614             m = ("", 0, False, [0,0], "" ,"", t["tags"], 0)
1615         (fn, dt, matched, depth, author, subj, tags, size) = m
1616         if attr == "message-id":
1617             val = mid
1618         elif attr == "thread-id":
1619             val = tid
1620         elif attr == "T-hilite":
1621             if "inbox" not in t["tags"]:
1622                 # FIXME maybe I should test 'current' ??
1623                 val = "fg:grey"
1624             elif "new" in t["tags"] and "unread" in t["tags"]:
1625                 val = "fg:red,bold"
1626             elif "unread" in t["tags"]:
1627                 val = "fg:blue"
1628             else:
1629                 val = "fg:black"
1630         elif attr == "T-flag":
1631             if 'deleted' in t["tags"]:
1632                 val = "🗑"  # WASTEBASKET     #1f5d1
1633             elif 'flagged' in t["tags"]:
1634                 val = "★"  # BLACK STAR       #2605
1635             elif 'newspam' in t["tags"]:
1636                 val = "✘"  # HEAVY BALLOT X   #2718
1637             elif 'notspam' in t["tags"]:
1638                 val = "✔"  # HEAVY CHECK MARK #2714
1639             elif 'replied' in t["tags"]:
1640                 val = "↵"  # DOWNWARDS ARROW WITH CORNER LEFTWARDS #21B5
1641             elif 'forwarded' in t["tags"]:
1642                 val = "→"  # RIGHTWARDS ARROW #2192
1643             else:
1644                 val = " "
1645         elif attr == "T-date_relative":
1646             val = self.rel_date(t['timestamp'])
1647         elif attr == "T-threadinfo":
1648             val = "[%d/%d]" % (t['matched'],t['total'])
1649             while len(val) < 7:
1650                 val += ' '
1651         elif attr == "T-size":
1652             val = ""
1653         elif attr[:2] == "T-" and attr[2:] in t:
1654             val = t[attr[2:]]
1655             if type(val) == int:
1656                 val = "%d" % val
1657             elif type(val) == list:
1658                 val = ','.join(val)
1659             else:
1660                 # Some mailers use ?Q to insert =0A (newline) in a subject!!
1661                 val = t[attr[2:]].replace('\n',' ')
1662             if attr == "T-authors":
1663                 val = val[:20]
1664
1665         elif attr == "matched":
1666             val = "True" if matched else "False"
1667         elif attr == "tags" or attr == "M-tags":
1668             val = ','.join(tags)
1669         elif attr == "M-hilite":
1670             if "inbox" not in tags:
1671                 val = "fg:grey"
1672                 if "new" in tags and "unread" in tags:
1673                     val = "fg:pink"
1674             elif "new" in tags and "unread" in tags:
1675                 val = "fg:red,bold"
1676             elif "unread" in tags:
1677                 val = "fg:blue"
1678             else:
1679                 val = "fg:black"
1680         elif attr == "M-flag":
1681             if 'deleted' in tags:
1682                 val = "🗑"  # WASTEBASKET     #1f5d1
1683             elif 'flagged' in tags:
1684                 val = "★"  # BLACK STAR       #2605
1685             elif 'newspam' in tags:
1686                 val = "✘"  # HEAVY BALLOT X   #2718
1687             elif 'notspam' in tags:
1688                 val = "✔"  # HEAVY CHECK MARK #2714
1689             elif 'replied' in tags:
1690                 val = "↵"  # DOWNWARDS ARROW WITH CORNER LEFTWARDS #21B5
1691             elif 'forwarded' in tags:
1692                 val = "→"  # RIGHTWARDS ARROW #2192
1693             else:
1694                 val = " "
1695         elif attr == "M-date_relative":
1696             val = self.rel_date(dt)
1697         elif attr == "M-authors":
1698             val = author[:20].replace('\n',' ')
1699         elif attr == "M-subject":
1700             val = subj.replace('\n',' ')
1701         elif attr == "M-threadinfo":
1702             val = self.cvt_depth(depth)
1703         elif attr == "M-size":
1704             if size < 0 and fn and fn[0]:
1705                 try:
1706                     st = os.lstat(fn[0])
1707                     size = st.st_size
1708                 except FileNotFoundError:
1709                     size = 0
1710                 self.threadinfo[tid][mid] = (fn, dt, matched, depth,
1711                                              author, subj, tags, size)
1712             if size > 0:
1713                 val = cvt_size(size)
1714             else:
1715                 val = "????"
1716
1717         if not val is None:
1718             comm2("callback", focus, val, mark, attr)
1719             return 1
1720         return edlib.Efallthrough
1721
1722     def handle_get_attr(self, key, focus, str, comm2, **a):
1723         "handle:get-attr"
1724         if str == "doc-type" and comm2:
1725             comm2("callback", focus, "notmuch-query")
1726             return 1
1727         return edlib.Efallthrough
1728
1729     def handle_maindoc(self, key, **a):
1730         "handle-prefix:doc:notmuch:"
1731         # any doc:notmuch calls that we don't handle directly
1732         # are handed to the maindoc
1733         return self.maindoc.call(key, **a)
1734
1735     def handle_reload(self, key, **a):
1736         "handle:doc:notmuch:query:reload"
1737         self.load_full()
1738         return 1
1739
1740     def handle_load_thread(self, key, mark, **a):
1741         "handle:doc:notmuch:load-thread"
1742         if mark.pos == None:
1743             return edlib.Efail
1744         (tid,mid) = mark.pos
1745         self.load_thread(mark, sync=True)
1746         if tid in self.threadinfo:
1747             return 1
1748         return 2
1749
1750     def handle_same_search(self, key, str2, **a):
1751         "handle:doc:notmuch:same-search"
1752         if self.query == str2:
1753             return 1
1754         return 2
1755
1756     def handle_query_refresh(self, key, **a):
1757         "handle:doc:notmuch:query-refresh"
1758         self.load_update()
1759         return 1
1760
1761     def handle_mark_read(self, key, str, str2, **a):
1762         "handle:doc:notmuch:mark-read"
1763         # Note that the thread might already have been pruned,
1764         # in which case there is no cached info to update.
1765         # In that case we just pass the request down to the db.
1766         if str in self.threadinfo:
1767             ti = self.threadinfo[str]
1768             m = ti[str2]
1769             tags = m[6]
1770             if "unread" not in tags and "new" not in tags:
1771                 return
1772             if "unread" in tags:
1773                 tags.remove("unread")
1774             if "new" in tags:
1775                 tags.remove("new")
1776             is_unread = False
1777             for mid in ti:
1778                 if "unread" in ti[mid][6]:
1779                     # still has unread messages
1780                     is_unread = True
1781                     break
1782             if not is_unread and str in self.threads:
1783                 # thread is no longer 'unread'
1784                 j = self.threads[str]
1785                 t = j["tags"]
1786                 if "unread" in t:
1787                     t.remove("unread")
1788             self.notify("doc:replaced")
1789         # Cached info is updated, pass down to
1790         # database document for permanent change
1791         self.maindoc.call(key, str, str2)
1792         return 1
1793
1794 class tag_popup(edlib.Pane):
1795     def __init__(self, focus):
1796         edlib.Pane.__init__(self, focus)
1797
1798     def handle_enter(self, key, focus, **a):
1799         "handle:K:Enter"
1800         str = focus.call("doc:get-str", ret='str')
1801         focus.call("popup:close", str)
1802         return 1
1803
1804 class query_popup(edlib.Pane):
1805     def __init__(self, focus):
1806         edlib.Pane.__init__(self, focus)
1807
1808     def handle_enter(self, key, focus, **a):
1809         "handle:K:Enter"
1810         str = focus.call("doc:get-str", ret='str')
1811         focus.call("popup:close", str)
1812         return 1
1813
1814 #
1815 # There are 4 viewers
1816 #  notmuch_master_view  manages multiple notmuch tiles.  When the notmuch_main
1817 #       is displayed, this gets gets attached *under* (closer to root) the
1818 #       doc-view pane together with a tiling window.  One tile is used to
1819 #       display the query list from the main doc, other tiles are used to
1820 #       display other components.
1821 #  notmuch_list_view  displays the list of saved-searched registered with
1822 #       notmuch_main.  It display list-name and a count of the most interesting
1823 #       sorts of message.
1824 #  notmuch_query_view displays the list of threads and messages for a single
1825 #       query - a notmuch_query document
1826 #  notmuch_message_view displays a single message, a doc-email document
1827 #       which is implemented separately.  notmuch_message_view primarily
1828 #       provides interactions consistent with the rest of edlib-notmuch
1829
1830 class notmuch_master_view(edlib.Pane):
1831     # This pane controls one visible instance of the notmuch application.
1832     # It manages the size and position of the 3 panes and provides common
1833     # handling for some keystrokes.
1834     # 'focus' is normally None and we are created parentless.  The creator
1835     # then attaches us and a tiler beneath the main notmuch document.
1836     #
1837     def __init__(self, focus = None):
1838         edlib.Pane.__init__(self, focus)
1839         self.maxlen = 0 # length of longest query name in list_pane
1840         self.list_pane = None
1841         self.query_pane = None
1842         self.message_pane = None
1843
1844     def handle_set_main_view(self, key, focus, **a):
1845         "handle:notmuch:set_list_pane"
1846         self.list_pane = focus
1847         return 1
1848
1849     def resize(self):
1850         if self.list_pane and (self.query_pane or self.message_pane):
1851             # list_pane must be no more than 25% total width, and no more than
1852             # 5+1+maxlen+1
1853             if self.maxlen <= 0:
1854                 m = self.list_pane["notmuch:max-search-len"]
1855                 if m and m.isnumeric():
1856                     self.maxlen = int(m)
1857                 else:
1858                     self.maxlen = 20
1859             tile = self.list_pane.call("ThisPane", "notmuch", ret='pane')
1860             space = self.w
1861             ch,ln = tile.scale()
1862             max = 5 + 1 + self.maxlen + 1
1863             if space * 100 / ch < max * 4:
1864                 w = space / 4
1865             else:
1866                 w = ch * 10 * max / 1000
1867             if tile.w != w:
1868                 tile.call("Window:x+", "notmuch", int(w - tile.w))
1869         if self.query_pane and self.message_pane:
1870             # query_pane must be at least 4 lines, else 1/4 height
1871             # but never more than 1/2 the height
1872             tile = self.query_pane.call("ThisPane", "notmuch", ret='pane')
1873             ch,ln = tile.scale()
1874             space = self.h
1875             min = 4
1876             if space * 100 / ln > min * 4:
1877                 h = space / 4
1878             else:
1879                 h = ln * 10 * min / 1000
1880                 if h > space / 2:
1881                     h = space / 2
1882             if tile.h != h:
1883                 tile.call("Window:y+", "notmuch", int(h - tile.h))
1884
1885     def handle_getattr(self, key, focus, str, comm2, **a):
1886         "handle:get-attr"
1887         if comm2:
1888             val = None
1889             if str in ["qname","query","filter"] and self.query_pane:
1890                 # WARNING these must always be set in the query doc,
1891                 # otherwise we can recurse infinitely.
1892                 val = self.query_pane[str]
1893             if val:
1894                 comm2("callback", focus, val, str)
1895                 return 1
1896         return edlib.Efallthrough
1897
1898     def handle_choose(self, key, **a):
1899         "handle:docs:choose"
1900         # If a notmuch tile needs to find a new doc, e.g. because
1901         # a message doc was killed, reject the request so that the
1902         # pane will be closed.
1903         return 1
1904
1905     def handle_clone(self, key, focus, **a):
1906         "handle:Clone"
1907         main = notmuch_master_view(focus)
1908         p = main.call("attach-tile", "notmuch", "main", ret='pane')
1909         frm = self.list_pane.call("ThisPane", "notmuch", ret='pane')
1910         frm.clone_children(p)
1911         return 1
1912
1913     recursed = None
1914     def handle_maindoc(self, key, **a):
1915         "handle-prefix:doc:notmuch:"
1916         # any doc:notmuch calls that haven't been handled
1917         # are handled to the list_pane
1918         if self.recursed == key:
1919             edlib.LOG("doc:notmuch: recursed!", key)
1920             return edlib.Efail
1921         prev = self.recursed
1922         self.recursed = key
1923         # FIXME catch exception to return failure state properly
1924         ret = self.list_pane.call(key, **a)
1925         self.recursed = prev
1926         return ret
1927
1928     def handle_size(self, key, **a):
1929         "handle:Refresh:size"
1930         # First, make sure the tiler has adjusted to the new size
1931         self.focus.w = self.w
1932         self.focus.h = self.h
1933         self.focus("Refresh:size")
1934         # then make sure children are OK
1935         self.resize()
1936         return 1
1937
1938     def handle_dot(self, key, focus, mark, **a):
1939         "handle:doc:char-."
1940         # select thing under point, but don't move
1941         focus.call("notmuch:select", mark, 0)
1942         return 1
1943
1944     def handle_return(self, key, focus, mark, **a):
1945         "handle:K:Enter"
1946         # select thing under point, and enter it
1947         focus.call("notmuch:select", mark, 1)
1948         return 1
1949
1950     def handle_select_1(self, key, focus, mark, **a):
1951         "handle:notmuch:select-1"
1952         # select thing under point, and enter it
1953         focus.call("notmuch:select", mark, 1)
1954         return 1
1955
1956     def handle_search(self, key, focus, **a):
1957         "handle:doc:char-s"
1958         pup = focus.call("PopupTile", "3", "", ret='pane')
1959         if not pup:
1960             return edlib.Efail
1961         pup['done-key'] = "notmuch-do-ad hoc"
1962         pup['prompt'] = "Ad hoc query"
1963         pup.call("doc:set-name", "Ad hoc query")
1964         p = pup.call("attach-history", "*Notmuch Query History*",
1965                      "popup:close", ret='pane')
1966         if p:
1967             pup = p
1968         query_popup(pup)
1969         return 1
1970
1971     def handle_compose(self, key, focus, **a):
1972         "handle:doc:char-c"
1973         choice = []
1974         def choose(choice, a):
1975             focus = a['focus']
1976             if focus['email-sent'] == 'no':
1977                 choice.append(focus)
1978                 return 1
1979             return 0
1980         focus.call("docs:byeach", lambda key,**a:choose(choice, a))
1981         if len(choice):
1982             par = focus.call("PopupTile", "MD3tsa", ret='pane')
1983             if par:
1984                 par = choice[0].call("doc:attach-view", par, 1, ret='pane')
1985                 par.take_focus()
1986         else:
1987             focus.call("Message:modal",
1988                        "No active email composition documents found.")
1989         return 1
1990
1991     def do_search(self, key, focus, str, **a):
1992         "handle:notmuch-do-ad hoc"
1993         if str:
1994             self.list_pane.call("doc:notmuch:set-adhoc", str)
1995             self.list_pane.call("notmuch:select-adhoc", 1)
1996         return 1
1997
1998     def handle_filter(self, key, focus, **a):
1999         "handle:doc:char-f"
2000         if not self.query_pane:
2001             return 1
2002         f = focus['filter']
2003         if not f:
2004             f = ""
2005         pup = focus.call("PopupTile", "3", f, ret='pane')
2006         if not pup:
2007             return edlib.Efail
2008         pup['done-key'] = "notmuch-do-filter"
2009         pup['prompt'] = "Query filter"
2010         pup.call("doc:set-name", "*Query filter for %s*" % focus['qname'])
2011         p = pup.call("attach-history", "*Notmuch Filter History*",
2012                      "popup:close", ret='pane')
2013         if p:
2014             pup = p
2015         query_popup(pup)
2016         return 1
2017
2018     def do_filter(self, key, focus, str1, **a):
2019         "handle:notmuch-do-filter"
2020         if self.query_pane and str1:
2021             self.query_pane.call("doc:notmuch:set-filter", str1)
2022         return 1
2023
2024     def handle_space(self, key, **a):
2025         "handle:doc:char- "
2026         if self.message_pane:
2027             m = self.message_pane.call("doc:point", ret='mark')
2028             self.message_pane.call(key, m)
2029         elif self.query_pane:
2030             m = self.query_pane.call("doc:point", ret='mark')
2031             self.query_pane.call("K:Enter", m)
2032         else:
2033             m = self.list_pane.call("doc:point", ret='mark')
2034             self.list_pane.call("K:Enter", m)
2035         return 1
2036
2037     def handle_bs(self, key, **a):
2038         "handle:K:Backspace"
2039         if self.message_pane:
2040             m = self.message_pane.call("doc:point", ret='mark')
2041             self.message_pane.call(key, m)
2042         elif self.query_pane:
2043             m = self.query_pane.call("doc:point", ret='mark')
2044             self.query_pane.call("doc:char-p", m)
2045         else:
2046             m = self.list_pane.call("doc:point", ret='mark')
2047             self.list_pane.call("K:A-p", m)
2048         return 1
2049
2050     def handle_move(self, key, **a):
2051         "handle-list/K:A-n/K:A-p/doc:char-n/doc:char-p"
2052         if key.startswith("K:A-") or not self.query_pane:
2053             p = self.list_pane
2054             op = self.query_pane
2055         else:
2056             p = self.query_pane
2057             op = self.message_pane
2058         if not p:
2059             return 1
2060
2061         direction = 1 if key[-1] in "na" else -1
2062         if op:
2063             # secondary window exists so move, otherwise just select
2064             try:
2065                 p.call("Move-Line", direction)
2066             except edlib.commandfailed:
2067                 pass
2068
2069         m = p.call("doc:dup-point", 0, edlib.MARK_UNGROUPED, ret='mark')
2070         p.call("notmuch:select", m, direction)
2071         return 1
2072
2073     def handle_j(self, key, focus, **a):
2074         "handle:doc:char-j"
2075         # jump to the next new/unread message/thread
2076         p = self.query_pane
2077         if not p:
2078             return 1
2079         m = p.call("doc:dup-point", 0, edlib.MARK_UNGROUPED, ret='mark')
2080         p.call("Move-Line", m, 1)
2081         tg = p.call("doc:get-attr", m, "tags", ret='str')
2082         while tg is not None:
2083             tl = tg.split(',')
2084             if "unread" in tl:
2085                 break
2086             p.call("Move-Line", m, 1)
2087             tg = p.call("doc:get-attr", m, "tags", ret='str')
2088         if tg is None:
2089             focus.call("Message", "All messsages read!")
2090             return 1
2091         p.call("Move-to", m)
2092         if self.message_pane:
2093             p.call("notmuch:select", m, 1)
2094         return 1
2095
2096     def handle_move_thread(self, key, **a):
2097         "handle-list/doc:char-N/doc:char-P"
2098         p = self.query_pane
2099         op = self.message_pane
2100         if not self.query_pane:
2101             return 1
2102
2103         direction = 1 if key[-1] in "N" else -1
2104         if self.message_pane:
2105             # message window exists so move, otherwise just select
2106             self.query_pane.call("notmuch:close-thread")
2107             self.query_pane.call("Move-Line", direction)
2108
2109         m = p.call("doc:dup-point", 0, edlib.MARK_UNGROUPED, ret='mark')
2110         p.call("notmuch:select", m, direction)
2111         return 1
2112
2113     def handle_A(self, key, focus, num, mark, str, **a):
2114         "handle-list/doc:char-a/doc:char-A/doc:char-k/doc:char-S/doc:char-H/doc:char-*/doc:char-!/doc:char-d/doc:char-D/"
2115         # adjust flags for this message or thread, and move to next
2116         # a - remove inbox
2117         # A - remove inbox from entire thread
2118         # k - remove inbox from this message and replies
2119         # d - remove inbox, add deleted
2120         # D - as D, for entire thread
2121         # S - add newspam
2122         # H - ham: remove newspam and add notspam
2123         # * - add flagged
2124         # ! - add unread,inbox remove newspam,notspam,flagged,deleted
2125         # If num is negative reverse the change (except for !)
2126         # If num is not +/-NO_NUMERIC, apply to whole thread
2127         which = focus['notmuch:pane']
2128         if which not in ['message', 'query']:
2129             return 1
2130
2131         wholethread = False
2132         replies = False
2133         if num != edlib.NO_NUMERIC and num != -edlib.NO_NUMERIC:
2134             wholethread = True
2135
2136         adds = []; removes = []
2137         if key[-1] == 'a':
2138             removes = ['inbox']
2139         if key[-1] == 'A':
2140             removes = ['inbox']
2141             wholethread = True
2142         if key[-1] == 'd':
2143             removes = ['inbox']
2144             adds = ['deleted']
2145         if key[-1] == 'D':
2146             removes = ['inbox']
2147             adds = ['deleted']
2148             wholethread = True
2149         if key[-1] == 'k':
2150             removes = ['inbox']
2151             replies = True
2152         if key[-1] == 'S':
2153             adds = ['newspam']
2154         if key[-1] == 'H':
2155             adds = ['notspam']
2156             removes = ['newspam']
2157         if key[-1] == '*':
2158             adds = ['flagged']
2159         if key[-1] == '!':
2160             adds = ['unread','inbox']
2161             removes = ['newspam','notspam','flagged','deleted']
2162
2163         if num < 0 and key[-1] != '!':
2164             adds, removes = removes, adds
2165
2166         if which == "message":
2167             thid = self.message_pane['thread-id']
2168             msid = self.message_pane['message-id']
2169         elif which == "query":
2170             thid = focus.call("doc:get-attr", "thread-id", mark, ret = 'str')
2171             msid = focus.call("doc:get-attr", "message-id", mark, ret = 'str')
2172         else:
2173             return 1
2174         if not thid:
2175             return 1
2176
2177         if wholethread:
2178             mids = self.query_pane.call("doc:notmuch-query:matched-mids",
2179                                     thid, ret='str')
2180         elif replies:
2181             # only mark messages which are replies to msid
2182             mids = self.query_pane.call("doc:notmuch-query:matched-replies",
2183                                         thid, msid, ret='str')
2184         else:
2185             mids = msid
2186         self.do_update(thid, mids, adds, removes)
2187         if mids:
2188             mid = mids.split("\n")[-1]
2189         else:
2190             mid = None
2191         m = edlib.Mark(self.query_pane)
2192         self.query_pane.call("notmuch:find-message", thid, mid, m)
2193         if m:
2194             self.query_pane.call("Move-to", m)
2195         self.query_pane.call("Move-Line", 1)
2196         if self.message_pane:
2197             # open the thread, and maybe the message, if the msid was open
2198             m = self.query_pane.call("doc:dup-point", 0,
2199                                      edlib.MARK_UNGROUPED, ret='mark')
2200             if msid and self.message_pane['notmuch:id'] == msid:
2201                 self.query_pane.call("notmuch:select", m, 1)
2202             else:
2203                 self.query_pane.call("notmuch:select", m, 0)
2204         return 1
2205
2206     def handle_new_mail(self, key, focus, **a):
2207         "handle:doc:char-m"
2208         v = make_composition(self.list_pane, focus)
2209         if v:
2210             v.call("compose-email:empty-headers")
2211         return 1
2212
2213     def handle_reply(self, key, focus, num, **a):
2214         "handle-list/doc:char-r/doc:char-R/doc:char-F/doc:char-z"
2215         if not self.message_pane:
2216             focus.call("Message", "Can only reply when a message is open")
2217             return edlib.Efail
2218         quote_mode = "inline"
2219         if num != edlib.NO_NUMERIC:
2220             quote_mode = "none"
2221         if key[-1] == 'F':
2222             hdr_mode = "forward"
2223             tag = "forwarded"
2224             if quote_mode == "none":
2225                 quote_mode = "attach"
2226         elif key[-1] == 'z':
2227             hdr_mode = "forward"
2228             tag = "forwarded"
2229             quote_mode = "attach"
2230         elif key[-1] == 'R':
2231             hdr_mode = "reply-all"
2232             tag = "replied"
2233         else:
2234             hdr_mode = "reply"
2235             tag = "replied"
2236         v = make_composition(self.list_pane, focus,
2237                              tag="tag +%s -new -unread id:%s" % (tag, self.message_pane['message-id']))
2238         if v:
2239             v.call("compose-email:copy-headers", self.message_pane, hdr_mode)
2240             if quote_mode == "inline":
2241                 # find first visible text part and copy it
2242                 msg = self.message_pane
2243                 m = edlib.Mark(msg)
2244                 while True:
2245                     msg.call("doc:step-part", m, 1)
2246                     which = msg.call("doc:get-attr",
2247                                      "multipart-this:email:which",
2248                                      m, ret='str')
2249                     if not which:
2250                         break
2251                     if which != "spacer":
2252                         continue
2253                     vis = msg.call("doc:get-attr", "email:visible", m,
2254                                    ret = 'str')
2255                     if not vis or vis == 'none':
2256                         continue
2257                     type = msg.call("doc:get-attr",
2258                                     "multipart-prev:email:content-type",
2259                                     m, ret='str')
2260                     if (not type or not type.startswith("text/") or
2261                         type == "text/rfc822-headers"):
2262                         continue
2263                     part = msg.call("doc:get-attr", m,
2264                                     "multipart:part-num", ret='str')
2265                     # try transformed first
2266                     c = msg.call("doc:multipart-%d-doc:get-str" % (int(part) - 1),
2267                                  ret = 'str')
2268                     if not c or not c.strip():
2269                         c = msg.call("doc:multipart-%d-doc:get-str" % (int(part) - 2),
2270                                      ret = 'str')
2271                     if c and c.strip():
2272                         break
2273
2274                 if c:
2275                     v.call("compose-email:quote-content", c)
2276
2277             if quote_mode == "attach":
2278                 fn = self.message_pane["filename"]
2279                 if fn:
2280                     v.call("compose-email:attach", fn, "message/rfc822")
2281         return 1
2282
2283     def handle_mesg_cmd(self, key, focus, mark, num, **a):
2284         "handle-list/doc:char-X"
2285         # general commands to be directed to message view
2286         if self.message_pane:
2287             self.message_pane.call(key, num)
2288         return 1
2289
2290     def tag_ok(self, t):
2291         for c in t:
2292             if not (c.isupper() or c.islower() or c.isdigit()):
2293                 return False
2294         return True
2295
2296     def do_update(self, tid, mid, adds, removes):
2297         skipped = []
2298         for t in adds:
2299             if self.tag_ok(t):
2300                 self.list_pane.call("doc:notmuch:tag-add-%s" % t, tid, mid)
2301             else:
2302                 skipped.append(t)
2303         for t in removes:
2304             if self.tag_ok(t):
2305                 self.list_pane.call("doc:notmuch:tag-remove-%s" % t, tid, mid)
2306             else:
2307                 skipped.append(t)
2308         if skipped:
2309             self.list_pane.call("Message", "Skipped illegal tags:" + ','.join(skipped))
2310
2311     def handle_tags(self, key, focus, mark, num, **a):
2312         "handle-list/doc:char-+"
2313         # add or remove flags, prompting for names
2314
2315         if self.message_pane and self.mychild(focus) == self.mychild(self.message_pane):
2316             curr = 'message'
2317         elif self.query_pane and self.mychild(focus) == self.mychild(self.query_pane):
2318             curr = 'query'
2319         else:
2320             curr = 'main'
2321
2322         if curr == 'main':
2323             return 1
2324         if curr == 'query':
2325             thid = focus.call("doc:get-attr", "thread-id", mark, ret='str')
2326             msid = focus.call("doc:get-attr", "message-id", mark, ret='str')
2327         else:
2328             thid = self.message_pane['thread-id']
2329             msid = self.message_pane['message-id']
2330         if not thid:
2331             # FIXME maybe warn that there is no message here.
2332             # Might be at EOF
2333             return 1
2334
2335         pup = focus.call("PopupTile", "2", '-' if num < 0 else '+', ret='pane')
2336         if not pup:
2337             return edlib.Fail
2338         done = "notmuch-do-tags-%s" % thid
2339         if msid:
2340             done += " " + msid
2341         pup['done-key'] = done
2342         pup['prompt'] = "[+/-]Tags"
2343         pup.call("doc:set-name", "Tag changes")
2344         tag_popup(pup)
2345         return 1
2346
2347     def handle_neg(self, key, focus, num, mark, **a):
2348         "handle:doc:char--"
2349         if num < 0:
2350             # double negative is 'tags'
2351             return self.handle_tags(key, focus, mark, num)
2352         # else negative prefix arg
2353         focus.call("Mode:set-num", -num)
2354         return 1
2355
2356     def parse_tags(self, tags):
2357         adds = []
2358         removes = []
2359         if not tags or tags[0] not in "+-":
2360             return None
2361         mode = ''
2362         tl = []
2363         for t in tags.split(','):
2364             tl.extend(t.split(' '))
2365         for t in tl:
2366             tg = ""
2367             for c in t:
2368                 if c in "+-":
2369                     if tg != "" and mode == '+':
2370                         adds.append(tg)
2371                     if tg != "" and mode == '-':
2372                         removes.append(tg)
2373                     tg = ""
2374                     mode = c
2375                 else:
2376                     tg += c
2377             if tg != "" and mode == '+':
2378                 adds.append(tg)
2379             if tg != "" and mode == '-':
2380                 removes.append(tg)
2381         return (adds, removes)
2382
2383     def do_tags(self, key, focus, str1, **a):
2384         "handle-prefix:notmuch-do-tags-"
2385         if not str1:
2386             return edlib.Efail
2387         suffix = key[16:]
2388         ids = suffix.split(' ', 1)
2389         thid = ids[0]
2390         if len(ids) == 2:
2391             msid = ids[1]
2392         else:
2393             msid = None
2394         t = self.parse_tags(str1)
2395         if t is None:
2396             focus.call("Message", "Tags list must start with + or -")
2397         else:
2398             self.do_update(thid, msid, t[0], t[1])
2399         return 1
2400
2401     def handle_close_message(self, key, num, **a):
2402         "handle:notmuch-close-message"
2403         self.message_pane = None
2404         if num and self.query_pane:
2405             pnt = self.query_pane.call("doc:point", ret='mark')
2406             if pnt:
2407                 pnt['notmuch:current-message'] = ''
2408                 pnt['notmuch:current-thread'] = ''
2409         return 1
2410
2411     def handle_xq(self, key, **a):
2412         "handle-list/doc:char-x/doc:char-q/doc:char-Q"
2413         if self.message_pane:
2414             if key != "doc:char-x":
2415                 self.mark_read()
2416             p = self.message_pane
2417             self("notmuch-close-message", 1)
2418             p.call("Window:close", "notmuch")
2419         elif self.query_pane:
2420             if (self.query_pane.call("notmuch:close-whole-thread") == 1 and
2421                 key != "doc:char-Q"):
2422                 return 1
2423             if (self.query_pane.call("notmuch:close-thread") == 1 and
2424                 key != "doc:char-Q"):
2425                 return 1
2426             if self.query_pane['filter']:
2427                 self.query_pane.call("doc:notmuch:set-filter")
2428                 if key != "doc:char-Q":
2429                     return 1
2430             if key != "doc:char-x":
2431                 self.query_pane.call("notmuch:mark-seen")
2432             p = self.query_pane
2433             self.query_pane = None
2434             pnt = self.list_pane.call("doc:point", ret='mark')
2435             pnt['notmuch:query-name'] = ""
2436             self.list_pane.call("view:changed")
2437
2438             p.call("Window:close", "notmuch")
2439         elif key == "doc:char-Q":
2440             p = self.call("ThisPane", ret='pane')
2441             if p and p.focus:
2442                 p.focus.close()
2443         return 1
2444
2445     def handle_v(self, key, **a):
2446         "handle:doc:char-V"
2447         # View the current message as a raw file
2448         if not self.message_pane:
2449             return 1
2450         p2 = self.call("doc:open", self.message_pane["filename"], -1,
2451                        ret='pane')
2452         p2.call("doc:set:autoclose", 1)
2453         p0 = self.call("DocPane", p2, ret='pane')
2454         if p0:
2455             p0.take_focus()
2456             return 1
2457         p0 = self.call("OtherPane", ret='pane')
2458         if p0:
2459             p2.call("doc:attach-view", p0, 1, "viewer")
2460         return 1
2461
2462     def handle_o(self, key, focus, **a):
2463         "handle:doc:char-o"
2464         # focus to next window
2465         focus.call("Window:next", "notmuch")
2466         return 1
2467
2468     def handle_O(self, key, focus, **a):
2469         "handle:doc:char-O"
2470         # focus to prev window
2471         focus.call("Window:prev", "notmuch")
2472         return 1
2473
2474     def handle_g(self, key, focus, **a):
2475         "handle:doc:char-g"
2476         focus.call("doc:notmuch:update")
2477         return 1
2478
2479     def handle_Z(self, key, **a):
2480         "handle-list/doc:char-Z/doc:char-=/"
2481         if self.query_pane:
2482             return self.query_pane.call(key)
2483         return edlib.Efallthrough
2484
2485     def handle_select_query(self, key, num, str, **a):
2486         "handle:notmuch:select-query"
2487         # A query was selected, identifed by 'str'.  Close the
2488         # message window and open a threads window.
2489         if self.message_pane:
2490             p = self.message_pane
2491             self("notmuch-close-message", 1)
2492             p.call("Window:close", "notmuch")
2493             self.message_pane = None
2494
2495         # doc:notmuch:query might auto-select a message, which will
2496         # call doc:notmuch:open, which tests self.query_pane,
2497         # which might be in the process of being closed.  Don't want
2498         # that, so just clear query_pane early.
2499         self.query_pane = None
2500         p0 = self.list_pane.call("doc:notmuch:query", str, ret='pane')
2501         p1 = self.list_pane.call("OtherPane", "notmuch", "threads", 15,
2502                                  ret='pane')
2503         self.query_pane = p0.call("doc:attach-view", p1, ret='pane')
2504
2505         pnt = self.list_pane.call("doc:point", ret='mark')
2506         pnt['notmuch:query-name'] = str
2507         self.list_pane.call("view:changed");
2508
2509         if num:
2510             self.query_pane.take_focus()
2511         self.resize()
2512         return 1
2513
2514     def handle_select_message(self, key, focus, num, str1, str2, **a):
2515         "handle:notmuch:select-message"
2516         # a thread or message was selected. id in 'str1'. threadid in str2
2517         # Find the file and display it in a 'message' pane
2518         self.mark_read()
2519
2520         p0 = None
2521         if self.query_pane:
2522             p0 = self.query_pane.call("doc:notmuch:open", str1, str2, ret='pane')
2523         if not p0:
2524             p0 = self.list_pane.call("doc:notmuch:byid", str1, str2, ret='pane')
2525         if not p0:
2526             focus.call("Message", "Failed to find message %s" % str2)
2527             return edlib.Efail
2528         p0['notmuch:tid'] = str2
2529
2530         qp = self.query_pane
2531         if not qp:
2532             qp = focus
2533         p1 = focus.call("OtherPane", "notmuch", "message", 13,
2534                                   ret='pane')
2535         p3 = p0.call("doc:attach-view", p1, ret='pane')
2536         p3 = p3.call("attach-render-notmuch:message", ret='pane')
2537
2538         # FIXME This still doesn't work: there are races: attaching a doc to
2539         # the pane causes the current doc to be closed.  But the new doc
2540         # hasn't been anchored yet so if they are the same, we lose.
2541         # Need a better way to anchor a document.
2542         #p0.call("doc:set:autoclose", 1)
2543         p3['thread-id'] = str2
2544         p3['message-id'] = str1
2545         self.message_pane = p3
2546         if self.query_pane:
2547             pnt = self.query_pane.call("doc:point", ret='mark')
2548             if pnt:
2549                 pnt['notmuch:current-thread'] = str2
2550                 pnt['notmuch:current-message'] = str1
2551         if num:
2552             self.message_pane.take_focus()
2553         self.resize()
2554         return 1
2555
2556     def mark_read(self):
2557         p = self.message_pane
2558         if not p:
2559             return
2560         if self.query_pane:
2561             self.query_pane.call("doc:notmuch:mark-read",
2562                                  p['thread-id'], p['message-id'])
2563
2564 class notmuch_list_view(edlib.Pane):
2565     # This pane provides view on the search-list document.
2566     def __init__(self, focus):
2567         edlib.Pane.__init__(self, focus)
2568         self['notmuch:pane'] = 'main'
2569         self['background'] = 'color:#A0FFFF'
2570         self['line-format'] = '<%fmt>%count%space<underline,action-activate:notmuch:select>%name</></>'
2571         self.call("notmuch:set_list_pane")
2572         self.call("doc:request:doc:replaced")
2573         self.selected = None
2574         pnt = self.call("doc:point", ret='mark')
2575         if pnt and pnt['notmuch:query-name']:
2576             self.call("notmuch:select-query", pnt['notmuch:query-name'])
2577
2578     def handle_clone(self, key, focus, **a):
2579         "handle:Clone"
2580         p = notmuch_list_view(focus)
2581         self.clone_children(focus.focus)
2582         return 1
2583
2584     def handle_notify_replace(self, key, **a):
2585         "handle:doc:replaced"
2586         # FIXME do I need to do anything here? - of not, why not
2587         return edlib.Efallthrough
2588
2589     def handle_select(self, key, focus, mark, num, **a):
2590         "handle:notmuch:select"
2591         s = focus.call("doc:get-attr", "query", mark, ret='str')
2592         if s:
2593             focus.call("notmuch:select-query", s, num)
2594         return 1
2595
2596     def handle_select_adhoc(self, key, focus, mark, num, **a):
2597         "handle:notmuch:select-adhoc"
2598         focus.call("notmuch:select-query", "-ad hoc-", num)
2599         return 1
2600
2601 ##################
2602 # query_view shows a list of threads/messages that match a given
2603 # search query.
2604 # We generate the thread-ids using "notmuch search --output=summary"
2605 # For a full-scan we collect at most 100 and at most 1 month at a time, until
2606 # we reach an empty month, then get all the rest together
2607 # For an update, we just check the last day and add anything missing.
2608 # We keep an array of thread-ids
2609 #
2610 # Three different views are presented of this document depending on
2611 # whether 'selected' and possibly 'whole_thread' are set.
2612 #
2613 # By default when neither is set, only the threads are displayed.
2614 # If a threadid is 'selected' but not 'whole_thread', then other threads
2615 # appear as a single line, but the selected thread displays as all
2616 # matching messages.
2617 # If whole_thread is set, then only the selected thread is visible,
2618 # and all messages, matched or not, of that thread are visisble.
2619 #
2620
2621 class notmuch_query_view(edlib.Pane):
2622     def __init__(self, focus):
2623         edlib.Pane.__init__(self, focus)
2624         self.selected = None
2625         self.selmsg = None
2626         self.whole_thread = False
2627         self.seen_threads = {}
2628         self.seen_msgs = {}
2629         self['notmuch:pane'] = 'query'
2630
2631         # thread_start and thread_end are marks which deliniate
2632         # the 'current' thread. thread_end is the start of the next
2633         # thread (if there is one).
2634         # thread_matched is the first matched message in the thread.
2635         self.thread_start = None
2636         self.thread_end = None
2637         self.thread_matched = None
2638         (xs,ys) = self.scale()
2639         ret = []
2640         self.call("Draw:text-size", "M", -1, ys,
2641                   lambda key, **a: ret.append(a))
2642         if ret:
2643             lh = ret[0]['xy'][1]
2644         else:
2645             lh = 1
2646         # fixme adjust for pane size
2647         self['render-vmargin'] = "%d" % (4 * lh)
2648
2649         # if first thread is new, move to it.
2650         m = edlib.Mark(self)
2651         t = self.call("doc:get-attr", m, "T-tags", ret='str')
2652         if t and 'new' in t.split(','):
2653             self.call("Move-to", m)
2654         else:
2655             # otherwise restore old state
2656             pt = self.call("doc:point", ret='mark')
2657             if pt:
2658                 if pt['notmuch:selected']:
2659                     self("notmuch:select", 1, pt, pt['notmuch:selected'])
2660                 mid = pt['notmuch:current-message']
2661                 tid = pt['notmuch:current-thread']
2662                 if mid and tid:
2663                     self.call("notmuch:select-message", mid, tid)
2664                     self.selmsg = mid
2665
2666         self.call("doc:request:doc:replaced")
2667         self.call("doc:request:notmuch:thread-changed")
2668         self.updating = False
2669         self.call("doc:request:notmuch:thread-open")
2670
2671     def handle_getattr(self, key, focus, str, comm2, **a):
2672         "handle:get-attr"
2673         if comm2 and str == "doc-status":
2674             val = self.parent['doc:status']
2675             if not val:
2676                 val = ""
2677             if self['filter']:
2678                 val = "filter: %s %s" % (
2679                     self['filter'], val)
2680             elif self['qname'] == '-ad hoc-':
2681                 val = "query: %s %s" % (
2682                     self['query'], val)
2683             comm2("callback", focus, val)
2684             return 1
2685
2686     def handle_clone(self, key, focus, **a):
2687         "handle:Clone"
2688         p = notmuch_query_view(focus)
2689         self.clone_children(focus.focus)
2690         return 1
2691
2692     def handle_close(self, key, focus, **a):
2693         "handle:Close"
2694
2695         # Reload the query so archived messages disappear
2696         self.call("doc:notmuch:query:reload")
2697         self.call("doc:notmuch:update-one", self['qname'])
2698         return 1
2699
2700     def handle_matched_mids(self, key, focus, str, str2, comm2, **a):
2701         "handle-prefix:doc:notmuch-query:matched-"
2702         # if whole_thread, everything should be considered matched.
2703         if str and str == self.selected and self.whole_thread:
2704             return self.parent.call(key, focus, str, str2, 1, comm2)
2705         return edlib.Efallthrough
2706
2707     def handle_notify_replace(self, key, **a):
2708         "handle:doc:replaced"
2709         if self.thread_start:
2710             # Possible insertion before thread_end - recalc.
2711             self.thread_end = self.thread_start.dup()
2712             self.leaf.call("doc:step-thread", 1, 1, self.thread_end)
2713         self.leaf.call("view:changed")
2714         self.call("doc:notify:doc:status-changed")
2715         return edlib.Efallthrough
2716
2717     def close_thread(self, gone = False):
2718         if not self.selected:
2719             return None
2720         # old thread is disappearing.  If it is not gone, clip marks
2721         # to start, else clip to next thread.
2722         self.leaf.call("Notify:clip", self.thread_start, self.thread_end,
2723                        0 if gone else 1)
2724         if self.whole_thread:
2725             # And clip anything after (at eof) to thread_end
2726             eof = edlib.Mark(self)
2727             self.leaf.call("doc:set-ref", eof, 0)
2728             eof.step(1)
2729             eof.index = 1 # make sure all eof marks are different
2730             self.leaf.call("Notify:clip", self.thread_end, eof, 1)
2731             eof.index = 0
2732         self.leaf.call("view:changed", self.thread_start, self.thread_end)
2733         self.selected = None
2734         self.thread_start = None
2735         self.thread_end = None
2736         self.thread_matched = None
2737         self.whole_thread = False
2738         return 1
2739
2740     def move_thread(self):
2741         if not self.selected:
2742             return
2743         # old thread is or moving
2744         # thread is still here, thread_start must have moved, thread_end
2745         # might be where thread used to be.
2746         self.thread_end = self.thread_start.dup()
2747         self.call("doc:step-thread", self.thread_end, 1, 1)
2748         self.leaf.call("view:changed", self.thread_start, self.thread_end)
2749         return 1
2750
2751     def find_message(self, key, focus, mark, str, str2, **a):
2752         "handle:notmuch:find-message"
2753         if not str or not mark:
2754             return edlib.Enoarg
2755         if not self.selected or self.selected != str:
2756             str2 = None
2757         if self.call("doc:notmuch:to-thread", mark, str) <= 0:
2758             return edlib.Efalse
2759         if str2 and self.call("doc:notmuch:to-message", mark, str2) <= 0:
2760             return edlib.Efalse
2761         return 1
2762
2763     def trim_thread(self):
2764         if self.whole_thread:
2765             return
2766         # clip any non-matching messages
2767         self.thread_matched = None
2768         m = self.thread_start.dup()
2769         while m < self.thread_end:
2770             mt = self.call("doc:get-attr", m, "matched", ret='str')
2771             if mt != "True":
2772                 m2 = m.dup()
2773                 self.call("doc:step-matched", m2, 1, 1)
2774                 self.leaf.call("Notify:clip", m, m2)
2775                 m = m2
2776             if not self.thread_matched:
2777                 self.thread_matched = m.dup()
2778             self.parent.next(m)
2779         self.leaf.call("view:changed", self.thread_start, self.thread_end)
2780
2781     def handle_notify_thread(self, key, str, num, **a):
2782         "handle:notmuch:thread-changed"
2783         if not str or self.selected != str:
2784             return 0
2785         if num == 0:
2786             self.close_thread(True)
2787         elif num == 1:
2788             self.move_thread()
2789         elif num == 2:
2790             self.trim_thread()
2791             self.updating = False
2792         return 1
2793
2794     def handle_notify_thread_open(self, key, str1, **a):
2795         "handle:notmuch:thread-open"
2796         # If we have requested an update (so .updating is set) pretend
2797         # the thread isn't open, so a full update happens
2798         return 1 if not self.updating and str1 and self.selected == str1 else 0
2799
2800     def handle_set_ref(self, key, mark, num, **a):
2801         "handle:doc:set-ref"
2802         start = num
2803         if start:
2804             if self.whole_thread:
2805                 mark.to_mark(self.thread_start)
2806                 return 1
2807             if (self.selected and self.thread_matched and
2808                 self.parent.prior(self.thread_start) is None):
2809                 # first thread is open
2810                 mark.to_mark(self.thread_matched)
2811                 return 1
2812         # otherwise fall-through to real start or end
2813         return edlib.Efallthrough
2814
2815     def handle_doc_char(self, key, focus, mark, num, num2, mark2, **a):
2816         "handle:doc:char"
2817         if not mark:
2818             return edlib.Enoarg
2819         end = mark2
2820         steps = num
2821         forward = 1 if steps > 0 else 0
2822         if end and end == mark:
2823             return 1
2824         if end and (end < mark) != (steps < 0):
2825             # can never cross 'end'
2826             return edlib.Einval
2827         ret = edlib.Einval
2828         while steps and ret != edlib.WEOF and (not end or mark == end):
2829             ret = self.handle_step(key,focus, mark, forward, 1)
2830             steps -= forward * 2 - 1
2831         if end:
2832             return 1 + (num - steps if forward else steps - num)
2833         if ret == edlib.WEOF or num2 == 0:
2834             return ret
2835         if num and (num2 < 0) == (num > 0):
2836             return ret
2837         # want the next character
2838         return self.handle_step(key, focus, mark, 1 if num2 > 0 else 0, 0)
2839
2840     def handle_step(self, key, focus, mark, num, num2):
2841         forward = num
2842         move = num2
2843         if self.whole_thread:
2844             # move one message, but stop at thread_start/thread_end
2845             if forward:
2846                 if mark < self.thread_start:
2847                     mark.to_mark(self.thread_start)
2848                 if mark >= self.thread_end:
2849                     ret = edlib.WEOF
2850                     if mark.pos:
2851                         focus.call("doc:set-ret", mark, 0)
2852                         mark.step(0)
2853                 else:
2854                     ret = self.parent.call("doc:char", focus, mark,
2855                                            1 if move else 0, 0 if move else 1)
2856                     if mark.pos and mark.pos[0] != self.selected:
2857                         focus.call("doc:set-ref", mark, 0)
2858                         mark.step(0)
2859             else:
2860                 if mark <= self.thread_start:
2861                     # at start already
2862                     ret = edlib.WEOF
2863                 else:
2864                     if mark > self.thread_end:
2865                         mark.to_mark(self.thread_end)
2866                     ret = self.parent.call("doc:char", focus, mark,
2867                                            -1 if move else 0, 0 if move else -1)
2868             return ret
2869         else:
2870             # if between thread_start/thread_end, move one message,
2871             # else move one thread
2872             if not self.thread_start:
2873                 in_thread = False
2874             elif forward and mark >= self.thread_end:
2875                 in_thread = False
2876             elif not forward and mark <= self.thread_start:
2877                 in_thread = False
2878             elif forward and mark < self.thread_start:
2879                 in_thread = False
2880             elif not forward and mark > self.thread_end:
2881                 in_thread = False
2882             else:
2883                 in_thread = True
2884             if in_thread:
2885                 # move one matched message
2886                 ret = self.parent.call("doc:step-matched", focus, mark, forward, move)
2887                 # We might be in the next thread, make sure we are at the
2888                 # start
2889                 if forward and move and mark > self.thread_end:
2890                     mark.to_mark(self.thread_end)
2891                 if not forward and move and mark < self.thread_start:
2892                     focus.call("doc:step-thread", mark, forward, move)
2893                 if not forward and move and mark == self.thread_start:
2894                     # must be at the start of the first thread, which is open.
2895                     if self.thread_matched:
2896                         mark.to_mark(self.thread_matched)
2897                 return ret
2898             else:
2899                 # move one thread
2900                 if forward:
2901                     ret = focus.call("doc:step-thread", focus, mark, forward, move)
2902                     if (self.thread_matched and
2903                         self.thread_start and mark == self.thread_start):
2904                         mark.to_mark(self.thread_matched)
2905                 else:
2906                     # make sure we are at the start of the thread
2907                     self.parent.call("doc:step-thread", focus, mark, forward, 1)
2908                     ret = self.parent.call("doc:char", focus, mark,
2909                                            -1 if move else 0, 0 if move else -1)
2910                     if move and ret != edlib.WEOF:
2911                         focus.call("doc:step-thread", focus, mark, forward, move)
2912                 return ret
2913
2914     def handle_get_attr(self, key, focus, mark, num, num2, str, comm2, **a):
2915         "handle:doc:get-attr"
2916         if mark is None:
2917             mark = focus.call("doc:point", ret='mark')
2918         if not mark or not mark.pos:
2919             return edlib.Efallthrough
2920         if self.whole_thread and mark >= self.thread_end:
2921             # no attributes after end
2922             return 1
2923         attr = str
2924         if attr == "BG":
2925             (tid,mid) = mark.pos
2926             if tid == self.selected and comm2:
2927                 if mid == self.selmsg:
2928                     comm2("cb", focus, "bg:magenta+60", mark, attr)
2929                 elif self.whole_thread:
2930                     # only set background for matched messages
2931                     mt = self.call("doc:get-attr", mark, "matched", ret='str')
2932                     if mt and mt == "True":
2933                         comm2("cb", focus, "bg:yellow+60", mark, attr)
2934                 else:
2935                     comm2("cb", focus, "bg:yellow+60", mark, attr)
2936             return 1
2937         if attr[:3] == "TM-":
2938             if self.thread_start and mark < self.thread_end and mark >= self.thread_start:
2939                 return self.parent.call("doc:get-attr", focus, num, num2, mark, "M-" + str[3:], comm2)
2940             else:
2941                 return self.parent.call("doc:get-attr", focus, num, num2, mark, "T-" + str[3:], comm2)
2942         if attr == "message-id":
2943             # high message-id when thread isn't open
2944             if not self.selected or not mark.pos or mark.pos[0] != self.selected:
2945                 return 1
2946
2947         return edlib.Efallthrough
2948
2949     def handle_Z(self, key, focus, **a):
2950         "handle:doc:char-Z"
2951         if not self.thread_start:
2952             return 1
2953         if self.whole_thread:
2954             # all non-match messages in this thread are about to
2955             # disappear, we need to clip them.
2956             if self.thread_matched:
2957                 mk = self.thread_start.dup()
2958                 mt = self.thread_matched.dup()
2959                 while mk < self.thread_end:
2960                     if mk < mt:
2961                         focus.call("Notify:clip", mk, mt)
2962                     mk.to_mark(mt)
2963                     self.parent.call("doc:step-matched", mt, 1, 1)
2964                     self.parent.next(mk)
2965             else:
2966                 focus.call("Notify:clip", self.thread_start, self.thread_end)
2967             # everything after to EOF moves to thread_end.
2968             eof = edlib.Mark(self)
2969             self.leaf.call("doc:set-ref", eof, 0)
2970             eof.step(1)
2971             eof.offset = 1 # make sure all eof marks are different
2972             self.leaf.call("Notify:clip", self.thread_end, eof, 1)
2973             eof.offset = 0
2974
2975             self['doc-status'] = "Query: %s" % self['qname']
2976         else:
2977             # everything before the thread, and after the thread disappears
2978             m = edlib.Mark(self)
2979             focus.call("Notify:clip", m, self.thread_start)
2980             focus.call("doc:set-ref", m, 0)
2981             focus.call("Notify:clip", self.thread_end, m)
2982             self['doc-status'] = "Query: %s - single-thread" % self['qname']
2983         self.whole_thread = not self.whole_thread
2984         # notify that everything is changed, don't worry about details.
2985         focus.call("view:changed")
2986         return 1
2987
2988     def handle_update(self, key, focus, **a):
2989         "handle:doc:char-="
2990         if self.selected:
2991             self.updating = True
2992             focus.call("doc:notmuch:load-thread", self.thread_start)
2993         focus.call("doc:notmuch:query:reload")
2994         return 1
2995
2996     def handle_close_whole_thread(self, key, focus, **a):
2997         "handle:notmuch:close-whole-thread"
2998         # 'q' is requesting that we close thread if it is open
2999         if not self.whole_thread:
3000             return edlib.Efalse
3001         self.handle_Z(key, focus)
3002         return 1
3003
3004     def handle_close_thread(self, key, focus, **a):
3005         "handle:notmuch:close-thread"
3006         if self.close_thread():
3007             return 1
3008         return edlib.Efalse
3009
3010     def handle_select(self, key, focus, mark, num, num2, str1, **a):
3011         "handle:notmuch:select"
3012         # num = 0 - open thread but don't show message
3013         # num > 0 - open thread and do show message
3014         # num < 0 - open thread, go to last message, and show
3015         # if 'str1' and that thread exists, go there instead of mark
3016         if not mark:
3017             return edlib.Efail
3018         if str1:
3019             m = mark.dup()
3020             if self.call("notmuch:find-message", m, str1) > 0:
3021                 mark.to_mark(m)
3022
3023         s = focus.call("doc:get-attr", "thread-id", mark, ret='str')
3024         if s and s != self.selected:
3025             self.close_thread()
3026
3027             ret = focus.call("doc:notmuch:load-thread", mark)
3028             if ret == 2:
3029                 focus.call("Message", "Cannot load thread %s" % s)
3030             if ret == 1:
3031                 self.selected = s
3032                 if mark:
3033                     self.thread_start = mark.dup()
3034                 else:
3035                     self.thread_start = focus.call("doc:dup-point", 0,
3036                                                    edlib.MARK_UNGROUPED, ret='mark')
3037                 focus.call("doc:step-thread", 0, 1, self.thread_start)
3038                 self.thread_end = self.thread_start.dup()
3039                 focus.call("doc:step-thread", 1, 1, self.thread_end)
3040                 self.thread_matched = self.thread_start.dup()
3041                 matched = focus.call("doc:get-attr", self.thread_matched, "matched", ret="str")
3042                 if matched != "True":
3043                     focus.call("doc:step-matched", 1, 1, self.thread_matched)
3044                 focus.call("view:changed", self.thread_start, self.thread_end)
3045                 self.thread_start.step(0)
3046                 if num < 0:
3047                     # we moved backward to land here, so go to last message
3048                     m = self.thread_end.dup()
3049                     focus.call("doc:step-matched", 0, 1, m)
3050                 else:
3051                     # choose first new, unread or thread_matched
3052                     new = None; unread = None
3053                     m = self.thread_matched.dup()
3054                     while focus.call("doc:get-attr", m, "thread-id",
3055                                      ret='str') == s:
3056                         tg = focus.call("doc:get-attr", m, "tags", ret='str')
3057                         tl = tg.split(',')
3058                         if "unread" in tg:
3059                             if not unread:
3060                                 unread = m.dup()
3061                             if "new" in tg and not new:
3062                                 new = m.dup()
3063                         focus.call("doc:step-matched", 1, 1, m)
3064                     if new:
3065                         m = new
3066                     elif unread:
3067                         m = unread
3068                     else:
3069                         m = self.thread_matched
3070                 # all marks on this thread get moved to chosen start
3071                 focus.call("Notify:clip", self.thread_start, m)
3072                 if mark:
3073                     mark.clip(self.thread_start, m)
3074                 pnt = self.call("doc:point", ret='mark')
3075                 if pnt:
3076                     pnt['notmuch:selected'] = s
3077         if num != 0:
3078             # thread-id shouldn't have changed, but it some corner-cases
3079             # it can, so get both ids before loading the message.
3080             s = focus.call("doc:get-attr", "thread-id", mark, ret='str')
3081             s2 = focus.call("doc:get-attr", "message-id", mark, ret='str')
3082             if s and s2:
3083                 focus.call("notmuch:select-message", s2, s)
3084                 self.selmsg = s2
3085                 self.call("view:changed")
3086         self.take_focus()
3087         return 1
3088
3089     def handle_reposition(self, key, focus, mark, mark2, **a):
3090         "handle:render:reposition"
3091         # some messages have been displayed, from mark to mark2
3092         # collect threadids and message ids
3093         if not mark or not mark2:
3094             return edlib.Efallthrough
3095         m = mark.dup()
3096
3097         while m < mark2:
3098             tg = focus.call("doc:get-attr", "tags", m, ret='str')
3099             if tg and 'new' in tg.split(','):
3100                 i1 = focus.call("doc:get-attr", "thread-id", m, ret='str')
3101                 i2 = focus.call("doc:get-attr", "message-id", m, ret='str')
3102                 if i1 and not i2 and i1 not in self.seen_threads:
3103                     self.seen_threads[i1] = True
3104                 if i1 and i2:
3105                     if i1 in self.seen_threads:
3106                         del self.seen_threads[i1]
3107                     if i2 not in self.seen_msgs:
3108                         self.seen_msgs[i2] = i1
3109             if self.next(m) is None:
3110                 break
3111         return edlib.Efallthrough
3112
3113     def handle_mark_seen(self, key, focus, **a):
3114         "handle:notmuch:mark-seen"
3115         for id in self.seen_threads:
3116             notmuch_set_tags(thread=id, remove=['new'])
3117             self.notify("Notify:Tag", id)
3118
3119         for id in self.seen_msgs:
3120             notmuch_set_tags(msg=id, remove=['new'])
3121             self.notify("Notify:Tag", self.seen_msgs[id], id)
3122
3123         self.seen_threads =  {}
3124         self.seen_msgs = {}
3125         return 1
3126
3127 class notmuch_message_view(edlib.Pane):
3128     def __init__(self, focus):
3129         edlib.Pane.__init__(self, focus)
3130         # Need to set default visibility on each part.
3131         # step forward with doc:step-part and for any 'odd' part,
3132         # which is a spacer, we look at email:path and email:content-type.
3133         # If alternative:[1-9] is found, or type isn't "text*", make it
3134         # invisible.
3135         self['notmuch:pane'] = 'message'
3136         p = 0
3137         focus.call("doc:notmuch:request:Notify:Tag", self)
3138         self.do_handle_notify_tag()
3139
3140         # a 'view' for recording where quoted sections are
3141         self.qview = focus.call("doc:add-view", self) - 1
3142
3143         self.extra_headers = False
3144         self.point = focus.call("doc:point", ret='mark')
3145         self.prev_point = None
3146         self.have_prev = False
3147         self.call("doc:request:mark:moving")
3148         self.menu = None
3149         self.addr = None
3150
3151         self['word-wrap'] = '1' # Should this be different in different parts?
3152
3153         choose = {}
3154         m = edlib.Mark(focus)
3155         while True:
3156             self.call("doc:step-part", m, 1)
3157             which = focus.call("doc:get-attr", "multipart-this:email:which",
3158                                m, ret='str')
3159             if not which:
3160                 break
3161             if which != "spacer":
3162                 continue
3163             path = focus.call("doc:get-attr",
3164                               "multipart-prev:email:path", m, ret='str')
3165             type = focus.call("doc:get-attr",
3166                               "multipart-prev:email:content-type", m, ret='str')
3167             disp = focus.call("doc:get-attr",
3168                               "multipart-prev:email:content-disposition", m, ret='str')
3169             fname = focus.call("doc:get-attr",
3170                                "multipart-prev:email:filename", m, ret='str')
3171             ext = None
3172             if fname and '/' in fname:
3173                 fname = os.path.basename(prefix)
3174             prefix = fname
3175             if fname and '.' in fname:
3176                 d = fname.rindex('.')
3177                 ext = fname[d:]
3178                 prefix = fname[:d]
3179             if not ext and type:
3180                 ext = mimetypes.guess_extension(type)
3181             if ext:
3182                 # if there is an extension, we can pass to xdg-open, so maybe
3183                 # there is an external viewer
3184                 focus.call("doc:set-attr", "multipart-prev:email:ext", m, ext)
3185                 focus.call("doc:set-attr", "multipart-prev:email:prefix", m, prefix)
3186                 focus.call("doc:set-attr", "multipart-prev:email:actions", m,
3187                            "hide:save:external view");
3188
3189             if (type.startswith("text/") and
3190                 (not disp or "attachment" not in disp)):
3191                 # mark up URLs and quotes in any text part.
3192                 # The part needs to be visible while we do this.
3193                 # Examine at most 50000 chars from the start.
3194                 self.set_vis(focus, m, True)
3195                 start = m.dup()
3196                 self.prev(start)
3197                 self.call("doc:step-part", start, 0)
3198                 end = start.dup()
3199                 self.call("doc:char", end, 10000, m)
3200
3201                 self.call("url:mark-up", start, end)
3202                 self.mark_quotes(start, end)
3203
3204             # When presented with alternatives we are supposed to show
3205             # the last alternative that we understand.  However html is
3206             # only partly understood, so I only want to show that if
3207             # there is no other option.... but now I have w3m let's try
3208             # prefering html...
3209             # An alternate may contain many parts and we need to make
3210             # everything invisible within an invisible part - and we only
3211             # know which is invisible when we get to the end.
3212             # So record the visibility of each group of alternatives
3213             # now, and the walk through again setting visibility.
3214             # An alternative itself may be multi-part, typically
3215             # multipart/related.  In this case we only look at
3216             # whether we can handle the type of the first part.
3217             p = path.split(',')
3218             i = len(p)-1
3219             while (i > 0 and p[i].endswith(":0") and
3220                    not p[i].startswith("alternative:")):
3221                 # Might be the first part of a multi-path alternative,
3222                 # look earlier in the path
3223                 i -= 1
3224             if p[i].startswith("alternative:"):
3225                 # this is one of several - can we handle it?
3226                 group = ','.join(p[:i])
3227                 this = p[i][12:]
3228                 if type in ['text/plain', 'text/calendar', 'text/rfc822-headers',
3229                             'message/rfc822']:
3230                     choose[group] = this
3231                 if type.startswith('image/'):
3232                     choose[group] = this
3233                 if type == 'text/html': # and group not in choose:
3234                     choose[group] = this
3235
3236         # Now go through and set visibility for alternates.
3237         m = edlib.Mark(focus)
3238         while True:
3239             self.call("doc:step-part", m, 1)
3240             which = focus.call("doc:get-attr", "multipart-this:email:which",
3241                                m, ret='str')
3242             if not which:
3243                 break
3244             if which != "spacer":
3245                 continue
3246             path = focus.call("doc:get-attr", "multipart-prev:email:path",
3247                               m, ret='str')
3248             type = focus.call("doc:get-attr", "multipart-prev:email:content-type",
3249                               m, ret='str')
3250             disp = focus.call("doc:get-attr", "multipart-prev:email:content-disposition",
3251                               m, ret='str')
3252
3253             vis = False
3254
3255             # Is this allowed to be visible by default?
3256             if (not type or
3257                 type.startswith("text/") or
3258                 type.startswith("image/")):
3259                 vis = True
3260
3261             if disp and "attachment" in disp:
3262                 # Attachments are never visible - even text.
3263                 vis = False
3264
3265             # Is this in a non-selected alternative?
3266             p = []
3267             for el in path.split(','):
3268                 p.append(el)
3269                 if el.startswith("alternative:"):
3270                     group = ','.join(p[:-1])
3271                     this = el[12:]
3272                     if choose[group] != this:
3273                         vis = False
3274
3275             self.set_vis(focus, m, vis)
3276
3277     def set_vis(self, focus, m, vis):
3278         if vis:
3279             it = focus.call("doc:get-attr", "multipart-prev:email:is_transformed", m, ret='str')
3280             if it and it == "yes":
3281                 focus.call("doc:set-attr", "email:visible", m, "transformed")
3282             else:
3283                 focus.call("doc:set-attr", "email:visible", m, "orig")
3284         else:
3285             focus.call("doc:set-attr", "email:visible", m, "none")
3286
3287
3288     def mark_quotes(self, ms, me):
3289         # if we find more than 7 quoted lines in a row, we add the
3290         # 4th and 4th-last to the qview with the first of these
3291         # having a 'quote-length' attr with number of lines
3292         ms = ms.dup()
3293         while ms < me:
3294             try:
3295                 self.call("text-search", "^>", ms, me)
3296             except:
3297                 return
3298             self.prev(ms)
3299             start = ms.dup()
3300             cnt = 1
3301             while (cnt <= 7 and self.call("doc:EOL", 1, 1, ms) > 0 and
3302                    self.following(ms) == '>'):
3303                 cnt += 1
3304             if cnt > 7:
3305                 try:
3306                     self.call("text-search", "^[^>]", ms, me)
3307                     self.prev(ms)
3308                 except:
3309                     ms.to_mark(me)
3310                 self.mark_one_quote(start, ms.dup())
3311
3312     def mark_one_quote(self, ms, me):
3313         self.call("doc:EOL", 3, 1, ms)  # Start of 3rd line
3314         self.call("doc:EOL", -4, 1, me) # End of 4th last line
3315         if me <= ms:
3316             return
3317         st = edlib.Mark(self, self.qview)
3318         st.to_mark(ms)
3319         lines = 0
3320         while ms < me:
3321             self.call("doc:EOL", 1, 1, ms)
3322             lines += 1
3323         st['quote-length'] = "%d" % lines
3324         st['quote-hidden'] = "yes"
3325         ed = edlib.Mark(orig=st)
3326         ed.to_mark(me)
3327
3328     def do_handle_notify_tag(self):
3329         # tags might have changed.
3330         tg = self.call("doc:notmuch:byid:tags", self['notmuch:id'], ret='str')
3331         if tg:
3332             self['doc-status'] = "Tags:" + tg
3333         else:
3334             self['doc-status'] = "No Tags"
3335         return 1
3336     def handle_notify_tag(self, key, str1, str2, **a):
3337         "handle:Notify:Tag"
3338         if str1 != self['notmuch:tid']:
3339             # not my thread
3340             return
3341         if str2 and str2 != self['notmuch:id']:
3342             # not my message
3343             return
3344         return self.do_handle_notify_tag()
3345
3346     def handle_close(self, key, **a):
3347         "handle:Close"
3348         self.call("notmuch-close-message")
3349         return 1
3350
3351     def handle_clone(self, key, focus, **a):
3352         "handle:Clone"
3353         p = notmuch_message_view(focus)
3354         self.clone_children(focus.focus)
3355         return 1
3356
3357     def handle_replace(self, key, **a):
3358         "handle:Replace"
3359         return 1
3360
3361     def handle_slash(self, key, focus, mark, **a):
3362         "handle:doc:char-/"
3363         s = focus.call("doc:get-attr", mark, "email:visible", ret='str')
3364         if not s:
3365             return 1
3366         self.set_vis(focus, mark, s == "none")
3367         return 1
3368
3369     def handle_space(self, key, focus, mark, **a):
3370         "handle:doc:char- "
3371         if focus.call("K:Next", 1, mark) == 2:
3372             focus.call("doc:char-n", mark)
3373         return 1
3374
3375     def handle_backspace(self, key, focus, mark, **a):
3376         "handle:K:Backspace"
3377         if focus.call("K:Prior", 1, mark) == 2:
3378             focus.call("doc:char-p", mark)
3379         return 1
3380
3381     def handle_return(self, key, focus, mark, **a):
3382         "handle:K:Enter"
3383         m = self.vmark_at_or_before(self.qview, mark)
3384         if m and not m['quote-length'] and m == mark:
3385             # at end of last line, wan't previous mark
3386             m = m.prev()
3387         if m and m['quote-length']:
3388             if m['quote-hidden'] == 'yes':
3389                 m['quote-hidden'] = "no"
3390             else:
3391                 m['quote-hidden'] = "yes"
3392             self.leaf.call("view:changed", m, m.next())
3393             return 1
3394
3395     def handle_vis(self, focus, mark, which):
3396         v = focus.call("doc:get-attr", mark, "email:visible", ret='str')
3397         self.parent.call("email:select:" + which, focus, mark)
3398         v2 = focus.call("doc:get-attr", mark, "email:visible", ret='str')
3399         if v != v2 or which == "extras":
3400             # when visibility changes, move point to start.
3401             focus.call("doc:email-step-part", mark, -1)
3402             pt = focus.call("doc:point", ret='mark');
3403             pt.to_mark(mark)
3404         return 1
3405
3406     def handle_toggle_hide(self, key, focus, mark, **a):
3407         "handle-list/email-hide/email:select:hide"
3408         return self.handle_vis(focus, mark, "hide")
3409
3410     def handle_toggle_full(self, key, focus, mark, **a):
3411         "handle-list/email-full/email:select:full"
3412         return self.handle_vis(focus, mark, "full")
3413
3414     def handle_toggle_extras(self, key, focus, mark, **a):
3415         "handle-list/email-extras/email:select:extras/doc:char-X"
3416         if not mark:
3417             # a mark at the first "sep" part will identify the headers
3418             mark = edlib.Mark(focus)
3419             focus.call("doc:email-step-part", mark, 1)
3420         self.handle_vis(focus, mark, "extras")
3421         if self.extra_headers:
3422             return 1
3423         self.extra_headers = 1
3424         hdrdoc = focus.call("doc:multipart:get-part", 1, ret='pane')
3425         point = hdrdoc.call("doc:vmark-new", edlib.MARK_POINT, ret='mark')
3426         hdrdoc.call("doc:set-ref", point)
3427         for i in range(10):
3428             f = self['notmuch:fn-%d' % i]
3429             if not f:
3430                 break
3431             if f == self['filename']:
3432                 continue
3433             hdr = "Filename-%d: " % i
3434             hdrdoc.call("doc:replace", 1, point, point, hdr,
3435                         ",render:rfc822header=%d" % (len(hdr)-1))
3436             hdrdoc.call("doc:replace", 1, point, point, f + '\n')
3437         hdrdoc.call("doc:replace", 1, point, point, "Thread-id: ",
3438                     ",render:rfc822header=10")
3439         hdrdoc.call("doc:replace", 1, point, point, self['notmuch:tid'] + '\n')
3440         try:
3441             ts = self['notmuch:timestamp']
3442             ts = int(ts)
3443         except:
3444             ts = 0
3445         if ts > 0:
3446             tm = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime(ts))
3447             hdrdoc.call("doc:replace", 1, point, point, "Local-Time: ",
3448                     ",render:rfc822header=11")
3449             hdrdoc.call("doc:replace", 1, point, point, tm + '\n')
3450         return 1
3451
3452     def handle_save(self, key, focus, mark, **a):
3453         "handle-list/email-save/email:select:save"
3454
3455         file = focus.call("doc:get-attr", "multipart-prev:email:filename", mark, ret='str')
3456         if not file:
3457             file = "edlib-saved-file"
3458         p = file.split('/')
3459         b = p[-1]
3460         part = focus.call("doc:get-attr", mark, "multipart:part-num", ret='str')
3461         part = int(part)-2
3462         fn = "/tmp/" + b
3463         f = open(fn, "w")
3464         content = focus.call("doc:multipart-%d-doc:get-bytes" % part, ret = 'bytes')
3465         f.buffer.write(content)
3466         f.close()
3467         focus.call("Message", "Content saved as %s" % fn)
3468         return 1
3469
3470     def handle_external(self, key, focus, mark, **a):
3471         "handle-list/email-external view/email:select:external view"
3472         type = focus.call("doc:get-attr", "multipart-prev:email:content-type", mark, ret='str')
3473         prefix = focus.call("doc:get-attr", "multipart-prev:email:prefix", mark, ret='str')
3474         ext = focus.call("doc:get-attr", "multipart-prev:email:ext", mark, ret='str')
3475         if not ext:
3476             ext = ""
3477         if not prefix:
3478             prefix = "tempfile"
3479
3480         part = focus.call("doc:get-attr", mark, "multipart:part-num", ret='str')
3481         part = int(part)-2
3482
3483         content = focus.call("doc:multipart-%d-doc:get-bytes" % part, ret = 'bytes')
3484         fd, path = tempfile.mkstemp(ext, prefix)
3485         os.write(fd, content)
3486         os.close(fd)
3487         focus.call("Display:external-viewer", path, prefix+"XXXXX"+ext)
3488         return 1
3489
3490     def handle_map_attr(self, key, focus, mark, str, str2, comm2, **a):
3491         "handle:map-attr"
3492         if str == "render:rfc822header":
3493             comm2("attr:callback", focus, int(str2), mark, "fg:#6495ed,nobold", 121)
3494             comm2("attr:callback", focus, 0, mark, "wrap-tail: ,wrap-head:    ",
3495                   121)
3496             return 1
3497         if str == "render:rfc822header-addr":
3498             # str is len,tag,hdr
3499             w=str2.split(",")
3500             if w[2] in ["From", "To", "Cc"]:
3501                 comm2("attr:callback", focus, int(w[0]), mark,
3502                       "underline,action-menu:notmuch-addr-menu,addr-tag:"+w[1],
3503                       200)
3504             return 1
3505         if str == "render:rfc822header-wrap":
3506             comm2("attr:callback", focus, int(str2), mark, "wrap", 120)
3507             return 1
3508         if str == "render:rfc822header:subject":
3509             comm2("attr:callback", focus, 10000, mark, "fg:blue,bold", 120)
3510             return 1
3511         if str == "render:rfc822header:to":
3512             comm2("attr:callback", focus, 10000, mark, "word-wrap:0,fg:blue,bold", 120)
3513             return 1
3514         if str == "render:rfc822header:cc":
3515             comm2("attr:callback", focus, 10000, mark, "word-wrap:0", 120)
3516             return 1
3517         if str == "render:hide":
3518             comm2("attr:callback", focus, 100000 if str2 == "1" else -1,
3519                   mark, "hide", 100000)
3520         if str == "render:bold":
3521             comm2("attr:callback", focus, 100000 if str2 == "1" else -1,
3522                   mark, "bold", 120)
3523         if str == "render:internal":
3524             comm2("attr:callback", focus, 100000 if str2 == "1" else -1,
3525                   mark, "hide", 120)
3526         if str == "render:imgalt":
3527             comm2("attr:callback", focus, 100000 if str2 == "1" else -1,
3528                   mark, "fg:green-60", 120)
3529         if str == "render:char":
3530             w = str2.split(':')
3531             attr = None
3532             if w[1] and  w[1][0] == '!' and w[1] != '!':
3533                 # not recognised, so highlight the name
3534                 attr = "fg:magenta-60,bold"
3535             comm2("attr:callback", focus, int(w[0]), mark,
3536                   attr, 120, str2=w[1])
3537             # Don't show the html entity description, just the rendering.
3538             comm2("attr:callback", focus, int(w[0]), mark,
3539                   "hide", 60000)
3540         if str == 'start-of-line':
3541             m = self.vmark_at_or_before(self.qview, mark)
3542             bg = None
3543             if m and m['quote-length']:
3544                 bg = "white-95"
3545             # if line starts '>', give it some colour
3546             if focus.following(mark) == '>':
3547                 colours = ['red', 'red-60', 'green-60', 'magenta-60']
3548                 m = mark.dup()
3549                 cnt = 0
3550                 c = focus.next(m)
3551                 while c and c in ' >':
3552                     if c == '>':
3553                         cnt += 1
3554                     c = focus.next(m)
3555
3556                 if cnt >= len(colours):
3557                     cnt = len(colours)
3558                 comm2("cb", focus, mark, 0, "fg:"+colours[cnt-1], 102)
3559                 if bg:
3560                     comm2("cb", focus, mark, 0, "bg:"+bg, 102)
3561             return edlib.Efallthrough
3562
3563     def handle_menu(self, key, focus, mark, xy, str1, **a):
3564         "handle:notmuch-addr-menu"
3565         if self.menu:
3566             self.menu.call("Cancel")
3567         for at in str1.split(','):
3568             if at.startswith("addr-tag:"):
3569                 t = at[9:]
3570                 addr = focus.call("doc:get-attr", 0, mark, "addr-"+t, ret='str')
3571         if not addr:
3572             return 1
3573         ad = email.utils.getaddresses([addr])
3574         if ad and ad[0] and len(ad[0]) == 2:
3575             addr = ad[0][1]
3576         focus.call("Message", "Menu for address %s" % addr)
3577         mp = self.call("attach-menu", "", "notmuch-addr-choice", xy, ret='pane')
3578         mp.call("menu-add", "Compose", "C")
3579         for dir in [ "from", "to" ]:
3580             q = focus.call("doc:notmuch:get-query", dir + "-list", ret='str')
3581             if q:
3582                 for t in q.split():
3583                     if t.startswith("query:"):
3584                         t = t[6:]
3585                         qq = focus.call("doc:notmuch:get-query", t, ret='str')
3586                         if qq and (dir + ":" + addr) in qq:
3587                             mp.call("menu-add", 1, 'Already in "%s"' % t, "-" + t)
3588                         else:
3589                             mp.call("menu-add", 'Add to "%s"' % t, dir+':'+t)
3590         mp.call("doc:file", -1)
3591         self.menu = mp
3592         self.addr = addr
3593         self.add_notify(mp, "Notify:Close")
3594         return 1
3595
3596     def handle_notify_close(self, key, focus, **a):
3597         "handle:Notify:Close"
3598         if focus == self.menu:
3599             self.menu = None
3600             return 1
3601         return edlib.Efallthrough
3602
3603     def handle_addr_choice(self, key, focus, mark, str1, **a):
3604         "handle:notmuch-addr-choice"
3605         if not str1 or not self.addr:
3606             return None
3607         if str1.startswith('-'):
3608             # already in this query
3609             return None
3610         dir = str1.split(':')[0]
3611         if len(str1) <= len(dir):
3612             return None
3613         query = str1[len(dir)+1:]
3614         q = focus.call("doc:notmuch:get-query", query, ret='str')
3615         if type(q) == str:
3616             # notmuch combines "to:" with "and" - weird
3617             if dir == "to":
3618                 q = q + " OR " + dir + ":" + self.addr
3619             else:
3620                 q = q + " " + dir + ":" + self.addr
3621             if focus.call("doc:notmuch:set-query", query, q) > 0:
3622                 focus.call("Message",
3623                            "Updated query.%s with %s" % (query, self.addr))
3624             else:
3625                 focus.call("Message",
3626                            "Update for query.%s failed." % query)
3627         return 1
3628
3629     def handle_render_line(self, key, focus, num, mark, mark2, comm2, **a):
3630         "handle:doc:render-line"
3631         # If between active quote marks, render a simple marker
3632         p = self.vmark_at_or_before(self.qview, mark)
3633         if p and not p['quote-length'] and p == mark:
3634             # at end of last line
3635             p = p.prev()
3636         cursor_at_end = mark2 and mark2 > mark
3637
3638         if not(p and p['quote-length'] and p['quote-hidden'] == 'yes'):
3639             return edlib.Efallthrough
3640         if num < 0:
3641             # render full line
3642             mark.to_mark(p.next())
3643             self.next(mark)
3644             eol="\n"
3645         if num >= 0:
3646             # don't move mark from start of line
3647             # So 'click' always places at start of line.
3648             eol = ""
3649
3650         if comm2:
3651             line = "<fg:yellow,bg:blue+30>%d quoted lines</>%s" % (int(p['quote-length']), eol)
3652             if cursor_at_end:
3653                 cpos = len(line)-1
3654             else:
3655                 cpos = 0
3656
3657             return comm2("cb", focus, cpos, line)
3658         return 1
3659
3660     def handle_render_line_prev(self, key, focus, num, mark, comm2, **a):
3661         "handle:doc:render-line-prev"
3662         # If between active quote marks, move to start first
3663         p = self.vmark_at_or_before(self.qview, mark)
3664         if not(p and p['quote-length'] and p['quote-active'] == 'yes'):
3665             return edlib.Efallthrough
3666         mark.to_mark(p)
3667         return edlib.Efallthrough
3668
3669     def handle_moving(self, key, focus, mark, mark2, **a):
3670         "handle:mark:moving"
3671         if mark == self.point and not self.have_prev:
3672             # We cannot dup because that triggers a recursive notification
3673             #self.prev_point = mark.dup()
3674             self.prev_point = self.vmark_at_or_before(self.qview, mark)
3675             self.have_prev = True
3676             self.damaged(edlib.DAMAGED_VIEW)
3677         return 1
3678
3679     def handle_review(self, key, focus, **a):
3680         "handle:Refresh:view"
3681         # if point is in a "quoted line" section that is hidden,
3682         # Move it to start or end opposite prev_point
3683         if not self.have_prev:
3684             return 1
3685         m = self.vmark_at_or_before(self.qview, self.point)
3686         if m and m != self.point and m['quote-length'] and m['quote-hidden'] == "yes":
3687             if not self.prev_point or self.prev_point < self.point:
3688                 # moving toward end of file
3689                 m = m.next()
3690             if self.point != m:
3691                 self.point.to_mark(m)
3692         self.prev_point = None
3693         self.have_prev = False
3694         return 1
3695
3696 def notmuch_doc(key, home, focus, comm2, **a):
3697     # Create the root notmuch document
3698     nm = notmuch_main(home)
3699     nm['render-default'] = "notmuch:master-view"
3700     nm.call("doc:set-name", "*Notmuch*")
3701     nm.call("global-multicall-doc:appeared-")
3702     nm.call("doc:notmuch:update")
3703     if comm2 is not None:
3704         comm2("callback", focus, nm)
3705     return 1
3706
3707 def render_query_attach(key, focus, comm2, **a):
3708     p = notmuch_query_view(focus)
3709     p["format:no-linecount"] = "1"
3710     p = p.call("attach-render-format", ret='pane')
3711     p['render-wrap'] = 'yes'
3712     if comm2:
3713         comm2("callback", p)
3714     return 1
3715
3716 def render_message_attach(key, focus, comm2, **a):
3717     p = focus.call("attach-email-view", ret='pane')
3718     p = notmuch_message_view(p)
3719     if p:
3720         p2 = p.call("attach-render-url-view", ret='pane')
3721         if p2:
3722             p = p2
3723     if comm2:
3724         comm2("callback", p)
3725     return 1
3726
3727 def render_master_view_attach(key, focus, comm2, **a):
3728     # The master view for the '*Notmuch*' document uses multiple tiles
3729     # to display the available searches, the current search results, and the
3730     # current message, though each of these is optional.
3731     # The tile which displays the search list does not have a document, as it
3732     # refers down the main document.  So it doesn't automatically get borders
3733     # from a 'view', so we must add one explicitly.
3734     # We assume 'focus' is a 'view' pane on a "doc" pane for the notmuch primary doc,
3735     # which is (probably) on a "tile" pane.
3736     # The master view needs to be below the "doc" pane, above the tile.
3737     # We attach it on focus and use Pane.reparent to rotate
3738     # from:  master_view -> view -> doc -> tile
3739     #   to:  view -> doc -> master_view -> tile
3740
3741     doc = focus.parent
3742     main = notmuch_master_view(focus)
3743     doc.reparent(main)
3744     p = main.call("attach-tile", "notmuch", "main", ret='pane')
3745     # Now we have tile(main) -> view -> doc -> master_view -> tile
3746     # and want tile(main) above doc
3747     doc.reparent(p)
3748     # Now 'view' doesn't have a child -we give it 'list_view'
3749     p = notmuch_list_view(focus)
3750     p = p.call("attach-render-format", ret='pane')
3751     main.list_pane = p
3752     p.take_focus()
3753     comm2("callback", p)
3754     return 1
3755
3756 def notmuch_pane(focus):
3757     p0 = focus.call("ThisPane", ret='pane')
3758     try:
3759         p1 = focus.call("docs:byname", "*Notmuch*", ret='pane')
3760     except edlib.commandfailed:
3761         p1 = focus.call("attach-doc-notmuch", ret='pane')
3762     if not p1:
3763         return None
3764     return p1.call("doc:attach-view", p0, ret='pane')
3765
3766 def notmuch_mode(key, focus, **a):
3767     if notmuch_pane(focus):
3768         return 1
3769     return edlib.Efail
3770
3771 def notmuch_compose(key, focus, **a):
3772     choice = []
3773     def choose(choice, a):
3774         focus = a['focus']
3775         if focus['email-sent'] == 'no':
3776             choice.append(focus)
3777             return 1
3778         return 0
3779     focus.call("docs:byeach", lambda key,**a:choose(choice, a))
3780     if len(choice):
3781         par = focus.call("ThisPane", ret='pane')
3782         if par:
3783             par = choice[0].call("doc:attach-view", par, 1, ret='pane')
3784             par.take_focus()
3785     else:
3786         try:
3787             db = focus.call("docs:byname", "*Notmuch*", ret='pane')
3788         except edlib.commandfailed:
3789             db = focus.call("attach-doc-notmuch", ret='pane')
3790         v = make_composition(db, focus, "ThisPane")
3791         if v:
3792             v.call("compose-email:empty-headers")
3793     return 1
3794
3795 def notmuch_search(key, focus, **a):
3796     p = notmuch_pane(focus)
3797     if p:
3798         p.call("doc:char-s")
3799     return 1
3800
3801 edlib.editor.call("global-set-command", "attach-doc-notmuch", notmuch_doc)
3802 edlib.editor.call("global-set-command", "attach-render-notmuch:master-view",
3803                   render_master_view_attach)
3804 edlib.editor.call("global-set-command", "attach-render-notmuch:threads",
3805                   render_query_attach)
3806 edlib.editor.call("global-set-command", "attach-render-notmuch:message",
3807                   render_message_attach)
3808 edlib.editor.call("global-set-command", "interactive-cmd-nm", notmuch_mode)
3809 edlib.editor.call("global-set-command", "interactive-cmd-nmc", notmuch_compose)
3810 edlib.editor.call("global-set-command", "interactive-cmd-nms", notmuch_search)