]> git.cworth.org Git - sup/commitdiff
moved evertying to devel
authorwmorgan <wmorgan@5c8cc53c-5e98-4d25-b20a-d8db53a31250>
Tue, 28 Nov 2006 22:10:56 +0000 (22:10 +0000)
committerwmorgan <wmorgan@5c8cc53c-5e98-4d25-b20a-d8db53a31250>
Tue, 28 Nov 2006 22:10:56 +0000 (22:10 +0000)
git-svn-id: svn://rubyforge.org/var/svn/sup/trunk@36 5c8cc53c-5e98-4d25-b20a-d8db53a31250

62 files changed:
History.txt [new file with mode: 0644]
LICENSE [new file with mode: 0644]
Manifest.txt [new file with mode: 0644]
README.txt [new file with mode: 0644]
Rakefile [new file with mode: 0644]
bin/sup [new file with mode: 0644]
bin/sup-import [new file with mode: 0644]
devel/count-loc.sh [new file with mode: 0644]
devel/load-index.rb [new file with mode: 0644]
devel/profile.rb [new file with mode: 0644]
doc/FAQ.txt [new file with mode: 0644]
doc/Philosophy.txt [new file with mode: 0644]
doc/TODO [new file with mode: 0644]
lib/sup.rb [new file with mode: 0644]
lib/sup/account.rb [new file with mode: 0644]
lib/sup/buffer.rb [new file with mode: 0644]
lib/sup/colormap.rb [new file with mode: 0644]
lib/sup/contact.rb [new file with mode: 0644]
lib/sup/draft.rb [new file with mode: 0644]
lib/sup/index.rb [new file with mode: 0644]
lib/sup/keymap.rb [new file with mode: 0644]
lib/sup/label.rb [new file with mode: 0644]
lib/sup/logger.rb [new file with mode: 0644]
lib/sup/mbox.rb [new file with mode: 0644]
lib/sup/mbox/loader.rb [new file with mode: 0644]
lib/sup/message.rb [new file with mode: 0644]
lib/sup/mode.rb [new file with mode: 0644]
lib/sup/modes/buffer-list-mode.rb [new file with mode: 0644]
lib/sup/modes/compose-mode.rb [new file with mode: 0644]
lib/sup/modes/contact-list-mode.rb [new file with mode: 0644]
lib/sup/modes/edit-message-mode.rb [new file with mode: 0644]
lib/sup/modes/forward-mode.rb [new file with mode: 0644]
lib/sup/modes/help-mode.rb [new file with mode: 0644]
lib/sup/modes/inbox-mode.rb [new file with mode: 0644]
lib/sup/modes/label-list-mode.rb [new file with mode: 0644]
lib/sup/modes/label-search-results-mode.rb [new file with mode: 0644]
lib/sup/modes/line-cursor-mode.rb [new file with mode: 0644]
lib/sup/modes/log-mode.rb [new file with mode: 0644]
lib/sup/modes/person-search-results-mode.rb [new file with mode: 0644]
lib/sup/modes/poll-mode.rb [new file with mode: 0644]
lib/sup/modes/reply-mode.rb [new file with mode: 0644]
lib/sup/modes/resume-mode.rb [new file with mode: 0644]
lib/sup/modes/scroll-mode.rb [new file with mode: 0644]
lib/sup/modes/search-results-mode.rb [new file with mode: 0644]
lib/sup/modes/text-mode.rb [new file with mode: 0644]
lib/sup/modes/thread-index-mode.rb [new file with mode: 0644]
lib/sup/modes/thread-view-mode.rb [new file with mode: 0644]
lib/sup/person.rb [new file with mode: 0644]
lib/sup/poll.rb [new file with mode: 0644]
lib/sup/sent.rb [new file with mode: 0644]
lib/sup/tagger.rb [new file with mode: 0644]
lib/sup/textfield.rb [new file with mode: 0644]
lib/sup/thread.rb [new file with mode: 0644]
lib/sup/update.rb [new file with mode: 0644]
lib/sup/util.rb [new file with mode: 0644]
www/index.html [new file with mode: 0644]
www/ss1.png [new file with mode: 0644]
www/ss2.png [new file with mode: 0644]
www/ss3.png [new file with mode: 0644]
www/ss4.png [new file with mode: 0644]
www/ss5.png [new file with mode: 0644]
www/ss6.png [new file with mode: 0644]

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