]> git.notmuchmail.org Git - notmuch/blob - vim/notmuch.vim
0ccaaf1ebebf32e5f82ec73389448f8bf278e8ce
[notmuch] / vim / notmuch.vim
1 if exists("g:loaded_notmuch_rb")
2         finish
3 endif
4
5 if !has("ruby") || version < 700
6         finish
7 endif
8
9 let g:loaded_notmuch_rb = "yep"
10
11 let g:notmuch_rb_folders_maps = {
12         \ '<Enter>':    'folders_show_search()',
13         \ 's':          'folders_search_prompt()',
14         \ '=':          'folders_refresh()',
15         \ }
16
17 let g:notmuch_rb_search_maps = {
18         \ 'q':          'kill_this_buffer()',
19         \ '<Enter>':    'search_show_thread(1)',
20         \ '<Space>':    'search_show_thread(2)',
21         \ 'A':          'search_tag("-inbox -unread")',
22         \ 'I':          'search_tag("-unread")',
23         \ 't':          'search_tag("")',
24         \ 's':          'search_search_prompt()',
25         \ '=':          'search_refresh()',
26         \ '?':          'search_info()',
27         \ }
28
29 let g:notmuch_rb_show_maps = {
30         \ 'q':          'kill_this_buffer()',
31         \ 'A':          'show_tag("-inbox -unread")',
32         \ 'I':          'show_tag("-unread")',
33         \ 't':          'show_tag("")',
34         \ 'o':          'show_open_msg()',
35         \ 'e':          'show_extract_msg()',
36         \ 's':          'show_save_msg()',
37         \ 'r':          'show_reply()',
38         \ '?':          'show_info()',
39         \ '<Tab>':      'show_next_msg()',
40         \ }
41
42 let g:notmuch_rb_compose_maps = {
43         \ ',s':         'compose_send()',
44         \ ',q':         'compose_quit()',
45         \ }
46
47 let s:notmuch_rb_folders_default = [
48         \ [ 'new', 'tag:inbox and tag:unread' ],
49         \ [ 'inbox', 'tag:inbox' ],
50         \ [ 'unread', 'tag:unread' ],
51         \ ]
52
53 let s:notmuch_rb_date_format_default = '%d.%m.%y'
54 let s:notmuch_rb_datetime_format_default = '%d.%m.%y %H:%M:%S'
55 let s:notmuch_rb_reader_default = 'mutt -f %s'
56 let s:notmuch_rb_sendmail_default = 'sendmail'
57 let s:notmuch_rb_folders_count_threads_default = 0
58
59 if !exists('g:notmuch_rb_date_format')
60         let g:notmuch_rb_date_format = s:notmuch_rb_date_format_default
61 endif
62
63 if !exists('g:notmuch_rb_datetime_format')
64         let g:notmuch_rb_datetime_format = s:notmuch_rb_datetime_format_default
65 endif
66
67 if !exists('g:notmuch_rb_reader')
68         let g:notmuch_rb_reader = s:notmuch_rb_reader_default
69 endif
70
71 if !exists('g:notmuch_rb_sendmail')
72         let g:notmuch_rb_sendmail = s:notmuch_rb_sendmail_default
73 endif
74
75 if !exists('g:notmuch_rb_folders_count_threads')
76         let g:notmuch_rb_folders_count_threads = s:notmuch_rb_folders_count_threads_default
77 endif
78
79 function! s:new_file_buffer(type, fname)
80         exec printf('edit %s', a:fname)
81         execute printf('set filetype=notmuch-%s', a:type)
82         execute printf('set syntax=notmuch-%s', a:type)
83         ruby $buf_queue.push($curbuf.number)
84 endfunction
85
86 function! s:compose_unload()
87         if b:compose_done
88                 return
89         endif
90         if input('[s]end/[q]uit? ') =~ '^s'
91                 call s:compose_send()
92         endif
93 endfunction
94
95 "" actions
96
97 function! s:compose_quit()
98         let b:compose_done = 1
99         call s:kill_this_buffer()
100 endfunction
101
102 function! s:compose_send()
103         let b:compose_done = 1
104         let fname = expand('%')
105
106         " remove headers
107         0,4d
108         write
109
110         let cmdtxt = g:notmuch_sendmail . ' -t -f ' . s:reply_from . ' < ' . fname
111         let out = system(cmdtxt)
112         let err = v:shell_error
113         if err
114                 undo
115                 write
116                 echohl Error
117                 echo 'Eeek! unable to send mail'
118                 echo out
119                 echohl None
120                 return
121         endif
122         call delete(fname)
123         echo 'Mail sent successfully.'
124         call s:kill_this_buffer()
125 endfunction
126
127 function! s:show_next_msg()
128 ruby << EOF
129         r, c = $curwin.cursor
130         n = $curbuf.line_number
131         i = $messages.index { |m| n >= m.start && n <= m.end }
132         m = $messages[i + 1]
133         if m
134                 r = m.body_start + 1
135                 VIM::command("normal #{m.start}zt")
136                 $curwin.cursor = r, c
137         end
138 EOF
139 endfunction
140
141 function! s:show_reply()
142         ruby open_reply get_message.mail
143         let b:compose_done = 0
144         call s:set_map(g:notmuch_rb_compose_maps)
145         autocmd BufUnload <buffer> call s:compose_unload()
146         startinsert!
147 endfunction
148
149 function! s:show_info()
150         ruby vim_puts get_message.inspect
151 endfunction
152
153 function! s:show_extract_msg()
154 ruby << EOF
155         m = get_message
156         m.mail.attachments.each do |a|
157                 File.open(a.filename, 'w') do |f|
158                         f.write a.body.decoded
159                         print "Extracted '#{a.filename}'"
160                 end
161         end
162 EOF
163 endfunction
164
165 function! s:show_open_msg()
166 ruby << EOF
167         m = get_message
168         mbox = File.expand_path('~/.notmuch/vim_mbox')
169         cmd = VIM::evaluate('g:notmuch_rb_reader') % mbox
170         system "notmuch show --format=mbox id:#{m.message_id} > #{mbox} && #{cmd}"
171 EOF
172 endfunction
173
174 function! s:show_save_msg()
175         let file = input('File name: ')
176 ruby << EOF
177         file = VIM::evaluate('file')
178         m = get_message
179         system "notmuch show --format=mbox id:#{m.message_id} > #{file}"
180 EOF
181 endfunction
182
183 function! s:show_tag(intags)
184         if empty(a:intags)
185                 let tags = input('tags: ')
186         else
187                 let tags = a:intags
188         endif
189         ruby do_tag(get_cur_view, VIM::evaluate('l:tags'))
190         call s:show_next_thread()
191 endfunction
192
193 function! s:search_search_prompt()
194         let text = input('Search: ')
195         setlocal modifiable
196 ruby << EOF
197         $cur_search = VIM::evaluate('text')
198         search_render($cur_search)
199 EOF
200         setlocal nomodifiable
201 endfunction
202
203 function! s:search_info()
204         ruby vim_puts get_thread_id
205 endfunction
206
207 function! s:search_refresh()
208         setlocal modifiable
209         ruby search_render($cur_search)
210         setlocal nomodifiable
211 endfunction
212
213 function! s:search_tag(intags)
214         if empty(a:intags)
215                 let tags = input('tags: ')
216         else
217                 let tags = a:intags
218         endif
219         ruby do_tag(get_thread_id, VIM::evaluate('l:tags'))
220         norm j
221         call s:search_refresh()
222 endfunction
223
224 function! s:folders_search_prompt()
225         let text = input('Search: ')
226         call s:search(text)
227 endfunction
228
229 function! s:folders_refresh()
230         setlocal modifiable
231         ruby folders_render()
232         setlocal nomodifiable
233 endfunction
234
235 "" basic
236
237 function! s:show_cursor_moved()
238 ruby << EOF
239         if $render.is_ready?
240                 VIM::command('setlocal modifiable')
241                 $render.do_next
242                 VIM::command('setlocal nomodifiable')
243         end
244 EOF
245 endfunction
246
247 function! s:show_next_thread()
248         call s:kill_this_buffer()
249         if line('.') != line('$')
250                 norm j
251                 call s:search_show_thread(0)
252         else
253                 echo 'No more messages.'
254         endif
255 endfunction
256
257 function! s:kill_this_buffer()
258         bdelete!
259 ruby << EOF
260         $buf_queue.pop
261         b = $buf_queue.last
262         VIM::command("buffer #{b}") if b
263 EOF
264 endfunction
265
266 function! s:set_map(maps)
267         nmapclear <buffer>
268         for [key, code] in items(a:maps)
269                 let cmd = printf(":call <SID>%s<CR>", code)
270                 exec printf('nnoremap <buffer> %s %s', key, cmd)
271         endfor
272 endfunction
273
274 function! s:new_buffer(type)
275         enew
276         setlocal buftype=nofile bufhidden=hide
277         keepjumps 0d
278         execute printf('set filetype=notmuch-%s', a:type)
279         execute printf('set syntax=notmuch-%s', a:type)
280         ruby $buf_queue.push($curbuf.number)
281 endfunction
282
283 function! s:set_menu_buffer()
284         setlocal nomodifiable
285         setlocal cursorline
286         setlocal nowrap
287 endfunction
288
289 "" main
290
291 function! s:show(thread_id)
292         call s:new_buffer('show')
293         setlocal modifiable
294 ruby << EOF
295         thread_id = VIM::evaluate('a:thread_id')
296         $cur_thread = thread_id
297         $messages.clear
298         $curbuf.render do |b|
299                 do_read do |db|
300                         q = db.query(get_cur_view)
301                         q.sort = 0
302                         msgs = q.search_messages
303                         msgs.each do |msg|
304                                 m = Mail.read(msg.filename)
305                                 part = m.find_first_text
306                                 nm_m = Message.new(msg, m)
307                                 $messages << nm_m
308                                 date_fmt = VIM::evaluate('g:notmuch_rb_datetime_format')
309                                 date = Time.at(msg.date).strftime(date_fmt)
310                                 nm_m.start = b.count
311                                 b << "%s %s (%s)" % [msg['from'], date, msg.tags]
312                                 b << "Subject: %s" % [msg['subject']]
313                                 b << "To: %s" % m['to']
314                                 b << "Cc: %s" % m['cc']
315                                 b << "Date: %s" % m['date']
316                                 nm_m.body_start = b.count
317                                 b << "--- %s ---" % part.mime_type
318                                 part.convert.each_line do |l|
319                                         b << l.chomp
320                                 end
321                                 b << ""
322                                 nm_m.end = b.count
323                         end
324                         b.delete(b.count)
325                 end
326         end
327         $messages.each_with_index do |msg, i|
328                 VIM::command("syntax region nmShowMsg#{i}Desc start='\\%%%il' end='\\%%%il' contains=@nmShowMsgDesc" % [msg.start, msg.start + 1])
329                 VIM::command("syntax region nmShowMsg#{i}Head start='\\%%%il' end='\\%%%il' contains=@nmShowMsgHead" % [msg.start + 1, msg.body_start])
330                 VIM::command("syntax region nmShowMsg#{i}Body start='\\%%%il' end='\\%%%dl' contains=@nmShowMsgBody" % [msg.body_start, msg.end])
331         end
332 EOF
333         setlocal nomodifiable
334         call s:set_map(g:notmuch_rb_show_maps)
335 endfunction
336
337 function! s:search_show_thread(mode)
338 ruby << EOF
339         mode = VIM::evaluate('a:mode')
340         id = get_thread_id
341         case mode
342         when 0;
343         when 1; $cur_filter = nil
344         when 2; $cur_filter = $cur_search
345         end
346         VIM::command("call s:show('#{id}')")
347 EOF
348 endfunction
349
350 function! s:search(search)
351         call s:new_buffer('search')
352 ruby << EOF
353         $cur_search = VIM::evaluate('a:search')
354         search_render($cur_search)
355 EOF
356         call s:set_menu_buffer()
357         call s:set_map(g:notmuch_rb_search_maps)
358         autocmd CursorMoved <buffer> call s:show_cursor_moved()
359 endfunction
360
361 function! s:folders_show_search()
362 ruby << EOF
363         n = $curbuf.line_number
364         s = $searches[n - 1]
365         VIM::command("call s:search('#{s}')")
366 EOF
367 endfunction
368
369 function! s:folders()
370         call s:new_buffer('folders')
371         ruby folders_render()
372         call s:set_menu_buffer()
373         call s:set_map(g:notmuch_rb_folders_maps)
374 endfunction
375
376 "" root
377
378 function! s:set_defaults()
379         if exists('g:notmuch_rb_custom_search_maps')
380                 call extend(g:notmuch_rb_search_maps, g:notmuch_rb_custom_search_maps)
381         endif
382
383         if exists('g:notmuch_rb_custom_show_maps')
384                 call extend(g:notmuch_rb_show_maps, g:notmuch_rb_custom_show_maps)
385         endif
386
387         " TODO for now lets check the old folders too
388         if !exists('g:notmuch_rb_folders')
389                 if exists('g:notmuch_folders')
390                         let g:notmuch_rb_folders = g:notmuch_folders
391                 else
392                         let g:notmuch_rb_folders = s:notmuch_rb_folders_default
393                 endif
394         endif
395 endfunction
396
397 function! s:NotMuch()
398         call s:set_defaults()
399
400 ruby << EOF
401         require 'notmuch'
402         require 'rubygems'
403         require 'tempfile'
404         begin
405                 require 'mail'
406         rescue LoadError
407         end
408
409         $db_name = nil
410         $email_address = nil
411         $searches = []
412         $buf_queue = []
413         $threads = []
414         $messages = []
415         $config = {}
416         $mail_installed = defined?(Mail)
417
418         def get_config
419                 group = nil
420                 config = ENV['NOTMUCH_CONFIG'] || '~/.notmuch-config'
421                 File.open(File.expand_path(config)).each do |l|
422                         l.chomp!
423                         case l
424                         when /^\[(.*)\]$/
425                                 group = $1
426                         when ''
427                         when /^(.*)=(.*)$/
428                                 key = "%s.%s" % [group, $1]
429                                 value = $2
430                                 $config[key] = value
431                         end
432                 end
433
434                 $db_name = $config['database.path']
435                 $email_address = "%s <%s>" % [$config['user.name'], $config['user.primary_email']]
436         end
437
438         def vim_puts(s)
439                 VIM::command("echo '#{s.to_s}'")
440         end
441
442         def vim_p(s)
443                 VIM::command("echo '#{s.inspect}'")
444         end
445
446         def author_filter(a)
447                 # TODO email format, aliases
448                 a.strip!
449                 a.gsub!(/[\.@].*/, '')
450                 a.gsub!(/^ext /, '')
451                 a.gsub!(/ \(.*\)/, '')
452                 a
453         end
454
455         def get_thread_id
456                 n = $curbuf.line_number - 1
457                 return "thread:%s" % $threads[n]
458         end
459
460         def get_message
461                 n = $curbuf.line_number
462                 return $messages.find { |m| n >= m.start && n <= m.end }
463         end
464
465         def get_cur_view
466                 if $cur_filter
467                         return "#{$cur_thread} and (#{$cur_filter})"
468                 else
469                         return $cur_thread
470                 end
471         end
472
473         def do_write
474                 db = Notmuch::Database.new($db_name, :mode => Notmuch::MODE_READ_WRITE)
475                 begin
476                         yield db
477                 ensure
478                         db.close
479                 end
480         end
481
482         def do_read
483                 db = Notmuch::Database.new($db_name)
484                 begin
485                         yield db
486                 ensure
487                         db.close
488                 end
489         end
490
491         def open_reply(orig)
492                 help_lines = [
493                         'Notmuch-Help: Type in your message here; to help you use these bindings:',
494                         'Notmuch-Help:   ,s    - send the message (Notmuch-Help lines will be removed)',
495                         'Notmuch-Help:   ,q    - abort the message',
496                         ]
497                 reply = orig.reply do |m|
498                         # fix headers
499                         if not m[:reply_to]
500                                 m.to = [orig[:from].to_s, orig[:to].to_s]
501                         end
502                         m.cc = orig[:cc]
503                         m.from = $email_address
504                         m.charset = 'utf-8'
505                         m.content_transfer_encoding = '7bit'
506                 end
507
508                 dir = File.expand_path('~/.notmuch/compose')
509                 FileUtils.mkdir_p(dir)
510                 Tempfile.open(['nm-', '.mail'], dir) do |f|
511                         lines = []
512
513                         lines += help_lines
514                         lines << ''
515
516                         body_lines = []
517                         if $mail_installed
518                                 addr = Mail::Address.new(orig[:from].value)
519                                 name = addr.name
520                                 name = addr.local + "@" if name.nil? && !addr.local.nil?
521                         else
522                                 name = orig[:from]
523                         end
524                         name = "somebody" if name.nil?
525
526                         body_lines << "%s wrote:" % name
527                         part = orig.find_first_text
528                         part.convert.each_line do |l|
529                                 body_lines << "> %s" % l.chomp
530                         end
531                         body_lines << ""
532                         body_lines << ""
533                         body_lines << ""
534
535                         reply.body = body_lines.join("\n")
536
537                         lines += reply.to_s.lines.map { |e| e.chomp }
538                         lines << ""
539
540                         old_count = lines.count - 1
541
542                         f.puts(lines)
543
544                         sig_file = File.expand_path('~/.signature')
545                         if File.exists?(sig_file)
546                                 f.puts("-- ")
547                                 f.write(File.read(sig_file))
548                         end
549
550                         f.flush
551
552                         VIM::command("let s:reply_from='%s'" % reply.from.first.to_s)
553                         VIM::command("call s:new_file_buffer('compose', '#{f.path}')")
554                         VIM::command("call cursor(#{old_count}, 0)")
555                 end
556         end
557
558         def folders_render()
559                 $curbuf.render do |b|
560                         folders = VIM::evaluate('g:notmuch_rb_folders')
561                         count_threads = VIM::evaluate('g:notmuch_rb_folders_count_threads')
562                         $searches.clear
563                         do_read do |db|
564                                 folders.each do |name, search|
565                                         q = db.query(search)
566                                         $searches << search
567                                         count = count_threads ? q.search_threads.count : q.search_messages.count
568                                         b << "%9d %-20s (%s)" % [count, name, search]
569                                 end
570                         end
571                 end
572         end
573
574         def search_render(search)
575                 date_fmt = VIM::evaluate('g:notmuch_rb_date_format')
576                 db = Notmuch::Database.new($db_name)
577                 q = db.query(search)
578                 $threads.clear
579                 t = q.search_threads
580
581                 $render = $curbuf.render_staged(t) do |b, items|
582                         items.each do |e|
583                                 authors = e.authors.to_utf8.split(/[,|]/).map { |a| author_filter(a) }.join(",")
584                                 date = Time.at(e.newest_date).strftime(date_fmt)
585                                 if $mail_installed
586                                         subject = Mail::Field.new("Subject: " + e.subject).to_s
587                                 else
588                                         subject = e.subject.force_encoding('utf-8')
589                                 end
590                                 b << "%-12s %3s %-20.20s | %s (%s)" % [date, e.matched_messages, authors, subject, e.tags]
591                                 $threads << e.thread_id
592                         end
593                 end
594         end
595
596         def do_tag(filter, tags)
597                 do_write do |db|
598                         q = db.query(filter)
599                         q.search_messages.each do |e|
600                                 e.freeze
601                                 tags.split.each do |t|
602                                         case t
603                                         when /^-(.*)/
604                                                 e.remove_tag($1)
605                                         when /^\+(.*)/
606                                                 e.add_tag($1)
607                                         when /^([^\+^-].*)/
608                                                 e.add_tag($1)
609                                         end
610                                 end
611                                 e.thaw
612                                 e.tags_to_maildir_flags
613                         end
614                 end
615         end
616
617         class Message
618                 attr_accessor :start, :body_start, :end
619                 attr_reader :message_id, :filename, :mail
620
621                 def initialize(msg, mail)
622                         @message_id = msg.message_id
623                         @filename = msg.filename
624                         @mail = mail
625                         @start = 0
626                         @end = 0
627                         mail.import_headers(msg) if not $mail_installed
628                 end
629
630                 def to_s
631                         "id:%s" % @message_id
632                 end
633
634                 def inspect
635                         "id:%s, file:%s" % [@message_id, @filename]
636                 end
637         end
638
639         class StagedRender
640                 def initialize(buffer, enumerable, block)
641                         @b = buffer
642                         @enumerable = enumerable
643                         @block = block
644                         @last_render = 0
645
646                         @b.render { do_next }
647                 end
648
649                 def is_ready?
650                         @last_render - @b.line_number <= $curwin.height
651                 end
652
653                 def do_next
654                         items = @enumerable.take($curwin.height * 2)
655                         return if items.empty?
656                         @block.call @b, items
657                         @last_render = @b.count
658                 end
659         end
660
661         class VIM::Buffer
662                 def <<(a)
663                         append(count(), a)
664                 end
665
666                 def render_staged(enumerable, &block)
667                         StagedRender.new(self, enumerable, block)
668                 end
669
670                 def render
671                         old_count = count
672                         yield self
673                         (1..old_count).each do
674                                 delete(1)
675                         end
676                 end
677         end
678
679         class Notmuch::Tags
680                 def to_s
681                         to_a.join(" ")
682                 end
683         end
684
685         class Notmuch::Message
686                 def to_s
687                         "id:%s" % message_id
688                 end
689         end
690
691         # workaround for bug in vim's ruby
692         class Object
693                 def flush
694                 end
695         end
696
697         module SimpleMessage
698                 class Header < Array
699                         def self.parse(string)
700                                 return nil if string.empty?
701                                 return Header.new(string.split(/,\s+/))
702                         end
703
704                         def to_s
705                                 self.join(', ')
706                         end
707                 end
708
709                 def initialize(string = nil)
710                         @raw_source = string
711                         @body = nil
712                         @headers = {}
713
714                         return if not string
715
716                         if string =~ /(.*?(\r\n|\n))\2/m
717                                 head, body = $1, $' || '', $2
718                         else
719                                 head, body = string, ''
720                         end
721                         @body = body
722                 end
723
724                 def [](name)
725                         @headers[name.to_sym]
726                 end
727
728                 def []=(name, value)
729                         @headers[name.to_sym] = value
730                 end
731
732                 def format_header(value)
733                         value.to_s.tr('_', '-').gsub(/(\w+)/) { $1.capitalize }
734                 end
735
736                 def to_s
737                         buffer = ''
738                         @headers.each do |key, value|
739                                 buffer << "%s: %s\r\n" %
740                                         [format_header(key), value]
741                         end
742                         buffer << "\r\n"
743                         buffer << @body
744                         buffer
745                 end
746
747                 def body=(value)
748                         @body = value
749                 end
750
751                 def from
752                         @headers[:from]
753                 end
754
755                 def decoded
756                         @body
757                 end
758
759                 def mime_type
760                         'text/plain'
761                 end
762
763                 def multipart?
764                         false
765                 end
766
767                 def reply
768                         r = Mail::Message.new
769                         r[:from] = self[:to]
770                         r[:to] = self[:from]
771                         r[:cc] = self[:cc]
772                         r[:in_reply_to] = self[:message_id]
773                         r[:references] = self[:references]
774                         r
775                 end
776
777                 HEADERS = [ :from, :to, :cc, :references, :in_reply_to, :reply_to, :message_id ]
778
779                 def import_headers(m)
780                         HEADERS.each do |e|
781                                 dashed = format_header(e)
782                                 @headers[e] = Header.parse(m[dashed])
783                         end
784                 end
785         end
786
787         module Mail
788
789                 if not $mail_installed
790                         puts "WARNING: Install the 'mail' gem, without it support is limited"
791
792                         def self.read(filename)
793                                 Message.new(File.open(filename, 'rb') { |f| f.read })
794                         end
795
796                         class Message
797                                 include SimpleMessage
798                         end
799                 end
800
801                 class Message
802
803                         def find_first_text
804                                 return self if not multipart?
805                                 return text_part || html_part
806                         end
807
808                         def convert
809                                 if mime_type != "text/html"
810                                         text = decoded
811                                 else
812                                         IO.popen("elinks --dump", "w+") do |pipe|
813                                                 pipe.write(decode_body)
814                                                 pipe.close_write
815                                                 text = pipe.read
816                                         end
817                                 end
818                                 text
819                         end
820                 end
821         end
822
823         class String
824                 def to_utf8
825                         RUBY_VERSION >= "1.9" ? force_encoding('utf-8') : self
826                 end
827         end
828
829         get_config
830 EOF
831         call s:folders()
832 endfunction
833
834 command NotMuch :call s:NotMuch()
835
836 " vim: set noexpandtab: