From: wmorgan Date: Tue, 28 Nov 2006 22:10:56 +0000 (+0000) Subject: moved evertying to devel X-Git-Url: https://git.cworth.org/git?a=commitdiff_plain;h=43df7bd75d887a04b9d1433a534b7fa77188663d;p=sup moved evertying to devel git-svn-id: svn://rubyforge.org/var/svn/sup/trunk@36 5c8cc53c-5e98-4d25-b20a-d8db53a31250 --- 43df7bd75d887a04b9d1433a534b7fa77188663d diff --git a/History.txt b/History.txt new file mode 100644 index 0000000..1f59089 --- /dev/null +++ b/History.txt @@ -0,0 +1,5 @@ +== 0.0.1 / 2006-11-28 + +* Initial release. Unix-centrism, support for mbox only, no i18n. + Untested on anything other than 1.8.5. Other than that, works great! + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e37680c --- /dev/null +++ b/LICENSE @@ -0,0 +1,280 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS diff --git a/Manifest.txt b/Manifest.txt new file mode 100644 index 0000000..233b081 --- /dev/null +++ b/Manifest.txt @@ -0,0 +1,52 @@ +History.txt +Manifest.txt +README.txt +LICENSE +Rakefile +doc/FAQ.txt +doc/Philosophy.txt +doc/TODO +bin/sup +bin/sup-import +lib/sup/mbox/loader.rb +lib/sup/modes/line-cursor-mode.rb +lib/sup/modes/reply-mode.rb +lib/sup/modes/scroll-mode.rb +lib/sup/modes/resume-mode.rb +lib/sup/modes/contact-list-mode.rb +lib/sup/modes/forward-mode.rb +lib/sup/modes/label-search-results-mode.rb +lib/sup/modes/search-results-mode.rb +lib/sup/modes/compose-mode.rb +lib/sup/modes/poll-mode.rb +lib/sup/modes/edit-message-mode.rb +lib/sup/modes/thread-index-mode.rb +lib/sup/modes/person-search-results-mode.rb +lib/sup/modes/inbox-mode.rb +lib/sup/modes/thread-view-mode.rb +lib/sup/modes/log-mode.rb +lib/sup/modes/buffer-list-mode.rb +lib/sup/modes/text-mode.rb +lib/sup/modes/label-list-mode.rb +lib/sup/modes/help-mode.rb +lib/sup/logger.rb +lib/sup/util.rb +lib/sup/update.rb +lib/sup/label.rb +lib/sup/message.rb +lib/sup/mode.rb +lib/sup/keymap.rb +lib/sup/textfield.rb +lib/sup/contact.rb +lib/sup/account.rb +lib/sup/draft.rb +lib/sup/mbox.rb +lib/sup/poll.rb +lib/sup/person.rb +lib/sup/index.rb +lib/sup/thread.rb +lib/sup/buffer.rb +lib/sup/sent.rb +lib/sup/tagger.rb +lib/sup/colormap.rb +lib/sup.rb diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..419e16b --- /dev/null +++ b/README.txt @@ -0,0 +1,119 @@ +sup + by William Morgan + http://sup.rubyforge.org + +== DESCRIPTION: + +Sup is an attempt to take the UI innovations of web-based email +readers (ok, really just GMail) and to combine them with the +traditional wholesome goodness of a console-based email client. + +Sup is designed to work with massive amounts of email, potentially +spread out across different mbox files, IMAP folders, and GMail +accounts, and to pull them all together into a single interface. + +The goal of Sup is to become the email client of choice for nerds +everywhere. + +== FEATURES/PROBLEMS: + +Features: + +- Scalability to massive amounts of email. Immediate startup and + operability, regardless of how much amount of email you have. + (At least, once everything's been indexed.) + +- Immediate full-text search of your entire email archive, using + the full Ferret query langauge. Search over message bodies, labels, + from: and to: fields, or any combination thereof. + +- Thread-centrism. Operations are performed at the thread, not the + message level. Entire threads are manipulated and viewed (with + redundancies removed) at a time. + +- Labels over folders. Drop that tired old metaphor and you'll see how + much easier it is to organize email. + +- GMail-style thread management. Archive a thread, and it will + disappear from your inbox until someone replies. Kill a thread, and + it will never come back to your inbox. (But it will still show up in + searches, of course.) + +- Console based, so instantaneous response to interaction. No mouse + clicking required! + +- Programmability. It's in Ruby. The code is good. It's easy to + extend. + +- Multiple buffer support. Why be limited to viewing one thread at a + time? + +- Automatic context-sensitive help. + +- Message tagging and multi-message tagged operations. + +- Mutt-style MIME attachment viewing. + +Current limitations which will be fixed: + +- Support for mbox ONLY at this point. No support for POP, IMAP, and + GMail accounts. + +- No internationalization support. No wide characters, no subject + demangling. + +- No GMail-style filters. + +- Unix-centrism in MIME attachment handling. + +== SYNOPSYS: + + 1. sup-import + + 2. sup + 3. edit ~/.sup/config.yaml for the (very few) settings sup has + + sup-import has several options which control whether you want + messages from particular mailboxes not to be added to the inbox, + or not to be marked as new, so run it with -h for help. + + Note that Sup *never* changes the contents of any mailboxes. So it + shouldn't ever corrupt your mail. The flip side is that if you + change a mailbox (e.g. delete or read messages) then Sup may crash, + and will tell you to run sup-import --rebuild to recalculate the + offsets within the mailbox have changed. + +== REQUIREMENTS: + +* ferret >= 0.10.13 +* ncurses >= 0.9.1 +* rmail >= 0.17 + +== INSTALL: + +* gem install sup -y +* Then, in rmail, change line 159 of multipart.rb to: + chunk = chunk[0..start] + (Sorry. it's an unsupported package.) You might be able to get away + without doing this but if you get frozen string exceptions when + reading in multipart email messages, this is what you need to + change. + +== LICENSE: + +Copyright (c) 2006 William Morgan. + +This program 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 2 +of the License, or (at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +02110-1301, USA. + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c9bceea --- /dev/null +++ b/Rakefile @@ -0,0 +1,45 @@ +# -*- ruby -*- + +require 'rubygems' +require 'hoe' +require './lib/sup.rb' + +Hoe.new('sup', Redwood::VERSION) do |p| + p.rubyforge_name = 'sup' + p.author = "William Morgan" + p.summary = 'A console-based email client with the best features of GMail, mutt, and emacs. Features full text search, labels, tagged operations, multiple buffers, recent contacts, and more.' + p.description = p.paragraphs_of('README.txt', 2..4).join("\n\n") + p.url = p.paragraphs_of('README.txt', 0).first.split(/\n/)[2].gsub(/^\s+/, "") + p.changes = p.paragraphs_of('History.txt', 0..1).join("\n\n") + p.email = "wmorgan-sup@masanjin.net" + p.extra_deps = [['ferret', '>= 0.10.13'], ['ncurses', '>= 0.9.1'], ['rmail', '>= 0.17']] +end + +rule 'ss?.png' => 'ss?-small.png' do |t| + +end + + +## is there really no way to make a rule for this? +WWW_FILES = %w(www/index.html README.txt doc/Philosophy.txt doc/FAQ.txt) + +SCREENSHOTS = FileList["www/ss?.png"] +SCREENSHOTS_SMALL = [] +SCREENSHOTS.each do |fn| + fn =~ /ss(\d+)\.png/ + sfn = "www/ss#{$1}-small.png" + file sfn => [fn] do |t| + sh "cat #{fn} | pngtopnm | pnmscale -xysize 320 240 | pnmtopng > #{sfn}" + end + SCREENSHOTS_SMALL << sfn +end + +task :upload_webpage => WWW_FILES do |t| + sh "scp -C #{t.prerequisites * ' '} wmorgan@rubyforge.org:/var/www/gforge-projects/sup/" +end + +task :upload_webpage_images => (SCREENSHOTS + SCREENSHOTS_SMALL) do |t| + sh "scp -C #{t.prerequisites * ' '} wmorgan@rubyforge.org:/var/www/gforge-projects/sup/" +end + +# vim: syntax=Ruby diff --git a/bin/sup b/bin/sup new file mode 100644 index 0000000..65dbaa3 --- /dev/null +++ b/bin/sup @@ -0,0 +1,229 @@ +#!/bin/env ruby + +require 'rubygems' +require 'ncurses' +require "sup" + +module Redwood + +$exception = nil + +global_keymap = Keymap.new do |k| + k.add :quit, "Quit Redwood", 'q' + k.add :help, "Show help", 'H', '?' + k.add :roll_buffers, "Switch to next buffer", 'b' + k.add :roll_buffers_backwards, "Switch to previous buffer", 'B' + k.add :kill_buffer, "Kill the current buffer", 'x' + k.add :list_buffers, "List all buffers", 'A' + k.add :list_contacts, "List contacts", 'C' + k.add :redraw, "Redraw screen", :ctrl_l + k.add :search, "Search messages", '/' + k.add :list_labels, "List labels", 'L' + k.add :poll, "Poll for new messages", 'P' + k.add :compose, "Compose new message", 'm' +end + +def start_cursing + Ncurses.initscr + Ncurses.noecho + Ncurses.cbreak + Ncurses.stdscr.keypad 1 + Ncurses.curs_set 0 + Ncurses.start_color +end + +def stop_cursing + Ncurses.curs_set 1 + Ncurses.echo + Ncurses.endwin +end +module_function :start_cursing, :stop_cursing + +Redwood::SentManager.new Redwood::SENT_FN +Redwood::ContactManager.new Redwood::CONTACT_FN +Redwood::LabelManager.new Redwood::LABEL_FN +Redwood::AccountManager.new $config[:accounts] +Redwood::DraftManager.new Redwood::DRAFT_DIR +Redwood::UpdateManager.new +Redwood::PollManager.new + +Index.new.load +log "loaded #{Index.size} messages from index" + +if(s = Index.source_for DraftManager.source_name) + DraftManager.source = s +else + Index.add_source DraftManager.new_source +end + +if(s = Index.source_for SentManager.source_name) + SentManager.source = s +else + Index.add_source SentManager.new_source +end + +begin + log "starting curses" + start_cursing + + log "initializing colormap" + Colormap.new do |c| + c.add :status_color, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLUE, Ncurses::A_BOLD + c.add :index_old_color, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK + c.add :index_new_color, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK, + Ncurses::A_BOLD + c.add :labellist_old_color, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK + c.add :labellist_new_color, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK, + Ncurses::A_BOLD + c.add :twiddle_color, Ncurses::COLOR_BLUE, Ncurses::COLOR_BLACK + c.add :label_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK + c.add :message_patina_color, Ncurses::COLOR_BLACK, Ncurses::COLOR_GREEN + c.add :mime_color, Ncurses::COLOR_CYAN, Ncurses::COLOR_BLACK + c.add :quote_patina_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK + c.add :sig_patina_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK + c.add :quote_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK + c.add :sig_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK + c.add :to_me_color, Ncurses::COLOR_GREEN, Ncurses::COLOR_BLACK + c.add :starred_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK, + Ncurses::A_BOLD + c.add :starred_patina_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_GREEN, + Ncurses::A_BOLD + c.add :snippet_color, Ncurses::COLOR_CYAN, Ncurses::COLOR_BLACK + c.add :option_color, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK + c.add :tagged_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK, + Ncurses::A_BOLD + c.add :draft_notification_color, Ncurses::COLOR_RED, Ncurses::COLOR_BLACK, + Ncurses::A_BOLD + end + + log "initializing buffer manager" + bm = BufferManager.new + + if Index.usual_sources.any? { |s| !s.done? } + log "polling for new mail" + pmode = PollMode.new + pbuf = bm.spawn "load new messages", pmode + pmode.poll +# sleep 1 +# bm.kill_buffer pbuf + end + + log "initializing mail index buffer" + imode = InboxMode.new + ibuf = bm.spawn "inbox", imode + + log "ready for (inter)action!" + Logger.make_buf + + bm.draw_screen + imode.load_more_threads ibuf.content_height + + until $exception + bm.draw_screen + c = Ncurses.nonblocking_getch + bm.erase_flash + + if c == Ncurses::KEY_RESIZE + bm.handle_resize + elsif c + unless bm.handle_input(c) + x = global_keymap.action_for c + case x + when :quit + break + when :help + curmode = bm.focus_buf.mode + bm.spawn_unless_exists("") { HelpMode.new curmode, global_keymap } + when :roll_buffers + bm.roll_buffers + when :roll_buffers_backwards + bm.roll_buffers_backwards + when :kill_buffer + bm.kill_buffer bm.focus_buf unless bm.focus_buf.mode.is_a? InboxMode + when :list_buffers + bm.spawn_unless_exists("Buffer List") { BufferListMode.new } + when :list_contacts + mode = ContactListMode.new + bm.spawn "compose to contacts", mode + when :search + text = bm.ask :search, "query: " + next unless text && text !~ /^\s*$/ + mode = SearchResultsMode.new text + short_text = + if text.length < 20 + text + else + text[0 ... 20] + "..." + end + bm.spawn "search: \"#{short_text}\"", mode + bm.draw_screen + mode.load_more_threads mode.buffer.content_height + when :list_labels + b = BufferManager.spawn_unless_exists("all labels") do + LabelListMode.new + end + b.mode.load_in_background + when :compose + mode = ComposeMode.new + bm.spawn "new message", mode + mode.edit + when :poll + b = BufferManager.spawn_unless_exists("load new messages") do + PollMode.new + end + b.mode.poll + when :nothing + when :redraw + bm.completely_redraw_screen + else + BufferManager.flash "Unknown key press '#{c.to_character}' for #{bm.focus_buf.mode.name}." + end + end + end + end + bm.kill_all_buffers + Redwood::LabelManager.save + Redwood::ContactManager.save +rescue Exception => e + $exception ||= e +ensure + stop_cursing +end + +Index.save unless $exception # TODO: think about this + +if $exception + if $exception.is_a? IndexError + $stderr.puts <* +where * is zero or more source descriptions (e.g., mbox +filenames on disk). + +If the sources listed are not already in the Sup source list, +they will be added to it, as parameterized by the following options: + --archive: messages from these sources will not appear in the inbox + --unusual: these sources will not be polled when the flag --the-usual + is called + +Regardless of whether the sources are new or not, they will be polled, +and any new messages will be added to the index, as parameterized by +the following options: + --force-archive: regardless of the source "archive" flag, any new + messages found will not appear in the inbox. + --force-read: any messages found will not be marked as new. + +The following options can also be specified: + --the-usual: import new messages from all usual sources + --rebuild: rebuild the index for the specified sources rather than + just adding new messages. Useful if the sources + have changed in any way *other* than new messages + being added. + --force-rebuild: force a rebuild of all messages in the inbox, not just + ones that have changed. You probably won't need this + unless William changes the index format. + --optimize: optimize the index after adding any new messages. + --help: don't do anything, just show this message. +EOS +#' stupid ruby-mode + exit +end + +educate_user if ARGV.member? '--help' + +archive = ARGV.delete "--archive" +unusual = ARGV.delete "--unusual" +force_archive = ARGV.delete "--force-archive" +force_read = ARGV.delete "--force-read" +the_usual = ARGV.delete "--the-usual" +rebuild = ARGV.delete "--rebuild" +force_rebuild = ARGV.delete "--force-rebuild" +optimize = ARGV.delete "--optimize" + +if(o = ARGV.find { |x| x =~ /^--/ }) + $stderr.puts "error: unknown option #{o}" + educate_user +end + +puts "loading index..." +index = Redwood::Index.new +index.load +pre_nm = index.size +puts "loaded index of #{index.size} messages" + +sources = ARGV.map do |fn| + source = index.source_for fn + unless source + source = Redwood::MBox::Loader.new(fn, 0, !unusual, !!archive) + index.add_source source + end + source +end +sources = (sources + index.usual_sources).uniq if the_usual +sources.each { |s| s.reset! } if rebuild || force_rebuild + +found = {} +start = Time.now +begin + sources.each do |source| + next if source.done? + puts "loading from #{source}... " + num = 0 + start_offset = nil + source.each do |offset, labels| + start_offset ||= offset + labels -= [:inbox] if force_archive + labels -= [:unread] if force_read + begin + m = Redwood::Message.new source, offset, labels + if found[m.id] + puts "skipping duplicate message #{m.id}" + next + else + found[m.id] = true + end + + m.remove_label :unread if m.mbox_status == "RO" unless force_read + if (rebuild || force_rebuild) && + (docid, entry = index.load_entry_for_id(m.id)) && entry + if force_rebuild || entry[:source_info].to_i != offset + puts "replacing message #{m.id} labels #{entry[:label].inspect} (offset #{entry[:source_info]} => #{offset})" + m.labels = entry[:label].split.map { |l| l.intern } + num += 1 if index.update_message m, source, offset + end + else + num += 1 if index.add_message m + end + rescue Redwood::MessageFormatError => e + $stderr.puts "ignoring erroneous message at #{source}##{offset}: #{e.message}" + end + if num % 1000 == 0 && num > 0 + elapsed = Time.now - start + pctdone = (offset.to_f - start_offset) / (source.total.to_f - start_offset) + remaining = (source.total.to_f - offset.to_f) * (elapsed.to_f / (offset.to_f - start_offset)) + puts "## #{num} (#{(pctdone * 100.0)}% done) read; #{elapsed.to_time_s} elapsed; est. #{remaining.to_time_s} remaining" + end + end + puts "loaded #{num} messages" unless num == 0 + end +ensure + index.save +end + +if rebuild || force_rebuild + puts "deleting missing messages from the index..." + numdel = 0 + sources.each do |source| + raise "no source id for #{source}" unless source.id + index.index.search_each("source_id:#{source.id}", :limit => :all) do |docid, score| + mid = index.index[docid][:message_id] + next if found[mid] + puts "deleting #{mid}" + index.index.delete docid + numdel += 1 + end + end + puts "deleted #{numdel} messages" +end + +if optimize + puts "optimizing index..." + optt = time { index.index.optimize } + puts "optimized index of size #{index.size} in #{optt}s." +end diff --git a/devel/count-loc.sh b/devel/count-loc.sh new file mode 100644 index 0000000..5ea1246 --- /dev/null +++ b/devel/count-loc.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +find . -type f -name \*.rb | xargs cat | grep -v "^ *$"|grep -v "^ *#"|grep -v "^ *end *$"|wc -l diff --git a/devel/load-index.rb b/devel/load-index.rb new file mode 100644 index 0000000..858c8b5 --- /dev/null +++ b/devel/load-index.rb @@ -0,0 +1,9 @@ +require 'sup' + +puts "loading index..." +@index = Redwood::Index.new +@index.load +@i = @index.index +puts "loaded index of #{@i.size} messages" + + diff --git a/devel/profile.rb b/devel/profile.rb new file mode 100644 index 0000000..67b6b08 --- /dev/null +++ b/devel/profile.rb @@ -0,0 +1,12 @@ +require 'rubygems' +require 'ruby-prof' +require "redwood" + +result = RubyProf.profile do + Redwood::ThreadSet.new(ARGV.map { |fn| Redwood::MBox::Scanner.new fn }).load_n_threads 100 +end + +printer = RubyProf::GraphHtmlPrinter.new(result) +File.open("profile.html", "w") { |f| printer.print(f, 1) } +puts "report in profile.html" + diff --git a/doc/FAQ.txt b/doc/FAQ.txt new file mode 100644 index 0000000..4e6324f --- /dev/null +++ b/doc/FAQ.txt @@ -0,0 +1,38 @@ +Sup FAQ +------- + +Q: How is Sup even possible? +A: Sup is only possible through the hard work of Dave Balmain, the + author of ferret. + + I started using Ferret when it was still slightly buggy, and it + seemed like every week Dave released a bugfix or a speed + improvement that directly affected sup. Ferret has become a + first-class piece of software, and it's almost entirely due to him. + It amazes me just how much time and effort he has put into it. + +Q: Why the console? +A: As the millions (ok, hundreds) of mutt users will tell you, there are + many advantages to the console: + - You don't need a bulky web browser. + - You can ssh and check your mail on another machine. + - Instantaneous interaction. + - A few keystrokes are worth a hundred mouse clicks. + +Q: If you love GMail so much, why not just use it? +A: I hate using a mouse, and I hate ads, and I hate non-programmability. + +Q: How does Sup deal with spam? +A: You can manually mark messages as spam, which prevents them from + showing up in future searches, but that's all that Sup does. Spam + filtering should be done by a dedicated tool like SpamAssassin. + +Q: What are all these "Redwood" references I see in the code? +A: That was Sup's original name. (Think pine, elm. Although I am a + Mutt user, I couldn't think of a good progression there.) But it was + taken by another project on RubyForge, and wasn't that original, + and was too long to type anyways. + + Maybe one day I'll do a huge search-and-replace on the code, but it + doesn't seem that important at this point. + diff --git a/doc/Philosophy.txt b/doc/Philosophy.txt new file mode 100644 index 0000000..9f35a58 --- /dev/null +++ b/doc/Philosophy.txt @@ -0,0 +1,59 @@ +Must an email client have a philosophy? I think so. For many people, +it is our primary means of communication. Something so important +should warrant a little thought. + +So here's Sup's philosophy. + +Using "traditional" email clients today is increasingly problematic. +Anyone who's on a high-traffic mailing list knows this. My ruby-talk +folder is 350 megs and Mutt sits there for 60 seconds while it opens +it. Keeping up with the all the new traffic is painful, even with +Mutt's excellent threading features, just because there's so much of +it. A single thread can span several pages. And Mutt is probably the +best email client out there in terms of threading and mailing list +support. + +The principle problem with traditional clients is that they place a +high mental cost on the user for each incoming email, by forcing them +to ask: + - Should I keep this email, or delete it? + - If I keep it, where should I file it? + +For example, I've spent the last 10 years of my life laboriously +hand-filing every email message I received and feeling a mild sense of +panic every time an email was both "from Mom" and "about school". The +massive amounts of email that many people receive, and the cheap cost +of storage, have made these questions both more costly and less useful +to answer. + +As a long-time Mutt user, when I watched people use GMail, I saw them +use email differently from how I had ever used it. I saw that making +certain operations quantitatively easier (namely, search) resulted in +a qualitative difference in usage (and for the better!). I saw that +thread-centrism had many advantages over message-centrism. + +So, in many ways, I believe GMail has taken the right approach to +handle both of the factors above, and much of the inspiration for Sup +was based on using GMail. Of course, I don't ultimately like using +GMail, which is why I created Sup in the first place. + +Sup is based on the following principles, which I learned from GMail: + +- An immediately accessible and fast search capability over the + entire email archive eliminates most of the need for folders, + and eliminates the necessity of having to ever delete email. + +- Labels eliminate the remaining need for folders. + +- A thread-centric approach to the UI is much more in line with how + people operate than dealing with individual messages is. A message + and its content deserve the same treatment in the vast majority + of cases. + +Sup is also based on many ideas from mutt (and Emacs and vi!), having +to do with the fantastic productivity of a console- and key-based +application, and the usefulness of multiple buffers, etc., and the +necessity of handling multiple email accounts, but these features form +less of the philosophy and more of the general usefulness of Sup. + + diff --git a/doc/TODO b/doc/TODO new file mode 100644 index 0000000..d24fc86 --- /dev/null +++ b/doc/TODO @@ -0,0 +1,31 @@ +forward attachments +tab completion on labels, contacts +within-buffer search +contact selector in edit-message-mode +undo +maybe: filters +maybe: rangefilter on the initial inbox to only consider the most recent 1000 messages +select all, starred, to me, etc +editing of arbitrary messages +annotations on messages + +x word wrap +x background indexing +x auto-insertion of draft messages +x drafts +x sent messages loader +x search: from +x contacts +x tagging for group operations +x view: starred, to me, etc +x pull in messages by subject as well in load_thread_for_ +x reply+compose+forward +x resize +x buffer respawns +x readline +x "loading" message +x search: body, to/from, tags (requires: readline) +x highlighting/different color stuff +x config: your email, sendmail, etc +x status: to/from_you, cc_you_others +x status: new/not, important diff --git a/lib/sup.rb b/lib/sup.rb new file mode 100644 index 0000000..819fae1 --- /dev/null +++ b/lib/sup.rb @@ -0,0 +1,141 @@ +require 'rubygems' +require 'yaml' +require 'zlib' +require 'thread' +require 'fileutils' +Thread.abort_on_exception = true # make debugging possible + +class Object + ## this is for debugging purposes because i keep calling nil.id and + ## i want it to throw an exception + def id + raise "wrong id called" + end +end + +module Redwood + VERSION = "0.0.1" + + BASE_DIR = File.join(ENV["HOME"], ".sup") + CONFIG_FN = File.join(BASE_DIR, "config.yaml") + SOURCE_FN = File.join(BASE_DIR, "sources.yaml") + LABEL_FN = File.join(BASE_DIR, "labels.txt") + CONTACT_FN = File.join(BASE_DIR, "contacts.txt") + DRAFT_DIR = File.join(BASE_DIR, "drafts") + SENT_FN = File.join(BASE_DIR, "sent.mbox") + + YAML_DOMAIN = "masanjin.net" + YAML_DATE = "2006-10-01" + +## one-stop shop for yamliciousness + + def register_yaml klass, props + vars = props.map { |p| "@#{p}" } + path = klass.name.gsub(/::/, "/") + + klass.instance_eval do + define_method(:to_yaml_properties) { vars } + define_method(:to_yaml_type) { "!#{YAML_DOMAIN},#{YAML_DATE}/#{path}" } + end + + YAML.add_domain_type("#{YAML_DOMAIN},#{YAML_DATE}", path) do |type, val| + klass.new(*props.map { |p| val[p] }) + end + end + + def save_yaml_obj object, fn, compress=false + if compress + Zlib::GzipWriter.open(fn) { |f| f.puts object.to_yaml } + else + File.open(fn, "w") { |f| f.puts object.to_yaml } + end + end + + def load_yaml_obj fn, compress=false + if File.exists? fn + if compress + Zlib::GzipReader.open(fn) { |f| YAML::load f } + else + YAML::load_file fn + end + end + end + + module_function :register_yaml, :save_yaml_obj, :load_yaml_obj +end + +## set up default configuration file + +if File.exists? Redwood::CONFIG_FN + $config = Redwood::load_yaml_obj Redwood::CONFIG_FN +else + $config = { + :accounts => { + :default => { + :name => "Your Name Here", + :email => "your.email.here@domain.tld", + :alternates => [], + :sendmail => "/usr/sbin/sendmail -oem -ti", + :sig_file => File.join(ENV["HOME"], ".signature") + } + }, + :editor => ENV["EDITOR"] || "/usr/bin/vi", + } + begin + FileUtils.mkdir_p Redwood::BASE_DIR + Redwood::save_yaml_obj $config, Redwood::CONFIG_FN + rescue StandardError => e + $stderr.puts "warning: #{e.message}" + end +end + +require "sup/util" +require "sup/update" +require "sup/message" +require "sup/mbox" +require "sup/person" +require "sup/account" +require "sup/thread" +require "sup/index" +require "sup/textfield" +require "sup/buffer" +require "sup/keymap" +require "sup/mode" +require "sup/colormap" +require "sup/label" +require "sup/contact" +require "sup/tagger" +require "sup/draft" +require "sup/poll" +require "sup/modes/scroll-mode" +require "sup/modes/text-mode" +require "sup/modes/line-cursor-mode" +require "sup/modes/help-mode" +require "sup/modes/edit-message-mode" +require "sup/modes/compose-mode" +require "sup/modes/resume-mode" +require "sup/modes/forward-mode" +require "sup/modes/reply-mode" +require "sup/modes/label-list-mode" +require "sup/modes/contact-list-mode" +require "sup/modes/thread-view-mode" +require "sup/modes/thread-index-mode" +require "sup/modes/label-search-results-mode" +require "sup/modes/search-results-mode" +require "sup/modes/person-search-results-mode" +require "sup/modes/inbox-mode" +require "sup/modes/buffer-list-mode" +require "sup/modes/log-mode" +require "sup/modes/poll-mode" +require "sup/logger" +require "sup/sent" + +module Redwood + def log s; Logger.log s; end + module_function :log +end + +$:.each do |base| + d = File.join base, "sup/share/modes/" + Redwood::Mode.load_all_modes d if File.directory? d +end diff --git a/lib/sup/account.rb b/lib/sup/account.rb new file mode 100644 index 0000000..adb7649 --- /dev/null +++ b/lib/sup/account.rb @@ -0,0 +1,53 @@ +module Redwood + +class Account < Person + attr_accessor :sendmail, :sig_file + + def initialize h + super h[:name], h[:email] + @sendmail = h[:sendmail] + @sig_file = h[:signature] + end +end + +class AccountManager + include Singleton + + attr_accessor :default_account + + def initialize accounts + @email_map = {} + @alternate_map = {} + @accounts = {} + @default_account = nil + + accounts.each { |k, v| add_account v, k == :default } + + self.class.i_am_the_instance self + end + + def user_accounts; @accounts.keys; end + def user_emails; (@email_map.keys + @alternate_map.keys).uniq.select { |e| String === e }; end + + def add_account hash, default=false + email = hash[:email] + + next if @email_map.member? email + a = Account.new hash + @accounts[a] = true + @email_map[email] = a + hash[:alternates].each { |aa| @alternate_map[aa] = a } + if default + raise ArgumentError, "multiple default accounts" if @default_account + @default_account = a + end + end + + def is_account? p; @accounts.member? p; end + def account_for email + @email_map[email] || @alternate_map[email] || @alternate_map.argfind { |k, v| k === email && v } + end + def is_account_email? email; !account_for(email).nil?; end +end + +end diff --git a/lib/sup/buffer.rb b/lib/sup/buffer.rb new file mode 100644 index 0000000..60404f7 --- /dev/null +++ b/lib/sup/buffer.rb @@ -0,0 +1,391 @@ +require 'thread' + +module Ncurses + def rows + lame, lamer = [], [] + stdscr.getmaxyx lame, lamer + lame.first + end + + def cols + lame, lamer = [], [] + stdscr.getmaxyx lame, lamer + lamer.first + end + + ## aaahhh, user input. who would have though that such a simple + ## idea would be SO FUCKING COMPLICATED?! because apparently + ## Ncurses.getch (and Curses.getch), even in cbreak mode, BLOCKS + ## ALL THREAD ACTIVITY. as in, no threads anywhere will run while + ## it's waiting for input. ok, fine, so we wrap it in a select. Of + ## course we also rely on Ncurses.getch to tell us when an xterm + ## resize has occurred, which select won't catch, so we won't + ## resize outselves after a sigwinch until the user hits a key. + ## and installing our own sigwinch handler means that the screen + ## size returned by getmaxyx() DOESN'T UPDATE! and Kernel#trap + ## RETURNS NIL as the previous handler! + ## + ## so basically, resizing with multi-threaded ruby Ncurses + ## applications will always be broken. + ## + ## i've coined a new word for this: lametarded. + def nonblocking_getch + if IO.select([$stdin], nil, nil, nil) + Ncurses.getch + else + nil + end + end + + module_function :rows, :cols, :nonblocking_getch + + KEY_CANCEL = "\a"[0] # ctrl-g +end + +module Redwood + +class Buffer + attr_reader :mode, :x, :y, :width, :height, :title + bool_reader :dirty + + def initialize window, mode, width, height, opts={} + @w = window + @mode = mode + @dirty = true + @focus = false + @title = opts[:title] || "" + @x, @y, @width, @height = 0, 0, width, height + end + + def content_height; @height - 1; end + def content_width; @width; end + + def resize rows, cols + @width = cols + @height = rows + mode.resize rows, cols + end + + def redraw + draw if @dirty + draw_status + commit + end + def mark_dirty; @dirty = true; end + + def commit + @dirty = false + @w.noutrefresh + end + + def draw + @mode.draw + draw_status + commit + end + + ## s nil means a blank line! + def write y, x, s, opts={} + return if x >= @width || y >= @height + + @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight]) + s ||= "" + maxl = @width - x + @w.mvaddstr y, x, s[0 ... maxl] + unless s.length >= maxl || opts[:no_fill] + @w.mvaddstr(y, x + s.length, " " * (maxl - s.length)) + end + end + + def clear + @w.clear + end + + def draw_status + write @height - 1, 0, " [#{mode.name}] #{title} #{mode.status}", + :color => :status_color + end + + def focus + @focus = true + @dirty = true + @mode.focus + end + + def blur + @focus = false + @dirty = true + @mode.blur + end +end + +class BufferManager + include Singleton + + attr_reader :focus_buf + + def initialize + @name_map = {} + @buffers = [] + @focus_buf = nil + @dirty = true + @minibuf_stack = [] + @textfields = {} + @flash = nil + @shelled_out = false + + self.class.i_am_the_instance self + end + + def buffers; @name_map.to_a; end + + def focus_on buf + raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless + @buffers.member? buf + return if buf == @focus_buf + @focus_buf.blur if @focus_buf + @focus_buf = buf + @focus_buf.focus + end + + def raise_to_front buf + raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless + @buffers.member? buf + @buffers.delete buf + @buffers.push buf + focus_on buf + @dirty = true + end + + def roll_buffers + raise_to_front @buffers.first + end + + def roll_buffers_backwards + return unless @buffers.length > 1 + raise_to_front @buffers[@buffers.length - 2] + end + + def handle_input c + @focus_buf && @focus_buf.mode.handle_input(c) + end + + def exists? n; @name_map.member? n; end + def [] n; @name_map[n]; end + def []= n, b + raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n) + @name_map[n] = b + end + + def completely_redraw_screen + return if @shelled_out + Ncurses.clear + @dirty = true + draw_screen + end + + def handle_resize + return if @shelled_out + rows, cols = Ncurses.rows, Ncurses.cols + @buffers.each { |b| b.resize rows - 1, cols } + completely_redraw_screen + flash "resized to #{rows}x#{cols}" + end + + def draw_screen skip_minibuf=false + return if @shelled_out + + ## disabling this for the time being, to help with debugging + ## (currently we only have one buffer visible at a time). + ## TODO: reenable this if we allow multiple buffers + false && @buffers.inject(@dirty) do |dirty, buf| + dirty ? buf.draw : buf.redraw + dirty || buf.dirty? + end + ## quick hack + true && (@dirty ? @buffers.last.draw : @buffers.last.redraw) + + draw_minibuf unless skip_minibuf + @dirty = false + Ncurses.doupdate + end + + ## gets the mode from the block, which is only called if the buffer + ## doesn't already exist. this is useful in the case that generating + ## the mode is expensive, as it often is. + def spawn_unless_exists title, opts={} + if @name_map.member? title + Redwood::log "buffer '#{title}' already exists, raising to front" + raise_to_front @name_map[title] + else + mode = yield + spawn title, mode, opts + end + @name_map[title] + end + + def spawn title, mode, opts={} + realtitle = title + num = 2 + while @name_map.member? realtitle + realtitle = "#{title} #{num}" + num += 1 + end + + Redwood::log "spawning buffer \"#{realtitle}\"" + width = opts[:width] || Ncurses.cols + height = opts[:height] || Ncurses.rows - 1 + + ## since we are currently only doing multiple full-screen modes, + ## use stdscr for each window. once we become more sophisticated, + ## we may need to use a new Ncurses::WINDOW + ## + ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0), + ## (opts[:left] || 0)) + w = Ncurses.stdscr + raise "nil window" unless w + + b = Buffer.new w, mode, width, height, :title => realtitle + mode.buffer = b + @name_map[realtitle] = b + if opts[:hidden] + @buffers.unshift b + focus_on b unless @focus_buf + else + @buffers.push b + raise_to_front b + end + b + end + + def kill_all_buffers + kill_buffer @buffers.first until @buffers.empty? + end + + def kill_buffer buf + raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf + Redwood::log "killing buffer \"#{buf.title}\"" + + buf.mode.cleanup + @buffers.delete buf + @name_map.delete buf.title + @focus_buf = nil if @focus_buf == buf + if @buffers.empty? + ## TODO: something intelligent here + ## for now I will simply prohibit killing the inbox buffer. + else + raise_to_front @buffers.last + end + end + + def ask domain, question, default=nil + @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, + Ncurses.cols + tf = @textfields[domain] + + ## this goddamn ncurses form shit is a fucking 1970's + ## nightmare. jesus christ. the exact sequence of ncurses events + ## that needs to happen in order to display a form and have the + ## entire screen not disappear and have the cursor in the right + ## place is TOO FUCKING COMPLICATED. + tf.activate question, default + @dirty = true + draw_screen true + tf.position_cursor + Ncurses.refresh + + ret = nil + while tf.handle_input(Ncurses.nonblocking_getch); end + + ret = tf.value + tf.deactivate + @dirty = true + + ret + end + + ## some pretty lame code in here! + def ask_getch question, accept=nil + accept = accept.split(//).map { |x| x[0] } if accept + + flash question + Ncurses.curs_set 1 + Ncurses.move Ncurses.rows - 1, question.length + 1 + Ncurses.refresh + + ret = nil + done = false + until done + key = Ncurses.nonblocking_getch + if key == Ncurses::KEY_CANCEL + done = true + elsif (accept && accept.member?(key)) || !accept + ret = key + done = true + end + end + + Ncurses.curs_set 0 + erase_flash + draw_screen + Ncurses.curs_set 0 + + ret + end + + def ask_yes_or_no question + [?y, ?Y].member? ask_getch(question, "ynYN") + end + + def draw_minibuf + s = @flash || @minibuf_stack.reverse.find { |x| x } || "" + + Ncurses.attrset Colormap.color_for(:none) + Ncurses.mvaddstr Ncurses.rows - 1, 0, s + (" " * [Ncurses.cols - s.length, + 0].max) + end + + def say s, id=nil + id ||= @minibuf_stack.length + @minibuf_stack[id] = s + unless @shelled_out + draw_minibuf + Ncurses.refresh + end + id + end + + def erase_flash; @flash = nil; end + + def flash s + @flash = s + unless @shelled_out + draw_minibuf + Ncurses.refresh + end + end + + def clear id + @minibuf_stack[id] = nil + if id == @minibuf_stack.length - 1 + id.downto(0) do |i| + break unless @minibuf_stack[i].nil? + @minibuf_stack.delete_at i + end + end + unless @shelled_out + draw_minibuf + Ncurses.refresh + end + end + + def shell_out command + @shelled_out = true + Ncurses.endwin + system command + Ncurses.refresh + Ncurses.curs_set 0 + @shelled_out = false + end +end +end diff --git a/lib/sup/colormap.rb b/lib/sup/colormap.rb new file mode 100644 index 0000000..d13e86c --- /dev/null +++ b/lib/sup/colormap.rb @@ -0,0 +1,118 @@ +require "curses" + +module Redwood + +class Colormap + @@instance = nil + + CURSES_COLORS = [Curses::COLOR_BLACK, Curses::COLOR_RED, Curses::COLOR_GREEN, + Curses::COLOR_YELLOW, Curses::COLOR_BLUE, + Curses::COLOR_MAGENTA, Curses::COLOR_CYAN, + Curses::COLOR_WHITE] + NUM_COLORS = 15 + + def initialize + raise "only one instance can be created" if @@instance + @@instance = self + @entries = {} + @color_pairs = {[Curses::COLOR_WHITE, Curses::COLOR_BLACK] => 0} + @users = [] + @next_id = 0 + yield self if block_given? + @entries[highlight_sym(:none)] = highlight_for(Curses::COLOR_WHITE, + Curses::COLOR_BLACK, + []) + [nil] + end + + def add sym, fg, bg, *attrs + raise ArgumentError, "color for #{sym} already defined" if + @entries.member? sym + raise ArgumentError, "color '#{fg}' unknown" unless CURSES_COLORS.include? fg + raise ArgumentError, "color '#{bg}' unknown" unless CURSES_COLORS.include? bg + + @entries[sym] = [fg, bg, attrs, nil] + @entries[highlight_sym(sym)] = highlight_for(fg, bg, attrs) + [nil] + end + + def highlight_sym sym + "#{sym}_highlight".intern + end + + def highlight_for fg, bg, attrs + hfg = + case fg + when Curses::COLOR_BLUE + Curses::COLOR_WHITE + when Curses::COLOR_YELLOW, Curses::COLOR_GREEN + fg + else + Curses::COLOR_BLACK + end + + hbg = + case bg + when Curses::COLOR_CYAN + Curses::COLOR_YELLOW + else + Curses::COLOR_CYAN + end + + attrs = + if fg == Curses::COLOR_WHITE && attrs.include?(Curses::A_BOLD) + [Curses::A_BOLD] + else + case hfg + when Curses::COLOR_BLACK + [] + else + [Curses::A_BOLD] + end + end + [hfg, hbg, attrs] + end + + def color_for sym, highlight=false + sym = highlight_sym(sym) if highlight + return Curses::COLOR_BLACK if sym == :none + raise ArgumentError, "undefined color #{sym}" unless @entries.member? sym + + ## if this color is cached, return it + fg, bg, attrs, color = @entries[sym] + return color if color + + if(cp = @color_pairs[[fg, bg]]) + ## nothing + else ## need to get a new colorpair + @next_id = (@next_id + 1) % NUM_COLORS + @next_id += 1 if @next_id == 0 # 0 is always white on black + id = @next_id + Redwood::log "colormap: for color #{sym}, using id #{id} -> #{fg}, #{bg}" + Curses.init_pair id, fg, bg or raise ArgumentError, + "couldn't initialize curses color pair #{fg}, #{bg} (key #{id})" + + cp = @color_pairs[[fg, bg]] = Curses.color_pair(id) + ## delete the old mapping, if it exists + if @users[cp] + @users[cp].each do |usym| + Redwood::log "dropping color #{usym} (#{id})" + @entries[usym][3] = nil + end + @users[cp] = [] + end + end + + ## by now we have a color pair + color = attrs.inject(cp) { |color, attr| color | attr } + @entries[sym][3] = color # fill the cache + (@users[cp] ||= []) << sym # record entry as a user of that color pair + color + end + + def self.instance; @@instance; end + def self.method_missing meth, *a + Colorcolors.new unless @@instance + @@instance.send meth, *a + end +end + +end diff --git a/lib/sup/contact.rb b/lib/sup/contact.rb new file mode 100644 index 0000000..715497b --- /dev/null +++ b/lib/sup/contact.rb @@ -0,0 +1,40 @@ +module Redwood + +class ContactManager + include Singleton + + def initialize fn + @fn = fn + @people = {} + + if File.exists? fn + IO.foreach(fn) do |l| + l =~ /^(\S+): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}" + aalias, addr = $1, $2 + @people[aalias] = Person.for addr + end + end + + self.class.i_am_the_instance self + end + + def contacts; @people; end + def set_contact person, aalias + oldentry = @people.find { |a, p| p == person } + @people.delete oldentry.first if oldentry + @people[aalias] = person + end + def drop_contact person; @people.delete person; end + def delete t; @people.delete t; end + def resolve aalias; @people[aalias]; end + + def save + File.open(@fn, "w") do |f| + @people.keys.sort.each do |aalias| + f.puts "#{aalias}: #{@people[aalias].full_address}" + end + end + end +end + +end diff --git a/lib/sup/draft.rb b/lib/sup/draft.rb new file mode 100644 index 0000000..12233f3 --- /dev/null +++ b/lib/sup/draft.rb @@ -0,0 +1,105 @@ +module Redwood + +class DraftManager + include Singleton + + attr_accessor :source + def initialize dir + @dir = dir + @source = nil + self.class.i_am_the_instance self + end + + def self.source_name; "drafts"; end + def self.source_id; 9999; end + def new_source; @source = DraftLoader.new @dir; end + + def write_draft + offset = @source.gen_offset + fn = @source.fn_for_offset offset + File.open(fn, "w") { |f| yield f } + + @source.each do |offset, labels| + m = Message.new @source, offset, labels + Index.add_message m + UpdateManager.relay :add, m + end + end + + def discard mid + docid, entry = Index.load_entry_for_id mid + raise ArgumentError, "can't find entry for draft: #{mid.inspect}" unless entry + raise ArgumentError, "not a draft: source id #{entry[:source_id].inspect}, should be #{DraftManager.source_id.inspect} for #{mid.inspect} / docno #{docid}" unless entry[:source_id].to_i == DraftManager.source_id + Index.drop_entry docid + File.delete @source.fn_for_offset(entry[:source_info]) + UpdateManager.relay :delete, mid + end +end + +class DraftLoader + attr_accessor :dir, :end_offset + bool_reader :dirty + + def initialize dir, end_offset=0 + Dir.mkdir dir unless File.exists? dir + @dir = dir + @end_offset = end_offset + @dirty = false + end + + def done?; !File.exists? fn_for_offset(@end_offset); end + def usual?; true; end + def id; DraftManager.source_id; end + def to_s; DraftManager.source_name; end + def is_source_for? x; x == DraftManager.source_name; end + + def gen_offset + i = @end_offset + while File.exists? fn_for_offset(i) + i += 1 + end + i + end + + def fn_for_offset o; File.join(@dir, o.to_s); end + + def load_header offset + File.open fn_for_offset(offset) do |f| + return MBox::read_header(f) + end + end + + def load_message offset + File.open fn_for_offset(offset) do |f| + RMail::Mailbox::MBoxReader.new(f).each_message do |input| + return RMail::Parser.read(input) + end + end + end + + ## load the full header text + def load_header_text offset + ret = "" + File.open fn_for_offset(offset) do |f| + until f.eof? || (l = f.gets) =~ /^$/ + ret += l + end + end + ret + end + + def each + while File.exists?(fn = File.join(@dir, @end_offset.to_s)) + yield @end_offset, [:draft, :inbox] + @end_offset += 1 + @dirty = true + end + end + + def total; Dir[File.join(@dir, "*")].sort.last.to_i; end + def reset!; @end_offset = 0; @dirty = true; end +end + +Redwood::register_yaml(DraftLoader, %w(dir end_offset)) + +end diff --git a/lib/sup/index.rb b/lib/sup/index.rb new file mode 100644 index 0000000..255e2f8 --- /dev/null +++ b/lib/sup/index.rb @@ -0,0 +1,353 @@ +## the index structure for redwood. interacts with ferret. + +require 'thread' +require 'fileutils' +require_gem 'ferret', ">= 0.10.13" + +module Redwood + +class IndexError < StandardError + attr_reader :source + + def initialize source, s + super s + @source = source + end +end + +class Index + include Singleton + + LOAD_THREAD_PETIT_DELAY = 0.1 + LOAD_THREAD_GRAND_DELAY = 5 + + MESSAGES_AT_A_TIME = 10 + + attr_reader :index # debugging only + + def initialize dir=BASE_DIR + @dir = dir + @mutex = Mutex.new + @load_thread = nil # loads new messages + @sources = {} + @sources_dirty = false + + self.class.i_am_the_instance self + end + + def load + load_sources + load_index + end + + def save + FileUtils.mkdir_p @dir unless File.exists? @dir + save_sources + save_index + end + + def add_source source + raise "duplicate source!" if @sources.include? source + @sources_dirty = true + source.id ||= @sources.size + source.id += 1 while @sources.member? source.id + @sources[source.id] = source + end + + def source_for name; @sources.values.find { |s| s.is_source_for? name }; end + def usual_sources; @sources.values.find_all { |s| s.usual? }; end + + def load_index dir=File.join(@dir, "ferret") + wsa = Ferret::Analysis::WhiteSpaceAnalyzer.new false + sa = Ferret::Analysis::StandardAnalyzer.new + analyzer = Ferret::Analysis::PerFieldAnalyzer.new wsa + analyzer[:body] = sa + + if File.exists? dir + Redwood::log "loading index" + @index = Ferret::Index::Index.new(:path => dir, :analyzer => analyzer) + else + Redwood::log "creating index" + field_infos = Ferret::Index::FieldInfos.new :store => :yes + field_infos.add_field :message_id + field_infos.add_field :source_id + field_infos.add_field :source_info, :index => :no, :term_vector => :no + field_infos.add_field :date, :index => :untokenized + field_infos.add_field :body, :store => :no + field_infos.add_field :label + field_infos.add_field :subject + field_infos.add_field :from + field_infos.add_field :to + field_infos.add_field :refs + field_infos.add_field :snippet, :index => :no, :term_vector => :no + field_infos.create_index dir + @index = Ferret::Index::Index.new(:path => dir, :analyzer => analyzer) + end + end + + ## update the message by deleting and re-adding + def update_message m, source=nil, source_info=nil + docid, entry = load_entry_for_id m.id + if entry + source ||= entry[:source_id].to_i + source_info ||= entry[:source_info].to_i + end + raise "no entry and no source info for message #{m.id}" unless source && source_info + + raise "deleting non-corresponding entry #{docid}" unless @index[docid][:message_id] == m.id + @index.delete docid + add_message m + end + + def save_index fn=File.join(@dir, "ferret") + # don't have to do anything apparently + end + + def contains_id? id + @index.search(Ferret::Search::TermQuery.new(:message_id, id)).total_hits > 0 + end + def contains? m; contains_id? m.id; end + def size; @index.size; end + + ## you should probably not call this on a block that doesn't break + ## rather quickly because the results will probably be, as we say + ## in scotland, frikkin' huuuge. + EACH_BY_DATE_NUM = 100 + def each_id_by_date opts={} + return if @index.size == 0 # otherwise ferret barfs + query = build_query opts + offset = 0 + while true + results = @index.search(query, :sort => "date DESC", :limit => EACH_BY_DATE_NUM, :offset => offset) + Redwood::log "got #{results.total_hits} results for query (offset #{offset}) #{query.inspect}" + results.hits.each { |hit| yield @index[hit.doc][:message_id], lambda { build_message hit.doc } } + break if offset >= results.total_hits - EACH_BY_DATE_NUM + offset += EACH_BY_DATE_NUM + end + end + + def num_results_for opts={} + query = build_query opts + x = @index.search(query).total_hits + Redwood::log "num_results_for: have #{x} for query #{query}" + x + end + + SAME_SUBJECT_DATE_LIMIT = 7 + def each_message_in_thread_for m, opts={} + messages = {} + searched = {} + num_queries = 0 + + ## temporarily disabling subject searching because it's a + ## significant slowdown. + ## + ## TODO: make this configurable, i guess + if false + date_min = m.date - (SAME_SUBJECT_DATE_LIMIT * 12 * 3600) + date_max = m.date + (SAME_SUBJECT_DATE_LIMIT * 12 * 3600) + + q = Ferret::Search::BooleanQuery.new true + sq = Ferret::Search::PhraseQuery.new(:subject) + wrap_subj(Message.normalize_subj(m.subj)).split(/\s+/).each do |t| + sq.add_term t + end + q.add_query sq, :must + q.add_query Ferret::Search::RangeQuery.new(:date, :>= => date_min.to_indexable_s, :<= => date_max.to_indexable_s), :must + + pending = @index.search(q).hits.map { |hit| @index[hit.doc][:message_id] } + Redwood::log "found #{pending.size} results for subject query #{q}" + else + pending = [m.id] + end + + until pending.empty? || (opts[:limit] && messages.size >= opts[:limit]) + id = pending.pop + next if searched.member? id + searched[id] = true + q = Ferret::Search::BooleanQuery.new true + q.add_query Ferret::Search::TermQuery.new(:message_id, id), :should + q.add_query Ferret::Search::TermQuery.new(:refs, id), :should + + num_queries += 1 + @index.search_each(q, :limit => :all) do |docid, score| + break if opts[:limit] && messages.size >= opts[:limit] + mid = @index[docid][:message_id] + unless messages.member? mid + messages[mid] ||= lambda { build_message docid } + refs = @index[docid][:refs].split(" ") + pending += refs + end + end + end + Redwood::log "ran #{num_queries} queries to build thread of #{messages.size} messages for #{m.id}" + messages.each { |mid, builder| yield mid, builder } + end + + ## builds a message object from a ferret result + def build_message docid + doc = @index[docid] + source = @sources[doc[:source_id].to_i] + #puts "building message #{doc[:message_id]} (#{source}##{doc[:source_info]})" + raise "invalid source #{doc[:source_id]}" unless source + begin + raise "no snippet" unless doc[:snippet] + Message.new source, doc[:source_info].to_i, + doc[:label].split(" ").map { |s| s.intern }, + doc[:snippet] + rescue MessageFormatError => e + raise IndexError.new(source, "error building message #{doc[:message_id]} at #{source}/#{doc[:source_info]}: #{e.message}") + nil + end + end + + def start_load_thread + return if @load_thread + @load_thread = true + @load_thread = ::Thread.new do + while @load_thread + load_some_entries ENTRIES_AT_A_TIME, LOAD_THREAD_PETIT_DELAY, LOAD_THREAD_GRAND_DELAY + end + end + end + + def end_load_thread; @load_thread = nil; end + def fresh_thread_id; @next_thread_id += 1; end + + def wrap_subj subj; "__START_SUBJECT__ #{subj} __END_SUBJECT__"; end + + def add_message m + return false if contains? m + + source_id = + if m.source.is_a? Integer + m.source + else + m.source.id or raise "unregistered source #{m.source}" + end + + to = (m.to + m.cc + m.bcc).map { |x| x.email }.join(" ") + d = { + :message_id => m.id, + :source_id => source_id, + :source_info => m.source_info, + :date => m.date.to_indexable_s, + :body => m.content, + :snippet => m.snippet, + :label => m.labels.join(" "), + :from => m.from ? m.from.email : "", + :to => (m.to + m.cc + m.bcc).map { |x| x.email }.join(" "), + :subject => wrap_subj(Message.normalize_subj(m.subj)), + :refs => (m.refs + m.replytos).join(" "), + } + + @index.add_document d + + ## TODO: figure out why this is sometimes triggered + #docid, entry = load_entry_for_id m.id + #raise "just added message #{m.id} but couldn't find it in a search" unless docid + true + end + + def drop_entry docno; @index.delete docno; end + + def load_entry_for_id mid + results = @index.search(Ferret::Search::TermQuery.new(:message_id, mid)) + return if results.total_hits == 0 + docid = results.hits[0].doc + [docid, @index[docid]] + end + + def load_contacts emails, h={} + q = Ferret::Search::BooleanQuery.new true + emails.each do |e| + qq = Ferret::Search::BooleanQuery.new true + qq.add_query Ferret::Search::TermQuery.new(:from, e), :should + qq.add_query Ferret::Search::TermQuery.new(:to, e), :should + q.add_query qq + end + q.add_query Ferret::Search::TermQuery.new(:label, "spam"), :must_not + + Redwood::log "contact search: #{q}" + contacts = {} + num = h[:num] || 20 + @index.search_each(q, :sort => "date DESC", :limit => :all) do |docid, score| + break if contacts.size >= num + #Redwood::log "got message with to: #{@index[docid][:to].inspect} and from: #{@index[docid][:from].inspect}" + f = @index[docid][:from] + t = @index[docid][:to] + + if AccountManager.is_account_email? f + t.split(" ").each { |e| #Redwood::log "adding #{e} because there's a message to him from account email #{f}"; + contacts[Person.for(e)] = true } + else + #Redwood::log "adding from #{f} because there's a message from him to #{t}" + contacts[Person.for(f)] = true + end + end + + contacts.keys.compact + end + +protected + + ## TODO: convert this to query objects rather than strings + def build_query opts + query = "" + query += opts[:labels].map { |t| "+label:#{t}" }.join(" ") if opts[:labels] + query += " +label:#{opts[:label]}" if opts[:label] + query += " #{opts[:content]}" if opts[:content] + if opts[:participants] + query += "+(" + + opts[:participants].map { |p| "from:#{p.email} OR to:#{p.email}" }.join(" OR ") + ")" + end + + query += " -label:spam" unless opts[:load_spam] || opts[:labels] == :spam || + (opts[:labels] && opts[:labels].include?(:spam)) + query += " -label:killed" unless opts[:load_killed] || opts[:labels] == :killed || + (opts[:labels] && opts[:labels].include?(:killed)) + query + end + + def load_sources fn=Redwood::SOURCE_FN + @sources = Hash[*(Redwood::load_yaml_obj(fn) || []).map { |s| [s.id, s] }.flatten] + @sources_dirty = false + end + + def save_sources fn=Redwood::SOURCE_FN + if @sources_dirty || @sources.any? { |id, s| s.dirty? } + FileUtils.mv fn, fn + ".bak", :force => true if File.exists? fn + Redwood::save_yaml_obj @sources.values, fn + end + @sources_dirty = false + end + + def load_some_entries max=ENTRIES_AT_A_TIME, delay1=nil, delay2=nil + num = 0 + begin + @sources.each_with_index do |source, source_id| + next if source.done? || num >= max + source.each do |source_info, label| + begin + m = Message.new(source, source_info, label + [:inbox]) + add_message m unless contains_id? m.id + puts m.content.inspect + num += 1 + rescue MessageFormatError => e + $stderr.puts "ignoring erroneous message at #{source}##{source_info}: #{e.message}" + end + break if num >= max + sleep delay1 if delay1 + end + Redwood::log "loaded #{num} entries from #{source}" + sleep delay2 if delay2 + end + ensure + save_sources + save_index + end + num + end +end + +end diff --git a/lib/sup/keymap.rb b/lib/sup/keymap.rb new file mode 100644 index 0000000..64935d9 --- /dev/null +++ b/lib/sup/keymap.rb @@ -0,0 +1,89 @@ +require "curses" + +module Redwood + +class Keymap + def initialize + @map = {} + @order = [] + yield self if block_given? + end + + def keysym_to_keycode k + case k + when :down: Curses::KEY_DOWN + when :up: Curses::KEY_UP + when :left: Curses::KEY_LEFT + when :right: Curses::KEY_RIGHT + when :page_down: Curses::KEY_NPAGE + when :page_up: Curses::KEY_PPAGE + when :backspace: Curses::KEY_BACKSPACE + when :home: Curses::KEY_HOME + when :end: Curses::KEY_END + when :ctrl_l: "\f"[0] + when :ctrl_g: "\a"[0] + when :tab: "\t"[0] + when :enter, :return: 10 #Curses::KEY_ENTER + else + if k.is_a?(String) && k.length == 1 + k[0] + else + raise ArgumentError, "unknown key name '#{k}'" + end + end + end + + def keysym_to_string k + case k + when :down: "" + when :up: "" + when :left: "" + when :right: "" + when :page_down: "" + when :page_up: "" + when :backspace: "" + when :home: "" + when :end: "" + when :enter, :return: "" + when :ctrl_l: "ctrl-l" + when :ctrl_l: "ctrl-g" + when :tab: "tab" + when " ": "" + else + if k.is_a?(String) && k.length == 1 + k + else + raise ArgumentError, "unknown key name \"#{k}\"" + end + end + end + + def add action, help, *keys + entry = [action, help, keys] + @order << entry + keys.each do |k| + raise ArgumentError, "key #{k} already defined (action #{action})" if @map.include? k + kc = keysym_to_keycode k + @map[kc] = entry + end + end + + def action_for kc + action, help, keys = @map[kc] + action + end + + def keysyms; @map.values.map { |action, help, keys| keys }.flatten; end + + def help_text except_for={} + lines = @order.map do |action, help, keys| + valid_keys = keys.select { |k| !except_for[k] } + next if valid_keys.empty? + [valid_keys.map { |k| keysym_to_string k }.join(", "), help] + end.compact + llen = lines.map { |a, b| a.length }.max + lines.map { |a, b| sprintf " %#{llen}s : %s", a, b }.join("\n") + end +end + +end diff --git a/lib/sup/label.rb b/lib/sup/label.rb new file mode 100644 index 0000000..a82313f --- /dev/null +++ b/lib/sup/label.rb @@ -0,0 +1,41 @@ +module Redwood + +class LabelManager + include Singleton + + ## all labels that have special meaning. user will be unable to + ## add/remove these via normal label mechanisms. + RESERVED_LABELS = [ :starred, :spam, :draft, :unread, :killed, :sent ] + + ## labels which it nonetheless makes sense to search for by + LISTABLE_LABELS = [ :starred, :spam, :draft, :sent ] + + ## labels that will never be displayed to the user + HIDDEN_LABELS = [ :starred, :unread ] + + def initialize fn + @fn = fn + labels = + if File.exists? fn + IO.readlines(fn).map { |x| x.chomp.intern } + else + [] + end + @labels = {} + labels.each { |t| @labels[t] = true } + + self.class.i_am_the_instance self + end + + def user_labels; @labels.keys; end + + def << t; @labels[t] = true unless @labels.member?(t) || RESERVED_LABELS.member?(t); end + + def delete t; @labels.delete t; end + + def save + File.open(@fn, "w") { |f| f.puts @labels.keys } + end +end + +end diff --git a/lib/sup/logger.rb b/lib/sup/logger.rb new file mode 100644 index 0000000..7a8026a --- /dev/null +++ b/lib/sup/logger.rb @@ -0,0 +1,42 @@ +module Redwood + +class Logger + @@instance = nil + + attr_reader :buf + + def initialize + raise "only one Log can be defined" if @@instance + @@instance = self + @mode = LogMode.new + @respawn = true + @spawning = false # to prevent infinite loops! + end + + ## must be called if you want to see anything! + ## once called, will respawn if killed... + def make_buf + return if @mode.buffer || !BufferManager.instantiated? || !@respawn || @spawning + @spawning = true + @mode.text = "" + @mode.buffer = BufferManager.instance.spawn "", @mode, :hidden => true + @spawning = false + end + + def log s +# $stderr.puts s + @mode << "#{Time.now}: #{s}\n" + make_buf + end + + def self.method_missing m, *a + @@instance = Logger.new unless @@instance + @@instance.send m, *a + end + + def self.buffer + @@instance.buf + end +end + +end diff --git a/lib/sup/mbox.rb b/lib/sup/mbox.rb new file mode 100644 index 0000000..5c9d612 --- /dev/null +++ b/lib/sup/mbox.rb @@ -0,0 +1,51 @@ +require "sup/mbox/loader" + +module Redwood + +## some utility functions +module MBox + BREAK_RE = /^From \S+@\S+/ + + def read_header f + header = {} + last = nil + + ## i do it in this weird way because i am trying to speed things up + ## at load-message time. + while(line = f.gets) + case line + when /^From:\s+(.*)$/i: header[last = "From"] = $1 + when /^To:\s+(.*)$/i: header[last = "To"] = $1 + when /^Cc:\s+(.*)$/i: header[last = "Cc"] = $1 + when /^Bcc:\s+(.*)$/i: header[last = "Bcc"] = $1 + when /^Subject:\s+(.*)$/i: header[last = "Subject"] = $1 + when /^Date:\s+(.*)$/i: header[last = "Date"] = $1 + when /^Message-Id:\s+<(.*)>$/i: header[last = "Message-Id"] = $1 + when /^References:\s+(.*)$/i: header[last = "References"] = $1 + when /^In-Reply-To:\s+(.*)$/i: header[last = "In-Reply-To"] = $1 + when /^List-Post:\s+(.*)$/i: header[last = "List-Post"] = $1 + when /^Reply-To:\s+(.*)$/i: header[last = "Reply-To"] = $1 + when /^Status:\s+(.*)$/i: header[last = "Status"] = $1 + when /^Delivered-To:\s+(.*)$/i + header[last = "Delivered-To"] = $1 unless header["Delivered-To"] + when /^$/: break + when /:/: last = nil + else + header[last] += line.gsub(/^\s+/, "") if last + end + end + header + end + + def read_body f + body = [] + f.each_line do |l| + break if l =~ BREAK_RE + body << l.chomp + end + body + end + + module_function :read_header, :read_body +end +end diff --git a/lib/sup/mbox/loader.rb b/lib/sup/mbox/loader.rb new file mode 100644 index 0000000..f144683 --- /dev/null +++ b/lib/sup/mbox/loader.rb @@ -0,0 +1,116 @@ +require 'thread' +require 'rmail' + +module Redwood +module MBox + +class Error < StandardError; end + +class Loader + attr_reader :filename + bool_reader :usual, :archived, :read, :dirty + attr_accessor :id, :labels + + ## end_offset is the last offsets within the file which we've read. + ## everything after that is considered new messages that haven't + ## been indexed. + def initialize filename, end_offset=0, usual=true, archived=false, id=nil + @filename = filename.gsub(%r(^mbox://), "") + @end_offset = end_offset + @dirty = false + @usual = usual + @archived = archived + @id = id + @mutex = Mutex.new + @f = File.open @filename + @labels = ([ + :unread, + archived ? nil : :inbox, + ] + + if File.dirname(filename) =~ /\b(var|usr|spool)\b/ + [] + else + [File.basename(filename).intern] + end).compact + end + + def reset!; @end_offset = 0; @dirty = true; end + def == o; o.is_a?(Loader) && o.filename == filename; end + def to_s; "mbox://#{@filename}"; end + + def is_source_for? s + @filename == s || self.to_s == s + end + + def load_header offset=nil + header = nil + @mutex.synchronize do + @f.seek offset if offset + header = MBox::read_header @f + end + header + end + + def load_message offset + ret = nil + @mutex.synchronize do + @f.seek offset + RMail::Mailbox::MBoxReader.new(@f).each_message do |input| + return RMail::Parser.read(input) + end + end + end + + ## load the full header text + def load_header_text offset + ret = "" + @mutex.synchronize do + @f.seek offset + until @f.eof? || (l = @f.gets) =~ /^$/ + ret += l + end + end + ret + end + + def next + return nil if done? + @dirty = true + next_end_offset = @end_offset + + @mutex.synchronize do + @f.seek @end_offset + + @f.gets # skip the From separator + next_end_offset = @f.tell + while(line = @f.gets) + break if line =~ BREAK_RE + next_end_offset = @f.tell + 1 + end + end + + start_offset = @end_offset + @end_offset = next_end_offset + + start_offset + end + + def each + until @end_offset >= File.size(@f) + n = self.next + yield(n, labels) if n + end + end + + def each_header + each { |offset, labels| yield offset, labels, load_header(offset) } + end + + def done?; @end_offset >= File.size(@f); end + def total; File.size @f; end +end + +Redwood::register_yaml(Loader, %w(filename end_offset usual archived id)) + +end +end diff --git a/lib/sup/message.rb b/lib/sup/message.rb new file mode 100644 index 0000000..5ceab55 --- /dev/null +++ b/lib/sup/message.rb @@ -0,0 +1,302 @@ +require 'tempfile' +require 'time' + +module Redwood + +class MessageFormatError < StandardError; end + +## a Message is what's threaded. +## +## it is also where the parsing for quotes and signatures is done, but +## that should be moved out to a separate class at some point (because +## i would like, for example, to be able to add in a ruby-talk +## specific module that would detect and link to /ruby-talk:\d+/ +## sequences in the text of an email. (how sweet would that be?) +## +## TODO: integrate with user's addressbook to render names +## appropriately. +class Message + SNIPPET_LEN = 80 + RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i + + ## some utility methods + class << self + def normalize_subj s; s.gsub(RE_PATTERN, ""); end + def subj_is_reply? s; s =~ RE_PATTERN; end + def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end + end + + class Attachment + attr_reader :content_type, :desc + def initialize content_type, desc, part + @content_type = content_type + @desc = desc + @part = part + @file = nil + end + + def view! + unless @file + @file = Tempfile.new "redwood.attachment" + @file.print @part.decode + @file.close + end + + ## TODO: handle unknown mime-types + system "/usr/bin/run-mailcap --action=view #{@content_type}:#{@file.path}" + end + end + + class Text + attr_reader :lines + def initialize lines + ## do some wrapping + @lines = lines.map { |l| l.wrap 80 }.flatten + end + end + + class Quote + attr_reader :lines + def initialize lines + @lines = lines + end + end + + class Signature + attr_reader :lines + def initialize lines + @lines = lines + end + end + + QUOTE_PATTERN = /^\s{0,4}[>|\}]/ + BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/ + QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/ + SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)/ + SIG_DISTANCE = 15 # lines from the end + DEFAULT_SUBJECT = "(missing subject)" + DEFAULT_SENDER = "(missing sender)" + + attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source, + :cc, :bcc, :labels, :list_address, :recipient_email, :replyto, + :source_info, :mbox_status + + bool_reader :dirty + + def initialize source, source_info, labels, snippet=nil + @source = source + @source_info = source_info + @dirty = false + @snippet = snippet + @labels = labels + + header = @source.load_header @source_info + header.each { |k, v| header[k.downcase] = v } + + %w(message-id date).each do |f| + raise MessageFormatError, "no #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header.include? f + raise MessageFormatError, "nil #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header[f] + end + + begin + @date = Time.parse header["date"] + rescue ArgumentError => e + raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}" + end + + if(@subj = header["subject"]) + @subj = @subj.gsub(/\s+/, " ").gsub(/\s+$/, "") + else + @subj = DEFAULT_SUBJECT + end + @from = Person.for header["from"] + @to = Person.for_several header["to"] + @cc = Person.for_several header["cc"] + @bcc = Person.for_several header["bcc"] + @id = header["message-id"] + @refs = (header["references"] || "").scan(/<(.*?)>/).flatten + @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten + @replyto = Person.for header["reply-to"] + @list_address = + if header["list-post"] + @list_address = Person.for header["list-post"].gsub(/^$/, "") + else + nil + end + + @recipient_email = header["delivered-to"] + @mbox_status = header["status"] + end + + def snippet + to_chunks unless @snippet + @snippet + end + + def is_list_message?; !@list_address.nil?; end + def is_draft?; DraftLoader === @source; end + def draft_filename + raise "not a draft" unless is_draft? + @source.fn_for_offset @source_info + end + + def save index + index.update_message self if @dirty + @dirty = false + end + + def has_label? t; @labels.member? t; end + def add_label t + return if @labels.member? t + @labels.push t + @dirty = true + end + def remove_label t + return unless @labels.member? t + @labels.delete t + @dirty = true + end + + def recipients + @to + @cc + @bcc + end + + def labels= l + @labels = l + @dirty = true + end + + def to_chunks + m = @source.load_message @source_info + message_to_chunks m + end + + def header_text + @source.load_header_text @source_info + end + + def content + [ + from && from.longname, + to.map { |p| p.longname }, + cc.map { |p| p.longname }, + bcc.map { |p| p.longname }, + to_chunks.select { |c| c.is_a? Text }.map { |c| c.lines }, + subj, + ].flatten.compact.join " " + end + + def basic_body_lines + to_chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten + end + + def basic_header_lines + ["From: #{@from.full_address}"] + + (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) + + (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) + + (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) + + ["Date: #{@date.rfc822}", + "Subject: #{@subj}"] + end + +private + + ## everything RubyMail-specific goes here. + def message_to_chunks m + ret = [] << + case m.header.content_type + when "text/plain", nil + raise MessageFormatError, "no message body before decode" unless + m.body + body = m.decode or raise MessageFormatError, "no message body" + text_to_chunks body.gsub(/\t/, " ").gsub(/\r/, "").split("\n") + when "multipart/alternative", "multipart/mixed" + nil + else + disp = m.header["Content-Disposition"] || "" + Attachment.new m.header.content_type, disp.gsub(/[\s\n]+/, " "), m + end + + m.each_part { |p| ret << message_to_chunks(p) } if m.multipart? + ret.compact.flatten + end + + ## parse the lines of text into chunk objects. the heuristics here + ## need tweaking in some nice manner. TODO: move these heuristics + ## into the classes themselves. + + def text_to_chunks lines + state = :text # one of :text, :quote, or :sig + chunks = [] + chunk_lines = [] + + lines.each_with_index do |line, i| + nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines + case state + when :text + newstate = nil + if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN)) + newstate = :quote + elsif line =~ SIG_PATTERN && (lines.length - i) < SIG_DISTANCE + newstate = :sig + elsif line =~ BLOCK_QUOTE_PATTERN + newstate = :block_quote + end + if newstate + chunks << Text.new(chunk_lines) unless chunk_lines.empty? + chunk_lines = [line] + state = newstate + else + chunk_lines << line + end + when :quote + newstate = nil + if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN || line =~ /^\s*$/ + chunk_lines << line + elsif line =~ SIG_PATTERN && (lines.length - i) < SIG_DISTANCE + newstate = :sig + else + newstate = :text + end + if newstate + if chunk_lines.empty? + # nothing + elsif chunk_lines.size == 1 + chunks << Text.new(chunk_lines) # forget about one-line quotes + else + chunks << Quote.new(chunk_lines) + end + chunk_lines = [line] + state = newstate + end + when :block_quote + chunk_lines << line + when :sig + chunk_lines << line + end + + if state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && + line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/ + @snippet = (@snippet ? @snippet + " " : "") + line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ") + @snippet = @snippet[0 ... SNIPPET_LEN] + end +# if @snippet.nil? && state == :text && (line.length > 40 || +# line =~ /\S+.*[^,!:]\s*$/) +# @snippet = line.gsub(/^\s+/, "").gsub(/[\r\n]/, "")[0 .. 80] +# end + end + + ## final object + case state + when :quote, :block_quote + chunks << Quote.new(chunk_lines) unless chunk_lines.empty? + when :text + chunks << Text.new(chunk_lines) unless chunk_lines.empty? + when :sig + chunks << Signature.new(chunk_lines) unless chunk_lines.empty? + end + chunks + end +end + +end diff --git a/lib/sup/mode.rb b/lib/sup/mode.rb new file mode 100644 index 0000000..b90401f --- /dev/null +++ b/lib/sup/mode.rb @@ -0,0 +1,79 @@ +module Redwood + +class Mode + attr_accessor :buffer + @@keymaps = {} + + def self.register_keymap keymap=nil, &b + keymap = Keymap.new(&b) if keymap.nil? + @@keymaps[self] = keymap + end + + def initialize + @buffer = nil + end + + def self.make_name s; s.gsub(/.*::/, "").camel_to_hyphy; end + def name; Mode.make_name self.class.name; end + + def self.load_all_modes dir + Dir[File.join(dir, "*.rb")].each do |f| + $stderr.puts "## loading mode #{f}" + require f + end + end + + def draw; end + def focus; end + def blur; end + def status; ""; end + def resize rows, cols; end + def cleanup + @buffer = nil + end + + ## turns an input keystroke into an action symbol + def resolve_input c + ## try all keymaps in order of age + action = nil + klass = self.class + + ancestors.each do |klass| + action = @@keymaps.member?(klass) && @@keymaps[klass].action_for(c) + return action if action + end + + nil + end + + def handle_input c + if(action = resolve_input c) + send action + true + else + false + end + end + + def help_text + used_keys = {} + ancestors.map do |klass| + km = @@keymaps[klass] or next + title = "Keybindings from #{Mode.make_name klass.name}" + s = < AccountManager.default_account.full_address, + "Message-Id" => gen_message_id, + } + + @header["To"] = [h[:to]].flatten.compact.map { |p| p.full_address } + @body = sig_lines + regen_text + end + + def lines; @text.length; end + def [] i; @text[i]; end + +protected + + def handle_new_text new_header, new_body + @header = new_header + @body = new_body + end + + def regen_text + @text = header_lines(@header - EditMessageMode::NON_EDITABLE_HEADERS) + [""] + @body + end +end + +end diff --git a/lib/sup/modes/contact-list-mode.rb b/lib/sup/modes/contact-list-mode.rb new file mode 100644 index 0000000..f2f5049 --- /dev/null +++ b/lib/sup/modes/contact-list-mode.rb @@ -0,0 +1,121 @@ +module Redwood + +class ContactListMode < LineCursorMode + LOAD_MORE_CONTACTS_NUM = 10 + + register_keymap do |k| + k.add :load_more, "Load #{LOAD_MORE_CONTACTS_NUM} more contacts", 'M' + k.add :reload, "Reload contacts", 'R' + k.add :alias, "Edit alias for contact", 'a' + k.add :toggle_tagged, "Tag/untag current line", 't' + k.add :apply_to_tagged, "Apply next command to all tagged items", ';' + k.add :search, "Search for messages from particular people", 'S' + end + + def initialize mode = :regular + @mode = mode + @tags = Tagger.new self + reload + super() + end + + def lines; @text.length; end + def [] i; @text[i]; end + + def toggle_tagged + p = @contacts[curpos] or return + @tags.toggle_tag_for p + update_text_for_line curpos + cursor_down + end + + def multi_toggle_tagged threads + @tags.drop_all_tags + regen_text + end + + def apply_to_tagged; @tags.apply_to_tagged; end + + def load; regen_text; end + def load_more + @num += LOAD_MORE_CONTACTS_NUM + regen_text + BufferManager.flash "Loaded #{LOAD_MORE_CONTACTS_NUM} more contacts." + end + + def multi_select people + case @mode + when :regular + mode = ComposeMode.new :to => people + BufferManager.spawn "new message", mode + mode.edit + end + end + + def select + p = @contacts[curpos] or return + multi_select [p] + end + + def multi_search people + mode = PersonSearchResultsMode.new people + BufferManager.spawn "personal search results", mode + mode.load_more_threads mode.buffer.content_height + end + + def search + p = @contacts[curpos] or return + multi_search [p] + end + + def reload + @tags.drop_all_tags + @num = LOAD_MORE_CONTACTS_NUM + load + end + + def alias + p = @contacts[curpos] or return + a = BufferManager.ask(:alias, "alias for #{p.longname}: ", @user_contacts[p]) or return + if a.empty? + ContactManager.drop_contact p + else + ContactManager.set_contact p, a + @user_contacts[p] = a + update_text_for_line curpos + end + end + +protected + + def update_text_for_line line + @text[line] = text_for_contact @contacts[line] + buffer.mark_dirty + end + + def text_for_contact p + aalias = @user_contacts[p] || "" + [[:tagged_color, @tags.tagged?(p) ? ">" : " "], + [:none, sprintf("%-#{@awidth}s %-#{@nwidth}s %s", aalias, p.name, p.email)]] + end + + def regen_text + @user_contacts = ContactManager.contacts.invert + recent = Index.load_contacts AccountManager.user_emails, + :num => @num + + @contacts = (@user_contacts.keys + recent.select { |p| !@user_contacts[p] }).sort_by { |p| p.sort_by_me + (p.name || "") + p.email }.remove_successive_dupes + + @awidth, @nwidth = 0, 0 + @contacts.each do |p| + aalias = @user_contacts[p] + @awidth = aalias.length if aalias && aalias.length > @awidth + @nwidth = p.name.length if p.name && p.name.length > @nwidth + end + + @text = @contacts.map { |p| text_for_contact p } + buffer.mark_dirty if buffer + end +end + +end diff --git a/lib/sup/modes/edit-message-mode.rb b/lib/sup/modes/edit-message-mode.rb new file mode 100644 index 0000000..7db5060 --- /dev/null +++ b/lib/sup/modes/edit-message-mode.rb @@ -0,0 +1,162 @@ +require 'tempfile' +require 'socket' # just for gethostname! + +module Redwood + +class EditMessageMode < LineCursorMode + FORCE_HEADERS = %w(From To Cc Bcc Subject) + MULTI_HEADERS = %w(To Cc Bcc) + NON_EDITABLE_HEADERS = %w(Message-Id Date) + + attr_reader :status + + register_keymap do |k| + k.add :send_message, "Send message", 'y' + k.add :edit, "Edit message", 'e', :enter + k.add :save_as_draft, "Save as draft", 'P' + end + + def initialize *a + super + @attachments = [] + @edited = false + end + + def edit + @file = Tempfile.new "redwood.#{self.class.name.camel_to_hyphy}" + @file.puts header_lines(header - NON_EDITABLE_HEADERS) + @file.puts + @file.puts body + @file.close + + editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi" + + mtime = File.mtime @file.path + BufferManager.shell_out "#{editor} #{@file.path}" + @edited = true if File.mtime(@file.path) > mtime + + new_header, new_body = parse_file(@file.path) + NON_EDITABLE_HEADERS.each { |h| new_header[h] = header[h] if header[h] } + handle_new_text new_header, new_body + update + end + +protected + + def gen_message_id + "<#{Time.now.to_i}-redwood-#{rand 10000}@#{Socket.gethostname}>" + end + + def update + regen_text + buffer.mark_dirty + end + + def parse_file fn + File.open(fn) do |f| + header = MBox::read_header f + body = MBox::read_body f + + header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k } + header.each do |k, v| + next unless MULTI_HEADERS.include?(k) && !v.empty? + header[k] = v.split_on_commas.map do |name| + (p = ContactManager.resolve(name)) && p.full_address || name + end + end + + [header, body] + end + end + + def header_lines header + force_headers = FORCE_HEADERS.map { |h| make_lines "#{h}:", header[h] } + other_headers = (header.keys - FORCE_HEADERS).map do |h| + make_lines "#{h}:", header[h] + end + + (force_headers + other_headers).flatten.compact + end + + def make_lines header, things + case things + when nil, [] + [header + " "] + when String + [header + " " + things] + else + if things.empty? + [header] + else + things.map_with_index do |name, i| + raise "an array: #{name.inspect} (things #{things.inspect})" if Array === name + if i == 0 + header + " " + name + else + (" " * (header.length + 1)) + name + end + (i == things.length - 1 ? "" : ",") + end + end + end + end + + def send_message + return false unless @edited || BufferManager.ask_yes_or_no("message unedited---really send?") + + raise "no message id!" unless header["Message-Id"] + date = Time.now + from_email = + if header["From"] =~ /?$/ + $1 + else + AccountManager.default_account.email + end + + sendmail = AccountManager.account_for(from_email).sendmail + raise "nil sendmail" unless sendmail + SentManager.write_sent_message(date, from_email) { |f| write_message f, true, date } + BufferManager.flash "sending..." + + IO.popen(sendmail, "w") { |p| write_message p, true, date } + + BufferManager.kill_buffer buffer + BufferManager.flash "Message sent!" + true + end + + def save_as_draft + DraftManager.write_draft { |f| write_message f, false } + BufferManager.kill_buffer buffer + BufferManager.flash "Saved for later editing." + end + + def sig_lines + sigfn = (AccountManager.account_for(header["From"]) || + AccountManager.default_account).sig_file + + if sigfn && File.exists?(sigfn) + ["", "-- "] + File.readlines(sigfn).map { |l| l.chomp } + else + [] + end + end + + def write_message f, full_header=true, date=Time.now + raise ArgumentError, "no pre-defined date: header allowed" if header["Date"] + f.puts header_lines(header) + f.puts "Date: #{date.rfc2822}" + if full_header + f.puts < AccountManager.default_account.full_address, + "Subject" => "Fwd: #{m.subj}", + "Message-Id" => gen_message_id, + } + @body = forward_body_lines(m) + sig_lines + regen_text + end + + def lines; @text.length; end + def [] i; @text[i]; end + +protected + + def forward_body_lines m + ["--- Begin forwarded message from #{m.from.mediumname} ---"] + + m.basic_header_lines + [""] + m.basic_body_lines + + ["--- End forwarded message ---"] + end + + def handle_new_text new_header, new_body + @header = new_header + @body = new_body + end + + def regen_text + @text = header_lines(@header - NON_EDITABLE_HEADERS) + [""] + @body + end +end + +end diff --git a/lib/sup/modes/help-mode.rb b/lib/sup/modes/help-mode.rb new file mode 100644 index 0000000..b98f131 --- /dev/null +++ b/lib/sup/modes/help-mode.rb @@ -0,0 +1,19 @@ +module Redwood + +class HelpMode < TextMode + def initialize mode, global_keymap + title = "Help for #{mode.name}" + super < :inbox, + :load_killed => false, + :load_spam => false, + :when_done => lambda { |num| + BufferManager.flash "Added #{num} threads." + } + end + + def reload + drop_all_threads + BufferManager.draw_screen + load_more_threads buffer.content_height + end +end + +end diff --git a/lib/sup/modes/label-list-mode.rb b/lib/sup/modes/label-list-mode.rb new file mode 100644 index 0000000..80f584c --- /dev/null +++ b/lib/sup/modes/label-list-mode.rb @@ -0,0 +1,89 @@ +module Redwood + +class LabelListMode < LineCursorMode + register_keymap do |k| + k.add :view_results, "View messages with the selected label", :enter + k.add :reload, "Reload", "R" + end + + def initialize + @labels = [] + @text = [] + super() + end + + def lines; @text.length; end + def [] i; @text[i]; end + + def load; regen_text; end + + def load_in_background + ::Thread.new do + regen_text do |i| + if i % 10 == 0 + buffer.mark_dirty + BufferManager.draw_screen + sleep 0.1 # ok, dirty trick. + end + end + buffer.mark_dirty + BufferManager.draw_screen + end + end + +protected + + def reload + buffer.mark_dirty + BufferManager.draw_screen + load_in_background + end + + def regen_text + @text = [] + @labels = LabelManager::LISTABLE_LABELS.sort_by { |t| t.to_s } + + LabelManager.user_labels.sort_by { |t| t.to_s } + + counts = @labels.map do |t| + total = Index.num_results_for :label => t + unread = Index.num_results_for :labels => [t, :unread] + [t, total, unread] + end + + width = @labels.map { |t| t.to_s.length }.max + + counts.map_with_index do |(t, total, unread), i| + if total == 0 && !LabelManager::LISTABLE_LABELS.include?(t) + Redwood::log "no hits for label #{t}, deleting" + LabelManager.delete t + @labels.delete t + next + end + + label = + case t + when *LabelManager::LISTABLE_LABELS + t.to_s.ucfirst + else + t.to_s + end + @text << [[(unread == 0 ? :labellist_old_color : :labellist_new_color), + sprintf("%#{width + 1}s %5d %s, %5d unread", label, total, total == 1 ? " message" : "messages", unread)]] + yield i if block_given? + end.compact + end + + def view_results + label = @labels[curpos] + if label == :inbox + BufferManager.raise_to_front BufferManager["inbox"] + else + b = BufferManager.spawn_unless_exists(label) do + mode = LabelSearchResultsMode.new [label] + end + b.mode.load_more_threads b.content_height + end + end +end + +end diff --git a/lib/sup/modes/label-search-results-mode.rb b/lib/sup/modes/label-search-results-mode.rb new file mode 100644 index 0000000..3f49e27 --- /dev/null +++ b/lib/sup/modes/label-search-results-mode.rb @@ -0,0 +1,29 @@ +module Redwood + +class LabelSearchResultsMode < ThreadIndexMode + register_keymap do |k| + k.add :load_more_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M' + end + + def initialize labels + @labels = labels + super + end + + def is_relevant? m; @labels.all? { |l| m.has_label? l }; end + + def load_more_threads n=ThreadIndexMode::LOAD_MORE_THREAD_NUM + load_n_threads_background n, :labels => @labels, + :load_killed => true, + :load_spam => false, + :when_done =>(lambda do |num| + if num > 0 + BufferManager.flash "Found #{num} threads" + else + BufferManager.flash "No matches" + end + end) + end +end + +end diff --git a/lib/sup/modes/line-cursor-mode.rb b/lib/sup/modes/line-cursor-mode.rb new file mode 100644 index 0000000..f8a46e9 --- /dev/null +++ b/lib/sup/modes/line-cursor-mode.rb @@ -0,0 +1,133 @@ +module Redwood + +class LineCursorMode < ScrollMode + register_keymap do |k| + ## overwrite scrollmode binding on arrow keys for cursor movement + ## but j and k still scroll! + k.add :cursor_down, "Move cursor down one line", :down, 'j' + k.add :cursor_up, "Move cursor up one line", :up, 'k' + k.add :select, "Select this item", :enter + end + + attr_reader :curpos + + def initialize cursor_top=0, opts={} + @cursor_top = cursor_top + @curpos = cursor_top + super opts + end + + def draw + super + set_status + end + +protected + + def draw_line ln, opts={} + if ln == @curpos + super ln, :highlight => true, :debug => opts[:debug] + else + super + end + end + + def ensure_mode_validity + super + raise @curpos.inspect unless @curpos.is_a?(Integer) + c = @curpos.clamp topline, botline - 1 + c = @cursor_top if c < @cursor_top + buffer.mark_dirty unless c == @curpos + @curpos = c + end + + def set_cursor_pos p + return if @curpos == p + @curpos = p.clamp @cursor_top, lines + buffer.mark_dirty + end + + def line_down # overwrite scrollmode + super + set_cursor_pos topline if @curpos < topline + end + + def line_up # overwrite scrollmode + super + set_cursor_pos botline - 1 if @curpos > botline - 1 + end + + def cursor_down + return false unless @curpos < lines - 1 + if @curpos >= botline - 1 + page_down + set_cursor_pos [topline + 1, botline].min + else + @curpos += 1 + unless buffer.dirty? + draw_line @curpos - 1 + draw_line @curpos + set_status + buffer.commit + end + end + true + end + + def cursor_up + return false unless @curpos > @cursor_top + if @curpos == topline + page_up + set_cursor_pos [botline - 2, topline].max +# raise "cursor position now #@curpos, topline #{topline} botline #{botline}" + else + @curpos -= 1 + unless buffer.dirty? + draw_line @curpos + 1 + draw_line @curpos + set_status + buffer.commit + end + end + true + end + + def page_up # overwrite + if topline <= @cursor_top + set_cursor_pos @cursor_top + else + relpos = @curpos - topline + super + set_cursor_pos topline + relpos + end + end + + def page_down + if topline >= lines - buffer.content_height + set_cursor_pos(lines - 1) + else + relpos = @curpos - topline + super + set_cursor_pos [topline + relpos, lines - 1].min + end + end + + def jump_to_home + super + set_cursor_pos @cursor_top + end + + def jump_to_end + super if topline < (lines - buffer.content_height) + set_cursor_pos(lines - 1) + end + +private + + def set_status + @status = "line #{@curpos + 1} of #{lines}" + end + +end + +end diff --git a/lib/sup/modes/log-mode.rb b/lib/sup/modes/log-mode.rb new file mode 100644 index 0000000..f71c0ed --- /dev/null +++ b/lib/sup/modes/log-mode.rb @@ -0,0 +1,44 @@ +module Redwood + +class LogMode < TextMode + register_keymap do |k| + k.add :toggle_follow, "Toggle follow mode", 'f' + end + + def initialize + @follow = true + super "" + end + + def toggle_follow + @follow = !@follow + if buffer + if @follow + jump_to_line lines - buffer.content_height + 1 # leave an empty line at bottom + end + buffer.mark_dirty + end + end + + def text= t + super + if buffer && @follow + follow_top = lines - buffer.content_height + 1 + jump_to_line follow_top if topline < follow_top + end + end + + def << line + super + if buffer && @follow + follow_top = lines - buffer.content_height + 1 + jump_to_line follow_top if topline < follow_top + end + end + + def status + super + " (follow: #@follow)" + end +end + +end diff --git a/lib/sup/modes/person-search-results-mode.rb b/lib/sup/modes/person-search-results-mode.rb new file mode 100644 index 0000000..8fe3c30 --- /dev/null +++ b/lib/sup/modes/person-search-results-mode.rb @@ -0,0 +1,29 @@ +module Redwood + +class PersonSearchResultsMode < ThreadIndexMode + register_keymap do |k| + k.add :load_more_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M' + end + + def initialize people + @people = people + super + end + + def is_relevant? m; @people.any? { |p| m.from == p }; end + + def load_more_threads n=ThreadIndexMode::LOAD_MORE_THREAD_NUM + load_n_threads_background n, :participants => @people, + :load_killed => true, + :load_spam => false, + :when_done =>(lambda do |num| + if num > 0 + BufferManager.flash "Found #{num} threads" + else + BufferManager.flash "No matches" + end + end) + end +end + +end diff --git a/lib/sup/modes/poll-mode.rb b/lib/sup/modes/poll-mode.rb new file mode 100644 index 0000000..8aaad03 --- /dev/null +++ b/lib/sup/modes/poll-mode.rb @@ -0,0 +1,24 @@ +module Redwood + +class PollMode < LogMode + def initialize + @new = true + super + end + + def puts s="" + self << s + "\n" +# if lines % 5 == 0 + BufferManager.draw_screen +# end + end + + def poll + puts unless @new + @new = false + puts "poll started at #{Time.now}" + PollManager.poll { |s| puts s } + end +end + +end diff --git a/lib/sup/modes/reply-mode.rb b/lib/sup/modes/reply-mode.rb new file mode 100644 index 0000000..d2dae22 --- /dev/null +++ b/lib/sup/modes/reply-mode.rb @@ -0,0 +1,136 @@ +module Redwood + +class ReplyMode < EditMessageMode + REPLY_TYPES = [:sender, :list, :all, :user] + TYPE_DESCRIPTIONS = { + :sender => "Reply to sender", + :all => "Reply to all", + :list => "Reply to mailing list", + :user => "Customized reply" + } + + register_keymap do |k| + k.add :move_cursor_right, "Move cursor to the right", :right + k.add :move_cursor_left, "Move cursor to the left", :left + end + + def initialize message + super 2, :twiddles => false + @m = message + + from = + if @m.recipient_email + AccountManager.account_for(@m.recipient_email) + else + (@m.to + @m.cc).argfind { |p| AccountManager.is_account? p } + end || AccountManager.default_account + + from_email = @m.recipient_email || from.email + + ## ignore reply-to for list messages because it's typically set to + ## the list address anyways + to = @m.is_list_message? ? @m.from : (@m.replyto || @m.from) + cc = (@m.to + @m.cc - [from, to]).uniq + + @headers = {} + @headers[:sender] = { + "From" => "#{from.name} <#{from_email}>", + "To" => [to.full_address], + } + + @headers[:user] = { + "From" => "#{from.name} <#{from_email}>", + "To" => "", + } + + @headers[:all] = { + "From" => "#{from.name} <#{from_email}>", + "To" => [to.full_address], + "Cc" => cc.map { |p| p.full_address }, + } unless cc.empty? + + @headers[:list] = { + "From" => "#{from.name} <#{from_email}>", + "To" => [@m.list_address.full_address], + } if @m.is_list_message? + + refs = gen_references + mid = gen_message_id + @headers.each do |k, v| + @headers[k] = v.merge({ + "In-Reply-To" => "<#{@m.id}>", + "Subject" => Message.reify_subj(@m.subj), + "Message-Id" => mid, + "References" => refs, + }) + end + + @type_labels = REPLY_TYPES.select { |t| @headers.member?(t) } + @selected_type = @m.is_list_message? ? :list : :sender + + @body = reply_body_lines(message) + sig_lines + regen_text + end + + def lines; @text.length + 2; end + def [] i + case i + when 0 + lame = [] + @type_labels.each do |t| + lame << [(t == @selected_type ? :none_highlight : :none), + "#{TYPE_DESCRIPTIONS[t]}"] + lame << [:none, " "] + end + lame + [[:none, ""]] + when 1 + "" + else + @text[i - 2] + end + end + +protected + + def body; @body; end + def header; @headers[@selected_type]; end + + def reply_body_lines m + lines = ["Excerpts from #{@m.from.name}'s message of #{@m.date}:"] + + m.basic_body_lines.map { |l| "> #{l}" } + lines.pop while lines.last !~ /[:alpha:]/ + lines + end + + def handle_new_text new_header, new_body + @body = new_body + + if new_header.size != header.size || + header.any? { |k, v| new_header[k] != v } + @selected_type = :user + @headers[:user] = new_header + end + end + + def regen_text + @text = header_lines(@headers[@selected_type] - NON_EDITABLE_HEADERS) + [""] + @body + end + + def gen_references + (@m.refs + [@m.id]).map { |x| "<#{x}>" }.join(" ") + end + + def move_cursor_left + i = @type_labels.index @selected_type + @selected_type = @type_labels[(i - 1) % @type_labels.length] + update + end + + def move_cursor_right + i = @type_labels.index @selected_type + @selected_type = @type_labels[(i + 1) % @type_labels.length] + update + end +end + +end diff --git a/lib/sup/modes/resume-mode.rb b/lib/sup/modes/resume-mode.rb new file mode 100644 index 0000000..b8b06b2 --- /dev/null +++ b/lib/sup/modes/resume-mode.rb @@ -0,0 +1,18 @@ +module Redwood + +class ResumeMode < ComposeMode + def initialize m + super() + @id = m.id + @header, @body = parse_file m.draft_filename + @header.delete "Date" + @header["Message-Id"] = gen_message_id # generate a new'n + regen_text + end + + def send_message + DraftManager.discard @id if super + end +end + +end diff --git a/lib/sup/modes/scroll-mode.rb b/lib/sup/modes/scroll-mode.rb new file mode 100644 index 0000000..32693a0 --- /dev/null +++ b/lib/sup/modes/scroll-mode.rb @@ -0,0 +1,106 @@ +module Redwood + +class ScrollMode < Mode + attr_reader :status, :topline, :botline + + COL_JUMP = 2 + + register_keymap do |k| + k.add :line_down, "Down one line", :down, 'j', 'J' + k.add :line_up, "Up one line", :up, 'k', 'K' + k.add :col_left, "Left one column", :left, 'h' + k.add :col_right, "Right one column", :right, 'l' + k.add :page_down, "Down one page", :page_down, 'n', ' ' + k.add :page_up, "Up one page", :page_up, 'p', :backspace + k.add :jump_to_home, "Jump to top", :home, '^', '1' + k.add :jump_to_end, "Jump to bottom", :end, '$', '0' + end + + def initialize opts={} + @topline, @botline, @leftcol = 0, 0, 0 + @slip_rows = opts[:slip_rows] || 0 # when we pgup/pgdown, + # how many lines do we keep? + @twiddles = opts.member?(:twiddles) ? opts[:twiddles] : true + super() + end + + def draw + ensure_mode_validity + (@topline ... @botline).each { |ln| draw_line ln } + ((@botline - @topline) ... buffer.content_height).each do |ln| + if @twiddles + buffer.write ln, 0, "~", :color => :twiddle_color + else + buffer.write ln, 0, "" + end + end + @status = "lines #{@topline + 1}:#{@botline}/#{lines}" + end + + def col_left + return unless @leftcol > 0 + @leftcol -= COL_JUMP + buffer.mark_dirty + end + + def col_right + @leftcol += COL_JUMP + buffer.mark_dirty + end + + ## set top line to l + def jump_to_line l + l = l.clamp 0, lines - 1 + return if @topline == l + @topline = l + @botline = [l + buffer.content_height, lines].min + buffer.mark_dirty + end + + def line_down; jump_to_line @topline + 1; end + def line_up; jump_to_line @topline - 1; end + def page_down; jump_to_line @topline + buffer.content_height - @slip_rows; end + def page_up; jump_to_line @topline - buffer.content_height + @slip_rows; end + def jump_to_home; jump_to_line 0; end + def jump_to_end; jump_to_line lines - buffer.content_height; end + + def ensure_mode_validity + @topline = @topline.clamp 0, lines - 1 + @topline = 0 if @topline < 0 # empty + @botline = [@topline + buffer.content_height, lines].min + end + +protected + + def draw_line ln, opts={} + case(s = self[ln]) + when String + buffer.write ln - @topline, 0, s[@leftcol .. -1], + :highlight => opts[:highlight] + when Array + xpos = 0 + s.each do |color, text| + raise "nil text for color '#{color}'" if text.nil? + if xpos + text.length < @leftcol + buffer.write ln - @topline, 0, "", :color => color, + :highlight => opts[:highlight] + xpos += text.length + ## nothing + elsif xpos < @leftcol + ## partial + buffer.write ln - @topline, 0, text[(@leftcol - xpos) .. -1], + :color => color, + :highlight => opts[:highlight] + xpos += text.length + else + buffer.write ln - @topline, xpos - @leftcol, text, + :color => color, :highlight => opts[:highlight] + xpos += text.length + end + + end + end + end +end + +end diff --git a/lib/sup/modes/search-results-mode.rb b/lib/sup/modes/search-results-mode.rb new file mode 100644 index 0000000..0a353a9 --- /dev/null +++ b/lib/sup/modes/search-results-mode.rb @@ -0,0 +1,31 @@ +module Redwood + +class SearchResultsMode < ThreadIndexMode + register_keymap do |k| + k.add :load_more_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M' + end + + def initialize content + raise ArgumentError, "no content" if content =~ /^\s*$/ + @content = content.gsub(/[\(\)]/) { |x| "\\" + x } + super + end + + ## TODO: think about this + def is_relevant? m; super; end + + def load_more_threads n=ThreadIndexMode::LOAD_MORE_THREAD_NUM + load_n_threads_background n, :content => @content, + :load_killed => true, + :load_spam => false, + :when_done =>(lambda do |num| + if num > 0 + BufferManager.flash "Found #{num} threads" + else + BufferManager.flash "No matches" + end + end) + end +end + +end diff --git a/lib/sup/modes/text-mode.rb b/lib/sup/modes/text-mode.rb new file mode 100644 index 0000000..f495219 --- /dev/null +++ b/lib/sup/modes/text-mode.rb @@ -0,0 +1,51 @@ +module Redwood + +class TextMode < ScrollMode + attr_reader :text + + def initialize text="" + @text = text + update_lines + buffer.mark_dirty if buffer + super() + end + + def text= t + @text = t + update_lines + if buffer + ensure_mode_validity + buffer.mark_dirty + end + end + + def << line + @lines = [0] if @text.empty? + @text << line + @lines << @text.length + if buffer + ensure_mode_validity + buffer.mark_dirty + end + end + + def lines + @lines.length - 1 + end + + def [] i + return nil unless i < @lines.length + @text[@lines[i] ... (i + 1 < @lines.length ? @lines[i + 1] - 1 : @text.length)] +# (@lines[i] ... (i + 1 < @lines.length ? @lines[i + 1] - 1 : @text.length)).inspect + end + +private + + def update_lines + pos = @text.find_all_positions("\n") + pos.push @text.length unless pos.last == @text.length - 1 + @lines = [0] + pos.map { |x| x + 1 } + end +end + +end diff --git a/lib/sup/modes/thread-index-mode.rb b/lib/sup/modes/thread-index-mode.rb new file mode 100644 index 0000000..6bce14c --- /dev/null +++ b/lib/sup/modes/thread-index-mode.rb @@ -0,0 +1,389 @@ +module Redwood + +class ThreadIndexMode < LineCursorMode + DATE_WIDTH = Time::TO_NICE_S_MAX_LEN + FROM_WIDTH = 15 + LOAD_MORE_THREAD_NUM = 20 + + register_keymap do |k| + k.add :toggle_archived, "Toggle archived status", 'a' + k.add :toggle_starred, "Star or unstar all messages in thread", '*' + k.add :toggle_new, "Toggle new/read status of all messages in thread", 'N' + k.add :edit_labels, "Edit or add labels for a thread", 'l' + k.add :edit_message, "Edit message (drafts only)", 'e' + k.add :mark_as_spam, "Mark thread as spam", 'S' + k.add :kill, "Kill thread (never to be seen in inbox again)", 'K' + k.add :save, "Save changes now", '$' + k.add :jump_to_next_new, "Jump to next new thread", :tab + k.add :reply, "Reply to a thread", 'r' + k.add :forward, "Forward a thread", 'f' + k.add :toggle_tagged, "Tag/untag current line", 't' + k.add :apply_to_tagged, "Apply next command to all tagged threads", ';' + end + + def initialize required_labels=[], hidden_labels=[] + super() + @load_thread = nil + @required_labels = required_labels + @hidden_labels = hidden_labels + LabelManager::HIDDEN_LABELS + @date_width = DATE_WIDTH + @from_width = FROM_WIDTH + @size_width = nil + + @tags = Tagger.new self + + initialize_threads + update + + UpdateManager.register self + end + + def lines; @text.length; end + def [] i; @text[i]; end + + ## open up a thread view window + def select + this_curpos = curpos + t = @threads[this_curpos] + + ## TODO: don't regen text completely + mode = ThreadViewMode.new t, @hidden_labels + BufferManager.spawn t.subj, mode + end + + def handle_starred_update m + return unless(t = @ts.thread_for m) + @starred_cache[t] = t.has_label? :starred + update_text_for_line @lines[t] + end + + def handle_read_update m + return unless(t = @ts.thread_for m) + @new_cache[t] = false + update_text_for_line @lines[t] + end + + ## overwrite me! + def is_relevant? m; false; end + + def handle_add_update m + if is_relevant?(m) || @ts.is_relevant?(m) + @ts.load_thread_for_message m + update + end + end + + def handle_delete_update mid + if @ts.contains_id? mid + @ts.remove mid + update + end + end + + def update + ## let's see you do THIS in python + @threads = @ts.threads.select { |t| !@hidden_threads[t] }.sort_by { |t| t.date }.reverse + @size_width = (@threads.map { |t| t.size }.max || 0).num_digits + regen_text + end + + def edit_message + t = @threads[curpos] or return + message, *crap = t.find { |m, *o| m.has_label? :draft } + if message + mode = ResumeMode.new message + BufferManager.spawn "Edit message", mode + else + BufferManager.flash "Not a draft message!" + end + end + + def toggle_starred + t = @threads[curpos] or return + @starred_cache[t] = t.toggle_label :starred + update_text_for_line curpos + cursor_down + end + + def multi_toggle_starred threads + threads.each { |t| @starred_cache[t] = t.toggle_label :starred } + regen_text + end + + def toggle_archived + return unless(t = @threads[curpos]) + t.toggle_label :inbox + update_text_for_line curpos + cursor_down + end + + def multi_toggle_archived threads + threads.each { |t| t.toggle_label :inbox } + regen_text + end + + def toggle_new + t = @threads[curpos] or return + @new_cache[t] = t.toggle_label :unread + update_text_for_line curpos + cursor_down + end + + def multi_toggle_new threads + threads.each { |t| @new_cache[t] = t.toggle_label :unread } + regen_text + end + + def multi_toggle_tagged threads + @tags.drop_all_tags + regen_text + end + + def jump_to_next_new + t = @threads[curpos] or return + n = ((curpos + 1) .. lines).find { |i| @new_cache[@threads[i]] } + n = (0 ... curpos).find { |i| @new_cache[@threads[i]] } unless n + if n + set_cursor_pos n + else + BufferManager.flash "No new messages" + end + end + + def mark_as_spam + t = @threads[curpos] or return + multi_mark_as_spam [t] + end + + def multi_mark_as_spam threads + threads.each do |t| + t.apply_label :spam + hide_thread t + end + regen_text + end + + def kill + t = @threads[curpos] or return + multi_kill [t] + end + + def multi_kill threads + threads.each do |t| + t.apply_label :killed + hide_thread t + end + regen_text + end + + def save + threads = @threads + @hidden_threads.keys + mbid = BufferManager.say "Saving threads..." + threads.each_with_index do |t, i| + BufferManager.say "Saving thread #{i + 1} of #{threads.length}...", + mbid + t.save Index + end + BufferManager.clear mbid + end + + def cleanup + UpdateManager.unregister self + + if @load_thread + @load_thread.kill + BufferManager.clear @mbid if @mbid + sleep 0.1 # TODO: necessary? + BufferManager.erase_flash + end + save + super + end + + def toggle_tagged + t = @threads[curpos] or return + @tags.toggle_tag_for t + update_text_for_line curpos + cursor_down + end + + def apply_to_tagged; @tags.apply_to_tagged; end + + def edit_labels + thread = @threads[curpos] + speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq + keepl, modifyl = thread.labels.partition { |t| speciall.member? t } + label_string = modifyl.join(" ") + + answer = BufferManager.ask :edit_labels, "edit labels: ", label_string + return unless answer + user_labels = answer.split(/\s+/).map { |l| l.intern } + + hl = user_labels.select { |l| speciall.member? l } + if hl.empty? + thread.labels = keepl + user_labels + user_labels.each { |l| LabelManager << l } + else + BufferManager.flash "'#{hl}' is a reserved label!" + end + update_text_for_line curpos + end + + def multi_edit_labels threads + answer = BufferManager.ask :add_labels, "add labels: " + return unless answer + user_labels = answer.split(/\s+/).map { |l| l.intern } + + hl = user_labels.select { |l| @hidden_labels.member? l } + if hl.empty? + threads.each { |t| user_labels.each { |l| t.apply_label l } } + user_labels.each { |l| LabelManager << l } + else + BufferManager.flash "'#{hl}' is a reserved label!" + end + regen_text + end + + def reply + t = @threads[curpos] or return + m = t.latest_message + return if m.nil? # probably won't happen + mode = ReplyMode.new m + BufferManager.spawn "Reply to #{m.subj}", mode + end + + def forward + t = @threads[curpos] or return + m = t.latest_message + return if m.nil? # probably won't happen + mode = ForwardMode.new m + BufferManager.spawn "Forward of #{m.subj}", mode + mode.edit + end + + def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={} + return if @load_thread + @load_thread = ::Thread.new do + begin + num = load_n_threads n, opts + opts[:when_done].call(num) if opts[:when_done] + rescue Exception => e + $exception ||= e + raise + end + @load_thread = nil + end + end + + def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={} + @mbid = BufferManager.say "Searching for threads..." + orig_size = @ts.size + @ts.load_n_threads(@ts.size + n, opts) do |i| + BufferManager.say "Loaded #{i} threads...", @mbid + if i % 5 == 0 + update + BufferManager.draw_screen + end + end + @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } } + + update + BufferManager.clear @mbid + @mbid = nil + + BufferManager.draw_screen + + @ts.size - orig_size + end + + def status + "line #{curpos + 1} of #{lines} #{dirty? ? '*modified*' : ''}" + end + +protected + + def cursor_thread; @threads[curpos]; end + + def drop_all_threads + @tags.drop_all_tags + initialize_threads + update + end + + def remove_label_and_hide_thread t, label + t.remove_label label + hide_thread t + end + + def hide_thread t + raise "already hidden" if @hidden_threads[t] + @hidden_threads[t] = true + @threads.delete t + @tags.drop_tag_for t + end + + def update_text_for_line l + @text[l] = text_for_thread @threads[l] + buffer.mark_dirty if buffer + end + + def regen_text + @text = @threads.map_with_index { |t, i| text_for_thread t } + @lines = @threads.map_with_index { |t, i| [t, i] }.to_h + buffer.mark_dirty if buffer + end + + def author_text_for_thread t + if t.authors.size == 1 + t.authors.first.mediumname + else + t.authors.map { |p| AccountManager.is_account?(p) ? "me" : p.shortname }.join ", " + end + end + + def text_for_thread t + date = (@date_cache[t] ||= t.date.to_nice_s(Time.now)) + from = (@who_cache[t] ||= author_text_for_thread(t)) + if from.length > @from_width + from = from[0 ... (@from_width - 1)] + from += "." unless from[-1] == ?\s + end + + new = @new_cache.member?(t) ? @new_cache[t] : @new_cache[t] = t.has_label?(:unread) + starred = @starred_cache.member?(t) ? @starred_cache[t] : @starred_cache[t] = t.has_label?(:starred) + + dp = (@dp_cache[t] ||= t.direct_participants.any? { |p| AccountManager.is_account? p }) + p = (@p_cache[t] ||= (dp || t.participants.any? { |p| AccountManager.is_account? p })) + + base_color = (new ? :index_new_color : :index_old_color) + [ + [:tagged_color, @tags.tagged?(t) ? ">" : " "], + [:none, sprintf("%#{@date_width}s ", date)], + [base_color, sprintf("%-#{@from_width}s ", from)], + [:starred_color, starred ? "*" : " "], + [:none, t.size == 1 ? " " * (@size_width + 2) : sprintf("(%#{@size_width}d)", t.size)], + [:to_me_color, dp ? " >" : (p ? ' -' : " ")], + [base_color, t.subj] + ] + + (t.labels - @hidden_labels).map { |label| [:label_color, " +#{label}"] } + + [[:snippet_color, " " + t.snippet] + ] + end + + def dirty?; (@hidden_threads.keys + @threads).any? { |t| t.dirty? }; end + +private + + def initialize_threads + @ts = ThreadSet.new Index.instance + @date_cache = {} + @who_cache = {} + @dp_cache = {} + @p_cache = {} + @new_cache = {} + @starred_cache = {} + @hidden_threads = {} + end +end + +end diff --git a/lib/sup/modes/thread-view-mode.rb b/lib/sup/modes/thread-view-mode.rb new file mode 100644 index 0000000..3eaa481 --- /dev/null +++ b/lib/sup/modes/thread-view-mode.rb @@ -0,0 +1,338 @@ +module Redwood + +class ThreadViewMode < LineCursorMode + DATE_FORMAT = "%B %e %Y %l:%M%P" + + register_keymap do |k| + k.add :toggle_detailed_header, "Toggle detailed header", 'd' + k.add :show_header, "Show full message header", 'H' + k.add :toggle_expanded, "Expand/collapse item", :enter + k.add :expand_all_messages, "Expand/collapse all messages", 'E' + k.add :edit_message, "Edit message (drafts only)", 'e' + k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o' + k.add :jump_to_next_open, "Jump to next open message", 'n' + k.add :jump_to_prev_open, "Jump to previous open message", 'p' + k.add :toggle_starred, "Star or unstar message", '*' + k.add :collapse_non_new_messages, "Collapse all but new messages", 'N' + k.add :reply, "Reply to a message", 'r' + k.add :forward, "Forward a message", 'f' + end + + def initialize thread, hidden_labels=[] + super() + @thread = thread + @state = {} + @hidden_labels = hidden_labels + + earliest = nil + latest = nil + latest_date = nil + @thread.each do |m, d, p| + next unless m + earliest ||= m + @state[m] = + if m.has_label?(:unread) && m == earliest + :detailed + elsif m.has_label?(:starred) || m.has_label?(:unread) + :open + else + :closed + end + if latest_date.nil? || m.date > latest_date + latest_date = m.date + latest = m + end + end + @state[latest] = :open if @state[latest] == :closed + + regen_chunks + regen_text + end + + def draw_line ln, opts={} + if ln == curpos + super ln, :highlight => true + else + super + end + end + def lines; @text.length; end + def [] i; @text[i]; end + + def show_header + return unless(m = @message_lines[curpos]) + BufferManager.spawn_unless_exists("Full header") do + TextMode.new m.content #m.header_text + end + end + + def toggle_detailed_header + return unless(m = @message_lines[curpos]) + @state[m] = (@state[m] == :detailed ? :open : :detailed) + update + end + + def reply + return unless(m = @message_lines[curpos]) + mode = ReplyMode.new m + BufferManager.spawn "Reply to #{m.subj}", mode + end + + def forward + return unless(m = @message_lines[curpos]) + mode = ForwardMode.new m + BufferManager.spawn "Forward of #{m.subj}", mode + mode.edit + end + + def toggle_starred + return unless(m = @message_lines[curpos]) + if m.has_label? :starred + m.remove_label :starred + else + m.add_label :starred + end + ## TODO: don't recalculate EVERYTHING just to add a stupid little + ## star to the display + update + UpdateManager.relay :starred, m + end + + def toggle_expanded + return unless(chunk = @chunk_lines[curpos]) + case chunk + when Message, Message::Quote, Message::Signature + @state[chunk] = (@state[chunk] != :closed ? :closed : :open) + when Message::Attachment + view_attachment chunk + end + update + end + + def edit_message + return unless(m = @message_lines[curpos]) + if m.is_draft? + mode = ResumeMode.new m + BufferManager.spawn "Edit message", mode + else + BufferManager.flash "Not a draft message!" + end + end + + def jump_to_next_open + return unless(m = @message_lines[curpos]) + while nextm = @messages[m][3] + break if @state[nextm] == :open + m = nextm + end + jump_to_message nextm if nextm + end + + def jump_to_prev_open + return unless(m = @message_lines[curpos]) + ## jump to the top of the current message if we're in the body; + ## otherwise, to the previous message + top = @messages[m][0] + if curpos == top + while prevm = @messages[m][2] + break if @state[prevm] == :open + m = prevm + end + jump_to_message prevm if prevm + else + jump_to_message m + end + end + + def jump_to_message m + top, bot, prevm, nextm = @messages[m] + jump_to_line top unless top >= topline && + top <= botline && bot >= topline && bot <= botline + set_cursor_pos top + end + + def expand_all_messages + @global_message_state ||= :closed + @global_message_state = (@global_message_state == :closed ? :open : :closed) + @state.each { |m, v| @state[m] = @global_message_state if m.is_a? Message } + update + end + + + def collapse_non_new_messages + @messages.each { |m, v| @state[m] = m.has_label?(:unread) ? :open : :closed } + update + end + + def expand_all_quotes + if(m = @message_lines[curpos]) + quotes = @chunks[m].select { |c| c.is_a?(Message::Quote) || c.is_a?(Message::Signature) } + open, closed = quotes.partition { |c| @state[c] == :open } + newstate = open.length > closed.length ? :closed : :open + Redwood::log "#{open.length} opened, #{closed.length} closed, new state is thus #{newstate}" + quotes.each { |c| @state[c] = newstate } + update + end + end + + ## not sure if this is really necessary but we might as well... + def cleanup + @thread.each do |m, d, p| + if m.has_label? :unread + m.remove_label :unread + UpdateManager.relay :read, m + end + end + + Redwood::log "releasing chunks and text from \"#{buffer.title}\"" + @messages = @chunks = @text = nil + end + +private + + def update + regen_text + buffer.mark_dirty if buffer + end + + def regen_chunks + @chunks = {} + @thread.each { |m, d, p| @chunks[m] = m.to_chunks if m.is_a?(Message) } + end + + def regen_text + @text = [] + @chunk_lines = [] + @message_lines = [] + @messages = {} + + prev_m = nil + @thread.each do |m, depth, parent| + text = chunk_to_lines m, @state[m], @text.length, depth, parent + (0 ... text.length).each do |i| + @chunk_lines[@text.length + i] = m + @message_lines[@text.length + i] = m + end + + @messages[m] = [@text.length, @text.length + text.length, prev_m, nil] + @messages[prev_m][3] = m if prev_m + prev_m = m + + @text += text + if @state[m] != :closed && @chunks.member?(m) + @chunks[m].each do |c| + @state[c] ||= :closed + text = chunk_to_lines c, @state[c], @text.length, depth + (0 ... text.length).each do |i| + @chunk_lines[@text.length + i] = c + @message_lines[@text.length + i] = m + end + @text += text + end + @messages[m][1] = @text.length + end + end + end + + def message_patina_lines m, state, parent, prefix + prefix_widget = [:message_patina_color, prefix] + widget = + case state + when :closed + [:message_patina_color, "+ "] + when :open, :detailed + [:message_patina_color, "- "] + end + imp_widget = + if m.has_label?(:starred) + [:starred_patina_color, "* "] + else + [:message_patina_color, " "] + end + + case state + when :open + [[prefix_widget, widget, imp_widget, + [:message_patina_color, + "#{m.from ? m.from.mediumname : '?'} to #{m.to.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]] +# (m.to.empty? ? [] : [[[:message_patina_color, prefix + " To: " + m.recipients.map { |x| x.mediumname }.join(", ")]]]) + + when :closed + [[prefix_widget, widget, imp_widget, + [:message_patina_color, + "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s}) #{m.snippet}"]]] + when :detailed + labels = m.labels# - @hidden_labels + x = [[prefix_widget, widget, imp_widget, [:message_patina_color, "From: #{m.from ? m.from.longname : '?'}"]]] + + ((m.to.empty? ? [] : break_into_lines(" To: ", m.to.map { |x| x.longname })) + + (m.cc.empty? ? [] : break_into_lines(" Cc: ", m.cc.map { |x| x.longname })) + + (m.bcc.empty? ? [] : break_into_lines(" Bcc: ", m.bcc.map { |x| x.longname })) + + [" Date: #{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"] + + [" Subject: #{m.subj}"] + + [(parent ? " In reply to: #{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}" : nil)] + + [labels.empty? ? nil : " Labels: #{labels.join(', ')}"] + ).flatten.compact.map { |l| [[:message_patina_color, prefix + " " + l]] } + #raise x.inspect + x + end + end + + def break_into_lines prefix, list + pad = " " * prefix.length + [prefix + list.first + (list.length > 1 ? "," : "")] + + list[1 .. -1].map_with_index do |e, i| + pad + e + (i == list.length - 1 ? "" : ",") + end + end + + + def chunk_to_lines chunk, state, start, depth, parent=nil + prefix = " " * depth + case chunk + when :fake_root + [[[:message_patina_color, "#{prefix}"]]] + when nil + [[[:message_patina_color, "#{prefix}"]]] + when Message + message_patina_lines(chunk, state, parent, prefix) + + (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. To edit, hit 'e'. <<<"]]] : []) + + when Message::Attachment + [[[:mime_color, "#{prefix}+ MIME attachment #{chunk.content_type}#{chunk.desc ? ' (' + chunk.desc + ')': ''}"]]] + when Message::Text + t = chunk.lines + if t.last =~ /^\s*$/ + t.pop while t[t.length - 2] =~ /^\s*$/ + end + t.map { |line| [[:none, "#{prefix}#{line}"]] } + when Message::Quote + case state + when :closed + [[[:quote_patina_color, "#{prefix}+ #{chunk.lines.length} quoted lines"]]] + when :open + t = chunk.lines + [[[:quote_patina_color, "#{prefix}- #{chunk.lines.length} quoted lines"]]] + + t.map { |line| [[:quote_color, "#{prefix}#{line}"]] } + end + when Message::Signature + case state + when :closed + [[[:sig_patina_color, "#{prefix}+ #{chunk.lines.length}-line signature"]]] + when :open + t = chunk.lines + [[[:sig_patina_color, "#{prefix}- #{chunk.lines.length}-line signature"]]] + + t.map { |line| [[:sig_color, "#{prefix}#{line}"]] } + end + else + raise "unknown chunk type #{chunk.class.name}" + end + end + + def view_attachment a + BufferManager.flash "viewing #{a.content_type} attachment..." + a.view! + BufferManager.erase_flash + end + +end + +end diff --git a/lib/sup/person.rb b/lib/sup/person.rb new file mode 100644 index 0000000..38fba31 --- /dev/null +++ b/lib/sup/person.rb @@ -0,0 +1,120 @@ +module Redwood + +class Person + @@email_map = {} + + attr_accessor :name, :email + + def initialize name, email + raise ArgumentError, "email can't be nil" unless email + @name = + if name + name.gsub(/^\s+|\s+$/, "").gsub(/\s+/, " ") + else + nil + end + @email = email.gsub(/^\s+|\s+$/, "").gsub(/\s+/, " ").downcase + @@email_map[@email] = self + end + + def == o; o && o.email == email; end + alias :eql? :== + + def hash + [name, email].hash + end + + def shortname + case @name + when /\S+, (\S+)/ + $1 + when /(\S+) \S+/ + $1 + when nil + @email #[0 ... 10] + else + @name #[0 ... 10] + end + end + + def longname + if @name && @email + "#@name <#@email>" + else + @email + end + end + + def mediumname + if @name + name + else + @email + end + end + + def full_address + if @name && @email + if @name =~ /"/ + "#{@name.inspect} <#@email>" + else + "#@name <#@email>" + end + else + @email + end + end + + def sort_by_me + case @name + when /^(\S+), \S+/ + $1 + when /^\S+ \S+ (\S+)/ + $1 + when /^\S+ (\S+)/ + $1 + when nil + @email + else + @name + end.downcase + end + + def self.for_several s + return [] if s.nil? + + begin + s.split_on_commas.map { |ss| self.for ss } + rescue StandardError => e + raise "#{e.message}: for #{s.inspect}" + end + end + + def self.for s + return nil if s.nil? + name, email = + case s + when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/ + a, b = $1, $2 + [a.gsub('\"', '"'), b] + when /<((\S+?)@\S+?)>/ + [$2, $1] + when /((\S+?)@\S+)/ + [$2, $1] + else + [nil, s] + end + + if name && (p = @@email_map[email]) + ## all else being equal, prefer longer names, unless the prior name + ## doesn't contain any capitalization + p.name = name if (p.name.nil? || p.name.length < name.length) unless + p.name =~ /[A-Z]/ || (AccountManager.instantiated? && AccountManager.is_account?(p)) + p + else + Person.new name, email + end + end +end + +end diff --git a/lib/sup/poll.rb b/lib/sup/poll.rb new file mode 100644 index 0000000..b6dfabc --- /dev/null +++ b/lib/sup/poll.rb @@ -0,0 +1,80 @@ +require 'thread' + +module Redwood + +class PollManager + include Singleton + + DELAY = 300 + + def initialize + @polling = false + @last_poll = nil + + self.class.i_am_the_instance self + + ::Thread.new do + while true + sleep DELAY / 2 + if @last_poll.nil? || (Time.now - @last_poll) >= DELAY + mbid = BufferManager.say "Polling for new messages..." + num, numi = poll { |s| BufferManager.say s, mbid } + BufferManager.clear mbid + BufferManager.flash "Loaded #{num} new messages, #{numi} to inbox." if num > 0 + end + end + end + end + + def poll + return [0, 0] if @polling + @polling = true + found = {} + total_num = 0 + total_numi = 0 + Index.usual_sources.each do |source| + next if source.done? + yield "Loading from #{source}... " + + start_offset = nil + num = 0 + num_inbox = 0 + source.each do |offset, labels| + start_offset ||= offset + + begin + m = Redwood::Message.new source, offset, labels + if found[m.id] + yield "Skipping duplicate message #{m.id}" + next + else + found[m.id] = true + end + + if Index.add_message m + UpdateManager.relay :add, m + num += 1 + total_num += 1 + total_numi += 1 if m.labels.include? :inbox + end + rescue Redwood::MessageFormatError => e + yield "Ignoring erroneous message at #{source}##{offset}: #{e.message}" + end + + if num % 1000 == 0 && num > 0 + elapsed = Time.now - start + pctdone = (offset.to_f - start_offset) / (source.total.to_f - start_offset) + remaining = (source.total.to_f - offset.to_f) * (elapsed.to_f / (offset.to_f - start_offset)) + yield "## #{num} (#{(pctdone * 100.0)}% done) read; #{elapsed.to_time_s} elapsed; est. #{remaining.to_time_s} remaining" + end + end + yield "Found #{num} messages" unless num == 0 + end + yield "Done polling; loaded #{total_num} new messages total" + @last_poll = Time.now + @polling = false + [total_num, total_numi] + end +end + +end diff --git a/lib/sup/sent.rb b/lib/sup/sent.rb new file mode 100644 index 0000000..6ef9050 --- /dev/null +++ b/lib/sup/sent.rb @@ -0,0 +1,46 @@ +module Redwood + +class SentManager + include Singleton + + attr_accessor :source + def initialize fn + @fn = fn + @source = nil + self.class.i_am_the_instance self + end + + def self.source_name; "sent"; end + def self.source_id; 9998; end + def new_source; @source = SentLoader.new @fn; end + + def write_sent_message date, from_email + need_blank = File.exists?(@fn) && !File.zero?(@fn) + File.open(@fn, "a") do |f| + f.puts if need_blank + f.puts "From #{from_email} #{date}" + yield f + end + @source.each do |offset, labels| + m = Message.new @source, offset, labels + Index.add_message m + UpdateManager.relay :add, m + end + end +end + +class SentLoader < MBox::Loader + def initialize filename, end_offset=0 + File.open(filename, "w") { } unless File.exists? filename + super filename, end_offset, true, true + end + + def id; SentManager.source_id; end + def to_s; SentManager.source_name; end + + def labels; [:sent, :inbox]; end +end + +Redwood::register_yaml(SentLoader, %w(filename end_offset)) + +end diff --git a/lib/sup/tagger.rb b/lib/sup/tagger.rb new file mode 100644 index 0000000..424dac2 --- /dev/null +++ b/lib/sup/tagger.rb @@ -0,0 +1,40 @@ +module Redwood + +class Tagger + def initialize mode + @mode = mode + @tagged = {} + end + + def tagged? o; @tagged[o]; end + def toggle_tag_for o; @tagged[o] = !@tagged[o]; end + def drop_all_tags; @tagged.clear; end + def drop_tag_for o; @tagged.delete o; end + + def apply_to_tagged + num_tagged = @tagged.map { |t| t ? 1 : 0 }.sum + if num_tagged == 0 + BufferManager.flash "No tagged messages!" + return + end + + noun = num_tagged == 1 ? "message" : "messages" + c = BufferManager.ask_getch "apply to #{num_tagged} tagged #{noun}:" + return if c.nil? # user cancelled + + if(action = @mode.resolve_input c) + tagged_sym = "multi_#{action}".intern + if @mode.respond_to? tagged_sym + targets = @tagged.select_by_value + @mode.send tagged_sym, targets + else + BufferManager.flash "That command cannot be applied to multiple messages." + end + else + BufferManager.flash "Unknown command #{c.to_character}." + end + end + +end + +end diff --git a/lib/sup/textfield.rb b/lib/sup/textfield.rb new file mode 100644 index 0000000..337a4fb --- /dev/null +++ b/lib/sup/textfield.rb @@ -0,0 +1,83 @@ +require 'curses' + +module Redwood + +class TextField + attr_reader :value + + def initialize window, y, x, width + @w, @x, @y = window, x, y + @width = width + @i = nil + @history = [] + end + + def activate question, default=nil + @question = question + @value = nil + @field = Ncurses::Form.new_field 1, @width - question.length, + @y, @x + question.length, 0, 0 + @form = Ncurses::Form.new_form [@field] + + @history[@i = @history.size] = default || "" + Ncurses::Form.post_form @form + @field.set_field_buffer 0, @history[@i] + end + + def position_cursor + @w.attrset Colormap.color_for(:none) + @w.mvaddstr @y, 0, @question + Ncurses.curs_set 1 + Ncurses::Form.form_driver @form, Ncurses::Form::REQ_END_FIELD + end + + def deactivate + @form.unpost_form + @form.free_form + @field.free_field + Ncurses.curs_set 0 + end + + def handle_input c + if c == 10 # Ncurses::KEY_ENTER + Ncurses::Form.form_driver @form, Ncurses::Form::REQ_VALIDATION + @value = @history[@i] = @field.field_buffer(0).gsub(/^\s+|\s+$/, "").gsub(/\s+/, " ") + return false + elsif c == Ncurses::KEY_CANCEL + @history.delete_at @i + @i = @history.empty? ? nil : (@i - 1) % @history.size + @value = nil + return false + end + + d = + case c + when Ncurses::KEY_LEFT + Ncurses::Form::REQ_PREV_CHAR + when Ncurses::KEY_RIGHT + Ncurses::Form::REQ_NEXT_CHAR + when Ncurses::KEY_BACKSPACE + Ncurses::Form::REQ_DEL_PREV + when ?\001 + Ncurses::Form::REQ_BEG_FIELD + when ?\005 + Ncurses::Form::REQ_END_FIELD + when Ncurses::KEY_UP + @history[@i] = @field.field_buffer(0) + @i = (@i - 1) % @history.size + @field.set_field_buffer 0, @history[@i] + when Ncurses::KEY_DOWN + @history[@i] = @field.field_buffer(0) + @i = (@i + 1) % @history.size + @field.set_field_buffer 0, @history[@i] + else + c + end + + Ncurses::Form.form_driver @form, d + Ncurses.refresh + + true + end +end +end diff --git a/lib/sup/thread.rb b/lib/sup/thread.rb new file mode 100644 index 0000000..688a033 --- /dev/null +++ b/lib/sup/thread.rb @@ -0,0 +1,358 @@ +require 'date' + +module Redwood + +class Thread + include Enumerable + + attr_reader :containers + def initialize + @containers = [] + end + + def << c + @containers << c + end + + def empty?; @containers.empty?; end + + def drop c + raise "bad drop" unless @containers.member? c + @containers.delete c + end + + def dump + puts "=== start thread #{self} with #{@containers.length} trees ===" + @containers.each { |c| c.dump_recursive } + puts "=== end thread ===" + end + + ## yields each message and some stuff + def each fake_root=false + adj = 0 + root = @containers.find_all { |c| !Message.subj_is_reply?(c) }.argmin { |c| c.date } + + if root + adj = 1 + root.first_useful_descendant.each_with_stuff do |c, d, par| + yield c.message, d, (par ? par.message : nil) + end + elsif @containers.length > 1 && fake_root + adj = 1 + yield :fake_root, 0, nil + end + + @containers.each do |cont| + next if cont == root + fud = cont.first_useful_descendant + fud.each_with_stuff do |c, d, par| + ## special case here: if we're an empty root that's already + ## been joined by a fake root, don't emit + yield c.message, d + adj, (par ? par.message : nil) unless + fake_root && c.message.nil? && root.nil? && c == fud + end + end + end + + def dirty?; any? { |m, *o| m && m.dirty? }; end + def date; map { |m, *o| m.date if m }.compact.max; end + def snippet; argfind { |m, *o| m && m.snippet }; end + def authors; map { |m, *o| m.from if m }.compact.uniq; end + + def apply_label t; each { |m, *o| m && m.add_label(t) }; end + def remove_label t + each { |m, *o| m && m.remove_label(t) } + end + + def toggle_label label + if has_label? label + remove_label label + return false + else + apply_label label + return true + end + end + + def set_labels l; each { |m, *o| m && m.labels = l }; end + + def has_label? t; any? { |m, *o| m && m.has_label?(t) }; end + def save index; each { |m, *o| m && m.save(index) }; end + + def direct_participants + map { |m, *o| [m.from] + m.to if m }.flatten.compact.uniq + end + + def participants + map { |m, *o| [m.from] + m.to + m.cc + m.bcc if m }.flatten.compact.uniq + end + + def size; map { |m, *o| m ? 1 : 0 }.sum; end + def subj; argfind { |m, *o| m && m.subj }; end + def labels + map { |m, *o| m && m.labels }.flatten.compact.uniq.sort_by { |t| t.to_s } + end + def labels= l + each { |m, *o| m && m.labels = l.clone } + end + + def latest_message + inject(nil) do |a, b| + b = b.first + if a.nil? + b + elsif b.nil? + a + else + b.date > a.date ? b : a + end + end + end + + def to_s + "" + end +end + +## recursive structure used internally to represent message trees as +## described by reply-to: and references: headers. +## +## the 'id' field is the same as the message id. but the message might +## be empty, in the case that we represent a message that was referenced +## by another message (as an ancestor) but never received. +class Container + attr_accessor :message, :parent, :children, :id, :thread + + def initialize id + raise "non-String #{id.inspect}" unless id.is_a? String + @id = id + @message, @parent, @thread = nil, nil, nil + @children = [] + end + + def each_with_stuff parent=nil + yield self, 0, parent + @children.each do |c| + c.each_with_stuff(self) { |cc, d, par| yield cc, d + 1, par } + end + end + + def descendant_of? o + if o == self + true + else + @parent && @parent.descendant_of?(o) + end + end + + def == o; Container === o && id == o.id; end + + def empty?; @message.nil?; end + def root?; @parent.nil?; end + def root; root? ? self : @parent.root; end + + def first_useful_descendant + if empty? && @children.size == 1 + @children.first.first_useful_descendant + else + self + end + end + + def find_attr attr + if empty? + @children.argfind { |c| c.find_attr attr } + else + @message.send attr + end + end + def subj; find_attr :subj; end + def date; find_attr :date; end + + def is_reply?; subj && Message.subject_is_reply?(subj); end + + def to_s + [ "<#{id}", + (@parent.nil? ? nil : "parent=#{@parent.id}"), + (@children.empty? ? nil : "children=#{@children.map { |c| c.id }.inspect}"), + ].compact.join(" ") + ">" + end + + def dump_recursive indent=0, root=true, parent=nil + raise "inconsistency" unless parent.nil? || parent.children.include?(self) + unless root + print " " * indent + print "+->" + end + line = #"[#{useful? ? 'U' : ' '}] " + + if @message + "[#{thread}] #{@message.subj} " ##{@message.refs.inspect} / #{@message.replytos.inspect}" + else + "" + end + + puts "#{id} #{line}"#[0 .. (105 - indent)] + indent += 3 + @children.each { |c| c.dump_recursive indent, false, self } + end +end + +## a set of threads (so a forest). builds the thread structures by +## reading messages from an index. +class ThreadSet + attr_reader :num_messages + + def initialize index + @index = index + @num_messages = 0 + @messages = {} ## map from message ids to container objects + @subj_thread = {} ## map from subject strings to thread objects + end + + def contains_id? id; @messages.member?(id) && !@messages[id].empty?; end + def thread_for m + (c = @messages[m.id]) && c.root.thread + end + + def delete_empties + @subj_thread.each { |k, v| @subj_thread.delete(k) if v.empty? } + end + private :delete_empties + + def threads; delete_empties; @subj_thread.values; end + def size; delete_empties; @subj_thread.size; end + + def dump + @subj_thread.each do |s, t| + puts "**********************" + puts "** for subject #{s} **" + puts "**********************" + t.dump + end + end + + def link p, c, overwrite=false + if p == c || p.descendant_of?(c) || c.descendant_of?(p) # would create a loop +# puts "*** linking parent #{p} and child #{c} would create a loop" + return + end + + if c.parent.nil? || overwrite + c.parent.children.delete c if overwrite && c.parent + if c.thread + c.thread.drop c + c.thread = nil + end + p.children << c + c.parent = p + end + end + private :link + + def remove mid + return unless(c = @messages[mid]) + + c.parent.children.delete c if c.parent + if c.thread + c.thread.drop c + c.thread = nil + end + end + + ## load in (at most) num number of threads from the index + def load_n_threads num, opts={} + @index.each_id_by_date opts do |mid, builder| + break if size >= num + next if contains_id? mid + + m = builder.call + add_message m + load_thread_for_message m + yield @subj_thread.size if block_given? + end + end + + ## loads in all messages needed to thread m + def load_thread_for_message m + @index.each_message_in_thread_for m, :limit => 100 do |mid, builder| + next if contains_id? mid + add_message builder.call + end + end + + def is_relevant? m + m.refs.any? { |ref_id| @messages[ref_id] } + end + + ## an "online" version of the jwz threading algorithm. + def add_message message + id = message.id + el = (@messages[id] ||= Container.new id) + return if @messages[id].message # we've seen it before + + el.message = message + oldroot = el.root + + ## link via references: + prev = nil + message.refs.each do |ref_id| + raise "non-String ref id #{ref_id.inspect} (full: #{message.refs.inspect})" unless ref_id.is_a?(String) + ref = (@messages[ref_id] ||= Container.new ref_id) + link prev, ref if prev + prev = ref + end + link prev, el, true if prev + + ## link via in-reply-to: + message.replytos.each do |ref_id| + ref = (@messages[ref_id] ||= Container.new ref_id) + link ref, el, true + break # only do the first one + end + + ## update subject grouping + root = el.root + # puts "> have #{el}, root #{root}, oldroot #{oldroot}" + # el.dump_recursive + + if root == oldroot + if oldroot.thread + # puts "*** root (#{root.subj}) == oldroot (#{oldroot.subj}); ignoring" + else + ## to disable subject grouping, use the next line instead + ## (and the same for below) + thread = (@subj_thread[Message.normalize_subj(root.subj)] ||= Thread.new) + #thread = (@subj_thread[root.id] ||= Thread.new) + + thread << root + root.thread = thread + # puts "# (1) added #{root} to #{thread}" + end + else + if oldroot.thread + ## new root. need to drop old one and put this one in its place + # puts "*** DROPPING #{oldroot} from #{oldroot.thread}" + oldroot.thread.drop oldroot + oldroot.thread = nil + end + + if root.thread + # puts "*** IGNORING cuz root already has a thread" + else + ## to disable subject grouping, use the next line instead + ## (and the same above) + thread = (@subj_thread[Message.normalize_subj(root.subj)] ||= Thread.new) + #thread = (@subj_thread[root.id] ||= Thread.new) + + thread << root + root.thread = thread + # puts "# (2) added #{root} to #{thread}" + end + end + + ## last bit + @num_messages += 1 + end +end + +end diff --git a/lib/sup/update.rb b/lib/sup/update.rb new file mode 100644 index 0000000..75c82ac --- /dev/null +++ b/lib/sup/update.rb @@ -0,0 +1,21 @@ +module Redwood + +class UpdateManager + include Singleton + + def initialize + @targets = {} + self.class.i_am_the_instance self + end + + def register o; @targets[o] = true; end + def unregister o; @targets.delete o; end + + def relay type, *args + meth = "handle_#{type}_update".intern + @targets.keys.each { |o| o.send meth, *args if o.respond_to? meth } + BufferManager.draw_screen ## TODO: think about this + end +end + +end diff --git a/lib/sup/util.rb b/lib/sup/util.rb new file mode 100644 index 0000000..60be36f --- /dev/null +++ b/lib/sup/util.rb @@ -0,0 +1,260 @@ +class Module + def bool_reader *args + args.each { |sym| class_eval %{ def #{sym}?; @#{sym}; end } } + end + def bool_writer *args; attr_writer(*args); end + def bool_accessor *args + bool_reader(*args) + bool_writer(*args) + end +end + +class Object + def ancestors + ret = [] + klass = self.class + + until klass == Object + ret << klass + klass = klass.superclass + end + ret + end +end + +class String + def camel_to_hyphy + self.gsub(/([a-z])([A-Z0-9])/, '\1-\2').downcase + end + + def find_all_positions x + ret = [] + start = 0 + while start < length + pos = index x, start + break if pos.nil? + ret << pos + start = pos + 1 + end + ret + end + + def ucfirst + self[0 .. 0].upcase + self[1 .. -1] + end + + ## found on teh internets + def split_on_commas + split(/,\s*(?=(?:[^"]*"[^"]*")*(?![^"]*"))/) + end + + def wrap len + ret = [] + s = self + while s.length > len + cut = s[0 ... len].rindex(/\s/) + if cut + ret << s[0 ... cut] + "\n" + s = s[(cut + 1) .. -1] + else + ret << s[0 ... len] + "\n" + s = s[len .. -1] + end + end + ret << s + end +end + +class Numeric + def clamp min, max + if self < min + min + elsif self > max + max + else + self + end + end + + def in? range; range.member? self; end +end + +class Fixnum + def num_digits base=10 + return 1 if self == 0 + 1 + (Math.log(self) / Math.log(10)).floor + end + + def to_character + if self < 128 && self >= 0 + chr + else + "<#{self}>" + end + end +end + +class Hash + def - o + Hash[*self.map { |k, v| [k, v] unless o.include? k }.compact.flatten_one_level] + end + + def select_by_value v=true + select { |k, vv| vv == v }.map { |x| x.first } + end +end + +module Enumerable + def map_with_index + ret = [] + each_with_index { |x, i| ret << yield(x, i) } + ret + end + + def sum; inject(0) { |x, y| x + y }; end + + def map_to_hash + ret = {} + each { |x| ret[x] = yield(x) } + ret + end + + # like find, except returns the value of the block rather than the + # element itself. + def argfind + ret = nil + find { |e| ret ||= yield(e) } + ret || nil # force + end + + def argmin + best, bestval = nil, nil + each do |e| + val = yield e + if bestval.nil? || val < bestval + best, bestval = e, val + end + end + best + end +end + +class Array + def flatten_one_level + inject([]) { |a, e| a + e } + end + + def to_h; Hash[*flatten]; end + def rest; self[1..-1]; end + + def to_boolean_h; Hash[*map { |x| [x, true] }.flatten]; end + + ## apparently uniq doesn't use ==. wtf. + def remove_successive_dupes + ret = [] + last = nil + each do |e| + unless e == last + ret << e + last = e + end + end + ret + end +end + +class Time + def to_indexable_s + sprintf "%012d", self + end + + def nearest_hour + if min < 30 + self + else + self + (60 - min) * 60 + end + end + + def midnight # within a second + self - (hour * 60 * 60) - (min * 60) - sec + end + + def is_the_same_day? other + (midnight - other.midnight).abs < 1 + end + + def is_the_day_before? other + other.midnight - midnight <= 24 * 60 * 60 + 1 + end + + def to_nice_distance_s from=Time.now + later_than = (self < from) + diff = (self.to_i - from.to_i).abs.to_f + text = + [ ["second", 60], + ["minute", 60], + ["hour", 24], + ["day", 7], + ["week", 4], # heh heh + ["month", 12], + ["year", nil], + ].argfind do |unit, size| + if diff <= 1 + "one #{unit}" + elsif size.nil? || diff < size + "#{diff} #{unit}s" + else + diff = (diff / size.to_f).round + false + end + end + if later_than + text + " ago" + else + "in " + text + end + end + + TO_NICE_S_MAX_LEN = 11 # e.g. "Jul 31 2005" + def to_nice_s from=Time.now + if year != from.year + strftime "%b %e %Y" + elsif month != from.month + strftime "%b %e" + else + if is_the_same_day? from + strftime("%l:%M%P") + elsif is_the_day_before? from + "Yest." + nearest_hour.strftime("%l%P") + else + strftime "%b %e" + end + end + end +end + +## simple singleton module. far less complete and insane than the ruby +## standard library one, but automatically forwards methods calls and +## allows for constructors that take arguments. +## +## You must have #initialize call "self.class.i_am_the_instance self" +## at some point or everything will fail horribly +module Singleton + module ClassMethods + def instance; @instance; end + def instantiated?; defined?(@instance) && !@instance.nil?; end + def method_missing meth, *a, &b + raise "no instance defined!" unless defined? @instance + @instance.send meth, *a, &b + end + def i_am_the_instance o + raise "there can be only one! (instance)" if defined? @instance + @instance = o + end + end + + def self.included klass + klass.extend ClassMethods + end +end diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..ca15980 --- /dev/null +++ b/www/index.html @@ -0,0 +1,63 @@ + + +Sup + + + + +

Sup

+ +

+Sup is an attempt to take the UI innovations of web-based email +readers (ok, really just GMail) and to combine them with the +traditional wholesome goodness of a console-based email client. +

+

+Sup is designed to work with massive amounts of email, potentially +spread out across different mbox files, IMAP folders, and GMail +accounts, and to pull them all together into a single interface. +

+

+The goal of Sup is to become the email client of choice for nerds +everywhere. +

+ +

Screenshots

+ + + + + + + +

Documentation

+ +Please read the README, the FAQ, and the philosophical statement. + +

Status

+ +

The current version of Sup is 0.0.1, released November 28th, 2006. +This is an alpha release. It is unix-centric, mbox-specific, and has +no i18n support. I plan to fix all of these problems.

+ +

Other than those limitations, it works great. I use it for my +everyday email.

+ +

More info

+ +

You can download Sup releases from the Sup RubyForge page. +Feel free to post comments, bug reports and patches to the forums there. +

+ +

Credits

+ +Sup is brought to you by William Morgan. + +Sup is made possible only by the hard work of Dave Balmain and his fantastic +IR engine Ferret. + + + diff --git a/www/ss1.png b/www/ss1.png new file mode 100644 index 0000000..a5c43d4 Binary files /dev/null and b/www/ss1.png differ diff --git a/www/ss2.png b/www/ss2.png new file mode 100644 index 0000000..f9b6df7 Binary files /dev/null and b/www/ss2.png differ diff --git a/www/ss3.png b/www/ss3.png new file mode 100644 index 0000000..4038cb5 Binary files /dev/null and b/www/ss3.png differ diff --git a/www/ss4.png b/www/ss4.png new file mode 100644 index 0000000..cdd172b Binary files /dev/null and b/www/ss4.png differ diff --git a/www/ss5.png b/www/ss5.png new file mode 100644 index 0000000..aa367ae Binary files /dev/null and b/www/ss5.png differ diff --git a/www/ss6.png b/www/ss6.png new file mode 100644 index 0000000..68ff5bf Binary files /dev/null and b/www/ss6.png differ