commit 7619cb9e42ecd21e6a184bfa13661cec3d721bb5
parent ef385eced7f0a2bbc3bb909861a9f5d077b5f179
Author: Gaute Hope <eg@gaute.vetsj.com>
Date: Thu, 15 Aug 2013 10:23:33 +0200
Merge tag 'release-0.14.0': Sup Release 0.14.0
Release 0.14.0
Diffstat:
104 files changed, 4782 insertions(+), 4298 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -11,3 +11,7 @@ sup-exception-log.txt
# bundler stuff
Gemfile.lock
.bundle
+
+# generated file for gnupg test
+test/gnupg_test_home/random_seed
+
diff --git a/.travis.yml b/.travis.yml
@@ -3,11 +3,10 @@ language: ruby
rvm:
- 2.0.0
- 1.9.3
- - 1.8.7
before_install:
- sudo apt-get update -qq
- - sudo apt-get install -qq uuid-dev uuid libncursesw5-dev libncursesw5
+ - sudo apt-get install -qq uuid-dev uuid libncursesw5-dev libncursesw5 gnupg2
script: bundle exec rake travis
diff --git a/CONTRIBUTORS b/CONTRIBUTORS
@@ -1,8 +1,8 @@
-William Morgan <wmorgan-sup at the masanjin dot nets>
+William Morgan <william at the twitter dot coms>
Rich Lane <rlane at the club.cc.cmu dot edus>
Gaute Hope <eg at the gaute.vetsj dot coms>
-Hamish Downer <dmishd at the gmail dot coms>
Whyme Lyu <callme5long at the gmail dot coms>
+Hamish Downer <dmishd at the gmail dot coms>
Sascha Silbe <sascha-pgp at the silbe dot orgs>
Ismo Puustinen <ismo at the iki dot fis>
Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
@@ -11,57 +11,58 @@ Eric Sherman <hyperbolist at the gmail dot coms>
Tero Tilus <tero at the tilus dot nets>
Ben Walton <bwalton at the artsci.utoronto dot cas>
Mike Stipicevic <stipim at the rpi dot edus>
+Clint Byrum <clint at the ubuntu dot coms>
Marcus Williams <marcus-sup at the bar-coded dot nets>
Lionel Ott <white.magic at the gmx dot des>
Gaudenz Steinlin <gaudenz at the soziologie dot chs>
Damien Leone <damien.leone at the fensalir dot frs>
Ingmar Vanhassel <ingmar at the exherbo dot orgs>
Mark Alexander <marka at the pobox dot coms>
-Eric Weikl <eric.weikl at the tngtech dot coms>
+Eric Weikl <eric.weikl at the gmx dot nets>
Christopher Warrington <chrisw at the rice dot edus>
W. Trevor King <wking at the drexel dot edus>
Richard Brown <rbrown at the exherbo dot orgs>
+Anthony Martinez <pi+sup at the pihost dot uss>
Marc Hartstein <marc.hartstein at the alum.vassar dot edus>
Israel Herraiz <israel.herraiz at the gmail dot coms>
-Anthony Martinez <pi+sup at the pihost dot uss>
Bo Borgerson <gigabo at the gmail dot coms>
-William Erik Baxter <web at the superscript dot coms>
Michael Hamann <michael at the content-space dot des>
+Jonathan Lassoff <jof at the thejof dot coms>
+William Erik Baxter <web at the superscript dot coms>
Grant Hollingworth <grant at the antiflux dot orgs>
+Markus Klinik <markus.klinik at the gmx dot des>
+Ico Doornekamp <ico at the pruts dot nls>
Adeodato Simó <dato at the net.com.org dot ess>
Daniel Schoepe <daniel.schoepe at the googlemail dot coms>
Jason Petsod <jason at the petsod dot orgs>
-Steve Goldman <sgoldman at the tower-research dot coms>
+Edward Z. Yang <edwardzyang at the thewritingpot dot coms>
Robin Burchell <viroteck at the viroteck dot nets>
+Steve Goldman <sgoldman at the tower-research dot coms>
Peter Harkins <ph at the malaprop dot orgs>
-Edward Z. Yang <ezyang at the MIT dot EDUs>
Decklin Foster <decklin at the red-bean dot coms>
Cameron Matheson <cam+sup at the cammunism dot orgs>
Carl Worth <cworth at the cworth dot orgs>
-Jeff Balogh <its.jeff.balogh at the gmail dot coms>
-Alex Vandiver <alexmv at the mit dot edus>
+Alex Vandiver <alex at the chmrr dot nets>
Andrew Pimlott <andrew at the pimlott dot nets>
+Jeff Balogh <its.jeff.balogh at the gmail dot coms>
Matías Aguirre <matiasaguirre at the gmail dot coms>
-Anthony Martinez <pi at the pihost dot uss>
Kornilios Kourtis <kkourt at the cslab.ece.ntua dot grs>
Kevin Riggle <kevinr at the free-dissociation dot coms>
Giorgio Lando <patroclo7 at the gmail dot coms>
Benoît PIERRE <benoit.pierre at the gmail dot coms>
+Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
Alvaro Herrera <alvherre at the alvh.no-ip dot orgs>
-Eric Weikl <eric.weikl at the gmx dot nets>
+Steven Lawrance <stl at the koffein dot nets>
Jonah <Jonah at the GoodCoffee dot cas>
-ian <ian at the lorf dot orgs>
-Adam Lloyd <adam at the alloy-d dot nets>
+ian <itaylor at the uark dot edus>
Todd Eisenberger <teisenbe at the andrew.cmu dot edus>
-Steven Walter <swalter at the monarch.(none)>
-Alex Vandiver <alex at the chmrr dot nets>
+Adam Lloyd <adam at the alloy-d dot nets>
+MichaelRevell <mikearevell at the gmail dot coms>
+Per Andersson <avtobiff at the gmail dot coms>
Gregor Hoffleit <gregor at the sam.mediasupervision dot des>
+Steven Walter <swalter at the monarch.(none)>
Jon M. Dugan <jdugan at the es dot nets>
-Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
-Stefan Lundström <lundst at the snabb.(none)>
Matthias Vallentin <vallentin at the icir dot orgs>
-Steven Lawrance <stl at the redhat dot coms>
-Jonathan Lassoff <jof at the thejof dot coms>
-ian <itaylor at the uark dot edus>
-Gregor Hoffleit <gregor at the hoffleit dot des>
+Stefan Lundström <lundst at the snabb.(none)>
+Horacio Sanson <horacio at the skillupjapan.co dot jps>
Kirill Smelkov <kirr at the landau.phys.spbu dot rus>
diff --git a/Gemfile b/Gemfile
@@ -1,3 +1,3 @@
-source 'http://rubygems.org/'
+source 'https://rubygems.org/'
gemspec
diff --git a/History.txt b/History.txt
@@ -1,3 +1,22 @@
+== 0.14.0 / 2013-08-15
+
+* CJK compatability
+* Psych over Syck
+* Ruby 1.8 deprecated
+* Thread safety
+* No more Iconv, but using built in Ruby encodings. Better UTF-8
+ handling.
+* GPGME 2.0 support
+
+== 0.13.2 / 2013-06-26
+
+* FreeBSD 10 comptability
+* More threadsafe polling
+
+== 0.13.1 / 2013-06-21
+
+* Bugfixes
+
== 0.13.0 / 2013-05-15
* Bugfixes
diff --git a/Rakefile b/Rakefile
@@ -3,9 +3,10 @@ require 'rake/testtask'
Rake::TestTask.new(:test) do |test|
test.libs << 'test'
- test.test_files = FileList.new('test/test_*.rb')
+ test.test_files = FileList.new('test/**/test_*.rb')
test.verbose = true
end
+task :default => :test
require 'rubygems/package_task'
# For those who don't have `rubygems-bundler` installed
diff --git a/ReleaseNotes b/ReleaseNotes
@@ -1,3 +1,31 @@
+Release 0.14.0:
+
+CJK-compatability, Psych usage, thread safety, GPGME 2.0 support. Sup is now
+Ruby 1.9 based, and apart from RMail - ready for Ruby 2.0.0.
+
+Sup now uses Psych as a YAML parser (default by Ruby) and your previous
+configuration files (~/.sup/*.yaml) may need to be migrated or re-created for
+them to work with the new sup. A migration script is included for this.
+
+Check https://github.com/sup-heliotrope/sup/wiki/Migration-0.13-to-0.14 for
+the latest instructions.
+
+First back up your ~/.sup directory and index, after installing the new sup
+run:
+
+$ sup-psych-ify-config-files
+
+to migrate your files. You should now be all set for buisness.
+
+
+Release 0.13.2:
+
+FreeBSD compatability and more thread safe polling.
+
+Release 0.13.1:
+
+Another ruby 1.8 compatible release, various fixes.
+
Release 0.13.0:
Collection of bugfixes and stability fixes since 0.12.1. We now depend on our
diff --git a/bin/sup b/bin/sup
@@ -1,4 +1,7 @@
#!/usr/bin/env ruby
+# encoding: utf-8
+
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
require 'rubygems'
@@ -6,8 +9,6 @@ require 'ncursesw'
no_gpgme = false
begin
- # gpgme broke its API in 2.0, so make sure we have the old version for now.
- gem 'gpgme', '=1.0.8'
require 'gpgme'
rescue LoadError
no_gpgme = true
@@ -15,7 +16,7 @@ end
require 'fileutils'
require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
if ENV['SUP_PROFILE']
require 'ruby-prof'
@@ -104,8 +105,6 @@ end
## ncurses.so that's been compiled against libncursesw. (note the w.) why
## this works, i have no idea. much like pretty much every aspect of
## dealing with curses. cargo cult programming at its best.
-##
-## BSD users: if libc.so.6 is not found, try installing compat6x.
require 'dl/import'
require 'rbconfig'
module LibC
@@ -113,6 +112,7 @@ module LibC
setlocale_lib = case RbConfig::CONFIG['arch']
when /darwin/; "libc.dylib"
when /cygwin/; "cygwin1.dll"
+ when /freebsd/; "libc.so.7"
else; "libc.so.6"
end
@@ -125,9 +125,6 @@ module LibC
rescue RuntimeError => e
warn "cannot dlload setlocale(); ncurses wide character support probably broken."
warn "dlload error was #{e.class}: #{e.message}"
- if RbConfig::CONFIG['arch'] =~ /bsd/
- warn "BSD variant detected. You may have to install a compat6x package to acquire libc."
- end
end
end
@@ -160,7 +157,11 @@ begin
$die = false
trap("TERM") { |x| $die = true }
- trap("WINCH") { |x| BufferManager.sigwinch_happened! }
+ trap("WINCH") do |x|
+ ::Thread.new do
+ BufferManager.sigwinch_happened!
+ end
+ end
if(s = Redwood::SourceManager.source_for DraftManager.source_name)
DraftManager.source = s
@@ -292,8 +293,8 @@ begin
b.mode.load_in_background if new
when :search
completions = LabelManager.all_labels.map { |l| "label:#{LabelManager.string_for l}" }
- completions = completions.each { |l| l.force_encoding 'UTF-8' if l.methods.include?(:encoding) }
- completions += ["from:", "to:", "after:", "before:", "date:", "limit:", "AND", "OR", "NOT"]
+ completions = completions.each { |l| l.fix_encoding }
+ completions += Index::COMPL_PREFIXES
query = BufferManager.ask_many_with_completions :search, "Search all messages (enter for saved searches): ", completions
unless query.nil?
if query.empty?
@@ -306,7 +307,7 @@ begin
SearchResultsMode.spawn_from_query "is:unread"
when :list_labels
labels = LabelManager.all_labels.map { |l| LabelManager.string_for l }
- labels = labels.each { |l| l.force_encoding 'UTF-8' if l.methods.include?(:encoding) }
+ labels = labels.each { |l| l.fix_encoding }
user_label = bm.ask_with_completions :label, "Show threads with label (enter for listing): ", labels
unless user_label.nil?
@@ -414,7 +415,7 @@ unless Redwood::exceptions.empty?
We are very sorry. It seems that an error occurred in Sup. Please
accept our sincere apologies. Please submit the contents of
#{BASE_DIR}/exception-log.txt and a brief report of the
-circumstances to https://github.com/sup-heliotrope/sup/issues so that
+circumstances to https://github.com/sup-heliotrope/sup/issues so that
we might address this problem. Thank you!
Sincerely,
diff --git a/bin/sup-add b/bin/sup-add
@@ -1,10 +1,12 @@
#!/usr/bin/env ruby
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
require 'uri'
require 'rubygems'
require 'highline/import'
require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
$opts = Trollop::options do
version "sup-add (sup #{Redwood::VERSION})"
diff --git a/bin/sup-config b/bin/sup-config
@@ -1,5 +1,7 @@
#!/usr/bin/env ruby
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
require 'rubygems'
require 'highline/import'
require 'trollop'
@@ -19,11 +21,12 @@ EOS
end
def axe q, default=nil
- ans = if default && !default.empty?
- ask "#{q} (enter for \"#{default}\"): "
- else
- ask "#{q}: "
- end
+ question = if default && !default.empty?
+ "#{q} (enter for \"#{default}\"): "
+ else
+ "#{q}: "
+ end
+ ans = ask question
ans.empty? ? default : ans.to_s
end
@@ -36,6 +39,8 @@ def build_cmd cmd
end
def add_source
+ require "sup/util/uri"
+
type = nil
say "Ok, adding a new source."
@@ -69,7 +74,7 @@ def add_source
end
uri = begin
- URI::Generic.build components
+ Redwood::Util::Uri.build components
rescue URI::Error => e
say "Whoopsie! I couldn't build a URI from that: #{e.message}"
if axe_yes("Try again?") then next else return end
diff --git a/bin/sup-dump b/bin/sup-dump
@@ -1,5 +1,7 @@
#!/usr/bin/env ruby
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
require 'rubygems'
require 'xapian'
require 'trollop'
diff --git a/bin/sup-import-dump b/bin/sup-import-dump
@@ -1,9 +1,11 @@
#!/usr/bin/env ruby
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
require 'uri'
require 'rubygems'
require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
PROGRESS_UPDATE_INTERVAL = 15 # seconds
diff --git a/bin/sup-psych-ify-config-files b/bin/sup-psych-ify-config-files
@@ -0,0 +1,16 @@
+#!/usr/bin/env ruby
+
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
+require "sup"
+require "fileutils"
+
+Redwood.start
+
+fn = Redwood::SOURCE_FN
+FileUtils.cp fn, "#{fn}.syck_bak"
+
+Redwood::SourceManager.load_sources fn
+Redwood::SourceManager.save_sources fn, true
+
+Redwood.finish
diff --git a/bin/sup-recover-sources b/bin/sup-recover-sources
@@ -1,5 +1,7 @@
#!/usr/bin/env ruby
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
require 'optparse'
$opts = {
diff --git a/bin/sup-sync b/bin/sup-sync
@@ -1,9 +1,11 @@
#!/usr/bin/env ruby
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
require 'uri'
require 'rubygems'
require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
PROGRESS_UPDATE_INTERVAL = 15 # seconds
diff --git a/bin/sup-sync-back b/bin/sup-sync-back
@@ -1,10 +1,12 @@
#!/usr/bin/env ruby
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
require 'rubygems'
require 'uri'
require 'tempfile'
require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
fail "not working yet"
diff --git a/bin/sup-tweak-labels b/bin/sup-tweak-labels
@@ -1,8 +1,10 @@
#!/usr/bin/env ruby
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
require 'rubygems'
require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
class Float
def to_s; sprintf '%.2f', self; end
diff --git a/doc/FAQ.txt b/doc/FAQ.txt
@@ -21,8 +21,8 @@ A: I hate ads, I hate using a mouse, and I hate non-programmability and
Q: Why the console?
A: Because a keystroke is worth a hundred mouse clicks, as any Unix
- user knows. Because you don't need web browser. Because you get
- instantaneous response and a simple interface.
+ user knows. Because you don't need a web browser. Because you get
+ an instantaneous response and a simple interface.
Q: How does Sup deal with spam?
A: You can manually mark messages as spam, which prevents them from
diff --git a/doc/NewUserGuide.txt b/doc/NewUserGuide.txt
@@ -1,258 +0,0 @@
-Welcome to Sup! Here's how to get started.
-
-First, try running `sup`. Since this is your first time, you'll be
-confronted with a mostly blank screen, and a notice at the bottom that
-you have no new messages. That's because Sup doesn't hasn't loaded
-anything into its index yet, and has no idea where to look for them
-anyways.
-
-If you want to play around a little at this point, you can press 'b'
-to cycle between buffers, ';' to get a list of the open buffers, and
-'x' to kill a buffer. There's probably not too much interesting there,
-but there's a log buffer with some cryptic messages. You can also
-press '?' at any point to get a list of keyboard commands, but in the
-absence of any email, these will be mostly useless. When you get
-bored, press 'q' to quit.
-
-To use Sup for email, we need to load messages into the index. The
-index is where Sup stores all message state (e.g. read or unread, any
-message labels), and all information necessary for searching and for
-threading messages. Sup only knows about messages in its index.
-
-We can add messages to the index by telling Sup about the "source"
-where the messages reside. Sources are things like mbox folders, and
-maildir directories. Sup doesn't duplicate the actual message content
-in the index; it only stores whatever information is necessary for
-searching, threading and labelling. So when you search for messages or
-view your inbox, Sup talks only to the index (stored locally on
-disk). When you view a thread, Sup requests the full content of all
-the messages from the source.
-
-The easiest way to set up all your sources is to run `sup-config`.
-This will interactively walk you through some basic configuration,
-prompt you for all the sources you need, and optionally import
-messages from them. Sup-config uses two other tools, sup-add and
-sup-sync, to load messages into the index. In the future you may make
-use of these tools directly (see below).
-
-Once you've run sup-config, you're ready to run `sup`. You should see
-the most recent unarchived messages appear in your inbox.
-Congratulations, you've got Sup working!
-
-If you're coming from the world of traditional MUAs, there are a
-couple differences you should be aware of at this point. First, Sup
-has no folders. Instead, you organize and find messages by a
-combination of search and labels (known as "tags" everywhere else in
-the world). Search and labels are an integral part of Sup because in
-Sup, rather than viewing the contents of a folder, you view the
-results of a search. I mentioned above that your inbox is, by
-definition, the set of all messages that aren't archived. This means
-that your inbox is nothing more than the result of the search for all
-messages with the label "inbox". (It's actually slightly more
-complicated---we also omit messages marked as killed, deleted or
-spam.)
-
-You could replicate the folder paradigm easily under this scheme, by
-giving each message exactly one label and only viewing the results of
-simple searches for those labels. But you'd quickly find out that life
-can be easier than that if you just trust the search engine, and use
-labels judiciously for things that are too hard to find with search.
-The idea is that a labeling system that allows arbitrary, user-defined
-labels, supplemented by a quick and easy-to-access search mechanism
-provides all the functionality that folders does, plus much more, at a
-far lower cost to the user.
-
-Now let's take a look at your inbox. You'll see that Sup groups
-messages together into threads: each line in the inbox is a thread,
-and the number in parentheses is the number of messages in that
-thread. (If there's no number, there's just one message in the
-thread.) In Sup, most operations are on threads, not individual
-messages. The idea is that you rarely want to operate on a message
-independent of its context. You typically want to view, archive, kill,
-or label all the messages in a thread at one time.
-
-Use the up and down arrows to highlight a thread. ('j' and 'k' do the
-same thing, and 'J' and 'K' will scroll the whole window. Even the
-left and right arrow keys work.) By default, Sup only loads as many
-threads as it takes to fill the window; if you'd like to load more,
-press 'M'. You can hit tab to cycle between only threads with new
-messages.
-
-Highlight a thread and press enter to view it. You'll notice that all
-messages in the thread are displayed together, laid out graphically by
-their relationship to each other (replies are nested under parents).
-By default, only the new messages in a thread are expanded, and the
-others are hidden. You can toggle an individual message's state by
-highlighting a green line and pressing enter. You can use 'E' to
-expand or collapse all messages or 'N' to expand only the new
-messages. You'll also notice that Sup hides quoted text and
-signatures. If you highlight a particular hidden chunk, you can press
-enter to expand it, or you can press 'o' to toggle every hidden chunk
-in a particular message.
-
-Other useful keyboard commands when viewing a thread are: 'n' and 'p'
-to jump to the next and previous open messages, 'h' to toggle the
-detailed headers for the current message, and enter to expand or
-collapse the current message (when it's on a text region). Enter and
-'n' in combination are useful for scanning through a thread---press
-enter to close the current message and jump to the next open one, and
-'n' to keep it open and jump. If the buffer is misaligned with a
-message, you can press 'z' to highlight it.
-
-This is a lot to remember, but you can always hit '?' to see the full
-list of keyboard commands at any point. There's a lot of useful stuff
-in there---once you learn some, try out some of the others!
-
-Now press 'x' to kill the thread view buffer. You should see the inbox
-again. If you don't, you can cycle through the buffers by pressing
-'b', or you can press ';' to see a list of all buffers and simply
-select the inbox.
-
-There are many operations you can perform on threads beyond viewing
-them. To archive a thread, press 'a'. The thread will disappear from
-your inbox, but will still appear in search results. If someone
-replies an archived thread, it will reappear in your inbox. To kill a
-thread, press '&'. Killed threads will never come back to your inbox,
-even if people reply, but will still be searchable. (This is useful
-for those interminable threads that you really have no immediate
-interest in, but which seem to pop up on every mailing list.)
-
-If a thread is spam, press 'S'. It will disappear and won't come back.
-It won't even appear in search results, unless you explicitly search
-for spam.
-
-You can star a thread by pressing '*'. Starred threads are displayed
-with a little yellow asterisk next to them, but otherwise have no
-special semantics. But you can also search for them easily---we'll see
-how in a moment.
-
-To edit the labels for (all the messages in) a thread, press 'l'. Type
-in the labels as a sequence of space-separated words. To cancel the
-input, press Ctrl-G.
-
-Many of these operations can be applied to a group of threads. Press
-'t' to tag a thread. Tag a couple, then press '=' to apply the next
-command to the set of threads. '=t', of course, will untag all tagged
-messages.
-
-Ok, let's try using labels and search. Press 'L' to do a quick label
-search. You'll be prompted for a label; simply hit enter to bring up
-scrollable list of all the labels you've ever used, along with some
-special labels (Draft, Starred, Sent, Spam, etc.). Highlight a label
-and press enter to view all the messages with that label.
-
-What you just did was actually a specific search. For a general search,
-press '\' (backslash---forward slash is used for in-buffer search,
-following console conventions). Now type in your query (again, Ctrl-G to
-cancel at any point.) You can just type in arbitrary text, which will be
-matched on a per-word basis against the bodies of all email in the
-index, or you can make use of the full Xapian query syntax
-(http://xapian.org/docs/queryparser.html):
-
-- Phrasal queries using double-quotes, e.g.: "three contiguous words"
-- Queries against a particular field using <field name>:<query>,
- e.g.: label:ruby-talk, or from:matz@ruby-lang.org. (Fields include:
- body, from, to, and subject.)
-- Force non-occurrence by -, e.g. -body:"hot soup".
-- If you have the chronic gem installed, date queries like
- "before:today", "on:today", "after:yesterday", "after:(2 days ago)"
- (parentheses required for multi-word descriptions).
-
-You can combine those all together. For example:
-
- label:ruby-talk subject:\[ANN\] -rails on:today
-
-Play around with the search, and see the Xapian documentation for
-details on more sophisticated queries (date ranges, "within n words",
-etc.)
-
-At this point, you're well on your way to figuring out all the cool
-things Sup can do. By repeated application of the '?' key, see if you
-can figure out how to:
-
- - List some recent contacts
- - Easily search for all mail from a recent contact
- - Easily search for all mail from several recent contacts
- - Add someone to your address book
- - Postpone a message (i.e., save a draft)
- - Quickly re-edit a just-saved draft message
- - View the raw header of a message
- - Star an individual message, not just a thread
-
-There's one last thing to be aware of when using Sup: how it interacts
-with other email programs. As I described above, Sup stores data about
-messages in the index, but doesn't duplicate the message contents
-themselves. The messages remain on the source. If the index and the
-source every fall out of sync, e.g. due to another email client
-modifying the source, then Sup will be unable to operate on that
-source. For example, for mbox files, Sup stores a byte offset into the
-file for each message. If a message deleted from that file by another
-client, or even marked as read (yeah, mbox sucks), all succeeding
-offsets will be wrong.
-
-That's the bad news. The good news is that Sup is pretty good at being
-able to detect this type of situation, and fixing it is just a matter
-of running `sup-sync --changed` on the source. Sup will even tell you
-how to invoke sup-sync when it detects a problem. This is a
-complication you will almost certainly run in to if you use both Sup
-and another MUA on the same source, so it's good to be aware of it.
-
-Have fun, and email sup-talk@rubyforge.org if you have any problems!
-
-Appendix A: sup-add and sup-sync
----------------------------------
-
-Instead of using sup-config to add a new source, you can manually run
-`sup-add` with a URI pointing to it. The URI should be of the form:
-
-- mbox://path/to/a/filename, for an mbox file on disk.
-- maildir://path/to/a/filename, for a maildir directory on disk.
-
-Before you add the source, you need make three decisions. The first is
-whether you want Sup to regularly poll this source for new messages.
-By default it will, but if this is a source that will never have new
-messages, you can specify `--unusual`. Sup polls only "usual" sources
-when checking for new mail (unless you manually invoke sup-sync).
-
-The second is whether you want messages from the source to be
-automatically archived. An archived message will not show up in your
-inbox, but will be found when you search. (Your inbox in Sup is, by
-definition, the set of all all non-archived messages). Specify
-`--archive` to automatically archive all messages from the source. This
-is useful for sources that contain, for example, high-traffic mailing
-lists that you don't want polluting your inbox.
-
-The final decision is whether you want any labels automatically
-applied to messages from this source. You can use `--labels` to do this.
-
-Now that you've added the source, let's import all the current
-messages from it, by running sup-sync with the source URI. You can
-specify `--archive` to automatically archive all messages in this
-import; typically you'll want to specify this for every source you
-import except your actual inbox. You can also specify `--read` to mark
-all imported messages as read; the default is to preserve the
-read/unread status from the source.
-
-Sup-sync will now load all the messages from the source into the
-index. Depending on the size of the source, this may take a while.
-Don't panic! It's a one-time process.
-
-Appendix B: Automatically labeling incoming email
--------------------------------------------------
-
-One option is to filter incoming email into different sources with
-something like procmail, and have each of these sources auto-apply
-labels by using `sup-add --labels`.
-
-But the better option is to learn Ruby and write a before-add hook.
-This will allow you to apply labels based on whatever crazy logic you
-can come up with. See http://sup.rubyforge.org/wiki/wiki.pl?Hooks for
-examples.
-
-Appendix C: Reading blogs with Sup
-----------------------------------
-
-Really, blog posts should be read like emails are read---you should be
-able to mark them as unread, flag them, label them, etc. Use rss2email
-to transform RSS feeds into emails, direct them all into a source, and
-add that source to Sup. Voila!
diff --git a/lib/sup.rb b/lib/sup.rb
@@ -1,15 +1,12 @@
-require 'rubygems'
+# encoding: utf-8
-require 'syck'
+require 'rubygems'
require 'yaml'
-if YAML.const_defined? :ENGINE
- YAML::ENGINE.yamler = 'syck'
-end
-
+YAML::ENGINE.yamler = 'psych'
require 'zlib'
require 'thread'
require 'fileutils'
-require 'gettext'
+require 'locale'
require 'curses'
require 'rmail'
begin
@@ -28,18 +25,23 @@ end
class Module
def yaml_properties *props
props = props.map { |p| p.to_s }
- vars = props.map { |p| "@#{p}" }
- klass = self
- path = klass.name.gsub(/::/, "/")
- klass.instance_eval do
- define_method(:to_yaml_properties) { vars }
- define_method(:to_yaml_type) { "!#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}" }
+ path = name.gsub(/::/, "/")
+ yaml_tag "!#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}"
+
+ define_method :init_with do |coder|
+ initialize(*coder.map.values_at(*props))
end
- YAML.add_domain_type("#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}", path) do |type, val|
- klass.new(*props.map { |p| val[p] })
+ define_method :encode_with do |coder|
+ coder.map = props.inject({}) do |hash, key|
+ hash[key] = instance_variable_get("@#{key}")
+ hash
+ end
end
+
+ # Legacy
+ Psych.load_tags["!#{Redwood::LEGACY_YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}"] = self
end
end
@@ -58,7 +60,8 @@ module Redwood
SEARCH_FN = File.join(BASE_DIR, "searches.txt")
LOG_FN = File.join(BASE_DIR, "log")
- YAML_DOMAIN = "masanjin.net"
+ YAML_DOMAIN = "supmua.org"
+ LEGACY_YAML_DOMAIN = "masanjin.net"
YAML_DATE = "2006-10-01"
## record exceptions thrown in threads nicely
@@ -234,36 +237,6 @@ EOM
end
end
- ## to be called by entry points in bin/, to ensure that
- ## their versions match up against the library versions.
- ##
- ## this is a perennial source of bug reports from people
- ## who both use git and have a gem version installed.
- def check_library_version_against v
- unless Redwood::VERSION == v
- $stderr.puts <<EOS
-Error: version mismatch!
-The sup executable is at version #{v.inspect}.
-The sup libraries are at version #{Redwood::VERSION.inspect}.
-
-Your development environment may be picking up code from a
-rubygems installation of sup.
-
-If you're running from git with a commandline like
-
- ruby -Ilib #{$0}
-
-try this instead:
-
- RUBY_INVOCATION="ruby -Ilib" ruby -Ilib #{$0}
-
-You can also try `gem uninstall sup` and removing all Sup rubygems.
-
-EOS
-#' duh!
- abort
- end
- end
## set up default configuration file
def load_config filename
@@ -298,7 +271,7 @@ EOS
else
require 'etc'
require 'socket'
- name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first rescue nil
+ name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first.force_encoding($encoding).fix_encoding rescue nil
name ||= ENV["USER"]
email = ENV["USER"] + "@" +
begin
@@ -310,8 +283,8 @@ EOS
config = {
:accounts => {
:default => {
- :name => name,
- :email => email,
+ :name => name.fix_encoding,
+ :email => email.fix_encoding,
:alternates => [],
:sendmail => "/usr/sbin/sendmail -oem -ti",
:signature => File.join(ENV["HOME"], ".signature"),
@@ -330,8 +303,7 @@ EOS
end
module_function :save_yaml_obj, :load_yaml_obj, :start, :finish,
- :report_broken_sources, :check_library_version_against,
- :load_config, :managers
+ :report_broken_sources, :load_config, :managers
end
require 'sup/version'
@@ -340,13 +312,12 @@ require "sup/hook"
require "sup/time"
## everything we need to get logging working
-require "sup/logger"
-Redwood::Logger.init.add_sink $stderr
-include Redwood::LogsStuff
+require "sup/logger/singleton"
## determine encoding and character set
$encoding = Locale.current.charset
$encoding = "UTF-8" if $encoding == "utf8"
+$encoding = "UTF-8" if $encoding == "UTF8"
if $encoding
debug "using character set encoding #{$encoding.inspect}"
else
@@ -354,14 +325,24 @@ else
$encoding = "UTF-8"
end
+# test encoding
+teststr = "test"
+teststr.encode('UTF-8')
+begin
+ teststr.encode($encoding)
+rescue Encoding::ConverterNotFoundError
+ warn "locale encoding is invalid, defaulting to utf-8"
+ $encoding = "UTF-8"
+end
+
require "sup/buffer"
require "sup/keymap"
require "sup/mode"
-require "sup/modes/scroll-mode"
-require "sup/modes/text-mode"
-require "sup/modes/log-mode"
+require "sup/modes/scroll_mode"
+require "sup/modes/text_mode"
+require "sup/modes/log_mode"
require "sup/update"
-require "sup/message-chunks"
+require "sup/message_chunks"
require "sup/message"
require "sup/source"
require "sup/mbox"
@@ -369,7 +350,7 @@ require "sup/maildir"
require "sup/person"
require "sup/account"
require "sup/thread"
-require "sup/interactive-lock"
+require "sup/interactive_lock"
require "sup/index"
require "sup/textfield"
require "sup/colormap"
@@ -380,31 +361,31 @@ require "sup/draft"
require "sup/poll"
require "sup/crypto"
require "sup/undo"
-require "sup/horizontal-selector"
-require "sup/modes/line-cursor-mode"
-require "sup/modes/help-mode"
-require "sup/modes/edit-message-mode"
-require "sup/modes/edit-message-async-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/poll-mode"
-require "sup/modes/file-browser-mode"
-require "sup/modes/completion-mode"
-require "sup/modes/console-mode"
+require "sup/horizontal_selector"
+require "sup/modes/line_cursor_mode"
+require "sup/modes/help_mode"
+require "sup/modes/edit_message_mode"
+require "sup/modes/edit_message_async_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/poll_mode"
+require "sup/modes/file_browser_mode"
+require "sup/modes/completion_mode"
+require "sup/modes/console_mode"
require "sup/sent"
require "sup/search"
-require "sup/modes/search-list-mode"
+require "sup/modes/search_list_mode"
require "sup/idle"
$:.each do |base|
diff --git a/lib/sup/account.rb b/lib/sup/account.rb
@@ -50,8 +50,9 @@ class AccountManager
[:name, :sendmail, :signature, :gpgkey].each { |k| hash[k] ||= @default_account.send(k) }
end
hash[:alternates] ||= []
+ fail "alternative emails are not an array: #{hash[:alternates]}" unless hash[:alternates].kind_of? Array
- [:name, :signature].each { |x| hash[x].force_encoding Encoding::UTF_8 if hash[x].respond_to? :encoding }
+ [:name, :signature].each { |x| hash[x] ? hash[x].fix_encoding : nil }
a = Account.new hash
@accounts[a] = true
diff --git a/lib/sup/buffer.rb b/lib/sup/buffer.rb
@@ -1,3 +1,5 @@
+# encoding: utf-8
+
require 'etc'
require 'thread'
@@ -126,14 +128,11 @@ class Buffer
@w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
s ||= ""
maxl = @width - x # maximum display width width
- stringl = maxl # string "length"
# fill up the line with blanks to overwrite old screen contents
@w.mvaddstr y, x, " " * maxl unless opts[:no_fill]
- ## the next horribleness is thanks to ruby's lack of widechar support
- stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
- @w.mvaddstr y, x, s[0 ... stringl]
+ @w.mvaddstr y, x, s.slice_by_display_length(maxl)
end
def clear
@@ -267,7 +266,7 @@ EOS
def handle_input c
if @focus_buf
- if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
+ if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY.ord
@focus_buf.mode.cancel_search!
@focus_buf.mark_dirty
end
@@ -450,7 +449,7 @@ EOS
def ask_with_completions domain, question, completions, default=nil
ask domain, question, default do |s|
- s.force_encoding 'UTF-8' if s.methods.include?(:encoding)
+ s.fix_encoding
completions.select { |x| x =~ /^#{Regexp::escape s}/iu }.map { |x| [x, x] }
end
end
@@ -467,9 +466,9 @@ EOS
raise "william screwed up completion: #{partial.inspect}"
end
- prefix.force_encoding 'UTF-8' if prefix.methods.include?(:encoding)
- target.force_encoding 'UTF-8' if target.methods.include?(:encoding)
- completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
+ prefix.fix_encoding
+ target.fix_encoding
+ completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.map { |x| [prefix + x, x] }
end
end
@@ -477,12 +476,12 @@ EOS
ask domain, question, default do |partial|
prefix, target = partial.split_on_commas_with_remainder
target ||= prefix.pop || ""
- target.force_encoding 'UTF-8' if target.methods.include?(:encoding)
+ target.fix_encoding
prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
- prefix.force_encoding 'UTF-8' if prefix.methods.include?(:encoding)
+ prefix.fix_encoding
- completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
+ completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
end
end
@@ -495,7 +494,7 @@ EOS
if dir
[[s.sub(full, dir), "~#{name}"]]
else
- users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
+ users.select { |u| u =~ /^#{Regexp::escape name}/u }.map do |u|
[s.sub("~#{name}", "~#{u}"), "~#{u}"]
end
end
@@ -554,6 +553,7 @@ EOS
completions = (recent + contacts).flatten.uniq
completions += HookManager.run("extra-contact-addresses") || []
+
answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
if answer
@@ -622,7 +622,7 @@ EOS
tf.deactivate
draw_screen :sync => false, :status => status, :title => title
end
- tf.value.tap { |x| x.force_encoding Encoding::UTF_8 if x && x.respond_to?(:encoding) }
+ tf.value.tap { |x| x }
end
def ask_getch question, accept=nil
@@ -709,7 +709,7 @@ EOS
end
Ncurses.mutex.lock unless opts[:sync] == false
- Ncurses.attrset Colormap.color_for(:none)
+ Ncurses.attrset Colormap.color_for(:text_color)
adj = @asking ? 2 : 1
m.each_with_index do |s, i|
Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
diff --git a/lib/sup/colormap.rb b/lib/sup/colormap.rb
@@ -26,6 +26,7 @@ class Colormap
@@instance = nil
DEFAULT_COLORS = {
+ :text => { :fg => "white", :bg => "black" },
:status => { :fg => "white", :bg => "blue", :attrs => ["bold"] },
:index_old => { :fg => "white", :bg => "default" },
:index_new => { :fg => "white", :bg => "default", :attrs => ["bold"] },
diff --git a/lib/sup/contact.rb b/lib/sup/contact.rb
@@ -1,3 +1,5 @@
+# encoding: utf-8
+
module Redwood
class ContactManager
@@ -54,7 +56,7 @@ class ContactManager
def is_aliased_contact? person; !@p2a[person].nil? end
def save
- File.open(@fn, "w") do |f|
+ File.open(@fn, "w:UTF-8") do |f|
@p2a.sort_by { |(p, a)| [p.full_address, a] }.each do |(p, a)|
f.puts "#{a || ''}: #{p.full_address}"
end
diff --git a/lib/sup/crypto.rb b/lib/sup/crypto.rb
@@ -1,6 +1,4 @@
begin
- # gpgme broke its API in 2.0, so make sure we have the old version for now.
- gem 'gpgme', '=1.0.8'
require 'gpgme'
rescue LoadError
end
@@ -64,28 +62,39 @@ EOS
@gpgme_present =
begin
begin
- GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
+ begin
+ GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
+ rescue TypeError
+ GPGME.check_version(nil)
+ end
true
rescue GPGME::Error
false
+ rescue ArgumentError
+ # gpgme 2.0.0 raises this due to the hash->string conversion
+ false
end
rescue NameError
false
end
unless @gpgme_present
- @not_working_reason = ['gpgme gem not present',
+ @not_working_reason = ['gpgme gem not present',
'Install the gpgme gem in order to use signed and encrypted emails']
return
end
# if gpg2 is available, it will start gpg-agent if required
if (bin = `which gpg2`.chomp) =~ /\S/
- GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
+ if GPGME.respond_to?('set_engine_info')
+ GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
+ else
+ GPGME.gpgme_set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
+ end
else
# check if the gpg-options hook uses the passphrase_callback
# if it doesn't then check if gpg agent is present
- gpg_opts = HookManager.run("gpg-options",
+ gpg_opts = HookManager.run("gpg-options",
{:operation => "sign", :options => {}}) || {}
if gpg_opts[:passphrase_callback].nil?
if ENV['GPG_AGENT_INFO'].nil?
@@ -110,22 +119,29 @@ EOS
end
def have_crypto?; @not_working_reason.nil? end
+ def not_working_reason; @not_working_reason end
def sign from, to, payload
return unknown_status(@not_working_reason) unless @not_working_reason.nil?
gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
gpg_opts.merge!(gen_sign_user_opts(from))
- gpg_opts = HookManager.run("gpg-options",
+ gpg_opts = HookManager.run("gpg-options",
{:operation => "sign", :options => gpg_opts}) || gpg_opts
begin
- sig = GPGME.detach_sign(format_payload(payload), gpg_opts)
+ if GPGME.respond_to?('detach_sign')
+ sig = GPGME.detach_sign(format_payload(payload), gpg_opts)
+ else
+ crypto = GPGME::Crypto.new
+ gpg_opts[:mode] = GPGME::SIG_MODE_DETACH
+ sig = crypto.sign(format_payload(payload), gpg_opts).read
+ end
rescue GPGME::Error => exc
raise Error, gpgme_exc_msg(exc.message)
end
- # if the key (or gpg-agent) is not available GPGME does not complain
+ # if the key (or gpg-agent) is not available GPGME does not complain
# but just returns a zero length string. Let's catch that
if sig.length == 0
raise Error, gpgme_exc_msg("GPG failed to generate signature: check that gpg-agent is running and your key is available.")
@@ -145,7 +161,7 @@ EOS
gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
if sign
- gpg_opts.merge!(gen_sign_user_opts(from))
+ gpg_opts.merge!(gen_sign_user_opts(from))
gpg_opts.merge!({:sign => true})
end
gpg_opts = HookManager.run("gpg-options",
@@ -153,12 +169,18 @@ EOS
recipients = to + [from]
recipients = HookManager.run("gpg-expand-keys", { :recipients => recipients }) || recipients
begin
- cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
+ if GPGME.respond_to?('encrypt')
+ cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
+ else
+ crypto = GPGME::Crypto.new
+ gpg_opts[:recipients] = recipients
+ cipher = crypto.encrypt(format_payload(payload), gpg_opts).read
+ end
rescue GPGME::Error => exc
raise Error, gpgme_exc_msg(exc.message)
end
- # if the key (or gpg-agent) is not available GPGME does not complain
+ # if the key (or gpg-agent) is not available GPGME does not complain
# but just returns a zero length string. Let's catch that
if cipher.length == 0
raise Error, gpgme_exc_msg("GPG failed to generate cipher text: check that gpg-agent is running and your key is available.")
@@ -262,7 +284,11 @@ EOS
{:operation => "decrypt", :options => gpg_opts}) || gpg_opts
ctx = GPGME::Ctx.new(gpg_opts)
cipher_data = GPGME::Data.from_str(format_payload(payload))
- plain_data = GPGME::Data.empty
+ if GPGME::Data.respond_to?('empty')
+ plain_data = GPGME::Data.empty
+ else
+ plain_data = GPGME::Data.empty!
+ end
begin
ctx.decrypt_verify(cipher_data, plain_data)
rescue GPGME::Error => exc
@@ -275,7 +301,7 @@ EOS
end
plain_data.seek(0, IO::SEEK_SET)
output = plain_data.read
- output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
+ output.transcode(Encoding::ASCII_8BIT, output.encoding)
## TODO: test to see if it is still necessary to do a 2nd run if verify
## fails.
@@ -290,7 +316,7 @@ EOS
# Look for Charset, they are put before the base64 crypted part
charsets = payload.body.split("\n").grep(/^Charset:/)
if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/
- output = Iconv.easy_decode($encoding, $1, output)
+ output.transcode($encoding, $1)
end
msg.body = output
else
@@ -314,7 +340,7 @@ EOS
msg = RMail::Parser.read output
if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
output = "MIME-Version: 1.0\n" + output
- output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
+ output.fix_encoding
msg = RMail::Parser.read output
end
end
@@ -330,7 +356,7 @@ private
def gpgme_exc_msg msg
err_msg = "Exception in GPGME call: #{msg}"
- info err_msg
+ #info err_msg
err_msg
end
@@ -362,7 +388,7 @@ private
else
first_sig = "Unknown error or empty signature"
end
- rescue EOFError
+ rescue EOFError
from_key = nil
first_sig = "No public key available for #{signature.fingerprint}"
end
diff --git a/lib/sup/draft.rb b/lib/sup/draft.rb
@@ -32,14 +32,17 @@ class DraftLoader < Source
attr_accessor :dir
yaml_properties
- def initialize
- dir = Redwood::DRAFT_DIR
+ def initialize dir=Redwood::DRAFT_DIR
Dir.mkdir dir unless File.exists? dir
super DraftManager.source_name, true, false
@dir = dir
@cur_offset = 0
end
+ def properly_initialized?
+ !!(@dir && @cur_offset)
+ end
+
def id; DraftManager.source_id; end
def to_s; DraftManager.source_name; end
def uri; DraftManager.source_name; end
diff --git a/lib/sup/hook.rb b/lib/sup/hook.rb
@@ -1,3 +1,5 @@
+require "sup/util"
+
module Redwood
class HookManager
@@ -17,6 +19,14 @@ class HookManager
end
end
+ def flash s
+ if BufferManager.instantiated?
+ BufferManager.flash s
+ else
+ log s
+ end
+ end
+
def log s
info "hook[#@__name]: #{s}"
end
diff --git a/lib/sup/horizontal-selector.rb b/lib/sup/horizontal-selector.rb
@@ -1,50 +0,0 @@
-module Redwood
-
-class HorizontalSelector
- attr_accessor :label, :changed_by_user
-
- def initialize label, vals, labels, base_color=:horizontal_selector_unselected_color, selected_color=:horizontal_selector_selected_color
- @label = label
- @vals = vals
- @labels = labels
- @base_color = base_color
- @selected_color = selected_color
- @selection = 0
- @changed_by_user = false
- end
-
- def set_to val; @selection = @vals.index(val) end
-
- def val; @vals[@selection] end
-
- def line width=nil
- label =
- if width
- sprintf "%#{width}s ", @label
- else
- "#{@label} "
- end
-
- [[@base_color, label]] +
- (0 ... @labels.length).inject([]) do |array, i|
- array + [
- if i == @selection
- [@selected_color, @labels[i]]
- else
- [@base_color, @labels[i]]
- end] + [[@base_color, " "]]
- end + [[@base_color, ""]]
- end
-
- def roll_left
- @selection = (@selection - 1) % @labels.length
- @changed_by_user = true
- end
-
- def roll_right
- @selection = (@selection + 1) % @labels.length
- @changed_by_user = true
- end
-end
-
-end
diff --git a/lib/sup/horizontal_selector.rb b/lib/sup/horizontal_selector.rb
@@ -0,0 +1,59 @@
+module Redwood
+
+class HorizontalSelector
+ class UnknownValue < StandardError; end
+
+ attr_accessor :label, :changed_by_user
+
+ def initialize label, vals, labels, base_color=:horizontal_selector_unselected_color, selected_color=:horizontal_selector_selected_color
+ @label = label
+ @vals = vals
+ @labels = labels
+ @base_color = base_color
+ @selected_color = selected_color
+ @selection = 0
+ @changed_by_user = false
+ end
+
+ def set_to val
+ raise UnknownValue, val.inspect unless can_set_to? val
+ @selection = @vals.index(val)
+ end
+
+ def can_set_to? val
+ @vals.include? val
+ end
+
+ def val; @vals[@selection] end
+
+ def line width=nil
+ label =
+ if width
+ sprintf "%#{width}s ", @label
+ else
+ "#{@label} "
+ end
+
+ [[@base_color, label]] +
+ (0 ... @labels.length).inject([]) do |array, i|
+ array + [
+ if i == @selection
+ [@selected_color, @labels[i]]
+ else
+ [@base_color, @labels[i]]
+ end] + [[@base_color, " "]]
+ end + [[@base_color, ""]]
+ end
+
+ def roll_left
+ @selection = (@selection - 1) % @labels.length
+ @changed_by_user = true
+ end
+
+ def roll_right
+ @selection = (@selection + 1) % @labels.length
+ @changed_by_user = true
+ end
+end
+
+end
diff --git a/lib/sup/index.rb b/lib/sup/index.rb
@@ -1,20 +1,27 @@
ENV["XAPIAN_FLUSH_THRESHOLD"] = "1000"
+ENV["XAPIAN_CJK_NGRAM"] = "1"
require 'xapian'
require 'set'
require 'fileutils'
require 'monitor'
+require 'chronic'
-begin
- require 'chronic'
- $have_chronic = true
-rescue LoadError => e
- debug "No 'chronic' gem detected. Install it for date/time query restrictions."
- $have_chronic = false
-end
+require "sup/util/query"
+require "sup/interactive_lock"
+require "sup/hook"
+require "sup/logger/singleton"
+
+
+if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,15]) < 0
+ fail <<-EOF
+\n
+Xapian version 1.2.15 or higher required.
+If you have xapian-full-alaveteli installed,
+Please remove it by running `gem uninstall xapian-full-alaveteli`
+since it's been replaced by the xapian-ruby gem.
-if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,1]) < 0
- fail "Xapian version 1.2.1 or higher required"
+ EOF
end
module Redwood
@@ -261,6 +268,11 @@ EOS
end
end
+ # Search messages. Returns an Enumerator.
+ def find_messages query_expr
+ enum_for :each_message, parse_query(query_expr)
+ end
+
# wrap all future changes inside a transaction so they're done atomically
def begin_transaction
synchronize { @xapian.begin_transaction }
@@ -309,6 +321,48 @@ EOS
class ParseError < StandardError; end
+ # Stemmed
+ NORMAL_PREFIX = {
+ 'subject' => {:prefix => 'S', :exclusive => false},
+ 'body' => {:prefix => 'B', :exclusive => false},
+ 'from_name' => {:prefix => 'FN', :exclusive => false},
+ 'to_name' => {:prefix => 'TN', :exclusive => false},
+ 'name' => {:prefix => %w(FN TN), :exclusive => false},
+ 'attachment' => {:prefix => 'A', :exclusive => false},
+ 'email_text' => {:prefix => 'E', :exclusive => false},
+ '' => {:prefix => %w(S B FN TN A E), :exclusive => false},
+ }
+
+ # Unstemmed
+ BOOLEAN_PREFIX = {
+ 'type' => {:prefix => 'K', :exclusive => true},
+ 'from_email' => {:prefix => 'FE', :exclusive => false},
+ 'to_email' => {:prefix => 'TE', :exclusive => false},
+ 'email' => {:prefix => %w(FE TE), :exclusive => false},
+ 'date' => {:prefix => 'D', :exclusive => true},
+ 'label' => {:prefix => 'L', :exclusive => false},
+ 'source_id' => {:prefix => 'I', :exclusive => true},
+ 'attachment_extension' => {:prefix => 'O', :exclusive => false},
+ 'msgid' => {:prefix => 'Q', :exclusive => true},
+ 'id' => {:prefix => 'Q', :exclusive => true},
+ 'thread' => {:prefix => 'H', :exclusive => false},
+ 'ref' => {:prefix => 'R', :exclusive => false},
+ 'location' => {:prefix => 'J', :exclusive => false},
+ }
+
+ PREFIX = NORMAL_PREFIX.merge BOOLEAN_PREFIX
+
+ COMPL_OPERATORS = %w[AND OR NOT]
+ COMPL_PREFIXES = (
+ %w[
+ from to
+ is has label
+ filename filetypem
+ before on in during after
+ limit
+ ] + NORMAL_PREFIX.keys + BOOLEAN_PREFIX.keys
+ ).map{|p|"#{p}:"} + COMPL_OPERATORS
+
## parse a query string from the user. returns a query object
## that can be passed to any index method with a 'query'
## argument.
@@ -388,27 +442,25 @@ EOS
end
end
- if $have_chronic
- lastdate = 2<<32 - 1
- firstdate = 0
- subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
- field, datestr = $1, ($3 || $4)
- realdate = Chronic.parse datestr, :guess => false, :context => :past
- if realdate
- case field
- when "after"
- debug "chronic: translated #{field}:#{datestr} to #{realdate.end}"
- "date:#{realdate.end.to_i}..#{lastdate}"
- when "before"
- debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}"
- "date:#{firstdate}..#{realdate.end.to_i}"
- else
- debug "chronic: translated #{field}:#{datestr} to #{realdate}"
- "date:#{realdate.begin.to_i}..#{realdate.end.to_i}"
- end
+ lastdate = 2<<32 - 1
+ firstdate = 0
+ subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
+ field, datestr = $1, ($3 || $4)
+ realdate = Chronic.parse datestr, :guess => false, :context => :past
+ if realdate
+ case field
+ when "after"
+ debug "chronic: translated #{field}:#{datestr} to #{realdate.end}"
+ "date:#{realdate.end.to_i}..#{lastdate}"
+ when "before"
+ debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}"
+ "date:#{firstdate}..#{realdate.end.to_i}"
else
- raise ParseError, "can't understand date #{datestr.inspect}"
+ debug "chronic: translated #{field}:#{datestr} to #{realdate}"
+ "date:#{realdate.begin.to_i}..#{realdate.end.to_i}"
end
+ else
+ raise ParseError, "can't understand date #{datestr.inspect}"
end
end
@@ -440,7 +492,7 @@ EOS
raise ParseError, "xapian query parser error: #{e}"
end
- debug "parsed xapian query: #{xapian_query.description}"
+ debug "parsed xapian query: #{Util::Query.describe(xapian_query)}"
raise ParseError if xapian_query.nil? or xapian_query.empty?
query[:qobj] = xapian_query
@@ -481,37 +533,6 @@ EOS
private
- # Stemmed
- NORMAL_PREFIX = {
- 'subject' => {:prefix => 'S', :exclusive => false},
- 'body' => {:prefix => 'B', :exclusive => false},
- 'from_name' => {:prefix => 'FN', :exclusive => false},
- 'to_name' => {:prefix => 'TN', :exclusive => false},
- 'name' => {:prefix => %w(FN TN), :exclusive => false},
- 'attachment' => {:prefix => 'A', :exclusive => false},
- 'email_text' => {:prefix => 'E', :exclusive => false},
- '' => {:prefix => %w(S B FN TN A E), :exclusive => false},
- }
-
- # Unstemmed
- BOOLEAN_PREFIX = {
- 'type' => {:prefix => 'K', :exclusive => true},
- 'from_email' => {:prefix => 'FE', :exclusive => false},
- 'to_email' => {:prefix => 'TE', :exclusive => false},
- 'email' => {:prefix => %w(FE TE), :exclusive => false},
- 'date' => {:prefix => 'D', :exclusive => true},
- 'label' => {:prefix => 'L', :exclusive => false},
- 'source_id' => {:prefix => 'I', :exclusive => true},
- 'attachment_extension' => {:prefix => 'O', :exclusive => false},
- 'msgid' => {:prefix => 'Q', :exclusive => true},
- 'id' => {:prefix => 'Q', :exclusive => true},
- 'thread' => {:prefix => 'H', :exclusive => false},
- 'ref' => {:prefix => 'R', :exclusive => false},
- 'location' => {:prefix => 'J', :exclusive => false},
- }
-
- PREFIX = NORMAL_PREFIX.merge BOOLEAN_PREFIX
-
MSGID_VALUENO = 0
THREAD_VALUENO = 1
DATE_VALUENO = 2
diff --git a/lib/sup/interactive-lock.rb b/lib/sup/interactive_lock.rb
diff --git a/lib/sup/label.rb b/lib/sup/label.rb
@@ -1,3 +1,5 @@
+# encoding: utf-8
+
module Redwood
class LabelManager
@@ -77,7 +79,7 @@ class LabelManager
def save
return unless @modified
- File.open(@fn, "w") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } }
+ File.open(@fn, "w:UTF-8") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } }
@new_labels = {}
end
end
diff --git a/lib/sup/logger.rb b/lib/sup/logger.rb
@@ -1,4 +1,4 @@
-require "sup"
+require "sup/util"
require 'stringio'
require 'thread'
diff --git a/lib/sup/logger/singleton.rb b/lib/sup/logger/singleton.rb
@@ -0,0 +1,10 @@
+# TODO: this is ugly. It's better to have a application singleton passed
+# down to lower level components instead of including logging methods in
+# class `Object'
+#
+# For now this is what we have to do.
+require "sup/logger"
+Redwood::Logger.init.add_sink $stderr
+class Object
+ include Redwood::LogsStuff
+end
diff --git a/lib/sup/message-chunks.rb b/lib/sup/message-chunks.rb
@@ -1,289 +0,0 @@
-require 'tempfile'
-require 'rbconfig'
-
-## Here we define all the "chunks" that a message is parsed
-## into. Chunks are used by ThreadViewMode to render a message. Chunks
-## are used for both MIME stuff like attachments, for Sup's parsing of
-## the message body into text, quote, and signature regions, and for
-## notices like "this message was decrypted" or "this message contains
-## a valid signature"---basically, anything we want to differentiate
-## at display time.
-##
-## A chunk can be inlineable, expandable, or viewable. If it's
-## inlineable, #color and #lines are called and the output is treated
-## as part of the message text. This is how Text and one-line Quotes
-## and Signatures work.
-##
-## If it's not inlineable but is expandable, #patina_color and
-## #patina_text are called to generate a "patina" (a one-line widget,
-## basically), and the user can press enter to toggle the display of
-## the chunk content, which is generated from #color and #lines as
-## above. This is how Quote, Signature, and most widgets
-## work. Exandable chunks can additionally define #initial_state to be
-## :open if they want to start expanded (default is to start collapsed).
-##
-## If it's not expandable but is viewable, a patina is displayed using
-## #patina_color and #patina_text, but no toggling is allowed. Instead,
-## if #view! is defined, pressing enter on the widget calls view! and
-## (if that returns false) #to_s. Otherwise, enter does nothing. This
-## is how non-inlineable attachments work.
-##
-## Independent of all that, a chunk can be quotable, in which case it's
-## included as quoted text during a reply. Text, Quotes, and mime-parsed
-## attachments are quotable; Signatures are not.
-
-## monkey-patch time: make temp files have the right extension
-## Backport from Ruby 1.9.2 for versions lower than 1.8.7
-if RUBY_VERSION < '1.8.7'
- class Tempfile
- def make_tmpname(prefix_suffix, n)
- case prefix_suffix
- when String
- prefix = prefix_suffix
- suffix = ""
- when Array
- prefix = prefix_suffix[0]
- suffix = prefix_suffix[1]
- else
- raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}"
- end
- t = Time.now.strftime("%Y%m%d")
- path = "#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}"
- path << "-#{n}" if n
- path << suffix
- end
- end
-end
-
-
-module Redwood
-module Chunk
- class Attachment
- HookManager.register "mime-decode", <<EOS
-Decodes a MIME attachment into text form. The text will be displayed
-directly in Sup. For attachments that you wish to use a separate program
-to view (e.g. images), you should use the mime-view hook instead.
-
-Variables:
- content_type: the content-type of the attachment
- charset: the charset of the attachment, if applicable
- filename: the filename of the attachment as saved to disk
- sibling_types: if this attachment is part of a multipart MIME attachment,
- an array of content-types for all attachments. Otherwise,
- the empty array.
-Return value:
- The decoded text of the attachment, or nil if not decoded.
-EOS
-
- HookManager.register "mime-view", <<EOS
-Views a non-text MIME attachment. This hook allows you to run
-third-party programs for attachments that require such a thing (e.g.
-images). To instead display a text version of the attachment directly in
-Sup, use the mime-decode hook instead.
-
-Note that by default (at least on systems that have a run-mailcap command),
-Sup uses the default mailcap handler for the attachment's MIME type. If
-you want a particular behavior to be global, you may wish to change your
-mailcap instead.
-
-Variables:
- content_type: the content-type of the attachment
- filename: the filename of the attachment as saved to disk
-Return value:
- True if the viewing was successful, false otherwise. If false, calling
- /usr/bin/run-mailcap will be tried.
-EOS
-#' stupid ruby-mode
-
- ## raw_content is the post-MIME-decode content. this is used for
- ## saving the attachment to disk.
- attr_reader :content_type, :filename, :lines, :raw_content
- bool_reader :quotable
-
- def initialize content_type, filename, encoded_content, sibling_types
- @content_type = content_type.downcase
- @filename = filename
- @quotable = false # changed to true if we can parse it through the
- # mime-decode hook, or if it's plain text
- @raw_content =
- if encoded_content.body
- encoded_content.decode
- else
- "For some bizarre reason, RubyMail was unable to parse this attachment.\n"
- end
-
- text = case @content_type
- when /^text\/plain\b/
- @raw_content
- else
- HookManager.run "mime-decode", :content_type => content_type,
- :filename => lambda { write_to_disk },
- :charset => encoded_content.charset,
- :sibling_types => sibling_types
- end
-
- @lines = nil
- if text
- text = text.transcode(encoded_content.charset || $encoding)
- @lines = text.gsub("\r\n", "\n").gsub(/\t/, " ").gsub(/\r/, "").split("\n")
- @quotable = true
- end
- end
-
- def color; :none end
- def patina_color; :attachment_color end
- def patina_text
- if expandable?
- "Attachment: #{filename} (#{lines.length} lines)"
- else
- "Attachment: #{filename} (#{content_type}; #{@raw_content.size.to_human_size})"
- end
- end
-
- ## an attachment is exapndable if we've managed to decode it into
- ## something we can display inline. otherwise, it's viewable.
- def inlineable?; false end
- def expandable?; !viewable? end
- def initial_state; :open end
- def viewable?; @lines.nil? end
- def view_default! path
- case RbConfig::CONFIG['arch']
- when /darwin/
- cmd = "open '#{path}'"
- else
- cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}'"
- end
- debug "running: #{cmd.inspect}"
- BufferManager.shell_out(cmd)
- $? == 0
- end
-
- def view!
- path = write_to_disk
- ret = HookManager.run "mime-view", :content_type => @content_type,
- :filename => path
- ret || view_default!(path)
- end
-
- def write_to_disk
- file = Tempfile.new(["sup", @filename.gsub("/", "_") || "sup-attachment"])
- file.print @raw_content
- file.close
- file.path
- end
-
- ## used when viewing the attachment as text
- def to_s
- @lines || @raw_content
- end
- end
-
- class Text
-
- attr_reader :lines
- def initialize lines
- @lines = lines
- ## trim off all empty lines except one
- @lines.pop while @lines.length > 1 && @lines[-1] =~ /^\s*$/ && @lines[-2] =~ /^\s*$/
- end
-
- def inlineable?; true end
- def quotable?; true end
- def expandable?; false end
- def viewable?; false end
- def color; :none end
- end
-
- class Quote
- attr_reader :lines
- def initialize lines
- @lines = lines
- end
-
- def inlineable?; @lines.length == 1 end
- def quotable?; true end
- def expandable?; !inlineable? end
- def viewable?; false end
-
- def patina_color; :quote_patina_color end
- def patina_text; "(#{lines.length} quoted lines)" end
- def color; :quote_color end
- end
-
- class Signature
- attr_reader :lines
- def initialize lines
- @lines = lines
- end
-
- def inlineable?; @lines.length == 1 end
- def quotable?; false end
- def expandable?; !inlineable? end
- def viewable?; false end
-
- def patina_color; :sig_patina_color end
- def patina_text; "(#{lines.length}-line signature)" end
- def color; :sig_color end
- end
-
- class EnclosedMessage
- attr_reader :lines
- def initialize from, to, cc, date, subj
- @from = from ? "unknown sender" : from.full_adress
- @to = to ? "" : to.map { |p| p.full_address }.join(", ")
- @cc = cc ? "" : cc.map { |p| p.full_address }.join(", ")
- if date
- @date = date.rfc822
- else
- @date = ""
- end
-
- @subj = subj
-
- @lines = "\nFrom: #{from}\n"
- @lines += "To: #{to}\n"
- if !cc.empty?
- @lines += "Cc: #{cc}\n"
- end
- @lines += "Date: #{date}\n"
- @lines += "Subject: #{subj}\n\n"
- end
-
- def inlineable?; false end
- def quotable?; false end
- def expandable?; true end
- def initial_state; :closed end
- def viewable?; false end
-
- def patina_color; :generic_notice_patina_color end
- def patina_text; "Begin enclosed message sent on #{@date}" end
-
- def color; :quote_color end
- end
-
- class CryptoNotice
- attr_reader :lines, :status, :patina_text
-
- def initialize status, description, lines=[]
- @status = status
- @patina_text = description
- @lines = lines
- end
-
- def patina_color
- case status
- when :valid then :cryptosig_valid_color
- when :valid_untrusted then :cryptosig_valid_untrusted_color
- when :invalid then :cryptosig_invalid_color
- else :cryptosig_unknown_color
- end
- end
- def color; patina_color end
-
- def inlineable?; false end
- def quotable?; false end
- def expandable?; !@lines.empty? end
- def viewable?; false end
- end
-end
-end
diff --git a/lib/sup/message.rb b/lib/sup/message.rb
@@ -69,7 +69,9 @@ class Message
return unless v
return v unless v.is_a? String
return unless v.size < MAX_HEADER_VALUE_SIZE # avoid regex blowup on spam
- Rfc2047.decode_to $encoding, Iconv.easy_decode($encoding, 'ASCII', v)
+ d = v.dup
+ d = d.transcode($encoding, 'ASCII')
+ Rfc2047.decode_to $encoding, d
end
def parse_header encoded_header
@@ -109,7 +111,9 @@ class Message
Time.now
end
- @subj = header["subject"] ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
+ subj = header["subject"]
+ subj = subj ? subj.fix_encoding : nil
+ @subj = subj ? subj.gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
@to = Person.from_address_list header["to"]
@cc = Person.from_address_list header["cc"]
@bcc = Person.from_address_list header["bcc"]
@@ -260,6 +264,8 @@ class Message
rescue SourceError, SocketError, RMail::EncodingUnsupportedError => e
warn "problem reading message #{id}"
[Chunk::Text.new(error_message.split("\n"))]
+
+ debug "could not load message: #{location.inspect}, exception: #{e.inspect}"
end
end
@@ -524,7 +530,7 @@ private
## if there's no charset, use the current encoding as the charset.
## this ensures that the body is normalized to avoid non-displayable
## characters
- body = Iconv.easy_decode($encoding, m.charset || $encoding, m.decode)
+ body = m.decode.transcode($encoding, m.charset)
else
body = ""
end
@@ -540,11 +546,13 @@ private
def inline_gpg_to_chunks body, encoding_to, encoding_from
lines = body.split("\n")
gpg = lines.between(GPG_SIGNED_START, GPG_SIGNED_END)
- if !gpg.empty?
+ # between does not check if GPG_END actually exists
+ # Reference: http://permalink.gmane.org/gmane.mail.sup.devel/641
+ if !gpg.empty? && !lines.index(GPG_END).nil?
msg = RMail::Message.new
msg.body = gpg.join("\n")
- body = Iconv.easy_decode(encoding_to, encoding_from, body)
+ body = body.transcode(encoding_to, encoding_from)
lines = body.split("\n")
sig = lines.between(GPG_SIGNED_START, GPG_SIG_START)
startidx = lines.index(GPG_SIGNED_START)
diff --git a/lib/sup/message_chunks.rb b/lib/sup/message_chunks.rb
@@ -0,0 +1,289 @@
+require 'tempfile'
+require 'rbconfig'
+
+## Here we define all the "chunks" that a message is parsed
+## into. Chunks are used by ThreadViewMode to render a message. Chunks
+## are used for both MIME stuff like attachments, for Sup's parsing of
+## the message body into text, quote, and signature regions, and for
+## notices like "this message was decrypted" or "this message contains
+## a valid signature"---basically, anything we want to differentiate
+## at display time.
+##
+## A chunk can be inlineable, expandable, or viewable. If it's
+## inlineable, #color and #lines are called and the output is treated
+## as part of the message text. This is how Text and one-line Quotes
+## and Signatures work.
+##
+## If it's not inlineable but is expandable, #patina_color and
+## #patina_text are called to generate a "patina" (a one-line widget,
+## basically), and the user can press enter to toggle the display of
+## the chunk content, which is generated from #color and #lines as
+## above. This is how Quote, Signature, and most widgets
+## work. Exandable chunks can additionally define #initial_state to be
+## :open if they want to start expanded (default is to start collapsed).
+##
+## If it's not expandable but is viewable, a patina is displayed using
+## #patina_color and #patina_text, but no toggling is allowed. Instead,
+## if #view! is defined, pressing enter on the widget calls view! and
+## (if that returns false) #to_s. Otherwise, enter does nothing. This
+## is how non-inlineable attachments work.
+##
+## Independent of all that, a chunk can be quotable, in which case it's
+## included as quoted text during a reply. Text, Quotes, and mime-parsed
+## attachments are quotable; Signatures are not.
+
+## monkey-patch time: make temp files have the right extension
+## Backport from Ruby 1.9.2 for versions lower than 1.8.7
+if RUBY_VERSION < '1.8.7'
+ class Tempfile
+ def make_tmpname(prefix_suffix, n)
+ case prefix_suffix
+ when String
+ prefix = prefix_suffix
+ suffix = ""
+ when Array
+ prefix = prefix_suffix[0]
+ suffix = prefix_suffix[1]
+ else
+ raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}"
+ end
+ t = Time.now.strftime("%Y%m%d")
+ path = "#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}"
+ path << "-#{n}" if n
+ path << suffix
+ end
+ end
+end
+
+
+module Redwood
+module Chunk
+ class Attachment
+ HookManager.register "mime-decode", <<EOS
+Decodes a MIME attachment into text form. The text will be displayed
+directly in Sup. For attachments that you wish to use a separate program
+to view (e.g. images), you should use the mime-view hook instead.
+
+Variables:
+ content_type: the content-type of the attachment
+ charset: the charset of the attachment, if applicable
+ filename: the filename of the attachment as saved to disk
+ sibling_types: if this attachment is part of a multipart MIME attachment,
+ an array of content-types for all attachments. Otherwise,
+ the empty array.
+Return value:
+ The decoded text of the attachment, or nil if not decoded.
+EOS
+
+ HookManager.register "mime-view", <<EOS
+Views a non-text MIME attachment. This hook allows you to run
+third-party programs for attachments that require such a thing (e.g.
+images). To instead display a text version of the attachment directly in
+Sup, use the mime-decode hook instead.
+
+Note that by default (at least on systems that have a run-mailcap command),
+Sup uses the default mailcap handler for the attachment's MIME type. If
+you want a particular behavior to be global, you may wish to change your
+mailcap instead.
+
+Variables:
+ content_type: the content-type of the attachment
+ filename: the filename of the attachment as saved to disk
+Return value:
+ True if the viewing was successful, false otherwise. If false, calling
+ /usr/bin/run-mailcap will be tried.
+EOS
+#' stupid ruby-mode
+
+ ## raw_content is the post-MIME-decode content. this is used for
+ ## saving the attachment to disk.
+ attr_reader :content_type, :filename, :lines, :raw_content
+ bool_reader :quotable
+
+ def initialize content_type, filename, encoded_content, sibling_types
+ @content_type = content_type.downcase
+ @filename = filename
+ @quotable = false # changed to true if we can parse it through the
+ # mime-decode hook, or if it's plain text
+ @raw_content =
+ if encoded_content.body
+ encoded_content.decode
+ else
+ "For some bizarre reason, RubyMail was unable to parse this attachment.\n"
+ end
+
+ text = case @content_type
+ when /^text\/plain\b/
+ @raw_content
+ else
+ HookManager.run "mime-decode", :content_type => content_type,
+ :filename => lambda { write_to_disk },
+ :charset => encoded_content.charset,
+ :sibling_types => sibling_types
+ end
+
+ @lines = nil
+ if text
+ text = text.transcode(encoded_content.charset || $encoding, text.encoding)
+ @lines = text.gsub("\r\n", "\n").gsub(/\t/, " ").gsub(/\r/, "").split("\n")
+ @quotable = true
+ end
+ end
+
+ def color; :text_color end
+ def patina_color; :attachment_color end
+ def patina_text
+ if expandable?
+ "Attachment: #{filename} (#{lines.length} lines)"
+ else
+ "Attachment: #{filename} (#{content_type}; #{@raw_content.size.to_human_size})"
+ end
+ end
+
+ ## an attachment is exapndable if we've managed to decode it into
+ ## something we can display inline. otherwise, it's viewable.
+ def inlineable?; false end
+ def expandable?; !viewable? end
+ def initial_state; :open end
+ def viewable?; @lines.nil? end
+ def view_default! path
+ case RbConfig::CONFIG['arch']
+ when /darwin/
+ cmd = "open '#{path}'"
+ else
+ cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}'"
+ end
+ debug "running: #{cmd.inspect}"
+ BufferManager.shell_out(cmd)
+ $? == 0
+ end
+
+ def view!
+ path = write_to_disk
+ ret = HookManager.run "mime-view", :content_type => @content_type,
+ :filename => path
+ ret || view_default!(path)
+ end
+
+ def write_to_disk
+ file = Tempfile.new(["sup", @filename.gsub("/", "_") || "sup-attachment"])
+ file.print @raw_content
+ file.close
+ file.path
+ end
+
+ ## used when viewing the attachment as text
+ def to_s
+ @lines || @raw_content
+ end
+ end
+
+ class Text
+
+ attr_reader :lines
+ def initialize lines
+ @lines = lines
+ ## trim off all empty lines except one
+ @lines.pop while @lines.length > 1 && @lines[-1] =~ /^\s*$/ && @lines[-2] =~ /^\s*$/
+ end
+
+ def inlineable?; true end
+ def quotable?; true end
+ def expandable?; false end
+ def viewable?; false end
+ def color; :text_color end
+ end
+
+ class Quote
+ attr_reader :lines
+ def initialize lines
+ @lines = lines
+ end
+
+ def inlineable?; @lines.length == 1 end
+ def quotable?; true end
+ def expandable?; !inlineable? end
+ def viewable?; false end
+
+ def patina_color; :quote_patina_color end
+ def patina_text; "(#{lines.length} quoted lines)" end
+ def color; :quote_color end
+ end
+
+ class Signature
+ attr_reader :lines
+ def initialize lines
+ @lines = lines
+ end
+
+ def inlineable?; @lines.length == 1 end
+ def quotable?; false end
+ def expandable?; !inlineable? end
+ def viewable?; false end
+
+ def patina_color; :sig_patina_color end
+ def patina_text; "(#{lines.length}-line signature)" end
+ def color; :sig_color end
+ end
+
+ class EnclosedMessage
+ attr_reader :lines
+ def initialize from, to, cc, date, subj
+ @from = from ? "unknown sender" : from.full_adress
+ @to = to ? "" : to.map { |p| p.full_address }.join(", ")
+ @cc = cc ? "" : cc.map { |p| p.full_address }.join(", ")
+ if date
+ @date = date.rfc822
+ else
+ @date = ""
+ end
+
+ @subj = subj
+
+ @lines = "\nFrom: #{from}\n"
+ @lines += "To: #{to}\n"
+ if !cc.empty?
+ @lines += "Cc: #{cc}\n"
+ end
+ @lines += "Date: #{date}\n"
+ @lines += "Subject: #{subj}\n\n"
+ end
+
+ def inlineable?; false end
+ def quotable?; false end
+ def expandable?; true end
+ def initial_state; :closed end
+ def viewable?; false end
+
+ def patina_color; :generic_notice_patina_color end
+ def patina_text; "Begin enclosed message sent on #{@date}" end
+
+ def color; :quote_color end
+ end
+
+ class CryptoNotice
+ attr_reader :lines, :status, :patina_text
+
+ def initialize status, description, lines=[]
+ @status = status
+ @patina_text = description
+ @lines = lines
+ end
+
+ def patina_color
+ case status
+ when :valid then :cryptosig_valid_color
+ when :valid_untrusted then :cryptosig_valid_untrusted_color
+ when :invalid then :cryptosig_invalid_color
+ else :cryptosig_unknown_color
+ end
+ end
+ def color; patina_color end
+
+ def inlineable?; false end
+ def quotable?; false end
+ def expandable?; !@lines.empty? end
+ def viewable?; false end
+ end
+end
+end
diff --git a/lib/sup/modes/buffer-list-mode.rb b/lib/sup/modes/buffer_list_mode.rb
diff --git a/lib/sup/modes/completion-mode.rb b/lib/sup/modes/completion-mode.rb
@@ -1,55 +0,0 @@
-module Redwood
-
-class CompletionMode < ScrollMode
- INTERSTITIAL = " "
-
- def initialize list, opts={}
- @list = list
- @header = opts[:header]
- @prefix_len = opts[:prefix_len]
- @lines = nil
- super :slip_rows => 1, :twiddles => false
- end
-
- def lines
- update_lines unless @lines
- @lines.length
- end
-
- def [] i
- update_lines unless @lines
- @lines[i]
- end
-
- def roll; if at_bottom? then jump_to_start else page_down end end
-
-private
-
- def update_lines
- width = buffer.content_width
- max_length = @list.max_of { |s| s.length }
- num_per = [1, buffer.content_width / (max_length + INTERSTITIAL.length)].max
- @lines = [@header].compact
- @list.each_with_index do |s, i|
- if @prefix_len
- @lines << [] if i % num_per == 0
- if @prefix_len < s.length
- prefix = s[0 ... @prefix_len]
- suffix = s[(@prefix_len + 1) .. -1]
- char = s[@prefix_len].chr
-
- @lines.last += [[:none, sprintf("%#{max_length - suffix.length - 1}s", prefix)],
- [:completion_character_color, char],
- [:none, suffix + INTERSTITIAL]]
- else
- @lines.last += [[:none, sprintf("%#{max_length}s#{INTERSTITIAL}", s)]]
- end
- else
- @lines << "" if i % num_per == 0
- @lines.last += sprintf "%#{max_length}s#{INTERSTITIAL}", s
- end
- end
- end
-end
-
-end
diff --git a/lib/sup/modes/completion_mode.rb b/lib/sup/modes/completion_mode.rb
@@ -0,0 +1,55 @@
+module Redwood
+
+class CompletionMode < ScrollMode
+ INTERSTITIAL = " "
+
+ def initialize list, opts={}
+ @list = list
+ @header = opts[:header]
+ @prefix_len = opts[:prefix_len]
+ @lines = nil
+ super :slip_rows => 1, :twiddles => false
+ end
+
+ def lines
+ update_lines unless @lines
+ @lines.length
+ end
+
+ def [] i
+ update_lines unless @lines
+ @lines[i]
+ end
+
+ def roll; if at_bottom? then jump_to_start else page_down end end
+
+private
+
+ def update_lines
+ width = buffer.content_width
+ max_length = @list.max_of { |s| s.length }
+ num_per = [1, buffer.content_width / (max_length + INTERSTITIAL.length)].max
+ @lines = [@header].compact
+ @list.each_with_index do |s, i|
+ if @prefix_len
+ @lines << [] if i % num_per == 0
+ if @prefix_len < s.length
+ prefix = s[0 ... @prefix_len]
+ suffix = s[(@prefix_len + 1) .. -1]
+ char = s[@prefix_len].chr
+
+ @lines.last += [[:text_color, sprintf("%#{max_length - suffix.length - 1}s", prefix)],
+ [:completion_character_color, char],
+ [:text_color, suffix + INTERSTITIAL]]
+ else
+ @lines.last += [[:text_color, sprintf("%#{max_length}s#{INTERSTITIAL}", s)]]
+ end
+ else
+ @lines << "" if i % num_per == 0
+ @lines.last += sprintf "%#{max_length}s#{INTERSTITIAL}", s
+ end
+ end
+ end
+end
+
+end
diff --git a/lib/sup/modes/compose-mode.rb b/lib/sup/modes/compose-mode.rb
@@ -1,36 +0,0 @@
-module Redwood
-
-class ComposeMode < EditMessageMode
- def initialize opts={}
- header = {}
- header["From"] = (opts[:from] || AccountManager.default_account).full_address
- header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
- header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
- header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
- header["Subject"] = opts[:subj] if opts[:subj]
- header["References"] = opts[:refs].map { |r| "<#{r}>" }.join(" ") if opts[:refs]
- header["In-Reply-To"] = opts[:replytos].map { |r| "<#{r}>" }.join(" ") if opts[:replytos]
-
- super :header => header, :body => (opts[:body] || [])
- end
-
- def edit_message
- edited = super
- BufferManager.kill_buffer self.buffer unless edited
- edited
- end
-
- def self.spawn_nicely opts={}
- from = opts[:from] || (BufferManager.ask_for_account(:account, "From (default #{AccountManager.default_account.email}): ") or return if $config[:ask_for_from])
- to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ", [opts[:to_default]]) or return if ($config[:ask_for_to] != false))
- cc = opts[:cc] || (BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc])
- bcc = opts[:bcc] || (BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc])
- subj = opts[:subj] || (BufferManager.ask(:subject, "Subject: ") or return if $config[:ask_for_subject])
-
- mode = ComposeMode.new :from => from, :to => to, :cc => cc, :bcc => bcc, :subj => subj
- BufferManager.spawn "New Message", mode
- mode.edit_message
- end
-end
-
-end
diff --git a/lib/sup/modes/compose_mode.rb b/lib/sup/modes/compose_mode.rb
@@ -0,0 +1,38 @@
+# encoding: utf-8
+
+module Redwood
+
+class ComposeMode < EditMessageMode
+ def initialize opts={}
+ header = {}
+ header["From"] = (opts[:from] || AccountManager.default_account).full_address
+ header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
+ header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
+ header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
+ header["Subject"] = opts[:subj] if opts[:subj]
+ header["References"] = opts[:refs].map { |r| "<#{r}>" }.join(" ") if opts[:refs]
+ header["In-Reply-To"] = opts[:replytos].map { |r| "<#{r}>" }.join(" ") if opts[:replytos]
+
+ super :header => header, :body => (opts[:body] || [])
+ end
+
+ def edit_message
+ edited = super
+ BufferManager.kill_buffer self.buffer unless edited
+ edited
+ end
+
+ def self.spawn_nicely opts={}
+ from = opts[:from] || (BufferManager.ask_for_account(:account, "From (default #{AccountManager.default_account.email}): ") or return if $config[:ask_for_from])
+ to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ", [opts[:to_default]]) or return if ($config[:ask_for_to] != false))
+ cc = opts[:cc] || (BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc])
+ bcc = opts[:bcc] || (BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc])
+ subj = opts[:subj] || (BufferManager.ask(:subject, "Subject: ") or return if $config[:ask_for_subject])
+
+ mode = ComposeMode.new :from => from, :to => to, :cc => cc, :bcc => bcc, :subj => subj
+ BufferManager.spawn "New Message", mode
+ mode.edit_message
+ end
+end
+
+end
diff --git a/lib/sup/modes/console-mode.rb b/lib/sup/modes/console-mode.rb
@@ -1,114 +0,0 @@
-require 'pp'
-
-module Redwood
-
-class Console
- def initialize mode
- @mode = mode
- end
-
- def query(query)
- Enumerator.new(Index.instance, :each_message, Index.parse_query(query))
- end
-
- def add_labels(query, *labels)
- query(query).each { |m| m.labels += labels; m.save Index }
- end
-
- def remove_labels(query, *labels)
- query(query).each { |m| m.labels -= labels; m.save Index }
- end
-
- def xapian; Index.instance.instance_variable_get :@xapian; end
-
- def loglevel; Redwood::Logger.level; end
- def set_loglevel(level); Redwood::Logger.level = level; end
-
- def special_methods; methods - Object.methods end
-
- def puts x; @mode << "#{x.to_s.rstrip}\n" end
- def p x; puts x.inspect end
-
- ## files that won't cause problems when reloaded
- ## TODO expand this list / convert to blacklist
- RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb)
-
- def reload
- old_verbose = $VERBOSE
- $VERBOSE = nil
- old_features = $".dup
- begin
- fs = $".grep(/^sup\//)
- fs.reject! { |f| not RELOAD_WHITELIST.member? f }
- fs.each { |f| $".delete f }
- fs.each do |f|
- @mode << "reloading #{f}\n"
- begin
- require f
- rescue LoadError => e
- raise unless e.message =~ /no such file to load/
- end
- end
- rescue Exception
- $".clear
- $".concat old_features
- raise
- ensure
- $VERBOSE = old_verbose
- end
- true
- end
-
- def clear_hooks
- HookManager.clear
- nil
- end
-end
-
-class ConsoleMode < LogMode
- register_keymap do |k|
- k.add :run, "Restart evaluation", 'e'
- end
-
- def initialize
- super "console"
- @console = Console.new self
- @binding = @console.instance_eval { binding }
- end
-
- def execute cmd
- begin
- self << ">> #{cmd}\n"
- ret = eval cmd, @binding
- self << "=> #{ret.pretty_inspect}\n"
- rescue Exception
- self << "#{$!.class}: #{$!.message}\n"
- clean_backtrace = []
- $!.backtrace.each { |l| break if l =~ /console-mode/; clean_backtrace << l }
- clean_backtrace.each { |l| self << "#{l}\n" }
- end
- end
-
- def prompt
- BufferManager.ask :console, ">> "
- end
-
- def run
- self << <<EOS
-Sup v#{VERSION} console session started.
-Available extra commands: #{(@console.special_methods) * ", "}
-Ctrl-G stops evaluation; 'e' restarts it.
-
-EOS
- while true
- if(cmd = prompt)
- execute cmd
- else
- self << "Console session ended."
- break
- end
- end
- end
-end
-
-end
diff --git a/lib/sup/modes/console_mode.rb b/lib/sup/modes/console_mode.rb
@@ -0,0 +1,125 @@
+require 'pp'
+
+require "sup/service/label_service"
+
+module Redwood
+
+class Console
+ def initialize mode
+ @mode = mode
+ @label_service = LabelService.new
+ end
+
+ def query(query)
+ Enumerator.new(Index.instance, :each_message, Index.parse_query(query))
+ end
+
+ def add_labels(query, *labels)
+ count = @label_service.add_labels(query, *labels)
+ print_buffer_dirty_msg count
+ end
+
+ def remove_labels(query, *labels)
+ count = @label_service.remove_labels(query, *labels)
+ print_buffer_dirty_msg count
+ end
+
+ def print_buffer_dirty_msg msg_count
+ puts "Scanned #{msg_count} messages."
+ puts "You might want to refresh open buffers with `@` key."
+ end
+ private :print_buffer_dirty_msg
+
+ def xapian; Index.instance.instance_variable_get :@xapian; end
+
+ def loglevel; Redwood::Logger.level; end
+ def set_loglevel(level); Redwood::Logger.level = level; end
+
+ def special_methods; public_methods - Object.methods end
+
+ def puts x; @mode << "#{x.to_s.rstrip}\n" end
+ def p x; puts x.inspect end
+
+ ## files that won't cause problems when reloaded
+ ## TODO expand this list / convert to blacklist
+ RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb)
+
+ def reload
+ old_verbose = $VERBOSE
+ $VERBOSE = nil
+ old_features = $".dup
+ begin
+ fs = $".grep(/^sup\//)
+ fs.reject! { |f| not RELOAD_WHITELIST.member? f }
+ fs.each { |f| $".delete f }
+ fs.each do |f|
+ @mode << "reloading #{f}\n"
+ begin
+ require f
+ rescue LoadError => e
+ raise unless e.message =~ /no such file to load/
+ end
+ end
+ rescue Exception
+ $".clear
+ $".concat old_features
+ raise
+ ensure
+ $VERBOSE = old_verbose
+ end
+ true
+ end
+
+ def clear_hooks
+ HookManager.clear
+ nil
+ end
+end
+
+class ConsoleMode < LogMode
+ register_keymap do |k|
+ k.add :run, "Restart evaluation", 'e'
+ end
+
+ def initialize
+ super "console"
+ @console = Console.new self
+ @binding = @console.instance_eval { binding }
+ end
+
+ def execute cmd
+ begin
+ self << ">> #{cmd}\n"
+ ret = eval cmd, @binding
+ self << "=> #{ret.pretty_inspect}\n"
+ rescue Exception
+ self << "#{$!.class}: #{$!.message}\n"
+ clean_backtrace = []
+ $!.backtrace.each { |l| break if l =~ /console-mode/; clean_backtrace << l }
+ clean_backtrace.each { |l| self << "#{l}\n" }
+ end
+ end
+
+ def prompt
+ BufferManager.ask :console, ">> "
+ end
+
+ def run
+ self << <<EOS
+Sup v#{VERSION} console session started.
+Available extra commands: #{(@console.special_methods) * ", "}
+Ctrl-G stops evaluation; 'e' restarts it.
+
+EOS
+ while true
+ if(cmd = prompt)
+ execute cmd
+ else
+ self << "Console session ended."
+ break
+ end
+ end
+ end
+end
+
+end
diff --git a/lib/sup/modes/contact-list-mode.rb b/lib/sup/modes/contact_list_mode.rb
diff --git a/lib/sup/modes/edit-message-mode.rb b/lib/sup/modes/edit-message-mode.rb
@@ -1,679 +0,0 @@
-require 'tempfile'
-require 'socket' # just for gethostname!
-require 'pathname'
-
-module Redwood
-
-class SendmailCommandFailed < StandardError; end
-
-class EditMessageMode < LineCursorMode
- DECORATION_LINES = 1
-
- FORCE_HEADERS = %w(From To Cc Bcc Subject)
- MULTI_HEADERS = %w(To Cc Bcc)
- NON_EDITABLE_HEADERS = %w(Message-id Date)
-
- HookManager.register "signature", <<EOS
-Generates a message signature.
-Variables:
- header: an object that supports string-to-string hashtable-style access
- to the raw headers for the message. E.g., header["From"],
- header["To"], etc.
- from_email: the email part of the From: line, or nil if empty
-Return value:
- A string (multi-line ok) containing the text of the signature, or nil to
- use the default signature, or :none for no signature.
-EOS
-
- HookManager.register "before-edit", <<EOS
-Modifies message body and headers before editing a new message. Variables
-should be modified in place.
-Variables:
- header: a hash of headers. See 'signature' hook for documentation.
- body: an array of lines of body text.
-Return value:
- none
-EOS
-
- HookManager.register "mentions-attachments", <<EOS
-Detects if given message mentions attachments the way it is probable
-that there should be files attached to the message.
-Variables:
- header: a hash of headers. See 'signature' hook for documentation.
- body: an array of lines of body text.
-Return value:
- True if attachments are mentioned.
-EOS
-
- HookManager.register "crypto-mode", <<EOS
-Modifies cryptography settings based on header and message content, before
-editing a new message. This can be used to set, for example, default cryptography
-settings.
-Variables:
- header: a hash of headers. See 'signature' hook for documentation.
- body: an array of lines of body text.
- crypto_selector: the UI element that controls the current cryptography setting.
-Return value:
- none
-EOS
-
- HookManager.register "sendmail", <<EOS
-Sends the given mail. If this hook doesn't exist, the sendmail command
-configured for the account is used.
-The message will be saved after this hook is run, so any modification to it
-will be recorded.
-Variables:
- message: RMail::Message instance of the mail to send
- account: Account instance matching the From address
-Return value:
- True if mail has been sent successfully, false otherwise.
-EOS
-
- attr_reader :status
- attr_accessor :body, :header
- bool_reader :edited
-
- register_keymap do |k|
- k.add :send_message, "Send message", 'y'
- k.add :edit_message_or_field, "Edit selected field", 'e'
- k.add :edit_to, "Edit To:", 't'
- k.add :edit_cc, "Edit Cc:", 'c'
- k.add :edit_subject, "Edit Subject", 's'
- k.add :edit_message, "Edit message", :enter
- k.add :edit_message_async, "Edit message asynchronously", 'E'
- k.add :save_as_draft, "Save as draft", 'P'
- k.add :attach_file, "Attach a file", 'a'
- k.add :delete_attachment, "Delete an attachment", 'd'
- k.add :move_cursor_right, "Move selector to the right", :right, 'l'
- k.add :move_cursor_left, "Move selector to the left", :left, 'h'
- end
-
- def initialize opts={}
- @header = opts.delete(:header) || {}
- @header_lines = []
-
- @body = opts.delete(:body) || []
-
- if opts[:attachments]
- @attachments = opts[:attachments].values
- @attachment_names = opts[:attachments].keys
- else
- @attachments = []
- @attachment_names = []
- end
-
- begin
- hostname = File.open("/etc/mailname", "r").gets.chomp
- rescue
- nil
- end
- hostname = Socket.gethostname if hostname.nil? or hostname.empty?
-
- @message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{hostname}>"
- @edited = false
- @sig_edited = false
- @selectors = []
- @selector_label_width = 0
- @async_mode = nil
-
- HookManager.run "before-edit", :header => @header, :body => @body
-
- @account_selector = nil
- # only show account selector if there is more than one email address
- if $config[:account_selector] && AccountManager.user_emails.length > 1
- ## Duplicate e-mail strings to prevent a "can't modify frozen
- ## object" crash triggered by the String::display_length()
- ## method in util.rb
- user_emails_copy = []
- AccountManager.user_emails.each { |e| user_emails_copy.push e.dup }
-
- @account_selector =
- HorizontalSelector.new "Account:", AccountManager.user_emails + [nil], user_emails_copy + ["Customized"]
-
- if @header["From"] =~ /<?(\S+@(\S+?))>?$/
- @account_selector.set_to $1
- @account_user = ""
- else
- @account_selector.set_to nil
- @account_user = @header["From"]
- end
-
- add_selector @account_selector
- end
-
- @crypto_selector =
- if CryptoManager.have_crypto?
- HorizontalSelector.new "Crypto:", [:none] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.keys, ["None"] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.values
- end
- add_selector @crypto_selector if @crypto_selector
-
- if @crypto_selector
- HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
- end
-
- super opts
- regen_text
- end
-
- def lines; @text.length + (@selectors.empty? ? 0 : (@selectors.length + DECORATION_LINES)) end
-
- def [] i
- if @selectors.empty?
- @text[i]
- elsif i < @selectors.length
- @selectors[i].line @selector_label_width
- elsif i == @selectors.length
- ""
- else
- @text[i - @selectors.length - DECORATION_LINES]
- end
- end
-
- ## hook for subclasses. i hate this style of programming.
- def handle_new_text header, body; end
-
- def edit_message_or_field
- lines = DECORATION_LINES + @selectors.size
- if lines > curpos
- return
- elsif (curpos - lines) >= @header_lines.length
- edit_message
- else
- edit_field @header_lines[curpos - lines]
- end
- end
-
- def edit_to; edit_field "To" end
- def edit_cc; edit_field "Cc" end
- def edit_subject; edit_field "Subject" end
-
- def save_message_to_file
- sig = sig_lines.join("\n")
- @file = Tempfile.new ["sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}", ".eml"]
- @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
- @file.puts
- @file.puts @body.join("\n")
- @file.puts sig if ($config[:edit_signature] and !@sig_edited)
- @file.close
- end
-
- def set_sig_edit_flag
- sig = sig_lines.join("\n")
- if $config[:edit_signature]
- pbody = @body.join("\n")
- blen = pbody.length
- slen = sig.length
-
- if blen > slen and pbody[blen-slen..blen] == sig
- @sig_edited = false
- @body = pbody[0..blen-slen].split("\n")
- else
- @sig_edited = true
- end
- end
- end
-
- def edit_message
- old_from = @header["From"] if @account_selector
-
- begin
- save_message_to_file
- rescue SystemCallError => e
- BufferManager.flash "Can't save message to file: #{e.message}"
- return
- end
-
- 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
-
- return @edited unless @edited
-
- header, @body = parse_file @file.path
- @header = header - NON_EDITABLE_HEADERS
- set_sig_edit_flag
-
- if @account_selector and @header["From"] != old_from
- @account_user = @header["From"]
- @account_selector.set_to nil
- end
-
- handle_new_text @header, @body
- rerun_crypto_selector_hook
- update
-
- @edited
- end
-
- def edit_message_async
- begin
- save_message_to_file
- rescue SystemCallError => e
- BufferManager.flash "Can't save message to file: #{e.message}"
- return
- end
-
- @mtime = File.mtime @file.path
-
- # put up buffer saying you can now edit the message in another
- # terminal or app, and continue to use sup in the meantime.
- subject = @header["Subject"] || ""
- @async_mode = EditMessageAsyncMode.new self, @file.path, subject
- BufferManager.spawn "Waiting for message \"#{subject}\" to be finished", @async_mode
-
- # hide ourselves, and wait for signal to resume from async mode ...
- buffer.hidden = true
- end
-
- def edit_message_async_resume being_killed=false
- buffer.hidden = false
- @async_mode = nil
- BufferManager.raise_to_front buffer if !being_killed
-
- @edited = true if File.mtime(@file.path) > @mtime
-
- header, @body = parse_file @file.path
- @header = header - NON_EDITABLE_HEADERS
- set_sig_edit_flag
- handle_new_text @header, @body
- update
-
- true
- end
-
- def killable?
- if !@async_mode.nil?
- return false if !@async_mode.killable?
- if File.mtime(@file.path) > @mtime
- @edited = true
- header, @body = parse_file @file.path
- @header = header - NON_EDITABLE_HEADERS
- handle_new_text @header, @body
- update
- end
- end
- !edited? || BufferManager.ask_yes_or_no("Discard message?")
- end
-
- def unsaved?; edited? end
-
- def attach_file
- fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
- return unless fn
- begin
- Dir[fn].each do |f|
- @attachments << RMail::Message.make_file_attachment(f)
- @attachment_names << f
- end
- update
- rescue SystemCallError => e
- BufferManager.flash "Can't read #{fn}: #{e.message}"
- end
- end
-
- def delete_attachment
- i = curpos - @attachment_lines_offset - DECORATION_LINES - 2
- if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachment_names[i]}?")
- @attachments.delete_at i
- @attachment_names.delete_at i
- update
- end
- end
-
-protected
-
- def rerun_crypto_selector_hook
- if @crypto_selector && !@crypto_selector.changed_by_user
- HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
- end
- end
-
- def mime_encode string
- string = [string].pack('M') # basic quoted-printable
- string.gsub!(/=\n/,'') # .. remove trailing newline
- string.gsub!(/_/,'=5F') # .. encode underscores
- string.gsub!(/\?/,'=3F') # .. encode question marks
- string.gsub!(/ /,'_') # .. translate space to underscores
- "=?utf-8?q?#{string}?="
- end
-
- def mime_encode_subject string
- return string if string.ascii_only?
- mime_encode string
- end
-
- RE_ADDRESS = /(.+)( <.*@.*>)/
-
- # Encode "bælammet mitt <user@example.com>" into
- # "=?utf-8?q?b=C3=A6lammet_mitt?= <user@example.com>
- def mime_encode_address string
- return string if string.ascii_only?
- string.sub(RE_ADDRESS) { |match| mime_encode($1) + $2 }
- end
-
- def move_cursor_left
- if curpos < @selectors.length
- @selectors[curpos].roll_left
- buffer.mark_dirty
- update if @account_selector
- else
- col_left
- end
- end
-
- def move_cursor_right
- if curpos < @selectors.length
- @selectors[curpos].roll_right
- buffer.mark_dirty
- update if @account_selector
- else
- col_right
- end
- end
-
- def add_selector s
- @selectors << s
- @selector_label_width = [@selector_label_width, s.label.length].max
- end
-
- def update
- if @account_selector
- if @account_selector.val.nil?
- @header["From"] = @account_user
- else
- @header["From"] = AccountManager.full_address_for @account_selector.val
- end
- end
-
- regen_text
- buffer.mark_dirty if buffer
- end
-
- def regen_text
- header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""]
- @text = header + [""] + @body
- @text += sig_lines unless @sig_edited
-
- @attachment_lines_offset = 0
-
- unless @attachments.empty?
- @text += [""]
- @attachment_lines_offset = @text.length
- @text += (0 ... @attachments.size).map { |i| [[:attachment_color, "+ Attachment: #{@attachment_names[i]} (#{@attachments[i].body.size.to_human_size})"]] }
- end
- end
-
- def parse_file fn
- File.open(fn) do |f|
- header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
- body = f.readlines.map { |l| l.chomp }
-
- header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
- header.each { |k, v| header[k] = parse_header k, v }
-
- [header, body]
- end
- end
-
- def parse_header k, v
- if MULTI_HEADERS.include?(k)
- v.split_on_commas.map do |name|
- (p = ContactManager.contact_for(name)) && p.full_address || name
- end
- else
- v
- end
- end
-
- def format_headers header
- header_lines = []
- headers = (FORCE_HEADERS + (header.keys - FORCE_HEADERS)).map do |h|
- lines = make_lines "#{h}:", header[h]
- lines.length.times { header_lines << h }
- lines
- end.flatten.compact
- [headers, header_lines]
- 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.display_length + 1)) + name
- end + (i == things.length - 1 ? "" : ",")
- end
- end
- end
- end
-
- def send_message
- return false if !edited? && !BufferManager.ask_yes_or_no("Message unedited. Really send?")
- return false if $config[:confirm_no_attachments] && mentions_attachments? && @attachments.size == 0 && !BufferManager.ask_yes_or_no("You haven't added any attachments. Really send?")#" stupid ruby-mode
- return false if $config[:confirm_top_posting] && top_posting? && !BufferManager.ask_yes_or_no("You're top-posting. That makes you a bad person. Really send?") #" stupid ruby-mode
-
- from_email =
- if @header["From"] =~ /<?(\S+@(\S+?))>?$/
- $1
- else
- AccountManager.default_account.email
- end
-
- acct = AccountManager.account_for(from_email) || AccountManager.default_account
- BufferManager.flash "Sending..."
-
- begin
- date = Time.now
- m = build_message date
-
- if HookManager.enabled? "sendmail"
- if not HookManager.run "sendmail", :message => m, :account => acct
- warn "Sendmail hook was not successful"
- return false
- end
- else
- IO.popen(acct.sendmail, "w") { |p| p.puts m }
- raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
- end
-
- SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) }
- BufferManager.kill_buffer buffer
- BufferManager.flash "Message sent!"
- true
- rescue SystemCallError, SendmailCommandFailed, CryptoManager::Error => e
- warn "Problem sending mail: #{e.message}"
- BufferManager.flash "Problem sending mail: #{e.message}"
- false
- end
- 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 build_message date
- m = RMail::Message.new
- m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
- m.body = @body.join("\n")
- m.body += "\n" + sig_lines.join("\n") unless @sig_edited
- ## body must end in a newline or GPG signatures will be WRONG!
- m.body += "\n" unless m.body =~ /\n\Z/
-
- ## there are attachments, so wrap body in an attachment of its own
- unless @attachments.empty?
- body_m = m
- body_m.header["Content-Disposition"] = "inline"
- m = RMail::Message.new
-
- m.add_part body_m
- @attachments.each { |a| m.add_part a }
- end
-
- ## do whatever crypto transformation is necessary
- if @crypto_selector && @crypto_selector.val != :none
- from_email = Person.from_address(@header["From"]).email
- to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
- if m.multipart?
- m.each_part {|p| p = transfer_encode p}
- else
- m = transfer_encode m
- end
-
- m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
- end
-
- ## finally, set the top-level headers
- @header.each do |k, v|
- next if v.nil? || v.empty?
- m.header[k] =
- case v
- when String
- k.match(/subject/i) ? mime_encode_subject(v) : mime_encode_address(v)
- when Array
- v.map { |v| mime_encode_address v }.join ", "
- end
- end
-
- m.header["Date"] = date.rfc2822
- m.header["Message-Id"] = @message_id
- m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
- m.header["Content-Transfer-Encoding"] ||= '8bit'
- m.header["MIME-Version"] = "1.0" if m.multipart?
- m
- end
-
- ## TODO: remove this. redundant with write_full_message_to.
- ##
- ## this is going to change soon: draft messages (currently written
- ## with full=false) will be output as yaml.
- def write_message f, full=true, date=Time.now
- raise ArgumentError, "no pre-defined date: header allowed" if @header["Date"]
- f.puts format_headers(@header).first
- f.puts <<EOS
-Date: #{date.rfc2822}
-Message-Id: #{@message_id}
-EOS
- if full
- 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 sanitize_body(@body.join("\n"))
- f.puts sig_lines if full unless $config[:edit_signature]
- end
-
-protected
-
- def edit_field field
- case field
- when "Subject"
- text = BufferManager.ask :subject, "Subject: ", @header[field]
- if text
- @header[field] = parse_header field, text
- update
- end
- else
- default = case field
- when *MULTI_HEADERS
- @header[field] ||= []
- @header[field].join(", ")
- else
- @header[field]
- end
-
- contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
- if contacts
- text = contacts.map { |s| s.full_address }.join(", ")
- @header[field] = parse_header field, text
-
- if @account_selector and field == "From"
- @account_user = @header["From"]
- @account_selector.set_to nil
- end
-
- rerun_crypto_selector_hook
- update
- end
- end
- end
-
-private
-
- def sanitize_body body
- body.gsub(/^From /, ">From ")
- end
-
- def mentions_attachments?
- if HookManager.enabled? "mentions-attachments"
- HookManager.run "mentions-attachments", :header => @header, :body => @body
- else
- @body.any? { |l| l =~ /^[^>]/ && l =~ /\battach(ment|ed|ing|)\b/i }
- end
- end
-
- def top_posting?
- @body.join("\n") =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/
- end
-
- def sig_lines
- p = Person.from_address(@header["From"])
- from_email = p && p.email
-
- ## first run the hook
- hook_sig = HookManager.run "signature", :header => @header, :from_email => from_email
-
- return [] if hook_sig == :none
- return ["", "-- "] + hook_sig.split("\n") if hook_sig
-
- ## no hook, do default signature generation based on config.yaml
- return [] unless from_email
- sigfn = (AccountManager.account_for(from_email) ||
- AccountManager.default_account).signature
-
- if sigfn && File.exists?(sigfn)
- ["", "-- "] + File.readlines(sigfn).map { |l| l.chomp }
- else
- []
- end
- end
-
- def transfer_encode msg_part
- ## return the message unchanged if it's already encoded
- if (msg_part.header["Content-Transfer-Encoding"] == "base64" ||
- msg_part.header["Content-Transfer-Encoding"] == "quoted-printable")
- return msg_part
- end
-
- ## encode to quoted-printable for all text/* MIME types,
- ## use base64 otherwise
- if msg_part.header["Content-Type"] =~ /text\/.*/
- msg_part.header["Content-Transfer-Encoding"] = 'quoted-printable'
- msg_part.body = [msg_part.body].pack('M')
- else
- msg_part.header["Content-Transfer-Encoding"] = 'base64'
- msg_part.body = [msg_part.body].pack('m')
- end
- msg_part
- end
-end
-
-end
diff --git a/lib/sup/modes/edit-message-async-mode.rb b/lib/sup/modes/edit_message_async_mode.rb
diff --git a/lib/sup/modes/edit_message_mode.rb b/lib/sup/modes/edit_message_mode.rb
@@ -0,0 +1,684 @@
+require 'tempfile'
+require 'socket' # just for gethostname!
+require 'pathname'
+
+module Redwood
+
+class SendmailCommandFailed < StandardError; end
+
+class EditMessageMode < LineCursorMode
+ DECORATION_LINES = 1
+
+ FORCE_HEADERS = %w(From To Cc Bcc Subject)
+ MULTI_HEADERS = %w(To Cc Bcc)
+ NON_EDITABLE_HEADERS = %w(Message-id Date)
+
+ HookManager.register "signature", <<EOS
+Generates a message signature.
+Variables:
+ header: an object that supports string-to-string hashtable-style access
+ to the raw headers for the message. E.g., header["From"],
+ header["To"], etc.
+ from_email: the email part of the From: line, or nil if empty
+Return value:
+ A string (multi-line ok) containing the text of the signature, or nil to
+ use the default signature, or :none for no signature.
+EOS
+
+ HookManager.register "before-edit", <<EOS
+Modifies message body and headers before editing a new message. Variables
+should be modified in place.
+Variables:
+ header: a hash of headers. See 'signature' hook for documentation.
+ body: an array of lines of body text.
+Return value:
+ none
+EOS
+
+ HookManager.register "mentions-attachments", <<EOS
+Detects if given message mentions attachments the way it is probable
+that there should be files attached to the message.
+Variables:
+ header: a hash of headers. See 'signature' hook for documentation.
+ body: an array of lines of body text.
+Return value:
+ True if attachments are mentioned.
+EOS
+
+ HookManager.register "crypto-mode", <<EOS
+Modifies cryptography settings based on header and message content, before
+editing a new message. This can be used to set, for example, default cryptography
+settings.
+Variables:
+ header: a hash of headers. See 'signature' hook for documentation.
+ body: an array of lines of body text.
+ crypto_selector: the UI element that controls the current cryptography setting.
+Return value:
+ none
+EOS
+
+ HookManager.register "sendmail", <<EOS
+Sends the given mail. If this hook doesn't exist, the sendmail command
+configured for the account is used.
+The message will be saved after this hook is run, so any modification to it
+will be recorded.
+Variables:
+ message: RMail::Message instance of the mail to send
+ account: Account instance matching the From address
+Return value:
+ True if mail has been sent successfully, false otherwise.
+EOS
+
+ attr_reader :status
+ attr_accessor :body, :header
+ bool_reader :edited
+
+ register_keymap do |k|
+ k.add :send_message, "Send message", 'y'
+ k.add :edit_message_or_field, "Edit selected field", 'e'
+ k.add :edit_to, "Edit To:", 't'
+ k.add :edit_cc, "Edit Cc:", 'c'
+ k.add :edit_subject, "Edit Subject", 's'
+ k.add :edit_message, "Edit message", :enter
+ k.add :edit_message_async, "Edit message asynchronously", 'E'
+ k.add :save_as_draft, "Save as draft", 'P'
+ k.add :attach_file, "Attach a file", 'a'
+ k.add :delete_attachment, "Delete an attachment", 'd'
+ k.add :move_cursor_right, "Move selector to the right", :right, 'l'
+ k.add :move_cursor_left, "Move selector to the left", :left, 'h'
+ end
+
+ def initialize opts={}
+ @header = opts.delete(:header) || {}
+ @header_lines = []
+
+ @body = opts.delete(:body) || []
+
+ if opts[:attachments]
+ @attachments = opts[:attachments].values
+ @attachment_names = opts[:attachments].keys
+ else
+ @attachments = []
+ @attachment_names = []
+ end
+
+ begin
+ hostname = File.open("/etc/mailname", "r").gets.chomp
+ rescue
+ nil
+ end
+ hostname = Socket.gethostname if hostname.nil? or hostname.empty?
+
+ @message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{hostname}>"
+ @edited = false
+ @sig_edited = false
+ @selectors = []
+ @selector_label_width = 0
+ @async_mode = nil
+
+ HookManager.run "before-edit", :header => @header, :body => @body
+
+ @account_selector = nil
+ # only show account selector if there is more than one email address
+ if $config[:account_selector] && AccountManager.user_emails.length > 1
+ ## Duplicate e-mail strings to prevent a "can't modify frozen
+ ## object" crash triggered by the String::display_length()
+ ## method in util.rb
+ user_emails_copy = []
+ AccountManager.user_emails.each { |e| user_emails_copy.push e.dup }
+
+ @account_selector =
+ HorizontalSelector.new "Account:", AccountManager.user_emails + [nil], user_emails_copy + ["Customized"]
+
+ if @header["From"] =~ /<?(\S+@(\S+?))>?$/
+ # TODO: this is ugly. might implement an AccountSelector and handle
+ # special cases more transparently.
+ account_from = @account_selector.can_set_to?($1) ? $1 : nil
+ @account_selector.set_to account_from
+ else
+ @account_selector.set_to nil
+ end
+
+ # A single source of truth might better than duplicating this in both
+ # @account_user and @account_selector.
+ @account_user = @header["From"]
+
+ add_selector @account_selector
+ end
+
+ @crypto_selector =
+ if CryptoManager.have_crypto?
+ HorizontalSelector.new "Crypto:", [:none] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.keys, ["None"] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.values
+ end
+ add_selector @crypto_selector if @crypto_selector
+
+ if @crypto_selector
+ HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
+ end
+
+ super opts
+ regen_text
+ end
+
+ def lines; @text.length + (@selectors.empty? ? 0 : (@selectors.length + DECORATION_LINES)) end
+
+ def [] i
+ if @selectors.empty?
+ @text[i]
+ elsif i < @selectors.length
+ @selectors[i].line @selector_label_width
+ elsif i == @selectors.length
+ ""
+ else
+ @text[i - @selectors.length - DECORATION_LINES]
+ end
+ end
+
+ ## hook for subclasses. i hate this style of programming.
+ def handle_new_text header, body; end
+
+ def edit_message_or_field
+ lines = DECORATION_LINES + @selectors.size
+ if lines > curpos
+ return
+ elsif (curpos - lines) >= @header_lines.length
+ edit_message
+ else
+ edit_field @header_lines[curpos - lines]
+ end
+ end
+
+ def edit_to; edit_field "To" end
+ def edit_cc; edit_field "Cc" end
+ def edit_subject; edit_field "Subject" end
+
+ def save_message_to_file
+ sig = sig_lines.join("\n")
+ @file = Tempfile.new ["sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}", ".eml"]
+ @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
+ @file.puts
+ @file.puts @body.join("\n")
+ @file.puts sig if ($config[:edit_signature] and !@sig_edited)
+ @file.close
+ end
+
+ def set_sig_edit_flag
+ sig = sig_lines.join("\n")
+ if $config[:edit_signature]
+ pbody = @body.join("\n")
+ blen = pbody.length
+ slen = sig.length
+
+ if blen > slen and pbody[blen-slen..blen] == sig
+ @sig_edited = false
+ @body = pbody[0..blen-slen].split("\n")
+ else
+ @sig_edited = true
+ end
+ end
+ end
+
+ def edit_message
+ old_from = @header["From"] if @account_selector
+
+ begin
+ save_message_to_file
+ rescue SystemCallError => e
+ BufferManager.flash "Can't save message to file: #{e.message}"
+ return
+ end
+
+ 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
+
+ return @edited unless @edited
+
+ header, @body = parse_file @file.path
+ @header = header - NON_EDITABLE_HEADERS
+ set_sig_edit_flag
+
+ if @account_selector and @header["From"] != old_from
+ @account_user = @header["From"]
+ @account_selector.set_to nil
+ end
+
+ handle_new_text @header, @body
+ rerun_crypto_selector_hook
+ update
+
+ @edited
+ end
+
+ def edit_message_async
+ begin
+ save_message_to_file
+ rescue SystemCallError => e
+ BufferManager.flash "Can't save message to file: #{e.message}"
+ return
+ end
+
+ @mtime = File.mtime @file.path
+
+ # put up buffer saying you can now edit the message in another
+ # terminal or app, and continue to use sup in the meantime.
+ subject = @header["Subject"] || ""
+ @async_mode = EditMessageAsyncMode.new self, @file.path, subject
+ BufferManager.spawn "Waiting for message \"#{subject}\" to be finished", @async_mode
+
+ # hide ourselves, and wait for signal to resume from async mode ...
+ buffer.hidden = true
+ end
+
+ def edit_message_async_resume being_killed=false
+ buffer.hidden = false
+ @async_mode = nil
+ BufferManager.raise_to_front buffer if !being_killed
+
+ @edited = true if File.mtime(@file.path) > @mtime
+
+ header, @body = parse_file @file.path
+ @header = header - NON_EDITABLE_HEADERS
+ set_sig_edit_flag
+ handle_new_text @header, @body
+ update
+
+ true
+ end
+
+ def killable?
+ if !@async_mode.nil?
+ return false if !@async_mode.killable?
+ if File.mtime(@file.path) > @mtime
+ @edited = true
+ header, @body = parse_file @file.path
+ @header = header - NON_EDITABLE_HEADERS
+ handle_new_text @header, @body
+ update
+ end
+ end
+ !edited? || BufferManager.ask_yes_or_no("Discard message?")
+ end
+
+ def unsaved?; edited? end
+
+ def attach_file
+ fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
+ return unless fn
+ begin
+ Dir[fn].each do |f|
+ @attachments << RMail::Message.make_file_attachment(f)
+ @attachment_names << f
+ end
+ update
+ rescue SystemCallError => e
+ BufferManager.flash "Can't read #{fn}: #{e.message}"
+ end
+ end
+
+ def delete_attachment
+ i = curpos - @attachment_lines_offset - DECORATION_LINES - 2
+ if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachment_names[i]}?")
+ @attachments.delete_at i
+ @attachment_names.delete_at i
+ update
+ end
+ end
+
+protected
+
+ def rerun_crypto_selector_hook
+ if @crypto_selector && !@crypto_selector.changed_by_user
+ HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
+ end
+ end
+
+ def mime_encode string
+ string = [string].pack('M') # basic quoted-printable
+ string.gsub!(/=\n/,'') # .. remove trailing newline
+ string.gsub!(/_/,'=5F') # .. encode underscores
+ string.gsub!(/\?/,'=3F') # .. encode question marks
+ string.gsub!(/ /,'_') # .. translate space to underscores
+ "=?utf-8?q?#{string}?="
+ end
+
+ def mime_encode_subject string
+ return string if string.ascii_only?
+ mime_encode string
+ end
+
+ RE_ADDRESS = /(.+)( <.*@.*>)/
+
+ # Encode "bælammet mitt <user@example.com>" into
+ # "=?utf-8?q?b=C3=A6lammet_mitt?= <user@example.com>
+ def mime_encode_address string
+ return string if string.ascii_only?
+ string.sub(RE_ADDRESS) { |match| mime_encode($1) + $2 }
+ end
+
+ def move_cursor_left
+ if curpos < @selectors.length
+ @selectors[curpos].roll_left
+ buffer.mark_dirty
+ update if @account_selector
+ else
+ col_left
+ end
+ end
+
+ def move_cursor_right
+ if curpos < @selectors.length
+ @selectors[curpos].roll_right
+ buffer.mark_dirty
+ update if @account_selector
+ else
+ col_right
+ end
+ end
+
+ def add_selector s
+ @selectors << s
+ @selector_label_width = [@selector_label_width, s.label.length].max
+ end
+
+ def update
+ if @account_selector
+ if @account_selector.val.nil?
+ @header["From"] = @account_user
+ else
+ @header["From"] = AccountManager.full_address_for @account_selector.val
+ end
+ end
+
+ regen_text
+ buffer.mark_dirty if buffer
+ end
+
+ def regen_text
+ header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""]
+ @text = header + [""] + @body
+ @text += sig_lines unless @sig_edited
+
+ @attachment_lines_offset = 0
+
+ unless @attachments.empty?
+ @text += [""]
+ @attachment_lines_offset = @text.length
+ @text += (0 ... @attachments.size).map { |i| [[:attachment_color, "+ Attachment: #{@attachment_names[i]} (#{@attachments[i].body.size.to_human_size})"]] }
+ end
+ end
+
+ def parse_file fn
+ File.open(fn) do |f|
+ header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
+ body = f.readlines.map { |l| l.chomp }
+
+ header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
+ header.each { |k, v| header[k] = parse_header k, v }
+
+ [header, body]
+ end
+ end
+
+ def parse_header k, v
+ if MULTI_HEADERS.include?(k)
+ v.split_on_commas.map do |name|
+ (p = ContactManager.contact_for(name)) && p.full_address || name
+ end
+ else
+ v
+ end
+ end
+
+ def format_headers header
+ header_lines = []
+ headers = (FORCE_HEADERS + (header.keys - FORCE_HEADERS)).map do |h|
+ lines = make_lines "#{h}:", header[h]
+ lines.length.times { header_lines << h }
+ lines
+ end.flatten.compact
+ [headers, header_lines]
+ 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.display_length + 1)) + name
+ end + (i == things.length - 1 ? "" : ",")
+ end
+ end
+ end
+ end
+
+ def send_message
+ return false if !edited? && !BufferManager.ask_yes_or_no("Message unedited. Really send?")
+ return false if $config[:confirm_no_attachments] && mentions_attachments? && @attachments.size == 0 && !BufferManager.ask_yes_or_no("You haven't added any attachments. Really send?")#" stupid ruby-mode
+ return false if $config[:confirm_top_posting] && top_posting? && !BufferManager.ask_yes_or_no("You're top-posting. That makes you a bad person. Really send?") #" stupid ruby-mode
+
+ from_email =
+ if @header["From"] =~ /<?(\S+@(\S+?))>?$/
+ $1
+ else
+ AccountManager.default_account.email
+ end
+
+ acct = AccountManager.account_for(from_email) || AccountManager.default_account
+ BufferManager.flash "Sending..."
+
+ begin
+ date = Time.now
+ m = build_message date
+
+ if HookManager.enabled? "sendmail"
+ if not HookManager.run "sendmail", :message => m, :account => acct
+ warn "Sendmail hook was not successful"
+ return false
+ end
+ else
+ IO.popen(acct.sendmail, "w") { |p| p.puts m }
+ raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
+ end
+
+ SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) }
+ BufferManager.kill_buffer buffer
+ BufferManager.flash "Message sent!"
+ true
+ rescue SystemCallError, SendmailCommandFailed, CryptoManager::Error => e
+ warn "Problem sending mail: #{e.message}"
+ BufferManager.flash "Problem sending mail: #{e.message}"
+ false
+ end
+ 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 build_message date
+ m = RMail::Message.new
+ m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
+ m.body = @body.join("\n")
+ m.body += "\n" + sig_lines.join("\n") unless @sig_edited
+ ## body must end in a newline or GPG signatures will be WRONG!
+ m.body += "\n" unless m.body =~ /\n\Z/
+
+ ## there are attachments, so wrap body in an attachment of its own
+ unless @attachments.empty?
+ body_m = m
+ body_m.header["Content-Disposition"] = "inline"
+ m = RMail::Message.new
+
+ m.add_part body_m
+ @attachments.each { |a| m.add_part a }
+ end
+
+ ## do whatever crypto transformation is necessary
+ if @crypto_selector && @crypto_selector.val != :none
+ from_email = Person.from_address(@header["From"]).email
+ to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
+ if m.multipart?
+ m.each_part {|p| p = transfer_encode p}
+ else
+ m = transfer_encode m
+ end
+
+ m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
+ end
+
+ ## finally, set the top-level headers
+ @header.each do |k, v|
+ next if v.nil? || v.empty?
+ m.header[k] =
+ case v
+ when String
+ k.match(/subject/i) ? mime_encode_subject(v) : mime_encode_address(v)
+ when Array
+ v.map { |v| mime_encode_address v }.join ", "
+ end
+ end
+
+ m.header["Date"] = date.rfc2822
+ m.header["Message-Id"] = @message_id
+ m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
+ m.header["Content-Transfer-Encoding"] ||= '8bit'
+ m.header["MIME-Version"] = "1.0" if m.multipart?
+ m
+ end
+
+ ## TODO: remove this. redundant with write_full_message_to.
+ ##
+ ## this is going to change soon: draft messages (currently written
+ ## with full=false) will be output as yaml.
+ def write_message f, full=true, date=Time.now
+ raise ArgumentError, "no pre-defined date: header allowed" if @header["Date"]
+ f.puts format_headers(@header).first
+ f.puts <<EOS
+Date: #{date.rfc2822}
+Message-Id: #{@message_id}
+EOS
+ if full
+ 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 sanitize_body(@body.join("\n"))
+ f.puts sig_lines if full unless $config[:edit_signature]
+ end
+
+protected
+
+ def edit_field field
+ case field
+ when "Subject"
+ text = BufferManager.ask :subject, "Subject: ", @header[field]
+ if text
+ @header[field] = parse_header field, text
+ update
+ end
+ else
+ default = case field
+ when *MULTI_HEADERS
+ @header[field] ||= []
+ @header[field].join(", ")
+ else
+ @header[field]
+ end
+
+ contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
+ if contacts
+ text = contacts.map { |s| s.full_address }.join(", ")
+ @header[field] = parse_header field, text
+
+ if @account_selector and field == "From"
+ @account_user = @header["From"]
+ @account_selector.set_to nil
+ end
+
+ rerun_crypto_selector_hook
+ update
+ end
+ end
+ end
+
+private
+
+ def sanitize_body body
+ body.gsub(/^From /, ">From ")
+ end
+
+ def mentions_attachments?
+ if HookManager.enabled? "mentions-attachments"
+ HookManager.run "mentions-attachments", :header => @header, :body => @body
+ else
+ @body.any? { |l| l =~ /^[^>]/ && l =~ /\battach(ment|ed|ing|)\b/i }
+ end
+ end
+
+ def top_posting?
+ @body.join("\n") =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/
+ end
+
+ def sig_lines
+ p = Person.from_address(@header["From"])
+ from_email = p && p.email
+
+ ## first run the hook
+ hook_sig = HookManager.run "signature", :header => @header, :from_email => from_email
+
+ return [] if hook_sig == :none
+ return ["", "-- "] + hook_sig.split("\n") if hook_sig
+
+ ## no hook, do default signature generation based on config.yaml
+ return [] unless from_email
+ sigfn = (AccountManager.account_for(from_email) ||
+ AccountManager.default_account).signature
+
+ if sigfn && File.exists?(sigfn)
+ ["", "-- "] + File.readlines(sigfn).map { |l| l.chomp }
+ else
+ []
+ end
+ end
+
+ def transfer_encode msg_part
+ ## return the message unchanged if it's already encoded
+ if (msg_part.header["Content-Transfer-Encoding"] == "base64" ||
+ msg_part.header["Content-Transfer-Encoding"] == "quoted-printable")
+ return msg_part
+ end
+
+ ## encode to quoted-printable for all text/* MIME types,
+ ## use base64 otherwise
+ if msg_part.header["Content-Type"] =~ /text\/.*/
+ msg_part.header["Content-Transfer-Encoding"] = 'quoted-printable'
+ msg_part.body = [msg_part.body].pack('M')
+ else
+ msg_part.header["Content-Transfer-Encoding"] = 'base64'
+ msg_part.body = [msg_part.body].pack('m')
+ end
+ msg_part
+ end
+end
+
+end
diff --git a/lib/sup/modes/file-browser-mode.rb b/lib/sup/modes/file_browser_mode.rb
diff --git a/lib/sup/modes/forward-mode.rb b/lib/sup/modes/forward_mode.rb
diff --git a/lib/sup/modes/help-mode.rb b/lib/sup/modes/help_mode.rb
diff --git a/lib/sup/modes/inbox-mode.rb b/lib/sup/modes/inbox-mode.rb
@@ -1,127 +0,0 @@
-require 'sup'
-
-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 :read_and_archive, "Archive thread (remove from inbox) and mark read", 'A'
- k.add :refine_search, "Refine search", '|'
- end
-
- def initialize
- super [:inbox, :sent, :draft], { :label => :inbox, :skip_killed => true }
- raise "can't have more than one!" if defined? @@instance
- @@instance = self
- end
-
- def is_relevant? m; (m.labels & [:spam, :deleted, :killed, :inbox]) == Set.new([:inbox]) end
-
- def refine_search
- text = BufferManager.ask :search, "refine inbox with query: "
- return unless text && text !~ /^\s*$/
- text = "label:inbox -label:spam -label:deleted " + text
- SearchResultsMode.spawn_from_query text
- end
-
- ## label-list-mode wants to be able to raise us if the user selects
- ## the "inbox" label, so we need to keep our singletonness around
- def self.instance; @@instance; end
- def killable?; false; end
-
- def archive
- return unless cursor_thread
- thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
-
- UndoManager.register "archiving thread" do
- thread.apply_label :inbox
- add_or_unhide thread.first
- Index.save_thread thread
- end
-
- cursor_thread.remove_label :inbox
- hide_thread cursor_thread
- regen_text
- Index.save_thread thread
- end
-
- def multi_archive threads
- UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do
- threads.map do |t|
- t.apply_label :inbox
- add_or_unhide t.first
- Index.save_thread t
- end
- regen_text
- end
-
- threads.each do |t|
- t.remove_label :inbox
- hide_thread t
- end
- regen_text
- threads.each { |t| Index.save_thread t }
- end
-
- def read_and_archive
- return unless cursor_thread
- thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
-
- was_unread = thread.labels.member? :unread
- UndoManager.register "reading and archiving thread" do
- thread.apply_label :inbox
- thread.apply_label :unread if was_unread
- add_or_unhide thread.first
- Index.save_thread thread
- end
-
- cursor_thread.remove_label :unread
- cursor_thread.remove_label :inbox
- hide_thread cursor_thread
- regen_text
- Index.save_thread thread
- end
-
- def multi_read_and_archive threads
- old_labels = threads.map { |t| t.labels.dup }
-
- threads.each do |t|
- t.remove_label :unread
- t.remove_label :inbox
- hide_thread t
- end
- regen_text
-
- UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
- threads.zip(old_labels).each do |t, l|
- t.labels = l
- add_or_unhide t.first
- Index.save_thread t
- end
- regen_text
- end
-
- threads.each { |t| Index.save_thread t }
- end
-
- def handle_unarchived_update sender, m
- add_or_unhide m
- end
-
- def handle_archived_update sender, m
- t = thread_containing(m) or return
- hide_thread t
- regen_text
- end
-
- def handle_idle_update sender, idle_since
- flush_index
- end
-
- def status
- super + " #{Index.size} messages in index"
- end
-end
-
-end
diff --git a/lib/sup/modes/inbox_mode.rb b/lib/sup/modes/inbox_mode.rb
@@ -0,0 +1,127 @@
+require "sup/modes/thread_index_mode"
+
+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 :read_and_archive, "Archive thread (remove from inbox) and mark read", 'A'
+ k.add :refine_search, "Refine search", '|'
+ end
+
+ def initialize
+ super [:inbox, :sent, :draft], { :label => :inbox, :skip_killed => true }
+ raise "can't have more than one!" if defined? @@instance
+ @@instance = self
+ end
+
+ def is_relevant? m; (m.labels & [:spam, :deleted, :killed, :inbox]) == Set.new([:inbox]) end
+
+ def refine_search
+ text = BufferManager.ask :search, "refine inbox with query: "
+ return unless text && text !~ /^\s*$/
+ text = "label:inbox -label:spam -label:deleted " + text
+ SearchResultsMode.spawn_from_query text
+ end
+
+ ## label-list-mode wants to be able to raise us if the user selects
+ ## the "inbox" label, so we need to keep our singletonness around
+ def self.instance; @@instance; end
+ def killable?; false; end
+
+ def archive
+ return unless cursor_thread
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+ UndoManager.register "archiving thread" do
+ thread.apply_label :inbox
+ add_or_unhide thread.first
+ Index.save_thread thread
+ end
+
+ cursor_thread.remove_label :inbox
+ hide_thread cursor_thread
+ regen_text
+ Index.save_thread thread
+ end
+
+ def multi_archive threads
+ UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do
+ threads.map do |t|
+ t.apply_label :inbox
+ add_or_unhide t.first
+ Index.save_thread t
+ end
+ regen_text
+ end
+
+ threads.each do |t|
+ t.remove_label :inbox
+ hide_thread t
+ end
+ regen_text
+ threads.each { |t| Index.save_thread t }
+ end
+
+ def read_and_archive
+ return unless cursor_thread
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+ was_unread = thread.labels.member? :unread
+ UndoManager.register "reading and archiving thread" do
+ thread.apply_label :inbox
+ thread.apply_label :unread if was_unread
+ add_or_unhide thread.first
+ Index.save_thread thread
+ end
+
+ cursor_thread.remove_label :unread
+ cursor_thread.remove_label :inbox
+ hide_thread cursor_thread
+ regen_text
+ Index.save_thread thread
+ end
+
+ def multi_read_and_archive threads
+ old_labels = threads.map { |t| t.labels.dup }
+
+ threads.each do |t|
+ t.remove_label :unread
+ t.remove_label :inbox
+ hide_thread t
+ end
+ regen_text
+
+ UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
+ threads.zip(old_labels).each do |t, l|
+ t.labels = l
+ add_or_unhide t.first
+ Index.save_thread t
+ end
+ regen_text
+ end
+
+ threads.each { |t| Index.save_thread t }
+ end
+
+ def handle_unarchived_update sender, m
+ add_or_unhide m
+ end
+
+ def handle_archived_update sender, m
+ t = thread_containing(m) or return
+ hide_thread t
+ regen_text
+ end
+
+ def handle_idle_update sender, idle_since
+ flush_index
+ end
+
+ def status
+ super + " #{Index.size} messages in index"
+ end
+end
+
+end
diff --git a/lib/sup/modes/label-list-mode.rb b/lib/sup/modes/label_list_mode.rb
diff --git a/lib/sup/modes/label-search-results-mode.rb b/lib/sup/modes/label_search_results_mode.rb
diff --git a/lib/sup/modes/line-cursor-mode.rb b/lib/sup/modes/line-cursor-mode.rb
@@ -1,184 +0,0 @@
-module Redwood
-
-## extends ScrollMode to have a line-based cursor.
-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 opts={}
- @cursor_top = @curpos = opts.delete(:skip_top_rows) || 0
- @load_more_callbacks = []
- @load_more_q = Queue.new
- @load_more_thread = ::Thread.new do
- while true
- e = @load_more_q.pop
- @load_more_callbacks.each { |c| c.call e }
- sleep 0.5
- @load_more_q.pop until @load_more_q.empty?
- end
- end
-
- super opts
- end
-
- def cleanup
- @load_more_thread.kill
- super
- end
-
- def draw
- super
- set_status
- end
-
-protected
-
- ## callbacks when the cursor is asked to go beyond the bottom
- def to_load_more &b
- @load_more_callbacks << b
- end
-
- 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
- set_status
- end
-
- ## override search behavior to be cursor-based. this is a stupid
- ## implementation and should be made better. TODO: improve.
- def search_goto_line line
- page_down while line >= botline
- page_up while line < topline
- set_cursor_pos line
- end
-
- def search_start_line; @curpos end
-
- def line_down # overwrite scrollmode
- super
- call_load_more_callbacks([topline + buffer.content_height - lines, 10].max) if topline + buffer.content_height > lines
- 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
- call_load_more_callbacks buffer.content_height if @curpos >= lines - [buffer.content_height/2,1].max
- return false unless @curpos < lines - 1
-
- if @curpos >= botline - 1
- page_down
- set_cursor_pos topline
- 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
- old_topline = topline
- page_up
- set_cursor_pos [old_topline - 1, topline].max
- 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
-
- ## more complicated than one might think. three behaviors.
- def page_down
- ## if we're on the last page, and it's not a full page, just move
- ## the cursor down to the bottom and assume we can't load anything
- ## else via the callbacks.
- if topline > lines - buffer.content_height
- set_cursor_pos(lines - 1)
-
- ## if we're on the last page, and it's a full page, try and load
- ## more lines via the callbacks and then shift the page down
- elsif topline == lines - buffer.content_height
- call_load_more_callbacks buffer.content_height
- super
-
- ## otherwise, just move down
- else
- relpos = @curpos - topline
- super
- set_cursor_pos [topline + relpos, lines - 1].min
- end
- end
-
- def jump_to_start
- 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
- l = lines
- @status = l > 0 ? "line #{@curpos + 1} of #{l}" : ""
- end
-
- def call_load_more_callbacks size
- @load_more_q.push size if $config[:load_more_threads_when_scrolling]
- end
-end
-
-end
diff --git a/lib/sup/modes/line_cursor_mode.rb b/lib/sup/modes/line_cursor_mode.rb
@@ -0,0 +1,184 @@
+module Redwood
+
+## extends ScrollMode to have a line-based cursor.
+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 opts={}
+ @cursor_top = @curpos = opts.delete(:skip_top_rows) || 0
+ @load_more_callbacks = []
+ @load_more_q = Queue.new
+ @load_more_thread = ::Thread.new do
+ while true
+ e = @load_more_q.pop
+ @load_more_callbacks.each { |c| c.call e }
+ sleep 0.5
+ @load_more_q.pop until @load_more_q.empty?
+ end
+ end
+
+ super opts
+ end
+
+ def cleanup
+ @load_more_thread.kill
+ super
+ end
+
+ def draw
+ super
+ set_status
+ end
+
+protected
+
+ ## callbacks when the cursor is asked to go beyond the bottom
+ def to_load_more &b
+ @load_more_callbacks << b
+ end
+
+ def draw_line ln, opts={}
+ if ln == @curpos
+ super ln, :highlight => true, :debug => opts[:debug], :color => :text_color
+ else
+ super ln, :color => :text_color
+ 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
+ set_status
+ end
+
+ ## override search behavior to be cursor-based. this is a stupid
+ ## implementation and should be made better. TODO: improve.
+ def search_goto_line line
+ page_down while line >= botline
+ page_up while line < topline
+ set_cursor_pos line
+ end
+
+ def search_start_line; @curpos end
+
+ def line_down # overwrite scrollmode
+ super
+ call_load_more_callbacks([topline + buffer.content_height - lines, 10].max) if topline + buffer.content_height > lines
+ 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
+ call_load_more_callbacks buffer.content_height if @curpos >= lines - [buffer.content_height/2,1].max
+ return false unless @curpos < lines - 1
+
+ if @curpos >= botline - 1
+ page_down
+ set_cursor_pos topline
+ 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
+ old_topline = topline
+ page_up
+ set_cursor_pos [old_topline - 1, topline].max
+ 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
+
+ ## more complicated than one might think. three behaviors.
+ def page_down
+ ## if we're on the last page, and it's not a full page, just move
+ ## the cursor down to the bottom and assume we can't load anything
+ ## else via the callbacks.
+ if topline > lines - buffer.content_height
+ set_cursor_pos(lines - 1)
+
+ ## if we're on the last page, and it's a full page, try and load
+ ## more lines via the callbacks and then shift the page down
+ elsif topline == lines - buffer.content_height
+ call_load_more_callbacks buffer.content_height
+ super
+
+ ## otherwise, just move down
+ else
+ relpos = @curpos - topline
+ super
+ set_cursor_pos [topline + relpos, lines - 1].min
+ end
+ end
+
+ def jump_to_start
+ 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
+ l = lines
+ @status = l > 0 ? "line #{@curpos + 1} of #{l}" : ""
+ end
+
+ def call_load_more_callbacks size
+ @load_more_q.push size if $config[:load_more_threads_when_scrolling]
+ end
+end
+
+end
diff --git a/lib/sup/modes/log-mode.rb b/lib/sup/modes/log_mode.rb
diff --git a/lib/sup/modes/person-search-results-mode.rb b/lib/sup/modes/person_search_results_mode.rb
diff --git a/lib/sup/modes/poll-mode.rb b/lib/sup/modes/poll_mode.rb
diff --git a/lib/sup/modes/reply-mode.rb b/lib/sup/modes/reply_mode.rb
diff --git a/lib/sup/modes/resume-mode.rb b/lib/sup/modes/resume_mode.rb
diff --git a/lib/sup/modes/scroll-mode.rb b/lib/sup/modes/scroll-mode.rb
@@ -1,252 +0,0 @@
-module Redwood
-
-class ScrollMode < Mode
- ## we define topline and botline as the top and bottom lines of any
- ## content in the currentview.
-
- ## we left leftcol and rightcol as the left and right columns of any
- ## content in the current view. but since we're operating in a
- ## line-centric fashion, rightcol is always leftcol + the buffer
- ## width. (whereas botline is topline + at most the buffer height,
- ## and can be == to topline in the case that there's no content.)
-
- attr_reader :status, :topline, :botline, :leftcol
-
- register_keymap do |k|
- k.add :line_down, "Down one line", :down, 'j', 'J', "\C-e"
- k.add :line_up, "Up one line", :up, 'k', 'K', "\C-y"
- 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, ' ', "\C-f"
- k.add :page_up, "Up one page", :page_up, 'p', :backspace, "\C-b"
- k.add :half_page_down, "Down one half page", "\C-d"
- k.add :half_page_up, "Up one half page", "\C-u"
- k.add :jump_to_start, "Jump to top", :home, '^', '1'
- k.add :jump_to_end, "Jump to bottom", :end, '$', '0'
- k.add :jump_to_left, "Jump to the left", '['
- k.add :search_in_buffer, "Search in current buffer", '/'
- k.add :continue_search_in_buffer, "Jump to next search occurrence in buffer", BufferManager::CONTINUE_IN_BUFFER_SEARCH_KEY
- 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
- @search_query = nil
- @search_line = nil
- @status = ""
- super()
- end
-
- def rightcol; @leftcol + buffer.content_width; 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 in_search?; @search_line end
- def cancel_search!; @search_line = nil end
-
- def continue_search_in_buffer
- unless @search_query
- BufferManager.flash "No current search!"
- return
- end
-
- start = @search_line || search_start_line
- line, col = find_text @search_query, start
- if line.nil? && (start > 0)
- line, col = find_text @search_query, 0
- BufferManager.flash "Search wrapped to top!" if line
- end
- if line
- @search_line = line + 1
- search_goto_pos line, col, col + @search_query.display_length
- buffer.mark_dirty
- else
- BufferManager.flash "Not found!"
- end
- end
-
- def search_in_buffer
- query = BufferManager.ask :search, "search in buffer: "
- return if query.nil? || query.empty?
- @search_query = Regexp.escape query
- continue_search_in_buffer
- end
-
- ## subclasses can override these three!
- def search_goto_pos line, leftcol, rightcol
- search_goto_line line
-
- if rightcol > self.rightcol # if it's occluded...
- jump_to_col [rightcol - buffer.content_width + 1, 0].max # move right
- end
- end
- def search_start_line; @topline end
- def search_goto_line line; jump_to_line line end
-
- def col_jump
- $config[:col_jump] || 2
- 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
-
- def jump_to_col col
- col = col - (col % col_jump)
- buffer.mark_dirty unless @leftcol == col
- @leftcol = col
- end
-
- def jump_to_left; jump_to_col 0; 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 at_top?; @topline == 0 end
- def at_bottom?; @botline == lines 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 half_page_down; jump_to_line @topline + buffer.content_height / 2; end
- def half_page_up; jump_to_line @topline - buffer.content_height / 2; end
- def jump_to_start; 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, 0].max
- @botline = [@topline + buffer.content_height, lines].min
- end
-
- def resize *a
- super(*a)
- ensure_mode_validity
- end
-
-protected
-
- def find_text query, start_line
- regex = /#{query}/i
- (start_line ... lines).each do |i|
- case(s = self[i])
- when String
- match = s =~ regex
- return [i, match] if match
- when Array
- offset = 0
- s.each do |color, string|
- match = string =~ regex
- if match
- return [i, offset + match]
- else
- offset += string.display_length
- end
- end
- end
- end
- nil
- end
-
- def draw_line ln, opts={}
- regex = /(#{@search_query})/i
- case(s = self[ln])
- when String
- if in_search?
- draw_line_from_array ln, matching_text_array(s, regex), opts
- else
- draw_line_from_string ln, s, opts
- end
- when Array
- if in_search?
- ## seems like there ought to be a better way of doing this
- array = []
- s.each do |color, text|
- if text =~ regex
- array += matching_text_array text, regex, color
- else
- array << [color, text]
- end
- end
- draw_line_from_array ln, array, opts
- else
- draw_line_from_array ln, s, opts
- end
- else
- raise "unknown drawable object: #{s.inspect} in #{self} for line #{ln}" # good for debugging
- end
-
- ## speed test
- # str = s.map { |color, text| text }.join
- # buffer.write ln - @topline, 0, str, :color => :none, :highlight => opts[:highlight]
- # return
- end
-
- def matching_text_array s, regex, oldcolor=:none
- s.split(regex).map do |text|
- next if text.empty?
- if text =~ regex
- [:search_highlight_color, text]
- else
- [oldcolor, text]
- end
- end.compact + [[oldcolor, ""]]
- end
-
- def draw_line_from_array ln, a, opts
- xpos = 0
- a.each_with_index do |(color, text), i|
- raise "nil text for color '#{color}'" if text.nil? # good for debugging
- l = text.display_length
- no_fill = i != a.size - 1
-
- if xpos + l < @leftcol
- buffer.write ln - @topline, 0, "", :color => color,
- :highlight => opts[:highlight]
- elsif xpos < @leftcol
- ## partial
- buffer.write ln - @topline, 0, text[(@leftcol - xpos) .. -1],
- :color => color,
- :highlight => opts[:highlight], :no_fill => no_fill
- else
- buffer.write ln - @topline, xpos - @leftcol, text,
- :color => color, :highlight => opts[:highlight],
- :no_fill => no_fill
- end
- xpos += l
- end
- end
-
- def draw_line_from_string ln, s, opts
- buffer.write ln - @topline, 0, s[@leftcol .. -1], :highlight => opts[:highlight]
- end
-end
-
-end
-
diff --git a/lib/sup/modes/scroll_mode.rb b/lib/sup/modes/scroll_mode.rb
@@ -0,0 +1,252 @@
+module Redwood
+
+class ScrollMode < Mode
+ ## we define topline and botline as the top and bottom lines of any
+ ## content in the currentview.
+
+ ## we left leftcol and rightcol as the left and right columns of any
+ ## content in the current view. but since we're operating in a
+ ## line-centric fashion, rightcol is always leftcol + the buffer
+ ## width. (whereas botline is topline + at most the buffer height,
+ ## and can be == to topline in the case that there's no content.)
+
+ attr_reader :status, :topline, :botline, :leftcol
+
+ register_keymap do |k|
+ k.add :line_down, "Down one line", :down, 'j', 'J', "\C-e"
+ k.add :line_up, "Up one line", :up, 'k', 'K', "\C-y"
+ 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, ' ', "\C-f"
+ k.add :page_up, "Up one page", :page_up, 'p', :backspace, "\C-b"
+ k.add :half_page_down, "Down one half page", "\C-d"
+ k.add :half_page_up, "Up one half page", "\C-u"
+ k.add :jump_to_start, "Jump to top", :home, '^', '1'
+ k.add :jump_to_end, "Jump to bottom", :end, '$', '0'
+ k.add :jump_to_left, "Jump to the left", '['
+ k.add :search_in_buffer, "Search in current buffer", '/'
+ k.add :continue_search_in_buffer, "Jump to next search occurrence in buffer", BufferManager::CONTINUE_IN_BUFFER_SEARCH_KEY
+ 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
+ @search_query = nil
+ @search_line = nil
+ @status = ""
+ super()
+ end
+
+ def rightcol; @leftcol + buffer.content_width; end
+
+ def draw
+ ensure_mode_validity
+ (@topline ... @botline).each { |ln| draw_line ln, :color => :text_color }
+ ((@botline - @topline) ... buffer.content_height).each do |ln|
+ if @twiddles
+ buffer.write ln, 0, "~", :color => :twiddle_color
+ else
+ buffer.write ln, 0, "", :color => :text_color
+ end
+ end
+ @status = "lines #{@topline + 1}:#{@botline}/#{lines}"
+ end
+
+ def in_search?; @search_line end
+ def cancel_search!; @search_line = nil end
+
+ def continue_search_in_buffer
+ unless @search_query
+ BufferManager.flash "No current search!"
+ return
+ end
+
+ start = @search_line || search_start_line
+ line, col = find_text @search_query, start
+ if line.nil? && (start > 0)
+ line, col = find_text @search_query, 0
+ BufferManager.flash "Search wrapped to top!" if line
+ end
+ if line
+ @search_line = line + 1
+ search_goto_pos line, col, col + @search_query.display_length
+ buffer.mark_dirty
+ else
+ BufferManager.flash "Not found!"
+ end
+ end
+
+ def search_in_buffer
+ query = BufferManager.ask :search, "search in buffer: "
+ return if query.nil? || query.empty?
+ @search_query = Regexp.escape query
+ continue_search_in_buffer
+ end
+
+ ## subclasses can override these three!
+ def search_goto_pos line, leftcol, rightcol
+ search_goto_line line
+
+ if rightcol > self.rightcol # if it's occluded...
+ jump_to_col [rightcol - buffer.content_width + 1, 0].max # move right
+ end
+ end
+ def search_start_line; @topline end
+ def search_goto_line line; jump_to_line line end
+
+ def col_jump
+ $config[:col_jump] || 2
+ 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
+
+ def jump_to_col col
+ col = col - (col % col_jump)
+ buffer.mark_dirty unless @leftcol == col
+ @leftcol = col
+ end
+
+ def jump_to_left; jump_to_col 0; 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 at_top?; @topline == 0 end
+ def at_bottom?; @botline == lines 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 half_page_down; jump_to_line @topline + buffer.content_height / 2; end
+ def half_page_up; jump_to_line @topline - buffer.content_height / 2; end
+ def jump_to_start; 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, 0].max
+ @botline = [@topline + buffer.content_height, lines].min
+ end
+
+ def resize *a
+ super(*a)
+ ensure_mode_validity
+ end
+
+protected
+
+ def find_text query, start_line
+ regex = /#{query}/i
+ (start_line ... lines).each do |i|
+ case(s = self[i])
+ when String
+ match = s =~ regex
+ return [i, match] if match
+ when Array
+ offset = 0
+ s.each do |color, string|
+ match = string =~ regex
+ if match
+ return [i, offset + match]
+ else
+ offset += string.display_length
+ end
+ end
+ end
+ end
+ nil
+ end
+
+ def draw_line ln, opts={}
+ regex = /(#{@search_query})/i
+ case(s = self[ln])
+ when String
+ if in_search?
+ draw_line_from_array ln, matching_text_array(s, regex), opts
+ else
+ draw_line_from_string ln, s, opts
+ end
+ when Array
+ if in_search?
+ ## seems like there ought to be a better way of doing this
+ array = []
+ s.each do |color, text|
+ if text =~ regex
+ array += matching_text_array text, regex, color
+ else
+ array << [color, text]
+ end
+ end
+ draw_line_from_array ln, array, opts
+ else
+ draw_line_from_array ln, s, opts
+ end
+ else
+ raise "unknown drawable object: #{s.inspect} in #{self} for line #{ln}" # good for debugging
+ end
+
+ ## speed test
+ # str = s.map { |color, text| text }.join
+ # buffer.write ln - @topline, 0, str, :color => :none, :highlight => opts[:highlight]
+ # return
+ end
+
+ def matching_text_array s, regex, oldcolor=:text_color
+ s.split(regex).map do |text|
+ next if text.empty?
+ if text =~ regex
+ [:search_highlight_color, text]
+ else
+ [oldcolor, text]
+ end
+ end.compact + [[oldcolor, ""]]
+ end
+
+ def draw_line_from_array ln, a, opts
+ xpos = 0
+ a.each_with_index do |(color, text), i|
+ raise "nil text for color '#{color}'" if text.nil? # good for debugging
+ l = text.display_length
+ no_fill = i != a.size - 1
+
+ if xpos + l < @leftcol
+ buffer.write ln - @topline, 0, "", :color => color,
+ :highlight => opts[:highlight]
+ elsif xpos < @leftcol
+ ## partial
+ buffer.write ln - @topline, 0, text[(@leftcol - xpos) .. -1],
+ :color => color,
+ :highlight => opts[:highlight], :no_fill => no_fill
+ else
+ buffer.write ln - @topline, xpos - @leftcol, text,
+ :color => color, :highlight => opts[:highlight],
+ :no_fill => no_fill
+ end
+ xpos += l
+ end
+ end
+
+ def draw_line_from_string ln, s, opts
+ buffer.write ln - @topline, 0, s[@leftcol .. -1], :highlight => opts[:highlight], :color => opts[:color]
+ end
+end
+
+end
+
diff --git a/lib/sup/modes/search-list-mode.rb b/lib/sup/modes/search_list_mode.rb
diff --git a/lib/sup/modes/search-results-mode.rb b/lib/sup/modes/search_results_mode.rb
diff --git a/lib/sup/modes/text-mode.rb b/lib/sup/modes/text_mode.rb
diff --git a/lib/sup/modes/thread-index-mode.rb b/lib/sup/modes/thread-index-mode.rb
@@ -1,950 +0,0 @@
-require 'set'
-
-module Redwood
-
-## subclasses should implement:
-## - is_relevant?
-
-class ThreadIndexMode < LineCursorMode
- DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
- MIN_FROM_WIDTH = 15
- LOAD_MORE_THREAD_NUM = 20
-
- HookManager.register "index-mode-size-widget", <<EOS
-Generates the per-thread size widget for each thread.
-Variables:
- thread: The message thread to be formatted.
-EOS
-
- HookManager.register "index-mode-date-widget", <<EOS
-Generates the per-thread date widget for each thread.
-Variables:
- thread: The message thread to be formatted.
-EOS
-
- HookManager.register "mark-as-spam", <<EOS
-This hook is run when a thread is marked as spam
-Variables:
- thread: The message thread being marked as spam.
-EOS
-
- register_keymap do |k|
- k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
- k.add_multi "Load all threads (! to confirm) :", '!' do |kk|
- kk.add :load_all_threads, "Load all threads (may list a _lot_ of threads)", '!'
- end
- k.add :cancel_search, "Cancel current search", :ctrl_g
- k.add :reload, "Refresh view", '@'
- 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 :toggle_spam, "Mark/unmark thread as spam", 'S'
- k.add :toggle_deleted, "Delete/undelete thread", 'd'
- k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
- k.add :flush_index, "Flush all changes now", '$'
- k.add :jump_to_next_new, "Jump to next new thread", :tab
- k.add :reply, "Reply to latest message in a thread", 'r'
- k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
- k.add :forward, "Forward latest message in a thread", 'f'
- k.add :toggle_tagged, "Tag/untag selected thread", 't'
- k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
- k.add :tag_matching, "Tag matching threads", 'g'
- k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
- k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
- k.add :undo, "Undo the previous action", 'u'
- end
-
- def initialize hidden_labels=[], load_thread_opts={}
- super()
- @mutex = Mutex.new # covers the following variables:
- @threads = []
- @hidden_threads = {}
- @size_widget_width = nil
- @size_widgets = []
- @date_widget_width = nil
- @date_widgets = []
- @tags = Tagger.new self
-
- ## these guys, and @text and @lines, are not covered
- @load_thread = nil
- @load_thread_opts = load_thread_opts
- @hidden_labels = hidden_labels + LabelManager::HIDDEN_RESERVED_LABELS
- @date_width = DATE_WIDTH
-
- @interrupt_search = false
-
- initialize_threads # defines @ts and @ts_mutex
- update # defines @text and @lines
-
- UpdateManager.register self
-
- @save_thread_mutex = Mutex.new
-
- @last_load_more_size = nil
- to_load_more do |size|
- next if @last_load_more_size == 0
- load_threads :num => size,
- :when_done => lambda { |num| @last_load_more_size = num }
- end
- end
-
- def unsaved?; dirty? end
- def lines; @text.length; end
- def [] i; @text[i]; end
- def contains_thread? t; @threads.include?(t) end
-
- def reload
- drop_all_threads
- UndoManager.clear
- BufferManager.draw_screen
- load_threads :num => buffer.content_height
- end
-
- ## open up a thread view window
- def select t=nil, when_done=nil
- t ||= cursor_thread or return
-
- Redwood::reporting_thread("load messages for thread-view-mode") do
- num = t.size
- message = "Loading #{num.pluralize 'message body'}..."
- BufferManager.say(message) do |sid|
- t.each_with_index do |(m, *o), i|
- next unless m
- BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1
- m.load_from_source!
- end
- end
- mode = ThreadViewMode.new t, @hidden_labels, self
- BufferManager.spawn t.subj, mode
- BufferManager.draw_screen
- mode.jump_to_first_open if $config[:jump_to_open_message]
- BufferManager.draw_screen # lame TODO: make this unnecessary
- ## the first draw_screen is needed before topline and botline
- ## are set, and the second to show the cursor having moved
-
- t.remove_label :unread
- Index.save_thread t
-
- update_text_for_line curpos
- UpdateManager.relay self, :read, t.first
- when_done.call if when_done
- end
- end
-
- def multi_select threads
- threads.each { |t| select t }
- end
-
- ## these two methods are called by thread-view-modes when the user
- ## wants to view the previous/next thread without going back to
- ## index-mode. we update the cursor as a convenience.
- def launch_next_thread_after thread, &b
- launch_another_thread thread, 1, &b
- end
-
- def launch_prev_thread_before thread, &b
- launch_another_thread thread, -1, &b
- end
-
- def launch_another_thread thread, direction, &b
- l = @lines[thread] or return
- target_l = l + direction
- t = @mutex.synchronize do
- if target_l >= 0 && target_l < @threads.length
- @threads[target_l]
- end
- end
-
- if t # there's a next thread
- set_cursor_pos target_l # move out of mutex?
- select t, b
- elsif b # no next thread. call the block anyways
- b.call
- end
- end
-
- def handle_single_message_labeled_update sender, m
- ## no need to do anything different here; we don't differentiate
- ## messages from their containing threads
- handle_labeled_update sender, m
- end
-
- def handle_labeled_update sender, m
- if(t = thread_containing(m))
- l = @lines[t] or return
- update_text_for_line l
- elsif is_relevant?(m)
- add_or_unhide m
- end
- end
-
- def handle_simple_update sender, m
- t = thread_containing(m) or return
- l = @lines[t] or return
- update_text_for_line l
- end
-
- %w(read unread archived starred unstarred).each do |state|
- define_method "handle_#{state}_update" do |*a|
- handle_simple_update(*a)
- end
- end
-
- ## overwrite me!
- def is_relevant? m; false; end
-
- def handle_added_update sender, m
- add_or_unhide m
- BufferManager.draw_screen
- end
-
- def handle_single_message_deleted_update sender, m
- @ts_mutex.synchronize do
- return unless @ts.contains? m
- @ts.remove_id m.id
- end
- update
- end
-
- def handle_deleted_update sender, m
- t = @ts_mutex.synchronize { @ts.thread_for m }
- return unless t
- hide_thread t
- update
- end
-
- def handle_spammed_update sender, m
- t = @ts_mutex.synchronize { @ts.thread_for m }
- return unless t
- hide_thread t
- update
- end
-
- def handle_undeleted_update sender, m
- add_or_unhide m
- end
-
- def undo
- UndoManager.undo
- end
-
- def update
- old_cursor_thread = cursor_thread
- @mutex.synchronize do
- ## let's see you do THIS in python
- @threads = @ts.threads.select { |t| !@hidden_threads.member?(t) }.select(&:has_message?).sort_by(&:sort_key)
- @size_widgets = @threads.map { |t| size_widget_for_thread t }
- @size_widget_width = @size_widgets.max_of { |w| w.display_length }
- @date_widgets = @threads.map { |t| date_widget_for_thread t }
- @date_widget_width = @date_widgets.max_of { |w| w.display_length }
- end
- set_cursor_pos @threads.index(old_cursor_thread)||curpos
-
- regen_text
- end
-
- def edit_message
- return unless(t = cursor_thread)
- 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
-
- ## returns an undo lambda
- def actually_toggle_starred t
- pos = curpos
- if t.has_label? :starred # if ANY message has a star
- t.remove_label :starred # remove from all
- UpdateManager.relay self, :unstarred, t.first
- lambda do
- t.first.add_label :starred
- UpdateManager.relay self, :starred, t.first
- regen_text
- end
- else
- t.first.add_label :starred # add only to first
- UpdateManager.relay self, :starred, t.first
- lambda do
- t.remove_label :starred
- UpdateManager.relay self, :unstarred, t.first
- regen_text
- end
- end
- end
-
- def toggle_starred
- t = cursor_thread or return
- undo = actually_toggle_starred t
- UndoManager.register "toggling thread starred status", undo, lambda { Index.save_thread t }
- update_text_for_line curpos
- cursor_down
- Index.save_thread t
- end
-
- def multi_toggle_starred threads
- UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
- threads.map { |t| actually_toggle_starred t },
- lambda { threads.each { |t| Index.save_thread t } }
- regen_text
- threads.each { |t| Index.save_thread t }
- end
-
- ## returns an undo lambda
- def actually_toggle_archived t
- thread = t
- pos = curpos
- if t.has_label? :inbox
- t.remove_label :inbox
- UpdateManager.relay self, :archived, t.first
- lambda do
- thread.apply_label :inbox
- update_text_for_line pos
- UpdateManager.relay self,:unarchived, thread.first
- end
- else
- t.apply_label :inbox
- UpdateManager.relay self, :unarchived, t.first
- lambda do
- thread.remove_label :inbox
- update_text_for_line pos
- UpdateManager.relay self, :unarchived, thread.first
- end
- end
- end
-
- ## returns an undo lambda
- def actually_toggle_spammed t
- thread = t
- if t.has_label? :spam
- t.remove_label :spam
- add_or_unhide t.first
- UpdateManager.relay self, :unspammed, t.first
- lambda do
- thread.apply_label :spam
- self.hide_thread thread
- UpdateManager.relay self,:spammed, thread.first
- end
- else
- t.apply_label :spam
- hide_thread t
- UpdateManager.relay self, :spammed, t.first
- lambda do
- thread.remove_label :spam
- add_or_unhide thread.first
- UpdateManager.relay self,:unspammed, thread.first
- end
- end
- end
-
- ## returns an undo lambda
- def actually_toggle_deleted t
- if t.has_label? :deleted
- t.remove_label :deleted
- add_or_unhide t.first
- UpdateManager.relay self, :undeleted, t.first
- lambda do
- t.apply_label :deleted
- hide_thread t
- UpdateManager.relay self, :deleted, t.first
- end
- else
- t.apply_label :deleted
- hide_thread t
- UpdateManager.relay self, :deleted, t.first
- lambda do
- t.remove_label :deleted
- add_or_unhide t.first
- UpdateManager.relay self, :undeleted, t.first
- end
- end
- end
-
- def toggle_archived
- t = cursor_thread or return
- undo = actually_toggle_archived t
- UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos },
- lambda { Index.save_thread t }
- update_text_for_line curpos
- Index.save_thread t
- end
-
- def multi_toggle_archived threads
- undos = threads.map { |t| actually_toggle_archived t }
- UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text },
- lambda { threads.each { |t| Index.save_thread t } }
- regen_text
- threads.each { |t| Index.save_thread t }
- end
-
- def toggle_new
- t = cursor_thread or return
- t.toggle_label :unread
- update_text_for_line curpos
- cursor_down
- Index.save_thread t
- end
-
- def multi_toggle_new threads
- threads.each { |t| t.toggle_label :unread }
- regen_text
- threads.each { |t| Index.save_thread t }
- end
-
- def multi_toggle_tagged threads
- @mutex.synchronize { @tags.drop_all_tags }
- regen_text
- end
-
- def join_threads
- ## this command has no non-tagged form. as a convenience, allow this
- ## command to be applied to tagged threads without hitting ';'.
- @tags.apply_to_tagged :join_threads
- end
-
- def multi_join_threads threads
- @ts.join_threads threads or return
- threads.each { |t| Index.save_thread t }
- @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
- update
- end
-
- def jump_to_next_new
- n = @mutex.synchronize do
- ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
- (0 ... curpos).find { |i| @threads[i].has_label? :unread }
- end
- if n
- ## jump there if necessary
- jump_to_line n unless n >= topline && n < botline
- set_cursor_pos n
- else
- BufferManager.flash "No new messages."
- end
- end
-
- def toggle_spam
- t = cursor_thread or return
- multi_toggle_spam [t]
- end
-
- ## both spam and deleted have the curious characteristic that you
- ## always want to hide the thread after either applying or removing
- ## that label. in all thread-index-views except for
- ## label-search-results-mode, when you mark a message as spam or
- ## deleted, you want it to disappear immediately; in LSRM, you only
- ## see deleted or spam emails, and when you undelete or unspam them
- ## you also want them to disappear immediately.
- def multi_toggle_spam threads
- undos = threads.map { |t| actually_toggle_spammed t }
- threads.each { |t| HookManager.run("mark-as-spam", :thread => t) }
- UndoManager.register "marking/unmarking #{threads.size.pluralize 'thread'} as spam",
- undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
- regen_text
- threads.each { |t| Index.save_thread t }
- end
-
- def toggle_deleted
- t = cursor_thread or return
- multi_toggle_deleted [t]
- end
-
- ## see comment for multi_toggle_spam
- def multi_toggle_deleted threads
- undos = threads.map { |t| actually_toggle_deleted t }
- UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
- undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
- regen_text
- threads.each { |t| Index.save_thread t }
- end
-
- def kill
- t = cursor_thread or return
- multi_kill [t]
- end
-
- def flush_index
- @flush_id = BufferManager.say "Flushing index..."
- Index.save_index
- BufferManager.clear @flush_id
- end
-
- ## m-m-m-m-MULTI-KILL
- def multi_kill threads
- UndoManager.register "killing/unkilling #{threads.size.pluralize 'threads'}" do
- threads.each do |t|
- if t.toggle_label :killed
- add_or_unhide t.first
- else
- hide_thread t
- end
- end.each do |t|
- UpdateManager.relay self, :labeled, t.first
- Index.save_thread t
- end
- regen_text
- end
-
- threads.each do |t|
- if t.toggle_label :killed
- hide_thread t
- else
- add_or_unhide t.first
- end
- end.each do |t|
- # send 'labeled'... this might be more specific
- UpdateManager.relay self, :labeled, t.first
- Index.save_thread t
- end
-
- killed, unkilled = threads.partition { |t| t.has_label? :killed }.map(&:size)
- BufferManager.flash "#{killed.pluralize 'thread'} killed, #{unkilled} unkilled"
- regen_text
- 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
- dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
- fail "dirty threads remain" unless dirty_threads.empty?
- super
- end
-
- def toggle_tagged
- t = cursor_thread or return
- @mutex.synchronize { @tags.toggle_tag_for t }
- update_text_for_line curpos
- cursor_down
- end
-
- def toggle_tagged_all
- @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } }
- regen_text
- end
-
- def tag_matching
- query = BufferManager.ask :search, "tag threads matching (regex): "
- return if query.nil? || query.empty?
- query = begin
- /#{query}/i
- rescue RegexpError => e
- BufferManager.flash "error interpreting '#{query}': #{e.message}"
- return
- end
- @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
- regen_text
- end
-
- def apply_to_tagged; @tags.apply_to_tagged; end
-
- def edit_labels
- thread = cursor_thread or return
- speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
-
- old_labels = thread.labels
- pos = curpos
-
- keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
-
- user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl.sort_by {|x| x.to_s}, @hidden_labels
- return unless user_labels
-
- thread.labels = Set.new(keepl) + user_labels
- user_labels.each { |l| LabelManager << l }
- update_text_for_line curpos
-
- UndoManager.register "labeling thread" do
- thread.labels = old_labels
- update_text_for_line pos
- UpdateManager.relay self, :labeled, thread.first
- Index.save_thread thread
- end
-
- UpdateManager.relay self, :labeled, thread.first
- Index.save_thread thread
- end
-
- def multi_edit_labels threads
- user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
- return unless user_labels
-
- user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
- hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
- unless hl.empty?
- BufferManager.flash "'#{hl}' is a reserved label!"
- return
- end
-
- old_labels = threads.map { |t| t.labels.dup }
-
- threads.each do |t|
- user_labels.each do |(l, to_remove)|
- if to_remove
- t.remove_label l
- else
- t.apply_label l
- LabelManager << l
- end
- end
- UpdateManager.relay self, :labeled, t.first
- end
-
- regen_text
-
- UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
- threads.zip(old_labels).map do |t, old_labels|
- t.labels = old_labels
- UpdateManager.relay self, :labeled, t.first
- Index.save_thread t
- end
- regen_text
- end
-
- threads.each { |t| Index.save_thread t }
- end
-
- def reply type_arg=nil
- t = cursor_thread or return
- m = t.latest_message
- return if m.nil? # probably won't happen
- m.load_from_source!
- mode = ReplyMode.new m, type_arg
- BufferManager.spawn "Reply to #{m.subj}", mode
- end
-
- def reply_all; reply :all; end
-
- def forward
- t = cursor_thread or return
- m = t.latest_message
- return if m.nil? # probably won't happen
- m.load_from_source!
- ForwardMode.spawn_nicely :message => m
- end
-
- def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
- return if @load_thread # todo: wrap in mutex
- @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do
- num = load_n_threads n, opts
- opts[:when_done].call(num) if opts[:when_done]
- @load_thread = nil
- end
- end
-
- ## TODO: figure out @ts_mutex in this method
- def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
- @interrupt_search = false
- @mbid = BufferManager.say "Searching for threads..."
-
- ts_to_load = n
- ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads
-
- orig_size = @ts.size
- last_update = Time.now
- @ts.load_n_threads(ts_to_load, opts) do |i|
- if (Time.now - last_update) >= 0.25
- BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid
- update
- BufferManager.draw_screen
- last_update = Time.now
- end
- ::Thread.pass
- break if @interrupt_search
- 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
- ignore_concurrent_calls :load_n_threads
-
- def status
- if (l = lines) == 0
- "line 0 of 0"
- else
- "line #{curpos + 1} of #{l}"
- end
- end
-
- def cancel_search
- @interrupt_search = true
- end
-
- def load_all_threads
- load_threads :num => -1
- end
-
- def load_threads opts={}
- if opts[:num].nil?
- n = ThreadIndexMode::LOAD_MORE_THREAD_NUM
- else
- n = opts[:num]
- end
-
- myopts = @load_thread_opts.merge({ :when_done => (lambda do |num|
- opts[:when_done].call(num) if opts[:when_done]
-
- if num > 0
- BufferManager.flash "Found #{num.pluralize 'thread'}."
- else
- BufferManager.flash "No matches."
- end
- end)})
-
- if opts[:background] || opts[:background].nil?
- load_n_threads_background n, myopts
- else
- load_n_threads n, myopts
- end
- end
- ignore_concurrent_calls :load_threads
-
- def resize rows, cols
- regen_text
- super
- end
-
-protected
-
- def add_or_unhide m
- @ts_mutex.synchronize do
- if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
- @ts.load_thread_for_message m, @load_thread_opts
- end
-
- @hidden_threads.delete @ts.thread_for(m)
- end
-
- update
- end
-
- def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
-
- ## used to tag threads by query. this can be made a lot more sophisticated,
- ## but for right now we'll do the obvious this.
- def thread_matches? t, query
- t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
- end
-
- def size_widget_for_thread t
- HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
- end
-
- def date_widget_for_thread t
- HookManager.run("index-mode-date-widget", :thread => t) || default_date_widget_for(t)
- end
-
- def cursor_thread; @mutex.synchronize { @threads[curpos] }; end
-
- def drop_all_threads
- @tags.drop_all_tags
- initialize_threads
- update
- end
-
- def hide_thread t
- @mutex.synchronize do
- i = @threads.index(t) or return
- raise "already hidden" if @hidden_threads[t]
- @hidden_threads[t] = true
- @threads.delete_at i
- @size_widgets.delete_at i
- @date_widgets.delete_at i
- @tags.drop_tag_for t
- end
- end
-
- def update_text_for_line l
- return unless l # not sure why this happens, but it does, occasionally
-
- need_update = false
-
- @mutex.synchronize do
- @size_widgets[l] = size_widget_for_thread @threads[l]
- @date_widgets[l] = date_widget_for_thread @threads[l]
-
- ## if a widget size has increased, we need to redraw everyone
- need_update =
- (@size_widgets[l].size > @size_widget_width) or
- (@date_widgets[l].size > @date_widget_width)
- end
-
- if need_update
- update
- else
- @text[l] = text_for_thread_at l
- buffer.mark_dirty if buffer
- end
- end
-
- def regen_text
- threads = @mutex.synchronize { @threads }
- @text = threads.map_with_index { |t, i| text_for_thread_at i }
- @lines = threads.map_with_index { |t, i| [t, i] }.to_h
- buffer.mark_dirty if buffer
- end
-
- def authors; map { |m, *o| m.from if m }.compact.uniq; end
-
- ## preserve author order from the thread
- def author_names_and_newness_for_thread t, limit=nil
- new = {}
- seen = {}
- authors = t.map do |m, *o|
- next unless m && m.from
- new[m.from] ||= m.has_label?(:unread)
- next if seen[m.from]
- seen[m.from] = true
- m.from
- end.compact
-
- result = []
- authors.each do |a|
- break if limit && result.size >= limit
- name = if AccountManager.is_account?(a)
- "me"
- elsif t.authors.size == 1
- a.mediumname
- else
- a.shortname
- end
-
- result << [name, new[a]]
- end
-
- if result.size == 1 && (author_and_newness = result.assoc("me"))
- unless (recipients = t.participants - t.authors).empty?
- result = recipients.collect do |r|
- break if limit && result.size >= limit
- name = (recipients.size == 1) ? r.mediumname : r.shortname
- ["(#{name})", author_and_newness[1]]
- end
- end
- end
-
- result
- end
-
- AUTHOR_LIMIT = 5
- def text_for_thread_at line
- t, size_widget, date_widget = @mutex.synchronize do
- [@threads[line], @size_widgets[line], @date_widgets[line]]
- end
-
- starred = t.has_label? :starred
-
- ## format the from column
- cur_width = 0
- ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT
- from = []
- ann.each_with_index do |(name, newness), i|
- break if cur_width >= from_width
- last = i == ann.length - 1
-
- abbrev =
- if cur_width + name.display_length > from_width
- name[0 ... (from_width - cur_width - 1)] + "."
- elsif cur_width + name.display_length == from_width
- name[0 ... (from_width - cur_width)]
- else
- if last
- name[0 ... (from_width - cur_width)]
- else
- name[0 ... (from_width - cur_width - 1)] + ","
- end
- end
-
- cur_width += abbrev.display_length
-
- if last && from_width > cur_width
- abbrev += " " * (from_width - cur_width)
- end
-
- from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev]
- end
-
- dp = t.direct_participants.any? { |p| AccountManager.is_account? p }
- p = dp || t.participants.any? { |p| AccountManager.is_account? p }
-
- subj_color =
- if t.has_label?(:draft)
- :index_draft_color
- elsif t.has_label?(:unread)
- :index_new_color
- elsif starred
- :index_starred_color
- elsif Colormap.sym_is_defined(:index_subject_color)
- :index_subject_color
- else
- :index_old_color
- end
-
- size_padding = @size_widget_width - size_widget.display_length
- size_widget_text = sprintf "%#{size_padding}s%s", "", size_widget
-
- date_padding = @date_widget_width - date_widget.display_length
- date_widget_text = sprintf "%#{date_padding}s%s", "", date_widget
-
- [
- [:tagged_color, @tags.tagged?(t) ? ">" : " "],
- [:date_color, date_widget_text],
- [:starred_color, (starred ? "*" : " ")],
- ] +
- from +
- [
- [:size_widget_color, size_widget_text],
- [:to_me_color, t.labels.member?(:attachment) ? "@" : " "],
- [:to_me_color, dp ? ">" : (p ? '+' : " ")],
- ] +
- (t.labels - @hidden_labels).sort_by {|x| x.to_s}.map {
- |label| [Colormap.sym_is_defined("label_#{label}_color".to_sym) || :label_color, "#{label} "]
- } +
- [
- [subj_color, t.subj + (t.subj.empty? ? "" : " ")],
- [:snippet_color, t.snippet],
- ]
- end
-
- def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
-
-private
-
- def default_size_widget_for t
- case t.size
- when 1
- ""
- else
- "(#{t.size})"
- end
- end
-
- def default_date_widget_for t
- t.date.getlocal.to_nice_s
- end
-
- def from_width
- [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
- end
-
- def initialize_threads
- @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
- @ts_mutex = Mutex.new
- @hidden_threads = {}
- end
-end
-
-end
diff --git a/lib/sup/modes/thread-view-mode.rb b/lib/sup/modes/thread-view-mode.rb
@@ -1,897 +0,0 @@
-module Redwood
-
-class ThreadViewMode < LineCursorMode
- ## this holds all info we need to lay out a message
- class MessageLayout
- attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new, :toggled_state
- end
-
- class ChunkLayout
- attr_accessor :state
- end
-
- DATE_FORMAT = "%B %e %Y %l:%M%p"
- INDENT_SPACES = 2 # how many spaces to indent child messages
-
- HookManager.register "detailed-headers", <<EOS
-Add or remove headers from the detailed header display of a message.
-Variables:
- message: The message whose headers are to be formatted.
- headers: A hash of header (name, value) pairs, initialized to the default
- headers.
-Return value:
- None. The variable 'headers' should be modified in place.
-EOS
-
- HookManager.register "bounce-command", <<EOS
-Determines the command used to bounce a message.
-Variables:
- from: The From header of the message being bounced
- (eg: likely _not_ your address).
- to: The addresses you asked the message to be bounced to as an array.
-Return value:
- A string representing the command to pipe the mail into. This
- should include the entire command except for the destination addresses,
- which will be appended by sup.
-EOS
-
- HookManager.register "publish", <<EOS
-Executed when a message or a chunk is requested to be published.
-Variables:
- chunk: Redwood::Message or Redwood::Chunk::* to be published.
-Return value:
- None.
-EOS
-
- register_keymap do |k|
- k.add :toggle_detailed_header, "Toggle detailed header", 'h'
- k.add :show_header, "Show full message header", 'H'
- k.add :show_message, "Show full message (raw form)", 'V'
- k.add :activate_chunk, "Expand/collapse or activate item", :enter
- k.add :expand_all_messages, "Expand/collapse all messages", 'E'
- k.add :edit_draft, "Edit draft", 'e'
- k.add :send_draft, "Send draft", 'y'
- k.add :edit_labels, "Edit or add labels for a thread", 'l'
- 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_next_and_open, "Jump to next message and open", "\C-n"
- k.add :jump_to_prev_open, "Jump to previous open message", 'p'
- k.add :jump_to_prev_and_open, "Jump to previous message and open", "\C-p"
- k.add :align_current_message, "Align current message in buffer", 'z'
- k.add :toggle_starred, "Star or unstar message", '*'
- k.add :toggle_new, "Toggle unread/read status of message", 'N'
-# k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
- k.add :reply, "Reply to a message", 'r'
- k.add :reply_all, "Reply to all participants of this message", 'G'
- k.add :forward, "Forward a message or attachment", 'f'
- k.add :bounce, "Bounce message to other recipient(s)", '!'
- k.add :alias, "Edit alias/nickname for a person", 'i'
- k.add :edit_as_new, "Edit message as new", 'D'
- k.add :save_to_disk, "Save message/attachment to disk", 's'
- k.add :save_all_to_disk, "Save all attachments to disk", 'A'
- k.add :publish, "Publish message/attachment using publish-hook", 'P'
- k.add :search, "Search for messages from particular people", 'S'
- k.add :compose, "Compose message to person", 'm'
- k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
- k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
- k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
-
- k.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
- k.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
- k.add :toggle_wrap, "Toggle wrapping of text", 'w'
-
- k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
- kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
- kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
- kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
- kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
- kk.add :do_nothing_and_kill, "Just kill this buffer", '.'
- end
-
- k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
- kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
- kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
- kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
- kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
- kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n', ','
- end
-
- k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
- kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
- kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
- kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
- kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
- kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n', ']'
- end
- end
-
- ## there are a couple important instance variables we hold to format
- ## the thread and to provide line-based functionality. @layout is a
- ## map from Messages to MessageLayouts, and @chunk_layout from
- ## Chunks to ChunkLayouts. @message_lines is a map from row #s to
- ## Message objects. @chunk_lines is a map from row #s to Chunk
- ## objects. @person_lines is a map from row #s to Person objects.
-
- def initialize thread, hidden_labels=[], index_mode=nil
- super :slip_rows => $config[:slip_rows]
- @thread = thread
- @hidden_labels = hidden_labels
-
- ## used for dispatch-and-next
- @index_mode = index_mode
- @dying = false
-
- @layout = SavingHash.new { MessageLayout.new }
- @chunk_layout = SavingHash.new { ChunkLayout.new }
- earliest, latest = nil, nil
- latest_date = nil
- altcolor = false
-
- @thread.each do |m, d, p|
- next unless m
- earliest ||= m
- @layout[m].state = initial_state_for m
- @layout[m].toggled_state = false
- @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
- @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
- @layout[m].orig_new = m.has_label? :read
- altcolor = !altcolor
- if latest_date.nil? || m.date > latest_date
- latest_date = m.date
- latest = m
- end
- end
-
- @wrap = true
-
- @layout[latest].state = :open if @layout[latest].state == :closed
- @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
- end
-
- def toggle_wrap
- @wrap = !@wrap
- regen_text
- buffer.mark_dirty if buffer
- 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
-
- ## a little hacky---since regen_text can depend on buffer features like the
- ## content_width, we don't call it in the constructor, and instead call it
- ## here, which is set before we're responsible for drawing ourself.
- def buffer= b
- super
- regen_text
- end
-
- def show_header
- m = @message_lines[curpos] or return
- BufferManager.spawn_unless_exists("Full header for #{m.id}") do
- TextMode.new m.raw_header.ascii
- end
- end
-
- def show_message
- m = @message_lines[curpos] or return
- BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
- TextMode.new m.raw_message.ascii
- end
- end
-
- def toggle_detailed_header
- m = @message_lines[curpos] or return
- @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
- update
- end
-
- def reply type_arg=nil
- m = @message_lines[curpos] or return
- mode = ReplyMode.new m, type_arg
- BufferManager.spawn "Reply to #{m.subj}", mode
- end
-
- def reply_all; reply :all; end
-
- def subscribe_to_list
- m = @message_lines[curpos] or return
- if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
- ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "subscribe")
- else
- BufferManager.flash "Can't find List-Subscribe header for this message."
- end
- end
-
- def unsubscribe_from_list
- m = @message_lines[curpos] or return
- if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
- ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "unsubscribe")
- else
- BufferManager.flash "Can't find List-Unsubscribe header for this message."
- end
- end
-
- def forward
- if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
- ForwardMode.spawn_nicely :attachments => [chunk]
- elsif(m = @message_lines[curpos])
- ForwardMode.spawn_nicely :message => m
- end
- end
-
- def bounce
- m = @message_lines[curpos] or return
- to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
-
- defcmd = AccountManager.default_account.bounce_sendmail
-
- cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
- when nil, /^$/ then defcmd
- else hookcmd
- end + ' ' + to.map { |t| t.email }.join(' ')
-
- bt = to.size > 1 ? "#{to.size} recipients" : to.to_s
-
- if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
- debug "bounce command: #{cmd}"
- begin
- IO.popen(cmd, 'w') do |sm|
- sm.puts m.raw_message
- end
- raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
- rescue SystemCallError, SendmailCommandFailed => e
- warn "problem sending mail: #{e.message}"
- BufferManager.flash "Problem sending mail: #{e.message}"
- end
- end
- end
-
- include CanAliasContacts
- def alias
- p = @person_lines[curpos] or return
- alias_contact p
- update
- end
-
- def search
- p = @person_lines[curpos] or return
- mode = PersonSearchResultsMode.new [p]
- BufferManager.spawn "Search for #{p.name}", mode
- mode.load_threads :num => mode.buffer.content_height
- end
-
- def compose
- p = @person_lines[curpos]
- if p
- ComposeMode.spawn_nicely :to_default => p
- else
- ComposeMode.spawn_nicely
- end
- end
-
- def edit_labels
- old_labels = @thread.labels
- reserved_labels = old_labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
- new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels.sort_by {|x| x.to_s}
-
- return unless new_labels
- @thread.labels = Set.new(reserved_labels) + new_labels
- new_labels.each { |l| LabelManager << l }
- update
- UpdateManager.relay self, :labeled, @thread.first
- Index.save_thread @thread
- UndoManager.register "labeling thread" do
- @thread.labels = old_labels
- Index.save_thread @thread
- UpdateManager.relay self, :labeled, @thread.first
- end
- end
-
- def toggle_starred
- m = @message_lines[curpos] or return
- toggle_label m, :starred
- end
-
- def toggle_new
- m = @message_lines[curpos] or return
- toggle_label m, :unread
- end
-
- def toggle_label m, label
- if m.has_label? label
- m.remove_label label
- else
- m.add_label label
- end
- ## TODO: don't recalculate EVERYTHING just to add a stupid little
- ## star to the display
- update
- UpdateManager.relay self, :single_message_labeled, m
- Index.save_thread @thread
- end
-
- ## called when someone presses enter when the cursor is highlighting
- ## a chunk. for expandable chunks (including messages) we toggle
- ## open/closed state; for viewable chunks (like attachments) we
- ## view.
- def activate_chunk
- chunk = @chunk_lines[curpos] or return
- if chunk.is_a? Chunk::Text
- ## if the cursor is over a text region, expand/collapse the
- ## entire message
- chunk = @message_lines[curpos]
- end
- layout = if chunk.is_a?(Message)
- @layout[chunk]
- elsif chunk.expandable?
- @chunk_layout[chunk]
- end
- if layout
- layout.state = (layout.state != :closed ? :closed : :open)
- #cursor_down if layout.state == :closed # too annoying
- update
- elsif chunk.viewable?
- view chunk
- end
- if chunk.is_a?(Message) && $config[:jump_to_open_message]
- jump_to_message chunk
- jump_to_next_open if layout.state == :closed
- end
- end
-
- def edit_as_new
- m = @message_lines[curpos] or return
- mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos)
- BufferManager.spawn "edit as new", mode
- mode.edit_message
- end
-
- def save_to_disk
- chunk = @chunk_lines[curpos] or return
- case chunk
- when Chunk::Attachment
- default_dir = $config[:default_attachment_save_dir]
- default_dir = ENV["HOME"] if default_dir.nil? || default_dir.empty?
- default_fn = File.expand_path File.join(default_dir, chunk.filename)
- fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_fn
- save_to_file(fn) { |f| f.print chunk.raw_content } if fn
- else
- m = @message_lines[curpos]
- fn = BufferManager.ask_for_filename :filename, "Save message to file: "
- return unless fn
- save_to_file(fn) do |f|
- m.each_raw_message_line { |l| f.print l }
- end
- end
- end
-
- def save_all_to_disk
- m = @message_lines[curpos] or return
- default_dir = ($config[:default_attachment_save_dir] || ".")
- folder = BufferManager.ask_for_filename :filename, "Save all attachments to folder: ", default_dir, true
- return unless folder
-
- num = 0
- num_errors = 0
- m.chunks.each do |chunk|
- next unless chunk.is_a?(Chunk::Attachment)
- fn = File.join(folder, chunk.filename)
- num_errors += 1 unless save_to_file(fn, false) { |f| f.print chunk.raw_content }
- num += 1
- end
-
- if num == 0
- BufferManager.flash "Didn't find any attachments!"
- else
- if num_errors == 0
- BufferManager.flash "Wrote #{num.pluralize 'attachment'} to #{folder}."
- else
- BufferManager.flash "Wrote #{(num - num_errors).pluralize 'attachment'} to #{folder}; couldn't write #{num_errors} of them (see log)."
- end
- end
- end
-
- def publish
- chunk = @chunk_lines[curpos] or return
- if HookManager.enabled? "publish"
- HookManager.run "publish", :chunk => chunk
- else
- BufferManager.flash "Publishing hook not defined."
- end
- end
-
- def edit_draft
- m = @message_lines[curpos] or return
- if m.is_draft?
- mode = ResumeMode.new m
- BufferManager.spawn "Edit message", mode
- BufferManager.kill_buffer self.buffer
- mode.edit_message
- else
- BufferManager.flash "Not a draft message!"
- end
- end
-
- def send_draft
- m = @message_lines[curpos] or return
- if m.is_draft?
- mode = ResumeMode.new m
- BufferManager.spawn "Send message", mode
- BufferManager.kill_buffer self.buffer
- mode.send_message
- else
- BufferManager.flash "Not a draft message!"
- end
- end
-
- def jump_to_first_open
- m = @message_lines[0] or return
- if @layout[m].state != :closed
- jump_to_message m#, true
- else
- jump_to_next_open #true
- end
- end
-
- def jump_to_next_and_open
- return continue_search_in_buffer if in_search? # err.. don't know why im doing this
-
- m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
- return unless m
-
- if @layout[m].toggled_state == true
- @layout[m].state = :closed
- @layout[m].toggled_state = false
- update
- end
-
- nextm = @layout[m].next
- if @layout[nextm].state == :closed
- @layout[nextm].state = :open
- @layout[nextm].toggled_state = true
- end
-
- jump_to_message nextm if nextm
-
- update if @layout[nextm].toggled_state
- end
-
- def jump_to_next_open force_alignment=nil
- return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
- m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
- return unless m
- while nextm = @layout[m].next
- break if @layout[nextm].state != :closed
- m = nextm
- end
- jump_to_message nextm, force_alignment if nextm
- end
-
- def align_current_message
- m = @message_lines[curpos] or return
- jump_to_message m, true
- end
-
- def jump_to_prev_and_open force_alignment=nil
- m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] }
- return unless m
-
- if @layout[m].toggled_state == true
- @layout[m].state = :closed
- @layout[m].toggled_state = false
- update
- end
-
- nextm = @layout[m].prev
- if @layout[nextm].state == :closed
- @layout[nextm].state = :open
- @layout[nextm].toggled_state = true
- end
-
- jump_to_message nextm if nextm
- update if @layout[nextm].toggled_state
- end
-
- def jump_to_prev_open
- m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
- return unless m
- ## jump to the top of the current message if we're in the body;
- ## otherwise, to the previous message
-
- top = @layout[m].top
- if curpos == top
- while(prevm = @layout[m].prev)
- break if @layout[prevm].state != :closed
- m = prevm
- end
- jump_to_message prevm if prevm
- else
- jump_to_message m
- end
- end
-
- def jump_to_message m, force_alignment=false
- l = @layout[m]
-
- ## boundaries of the message
- message_left = l.depth * INDENT_SPACES
- message_right = message_left + l.width
-
- ## calculate leftmost colum
- left = if force_alignment # force mode: align exactly
- message_left
- else # regular: minimize cursor movement
- ## leftmost and rightmost are boundaries of all valid left-column
- ## alignments.
- leftmost = [message_left, message_right - buffer.content_width + 1].min
- rightmost = message_left
- leftcol.clamp(leftmost, rightmost)
- end
-
- jump_to_line l.top # move vertically
- jump_to_col left # move horizontally
- set_cursor_pos l.top # set cursor pos
- end
-
- def expand_all_messages
- @global_message_state ||= :closed
- @global_message_state = (@global_message_state == :closed ? :open : :closed)
- @layout.each { |m, l| l.state = @global_message_state }
- update
- end
-
- def collapse_non_new_messages
- @layout.each { |m, l| l.state = l.orig_new ? :open : :closed }
- update
- end
-
- def expand_all_quotes
- if(m = @message_lines[curpos])
- quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 }
- numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) }
- newstate = numopen > quotes.length / 2 ? :closed : :open
- quotes.each { |c| @chunk_layout[c].state = newstate }
- update
- end
- end
-
- def cleanup
- @layout = @chunk_layout = @text = nil # for good luck
- end
-
- def archive_and_kill; archive_and_then :kill end
- def spam_and_kill; spam_and_then :kill end
- def delete_and_kill; delete_and_then :kill end
- def unread_and_kill; unread_and_then :kill end
- def do_nothing_and_kill; do_nothing_and_then :kill end
-
- def archive_and_next; archive_and_then :next end
- def spam_and_next; spam_and_then :next end
- def delete_and_next; delete_and_then :next end
- def unread_and_next; unread_and_then :next end
- def do_nothing_and_next; do_nothing_and_then :next end
-
- def archive_and_prev; archive_and_then :prev end
- def spam_and_prev; spam_and_then :prev end
- def delete_and_prev; delete_and_then :prev end
- def unread_and_prev; unread_and_then :prev end
- def do_nothing_and_prev; do_nothing_and_then :prev end
-
- def archive_and_then op
- dispatch op do
- @thread.remove_label :inbox
- UpdateManager.relay self, :archived, @thread.first
- Index.save_thread @thread
- UndoManager.register "archiving 1 thread" do
- @thread.apply_label :inbox
- Index.save_thread @thread
- UpdateManager.relay self, :unarchived, @thread.first
- end
- end
- end
-
- def spam_and_then op
- dispatch op do
- @thread.apply_label :spam
- UpdateManager.relay self, :spammed, @thread.first
- Index.save_thread @thread
- UndoManager.register "marking 1 thread as spam" do
- @thread.remove_label :spam
- Index.save_thread @thread
- UpdateManager.relay self, :unspammed, @thread.first
- end
- end
- end
-
- def delete_and_then op
- dispatch op do
- @thread.apply_label :deleted
- UpdateManager.relay self, :deleted, @thread.first
- Index.save_thread @thread
- UndoManager.register "deleting 1 thread" do
- @thread.remove_label :deleted
- Index.save_thread @thread
- UpdateManager.relay self, :undeleted, @thread.first
- end
- end
- end
-
- def unread_and_then op
- dispatch op do
- @thread.apply_label :unread
- UpdateManager.relay self, :unread, @thread.first
- Index.save_thread @thread
- end
- end
-
- def do_nothing_and_then op
- dispatch op
- end
-
- def dispatch op
- return if @dying
- @dying = true
-
- l = lambda do
- yield if block_given?
- BufferManager.kill_buffer_safely buffer
- end
-
- case op
- when :next
- @index_mode.launch_next_thread_after @thread, &l
- when :prev
- @index_mode.launch_prev_thread_before @thread, &l
- when :kill
- l.call
- else
- raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
- end
- end
- private :dispatch
-
- def pipe_message
- chunk = @chunk_lines[curpos]
- chunk = nil unless chunk.is_a?(Chunk::Attachment)
- message = @message_lines[curpos] unless chunk
-
- return unless chunk || message
-
- command = BufferManager.ask(:shell, "pipe command: ")
- return if command.nil? || command.empty?
-
- output = pipe_to_process(command) do |stream|
- if chunk
- stream.print chunk.raw_content
- else
- message.each_raw_message_line { |l| stream.print l }
- end
- end
-
- if output
- BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
- else
- BufferManager.flash "'#{command}' done!"
- end
- end
-
-private
-
- def initial_state_for m
- if m.has_label?(:starred) || m.has_label?(:unread)
- :open
- else
- :closed
- end
- end
-
- def update
- regen_text
- buffer.mark_dirty if buffer
- end
-
- ## here we generate the actual content lines. we accumulate
- ## everything into @text, and we set @chunk_lines and
- ## @message_lines, and we update @layout.
- def regen_text
- @text = []
- @chunk_lines = []
- @message_lines = []
- @person_lines = []
-
- prevm = nil
- @thread.each do |m, depth, parent|
- unless m.is_a? Message # handle nil and :fake_root
- @text += chunk_to_lines m, nil, @text.length, depth, parent
- next
- end
- l = @layout[m]
-
- ## is this still necessary?
- next unless @layout[m].state # skip discarded drafts
-
- ## build the patina
- text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color
-
- l.top = @text.length
- l.bot = @text.length + text.length # updated below
- l.prev = prevm
- l.next = nil
- l.depth = depth
- # l.state we preserve
- l.width = 0 # updated below
- @layout[l.prev].next = m if l.prev
-
- (0 ... text.length).each do |i|
- @chunk_lines[@text.length + i] = m
- @message_lines[@text.length + i] = m
- lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
- end
-
- @text += text
- prevm = m
- if l.state != :closed
- m.chunks.each do |c|
- cl = @chunk_layout[c]
-
- ## set the default state for chunks
- cl.state ||=
- if c.expandable? && c.respond_to?(:initial_state)
- c.initial_state
- else
- :closed
- end
-
- text = chunk_to_lines c, cl.state, @text.length, depth
- (0 ... text.length).each do |i|
- @chunk_lines[@text.length + i] = c
- @message_lines[@text.length + i] = m
- lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
- l.width = lw if lw > l.width
- end
- @text += text
- end
- @layout[m].bot = @text.length
- end
- end
- end
-
- def message_patina_lines m, state, start, parent, prefix, color, star_color
- prefix_widget = [color, prefix]
-
- open_widget = [color, (state == :closed ? "+ " : "- ")]
- new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
- starred_widget = if m.has_label?(:starred)
- [star_color, "*"]
- else
- [color, " "]
- end
- attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
-
- case state
- when :open
- @person_lines[start] = m.from
- [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
- [color,
- "#{m.from ? m.from.mediumname : '?'} to #{m.recipients.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
-
- when :closed
- @person_lines[start] = m.from
- [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
- [color,
- "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s}) #{m.snippet}"]]]
-
- when :detailed
- @person_lines[start] = m.from
- from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
- [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
-
- addressee_lines = []
- unless m.to.empty?
- m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
- addressee_lines += format_person_list " To: ", m.to
- end
- unless m.cc.empty?
- m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
- addressee_lines += format_person_list " Cc: ", m.cc
- end
- unless m.bcc.empty?
- m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
- addressee_lines += format_person_list " Bcc: ", m.bcc
- end
-
- headers = OrderedHash.new
- headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"
- headers["Subject"] = m.subj
-
- show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
- unless show_labels.empty?
- headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
- end
- if parent
- headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}"
- end
-
- HookManager.run "detailed-headers", :message => m, :headers => headers
-
- from_line + (addressee_lines + headers.map { |k, v| " #{k}: #{v}" }).map { |l| [[color, prefix + " " + l]] }
- end
- end
-
- def format_person_list prefix, people
- ptext = people.map { |p| format_person p }
- pad = " " * prefix.display_length
- [prefix + ptext.first + (ptext.length > 1 ? "," : "")] +
- ptext[1 .. -1].map_with_index do |e, i|
- pad + e + (i == ptext.length - 1 ? "" : ",")
- end
- end
-
- def format_person p
- p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
- end
-
- def maybe_wrap_text lines
- if @wrap
- config_width = $config[:wrap_width]
- if config_width and config_width != 0
- width = [config_width, buffer.content_width].min
- else
- width = buffer.content_width
- end
- lines = lines.map { |l| l.chomp.wrap width if l }.flatten
- end
- return lines
- end
-
- ## todo: check arguments on this overly complex function
- def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil
- prefix = " " * INDENT_SPACES * depth
- case chunk
- when :fake_root
- [[[:missing_message_color, "#{prefix}<one or more unreceived messages>"]]]
- when nil
- [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
- when Message
- message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
- (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
-
- else
- raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
- if chunk.inlineable?
- lines = maybe_wrap_text(chunk.lines)
- lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
- elsif chunk.expandable?
- case state
- when :closed
- [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]]
- when :open
- lines = maybe_wrap_text(chunk.lines)
- [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
- end
- else
- [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]]
- end
- end
- end
-
- def view chunk
- BufferManager.flash "viewing #{chunk.content_type} attachment..."
- success = chunk.view!
- BufferManager.erase_flash
- BufferManager.completely_redraw_screen
- unless success
- BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s.ascii, chunk.filename)
- BufferManager.flash "Couldn't execute view command, viewing as text."
- end
- end
-end
-
-end
diff --git a/lib/sup/modes/thread_index_mode.rb b/lib/sup/modes/thread_index_mode.rb
@@ -0,0 +1,950 @@
+require 'set'
+
+module Redwood
+
+## subclasses should implement:
+## - is_relevant?
+
+class ThreadIndexMode < LineCursorMode
+ DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
+ MIN_FROM_WIDTH = 15
+ LOAD_MORE_THREAD_NUM = 20
+
+ HookManager.register "index-mode-size-widget", <<EOS
+Generates the per-thread size widget for each thread.
+Variables:
+ thread: The message thread to be formatted.
+EOS
+
+ HookManager.register "index-mode-date-widget", <<EOS
+Generates the per-thread date widget for each thread.
+Variables:
+ thread: The message thread to be formatted.
+EOS
+
+ HookManager.register "mark-as-spam", <<EOS
+This hook is run when a thread is marked as spam
+Variables:
+ thread: The message thread being marked as spam.
+EOS
+
+ register_keymap do |k|
+ k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
+ k.add_multi "Load all threads (! to confirm) :", '!' do |kk|
+ kk.add :load_all_threads, "Load all threads (may list a _lot_ of threads)", '!'
+ end
+ k.add :cancel_search, "Cancel current search", :ctrl_g
+ k.add :reload, "Refresh view", '@'
+ 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 :toggle_spam, "Mark/unmark thread as spam", 'S'
+ k.add :toggle_deleted, "Delete/undelete thread", 'd'
+ k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
+ k.add :flush_index, "Flush all changes now", '$'
+ k.add :jump_to_next_new, "Jump to next new thread", :tab
+ k.add :reply, "Reply to latest message in a thread", 'r'
+ k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
+ k.add :forward, "Forward latest message in a thread", 'f'
+ k.add :toggle_tagged, "Tag/untag selected thread", 't'
+ k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
+ k.add :tag_matching, "Tag matching threads", 'g'
+ k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
+ k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
+ k.add :undo, "Undo the previous action", 'u'
+ end
+
+ def initialize hidden_labels=[], load_thread_opts={}
+ super()
+ @mutex = Mutex.new # covers the following variables:
+ @threads = []
+ @hidden_threads = {}
+ @size_widget_width = nil
+ @size_widgets = []
+ @date_widget_width = nil
+ @date_widgets = []
+ @tags = Tagger.new self
+
+ ## these guys, and @text and @lines, are not covered
+ @load_thread = nil
+ @load_thread_opts = load_thread_opts
+ @hidden_labels = hidden_labels + LabelManager::HIDDEN_RESERVED_LABELS
+ @date_width = DATE_WIDTH
+
+ @interrupt_search = false
+
+ initialize_threads # defines @ts and @ts_mutex
+ update # defines @text and @lines
+
+ UpdateManager.register self
+
+ @save_thread_mutex = Mutex.new
+
+ @last_load_more_size = nil
+ to_load_more do |size|
+ next if @last_load_more_size == 0
+ load_threads :num => size,
+ :when_done => lambda { |num| @last_load_more_size = num }
+ end
+ end
+
+ def unsaved?; dirty? end
+ def lines; @text.length; end
+ def [] i; @text[i]; end
+ def contains_thread? t; @threads.include?(t) end
+
+ def reload
+ drop_all_threads
+ UndoManager.clear
+ BufferManager.draw_screen
+ load_threads :num => buffer.content_height
+ end
+
+ ## open up a thread view window
+ def select t=nil, when_done=nil
+ t ||= cursor_thread or return
+
+ Redwood::reporting_thread("load messages for thread-view-mode") do
+ num = t.size
+ message = "Loading #{num.pluralize 'message body'}..."
+ BufferManager.say(message) do |sid|
+ t.each_with_index do |(m, *_), i|
+ next unless m
+ BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1
+ m.load_from_source!
+ end
+ end
+ mode = ThreadViewMode.new t, @hidden_labels, self
+ BufferManager.spawn t.subj, mode
+ BufferManager.draw_screen
+ mode.jump_to_first_open if $config[:jump_to_open_message]
+ BufferManager.draw_screen # lame TODO: make this unnecessary
+ ## the first draw_screen is needed before topline and botline
+ ## are set, and the second to show the cursor having moved
+
+ t.remove_label :unread
+ Index.save_thread t
+
+ update_text_for_line curpos
+ UpdateManager.relay self, :read, t.first
+ when_done.call if when_done
+ end
+ end
+
+ def multi_select threads
+ threads.each { |t| select t }
+ end
+
+ ## these two methods are called by thread-view-modes when the user
+ ## wants to view the previous/next thread without going back to
+ ## index-mode. we update the cursor as a convenience.
+ def launch_next_thread_after thread, &b
+ launch_another_thread thread, 1, &b
+ end
+
+ def launch_prev_thread_before thread, &b
+ launch_another_thread thread, -1, &b
+ end
+
+ def launch_another_thread thread, direction, &b
+ l = @lines[thread] or return
+ target_l = l + direction
+ t = @mutex.synchronize do
+ if target_l >= 0 && target_l < @threads.length
+ @threads[target_l]
+ end
+ end
+
+ if t # there's a next thread
+ set_cursor_pos target_l # move out of mutex?
+ select t, b
+ elsif b # no next thread. call the block anyways
+ b.call
+ end
+ end
+
+ def handle_single_message_labeled_update sender, m
+ ## no need to do anything different here; we don't differentiate
+ ## messages from their containing threads
+ handle_labeled_update sender, m
+ end
+
+ def handle_labeled_update sender, m
+ if(t = thread_containing(m))
+ l = @lines[t] or return
+ update_text_for_line l
+ elsif is_relevant?(m)
+ add_or_unhide m
+ end
+ end
+
+ def handle_simple_update sender, m
+ t = thread_containing(m) or return
+ l = @lines[t] or return
+ update_text_for_line l
+ end
+
+ %w(read unread archived starred unstarred).each do |state|
+ define_method "handle_#{state}_update" do |*a|
+ handle_simple_update(*a)
+ end
+ end
+
+ ## overwrite me!
+ def is_relevant? m; false; end
+
+ def handle_added_update sender, m
+ add_or_unhide m
+ BufferManager.draw_screen
+ end
+
+ def handle_single_message_deleted_update sender, m
+ @ts_mutex.synchronize do
+ return unless @ts.contains? m
+ @ts.remove_id m.id
+ end
+ update
+ end
+
+ def handle_deleted_update sender, m
+ t = @ts_mutex.synchronize { @ts.thread_for m }
+ return unless t
+ hide_thread t
+ update
+ end
+
+ def handle_spammed_update sender, m
+ t = @ts_mutex.synchronize { @ts.thread_for m }
+ return unless t
+ hide_thread t
+ update
+ end
+
+ def handle_undeleted_update sender, m
+ add_or_unhide m
+ end
+
+ def undo
+ UndoManager.undo
+ end
+
+ def update
+ old_cursor_thread = cursor_thread
+ @mutex.synchronize do
+ ## let's see you do THIS in python
+ @threads = @ts.threads.select { |t| !@hidden_threads.member?(t) }.select(&:has_message?).sort_by(&:sort_key)
+ @size_widgets = @threads.map { |t| size_widget_for_thread t }
+ @size_widget_width = @size_widgets.max_of { |w| w.display_length }
+ @date_widgets = @threads.map { |t| date_widget_for_thread t }
+ @date_widget_width = @date_widgets.max_of { |w| w.display_length }
+ end
+ set_cursor_pos @threads.index(old_cursor_thread)||curpos
+
+ regen_text
+ end
+
+ def edit_message
+ return unless(t = cursor_thread)
+ message, *_ = 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
+
+ ## returns an undo lambda
+ def actually_toggle_starred t
+ if t.has_label? :starred # if ANY message has a star
+ t.remove_label :starred # remove from all
+ UpdateManager.relay self, :unstarred, t.first
+ lambda do
+ t.first.add_label :starred
+ UpdateManager.relay self, :starred, t.first
+ regen_text
+ end
+ else
+ t.first.add_label :starred # add only to first
+ UpdateManager.relay self, :starred, t.first
+ lambda do
+ t.remove_label :starred
+ UpdateManager.relay self, :unstarred, t.first
+ regen_text
+ end
+ end
+ end
+
+ def toggle_starred
+ t = cursor_thread or return
+ undo = actually_toggle_starred t
+ UndoManager.register "toggling thread starred status", undo, lambda { Index.save_thread t }
+ update_text_for_line curpos
+ cursor_down
+ Index.save_thread t
+ end
+
+ def multi_toggle_starred threads
+ UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
+ threads.map { |t| actually_toggle_starred t },
+ lambda { threads.each { |t| Index.save_thread t } }
+ regen_text
+ threads.each { |t| Index.save_thread t }
+ end
+
+ ## returns an undo lambda
+ def actually_toggle_archived t
+ thread = t
+ pos = curpos
+ if t.has_label? :inbox
+ t.remove_label :inbox
+ UpdateManager.relay self, :archived, t.first
+ lambda do
+ thread.apply_label :inbox
+ update_text_for_line pos
+ UpdateManager.relay self,:unarchived, thread.first
+ end
+ else
+ t.apply_label :inbox
+ UpdateManager.relay self, :unarchived, t.first
+ lambda do
+ thread.remove_label :inbox
+ update_text_for_line pos
+ UpdateManager.relay self, :unarchived, thread.first
+ end
+ end
+ end
+
+ ## returns an undo lambda
+ def actually_toggle_spammed t
+ thread = t
+ if t.has_label? :spam
+ t.remove_label :spam
+ add_or_unhide t.first
+ UpdateManager.relay self, :unspammed, t.first
+ lambda do
+ thread.apply_label :spam
+ self.hide_thread thread
+ UpdateManager.relay self,:spammed, thread.first
+ end
+ else
+ t.apply_label :spam
+ hide_thread t
+ UpdateManager.relay self, :spammed, t.first
+ lambda do
+ thread.remove_label :spam
+ add_or_unhide thread.first
+ UpdateManager.relay self,:unspammed, thread.first
+ end
+ end
+ end
+
+ ## returns an undo lambda
+ def actually_toggle_deleted t
+ if t.has_label? :deleted
+ t.remove_label :deleted
+ add_or_unhide t.first
+ UpdateManager.relay self, :undeleted, t.first
+ lambda do
+ t.apply_label :deleted
+ hide_thread t
+ UpdateManager.relay self, :deleted, t.first
+ end
+ else
+ t.apply_label :deleted
+ hide_thread t
+ UpdateManager.relay self, :deleted, t.first
+ lambda do
+ t.remove_label :deleted
+ add_or_unhide t.first
+ UpdateManager.relay self, :undeleted, t.first
+ end
+ end
+ end
+
+ def toggle_archived
+ t = cursor_thread or return
+ undo = actually_toggle_archived t
+ UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos },
+ lambda { Index.save_thread t }
+ update_text_for_line curpos
+ Index.save_thread t
+ end
+
+ def multi_toggle_archived threads
+ undos = threads.map { |t| actually_toggle_archived t }
+ UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text },
+ lambda { threads.each { |t| Index.save_thread t } }
+ regen_text
+ threads.each { |t| Index.save_thread t }
+ end
+
+ def toggle_new
+ t = cursor_thread or return
+ t.toggle_label :unread
+ update_text_for_line curpos
+ cursor_down
+ Index.save_thread t
+ end
+
+ def multi_toggle_new threads
+ threads.each { |t| t.toggle_label :unread }
+ regen_text
+ threads.each { |t| Index.save_thread t }
+ end
+
+ def multi_toggle_tagged threads
+ @mutex.synchronize { @tags.drop_all_tags }
+ regen_text
+ end
+
+ def join_threads
+ ## this command has no non-tagged form. as a convenience, allow this
+ ## command to be applied to tagged threads without hitting ';'.
+ @tags.apply_to_tagged :join_threads
+ end
+
+ def multi_join_threads threads
+ @ts.join_threads threads or return
+ threads.each { |t| Index.save_thread t }
+ @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
+ update
+ end
+
+ def jump_to_next_new
+ n = @mutex.synchronize do
+ ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
+ (0 ... curpos).find { |i| @threads[i].has_label? :unread }
+ end
+ if n
+ ## jump there if necessary
+ jump_to_line n unless n >= topline && n < botline
+ set_cursor_pos n
+ else
+ BufferManager.flash "No new messages."
+ end
+ end
+
+ def toggle_spam
+ t = cursor_thread or return
+ multi_toggle_spam [t]
+ end
+
+ ## both spam and deleted have the curious characteristic that you
+ ## always want to hide the thread after either applying or removing
+ ## that label. in all thread-index-views except for
+ ## label-search-results-mode, when you mark a message as spam or
+ ## deleted, you want it to disappear immediately; in LSRM, you only
+ ## see deleted or spam emails, and when you undelete or unspam them
+ ## you also want them to disappear immediately.
+ def multi_toggle_spam threads
+ undos = threads.map { |t| actually_toggle_spammed t }
+ threads.each { |t| HookManager.run("mark-as-spam", :thread => t) }
+ UndoManager.register "marking/unmarking #{threads.size.pluralize 'thread'} as spam",
+ undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
+ regen_text
+ threads.each { |t| Index.save_thread t }
+ end
+
+ def toggle_deleted
+ t = cursor_thread or return
+ multi_toggle_deleted [t]
+ end
+
+ ## see comment for multi_toggle_spam
+ def multi_toggle_deleted threads
+ undos = threads.map { |t| actually_toggle_deleted t }
+ UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
+ undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
+ regen_text
+ threads.each { |t| Index.save_thread t }
+ end
+
+ def kill
+ t = cursor_thread or return
+ multi_kill [t]
+ end
+
+ def flush_index
+ @flush_id = BufferManager.say "Flushing index..."
+ Index.save_index
+ BufferManager.clear @flush_id
+ end
+
+ ## m-m-m-m-MULTI-KILL
+ def multi_kill threads
+ UndoManager.register "killing/unkilling #{threads.size.pluralize 'threads'}" do
+ threads.each do |t|
+ if t.toggle_label :killed
+ add_or_unhide t.first
+ else
+ hide_thread t
+ end
+ end.each do |t|
+ UpdateManager.relay self, :labeled, t.first
+ Index.save_thread t
+ end
+ regen_text
+ end
+
+ threads.each do |t|
+ if t.toggle_label :killed
+ hide_thread t
+ else
+ add_or_unhide t.first
+ end
+ end.each do |t|
+ # send 'labeled'... this might be more specific
+ UpdateManager.relay self, :labeled, t.first
+ Index.save_thread t
+ end
+
+ killed, unkilled = threads.partition { |t| t.has_label? :killed }.map(&:size)
+ BufferManager.flash "#{killed.pluralize 'thread'} killed, #{unkilled} unkilled"
+ regen_text
+ 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
+ dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
+ fail "dirty threads remain" unless dirty_threads.empty?
+ super
+ end
+
+ def toggle_tagged
+ t = cursor_thread or return
+ @mutex.synchronize { @tags.toggle_tag_for t }
+ update_text_for_line curpos
+ cursor_down
+ end
+
+ def toggle_tagged_all
+ @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } }
+ regen_text
+ end
+
+ def tag_matching
+ query = BufferManager.ask :search, "tag threads matching (regex): "
+ return if query.nil? || query.empty?
+ query = begin
+ /#{query}/i
+ rescue RegexpError => e
+ BufferManager.flash "error interpreting '#{query}': #{e.message}"
+ return
+ end
+ @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
+ regen_text
+ end
+
+ def apply_to_tagged; @tags.apply_to_tagged; end
+
+ def edit_labels
+ thread = cursor_thread or return
+ speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
+
+ old_labels = thread.labels
+ pos = curpos
+
+ keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
+
+ user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl.sort_by {|x| x.to_s}, @hidden_labels
+ return unless user_labels
+
+ thread.labels = Set.new(keepl) + user_labels
+ user_labels.each { |l| LabelManager << l }
+ update_text_for_line curpos
+
+ UndoManager.register "labeling thread" do
+ thread.labels = old_labels
+ update_text_for_line pos
+ UpdateManager.relay self, :labeled, thread.first
+ Index.save_thread thread
+ end
+
+ UpdateManager.relay self, :labeled, thread.first
+ Index.save_thread thread
+ end
+
+ def multi_edit_labels threads
+ user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
+ return unless user_labels
+
+ user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
+ hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
+ unless hl.empty?
+ BufferManager.flash "'#{hl}' is a reserved label!"
+ return
+ end
+
+ old_labels = threads.map { |t| t.labels.dup }
+
+ threads.each do |t|
+ user_labels.each do |(l, to_remove)|
+ if to_remove
+ t.remove_label l
+ else
+ t.apply_label l
+ LabelManager << l
+ end
+ end
+ UpdateManager.relay self, :labeled, t.first
+ end
+
+ regen_text
+
+ UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
+ threads.zip(old_labels).map do |t, old_labels|
+ t.labels = old_labels
+ UpdateManager.relay self, :labeled, t.first
+ Index.save_thread t
+ end
+ regen_text
+ end
+
+ threads.each { |t| Index.save_thread t }
+ end
+
+ def reply type_arg=nil
+ t = cursor_thread or return
+ m = t.latest_message
+ return if m.nil? # probably won't happen
+ m.load_from_source!
+ mode = ReplyMode.new m, type_arg
+ BufferManager.spawn "Reply to #{m.subj}", mode
+ end
+
+ def reply_all; reply :all; end
+
+ def forward
+ t = cursor_thread or return
+ m = t.latest_message
+ return if m.nil? # probably won't happen
+ m.load_from_source!
+ ForwardMode.spawn_nicely :message => m
+ end
+
+ def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
+ return if @load_thread # todo: wrap in mutex
+ @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do
+ num = load_n_threads n, opts
+ opts[:when_done].call(num) if opts[:when_done]
+ @load_thread = nil
+ end
+ end
+
+ ## TODO: figure out @ts_mutex in this method
+ def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
+ @interrupt_search = false
+ @mbid = BufferManager.say "Searching for threads..."
+
+ ts_to_load = n
+ ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads
+
+ orig_size = @ts.size
+ last_update = Time.now
+ @ts.load_n_threads(ts_to_load, opts) do |i|
+ if (Time.now - last_update) >= 0.25
+ BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid
+ update
+ BufferManager.draw_screen
+ last_update = Time.now
+ end
+ ::Thread.pass
+ break if @interrupt_search
+ 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
+ ignore_concurrent_calls :load_n_threads
+
+ def status
+ if (l = lines) == 0
+ "line 0 of 0"
+ else
+ "line #{curpos + 1} of #{l}"
+ end
+ end
+
+ def cancel_search
+ @interrupt_search = true
+ end
+
+ def load_all_threads
+ load_threads :num => -1
+ end
+
+ def load_threads opts={}
+ if opts[:num].nil?
+ n = ThreadIndexMode::LOAD_MORE_THREAD_NUM
+ else
+ n = opts[:num]
+ end
+
+ myopts = @load_thread_opts.merge({ :when_done => (lambda do |num|
+ opts[:when_done].call(num) if opts[:when_done]
+
+ if num > 0
+ BufferManager.flash "Found #{num.pluralize 'thread'}."
+ else
+ BufferManager.flash "No matches."
+ end
+ end)})
+
+ if opts[:background] || opts[:background].nil?
+ load_n_threads_background n, myopts
+ else
+ load_n_threads n, myopts
+ end
+ end
+ ignore_concurrent_calls :load_threads
+
+ def resize rows, cols
+ regen_text
+ super
+ end
+
+protected
+
+ def add_or_unhide m
+ @ts_mutex.synchronize do
+ if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
+ @ts.load_thread_for_message m, @load_thread_opts
+ end
+
+ @hidden_threads.delete @ts.thread_for(m)
+ end
+
+ update
+ end
+
+ def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
+
+ ## used to tag threads by query. this can be made a lot more sophisticated,
+ ## but for right now we'll do the obvious this.
+ def thread_matches? t, query
+ t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
+ end
+
+ def size_widget_for_thread t
+ HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
+ end
+
+ def date_widget_for_thread t
+ HookManager.run("index-mode-date-widget", :thread => t) || default_date_widget_for(t)
+ end
+
+ def cursor_thread; @mutex.synchronize { @threads[curpos] }; end
+
+ def drop_all_threads
+ @tags.drop_all_tags
+ initialize_threads
+ update
+ end
+
+ def hide_thread t
+ @mutex.synchronize do
+ i = @threads.index(t) or return
+ raise "already hidden" if @hidden_threads[t]
+ @hidden_threads[t] = true
+ @threads.delete_at i
+ @size_widgets.delete_at i
+ @date_widgets.delete_at i
+ @tags.drop_tag_for t
+ end
+ end
+
+ def update_text_for_line l
+ return unless l # not sure why this happens, but it does, occasionally
+
+ need_update = false
+
+ @mutex.synchronize do
+ @size_widgets[l] = size_widget_for_thread @threads[l]
+ @date_widgets[l] = date_widget_for_thread @threads[l]
+
+ ## if a widget size has increased, we need to redraw everyone
+ need_update =
+ (@size_widgets[l].size > @size_widget_width) or
+ (@date_widgets[l].size > @date_widget_width)
+ end
+
+ if need_update
+ update
+ else
+ @text[l] = text_for_thread_at l
+ buffer.mark_dirty if buffer
+ end
+ end
+
+ def regen_text
+ threads = @mutex.synchronize { @threads }
+ @text = threads.map_with_index { |t, i| text_for_thread_at i }
+ @lines = threads.map_with_index { |t, i| [t, i] }.to_h
+ buffer.mark_dirty if buffer
+ end
+
+ def authors; map { |m, *o| m.from if m }.compact.uniq; end
+
+ ## preserve author order from the thread
+ def author_names_and_newness_for_thread t, limit=nil
+ new = {}
+ seen = {}
+ authors = t.map do |m, *o|
+ next unless m && m.from
+ new[m.from] ||= m.has_label?(:unread)
+ next if seen[m.from]
+ seen[m.from] = true
+ m.from
+ end.compact
+
+ result = []
+ authors.each do |a|
+ break if limit && result.size >= limit
+ name = if AccountManager.is_account?(a)
+ "me"
+ elsif t.authors.size == 1
+ a.mediumname
+ else
+ a.shortname
+ end
+
+ result << [name, new[a]]
+ end
+
+ if result.size == 1 && (author_and_newness = result.assoc("me"))
+ unless (recipients = t.participants - t.authors).empty?
+ result = recipients.collect do |r|
+ break if limit && result.size >= limit
+ name = (recipients.size == 1) ? r.mediumname : r.shortname
+ ["(#{name})", author_and_newness[1]]
+ end
+ end
+ end
+
+ result
+ end
+
+ AUTHOR_LIMIT = 5
+ def text_for_thread_at line
+ t, size_widget, date_widget = @mutex.synchronize do
+ [@threads[line], @size_widgets[line], @date_widgets[line]]
+ end
+
+ starred = t.has_label? :starred
+
+ ## format the from column
+ cur_width = 0
+ ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT
+ from = []
+ ann.each_with_index do |(name, newness), i|
+ break if cur_width >= from_width
+ last = i == ann.length - 1
+
+ abbrev =
+ if cur_width + name.display_length > from_width
+ name.slice_by_display_length(from_width - cur_width - 1) + "."
+ elsif cur_width + name.display_length == from_width
+ name.slice_by_display_length(from_width - cur_width)
+ else
+ if last
+ name.slice_by_display_length(from_width - cur_width)
+ else
+ name.slice_by_display_length(from_width - cur_width - 1) + ","
+ end
+ end
+
+ cur_width += abbrev.display_length
+
+ if last && from_width > cur_width
+ abbrev += " " * (from_width - cur_width)
+ end
+
+ from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev]
+ end
+
+ is_me = AccountManager.method(:is_account?)
+ directly_participated = t.direct_participants.any?(&is_me)
+ participated = directly_participated || t.participants.any?(&is_me)
+
+ subj_color =
+ if t.has_label?(:draft)
+ :index_draft_color
+ elsif t.has_label?(:unread)
+ :index_new_color
+ elsif starred
+ :index_starred_color
+ elsif Colormap.sym_is_defined(:index_subject_color)
+ :index_subject_color
+ else
+ :index_old_color
+ end
+
+ size_padding = @size_widget_width - size_widget.display_length
+ size_widget_text = sprintf "%#{size_padding}s%s", "", size_widget
+
+ date_padding = @date_widget_width - date_widget.display_length
+ date_widget_text = sprintf "%#{date_padding}s%s", "", date_widget
+
+ [
+ [:tagged_color, @tags.tagged?(t) ? ">" : " "],
+ [:date_color, date_widget_text],
+ [:starred_color, (starred ? "*" : " ")],
+ ] +
+ from +
+ [
+ [:size_widget_color, size_widget_text],
+ [:to_me_color, t.labels.member?(:attachment) ? "@" : " "],
+ [:to_me_color, directly_participated ? ">" : (participated ? '+' : " ")],
+ ] +
+ (t.labels - @hidden_labels).sort_by {|x| x.to_s}.map {
+ |label| [Colormap.sym_is_defined("label_#{label}_color".to_sym) || :label_color, "#{label} "]
+ } +
+ [
+ [subj_color, t.subj + (t.subj.empty? ? "" : " ")],
+ [:snippet_color, t.snippet],
+ ]
+ end
+
+ def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
+
+private
+
+ def default_size_widget_for t
+ case t.size
+ when 1
+ ""
+ else
+ "(#{t.size})"
+ end
+ end
+
+ def default_date_widget_for t
+ t.date.getlocal.to_nice_s
+ end
+
+ def from_width
+ [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
+ end
+
+ def initialize_threads
+ @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
+ @ts_mutex = Mutex.new
+ @hidden_threads = {}
+ end
+end
+
+end
diff --git a/lib/sup/modes/thread_view_mode.rb b/lib/sup/modes/thread_view_mode.rb
@@ -0,0 +1,901 @@
+module Redwood
+
+class ThreadViewMode < LineCursorMode
+ ## this holds all info we need to lay out a message
+ class MessageLayout
+ attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new, :toggled_state
+ end
+
+ class ChunkLayout
+ attr_accessor :state
+ end
+
+ DATE_FORMAT = "%B %e %Y %l:%M%p"
+ INDENT_SPACES = 2 # how many spaces to indent child messages
+
+ HookManager.register "detailed-headers", <<EOS
+Add or remove headers from the detailed header display of a message.
+Variables:
+ message: The message whose headers are to be formatted.
+ headers: A hash of header (name, value) pairs, initialized to the default
+ headers.
+Return value:
+ None. The variable 'headers' should be modified in place.
+EOS
+
+ HookManager.register "bounce-command", <<EOS
+Determines the command used to bounce a message.
+Variables:
+ from: The From header of the message being bounced
+ (eg: likely _not_ your address).
+ to: The addresses you asked the message to be bounced to as an array.
+Return value:
+ A string representing the command to pipe the mail into. This
+ should include the entire command except for the destination addresses,
+ which will be appended by sup.
+EOS
+
+ HookManager.register "publish", <<EOS
+Executed when a message or a chunk is requested to be published.
+Variables:
+ chunk: Redwood::Message or Redwood::Chunk::* to be published.
+Return value:
+ None.
+EOS
+
+ register_keymap do |k|
+ k.add :toggle_detailed_header, "Toggle detailed header", 'h'
+ k.add :show_header, "Show full message header", 'H'
+ k.add :show_message, "Show full message (raw form)", 'V'
+ k.add :activate_chunk, "Expand/collapse or activate item", :enter
+ k.add :expand_all_messages, "Expand/collapse all messages", 'E'
+ k.add :edit_draft, "Edit draft", 'e'
+ k.add :send_draft, "Send draft", 'y'
+ k.add :edit_labels, "Edit or add labels for a thread", 'l'
+ 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_next_and_open, "Jump to next message and open", "\C-n"
+ k.add :jump_to_prev_open, "Jump to previous open message", 'p'
+ k.add :jump_to_prev_and_open, "Jump to previous message and open", "\C-p"
+ k.add :align_current_message, "Align current message in buffer", 'z'
+ k.add :toggle_starred, "Star or unstar message", '*'
+ k.add :toggle_new, "Toggle unread/read status of message", 'N'
+# k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
+ k.add :reply, "Reply to a message", 'r'
+ k.add :reply_all, "Reply to all participants of this message", 'G'
+ k.add :forward, "Forward a message or attachment", 'f'
+ k.add :bounce, "Bounce message to other recipient(s)", '!'
+ k.add :alias, "Edit alias/nickname for a person", 'i'
+ k.add :edit_as_new, "Edit message as new", 'D'
+ k.add :save_to_disk, "Save message/attachment to disk", 's'
+ k.add :save_all_to_disk, "Save all attachments to disk", 'A'
+ k.add :publish, "Publish message/attachment using publish-hook", 'P'
+ k.add :search, "Search for messages from particular people", 'S'
+ k.add :compose, "Compose message to person", 'm'
+ k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
+ k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
+ k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
+
+ k.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
+ k.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
+ k.add :toggle_wrap, "Toggle wrapping of text", 'w'
+
+ k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
+ kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
+ kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
+ kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
+ kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
+ kk.add :do_nothing_and_kill, "Just kill this buffer", '.'
+ end
+
+ k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
+ kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
+ kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
+ kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
+ kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
+ kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n', ','
+ end
+
+ k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
+ kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
+ kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
+ kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
+ kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
+ kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n', ']'
+ end
+ end
+
+ ## there are a couple important instance variables we hold to format
+ ## the thread and to provide line-based functionality. @layout is a
+ ## map from Messages to MessageLayouts, and @chunk_layout from
+ ## Chunks to ChunkLayouts. @message_lines is a map from row #s to
+ ## Message objects. @chunk_lines is a map from row #s to Chunk
+ ## objects. @person_lines is a map from row #s to Person objects.
+
+ def initialize thread, hidden_labels=[], index_mode=nil
+ super :slip_rows => $config[:slip_rows]
+ @thread = thread
+ @hidden_labels = hidden_labels
+
+ ## used for dispatch-and-next
+ @index_mode = index_mode
+ @dying = false
+
+ @layout = SavingHash.new { MessageLayout.new }
+ @chunk_layout = SavingHash.new { ChunkLayout.new }
+ earliest, latest = nil, nil
+ latest_date = nil
+ altcolor = false
+
+ @thread.each do |m, d, p|
+ next unless m
+ earliest ||= m
+ @layout[m].state = initial_state_for m
+ @layout[m].toggled_state = false
+ @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
+ @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
+ @layout[m].orig_new = m.has_label? :read
+ altcolor = !altcolor
+ if latest_date.nil? || m.date > latest_date
+ latest_date = m.date
+ latest = m
+ end
+ end
+
+ @wrap = true
+
+ @layout[latest].state = :open if @layout[latest].state == :closed
+ @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
+ end
+
+ def toggle_wrap
+ @wrap = !@wrap
+ regen_text
+ buffer.mark_dirty if buffer
+ 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
+
+ ## a little hacky---since regen_text can depend on buffer features like the
+ ## content_width, we don't call it in the constructor, and instead call it
+ ## here, which is set before we're responsible for drawing ourself.
+ def buffer= b
+ super
+ regen_text
+ end
+
+ def show_header
+ m = @message_lines[curpos] or return
+ BufferManager.spawn_unless_exists("Full header for #{m.id}") do
+ TextMode.new m.raw_header.ascii
+ end
+ end
+
+ def show_message
+ m = @message_lines[curpos] or return
+ BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
+ TextMode.new m.raw_message.ascii
+ end
+ end
+
+ def toggle_detailed_header
+ m = @message_lines[curpos] or return
+ @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
+ update
+ end
+
+ def reply type_arg=nil
+ m = @message_lines[curpos] or return
+ mode = ReplyMode.new m, type_arg
+ BufferManager.spawn "Reply to #{m.subj}", mode
+ end
+
+ def reply_all; reply :all; end
+
+ def subscribe_to_list
+ m = @message_lines[curpos] or return
+ if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
+ ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "subscribe")
+ else
+ BufferManager.flash "Can't find List-Subscribe header for this message."
+ end
+ end
+
+ def unsubscribe_from_list
+ m = @message_lines[curpos] or return
+ if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
+ ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "unsubscribe")
+ else
+ BufferManager.flash "Can't find List-Unsubscribe header for this message."
+ end
+ end
+
+ def forward
+ if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
+ ForwardMode.spawn_nicely :attachments => [chunk]
+ elsif(m = @message_lines[curpos])
+ ForwardMode.spawn_nicely :message => m
+ end
+ end
+
+ def bounce
+ m = @message_lines[curpos] or return
+ to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
+
+ defcmd = AccountManager.default_account.bounce_sendmail
+
+ cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
+ when nil, /^$/ then defcmd
+ else hookcmd
+ end + ' ' + to.map { |t| t.email }.join(' ')
+
+ bt = to.size > 1 ? "#{to.size} recipients" : to.to_s
+
+ if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
+ debug "bounce command: #{cmd}"
+ begin
+ IO.popen(cmd, 'w') do |sm|
+ sm.puts m.raw_message
+ end
+ raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
+ rescue SystemCallError, SendmailCommandFailed => e
+ warn "problem sending mail: #{e.message}"
+ BufferManager.flash "Problem sending mail: #{e.message}"
+ end
+ end
+ end
+
+ include CanAliasContacts
+ def alias
+ p = @person_lines[curpos] or return
+ alias_contact p
+ update
+ end
+
+ def search
+ p = @person_lines[curpos] or return
+ mode = PersonSearchResultsMode.new [p]
+ BufferManager.spawn "Search for #{p.name}", mode
+ mode.load_threads :num => mode.buffer.content_height
+ end
+
+ def compose
+ p = @person_lines[curpos]
+ if p
+ ComposeMode.spawn_nicely :to_default => p
+ else
+ ComposeMode.spawn_nicely
+ end
+ end
+
+ def edit_labels
+ old_labels = @thread.labels
+ reserved_labels = old_labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
+ new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels.sort_by {|x| x.to_s}
+
+ return unless new_labels
+ @thread.labels = Set.new(reserved_labels) + new_labels
+ new_labels.each { |l| LabelManager << l }
+ update
+ UpdateManager.relay self, :labeled, @thread.first
+ Index.save_thread @thread
+ UndoManager.register "labeling thread" do
+ @thread.labels = old_labels
+ Index.save_thread @thread
+ UpdateManager.relay self, :labeled, @thread.first
+ end
+ end
+
+ def toggle_starred
+ m = @message_lines[curpos] or return
+ toggle_label m, :starred
+ end
+
+ def toggle_new
+ m = @message_lines[curpos] or return
+ toggle_label m, :unread
+ end
+
+ def toggle_label m, label
+ if m.has_label? label
+ m.remove_label label
+ else
+ m.add_label label
+ end
+ ## TODO: don't recalculate EVERYTHING just to add a stupid little
+ ## star to the display
+ update
+ UpdateManager.relay self, :single_message_labeled, m
+ Index.save_thread @thread
+ end
+
+ ## called when someone presses enter when the cursor is highlighting
+ ## a chunk. for expandable chunks (including messages) we toggle
+ ## open/closed state; for viewable chunks (like attachments) we
+ ## view.
+ def activate_chunk
+ chunk = @chunk_lines[curpos] or return
+ if chunk.is_a? Chunk::Text
+ ## if the cursor is over a text region, expand/collapse the
+ ## entire message
+ chunk = @message_lines[curpos]
+ end
+ layout = if chunk.is_a?(Message)
+ @layout[chunk]
+ elsif chunk.expandable?
+ @chunk_layout[chunk]
+ end
+ if layout
+ layout.state = (layout.state != :closed ? :closed : :open)
+ #cursor_down if layout.state == :closed # too annoying
+ update
+ elsif chunk.viewable?
+ view chunk
+ end
+ if chunk.is_a?(Message) && $config[:jump_to_open_message]
+ jump_to_message chunk
+ jump_to_next_open if layout.state == :closed
+ end
+ end
+
+ def edit_as_new
+ m = @message_lines[curpos] or return
+ mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos)
+ BufferManager.spawn "edit as new", mode
+ mode.edit_message
+ end
+
+ def save_to_disk
+ chunk = @chunk_lines[curpos] or return
+ case chunk
+ when Chunk::Attachment
+ default_dir = $config[:default_attachment_save_dir]
+ default_dir = ENV["HOME"] if default_dir.nil? || default_dir.empty?
+ default_fn = File.expand_path File.join(default_dir, chunk.filename)
+ fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_fn
+ save_to_file(fn) { |f| f.print chunk.raw_content } if fn
+ else
+ m = @message_lines[curpos]
+ fn = BufferManager.ask_for_filename :filename, "Save message to file: "
+ return unless fn
+ save_to_file(fn) do |f|
+ m.each_raw_message_line { |l| f.print l }
+ end
+ end
+ end
+
+ def save_all_to_disk
+ m = @message_lines[curpos] or return
+ default_dir = ($config[:default_attachment_save_dir] || ".")
+ folder = BufferManager.ask_for_filename :filename, "Save all attachments to folder: ", default_dir, true
+ return unless folder
+
+ num = 0
+ num_errors = 0
+ m.chunks.each do |chunk|
+ next unless chunk.is_a?(Chunk::Attachment)
+ fn = File.join(folder, chunk.filename)
+ num_errors += 1 unless save_to_file(fn, false) { |f| f.print chunk.raw_content }
+ num += 1
+ end
+
+ if num == 0
+ BufferManager.flash "Didn't find any attachments!"
+ else
+ if num_errors == 0
+ BufferManager.flash "Wrote #{num.pluralize 'attachment'} to #{folder}."
+ else
+ BufferManager.flash "Wrote #{(num - num_errors).pluralize 'attachment'} to #{folder}; couldn't write #{num_errors} of them (see log)."
+ end
+ end
+ end
+
+ def publish
+ chunk = @chunk_lines[curpos] or return
+ if HookManager.enabled? "publish"
+ HookManager.run "publish", :chunk => chunk
+ else
+ BufferManager.flash "Publishing hook not defined."
+ end
+ end
+
+ def edit_draft
+ m = @message_lines[curpos] or return
+ if m.is_draft?
+ mode = ResumeMode.new m
+ BufferManager.spawn "Edit message", mode
+ BufferManager.kill_buffer self.buffer
+ mode.edit_message
+ else
+ BufferManager.flash "Not a draft message!"
+ end
+ end
+
+ def send_draft
+ m = @message_lines[curpos] or return
+ if m.is_draft?
+ mode = ResumeMode.new m
+ BufferManager.spawn "Send message", mode
+ BufferManager.kill_buffer self.buffer
+ mode.send_message
+ else
+ BufferManager.flash "Not a draft message!"
+ end
+ end
+
+ def jump_to_first_open
+ m = @message_lines[0] or return
+ if @layout[m].state != :closed
+ jump_to_message m#, true
+ else
+ jump_to_next_open #true
+ end
+ end
+
+ def jump_to_next_and_open
+ return continue_search_in_buffer if in_search? # err.. don't know why im doing this
+
+ m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
+ return unless m
+
+ if @layout[m].toggled_state == true
+ @layout[m].state = :closed
+ @layout[m].toggled_state = false
+ update
+ end
+
+ nextm = @layout[m].next
+ if @layout[nextm].state == :closed
+ @layout[nextm].state = :open
+ @layout[nextm].toggled_state = true
+ end
+
+ jump_to_message nextm if nextm
+
+ update if @layout[nextm].toggled_state
+ end
+
+ def jump_to_next_open force_alignment=nil
+ return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
+ m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
+ return unless m
+ while nextm = @layout[m].next
+ break if @layout[nextm].state != :closed
+ m = nextm
+ end
+ jump_to_message nextm, force_alignment if nextm
+ end
+
+ def align_current_message
+ m = @message_lines[curpos] or return
+ jump_to_message m, true
+ end
+
+ def jump_to_prev_and_open force_alignment=nil
+ m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] }
+ return unless m
+
+ if @layout[m].toggled_state == true
+ @layout[m].state = :closed
+ @layout[m].toggled_state = false
+ update
+ end
+
+ nextm = @layout[m].prev
+ if @layout[nextm].state == :closed
+ @layout[nextm].state = :open
+ @layout[nextm].toggled_state = true
+ end
+
+ jump_to_message nextm if nextm
+ update if @layout[nextm].toggled_state
+ end
+
+ def jump_to_prev_open
+ m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
+ return unless m
+ ## jump to the top of the current message if we're in the body;
+ ## otherwise, to the previous message
+
+ top = @layout[m].top
+ if curpos == top
+ while(prevm = @layout[m].prev)
+ break if @layout[prevm].state != :closed
+ m = prevm
+ end
+ jump_to_message prevm if prevm
+ else
+ jump_to_message m
+ end
+ end
+
+ def jump_to_message m, force_alignment=false
+ l = @layout[m]
+
+ ## boundaries of the message
+ message_left = l.depth * INDENT_SPACES
+ message_right = message_left + l.width
+
+ ## calculate leftmost colum
+ left = if force_alignment # force mode: align exactly
+ message_left
+ else # regular: minimize cursor movement
+ ## leftmost and rightmost are boundaries of all valid left-column
+ ## alignments.
+ leftmost = [message_left, message_right - buffer.content_width + 1].min
+ rightmost = message_left
+ leftcol.clamp(leftmost, rightmost)
+ end
+
+ jump_to_line l.top # move vertically
+ jump_to_col left # move horizontally
+ set_cursor_pos l.top # set cursor pos
+ end
+
+ def expand_all_messages
+ @global_message_state ||= :closed
+ @global_message_state = (@global_message_state == :closed ? :open : :closed)
+ @layout.each { |m, l| l.state = @global_message_state }
+ update
+ end
+
+ def collapse_non_new_messages
+ @layout.each { |m, l| l.state = l.orig_new ? :open : :closed }
+ update
+ end
+
+ def expand_all_quotes
+ if(m = @message_lines[curpos])
+ quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 }
+ numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) }
+ newstate = numopen > quotes.length / 2 ? :closed : :open
+ quotes.each { |c| @chunk_layout[c].state = newstate }
+ update
+ end
+ end
+
+ def cleanup
+ @layout = @chunk_layout = @text = nil # for good luck
+ end
+
+ def archive_and_kill; archive_and_then :kill end
+ def spam_and_kill; spam_and_then :kill end
+ def delete_and_kill; delete_and_then :kill end
+ def unread_and_kill; unread_and_then :kill end
+ def do_nothing_and_kill; do_nothing_and_then :kill end
+
+ def archive_and_next; archive_and_then :next end
+ def spam_and_next; spam_and_then :next end
+ def delete_and_next; delete_and_then :next end
+ def unread_and_next; unread_and_then :next end
+ def do_nothing_and_next; do_nothing_and_then :next end
+
+ def archive_and_prev; archive_and_then :prev end
+ def spam_and_prev; spam_and_then :prev end
+ def delete_and_prev; delete_and_then :prev end
+ def unread_and_prev; unread_and_then :prev end
+ def do_nothing_and_prev; do_nothing_and_then :prev end
+
+ def archive_and_then op
+ dispatch op do
+ @thread.remove_label :inbox
+ UpdateManager.relay self, :archived, @thread.first
+ Index.save_thread @thread
+ UndoManager.register "archiving 1 thread" do
+ @thread.apply_label :inbox
+ Index.save_thread @thread
+ UpdateManager.relay self, :unarchived, @thread.first
+ end
+ end
+ end
+
+ def spam_and_then op
+ dispatch op do
+ @thread.apply_label :spam
+ UpdateManager.relay self, :spammed, @thread.first
+ Index.save_thread @thread
+ UndoManager.register "marking 1 thread as spam" do
+ @thread.remove_label :spam
+ Index.save_thread @thread
+ UpdateManager.relay self, :unspammed, @thread.first
+ end
+ end
+ end
+
+ def delete_and_then op
+ dispatch op do
+ @thread.apply_label :deleted
+ UpdateManager.relay self, :deleted, @thread.first
+ Index.save_thread @thread
+ UndoManager.register "deleting 1 thread" do
+ @thread.remove_label :deleted
+ Index.save_thread @thread
+ UpdateManager.relay self, :undeleted, @thread.first
+ end
+ end
+ end
+
+ def unread_and_then op
+ dispatch op do
+ @thread.apply_label :unread
+ UpdateManager.relay self, :unread, @thread.first
+ Index.save_thread @thread
+ end
+ end
+
+ def do_nothing_and_then op
+ dispatch op
+ end
+
+ def dispatch op
+ return if @dying
+ @dying = true
+
+ l = lambda do
+ yield if block_given?
+ BufferManager.kill_buffer_safely buffer
+ end
+
+ case op
+ when :next
+ @index_mode.launch_next_thread_after @thread, &l
+ when :prev
+ @index_mode.launch_prev_thread_before @thread, &l
+ when :kill
+ l.call
+ else
+ raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
+ end
+ end
+ private :dispatch
+
+ def pipe_message
+ chunk = @chunk_lines[curpos]
+ chunk = nil unless chunk.is_a?(Chunk::Attachment)
+ message = @message_lines[curpos] unless chunk
+
+ return unless chunk || message
+
+ command = BufferManager.ask(:shell, "pipe command: ")
+ return if command.nil? || command.empty?
+
+ output = pipe_to_process(command) do |stream|
+ if chunk
+ stream.print chunk.raw_content
+ else
+ message.each_raw_message_line { |l| stream.print l }
+ end
+ end
+
+ if output
+ BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
+ else
+ BufferManager.flash "'#{command}' done!"
+ end
+ end
+
+private
+
+ def initial_state_for m
+ if m.has_label?(:starred) || m.has_label?(:unread)
+ :open
+ else
+ :closed
+ end
+ end
+
+ def update
+ regen_text
+ buffer.mark_dirty if buffer
+ end
+
+ ## here we generate the actual content lines. we accumulate
+ ## everything into @text, and we set @chunk_lines and
+ ## @message_lines, and we update @layout.
+ def regen_text
+ @text = []
+ @chunk_lines = []
+ @message_lines = []
+ @person_lines = []
+
+ prevm = nil
+ @thread.each do |m, depth, parent|
+ unless m.is_a? Message # handle nil and :fake_root
+ @text += chunk_to_lines m, nil, @text.length, depth, parent
+ next
+ end
+ l = @layout[m]
+
+ ## is this still necessary?
+ next unless @layout[m].state # skip discarded drafts
+
+ ## build the patina
+ text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color
+
+ l.top = @text.length
+ l.bot = @text.length + text.length # updated below
+ l.prev = prevm
+ l.next = nil
+ l.depth = depth
+ # l.state we preserve
+ l.width = 0 # updated below
+ @layout[l.prev].next = m if l.prev
+
+ (0 ... text.length).each do |i|
+ @chunk_lines[@text.length + i] = m
+ @message_lines[@text.length + i] = m
+ lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
+ end
+
+ @text += text
+ prevm = m
+ if l.state != :closed
+ m.chunks.each do |c|
+ cl = @chunk_layout[c]
+
+ ## set the default state for chunks
+ cl.state ||=
+ if c.expandable? && c.respond_to?(:initial_state)
+ c.initial_state
+ else
+ :closed
+ end
+
+ text = chunk_to_lines c, cl.state, @text.length, depth
+ (0 ... text.length).each do |i|
+ @chunk_lines[@text.length + i] = c
+ @message_lines[@text.length + i] = m
+ lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
+ l.width = lw if lw > l.width
+ end
+ @text += text
+ end
+ @layout[m].bot = @text.length
+ end
+ end
+ end
+
+ def message_patina_lines m, state, start, parent, prefix, color, star_color
+ prefix_widget = [color, prefix]
+
+ open_widget = [color, (state == :closed ? "+ " : "- ")]
+ new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
+ starred_widget = if m.has_label?(:starred)
+ [star_color, "*"]
+ else
+ [color, " "]
+ end
+ attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
+
+ case state
+ when :open
+ @person_lines[start] = m.from
+ [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
+ [color,
+ "#{m.from ? m.from.mediumname : '?'} to #{m.recipients.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
+
+ when :closed
+ @person_lines[start] = m.from
+ [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
+ [color,
+ "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s}) #{m.snippet}"]]]
+
+ when :detailed
+ @person_lines[start] = m.from
+ from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
+ [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
+
+ addressee_lines = []
+ unless m.to.empty?
+ m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
+ addressee_lines += format_person_list " To: ", m.to
+ end
+ unless m.cc.empty?
+ m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
+ addressee_lines += format_person_list " Cc: ", m.cc
+ end
+ unless m.bcc.empty?
+ m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
+ addressee_lines += format_person_list " Bcc: ", m.bcc
+ end
+
+ headers = OrderedHash.new
+ headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"
+ headers["Subject"] = m.subj
+
+ show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
+ unless show_labels.empty?
+ headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
+ end
+ if parent
+ headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}"
+ end
+
+ HookManager.run "detailed-headers", :message => m, :headers => headers
+
+ from_line + (addressee_lines + headers.map { |k, v| " #{k}: #{v}" }).map { |l| [[color, prefix + " " + l]] }
+ end
+ end
+
+ def format_person_list prefix, people
+ ptext = people.map { |p| format_person p }
+ pad = " " * prefix.display_length
+ [prefix + ptext.first + (ptext.length > 1 ? "," : "")] +
+ ptext[1 .. -1].map_with_index do |e, i|
+ pad + e + (i == ptext.length - 1 ? "" : ",")
+ end
+ end
+
+ def format_person p
+ p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
+ end
+
+ def maybe_wrap_text lines
+ if @wrap
+ config_width = $config[:wrap_width]
+ if config_width and config_width != 0
+ width = [config_width, buffer.content_width].min
+ else
+ width = buffer.content_width
+ end
+ # lines can apparently be both String and Array, convert to Array for map.
+ if lines.kind_of? String
+ lines = lines.lines.to_a
+ end
+ lines = lines.map { |l| l.chomp.wrap width if l }.flatten
+ end
+ return lines
+ end
+
+ ## todo: check arguments on this overly complex function
+ def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil
+ prefix = " " * INDENT_SPACES * depth
+ case chunk
+ when :fake_root
+ [[[:missing_message_color, "#{prefix}<one or more unreceived messages>"]]]
+ when nil
+ [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
+ when Message
+ message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
+ (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
+
+ else
+ raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
+ if chunk.inlineable?
+ lines = maybe_wrap_text(chunk.lines)
+ lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
+ elsif chunk.expandable?
+ case state
+ when :closed
+ [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]]
+ when :open
+ lines = maybe_wrap_text(chunk.lines)
+ [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
+ end
+ else
+ [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]]
+ end
+ end
+ end
+
+ def view chunk
+ BufferManager.flash "viewing #{chunk.content_type} attachment..."
+ success = chunk.view!
+ BufferManager.erase_flash
+ BufferManager.completely_redraw_screen
+ unless success
+ BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s.ascii, chunk.filename)
+ BufferManager.flash "Couldn't execute view command, viewing as text."
+ end
+ end
+end
+
+end
diff --git a/lib/sup/poll.rb b/lib/sup/poll.rb
@@ -22,6 +22,8 @@ Variables:
num: the total number of new messages added in this poll
num_inbox: the number of new messages added in this poll which
appear in the inbox (i.e. were not auto-archived).
+ num_total: the total number of messages
+ num_inbox_total: the total number of new messages in the inbox.
num_inbox_total_unread: the total number of unread messages in the inbox
from_and_subj: an array of (from email address, subject) pairs
from_and_subj_inbox: an array of (from email address, subject) pairs for
@@ -33,7 +35,7 @@ EOS
@mutex = Mutex.new
@thread = nil
@last_poll = nil
- @polling = false
+ @polling = Mutex.new
@poll_sources = nil
@mode = nil
@should_clear_running_totals = false
@@ -43,40 +45,59 @@ EOS
def poll_with_sources
@mode ||= PollMode.new
- HookManager.run "before-poll"
- BufferManager.flash "Polling for new messages..."
+ if HookManager.enabled? "before-poll"
+ HookManager.run("before-poll")
+ else
+ BufferManager.flash "Polling for new messages..."
+ end
+
num, numi, from_and_subj, from_and_subj_inbox, loaded_labels = @mode.poll
clear_running_totals if @should_clear_running_totals
@running_totals[:num] += num
@running_totals[:numi] += numi
@running_totals[:loaded_labels] += loaded_labels || []
- if @running_totals[:num] > 0
- BufferManager.flash "Loaded #{@running_totals[:num].pluralize 'new message'}, #{@running_totals[:numi]} to inbox. Labels: #{@running_totals[:loaded_labels].map{|l| l.to_s}.join(', ')}"
+
+
+ if HookManager.enabled? "after-poll"
+ hook_args = { :num => num, :num_inbox => numi,
+ :num_total => @running_totals[:num], :num_inbox_total => @running_totals[:numi],
+ :from_and_subj => from_and_subj, :from_and_subj_inbox => from_and_subj_inbox,
+ :num_inbox_total_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] } }
+
+ HookManager.run("after-poll", hook_args)
else
- BufferManager.flash "No new messages."
+ if @running_totals[:num] > 0
+ BufferManager.flash "Loaded #{@running_totals[:num].pluralize 'new message'}, #{@running_totals[:numi]} to inbox. Labels: #{@running_totals[:loaded_labels].map{|l| l.to_s}.join(', ')}"
+ else
+ BufferManager.flash "No new messages."
+ end
end
- HookManager.run "after-poll", :num => num, :num_inbox => numi, :from_and_subj => from_and_subj, :from_and_subj_inbox => from_and_subj_inbox, :num_inbox_total_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] }
-
end
def poll
- return if @polling
- @polling = true
- @poll_sources = SourceManager.usual_sources
- num, numi = poll_with_sources
- @polling = false
- [num, numi]
+ if @polling.try_lock
+ @poll_sources = SourceManager.usual_sources
+ num, numi = poll_with_sources
+ @polling.unlock
+ [num, numi]
+ else
+ debug "poll already in progress."
+ return
+ end
end
def poll_unusual
- return if @polling
- @polling = true
- @poll_sources = SourceManager.unusual_sources
- num, numi = poll_with_sources
- @polling = false
- [num, numi]
+ if @polling.try_lock
+ @poll_sources = SourceManager.unusual_sources
+ num, numi = poll_with_sources
+ @polling.unlock
+ [num, numi]
+ else
+ debug "poll_unusual already in progress."
+ return
+ end
end
def start
@@ -142,7 +163,6 @@ EOS
loaded_labels = loaded_labels - LabelManager::HIDDEN_RESERVED_LABELS - [:inbox, :killed]
yield "Done polling; loaded #{total_num} new messages total"
@last_poll = Time.now
- @polling = false
end
[total_num, total_numi, from_and_subj, from_and_subj_inbox, loaded_labels]
end
@@ -151,42 +171,51 @@ EOS
## labels and locations set correctly. The Messages are saved to or removed
## from the index after being yielded.
def poll_from source, opts={}
- begin
- source.poll do |sym, args|
- case sym
- when :add
- m = Message.build_from_source source, args[:info]
- old_m = Index.build_message m.id
- m.labels += args[:labels]
- m.labels.delete :inbox if source.archived?
- m.labels.delete :unread if source.read?
- m.labels.delete :unread if m.source_marked_read? # preserve read status if possible
- m.labels.each { |l| LabelManager << l }
- m.labels = old_m.labels + (m.labels - [:unread, :inbox]) if old_m
- m.locations = old_m.locations + m.locations if old_m
- HookManager.run "before-add-message", :message => m
- yield :add, m, old_m, args[:progress] if block_given?
- Index.sync_message m, true
-
- ## We need to add or unhide the message when it either did not exist
- ## before at all or when it was updated. We do *not* add/unhide when
- ## the same message was found at a different location
- if !old_m or not old_m.locations.member? m.location
- UpdateManager.relay self, :added, m
- end
- when :delete
- Index.each_message :location => [source.id, args[:info]] do |m|
- m.locations.delete Location.new(source, args[:info])
- yield :delete, m, [source,args[:info]], args[:progress] if block_given?
- Index.sync_message m, false
- #UpdateManager.relay self, :deleted, m
+ debug "trying to acquire poll lock for: #{source}.."
+ if source.poll_lock.try_lock
+ debug "lock acquired for: #{source}."
+ begin
+ source.poll do |sym, args|
+ case sym
+ when :add
+ m = Message.build_from_source source, args[:info]
+ old_m = Index.build_message m.id
+ m.labels += args[:labels]
+ m.labels.delete :inbox if source.archived?
+ m.labels.delete :unread if source.read?
+ m.labels.delete :unread if m.source_marked_read? # preserve read status if possible
+ m.labels.each { |l| LabelManager << l }
+ m.labels = old_m.labels + (m.labels - [:unread, :inbox]) if old_m
+ m.locations = old_m.locations + m.locations if old_m
+ HookManager.run "before-add-message", :message => m
+ yield :add, m, old_m, args[:progress] if block_given?
+ Index.sync_message m, true
+
+ ## We need to add or unhide the message when it either did not exist
+ ## before at all or when it was updated. We do *not* add/unhide when
+ ## the same message was found at a different location
+ if !old_m or not old_m.locations.member? m.location
+ UpdateManager.relay self, :added, m
+ end
+ when :delete
+ Index.each_message :location => [source.id, args[:info]] do |m|
+ m.locations.delete Location.new(source, args[:info])
+ yield :delete, m, [source,args[:info]], args[:progress] if block_given?
+ Index.sync_message m, false
+ #UpdateManager.relay self, :deleted, m
+ end
end
end
- end
- source.go_idle
- rescue SourceError => e
- warn "problem getting messages from #{source}: #{e.message}"
+ rescue SourceError => e
+ warn "problem getting messages from #{source}: #{e.message}"
+
+ ensure
+ source.go_idle
+ source.poll_lock.unlock
+ end
+ else
+ debug "source #{source} is already being polled."
end
end
diff --git a/lib/sup/rfc2047.rb b/lib/sup/rfc2047.rb
@@ -16,8 +16,6 @@
#
# This file is distributed under the same terms as Ruby.
-require 'iconv'
-
module Rfc2047
WORD = %r{=\?([!\#$%&'*+-/0-9A-Z\\^\`a-z{|}~]+)\?([BbQq])\?([!->@-~]+)\?=} # :nodoc: 'stupid ruby-mode
WORDSEQ = %r{(#{WORD.source})\s+(?=#{WORD.source})}
@@ -52,7 +50,7 @@ module Rfc2047
# WORD.
end
- Iconv.easy_decode(target, charset, text)
+ text.transcode(target, charset)
end
end
end
diff --git a/lib/sup/sent.rb b/lib/sup/sent.rb
@@ -25,8 +25,13 @@ class SentManager
end
def write_sent_message date, from_email, &block
- @source.store_message date, from_email, &block
- PollManager.poll_from @source
+ ::Thread.new do
+ debug "store the sent message (locking sent source..)"
+ @source.poll_lock.synchronize do
+ @source.store_message date, from_email, &block
+ end
+ PollManager.poll_from @source
+ end
end
end
diff --git a/lib/sup/service/label_service.rb b/lib/sup/service/label_service.rb
@@ -0,0 +1,45 @@
+require "sup/index"
+
+module Redwood
+ # Provides label tweaking service to the user.
+ # Working as the backend of ConsoleMode.
+ #
+ # Should become the backend of bin/sup-tweak-labels in the future.
+ class LabelService
+ # @param index [Redwood::Index]
+ def initialize index=Index.instance
+ @index = index
+ end
+
+ def add_labels query, *labels
+ run_on_each_message(query) do |m|
+ labels.each {|l| m.add_label l }
+ end
+ end
+
+ def remove_labels query, *labels
+ run_on_each_message(query) do |m|
+ labels.each {|l| m.remove_label l }
+ end
+ end
+
+
+ private
+ def run_on_each_message query, &operation
+ count = 0
+
+ find_messages(query).each do |m|
+ operation.call(m)
+ @index.update_message_state m
+ count += 1
+ end
+
+ @index.save_index
+ count
+ end
+
+ def find_messages query
+ @index.find_messages(query)
+ end
+ end
+end
diff --git a/lib/sup/source.rb b/lib/sup/source.rb
@@ -22,30 +22,23 @@ class Source
## read, delete them, or anything else. (Well, it's nice to be able
## to delete them, but that is optional.)
##
- ## On the other hand, Sup assumes that you can assign each message a
- ## unique integer id, such that newer messages have higher ids than
- ## earlier ones, and that those ids stay constant across sessions
- ## (in the absence of some other client going in and fucking
- ## everything up). For example, for mboxes I use the file offset of
- ## the start of the message. If a source does NOT have that
- ## capability, e.g. IMAP, then you have to do a little more work to
- ## simulate it.
+ ## Messages are identified internally based on the message id, and stored
+ ## with an unique document id. Along with the message, source information
+ ## that can contain arbitrary fields (set up by the source) is stored. This
+ ## information will be passed back to the source when a message in the
+ ## index (Sup database) needs to be identified to its source, e.g. when
+ ## re-reading or modifying a unique message.
##
## To write a new source, subclass this class, and implement:
##
- ## - start_offset
- ## - end_offset (exclusive!) (or, #done?)
+ ## - initialize
## - load_header offset
## - load_message offset
## - raw_header offset
## - raw_message offset
- ## - check (optional)
+ ## - store_message (optional)
+ ## - poll (loads new messages)
## - go_idle (optional)
- ## - next (or each, if you prefer): should return a message and an
- ## array of labels.
- ##
- ## ... where "offset" really means unique id. (You can tell I
- ## started with mbox.)
##
## All exceptions relating to accessing the source must be caught
## and rethrown as FatalSourceErrors or OutOfSyncSourceErrors.
@@ -57,12 +50,11 @@ class Source
## Finally, be sure the source is thread-safe, since it WILL be
## pummelled from multiple threads at once.
##
- ## Examples for you to look at: mbox/loader.rb, imap.rb, and
- ## maildir.rb.
+ ## Examples for you to look at: mbox.rb and maildir.rb.
bool_accessor :usual, :archived
attr_reader :uri
- attr_accessor :id
+ attr_accessor :id, :poll_lock
def initialize uri, usual=true, archived=false, id=nil
raise ArgumentError, "id must be an integer: #{id.inspect}" unless id.is_a? Fixnum if id
@@ -71,6 +63,8 @@ class Source
@usual = usual
@archived = archived
@id = id
+
+ @poll_lock = Mutex.new
end
## overwrite me if you have a disk incarnation (currently used only for sup-sync-back)
@@ -209,9 +203,9 @@ class SourceManager
end
end
- def save_sources fn=Redwood::SOURCE_FN
+ def save_sources fn=Redwood::SOURCE_FN, force=false
@source_mutex.synchronize do
- if @sources_dirty
+ if @sources_dirty || force
Redwood::save_yaml_obj sources, fn, false, true
end
@sources_dirty = false
diff --git a/lib/sup/textfield.rb b/lib/sup/textfield.rb
@@ -168,6 +168,16 @@ private
else # trailing spaces
v + (" " * (x - @question.length - v.length))
end
+
+ # ncurses returns a ASCII-8BIT (binary) string, which
+ # bytes presumably are of current charset encoding. we force_encoding
+ # so that the char representation / string is tagged will be the
+ # system locale and also hopefully the terminal/input encoding. an
+ # incorrectly configured terminal encoding (not matching the system
+ # encoding) will produce erronous results, but will also do that for
+ # a log of other programs since it is impossible to detect which is
+ # which and what encoding the inputted byte chars are supposed to have.
+ v.force_encoding($encoding).fix_encoding
end
def remove_extra_space
diff --git a/lib/sup/util.rb b/lib/sup/util.rb
@@ -1,3 +1,5 @@
+# encoding: utf-8
+
require 'thread'
require 'lockfile'
require 'mime/types'
@@ -5,7 +7,7 @@ require 'pathname'
require 'set'
require 'enumerator'
require 'benchmark'
-require 'iconv'
+require 'unicode'
## time for some monkeypatching!
class Symbol
@@ -31,7 +33,7 @@ class Lockfile
def dump_lock_id lock_id = @lock_id
"host: %s\npid: %s\nppid: %s\ntime: %s\nuser: %s\npname: %s\n" %
lock_id.values_at('host','pid','ppid','time','user', 'pname')
- end
+ end
def lockinfo_on_disk
h = load_lock_id IO.read(path)
@@ -114,6 +116,25 @@ module RMail
end
class Header
+
+ # Convert to ASCII before trying to match with regexp
+ class Field
+
+ EXTRACT_FIELD_NAME_RE = /\A([^\x00-\x1f\x7f-\xff :]+):\s*/no
+
+ class << self
+ def parse(field)
+ field = field.dup.to_s
+ field = field.fix_encoding.ascii
+ if field =~ EXTRACT_FIELD_NAME_RE
+ [ $1, $'.chomp ]
+ else
+ [ "", Field.value_strip(field) ]
+ end
+ end
+ end
+ end
+
## Be more cautious about invalid content-type headers
## the original RMail code calls
## value.strip.split(/\s*;\s*/)[0].downcase
@@ -234,14 +255,14 @@ class Object
end
class String
- ## nasty multibyte hack for ruby 1.8. if it's utf-8, split into chars using
- ## the utf8 regex and count those. otherwise, use the byte length.
def display_length
- if RUBY_VERSION < '1.9.1' && ($encoding == "UTF-8" || $encoding == "utf8")
- # scan hack is somewhat slow, worth trying to cache
- @display_length ||= scan(/./u).size
- else
- size
+ @display_length ||= Unicode.width(self, false)
+ end
+
+ def slice_by_display_length len
+ each_char.each_with_object "" do |c, buffer|
+ len -= c.display_length
+ buffer << c if len >= 0
end
end
@@ -328,20 +349,69 @@ class String
def wrap len
ret = []
s = self
- while s.length > len
- cut = s[0 ... len].rindex(/\s/)
+ while s.display_length > len
+ cut = s.slice_by_display_length(len).rindex(/\s/)
if cut
ret << s[0 ... cut]
s = s[(cut + 1) .. -1]
else
- ret << s[0 ... len]
- s = s[len .. -1]
+ ret << s.slice_by_display_length(len)
+ s = s[ret.last.length .. -1]
end
end
ret << s
end
+ # Fix the damn string! make sure it is valid utf-8, then convert to
+ # user encoding.
+ #
+ # Not Ruby 1.8 compatible
+ def fix_encoding
+ # first try to encode to utf-8 from whatever current encoding
+ encode!('UTF-8', :invalid => :replace, :undef => :replace)
+
+ # do this anyway in case string is set to be UTF-8, encoding to
+ # something else (UTF-16 which can fully represent UTF-8) and back
+ # ensures invalid chars are replaced.
+ encode!('UTF-16', 'UTF-8', :invalid => :replace, :undef => :replace)
+ encode!('UTF-8', 'UTF-16', :invalid => :replace, :undef => :replace)
+
+ fail "Could not create valid UTF-8 string out of: '#{self.to_s}'." unless valid_encoding?
+
+ # now convert to $encoding
+ encode!($encoding, :invalid => :replace, :undef => :replace)
+
+ fail "Could not create valid #{$encoding.inspect} string out of: '#{self.to_s}'." unless valid_encoding?
+
+ self
+ end
+
+ # transcode the string if original encoding is know
+ # fix if broken.
+ #
+ # Not Ruby 1.8 compatible
+ def transcode to_encoding, from_encoding
+ begin
+ encode!(to_encoding, from_encoding, :invalid => :replace, :undef => :replace)
+
+ unless valid_encoding?
+ # fix encoding (through UTF-8)
+ encode!('UTF-16', from_encoding, :invalid => :replace, :undef => :replace)
+ encode!(to_encoding, 'UTF-16', :invalid => :replace, :undef => :replace)
+ end
+
+ rescue Encoding::ConverterNotFoundError
+ debug "Encoding converter not found for #{from_encoding.inspect} or #{to_encoding.inspect}, fixing string: '#{self.to_s}', but expect weird characters."
+ fix_encoding
+ end
+
+ fail "Could not create valid #{to_encoding.inspect} string out of: '#{self.to_s}'." unless valid_encoding?
+
+ self
+ end
+
def normalize_whitespace
+ fix_encoding
gsub(/\t/, " ").gsub(/\r/, "")
end
@@ -383,12 +453,8 @@ class String
out << b.chr
end
end
- out.force_encoding Encoding::UTF_8 if out.respond_to? :force_encoding
- out
- end
-
- def transcode src_encoding=$encoding
- Iconv.easy_decode $encoding, src_encoding, self
+ out = out.fix_encoding # this should now be an utf-8 string of ascii
+ # compat chars.
end
unless method_defined? :ascii_only?
@@ -659,27 +725,3 @@ class FinishLine
end
end
-class Iconv
- def self.easy_decode target, orig_charset, text
- if text.respond_to? :force_encoding
- text = text.dup
- text.force_encoding Encoding::BINARY
- end
- charset = case orig_charset
- when /UTF[-_ ]?8/i then "utf-8"
- when /(iso[-_ ])?latin[-_ ]?1$/i then "ISO-8859-1"
- when /iso[-_ ]?8859[-_ ]?15/i then 'ISO-8859-15'
- when /unicode[-_ ]1[-_ ]1[-_ ]utf[-_]7/i then "utf-7"
- when /^euc$/i then 'EUC-JP' # XXX try them all?
- when /^(x-unknown|unknown[-_ ]?8bit|ascii[-_ ]?7[-_ ]?bit)$/i then 'ASCII'
- else orig_charset
- end
-
- begin
- returning(Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]) { |str| str.check }
- rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::InvalidCharacter, Iconv::IllegalSequence, String::CheckError
- debug "couldn't transcode text from #{orig_charset} (#{charset}) to #{target} (#{text[0 ... 20].inspect}...): got #{$!.class} (#{$!.message})"
- text.ascii
- end
- end
-end
diff --git a/lib/sup/util/path.rb b/lib/sup/util/path.rb
@@ -0,0 +1,9 @@
+module Redwood
+ module Util
+ module Path
+ def self.expand(path)
+ ::File.expand_path(path)
+ end
+ end
+ end
+end
diff --git a/lib/sup/util/query.rb b/lib/sup/util/query.rb
@@ -0,0 +1,14 @@
+module Redwood
+ module Util
+ module Query
+ class QueryDescriptionError < ArgumentError; end
+
+ def self.describe query
+ d = query.description.force_encoding("UTF-8")
+
+ raise QueryDescriptionError.new(d) unless d.valid_encoding?
+ return d
+ end
+ end
+ end
+end
diff --git a/lib/sup/util/uri.rb b/lib/sup/util/uri.rb
@@ -0,0 +1,15 @@
+require "uri"
+
+require "sup/util/path"
+
+module Redwood
+ module Util
+ module Uri
+ def self.build(components)
+ components = components.dup
+ components[:path] = Path.expand(components[:path])
+ ::URI::Generic.build(components)
+ end
+ end
+ end
+end
diff --git a/lib/sup/version.rb b/lib/sup/version.rb
@@ -1,3 +1,3 @@
module Redwood
- VERSION = "git"
+ VERSION = "0.14.0"
end
diff --git a/release-script.txt b/release-script.txt
@@ -1,17 +0,0 @@
-Just a few simple steps to make a new release.
-
-vi History.txt
-vi ReleaseNotes
-vi www/index.html # and bump version number
-git rank-contributors -n -o > CONTRIBUTORS
-vi CONTRIBUTORS # and merge
-vi www/index.html # and include CONTRIBUTORS
-# ... git add, commit, etc
-git checkout -b release-<releasename>
-vi lib/sup.rb bin/* # and bump version numbers in all files
-# ... git add, commit, etc
-rake gem
-rake tarball
-gem push pkg/<gem name> # now using gemcutter
-git publish-branch
-rake upload_webpage
diff --git a/sup.gemspec b/sup.gemspec
@@ -5,7 +5,8 @@ require 'sup/version'
# Files
SUP_EXECUTABLES = %w(sup sup-add sup-config sup-dump sup-import-dump
- sup-recover-sources sup-sync sup-sync-back sup-tweak-labels)
+ sup-recover-sources sup-sync sup-sync-back sup-tweak-labels
+ sup-psych-ify-config-files)
SUP_EXTRA_FILES = %w(CONTRIBUTORS README.md LICENSE History.txt ReleaseNotes)
SUP_FILES =
SUP_EXTRA_FILES +
@@ -21,7 +22,7 @@ module Redwood
s.authors = ["William Morgan", "Gaute Hope", "Hamish Downer", "Matthieu Rakotojaona"]
s.email = "sup-talk@rubyforge.org"
s.summary = "A console-based email client with the best features of GMail, mutt and Emacs"
- s.homepage = "https://github.com/sup-heliotrope/sup/wiki"
+ s.homepage = "http://supmua.org"
s.description = <<-DESC
Sup is a console-based email client for people with a lot of email.
@@ -33,19 +34,33 @@ module Redwood
* Automatically tracking recent contacts
DESC
s.license = 'GPL-2'
+ # TODO: might want to add index migrating script here, too
+ s.post_install_message = <<-EOF
+SUP: Please run `sup-psych-ify-config-files` to migrate from 0.13 to 0.14.
+
+SUP: Check https://github.com/sup-heliotrope/sup/wiki/Migration-0.13-to-0.14
+ for more detailed up-to-date instructions.
+ EOF
s.files = SUP_FILES
s.executables = SUP_EXECUTABLES
- s.add_dependency "xapian-full-alaveteli", "~> 1.2"
- s.add_dependency "ncursesw-sup", "~> 1.3", ">= 1.3.1"
- s.add_dependency "rmail", ">= 0.17"
- s.add_dependency "highline"
- s.add_dependency "trollop", ">= 1.12"
- s.add_dependency "lockfile"
- s.add_dependency "mime-types", "~> 1"
- s.add_dependency "gettext"
+ s.required_ruby_version = '>= 1.9.2'
+
+ s.add_runtime_dependency "xapian-ruby", "~> 1.2.15"
+ s.add_runtime_dependency "ncursesw-sup", "~> 1.3.1"
+ s.add_runtime_dependency "rmail", ">= 0.17"
+ s.add_runtime_dependency "highline"
+ s.add_runtime_dependency "trollop", ">= 1.12"
+ s.add_runtime_dependency "lockfile"
+ s.add_runtime_dependency "mime-types", "~> 1.0"
+ s.add_runtime_dependency "locale", "~> 2.0"
+ s.add_runtime_dependency "chronic", "~> 0.9.1"
+ s.add_runtime_dependency "unicode", "~> 0.4.4"
s.add_development_dependency "bundler", "~> 1.3"
s.add_development_dependency "rake"
+ s.add_development_dependency "minitest", "~> 4.7"
+ s.add_development_dependency "rr", "~> 1.0.5"
+ s.add_development_dependency "gpgme", ">= 2.0.2"
end
end
diff --git a/test/gnupg_test_home/gpg.conf b/test/gnupg_test_home/gpg.conf
@@ -0,0 +1 @@
+default-key 789E7011
diff --git a/test/gnupg_test_home/pubring.gpg b/test/gnupg_test_home/pubring.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/receiver_pubring.gpg b/test/gnupg_test_home/receiver_pubring.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/receiver_secring.gpg b/test/gnupg_test_home/receiver_secring.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/receiver_trustdb.gpg b/test/gnupg_test_home/receiver_trustdb.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/secring.gpg b/test/gnupg_test_home/secring.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/sup-test-2@foo.bar.asc b/test/gnupg_test_home/sup-test-2@foo.bar.asc
@@ -0,0 +1,20 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v2.0.20 (GNU/Linux)
+
+mI0EUgi0fAEEAOLAcQW96NEUSB7YE/la8X56jGW5BMX3aAixOF8LvOwMBbUK1T+U
+0H2PGIrXVcYyHcPqWRpRahbsIAldBqzffPlzMa+aqJaB1xKkNruxSoIzwPdidZMe
+l0Dxz2FDsoXD0KPyWnAYhGmQyz2MFpZxu2tlYqvwWVW//XGnk/KHvIXbABEBAAG0
+PlN1cCBUZXN0IFJlY2VpdmVyIChUZXN0IHJlY2VpdmVyIGZvciBTdXApIDxzdXAt
+dGVzdC0yQGZvby5iYXI+iL8EEwECACkFAlIItHwCGwMFCQHhM4AHCwkIBwMCAQYV
+CAIJCgsEFgIDAQIeAQIXgAAKCRAsABl+cWpykMMVBADHkQPgTz0CqKKp3k+z3dbm
+ocmI4tYNn1dOkDQqyfoBTfs6L3g4j5OE2UrguntRYyg5oon+uO5d18CQ5dY0sCw/
+o5IwyzTrxI8IocbtZvBdSb+XjLndynGuIQoqaJq9i6n1V4klFHVOna8Q9JstLfRX
+H1d4xPhnvKcaDDx/NV3X/biNBFIItHwBBADBpb43MpkrUWlg7HWJ1ZfOlxnOxrJ3
+Gz9WFNV06UbcZEuFKA/vHRjM6gWzUn5903FLuCWu3eBrq5xQfWipbp187PmocpoG
+skJ6gosLs1fMYRBjv2VbG9xJVKdKJMjqZw5FUpXKAaHr8P9jN6g2STQrbeQ8CVUK
+h7zOWRXAXSKUgwARAQABiKUEGAECAA8FAlIItHwCGwwFCQHhM4AACgkQLAAZfnFq
+cpDV1QQAzcxFXznEX92DjWxWRC7gRHgIsQk9WJnDzjtnDjSWCp3H85qeTZGZrn9W
+NoneV/S5Y7K3Mkceh4rFaANQ3zx4b05y1LFt5N/lPwIe5VB0vcPumtZum2fSGfpK
+nTXvzelcWcm2aGyUSaWvOkntWKEEt1kB5Oq6EtZoRZLMzAxLd7s=
+=aKsV
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/test/gnupg_test_home/trustdb.gpg b/test/gnupg_test_home/trustdb.gpg
Binary files differ.
diff --git a/test/integration/test_label_service.rb b/test/integration/test_label_service.rb
@@ -0,0 +1,18 @@
+require "test_helper"
+
+require "sup/service/label_service"
+
+require "tmpdir"
+
+describe Redwood::LabelService do
+ let(:tmpdir) { Dir.mktmpdir }
+ after do
+ require "fileutils"
+ FileUtils.remove_entry_secure @tmpdir unless @tmpdir.nil?
+ end
+
+ describe "#add_labels" do
+ # Integration tests are hard to write at this moment :(
+ it "add labels to all messages matching the query"
+ end
+end
diff --git a/test/test_crypto.rb b/test/test_crypto.rb
@@ -0,0 +1,109 @@
+# tests for sup's crypto libs
+#
+# Copyright Clint Byrum <clint@ubuntu.com> 2011. All Rights Reserved.
+# Copyright Sup Developers 2013.
+#
+# 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.
+
+require 'test_helper'
+require 'sup'
+require 'stringio'
+require 'tmpdir'
+
+module Redwood
+
+class TestCryptoManager < ::Minitest::Unit::TestCase
+
+ def setup
+ @from_email = 'sup-test-1@foo.bar'
+ @to_email = 'sup-test-2@foo.bar'
+ # Use test gnupg setup
+ @orig_gnupghome = ENV['GNUPGHOME']
+ ENV['GNUPGHOME'] = File.join(File.dirname(__FILE__), 'gnupg_test_home')
+
+ @path = Dir.mktmpdir
+ Redwood::HookManager.init File.join(@path, 'hooks')
+
+ am = {:default=> {:name => "test", :email=> 'sup-test-1@foo.bar'}}
+ Redwood::AccountManager.init am
+
+ Redwood::CryptoManager.init
+
+ if not CryptoManager.have_crypto?
+ warn "No crypto set up, crypto will not be tested. Reason: #{CryptoManager.not_working_reason}"
+ end
+ end
+
+ def teardown
+ CryptoManager.deinstantiate!
+ AccountManager.deinstantiate!
+ HookManager.deinstantiate!
+ FileUtils.rm_r @path
+
+ ENV['GNUPGHOME'] = @orig_gnupghome
+ end
+
+ def test_sign
+ if CryptoManager.have_crypto? then
+ signed = CryptoManager.sign @from_email,@to_email,"ABCDEFG"
+ assert_instance_of RMail::Message, signed
+ assert_equal "ABCDEFG", signed.body[0]
+ assert signed.body[1].body.length > 0 , "signature length must be > 0"
+ assert (signed.body[1].body.include? "-----BEGIN PGP SIGNATURE-----") , "Expecting PGP armored data"
+ end
+ end
+
+ def test_encrypt
+ if CryptoManager.have_crypto? then
+ encrypted = CryptoManager.encrypt @from_email, [@to_email], "ABCDEFG"
+ assert_instance_of RMail::Message, encrypted
+ assert (encrypted.body[1].body.include? "-----BEGIN PGP MESSAGE-----") , "Expecting PGP armored data"
+ end
+ end
+
+ def test_sign_and_encrypt
+ if CryptoManager.have_crypto? then
+ encrypted = CryptoManager.sign_and_encrypt @from_email, [@to_email], "ABCDEFG"
+ assert_instance_of RMail::Message, encrypted
+ assert (encrypted.body[1].body.include? "-----BEGIN PGP MESSAGE-----") , "Expecting PGP armored data"
+ end
+ end
+
+ def test_decrypt
+ if CryptoManager.have_crypto? then
+ encrypted = CryptoManager.encrypt @from_email, [@to_email], "ABCDEFG"
+ assert_instance_of RMail::Message, encrypted
+ assert_instance_of String, (encrypted.body[1].body)
+ decrypted = CryptoManager.decrypt encrypted.body[1], true
+ assert_instance_of Array, decrypted
+ assert_instance_of Chunk::CryptoNotice, decrypted[0]
+ assert_instance_of Chunk::CryptoNotice, decrypted[1]
+ assert_instance_of RMail::Message, decrypted[2]
+ assert_equal "ABCDEFG" , decrypted[2].body
+ end
+ end
+
+ def test_verify
+ if CryptoManager.have_crypto?
+ signed = CryptoManager.sign @from_email, @to_email, "ABCDEFG"
+ assert_instance_of RMail::Message, signed
+ assert_instance_of String, (signed.body[1].body)
+ CryptoManager.verify signed.body[0], signed.body[1], true
+ end
+ end
+end
+
+end
diff --git a/test/test_header_parsing.rb b/test/test_header_parsing.rb
@@ -1,12 +1,12 @@
#!/usr/bin/ruby
-require 'test/unit'
+require 'test_helper'
require 'sup'
require 'stringio'
include Redwood
-class TestMBoxParsing < Test::Unit::TestCase
+class TestMBoxParsing < Minitest::Unit::TestCase
def setup
@path = Dir.mktmpdir
@@ -150,10 +150,10 @@ To: a dear friend
Hello again! Would you like to buy my products?
EOS
offset = l.next_offset 0
- assert_not_nil offset
+ refute_nil offset
offset = l.next_offset offset
- assert_not_nil offset
+ refute_nil offset
offset = l.next_offset offset
assert_nil offset
diff --git a/test/test_helper.rb b/test/test_helper.rb
@@ -0,0 +1,7 @@
+require "rubygems" rescue nil
+require 'minitest/autorun'
+require "rr"
+
+class Minitest::Unit::TestCase
+ include ::RR::Adapters::MiniTest
+end
diff --git a/test/test_message.rb b/test/test_message.rb
@@ -1,6 +1,6 @@
#!/usr/bin/ruby
-require 'test/unit'
+require 'test_helper'
require 'sup'
require 'stringio'
@@ -26,7 +26,7 @@ end
module Redwood
-class TestMessage < Test::Unit::TestCase
+class TestMessage < ::Minitest::Unit::TestCase
def setup
@path = Dir.mktmpdir
@@ -289,7 +289,7 @@ EOS
from = sup_message.from
# very basic email address check
assert_match(/\w+@\w+\.\w{2,4}/, from.email)
- assert_not_nil(from.name)
+ refute_nil(from.name)
end
@@ -327,11 +327,7 @@ EOS
# read the message body chunks: no errors should reach this level
- chunks = nil
-
- assert_nothing_raised() do
- chunks = sup_message.load_from_source!
- end
+ chunks = sup_message.load_from_source!
# the chunks list should be empty
@@ -426,10 +422,7 @@ EOS
# read the message body chunks
- assert_nothing_raised() do
- chunks = sup_message.load_from_source!
- end
-
+ sup_message.load_from_source!
end
def test_blank_header_lines
@@ -537,4 +530,3 @@ end
end
# vim:noai:ts=2:sw=2:
-
diff --git a/test/test_yaml_migration.rb b/test/test_yaml_migration.rb
@@ -0,0 +1,80 @@
+require "test_helper"
+
+require "sup"
+require "psych"
+
+describe "Sup's YAML util" do
+ describe "Module#yaml_properties" do
+ def build_class_with_name name, &b
+ Class.new do
+ meta_cls = class << self; self; end
+ meta_cls.send(:define_method, :name) { name }
+ class_exec(&b) unless b.nil?
+ end
+ end
+
+ after do
+ Psych.load_tags = {}
+ Psych.dump_tags = {}
+ end
+
+ it "defines YAML tag for class" do
+ cls = build_class_with_name 'Cls' do
+ yaml_properties
+ end
+
+ expected_yaml_tag = "!supmua.org,2006-10-01/Cls"
+
+ Psych.load_tags[expected_yaml_tag].must_equal cls
+ Psych.dump_tags[cls].must_equal expected_yaml_tag
+
+ end
+
+ it "Loads legacy YAML format as well" do
+ cls = build_class_with_name 'Cls' do
+ yaml_properties :id
+ attr_accessor :id
+ def initialize id
+ @id = id
+ end
+ end
+
+ Psych.load_tags["!masanjin.net,2006-10-01/Cls"].must_equal cls
+
+ yaml = <<EOF
+--- !masanjin.net,2006-10-01/Cls
+id: ID
+EOF
+ loaded = YAML.load(yaml)
+
+ loaded.id.must_equal 'ID'
+ loaded.must_be_kind_of cls
+ end
+
+ it "Dumps & loads w/ state re-initialized" do
+ cls = build_class_with_name 'Cls' do
+ yaml_properties :id
+ attr_accessor :id
+ attr_reader :flag
+
+ def initialize id
+ @id = id
+ @flag = true
+ end
+ end
+
+ instance = cls.new 'ID'
+
+ dumped = YAML.dump(instance)
+ loaded = YAML.load(dumped)
+
+ dumped.must_equal <<-EOF
+--- !supmua.org,2006-10-01/Cls
+id: ID
+ EOF
+
+ loaded.id.must_equal 'ID'
+ assert loaded.flag
+ end
+ end
+end
diff --git a/test/test_yaml_regressions.rb b/test/test_yaml_regressions.rb
@@ -1,4 +1,4 @@
-require 'test/unit'
+require 'test_helper'
# Requiring 'yaml' before 'sup' in 1.9.x would get Psych loaded first
# and becoming the default yamler.
@@ -6,7 +6,7 @@ require 'yaml'
require 'sup'
module Redwood
- class TestYamlRegressions < Test::Unit::TestCase
+ class TestYamlRegressions < ::Minitest::Unit::TestCase
def test_yamling_hash
hsh = {:foo => 42}
reloaded = YAML.load(hsh.to_yaml)
diff --git a/test/unit/service/test_label_service.rb b/test/unit/service/test_label_service.rb
@@ -0,0 +1,19 @@
+require "test_helper"
+
+require "sup/service/label_service"
+
+describe Redwood::LabelService do
+ describe "#add_labels" do
+ it "add labels to all messages matching the query" do
+ q = 'is:starred'
+ label = 'superstarred'
+ message = mock!.add_label(label).subject
+ index = mock!.find_messages(q){ [message] }.subject
+ mock(index).update_message_state(message)
+ mock(index).save_index
+
+ service = Redwood::LabelService.new(index)
+ service.add_labels q, label
+ end
+ end
+end
diff --git a/test/unit/test_horizontal_selector.rb b/test/unit/test_horizontal_selector.rb
@@ -0,0 +1,40 @@
+require "test_helper"
+
+require "sup/horizontal_selector"
+
+describe Redwood::HorizontalSelector do
+ let(:values) { %w[foo@example.com bar@example.com] }
+ let(:strange_value) { "strange@example.com" }
+
+ before do
+ @selector = Redwood::HorizontalSelector.new(
+ 'Acc:', values, [])
+ end
+
+ it "init w/ the first value selected" do
+ first_value = values.first
+ @selector.val.must_equal first_value
+ end
+
+ it "stores value for selection" do
+ second_value = values[1]
+ @selector.set_to second_value
+ @selector.val.must_equal second_value
+ end
+
+ describe "for unknown value" do
+ it "cannot select unknown value" do
+ @selector.wont_be :can_set_to?, strange_value
+ end
+
+ it "refuses selecting unknown value" do
+ old_value = @selector.val
+
+ assert_raises Redwood::HorizontalSelector::UnknownValue do
+ @selector.set_to strange_value
+ end
+
+ @selector.val.must_equal old_value
+ end
+ end
+end
diff --git a/test/unit/util/test_query.rb b/test/unit/util/test_query.rb
@@ -0,0 +1,37 @@
+# encoding: utf-8
+
+require "test_helper"
+
+require "sup/util/query"
+require "xapian"
+
+describe Redwood::Util::Query do
+ describe ".describe" do
+ it "returns a UTF-8 description of query" do
+ query = Xapian::Query.new "テスト"
+ life = "生活: "
+
+ assert_raises Encoding::CompatibilityError do
+ _ = life + query.description
+ end
+
+ desc = Redwood::Util::Query.describe(query)
+ _ = (life + desc) # No exception thrown
+ end
+
+ it "returns a valid UTF-8 description of bad input" do
+ msg = "asdfa \xc3\x28 åasdf"
+ query = Xapian::Query.new msg
+ life = 'hæi'
+
+ # this is now possibly UTF-8 string with possibly invalid chars
+ assert_raises Redwood::Util::Query::QueryDescriptionError do
+ desc = Redwood::Util::Query.describe (query)
+ end
+
+ assert_raises Encoding::CompatibilityError do
+ _ = life + query.description
+ end
+ end
+ end
+end
diff --git a/test/unit/util/test_string.rb b/test/unit/util/test_string.rb
@@ -0,0 +1,57 @@
+# encoding: utf-8
+
+require "test_helper"
+
+require "sup/util"
+
+describe "Sup's String extension" do
+ describe "#display_length" do
+ let :data do
+ [
+ ['some words', 10,],
+ ['中文', 4,],
+ ['ä', 1,],
+ ]
+ end
+
+ it "calculates display length of a string" do
+ data.each do |(str, length)|
+ str.display_length.must_equal length
+ end
+ end
+ end
+
+ describe "#slice_by_display_length(len)" do
+ let :data do
+ [
+ ['some words', 6, 'some w'],
+ ['中文', 2, '中'],
+ ['älpha', 3, 'älp'],
+ ]
+ end
+
+ it "slices string by display length" do
+ data.each do |(str, length, sliced)|
+ str.slice_by_display_length(length).must_equal sliced
+ end
+ end
+ end
+
+ describe "#wrap" do
+ let :data do
+ [
+ ['some words', 6, ['some', 'words']],
+ ['some words', 80, ['some words']],
+ ['中文', 2, ['中', '文']],
+ ['中文', 5, ['中文']],
+ ['älpha', 3, ['älp', 'ha']],
+ ]
+ end
+
+ it "wraps string by display length" do
+ data.each do |(str, length, wrapped)|
+ str.wrap(length).must_equal wrapped
+ end
+ end
+ end
+end
diff --git a/test/unit/util/test_uri.rb b/test/unit/util/test_uri.rb
@@ -0,0 +1,19 @@
+require "test_helper.rb"
+
+require "sup/util/uri"
+
+describe Redwood::Util::Uri do
+ describe ".build" do
+ it "builds uri from hash" do
+ components = {:path => "/var/mail/foo", :scheme => "mbox"}
+ uri = Redwood::Util::Uri.build(components)
+ uri.to_s.must_equal "mbox:/var/mail/foo"
+ end
+
+ it "expands ~ in path" do
+ components = {:path => "~/foo", :scheme => "maildir"}
+ uri = Redwood::Util::Uri.build(components)
+ uri.to_s.must_equal "maildir:#{ENV["HOME"]}/foo"
+ end
+ end
+end