--- /dev/null
+== 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!
+
--- /dev/null
+ 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
--- /dev/null
+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
--- /dev/null
+sup
+ by William Morgan <wmorgan-sup@masanjin.net>
+ 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 <mbox filename>+
+ 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.
+
--- /dev/null
+# -*- 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
--- /dev/null
+#!/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("<help for #{curmode.name}>") { 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 <<EOS
+An error occurred while loading a message from source "#{$exception.source}".
+Typically, this means that the source has been modified in some
+way which has rendered the messages invalid.
+
+You must rebuild the index for this source. Please run:
+ sup-import --rebuild #{$exception.source}
+to correct this error.
+EOS
+ raise $exception
+ else
+ $stderr.puts <<EOS
+-----------------------------------------------------------------
+I'm very sorry, but it seems that an error occurred in Redwood.
+Please accept my sincere apologies. If you don't mind, please
+send the backtrace below and a brief report of the circumstances
+to user wmorgan-sup at site masanjin dot net so that I might
+address this problem. Thank you!
+
+Sincerely,
+William
+-----------------------------------------------------------------
+
+The problem was: #{$exception.message} (error type #{$exception.class.name})
+A backtrace follows:
+EOS
+ raise $exception
+ end
+end
+
+
+end
+
--- /dev/null
+#!/bin/env ruby
+
+require "sup"
+
+class Float
+ def to_s; sprintf '%.2f', self; end
+end
+
+class Numeric
+ def to_time_s
+ i = to_i
+ sprintf "%d:%02d:%02d", i / 3600, (i / 60) % 60, i % 60
+ end
+end
+
+def time
+ startt = Time.now
+ yield
+ Time.now - startt
+end
+
+def educate_user
+ $stderr.puts <<EOS
+Loads messages into the Sup index, adding sources as needed to the
+source list.
+
+Usage:
+ sup-import [options] <source>*
+where <source>* 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
--- /dev/null
+#!/bin/sh
+
+find . -type f -name \*.rb | xargs cat | grep -v "^ *$"|grep -v "^ *#"|grep -v "^ *end *$"|wc -l
--- /dev/null
+require 'sup'
+
+puts "loading index..."
+@index = Redwood::Index.new
+@index.load
+@i = @index.index
+puts "loaded index of #{@i.size} messages"
+
+
--- /dev/null
+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"
+
--- /dev/null
+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.
+
--- /dev/null
+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.
+
+
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+## 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
--- /dev/null
+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: "<down arrow>"
+ when :up: "<up arrow>"
+ when :left: "<left arrow>"
+ when :right: "<right arrow>"
+ when :page_down: "<page down>"
+ when :page_up: "<page up>"
+ when :backspace: "<backspace>"
+ when :home: "<home>"
+ when :end: "<end>"
+ when :enter, :return: "<enter>"
+ when :ctrl_l: "ctrl-l"
+ when :ctrl_l: "ctrl-g"
+ when :tab: "tab"
+ when " ": "<space>"
+ 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
--- /dev/null
+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
--- /dev/null
+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 "<log>", @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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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(/^<mailto:|>$/, "")
+ 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
--- /dev/null
+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 = <<EOS
+#{title}
+#{'-' * title.length}
+
+#{km.help_text used_keys}
+EOS
+ begin
+ used_keys.merge! km.keysyms.to_boolean_h
+ rescue ArgumentError
+ raise km.keysyms.inspect
+ end
+ s
+ end.compact.join "\n"
+ end
+end
+
+end
--- /dev/null
+module Redwood
+
+class BufferListMode < LineCursorMode
+ register_keymap do |k|
+ k.add :jump_to_buffer, "Jump to that buffer", :enter
+ k.add :reload, "Reload", "R"
+ end
+
+ def initialize
+ regen_text
+ super
+ end
+
+ def lines; @text.length; end
+ def [] i; @text[i]; end
+
+protected
+
+ def reload
+ regen_text
+ buffer.mark_dirty
+ end
+
+ def regen_text
+ @bufs = BufferManager.buffers.sort_by { |name, buf| name }
+ width = @bufs.map { |name, buf| name.length }.max
+ @text = @bufs.map do |name, buf|
+ sprintf "%#{width}s %s", name, buf.mode.name
+ end
+ end
+
+ def jump_to_buffer
+ BufferManager.raise_to_front @bufs[curpos][1]
+ end
+end
+
+end
--- /dev/null
+module Redwood
+
+class ComposeMode < EditMessageMode
+ attr_reader :body, :header
+
+ def initialize h={}
+ super()
+ @header = {
+ "From" => 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
--- /dev/null
+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
--- /dev/null
+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"] =~ /<?(\S+@(\S+?))>?$/
+ $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 <<EOS
+Mime-Version: 1.0
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+User-Agent: Redwood/#{Redwood::VERSION}
+EOS
+ end
+
+ f.puts
+ f.puts @body
+ end
+end
+
+end
--- /dev/null
+module Redwood
+
+class ForwardMode < EditMessageMode
+ attr_reader :body, :header
+
+ def initialize m
+ super()
+ @header = {
+ "From" => 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
--- /dev/null
+module Redwood
+
+class HelpMode < TextMode
+ def initialize mode, global_keymap
+ title = "Help for #{mode.name}"
+ super <<EOS
+#{title}
+#{'=' * title.length}
+
+#{mode.help_text}
+Global keybindings
+------------------
+#{global_keymap.help_text}
+EOS
+ end
+end
+
+end
+
--- /dev/null
+require 'thread'
+
+module Redwood
+
+class InboxMode < ThreadIndexMode
+ register_keymap do |k|
+ ## overwrite toggle_archived with archive
+ k.add :archive, "Archive thread (remove from inbox)", 'a'
+ k.add :load_more_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
+ k.add :reload, "Discard threads and reload", 'R'
+ end
+
+ def initialize
+ super [:inbox], [:inbox]
+ end
+
+ def archive
+ remove_label_and_hide_thread cursor_thread, :inbox
+ regen_text
+ end
+
+ def multi_archive threads
+ threads.each { |t| remove_label_and_hide_thread t, :inbox }
+ regen_text
+ end
+
+ def is_relevant? m; m.has_label? :inbox; end
+
+ def load_more_threads n=ThreadIndexMode::LOAD_MORE_THREAD_NUM
+ load_n_threads_background n, :label => :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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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}<one or more unreceived messages>"]]]
+ when nil
+ [[[:message_patina_color, "#{prefix}<an unreceived message>"]]]
+ 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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
+ "<thread containing: #{@containers.join ', '}>"
+ 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
+ "<no message>"
+ 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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+<html>
+<head>
+<title>Sup</title>
+</head>
+
+<body>
+
+<h1>Sup</h1>
+
+<p>
+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.
+</p>
+<p>
+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.
+</p>
+<p>
+The goal of Sup is to become the email client of choice for nerds
+everywhere.
+</p>
+
+<h2>Screenshots</h2>
+
+<a href="ss1.png"><img src="ss1-small.png"></a>
+<a href="ss2.png"><img src="ss2-small.png"></a>
+<a href="ss3.png"><img src="ss3-small.png"></a>
+<a href="ss5.png"><img src="ss5-small.png"></a>
+<a href="ss6.png"><img src="ss6-small.png"></a>
+
+<h2>Documentation</h2>
+
+Please read the <a href="README.txt">README</a>, the <a
+href="FAQ.txt">FAQ</a>, and the <a
+href="Philosophy.txt">philosophical statement</a>.
+
+<h2>Status</h2>
+
+<p>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.</p>
+
+<p>Other than those limitations, it works great. I use it for my
+everyday email.</p>
+
+<h3>More info</h3>
+
+<p> You can download Sup releases from the <a href="http://rubyforge.org/projects/sup/">Sup RubyForge page</a>.
+Feel free to post comments, bug reports and patches to the forums there.
+</p>
+
+<h3>Credits</h3>
+
+Sup is brought to you by <a href="http://cs.stanford.edu/~ruby/">William Morgan</a>.
+
+Sup is made possible only by the hard work of <a
+href="http://www.davebalmain.com/">Dave Balmain</a> and his fantastic
+IR engine <a href="http://ferret.davebalmain.com/trac/">Ferret</a>.
+
+</body>
+</html>