Return-Path: X-Original-To: notmuch@notmuchmail.org Delivered-To: notmuch@notmuchmail.org Received: from localhost (localhost [127.0.0.1]) by olra.theworths.org (Postfix) with ESMTP id 8D147431FD0 for ; Sun, 15 May 2011 12:21:07 -0700 (PDT) X-Virus-Scanned: Debian amavisd-new at olra.theworths.org X-Spam-Flag: NO X-Spam-Score: 1.845 X-Spam-Level: * X-Spam-Status: No, score=1.845 tagged_above=-999 required=5 tests=[LONGWORDS=1.844, WEIRD_QUOTING=0.001] autolearn=disabled Received: from olra.theworths.org ([127.0.0.1]) by localhost (olra.theworths.org [127.0.0.1]) (amavisd-new, port 10024) with ESMTP id lo3fDz2gKhhp for ; Sun, 15 May 2011 12:21:03 -0700 (PDT) X-Greylist: delayed 323 seconds by postgrey-1.32 at olra; Sun, 15 May 2011 12:21:03 PDT Received: from lain.khirnov.net (lain.khirnov.net [193.85.154.54]) by olra.theworths.org (Postfix) with ESMTP id 19A0F431FB6 for ; Sun, 15 May 2011 12:21:03 -0700 (PDT) Received: from localhost (localhost [127.0.0.1]) by lain.khirnov.net (Postfix) with ESMTP id 01412C127B for ; Sun, 15 May 2011 21:15:39 +0200 (CEST) Received: from lain.khirnov.net ([127.0.0.1]) by localhost (mail.khirnov.net [127.0.0.1]) (amavisd-new, port 10024) with ESMTP id 5IEjlamK6tts for ; Sun, 15 May 2011 21:15:32 +0200 (CEST) Received: from zohar.localdomain (unknown [192.168.0.1]) by lain.khirnov.net (Postfix) with ESMTP id C47E7C0DEF for ; Sun, 15 May 2011 21:15:32 +0200 (CEST) Received: from zohar.khirnov.net (localhost [127.0.0.1]) by zohar.localdomain (Postfix) with ESMTP id 97FCC7F43E for ; Sun, 15 May 2011 21:15:32 +0200 (CEST) Content-Type: multipart/mixed; boundary="===============0474093823==" MIME-Version: 1.0 User-Agent: Notmuch-vim EXPERIMENTAL Message-ID: <20110515191531.25709.21869@zohar.khirnov.net> Date: Sun, 15 May 2011 21:15:31 +0200 To: notmuch@notmuchmail.org From: anton@khirnov.net Subject: [RFC/PATCH] Vim client rewrite X-BeenThere: notmuch@notmuchmail.org X-Mailman-Version: 2.1.13 Precedence: list List-Id: "Use and development of the notmuch mail system." List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Sun, 15 May 2011 19:21:07 -0000 --===============0474093823== Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Hi, my attempts to make the vim client more usable somehow spiraled out of control and turned into a huge rewrite. The intermediate results I hereby present for your amusement and comments. (attached as whole files, since the patch would be unreadable) The main point of the rewrite is splitting of a large part of the code into Python. This should have the following advantages: 1) python-notmuch bindings can be used, which should allow for cleaner and more reliable code than running the binary and parsing its output with regexps. (also provides a nice use case for python-notmuch) 2) Python's huge standard library makes implementing some features MUCH eas= ier. 3) More people know Python than vimscript, thus making the client development easier The code is =CE=B1 quality, but should be close to usable. It already has some features not present in the mainline vim client, like attachments (viewing and sending, saving to file should be trivial to add, will be done when I have some time), better support for unicode and more. Some UI features from the mainline versions that I didn't use were removed and customization options are somewhat lacking atm. This is of course to be improved later, depending on the responses. Comments, bugreports and fixes very much welcome. -- Anton Khirnov --===============0474093823== Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Disposition: attachment; filename="notmuch.vim" " notmuch.vim plugin --- run notmuch within vim " " Copyright =C2=A9 Carl Worth " " This file is part of Notmuch. " " Notmuch is free software: you can redistribute it and/or modify it " under the terms of the GNU General Public License as published by " the Free Software Foundation, either version 3 of the License, or " (at your option) any later version. " " Notmuch is distributed in the hope that it will be useful, but " WITHOUT ANY WARRANTY; without even the implied warranty of " MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU " General Public License for more details. " " You should have received a copy of the GNU General Public License " along with Notmuch. If not, see . " " Authors: Bart Trojanowski " Contributors: Felipe Contreras , " Peter Hartman " if exists('s:notmuch_loaded') || &cp finish endif let s:notmuch_loaded =3D 1 " --- configuration defaults {{{1 let s:notmuch_defaults =3D { \ 'g:notmuch_cmd': 'notmuch' = , \ \ 'g:notmuch_search_newest_first': 1 = , \ \ 'g:notmuch_compose_insert_mode_start': 1 = , \ 'g:notmuch_compose_header_help': 1 = , \ 'g:notmuch_compose_temp_file_dir': '~/.notmuch/compose/' = , \ 'g:notmuch_fcc_maildir': 'sent' = , \ } " defaults for g:notmuch_folders " override with: let g:notmuch_folders =3D [ ... ] let s:notmuch_folders_defaults =3D [ \ [ 'new', 'tag:inbox and tag:unread' ], \ [ 'inbox', 'tag:inbox' ], \ [ 'unread', 'tag:unread' ], \ ] let s:notmuch_show_headers_defaults =3D [ \ 'From', \ 'To', \ 'Cc', \ 'Subject', \ 'Date', \ 'Reply-To', \ 'Message-Id', \] " defaults for g:notmuch_compose_headers " override with: let g:notmuch_compose_headers =3D [ ... ] let s:notmuch_compose_headers_defaults =3D [ \ 'From', \ 'To', \ 'Cc', \ 'Bcc', \ 'Subject' \ ] " --- keyboard mapping definitions {{{1 " --- --- bindings for folders mode {{{2 let g:notmuch_folders_maps =3D { \ 'm': ':call NM_new_mail()', \ 's': ':call NM_search_prompt(0)', \ 'q': ':call NM_kill_this_buffer()', \ '=3D': ':call NM_folders_refresh_view()', \ '': ':call NM_folders_show_search('''')', \ '': ':call NM_folders_show_search(''tag:unread'')<= CR>', \ 'tt': ':call NM_folders_from_tags()', \ } " --- --- bindings for search screen {{{2 let g:notmuch_search_maps =3D { \ '': ':call NM_search_show_thread()', \ '': ':call NM_search_show_thread()', \ '': ':call NM_search_expand('''')', \ 'a': ':call NM_search_archive_thread()', \ 'A': ':call NM_search_mark_read_then_archive_thread= ()', \ 'D': ':call NM_search_delete_thread()', \ 'f': ':call NM_search_filter()', \ 'm': ':call NM_new_mail()', \ 'o': ':call NM_search_toggle_order()', \ 'r': ':call NM_search_reply_to_thread()', \ 's': ':call NM_search_prompt(0)', \ ',s': ':call NM_search_prompt(1)', \ 'q': ':call NM_kill_this_buffer()', \ '+': ':call NM_search_add_tags([])', \ '-': ':call NM_search_remove_tags([])', \ '=3D': ':call NM_search_refresh_view()', \ } " --- --- bindings for show screen {{{2 let g:notmuch_show_maps =3D { \ '': ':call NM_jump_message(-1)', \ '': ':call NM_jump_message(+1)', \ '': ':call NM_search_expand('''')', \ 'q': ':call NM_kill_this_buffer()', \ 's': ':call NM_search_prompt(0)', \ \ \ 'a': ':call NM_show_archive_thread()', \ 'A': ':call NM_show_mark_read_then_archive_thread()= ', \ 'N': ':call NM_show_mark_read_then_next_open_messag= e()', \ 'v': ':call NM_show_view_all_mime_parts()', \ '+': ':call NM_show_add_tag()', \ '-': ':call NM_show_remove_tag()', \ '': ':call NM_show_advance()', \ '\|': ':call NM_show_pipe_message()', \ \ '': ':call NM_show_previous_fold()', \ '': ':call NM_show_next_fold()', \ '': ':call NM_show_view_attachment()', \ \ 'r': ':call NM_show_reply()', \ 'm': ':call NM_new_mail()', \ } " --- --- bindings for compose screen {{{2 let g:notmuch_compose_nmaps =3D { \ ',s': ':call NM_compose_send()', \ ',a': ':call NM_compose_attach()', \ ',q': ':call NM_kill_this_buffer()', \ '': ':call NM_compose_next_entry_area()', \ } let g:notmuch_compose_imaps =3D { \ '': '=3DNM_compose_next_entry_area()', \ } " --- implement folders screen {{{1 " Create the folders buffer. " Takes a list of [ folder name, query string] " TODO decorate (help on the first line?) function! s:NM_cmd_folders(folders) call NM_create_buffer('folders') silent 0put!=3D' Notmuch plugin.' python nm_vim.SavedSearches(vim.eval("a:folders")) call NM_finalize_menu_buffer() call NM_set_map('n', g:notmuch_folders_maps) endfunction " Show a folder for each existing tag. function! s:NM_folders_from_tags() let folders =3D [] python nm_vim.vim_get_tags() for tag in split(taglist, '\n') call add(folders, [tag, 'tag:' . tag ]) endfor call NM_cmd_folders(folders) endfunction " --- --- folders screen action functions {{{2 " Refresh the folders screen function! s:NM_folders_refresh_view() let lno =3D line('.') setlocal modifiable silent norm 3GdG python nm_vim.get_current_buffer().refresh() setlocal nomodifiable exec printf('norm %dG', lno) endfunction " Show contents of the folder corresponding to current line AND query function! s:NM_folders_show_search(query) exec printf('python nm_vim.vim_get_object(%d, 0)', line('.')) if exists('obj') if len(a:query) let querystr =3D '(' . obj['id'] . ') and ' . a:query else let querystr =3D obj['id'] endif call NM_cmd_search(querystr, 0) endif endfunction " Create the search buffer corresponding to querystr. " If relative is 1, the search is relative to current buffer function! s:NM_cmd_search(querystr, relative) let cur_buf =3D bufnr('%') call NM_create_buffer('search') if a:relative exec printf('python nm_vim.Search(querystr =3D "%s", parent =3D nm_= vim.nm_buffers["%d"])', a:querystr, cur_buf) else exec printf('python nm_vim.Search(querystr =3D "%s")', a:querystr) endif call NM_finalize_menu_buffer() call NM_set_map('n', g:notmuch_search_maps) endfunction " --- --- search screen action functions {{{2 " Show the thread corresponding to current line function! s:NM_search_show_thread() let querystr =3D NM_search_thread_id() if len(querystr) call NM_cmd_show(querystr) endif endfunction " Search according to input from user. " If edit is 1, current query string is inserted to prompt for editing. function! s:NM_search_prompt(edit) if a:edit python nm_vim.vim_get_id() else let buf_id =3D '' endif let querystr =3D input('Search: ', buf_id, 'custom,Search_type_completi= on') if len(querystr) call NM_cmd_search(querystr, 0) endif endfunction " Filter current search, i.e. search for " (current querystr) AND (user input) function! s:NM_search_filter() let querystr =3D input('Filter: ', '', 'custom,Search_type_completion') if len(querystr) call NM_cmd_search(querystr, 1) endif endfunction """"""""""""""""""""""'' TODO function! s:NM_search_archive_thread() call NM_tag([], ['-inbox']) norm j endfunction function! s:NM_search_mark_read_then_archive_thread() call NM_tag([], ['-unread', '-inbox']) norm j endfunction function! s:NM_search_delete_thread() call NM_tag([], ['+junk','-inbox','-unread']) norm j endfunction """"""""""""""""""""""""""""""""""""""""""""""""""""" " XXX This function is broken function! s:NM_search_toggle_order() let g:notmuch_search_newest_first =3D !g:notmuch_search_newest_first " FIXME: maybe this would be better done w/o reading re-reading the= lines " reversing the b:nm_raw_lines and the buffer lines would b= e better call NM_search_refresh_view() endfunction "XXX this function is broken function! s:NM_search_reply_to_thread() python vim.command('let querystr =3D "%s"'%nm_vim.get_current_buffer().= id) let cmd =3D ['reply'] call add(cmd, NM_search_thread_id()) call add(cmd, 'AND') call extend(cmd, [querystr]) let data =3D NM_run(cmd) let lines =3D split(data, "\n") call NM_newComposeBuffer(lines, 0) endfunction function! s:NM_search_add_tags(tags) call NM_search_add_remove_tags('Add Tag(s): ', '+', a:tags) endfunction function! s:NM_search_remove_tags(tags) call NM_search_add_remove_tags('Remove Tag(s): ', '-', a:tags) endfunction function! s:NM_search_refresh_view() let lno =3D line('.') setlocal modifiable norm ggdG python nm_vim.get_current_buffer().refresh() setlocal nomodifiable " FIXME: should find the line of the thread we were on if possible exec printf('norm %dG', lno) endfunction " --- --- search screen helper functions {{{2 function! s:NM_search_thread_id() exec printf('python nm_vim.vim_get_object(%d, 0)', line('.')) if exists('obj') return 'thread:' . obj['id'] endif return '' endfunction function! s:NM_search_add_remove_tags(prompt, prefix, intags) if type(a:intags) !=3D type([]) || len(a:intags) =3D=3D 0 " TODO: input() can support completion let text =3D input(a:prompt) if !strlen(text) return endif let tags =3D split(text, ' ') else let tags =3D a:intags endif call map(tags, 'a:prefix . v:val') call NM_tag([], tags) endfunction " --- implement show screen {{{1 function! s:NM_cmd_show(querystr) "TODO: folding, syntax call NM_create_buffer('show') exec printf('python nm_vim.ShowThread("%s")', a:querystr) call NM_set_map('n', g:notmuch_show_maps) setlocal fillchars=3D setlocal foldtext=3DNM_show_foldtext() setlocal foldcolumn=3D6 setlocal foldmethod=3Dsyntax endfunction function! s:NM_jump_message(offset) "TODO implement can_change_thread and find_matching, nicer positioning exec printf('python nm_vim.vim_get_object(%d, %d)', line('.'), a:offset) if exists('obj') silent norm zc exec printf('norm %dGzt', obj['start']) silent norm zo endif endfunction function! s:NM_show_next_thread() call NM_kill_this_buffer() if line('.') !=3D line('$') norm j call NM_search_show_thread() else echo 'No more messages.' endif endfunction function! s:NM_show_archive_thread() call NM_tag('', ['-inbox']) call NM_show_next_thread() endfunction function! s:NM_show_mark_read_then_archive_thread() call NM_tag('', ['-unread', '-inbox']) call NM_show_next_thread() endfunction function! s:NM_show_mark_read_then_next_open_message() echo 'not implemented' endfunction function! s:NM_show_previous_message() echo 'not implemented' endfunction "XXX pythonise function! s:NM_show_reply() let cmd =3D ['reply'] call add(cmd, 'id:' . NM_show_message_id()) let data =3D NM_run(cmd) let lines =3D split(data, "\n") call NM_newComposeBuffer(lines, 0) endfunction function! s:NM_show_view_all_mime_parts() echo 'not implemented' endfunction function! s:NM_show_view_raw_message() echo 'not implemented' endfunction function! s:NM_show_add_tag() echo 'not implemented' endfunction function! s:NM_show_remove_tag() echo 'not implemented' endfunction function! s:NM_show_advance() let advance_tags =3D ['-unread'] exec printf('python nm_vim.vim_get_object(%d, 0)', line('.')) if !exists('obj') return endif call NM_tag(['id:' . obj['id']], advance_tags) if obj['end'] =3D=3D line('$') call NM_kill_this_buffer() else call NM_jump_message(1) endif endfunction function! s:NM_show_pipe_message() echo 'not implemented' endfunction function! s:NM_show_view_attachment() exec printf('python nm_vim.vim_view_attachment(%d)', line('.')) endfunction " --- --- show screen helper functions {{{2 function! s:NM_show_message_id() exec printf('python nm_vim.vim_get_object(%d, 0)', line('.')) if exists('obj') return obj['id'] else return '' endfunction " --- implement compose screen {{{1 function! s:NM_cmd_compose(words, body_lines) let lines =3D [] let start_on_line =3D 0 let hdrs =3D { } if !has_key(hdrs, 'From') || !len(hdrs['From']) let me =3D NM_compose_get_user_email() let hdrs['From'] =3D [ me ] endif for key in g:notmuch_compose_headers let text =3D has_key(hdrs, key) ? join(hdrs[key], ', ') : '' call add(lines, key . ': ' . text) if !start_on_line && !strlen(text) let start_on_line =3D len(lines) endif endfor for [key,val] in items(hdrs) if match(g:notmuch_compose_headers, key) =3D=3D -1 let line =3D key . ': ' . join(val, ', ') call add(lines, line) endif endfor call add(lines, '') if !start_on_line let start_on_line =3D len(lines) + 1 endif call extend(lines, [ '', '' ]) call NM_newComposeBuffer(lines, start_on_line) endfunction function! s:NM_compose_send() let fname =3D expand('%') try python nm_vim.get_current_buffer().send() call NM_kill_this_buffer() call delete(fname) echo 'Mail sent successfully.' endtry endfunction function! s:NM_compose_attach() let attachment =3D input('Enter attachment filename: ', '', 'file') if len(attachment) exec printf('python nm_vim.get_current_buffer().attach("%s")', atta= chment) endif endfunction function! s:NM_compose_next_entry_area() let lnum =3D line('.') let hdr_end =3D NM_compose_find_line_match(1,'^$',1) if lnum < hdr_end let lnum =3D lnum + 1 let line =3D getline(lnum) if match(line, '^\([^:]\+\):\s*$') =3D=3D -1 call cursor(lnum, strlen(line) + 1) return '' endif while match(getline(lnum+1), '^\s') !=3D -1 let lnum =3D lnum + 1 endwhile call cursor(lnum, strlen(getline(lnum)) + 1) return '' elseif lnum =3D=3D hdr_end call cursor(lnum+1, strlen(getline(lnum+1)) + 1) return '' endif if mode() =3D=3D 'i' if !getbufvar(bufnr('.'), '&et') return "\t" endif let space =3D '' let shiftwidth =3D a:shiftwidth let shiftwidth =3D shiftwidth - ((virtcol('.')-1) % shiftwidth) " we assume no one has shiftwidth set to more than 40 :) return ' '[0:shiftwi= dth] endif endfunction " --- --- compose screen helper functions {{{2 function! s:NM_compose_get_user_email() " TODO: do this properly (still), i.e., allow for multiple email ac= counts let email =3D substitute(system('notmuch config get user.primary_em= ail'), '\v(^\s*|\s*$|\n)', '', 'g') return email endfunction function! s:NM_compose_find_line_match(start, pattern, failure) let lnum =3D a:start let lend =3D line('$') while lnum < lend if match(getline(lnum), a:pattern) !=3D -1 return lnum endif let lnum =3D lnum + 1 endwhile return a:failure endfunction " --- notmuch helper functions {{{1 function! s:NM_create_buffer(type) let prev_bufnr =3D bufnr('%') enew setlocal buftype=3Dnofile execute printf('set filetype=3Dnotmuch-%s', a:type) execute printf('set syntax=3Dnotmuch-%s', a:type) "XXX this should probably go let b:nm_prev_bufnr =3D prev_bufnr endfunction "set some options for "menu"-like buffers -- folders/searches function! s:NM_finalize_menu_buffer() setlocal nomodifiable setlocal cursorline setlocal nowrap endfunction function! s:NM_newBuffer(how, type, content) if strlen(a:how) exec a:how else enew endif setlocal buftype=3Dnofile readonly modifiable scrolloff=3D0 sidescr= olloff=3D0 silent put=3Da:content keepjumps 0d setlocal nomodifiable execute printf('set filetype=3Dnotmuch-%s', a:type) execute printf('set syntax=3Dnotmuch-%s', a:type) endfunction function! s:NM_newFileBuffer(fdir, fname, type, lines) let fdir =3D expand(a:fdir) if !isdirectory(fdir) call mkdir(fdir, 'p') endif let file_name =3D NM_mktemp(fdir, a:fname) if writefile(a:lines, file_name) throw 'Eeek! couldn''t write to temporary file ' . file_name endif exec printf('edit %s', file_name) setlocal buftype=3D noreadonly modifiable scrolloff=3D0 sidescrollo= ff=3D0 execute printf('set filetype=3Dnotmuch-%s', a:type) execute printf('set syntax=3Dnotmuch-%s', a:type) endfunction function! s:NM_newComposeBuffer(lines, start_on_line) let lines =3D a:lines let start_on_line =3D a:start_on_line let real_hdr_start =3D 1 if g:notmuch_compose_header_help let help_lines =3D [ \ 'Notmuch-Help: Type in your message here; to help you u= se these bindings:', \ 'Notmuch-Help: ,a - attach a file', \ 'Notmuch-Help: ,s - send the message (Notmuch-Help= lines will be removed)', \ 'Notmuch-Help: ,q - abort the message', \ 'Notmuch-Help: - skip through header lines', \ ] call extend(lines, help_lines, 0) let real_hdr_start =3D len(help_lines) if start_on_line > 0 let start_on_line =3D start_on_line + len(help_line= s) endif endif if exists('g:notmuch_signature') call extend(lines, ['', '--']) call extend(lines, g:notmuch_signature) endif let prev_bufnr =3D bufnr('%') call NM_newFileBuffer(g:notmuch_compose_temp_file_dir, '%s.mai= l', \ 'compose', lines) let b:nm_prev_bufnr =3D prev_bufnr call NM_set_map('n', g:notmuch_compose_nmaps) call NM_set_map('i', g:notmuch_compose_imaps) if start_on_line > 0 && start_on_line <=3D len(lines) call cursor(start_on_line, strlen(getline(start_on_line)) += 1) else call cursor(real_hdr_start, strlen(getline(real_hdr_start))= + 1) call NM_compose_next_entry_area() endif if g:notmuch_compose_insert_mode_start startinsert! endif python nm_vim.Compose() endfunction function! s:NM_mktemp(dir, name) let time_stamp =3D strftime('%Y%m%d-%H%M%S') let file_name =3D substitute(a:dir,'/*$','/','') . printf(a:name, t= ime_stamp) " TODO: check if it exists, try again return file_name endfunction function! s:NM_shell_escape(word) " TODO: use shellescape() let word =3D substitute(a:word, '''', '\\''', 'g') return '''' . word . '''' endfunction function! s:NM_run(args) let words =3D a:args call map(words, 's:NM_shell_escape(v:val)') let cmd =3D g:notmuch_cmd . ' ' . join(words) . '< /dev/null' let out =3D system(cmd) let err =3D v:shell_error if err echohl Error echo substitute(out, '\n*$', '', '') echohl None return '' else return out endif endfunction " --- external mail handling helpers {{{1 function! s:NM_new_mail() call NM_cmd_compose([], []) endfunction " --- tag manipulation helpers {{{1 " used to combine an array of words with prefixes and separators " example: " NM_combine_tags('tag:', ['one', 'two', 'three'], 'OR', '()') " -> ['(', 'tag:one', 'OR', 'tag:two', 'OR', 'tag:three', ')'] function! s:NM_combine_tags(word_prefix, words, separator, brackets) let res =3D [] for word in a:words if len(res) && strlen(a:separator) call add(res, a:separator) endif call add(res, a:word_prefix . word) endfor if len(res) > 1 && strlen(a:brackets) if strlen(a:brackets) !=3D 2 throw 'Eeek! brackets arg to NM_combine_tags must b= e 2 chars' endif call insert(res, a:brackets[0]) call add(res, a:brackets[1]) endif return res endfunction " --- other helpers {{{1 function! s:NM_kill_this_buffer() let prev_bufnr =3D b:nm_prev_bufnr python nm_vim.delete_current_buffer() bdelete! exec printf("buffer %d", prev_bufnr) endfunction function! s:NM_search_expand(arg) let word =3D expand(a:arg) let prev_bufnr =3D bufnr('%') call NM_cmd_search(word, 0) let b:nm_prev_bufnr =3D prev_bufnr endfunction function! s:NM_tag(filter, tags) let filter =3D len(a:filter) ? a:filter : [NM_search_thread_id()] if !len(filter) throw 'Eeek! I couldn''t find the thead id!' endif exec printf('python nm_vim.get_current_buffer().tag(tags =3D vim.eval("= a:tags"), querystr =3D "%s")', join(filter)) endfunction " --- process and set the defaults {{{1 function! NM_set_defaults(force) setlocal bufhidden=3Dhide for [key, dflt] in items(s:notmuch_defaults) let cmd =3D '' if !a:force && exists(key) && type(dflt) =3D=3D type(eval(key)) continue elseif type(dflt) =3D=3D type(0) let cmd =3D printf('let %s =3D %d', key, dflt) elseif type(dflt) =3D=3D type('') let cmd =3D printf('let %s =3D ''%s''', key, dflt) " FIXME: not sure why this didn't work when dflt is an array "elseif type(dflt) =3D=3D type([]) " let cmd =3D printf('let %s =3D %s', key, string(dflt)) else echoe printf('E: Unknown type in NM_set_defaults(%d) using [%s,= %s]', \ a:force, key, string(dflt)) continue endif exec cmd endfor endfunction call NM_set_defaults(0) " for some reason NM_set_defaults() didn't work for arrays... if !exists('g:notmuch_folders') let g:notmuch_folders =3D s:notmuch_folders_defaults endif if !exists('g:notmuch_show_headers') let g:notmuch_show_headers =3D s:notmuch_show_headers_defaults endif if !exists('g:notmuch_signature') if filereadable(glob('~/.signature')) let g:notmuch_signature =3D readfile(glob('~/.signature')) endif endif if !exists('g:notmuch_compose_headers') let g:notmuch_compose_headers =3D s:notmuch_compose_headers_defaults endif " --- assign keymaps {{{1 function! s:NM_set_map(type, maps) for [key, code] in items(a:maps) exec printf('%snoremap %s %s', a:type, key, code) endfor endfunction " --- command handler {{{1 function! NotMuch() if !exists('s:notmuch_inited') " init the python layer python import sys exec "python sys.path +=3D [r'" . s:python_path . "']" python import vim, nm_vim let s:notmuch_inited =3D 1 endif call NM_cmd_folders(g:notmuch_folders) endfunction "Custom foldtext() for show buffers, which indents folds to "represent thread structure function! NM_show_foldtext() if v:foldlevel !=3D 1 return foldtext() endif let numlines =3D v:foldend - v:foldstart + 1 let indentlevel =3D matchstr(getline(v:foldstart), '^[0-9]\+') return repeat(' ', indentlevel) . getline(v:foldstart + 1) endfunction "Completion of search prompt "TODO properly deal with complex queries function! Search_type_completion(arg_lead, cmd_line, cursor_pos) let idx =3D stridx(a:arg_lead, ':') if idx < 0 return 'from:' . "\n" . \ 'to:' . "\n" . \ 'subject:' . "\n" . \ 'attachment:' . "\n" . \ 'tag:' . "\n" . \ 'id:' . "\n" . \ 'thread:' . "\n" . \ 'folder:' endif if stridx(a:arg_lead, 'tag:') >=3D 0 python nm_vim.vim_get_tags() return 'tag:' . substitute(taglist, "\n", "\ntag:", "g") endif return '' endfunction " --- glue {{{1 command! NotMuch call NotMuch() let s:python_path =3D expand(':p:h') --===============0474093823== Content-Type: text/x-python; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Disposition: attachment; filename="nm_vim.py" #!/usr/bin/python # # This file is part of Notmuch. # # Notmuch is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Notmuch is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Notmuch. If not, see . # # python-notmuch wrapper for the notmuch vim client import datetime import email, email.header, email.charset, email.utils, email.message import email.mime.text, email.mime.audio, email.mime.image, email.mime.mult= ipart import mailbox import mailcap import mimetypes import notmuch import os, os.path import shlex import smtplib import subprocess import tempfile import vim #### classes #### class NMBuffer(object): """ An object mapping line ranges in current buffer to notmuch structures. """ # a string identifying the buffer id =3D None # a list of NMBufferElement subclasses representing objects in current = buffer objects =3D None def __new__(cls, *args, **kwargs): bufnr =3D vim.eval('bufnr("%")') if bufnr in nm_buffers: return nm_buffers[bufnr] ret =3D object.__new__(cls) nm_buffers[bufnr] =3D ret return ret def __init__(self): self.objects =3D [] def get_object(self, line, offset): """ Get an object that's offset objects away from given line or None. E.g. offset =3D 0 gets the object on the line, offset =3D 1 gets next, offset =3D -1 previous. """ if not self.objects or line < self.objects[0].start or line > self.= objects[-1].end: return for i in xrange(len(self.objects)): obj =3D self.objects[i] if line >=3D obj.start and line <=3D obj.end: return self.objects[ max(min(len(self.objects) - 1, i + off= set), 0) ] def tag(self, tags, querystr =3D None): if querystr: querystr =3D '( %s ) and ( %s )'%(self.id, querystr) else: querystr =3D self.id db =3D notmuch.Database(mode =3D notmuch.Database.MODE.READ_WRITE) map_query(db, querystr, lambda m, l: self._tag_message(m, tags)) def _tag_message(self, message, tags): for tag in tags: if tag[0] =3D=3D '+': message.add_tag(tag[1:]) elif tag[0] =3D=3D '-': message.remove_tag(tag[1:]) class SavedSearches(NMBuffer): """ This buffer displays a list of saved searches ('folders'). """ def __init__(self, folders): """ @param folders A list of (folder name, query string) tuples. """ super(SavedSearches, self).__init__() for folder in folders: self.objects.append(Folder(0, 0, folder[1], folder[0])) self.refresh() def refresh(self): b =3D vim.current.buffer db =3D notmuch.Database() for obj in self.objects: q =3D db.create_query(obj.id) q1 =3D db.create_query('( %s ) and tag:unread'%(obj.id)) b.append('{0:>7} {1:7} {2: <30s} ({3})'.format(q.count_messages= (), '({0})'.format(q1.count_messages()), obj.name, obj.id= )) obj.start =3D obj.end =3D len(b) - 1 def __repr__(self): return '' def tag(self): raise TypeError('Attempted to tag in folders view.') class Search(NMBuffer): """ This buffer displays results of a db search -- a list of found threads. """ def __init__(self, querystr =3D '', parent =3D None): super(Search, self).__init__() assert(querystr or parent) # FIXME simplify if parent: self.id =3D parent.id if querystr: if self.id: self.id =3D '( %s ) and ( %s )'%(self.id, querystr) else: self.id =3D querystr self.refresh() def refresh(self): b =3D vim.current.buffer self.objects =3D [] db =3D notmuch.Database() q =3D db.create_query(self.id) q.set_sort(q.SORT.NEWEST_FIRST) # FIXME allow different search = orders for t in q.search_threads(): start =3D len(b) # we have to decode the unicode strings to get the alignment ri= ght datestr =3D get_relative_date(t.get_newest_date()) authors =3D t.get_authors().decode('utf-8') subj =3D t.get_subject().decode('utf-8') tags =3D str(t.get_tags()).decode('utf-8') b.append((u'%-12s %3s/%3s %-20.20s | %s (%s)'%(datestr, t.get_m= atched_messages(), t.get_total_messages(), authors, subj, t= ags)).encode('utf-8')) self.objects.append(NMBufferElement(start, len(b) - 1, t.get_th= read_id())) def __repr__(self): return ''%(self.id) class ShowThread(NMBuffer): """ This buffer represents a thread view. """ # a list of temporary files for viewing attachments # they will be automagically closed and deleted on this object's demise _tmpfiles =3D None # a list of headers to show _headers =3D None def __init__(self, querystr): self.id =3D querystr self._tmpfiles =3D [] self.refresh() def refresh(self): self._headers =3D vim.eval('g:notmuch_show_headers') self.objects =3D [] db =3D notmuch.Database() map_query(db, self.id, self._print_message) def view_attachment(self, line): """ View attachment corresponding to given line. """ message =3D self.get_object(line, 0) if not message: print 'No message on this line.' return data =3D None for a in message.attachments: if line =3D=3D a.start: data =3D a.data break if not data: return f =3D tempfile.NamedTemporaryFile() f.write(data.get_payload(decode =3D True)) f.flush() os.fsync(f.fileno()) caps =3D mailcap.getcaps() ret =3D mailcap.findmatch(caps, data.get_content_type(), filename = =3D f.name) if ret[0]: with open(os.devnull, 'w') as null: subprocess.Popen(shlex.split(ret[0]), stderr =3D null, stdo= ut =3D null) self._tmpfiles.append(f) def _read_text_payload(self, part): """ Try converting the payload of the MIME part into utf-8. """ p =3D part.get_payload(decode =3D True) ch =3D part.get_content_charset('utf-8') if ch !=3D 'utf-8': try: p =3D p.decode(ch).encode('utf-8') except LookupError: # if the encoding is unknown, try utf-8 try: p.decode('utf-8') except UnicodeDecodeError: return 'Unknown encoding: %s, cannot decode message'%ch return p def _print_part(self, part): """ Walk through the part recursively and print it and its subparts to current buffer. """ if part.is_multipart(): if part.get_content_subtype() =3D=3D 'alternative': # try to find the plaintext version, if that fails just try= to print the first for subpart in part.get_payload(): if subpart.get_content_type() =3D=3D 'text/plain': return self._print_part(subpart) self._print_part(part.get_payload()[0]) else: for subpart in part.get_payload(): self._print_part(subpart) else: b =3D vim.current.buffer if part.get('Content-Disposition', '').lower().startswith('atta= chment'): self.objects[-1].attachments.append(Attachment(len(b), len(= b), part)) b.append(('[ Attachment: %s (%s)]'%(part.get_filename(), pa= rt.get_content_type())).split('\n')) if part.get_content_maintype() =3D=3D 'text': p =3D self._read_text_payload(part) b.append(p.split('\n')) def _print_message(self, message, level): b =3D vim.current.buffer msg =3D Message(len(b), 0, message.get_message_id()) self.objects.append(msg) fp =3D open(message.get_filename()) email_msg =3D email.message_from_file(fp) fp.close() # print the title b.append('%d/'%level + 20*'-' + 'message start' + 20*'-' + '\\') b.append('%s: %s (%s) (%s)'%(get_author(message.get_header('from'))= , message.get_header('subject'), get_relative_date(message.get_date()), ' '= .join(message.get_tags()))) # print the headers # TODO toggle all (like in mutt) for header in self._headers: if header in email_msg: b.append(('%s: %s'%(header, decode_header(email_msg[header]= ))).split('\n')) b.append('') self._print_part(email_msg) b.append('\\' + 20*'-' + 'message end' + 20*'-' + '/') msg.end =3D len(b) - 1 def __repr__(self): return ''%self.id class Compose(NMBuffer): _attachment_prefix =3D 'Notmuch-Attachment: ' def __init__(self): super(Compose, self).__init__() # python wants to use base64 for some reason, force quoted-printable email.charset.add_charset('utf-8', email.charset.QP, email.charset.= QP, 'utf-8') def _encode_header(self, text): try: text.decode('ascii') return text except UnicodeDecodeError: return email.header.Header(text, 'utf-8').encode() def attach(self, filename): type, encoding =3D mimetypes.guess_type(filename) if encoding or not type: type =3D 'application/octet-stream' vim.current.buffer.append('%s%s:%s'%(self._attachment_prefix, filen= ame, type), 0) def send(self): """ Send the message in current buffer. """ b =3D vim.current.buffer i =3D 0 # parse attachments attachments =3D [] while b[i].startswith(self._attachment_prefix): filename, sep, type =3D b[i][len(self._attachment_prefix):].rpa= rtition(':') attachments.append((os.path.expanduser(filename), type)) i +=3D 1 # skip the inline help while b[i].startswith('Notmuch-Help:'): i +=3D 1 # add the headers headers =3D {} recipients =3D [] from_addr =3D None while i < len(b): if not b[i]: break key, sep, val =3D b[i].partition(':') i +=3D 1 try: key.decode('ascii') except UnicodeDecodeError: raise ValueError('Header name must be ASCII only.') if not val.strip(): # skip empty headers continue if key.lower() in ('to', 'cc', 'bcc'): names, addrs =3D zip(*email.utils.getaddresses([val])) names =3D map(self._encode_header, names) recipients +=3D addrs if key.lower() =3D=3D 'bcc': continue val =3D ','.join(map(email.utils.formataddr, zip(names, add= rs))) else: if key.lower() =3D=3D 'from': from_addr =3D email.utils.parseaddr(val)[1] val =3D self._encode_header(val) headers[key] =3D val body =3D email.mime.text.MIMEText('\n'.join(b[i:]), 'plain', 'utf-8= ') # add the body if not attachments: msg =3D body else: msg =3D email.mime.multipart.MIMEMultipart() msg.attach(body) for attachment in attachments: maintype, subtype =3D attachment[1].split('/', 1) if maintype =3D=3D 'text': with open(attachment[0]) as f: part =3D email.mime.text.MIMEText(f.read(), subtype= , 'utf-8') else: if maintype =3D=3D 'image': obj =3D email.mime.image.MIMEImage elif maintype =3D=3D 'audio': obj =3D email.mime.audio.MIMEAudio else: obj =3D email.mime.application.MIMEApplication with open(attachment[0]) as f: part =3D obj(f.read(), subtype) part.add_header('Content-Disposition', 'attachment', filename =3D os.path.basename(attachment[0]= )) msg.attach(part) msg['User-Agent'] =3D 'Notmuch-vim EXPERIMENTAL' msg['Message-ID'] =3D email.utils.make_msgid() msg['Date'] =3D email.utils.formatdate(localtime =3D True) for key in headers: msg[key] =3D headers[key] # sanity checks if not from_addr: # XXX notmuch-python should export the user email address raise ValueError('No sender address specified.') if not recipients: raise ValueError('No recipient specified.') # send fcc =3D vim.eval('g:notmuch_fcc_maildir') if fcc: dbroot =3D notmuch.Database().get_path() mdir =3D mailbox.Maildir(os.path.join(dbroot, fcc)) mdir.add(msg) mdir.close() # TODO configurable host s =3D smtplib.SMTP('localhost') ret =3D s.sendmail(from_addr, recipients, msg.as_string()) for key in ret: print 'Error sending mail to %s: %s'%(key, ret[key]) s.quit() class NMBufferElement(object): """ This object represents a structure (e.g. folder, thread or message) corresponding to a range of lines in NMBuffer. """ # start and end lines, 0-based start =3D None end =3D None # a string identifying the element id =3D None def __init__(self, start, end, id): self.start =3D start self.end =3D end self.id =3D id class Folder(NMBufferElement): """ A saved search / 'folder'. """ # folder name name =3D None def __init__(self, start, end, id, name): super(Folder, self).__init__(start, end, id) self.name =3D name class Message(NMBufferElement): attachments =3D None def __init__(self, start, end, id): super(Message, self).__init__(start, end, id) self.attachments =3D [] class Attachment(NMBufferElement): data =3D None def __init__(self, start, end, data): super(Attachment, self).__init__(start, end, '') self. data =3D data #### global variables #### # this dictionary stores the Python objects corresponding to notmuch-managed # buffers, each indexed by the buffer number nm_buffers =3D {} #### utility functions #### def get_current_buffer(): """ Get the NMBuffer object associated with current buffer or None. """ try: return nm_buffers[vim.eval('bufnr("%")')] except KeyError: return None def delete_current_buffer(): """ Delete the NMBuffer associated with current buffer. """ del nm_buffers[vim.eval('bufnr("%")')] def get_relative_date(timestamp): """ Format a nice representation of 'time' relative to the current time. Examples include: 5 mins. ago (For times less than 60 minutes ago) Today 12:30 (For times >60 minutes but still today) Yest. 12:30 Mon. 12:30 (Before yesterday but fewer than 7 days ago) October 12 (Between 7 and 180 days ago (about 6 months)) 2008-06-30 (More than 180 days ago) Shamelessly lifted from notmuch-time TODO: this should probably be in python-notmuch """ try: now =3D datetime.datetime.now() then =3D datetime.datetime.fromtimestamp(timestamp) except ValueError: return "when?" if then > now: return "the future" delta =3D now - then if delta.days > 180: return then.strftime("%F") # 2008-06-30 total_seconds =3D delta.seconds + delta.days * 24 * 3600 if total_seconds < 3600: return "%d min. ago"%(total_seconds / 60) if delta.days < 7: if then.day =3D=3D now.day: return then.strftime("Today %R") # Today 12:30 if then.day + 1 =3D=3D now.day: return then.strftime("Yest. %R") # Yest. 12:30 return then.strftime("%a. %R") # Mon. 12:30 return then.strftime("%B %d") # October 12 def map_query(db, query, run): """ Execute runnable run on every message found for the specified query. """ def walk_query(message, run, level): run(message, level) level +=3D 1 replies =3D message.get_replies() if replies is not None: for reply in replies: walk_query(reply, run, level) q =3D db.create_query(query) for t in q.search_threads(): for m in t.get_toplevel_messages(): walk_query(m, run, 0) def decode_header(header): """ Decode a RFC 2822 header into a utf-8 string. """ ret =3D [] for part in email.header.decode_header(header): if part[1]: ret.append(part[0].decode(part[1]).encode('utf-8')) else: ret.append(part[0]) return ''.join(ret) def get_author(address): """ Extract the author name from an address if possible. """ #XXX should be in the bindings if address.endswith('>'): try: ret =3D address[:address.rindex('<') - 1].strip() if ret: return ret except ValueError: pass return address #### functions for exporting stuff to viml #### def vim_get_tags(): """ Export a string listing all tags in the database, one per line into a vim variable named 'taglist'. """ db =3D notmuch.Database() tags =3D '\n'.join(db.get_all_tags()) vim.command('let taglist =3D \'%s\''%tags) def vim_get_object(line, offset): """ Export start/end lines and id of the object that's offset objects aways from given line into a dict named 'obj' in viml. E.g. offset =3D 0 gets the object on the line, offset =3D 1 gets next, offset =3D -1 previous. This method is a noop if current line doesn't correspond to anything. """ # vim lines are 1-based line -=3D 1 assert line >=3D 0 obj =3D get_current_buffer().get_object(line, offset) if obj: vim.command('let obj =3D { "start" : %d, "end" : %d, "id" : "%s" }'= %( obj.start + 1, obj.end + 1, obj.id)) def vim_get_id(): """ Export the id string of current buffer into a vim string named 'buf_id'. """ id =3D get_current_buffer().id vim.command('let buf_id =3D "%s"'%(id if id else '')) def vim_view_attachment(line): """ View an attachment corresponding to the given vim line in an external viewer. """ line -=3D 1 # vim lines are 1-based get_current_buffer().view_attachment(line) --===============0474093823== Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Disposition: attachment; filename="notmuch-folders.vim" " notmuch folders mode syntax file syntax region nmFolfers start=3D/^/ end=3D/$/ oneline c= ontains=3DnmFoldersMessageCount syntax match nmFoldersMessageCount /^ *[0-9]\+ */ contained nex= tgroup=3DnmFoldersUnreadCount syntax match nmFoldersUnreadCount /(.\{-}) */ contained nex= tgroup=3DnmFoldersName syntax match nmFoldersName /.*\ze(/ contained nex= tgroup=3DnmFoldersSearch syntax match nmFoldersSearch /([^()]\+)$/ highlight link nmFoldersMessageCount Statement highlight link nmFoldersUnreadCount Underlined highlight link nmFoldersName Type highlight link nmFoldersSearch String highlight CursorLine term=3Dreverse cterm=3Dreverse gui=3Dreverse --===============0474093823== Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Disposition: attachment; filename="notmuch-search.vim" syntax region nmSearch start=3D/^/ end=3D/$/ oneline contain= s=3DnmSearchDate keepend syntax match nmSearchDate /^.\{-13}/ contained nextgroup= =3DnmSearchNum skipwhite syntax match nmSearchNum "[0-9]\+\/" contained nextgroup= =3DnmSearchTotal skipwhite syntax match nmSearchTotal /[0-9]\+/ contained nextgroup= =3DnmSearchFrom skipwhite syntax match nmSearchFrom /.\{-}\ze|/ contained nextgroup= =3DnmSearchSubject skipwhite "XXX this fails on some messages with multiple authors syntax match nmSearchSubject /.*\ze(/ contained nextgroup= =3DnmSearchTags syntax match nmSearchTags /.\+$/ contained syntax match nmUnread /^.*(.*\.*)$/ highlight link nmSearchDate Statement highlight link nmSearchNum Number highlight link nmSearchTotal Type highlight link nmSearchFrom Include highlight link nmSearchSubject Normal highlight link nmSearchTags String highlight link nmUnread Underlined --===============0474093823== Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Disposition: attachment; filename="notmuch-show.vim" " notmuch show mode syntax file setlocal conceallevel=3D2 setlocal concealcursor=3Dvinc syntax region nmMessage matchgroup=3DIgnore concealends start=3D'[0-9]= \+\/-*message start-*\\' end=3D'\\-*message end-*\/' fold contains=3D@nmSho= wMsgBody keepend "TODO what about those syntax cluster nmShowMsgDesc contains=3DnmShowMsgDescWho,nmShowMsgDescDate,= nmShowMsgDescTags syntax match nmShowMsgDescWho /[^)]\+)/ contained syntax match nmShowMsgDescDate / ([^)]\+[0-9]) / contained syntax match nmShowMsgDescTags /([^)]\+)$/ contained syntax cluster nmShowMsgBody contains=3D@nmShowMsgBodyMail,@nmShowMsgBodyGit syntax include @nmShowMsgBodyMail syntax/mail.vim silent! syntax include @nmShowMsgBodyGit syntax/notmuch-git-diff.vim highlight nmShowMsgDescWho term=3Dreverse cterm=3Dreverse gui=3Dreverse highlight link nmShowMsgDescDate Type highlight link nmShowMsgDescTags String "TODO what about this? highlight Folded term=3Dreverse ctermfg=3DLightGrey ctermbg=3DBlack guifg= =3DLightGray guibg=3DBlack --===============0474093823==--