pax_global_header00006660000000000000000000000064116615426400014517gustar00rootroot0000000000000052 comment=aaa852fbe513291144301bb31af50558177f891c sup-mainline/000077500000000000000000000000001166154264000134645ustar00rootroot00000000000000sup-mainline/.ditz-plugins000066400000000000000000000000061166154264000161120ustar00rootroot00000000000000- git sup-mainline/.gitignore000066400000000000000000000001161166154264000154520ustar00rootroot00000000000000# i use vi *.swp .ditz-config # i use emacs *~ # i use rake package task pkg/ sup-mainline/CONTRIBUTORS000066400000000000000000000051161166154264000153470ustar00rootroot00000000000000William Morgan Rich Lane Ismo Puustinen Nicolas Pouillard Eric Sherman Michael Stapelberg Ben Walton Mike Stipicevic Marcus Williams Lionel Ott Tero Tilus Ingmar Vanhassel Mark Alexander Gaute Hope Christopher Warrington W. Trevor King Gaudenz Steinlin Richard Brown Marc Hartstein Sascha Silbe Israel Herraiz Anthony Martinez Hamish Downer Bo Borgerson William Erik Baxter Michael Hamann Grant Hollingworth Adeodato Simó Daniel Schoepe Jason Petsod Steve Goldman Edward Z. Yang Decklin Foster Cameron Matheson Carl Worth Jeff Balogh Andrew Pimlott Alex Vandiver Peter Harkins Kornilios Kourtis Giorgio Lando Damien Leone Benoît PIERRE Alvaro Herrera Jonah Adam Lloyd Todd Eisenberger ian Steven Walter ian Jon M. Dugan Gregor Hoffleit Stefan Lundström Kirill Smelkov sup-mainline/HACKING000066400000000000000000000036771166154264000144700ustar00rootroot00000000000000Running Sup from your git checkout ---------------------------------- Invoke it like this: ruby -I lib -w bin/sup You'll have to install all gems mentioned in the Rakefile (look for the line setting p.extra_deps). If you're on a Debian or Debian-based system (e.g. Ubuntu), you'll have to make sure you have a complete Ruby installation, especially libssl-ruby. You will need libruby-devel, gcc, and rake installed to build certain gems like Xapian. Gem install does not do a good job of detecting when these things are missing and the build fails. Rubygems also is particularly aggressive about picking up libraries from installed gems. If you do have Sup installed as a gem, please examine backtraces to make sure you're loading files from the repository and NOT from the installed gem before submitting any bug reports. Coding standards ---------------- - Don't wrap code unless it really benefits from it. - Do wrap comments at 72 characters. - Old lisp-style comment differentiations: # one for comments on the same line as a line of code ## two for comments on their own line, except: ### three for comments that demarcate large sections of code (rare) - Use {} for one-liner blocks and do/end for multi-line blocks. - I like poetry mode. Don't use parentheses unless you must. - The one exception to poetry mode is if-statements that have an assignment in the condition. To make it clear this is not a comparison, surround the condition by parentheses. E.g.: if a == b if(a = some.computation) ... BUT ... something with a end end - and/or versus ||/&&. In Ruby, "and" and "or" bind very loosely---even more loosely than function application. This makes them ideal for end-of-line short-circuit control in poetry mode. So, use || and && for ordinary logical comparisons, and "and" and "or" for end-of-line flow control. E.g.: x = a || b or raise "neither is true" sup-mainline/History.txt000066400000000000000000000210571166154264000156730ustar00rootroot00000000000000== 0.12.1 / 2011-01-23 * Depend on ncursesw rather than ncurses (Ruby 1.9 compatibility) * Add sup-import-dump == 0.12 / 2011-01-13 * Remove deprecated IMAP, IMAPS, and mbox+ssh sources * Inline GPG support * Robust maildir support * sup-dump compatibility between Sup versions * New hook: sendmail * Better Ruby 1.9/UTF8 support * As always, many bugfixes and tweaks. == 0.11 / 2010-03-07 * Remove deprecated Ferret backend. * Add deprecation notices to IMAP, IMAPS, and mbox+ssh sources. * 256 color support. * Backwards-compatible index format improvements. * Saved searches. * Improved support for custom keybindings. * Idle detection - poll totals accumulate and index flushes on idle. * Several textfield improvments. * New hooks: publish, mentions-attachments, keybindings, index-mode-date-widget, gpg-args, and crypto-settings. * sup-cmd for easy programmatic access to a Sup index. * As always, many bugfixes and tweaks. == 0.10.2 / 2010-01-26 * Update gem dependencies to pull in xapian-full and ncursesw instead of ferret and ncurses. * Fix a minor problem when running with Ruby 1.8.5. * Fix a warning. == 0.10.1 / 2010-01-24 * Fix a missing file in the gem. == 0.10 / 2010-01-22 * Make Xapian backend the default, and add deprecation notice to Ferret indexes. * Now Ruby 1.9 compatible (Xapian backend only). * Changes are now saved automatically to the index. Pressing "$" now just forces a flush of Xapian indexes, which can minimize quit time. * Fix problem with replying to Google Groups messages. * Allow toggling of line wrap. Useful for long URLs. * Multiple attachments can be added at once by specifying a wildcard. * New command to save all attachments at once. * As always, many bugfixes and tweaks. == 0.9.1 / 2009-12-10 * Make textfield behave more like readline, including C-w * Add ask_for_to config option. You can set all ask_for_* options to false, and composing a message will go immediately to the editor. * RFC 2047 decode attachment file names * default ask_for_to to true * add undo power to thread-view-mode * display labels of polled messages * increase numbers in contact-list-mode * fix --compose option, and add a --subject option * include hook filename in error messages * As always, many bugfixes and tweaks. == 0.9 / 2009-10-01 * Experimental Xapian backend to replace Ferret. Not everything works with it, but it's fast and less likely to barf. See release notes. * New keybinding: "G" for reply-all. * New hook: custom-search, for adding your own query expansions. * Better preemptive thread loading. * Random UI tweaks: display labels before subjects, change thread-view-mode's 'n' and 'p' commands slightly * Better killing of other Sup processes. * Die gracefully upon SIGKILL. * Finally figure out the curses+ruby magic to make SIGWINCH (i.e. xterm resizing) work correctly. * Add a console mode (press ~) for interactively playing with the index. * Finally figure out the curses magic to stop the weird keyboard behavior after leaving the editor. * Improved logging. Logging now supports SUP_LOG_LEVEL environment variable. Set this to "debug" for verbiage. * As always, many bugfixes and tweaks. == 0.8.1 / 2009-06-15 * make multibyte display "work" for non-utf8 locales * fix reply-mode always selecting "Customized" * reduce email quote parsing worst-case behavior == 0.8 / 2009-06-05 * Undo support on many operations. Yay! * Mbox splitting fixes. No more "From "-line problems. * Mail parsing speedups. * Many utf8 and widechar fixes. Display of crazy characters should be pretty close. * Outgoing email with non-ASCII headers is now properly encoded. * Email addresses are no longer permanently attached to names. This was causing problems with automated email systems that used different names with the same address. * Curses background now retains the terminal default color. This also makes Sup work better on transparent terminals. * Improve dynamic loading of setlocale for Cygwin and BSD systems. * Labels can now be removed from multiple tagged threads. * Applying operations to tagged threads is now invoked with '='. * Buffer list is betterified and is now invoked with ';'. * Zsh autocompletion support. * As always, many bugfixes and tweaks. == 0.7 / 2009-03-16 * Ferret index corruption issues fixed (hopefully!) * Text entry now scrolls to the right on overflow, i.e. is actually usable * Ctrl-C now asks user if Sup should die ungracefully * Add a limit: search operator to limit the number of results * Added a --query option to sup-tweak-labels * Added a new hook: shutdown * Automatically add self as recipient on crypted sent messages * Read in X-Foo headers * Added global keybinding 'U' shows only unread messages * As always, many bugfixes and tweaks == 0.6 / 2008-08-04 * new hooks: mark-as-spam, reply-to, reply-from * configurable colors. finally! * many bugfixes * more vi keys added, and 'q' now asks before quitting * attachment markers (little @ signs!) in thread-index-mode * maildir speedups * attachment name searchability * archive-and-mark-read command in inbox-mode == 0.5 / 2008-04-22 * new hooks: extra-contact-addresses, startup * '!!' now loads all threads in current search * general state saving speedup * threads with unsent draft messages are now shown in red * --compose spawns a compose-message buffer on startup * Many bugfixes and UI improvements == 0.4 / 2008-01-23 * GPG support for signing and encrypting outgoing mail * New hooks: mime attachment, attribution line * Improved local charset detection using gettext library * Better quoted region detection * Many bugfixes and UI improvements == 0.3 / 2007-10-29 * In-buffer search (finally!) * Subscribe to/unsubscribe from mailing list commands. * IMAP speedups. * More hooks: set status bar, set terminal title bar, modify message headers and bodies before editing, etc. * Optionally use chronic gem to allow for natural-language dates in searches. * Many, many bugfixes and minor improvements. * Tomorrow is Sup's first birthday! == 0.2 / 2007-10-29 * Complete hook system for user-inserted code. * GPG signature verification and decryption. * Automatically scold users who top-post. * Automatically warn when sending a message with words like "attachment" in the body if there aren't actually any attachments to the message. * Millions of bugfixes. == 0.1 / 2007-07-17 * MIME attachment creation. * i18n support: character set conversion and rfc2047 header decoding. * Better MIME handling. * Multiple account support. * Locking and concurrent Sup process detection and killation. * Thread autoloading when you scroll down. * Batch deletion of messages marked deleted or spam from message sources via sup-sync-back tool (mbox only). * Millions of bugfixes. == 0.0.8 / 2007-04-01 * Maildir support! * New command: sup-config. Interactively walks you through everything you need to get up and running. * Now proactive about notifying users of de-synced sources. * Renamed sup-import => sup-sync with a brand new, less illogical interface. * Added a sup-dump, to enable backing up and rebuilding indices from scratch (e.g. when Ferret upgrades break index formats). * More bugfixes. Will they ever end? == 0.0.7 / 2007-02-12 * Split sup-import into two bits: sup-import and sup-add. * Command-line arguments now handled by trollop. * Better error handling for IMAP and svn+ssh. * Messages can now be moved between sources while preserving all message state. * New commands in thread-view-mode: - 'a' to add an email to the addressbook - 'S' to search for all email to/from an email address - 'A' to kill buffer and archive thread in one swell foop * Removed hoe dependency. == 0.0.6 / 2007-01-06 * Very minor fix to support more types of IMAP authentication. == 0.0.5 / 2007-01-05 * More bugfixes, primarily for IMAP. * doc/UserGuide.txt == 0.0.4 / 2007-01-03 * Bugfixes, primarily for threaded networking. == 0.0.3 / 2007-01-02 * Major speed increase for index views (inbox, search results), which are now loaded completely from the IR index. The only time the original sources need to be touched is when viewing a thread. This is important for slow sources like IMAP and mbox+ssh. * Remote mbox support with mbox+ssh URIs. * IMAP now actually works. * sup-import uses HighLine and is generally much improved. * Multitudinous minor bug fixes and improvements. == 0.0.2 / 2006-12-10 * IMAP support * Better handling of broken sources. (Everything won't just die.) * You will need to rebuild both your index, and sources.yaml. Sorry! == 0.0.1 / 2006-11-28 * Initial release. Unix-centrism, support for mbox only, no i18n. Untested on anything other than 1.8.5. Other than that, works great! sup-mainline/LICENSE000066400000000000000000000354221166154264000144770ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS sup-mainline/README.txt000066400000000000000000000100541166154264000151620ustar00rootroot00000000000000sup by William Morgan http://sup.rubyforge.org == DESCRIPTION: Sup is a console-based email client for people with a lot of email. It supports tagging, very fast full-text search, automatic contact- list management, and more. If you're the type of person who treats email as an extension of your long-term memory, Sup is for you. Sup makes it easy to: - Handle massive amounts of email. - Mix email from different sources: mbox files and Maildirs. - Instantaneously search over your entire email collection. Search over body text, or use a query language to combine search predicates in any way. - Handle multiple accounts. Replying to email sent to a particular account will use the correct SMTP server, signature, and from address. - Add custom code to customize Sup to whatever particular and bizarre needs you may have. - Organize email with user-defined labels, automatically track recent contacts, and much more! The goal of Sup is to become the email client of choice for nerds everywhere. == FEATURES/PROBLEMS: Features: - Scalability to massive amounts of email. Immediate startup and operability, regardless of how much amount of email you have. - Immediate full-text search of your entire email archive, using the Xapian query language. Search over message bodies, labels, from: and to: fields, or any combination thereof. - Thread-centrism. Operations are performed at the thread, not the message level. Entire threads are manipulated and viewed (with redundancies removed) at a time. - Labels instead of folders. Drop that tired old metaphor and you'll see how much easier it is to organize email. - GMail-style thread management. Archive a thread, and it will disappear from your inbox until someone replies. Kill a thread, and it will never come back to your inbox (but will still show up in searches.) Mark a thread as spam and you'll never again see it unless explicitly searching for spam. - Console based interface. No mouse clicking required! - Programmability. It's in Ruby. The code is good. It has an extensive hook system that makes it easy to extend and customize. - Multiple buffer support. Why be limited to viewing one thing at a time? - Tons of other little features, like automatic context-sensitive help, multi-message operations, MIME attachment viewing, recent contact list generation, etc. Current limitations which will be fixed: - Sup doesn't play nicely with other mail clients. If you alter a mail source (read, move, delete, etc) with another client Sup will punish you with a lengthy reindexing process. - Unix-centrism in MIME attachment handling and in sendmail invocation. == SYNOPSYS: 0. sup-config 1. sup Note that Sup never changes the contents of any mailboxes; it only indexes in to them. So it shouldn't ever corrupt your mail. The flip side is that if you change a mailbox (e.g. delete messages, or, in the case of mbox files, read an unread message) then Sup will be unable to load messages from that source and will ask you to run sup-sync --changed. == REQUIREMENTS: - xapian-full >= 1.1.3.2 - ncurses >= 0.9.1 - rmail >= 0.17 - highline - net-ssh - trollop >= 1.12 - lockfile - mime-types - gettext - fastthread == INSTALL: * gem install sup == PROBLEMS: See FAQ.txt for some common problems and their solutions. == LICENSE: Copyright (c) 2006--2009 William Morgan. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. sup-mainline/Rakefile000066400000000000000000000057241166154264000151410ustar00rootroot00000000000000## is there really no way to make a rule for this? WWW_FILES = %w(www/index.html README.txt doc/Philosophy.txt doc/FAQ.txt doc/NewUserGuide.txt www/main.css) rule 'ss?.png' => 'ss?-small.png' do |t| end SCREENSHOTS = FileList["www/ss?.png"] SCREENSHOTS_SMALL = [] SCREENSHOTS.each do |fn| fn =~ /ss(\d+)\.png/ sfn = "www/ss#{$1}-small.png" file sfn => [fn] do |t| sh "cat #{fn} | pngtopnm | pnmscale -xysize 320 240 | pnmtopng > #{sfn}" end SCREENSHOTS_SMALL << sfn end task :upload_webpage => WWW_FILES do |t| sh "rsync -essh -cavz #{t.prerequisites * ' '} wmorgan@rubyforge.org:/var/www/gforge-projects/sup/" end task :upload_webpage_images => (SCREENSHOTS + SCREENSHOTS_SMALL) do |t| sh "rsync -essh -cavz #{t.prerequisites * ' '} wmorgan@rubyforge.org:/var/www/gforge-projects/sup/" end # vim: syntax=ruby # -*- ruby -*- task :upload_report do |t| sh "ditz html ditz" sh "rsync -essh -cavz ditz wmorgan@rubyforge.org:/var/www/gforge-projects/sup/" end $:.push "lib" require 'rubygems' require "sup-files" require "sup-version" require 'rake/gempackagetask.rb' spec = Gem::Specification.new do |s| s.name = %q{sup} s.version = SUP_VERSION s.date = Time.now.to_s s.authors = ["William Morgan"] s.email = %q{wmorgan-sup@masanjin.net} s.summary = %q{A console-based email client with the best features of GMail, mutt, and emacs. Features full text search, labels, tagged operations, multiple buffers, recent contacts, and more.} s.homepage = %q{http://sup.rubyforge.org/} s.description = %q{Sup is a console-based email client for people with a lot of email. It supports tagging, very fast full-text search, automatic contact-list management, and more. If you're the type of person who treats email as an extension of your long-term memory, Sup is for you. Sup makes it easy to: - Handle massive amounts of email. - Mix email from different sources: mbox files (even across different machines), Maildir directories, POP accounts, and GMail accounts. - Instantaneously search over your entire email collection. Search over body text, or use a query language to combine search predicates in any way. - Handle multiple accounts. Replying to email sent to a particular account will use the correct SMTP server, signature, and from address. - Add custom code to handle certain types of messages or to handle certain types of text within messages. - Organize email with user-defined labels, automatically track recent contacts, and much more! The goal of Sup is to become the email client of choice for nerds everywhere.} s.files = SUP_FILES s.executables = SUP_EXECUTABLES s.add_dependency "xapian-full", ">= 1.2.1" s.add_dependency "ncursesw" 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" end Rake::GemPackageTask.new(spec) do |pkg| pkg.need_tar = true end task :tarball => ["pkg/sup-#{SUP_VERSION}.tgz"] sup-mainline/ReleaseNotes000066400000000000000000000105261166154264000160040ustar00rootroot00000000000000Release 0.12.1: This release changes the gem dependency on ncurses to ncursesw, which allows the gem to install cleanly on Ruby 1.9. The new sup-import-dump program applies labels to an existing index, which could be done with sup-sync before 0.12. Release 0.12: Deprecated remote sources have been removed. Maildir support has been improved to gracefully handle messages that move or disappear. The "out of sync" errors should no longer occur. Inline GPG is now supported. Release 0.11: The deprecated Ferret index has been removed. Remote sources (IMAP, IMAPS, and mbox+ssh) have been deprecated and will be removed in 0.12. Tools like offlineimap, fetchmail, and rsync provide a much better user experience for these mail sources than Sup would ever be able to by itself. If your terminal supports it you can now use 256 colors in your colorscheme. Run the contrib/colorpicker.rb program to get the color names to put in colors.yaml. Saved searches are now supported. Hit '%' in search-results-mode to save the current search, and enter an empty search string to open the list of saved searches. Release 0.10: The Xapian backend is now the default. Convert your old, crash-prone Ferret index to Xapian by running sup-convert-ferret-index. Using a Ferret backend will produce a deprecation notice, and will not be supported in 0.11. Many thanks to Rich Lane. Release 0.9.1: This is mainly a bugfix release, with a couple minor new features rolled up. If you are using the Ferret backend, consider convering soon. I will probably add a deprecation notice in 0.10, and you support will be dropped in 0.11. Release 0.9: There's a new Xapian backend as an alternative to the Ferret one. It's still in a beta stage. It's much faster and much less prone to the random crashes than Ferret, but certain things don't work yet, most noticeably the unread message counts in label-list-mode. You can switch back and forth between both indexes without harm, *except* any new messages added to the one index won't be picked up by the other. Follow these instructions: To TRY the Xapian index, without screwing Ferret up: 1. sup-dump > dump # takes a while 2. export SUP_INDEX=xapian # or however you do it in your shell 3. sup-sync --all --all-sources --restore dump # takes a long time 4. sup -n # -n ensures that no polling is done. don't hit 'P' either Step 1 will take a long time, and step 3 will take a very long time. At this point, whenever you run Sup, the SUP_INDEX environment variable will determine which index you use. If it's unset, or "ferret", you will use the ferret index. If it's "xapian", the Xapian index. Make sure when you run sup with the Xapian index, you use -n and don't hit 'P', to avoid loading new messages into it. If you want to switch to Xapian permanently, you can then: 1. rm -rf ~/.sup/ferret 2. permanently set SUP_INDEX=xapian according to your shell 3. Run sup as normal, i.e. without -n. If you want to go back to Ferret, you can just rm -rf ~/.sup/xapian and make sure your SUP_INDEX environment variable is unset. Release 0.8.1: A bugfix release with fixes for quote parsing (bad behavior in certain long emails), multibyte display for non-utf8 locales, and reply-mode mode selection. Release 0.8: The big wins are undo support, mbox splitting fixes, and the various UI speedups and bugfixes. Parsing new email should also be faster, although IMAP remains tragically slow, as usual. Release 0.7: The big win in this release is that Ferret index corruption issues should now be fixed, thanks to an extensive programming of locking and thread-safety-adding. The other nice change is that text entry will now scroll to the right upon overflow, thanks to some arcane Curses magic that Steve Goldman discovered. As always, this release includes many other bugfixes and enhancements. Release 0.6: Message attachment searchability automatically takes effect on new messages, but if you want it on older ones, you'll have to reindex them. See the instructions below, and the help for sup-sync, for how to do this. Release 0.5: Saving message state (pressing "$") has been sped up. However, this is only automatically in effect for new messages. To make it effective for older messages (i.e. messages indexed with versions of Sup before 0.5), you must reindex them, e.g. by running sup-sync --all on a source. sup-mainline/bin/000077500000000000000000000000001166154264000142345ustar00rootroot00000000000000sup-mainline/bin/sup000077500000000000000000000307151166154264000147770ustar00rootroot00000000000000#!/usr/bin/env ruby require 'rubygems' no_ncursesw = false begin require 'ncursesw' rescue LoadError require 'ncurses' no_ncursesw = true end no_gpgme = false begin require 'gpgme' rescue LoadError no_gpgme = true end require 'fileutils' require 'trollop' require "sup"; Redwood::check_library_version_against "git" if ENV['SUP_PROFILE'] require 'ruby-prof' RubyProf.start end if no_ncursesw info "No 'ncursesw' gem detected. Install it for wide character support." end if no_gpgme info "No 'gpgme' gem detected. Install it for email encryption, decryption and signatures." end $opts = Trollop::options do version "sup v#{Redwood::VERSION}" banner < String opt :compose, "Compose message to this recipient upon startup", :type => String opt :subject, "When composing, use this subject", :type => String, :short => "j" end Trollop::die :subject, "requires --compose" if $opts[:subject] && !$opts[:compose] Redwood::HookManager.register "startup", < e warn "cannot dlload setlocale(); ncurses wide character support probably broken." warn "dlload error was #{e.class}: #{e.message}" if Config::CONFIG['arch'] =~ /bsd/ warn "BSD variant detected. You may have to install a compat6x package to acquire libc." end end end def start_cursing Ncurses.initscr Ncurses.noecho Ncurses.cbreak Ncurses.stdscr.keypad 1 Ncurses.use_default_colors Ncurses.curs_set 0 Ncurses.start_color $cursing = true end def stop_cursing return unless $cursing Ncurses.curs_set 1 Ncurses.echo Ncurses.endwin end module_function :start_cursing, :stop_cursing Index.init Index.lock_interactively or exit begin Redwood::start Index.load Index.start_sync_worker unless $opts[:no_threads] $die = false trap("TERM") { |x| $die = true } trap("WINCH") { |x| BufferManager.sigwinch_happened! } if(s = Redwood::SourceManager.source_for DraftManager.source_name) DraftManager.source = s else debug "no draft source, auto-adding..." Redwood::SourceManager.add_source DraftManager.new_source end if(s = Redwood::SourceManager.source_for SentManager.source_uri) SentManager.source = s else Redwood::SourceManager.add_source SentManager.default_source end HookManager.run "startup" Redwood::Keymap.run_hook global_keymap debug "starting curses" Redwood::Logger.remove_sink $stderr start_cursing bm = BufferManager.init Colormap.new.populate_colormap debug "initializing log buffer" lmode = Redwood::LogMode.new "system log" lmode.on_kill { Logger.clear! } Logger.add_sink lmode Logger.force_message "Welcome to Sup! Log level is set to #{Logger.level}." if Logger::LEVELS.index(Logger.level) > 0 Logger.force_message "For more verbose logging, restart with SUP_LOG_LEVEL=#{Logger::LEVELS[Logger::LEVELS.index(Logger.level)-1]}." end debug "initializing inbox buffer" imode = InboxMode.new ibuf = bm.spawn "Inbox", imode debug "ready for interaction!" bm.draw_screen Redwood::SourceManager.usual_sources.each do |s| next unless s.respond_to? :connect reporting_thread("call #connect on #{s}") do begin s.connect rescue SourceError => e error "fatal error loading from #{s}: #{e.message}" end end end unless $opts[:no_initial_poll] imode.load_threads :num => ibuf.content_height, :when_done => lambda { |num| reporting_thread("poll after loading inbox") { sleep 1; PollManager.poll } unless $opts[:no_threads] || $opts[:no_initial_poll] } if $opts[:compose] to = Person.from_address_list $opts[:compose] mode = ComposeMode.new :to => to, :subj => $opts[:subject] BufferManager.spawn "New Message", mode mode.edit_message end unless $opts[:no_threads] PollManager.start IdleManager.start Index.start_lock_update_thread end if $opts[:search] SearchResultsMode.spawn_from_query $opts[:search] end until Redwood::exceptions.nonempty? || $die c = begin Ncurses.nonblocking_getch rescue Interrupt raise if BufferManager.ask_yes_or_no "Die ungracefully now?" BufferManager.draw_screen nil end if c.nil? if BufferManager.sigwinch_happened? debug "redrawing screen on sigwinch" BufferManager.completely_redraw_screen end next end IdleManager.ping if c == 410 ## this is ncurses's way of telling us it's detected a refresh. ## since we have our own sigwinch handler, we don't do anything. next end bm.erase_flash action = begin if bm.handle_input c :nothing else bm.resolve_input_with_keymap c, global_keymap end rescue InputSequenceAborted :nothing end case action when :quit_now break if bm.kill_all_buffers_safely when :quit_ask if bm.ask_yes_or_no "Really quit?" break if bm.kill_all_buffers_safely end when :help curmode = bm.focus_buf.mode bm.spawn_unless_exists("") { HelpMode.new curmode, global_keymap } when :roll_buffers bm.roll_buffers when :roll_buffers_backwards bm.roll_buffers_backwards when :kill_buffer bm.kill_buffer_safely bm.focus_buf when :list_buffers bm.spawn_unless_exists("buffer list", :system => true) { BufferListMode.new } when :list_contacts b, new = bm.spawn_unless_exists("Contact List") { ContactListMode.new } b.mode.load_in_background if new when :search query = BufferManager.ask :search, "Search all messages (enter for saved searches): " unless query.nil? if query.empty? bm.spawn_unless_exists("Saved searches") { SearchListMode.new } else SearchResultsMode.spawn_from_query query end end when :search_unread 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) } user_label = bm.ask_with_completions :label, "Show threads with label (enter for listing): ", labels unless user_label.nil? if user_label.empty? bm.spawn_unless_exists("Label list") { LabelListMode.new } if user_label && user_label.empty? else LabelSearchResultsMode.spawn_nicely user_label end end when :compose ComposeMode.spawn_nicely when :poll reporting_thread("user-invoked poll") { PollManager.poll } when :poll_unusual if BufferManager.ask_yes_or_no "Really poll unusual sources?" reporting_thread("user-invoked unusual poll") { PollManager.poll_unusual } end when :recall_draft case Index.num_results_for :label => :draft when 0 bm.flash "No draft messages." when 1 m = nil Index.each_id_by_date(:label => :draft) { |mid, builder| m = builder.call } r = ResumeMode.new(m) BufferManager.spawn "Edit message", r r.edit_message else b, new = BufferManager.spawn_unless_exists("All drafts") { LabelSearchResultsMode.new [:draft] } b.mode.load_threads :num => b.content_height if new end when :show_inbox BufferManager.raise_to_front ibuf when :clear_hooks HookManager.clear when :show_console b, new = bm.spawn_unless_exists("Console", :system => true) { ConsoleMode.new } b.mode.run when :reload_colors Colormap.reset Colormap.populate_colormap bm.completely_redraw_screen bm.flash "reloaded colors" when :run_keybindings_hook HookManager.clear_one 'keybindings' Keymap.run_hook global_keymap bm.flash "keybindings hook run" when :nothing, InputSequenceAborted when :redraw bm.completely_redraw_screen else bm.flash "Unknown keypress '#{c.to_character}' for #{bm.focus_buf.mode.name}." end bm.draw_screen end bm.kill_all_buffers if $die rescue Exception => e Redwood::record_exception e, "main" ensure unless $opts[:no_threads] PollManager.stop if PollManager.instantiated? IdleManager.stop if IdleManager.instantiated? Index.stop_lock_update_thread end HookManager.run "shutdown" Index.stop_sync_worker Redwood::finish stop_cursing Redwood::Logger.remove_all_sinks! Redwood::Logger.add_sink $stderr, false debug "stopped cursing" if $die info "I've been ordered to commit seppuku. I obey!" end if Redwood::exceptions.empty? debug "no fatal errors. good job, william." Index.save else error "oh crap, an exception" end Index.unlock if (fn = ENV['SUP_PROFILE']) result = RubyProf.stop File.open(fn, 'w') { |io| RubyProf::CallTreePrinter.new(result).print(io) } end end unless Redwood::exceptions.empty? File.open(File.join(BASE_DIR, "exception-log.txt"), "w") do |f| Redwood::exceptions.each do |e, name| f.puts "--- #{e.class.name} from thread: #{name}" f.puts e.message, e.backtrace end end $stderr.puts <+ where + is one or more source URIs. For mbox files on local disk, use the form: mbox:, or mbox:// For Maildir folders, use the form: maildir:; or maildir:// Options are: EOS opt :archive, "Automatically archive all new messages from these sources." opt :unusual, "Do not automatically poll these sources for new messages." opt :labels, "A comma-separated set of labels to apply to all messages from this source", :type => String opt :force_new, "Create a new account for this source, even if one already exists." opt :force_account, "Reuse previously defined account user@hostname.", :type => String end Trollop::die "require one or more sources" if ARGV.empty? ## for sources that require login information, prompt the user for ## that. also provide a list of previously-defined login info to ## choose from, if any. def get_login_info uri, sources uri = URI(uri) accounts = sources.map do |s| next unless s.respond_to?(:username) suri = URI(s.uri) [suri.host, s.username, s.password] end.compact.uniq.sort_by { |h, u, p| h == uri.host ? 0 : 1 } username, password = nil, nil unless accounts.empty? || $opts[:force_new] if $opts[:force_account] host, username, password = accounts.find { |h, u, p| $opts[:force_account] == "#{u}@#{h}" } unless username && password say "No previous account #{$opts[:force_account].inspect} found." end else say "Would you like to use the same account as for a previous source for #{uri}?" choose do |menu| accounts.each do |host, olduser, oldpw| menu.choice("Use the account info for #{olduser}@#{host}") { username, password = olduser, oldpw } end menu.choice("Use a new account") { } menu.prompt = "Account selection? " end end end unless username && password username = ask("Username for #{uri.host}: "); password = ask("Password for #{uri.host}: ") { |q| q.echo = false } puts # why? end [username, password] end $terminal.wrap_at = :auto Redwood::start index = Redwood::Index.init index.load index.lock_interactively or exit begin Redwood::SourceManager.load_sources ARGV.each do |uri| labels = $opts[:labels] ? $opts[:labels].split(/\s*,\s*/).uniq : [] if !$opts[:force_new] && Redwood::SourceManager.source_for(uri) say "Already know about #{uri}; skipping." next end parsed_uri = URI(uri) source = case parsed_uri.scheme when "maildir" Redwood::Maildir.new uri, !$opts[:unusual], $opts[:archive], nil, labels when "mbox" Redwood::MBox.new uri, !$opts[:unusual], $opts[:archive], nil, labels when nil Trollop::die "Sources must be specified with an URI" else Trollop::die "Unknown source type #{parsed_uri.scheme.inspect}" end say "Adding #{source}..." Redwood::SourceManager.add_source source end ensure index.save index.unlock Redwood::finish end sup-mainline/bin/sup-cmd000077500000000000000000000062331166154264000155360ustar00rootroot00000000000000#!/usr/bin/env ruby require 'rubygems' require 'trollop' require 'sup' require 'sup/client' require 'pp' require 'yaml' include Redwood SUB_COMMANDS = %w(query count label add) global_opts = Trollop::options do #version = "sup-cmd (sup #{Redwood::VERSION})" banner < :string, :default => 'localhost', :short => 'o' opt :port, "server port", :type => :int, :default => 4300 opt :socket, "unix domain socket path", :type => :string, :default => nil opt :verbose conflicts :host, :socket conflicts :port, :socket stop_on SUB_COMMANDS end cmd = ARGV.shift cmd_opts = case cmd when "query" Trollop.options do opt :offset, "Offset", :default => 0, :type => :int opt :limit, "Limit", :type => :int opt :raw, "Retrieve raw message text", :default => false end when "count" Trollop.options do end when "label" Trollop.options do opt :add_labels, "Labels to add", :default => "" opt :remove_labels, "Labels to remove", :default => "" end when "add" Trollop.options do opt :labels, "Labels separated by commas", :default => "" opt :mbox, "Treat input files as mboxes", :default => false end else Trollop::die "unrecognized command #{cmd.inspect}" end class SupCmd < Redwood::Client def initialize cmd, args, opts @cmd = cmd @opts = opts @args = args super() end def get_query @args.first or fail "query argument required" end def connection_established case @cmd when "query" query get_query, @opts[:offset], @opts[:limit], @opts[:raw] do |result| if result puts YAML.dump(result['summary']) puts YAML.dump(result['raw']) if @opts[:raw] else close_connection end end when "count" count(get_query) do |x| puts x close_connection end when "label" label get_query, @opts[:remove_labels].split(','), @opts[:add_labels].split(',') do close_connection end when "add" ARGF.binmode labels = @opts[:labels].split(',') get_message = lambda do return ARGF.gets(nil) unless @opts[:mbox] str = "" l = ARGF.gets str << l until ARGF.closed? || ARGF.eof? || MBox::is_break_line?(l = ARGF.gets) str.empty? ? nil : str end i_s = i = 0 t = Time.now while raw = get_message[] i += 1 t_d = Time.now - t if t_d >= 5 i_d = i - i_s puts "indexed #{i} messages (#{i_d/t_d} m/s)" if global_opts[:verbose] t = Time.now i_s = i end add raw, labels do close_connection end end else fail "#{@cmd} command unimplemented" close_connection end end def unbind EM.stop end end EM.run do if global_opts[:socket] EM.connect global_opts[:socket], SupCmd, cmd, ARGV, cmd_opts.merge(global_opts) else EM.connect global_opts[:host], global_opts[:port], SupCmd, cmd, ARGV, cmd_opts.merge(global_opts) end end exit 0 sup-mainline/bin/sup-config000077500000000000000000000144741166154264000162460ustar00rootroot00000000000000#!/usr/bin/env ruby require 'rubygems' require 'highline/import' require 'yaml' require 'trollop' require "sup" $opts = Trollop::options do version "sup-config (sup #{Redwood::VERSION})" banner < "mbox", :path => fn }] when :maildir $last_fn ||= ENV["MAIL"] fn = axe "What's the full path to the maildir directory?", $last_fn return if fn.nil? || fn.empty? $last_fn = fn [Redwood::Maildir.suggest_labels_for(fn), { :scheme => "maildir", :path => fn }] end uri = begin URI::Generic.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 end say "I'm going to add this source: #{uri}" unless axe("Does that look right?", "y") =~ /^y|yes$/i if axe_yes("Try again?") then next else return end end usual = axe_yes "Does this source ever receive new messages?", "y" archive = usual ? axe_yes("Should new messages be automatically archived? (I.e. not appear in your inbox, though still be accessible via search.)") : false labels_str = axe("Enter any labels to be automatically added to all messages from this source, separated by spaces (or 'none')", default_labels.join(",")) labels = if labels_str =~ /^\s*none\s*$/i nil else labels_str.split(/\s+/) end cmd = build_cmd "sup-add" cmd += " --unusual" unless usual cmd += " --archive" if archive cmd += " --labels=#{labels.join(',')}" if labels && !labels.empty? cmd += " #{uri}" puts "Ok, trying to run \"#{cmd}\"..." system cmd if $?.success? say "Great! Added!" break else say "Rats, that failed. You may have to do it manually." if axe_yes("Try again?") then next else return end end end end $terminal.wrap_at = :auto Redwood::start index = Redwood::Index.init Redwood::SourceManager.load_sources say <" say "\nDo you have any alternate email addresses that also receive email?" say "If so, enter them now, separated by spaces." alts = axe("Alternate email addresses", account[:alternates].join(" ")).split(/\s+/) sigfn = axe "What file contains your signature?", account[:signature] editor = axe "What editor would you like to use?", $config[:editor] $config[:accounts][:default][:name] = name $config[:accounts][:default][:email] = email $config[:accounts][:default][:alternates] = alts $config[:accounts][:default][:signature] = sigfn $config[:editor] = editor done = false until done say "\nNow, we'll tell Sup where to find all your email." Redwood::SourceManager.load_sources say "Current sources:" if Redwood::SourceManager.sources.empty? say " No sources!" else Redwood::SourceManager.sources.each { |s| puts "* #{s}" } end say "\n" choose do |menu| menu.prompt = "Your wish? " menu.choice("Add a new source.") { add_source } menu.choice("Done adding sources!") { done = true } end end say "\nSup needs to know where to store your sent messages." say "Only sources capable of storing mail will be listed.\n\n" Redwood::SourceManager.load_sources if Redwood::SourceManager.sources.empty? say "\nUsing the default sup://sent, since you haven't configured other sources yet." $config[:sent_source] = 'sup://sent' else # this handles the event that source.yaml already contains the SentLoader # source. have_sup_sent = false choose do |menu| menu.prompt = "Store my sent mail in? " menu.choice('Default (an mbox in ~/.sup, aka sup://sent)') { $config[:sent_source] = 'sup://sent'} unless have_sup_sent valid_sents = Redwood::SourceManager.sources.each do |s| have_sup_sent = true if s.to_s.eql?('sup://sent') menu.choice(s.to_s) { $config[:sent_source] = s.to_s } if s.respond_to? :store_message end end end Redwood::save_yaml_obj $config, Redwood::CONFIG_FN, false, true say "Ok, I've saved you up a nice lil' #{Redwood::CONFIG_FN}." say < to recover the index. This tool is primarily useful in the event that a Sup upgrade breaks index format compatibility. Usage: sup-dump > sup-dump | bzip2 > # even better EOS end xapian = Xapian::Database.new File.join(BASE_DIR, 'xapian') version = xapian.get_metadata 'rescue-version' version = '0' if version.empty? case version when '0' xapian.postlist('Kmail').each do |x| begin entry = Marshal.load(xapian.document(x.docid).data) puts "#{entry[:message_id]} (#{entry[:labels].sort_by { |l| l.to_s } * ' '})" rescue $stderr.puts "failed to dump document #{x.docid}" end end else abort "this sup-dump version doesn't understand your index" end sup-mainline/bin/sup-import-dump000077500000000000000000000056411166154264000172520ustar00rootroot00000000000000#!/usr/bin/env ruby require 'uri' require 'rubygems' require 'trollop' require "sup"; Redwood::check_library_version_against "git" PROGRESS_UPDATE_INTERVAL = 15 # seconds class AbortExecution < SystemExit end opts = Trollop::options do version "sup-import-dump (sup #{Redwood::VERSION})" banner < instead. Messages not mentioned in the dump file will not be modified. Usage: sup-import-dump [options] Options: EOS opt :verbose, "Print message ids as they're processed." opt :ignore_missing, "Silently skip over messages that are not in the index." opt :warn_missing, "Warn about messages that are not in the index, but continue." opt :abort_missing, "Abort on encountering messages that are not in the index. (default)" opt :atomic, "Use transaction to apply all changes atomically." opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n" opt :version, "Show version information", :short => :none conflicts :ignore_missing, :warn_missing, :abort_missing end Trollop::die "No dump file given" if ARGV.empty? Trollop::die "Extra arguments given" if ARGV.length > 1 dump_name = ARGV.shift missing_action = [:ignore_missing, :warn_missing, :abort_missing].find { |x| opts[x] } || :abort_missing Redwood::start index = Redwood::Index.init index.lock_interactively or exit begin num_read = 0 num_changed = 0 index.load index.begin_transaction if opts[:atomic] IO.foreach dump_name do |l| l =~ /^(\S+) \((.*?)\)$/ or raise "Can't read dump line: #{l.inspect}" mid, labels = $1, $2 num_read += 1 unless index.contains_id? mid if missing_action == :abort_missing $stderr.puts "Message #{mid} not found in index, aborting." raise AbortExecution, 10 elsif missing_action == :warn_missing $stderr.puts "Message #{mid} not found in index, skipping." end next end m = index.build_message mid new_labels = labels.to_set_of_symbols if m.labels == new_labels puts "#{mid} unchanged" if opts[:verbose] next end puts "Changing flags for #{mid} from '#{m.labels.to_a * ' '}' to '#{new_labels.to_a * ' '}'" if opts[:verbose] num_changed += 1 next if opts[:dry_run] m.labels = new_labels index.update_message_state m end index.commit_transaction if opts[:atomic] puts "Updated #{num_changed} of #{num_read} messages." rescue AbortExecution index.cancel_transaction if opts[:atomic] raise rescue Exception => e index.cancel_transaction if opts[:atomic] File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace } raise ensure index.save_index unless opts[:atomic] Redwood::finish index.unlock end sup-mainline/bin/sup-recover-sources000077500000000000000000000050661166154264000201240ustar00rootroot00000000000000#!/usr/bin/env ruby require 'optparse' $opts = { :unusual => false, :archive => false, :scan_num => 10, } OPTIONPARSERSUCKS = "\n" + " " * 38 OptionParser.new do |opts| opts.banner = <+ Rebuilds a lost sources.yaml file by reading messages from a list of sources and determining, for each source, the most prevalent 'source_id' field of messages from that source in the index. The only non-deterministic component to this is that if the same message appears in multiple sources, those sources may be mis-diagnosed by this program. If the first N messages (--scan-num below) all have the same source_id in the index, the source will be added to sources.yaml. Otherwise, the distribution will be printed, and you will have to add it by hand. The offset pointer into the sources will be set to the end of the source, so you will have to run sup-import --rebuild for each new source after doing this. Options include: EOS opts.on("--unusual", "Mark sources as 'unusual'. Only usual#{OPTIONPARSERSUCKS}sources will be polled by hand. Default:#{OPTIONPARSERSUCKS}#{$opts[:unusual]}.") { $opts[:unusual] = true } opts.on("--archive", "Mark sources as 'archive'. New messages#{OPTIONPARSERSUCKS}from these sources will not appear in#{OPTIONPARSERSUCKS}the inbox. Default: #{$opts[:archive]}.") { $opts[:archive] = true } opts.on("--scan-num N", Integer, "Number of messages to scan per source.#{OPTIONPARSERSUCKS}Default: #{$opts[:scan_num]}.") do |n| $opts[:scan_num] = n end opts.on_tail("-h", "--help", "Show this message") do puts opts exit end end.parse(ARGV) require "sup" Redwood::start puts "loading index..." index = Redwood::Index.init index.load puts "loaded index of #{index.size} messages" ARGV.each do |fn| next if Redwood::SourceManager.source_for fn ## TODO: merge this code with the same snippet in import source = Redwood::MBox.new(fn, nil, !$opts[:unusual], $opts[:archive]) source_ids = Hash.new 0 count = 0 source.each do |offset, labels| m = Redwood::Message.new :source => source, :source_info => offset m.load_from_source! source_id = Redwood::SourceManager.source_for_id m.id next unless source_id source_ids[source_id] += 1 count += 1 break if count == $opts[:scan_num] end if source_ids.size == 1 id = source_ids.keys.first.to_i puts "assigned #{source} to #{source_ids.keys.first}" source.id = id Redwood::SourceManager.add_source source else puts ">> unable to determine #{source}: #{source_ids.inspect}" end end index.save sup-mainline/bin/sup-server000077500000000000000000000016301166154264000162750ustar00rootroot00000000000000#!/usr/bin/env ruby require 'rubygems' require 'trollop' require 'sup' require 'sup/server' require 'pp' require 'yaml' include Redwood global_opts = Trollop::options do #version = "sup-cmd (sup #{Redwood::VERSION})" banner < :string, :default => 'localhost', :short => 'o' opt :port, "port to listen on", :type => :int, :default => 4300 opt :verbose end Redwood.start Index.init Index.lock_interactively or exit begin if(s = Redwood::SourceManager.source_for SentManager.source_uri) SentManager.source = s else Redwood::SourceManager.add_source SentManager.default_source end Index.load EM.run do EM.start_server global_opts[:host], global_opts[:port], Redwood::Server, Index.instance EM.next_tick { puts "ready" } end ensure Index.unlock end sup-mainline/bin/sup-sync000077500000000000000000000156041166154264000157510ustar00rootroot00000000000000#!/usr/bin/env ruby require 'uri' require 'rubygems' require 'trollop' require "sup"; Redwood::check_library_version_against "git" PROGRESS_UPDATE_INTERVAL = 15 # seconds class Float def to_s; sprintf '%.2f', self; end def to_time_s; infinite? ? "unknown" : super end end class Numeric def to_time_s i = to_i sprintf "%d:%02d:%02d", i / 3600, (i / 60) % 60, i % 60 end end class Set def to_s; to_a * ',' end end def time startt = Time.now yield Time.now - startt end opts = Trollop::options do version "sup-sync (sup #{Redwood::VERSION})" banner <* where * is zero or more source URIs. If no sources are given, sync from all usual sources. Supported source URI schemes can be seen by running "sup-add --help". Options controlling HOW message state is altered: EOS opt :asis, "If the message is already in the index, preserve its state. Otherwise, use default source state. (Default.)", :short => :none opt :restore, "Restore message state from a dump file created with sup-dump. If a message is not in this dumpfile, act as --asis.", :type => String, :short => :none opt :discard, "Discard any message state in the index and use the default source state. Dangerous!", :short => :none opt :archive, "When using the default source state, mark messages as archived.", :short => "-x" opt :read, "When using the default source state, mark messages as read." opt :extra_labels, "When using the default source state, also apply these user-defined labels (a comma-separated list)", :default => "", :short => :none text < :none opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n" opt :version, "Show version information", :short => :none conflicts :asis, :restore, :discard end op = [:asis, :restore, :discard].find { |x| opts[x] } || :asis Redwood::start index = Redwood::Index.init restored_state = if opts[:restore] dump = {} puts "Loading state dump from #{opts[:restore]}..." IO.foreach opts[:restore] do |l| l =~ /^(\S+) \((.*?)\)$/ or raise "Can't read dump line: #{l.inspect}" mid, labels = $1, $2 dump[mid] = labels.to_set_of_symbols end puts "Read #{dump.size} entries from dump file." dump else {} end seen = {} index.lock_interactively or exit begin index.load if(s = Redwood::SourceManager.source_for Redwood::SentManager.source_uri) Redwood::SentManager.source = s else Redwood::SourceManager.add_source Redwood::SentManager.default_source end sources = if opts[:all_sources] Redwood::SourceManager.sources elsif ARGV.empty? Redwood::SourceManager.usual_sources else ARGV.map do |uri| Redwood::SourceManager.source_for uri or Trollop::die "Unknown source: #{uri}. Did you add it with sup-add first?" end end sources.each do |source| puts "Scanning #{source}..." num_added = num_updated = num_deleted = num_scanned = num_restored = 0 last_info_time = start_time = Time.now Redwood::PollManager.poll_from source do |action,m,old_m,progress| num_scanned += 1 if action == :delete num_deleted += 1 puts "Deleting #{m.id}" if opts[:verbose] elsif action == :add seen[m.id] = true ## tweak source labels according to commandline arguments if necessary m.labels.delete :inbox if opts[:archive] m.labels.delete :unread if opts[:read] m.labels += opts[:extra_labels].to_set_of_symbols(",") ## decide what to do based on message labels and the operation we're performing dothis = case when (op == :restore) && restored_state[m.id] if old_m && (old_m.labels != restored_state[m.id]) num_restored += 1 m.labels = restored_state[m.id] :update_message_state elsif old_m.nil? num_restored += 1 m.labels = restored_state[m.id] :add_message else # labels are the same; don't do anything end when op == :discard if old_m && (old_m.labels != m.labels) :update_message_state else # labels are the same; don't do anything end else if old_m :update_message else :add_message end end ## now, actually do the operation case dothis when :add_message puts "Adding new message #{source}##{m.source_info} with labels #{m.labels}" if opts[:verbose] num_added += 1 when :update_message puts "Updating message #{source}##{m.source_info}; labels #{old_m.labels} => #{m.labels}; offset #{old_m.source_info} => #{m.source_info}" if opts[:verbose] num_updated += 1 when :update_message_state puts "Changing flags for #{source}##{m.source_info} from #{old_m.labels} to #{m.labels}" if opts[:verbose] num_updated += 1 end else fail end if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL last_info_time = Time.now elapsed = last_info_time - start_time pctdone = progress * 100.0 remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone) printf "## scanned %dm (~%.0f%%) @ %.1fm/s. %s elapsed, ~%s remaining\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s end next if opts[:dry_run] end puts "Scanned #{num_scanned}, added #{num_added}, updated #{num_updated}, deleted #{num_deleted} messages from #{source}." puts "Restored state on #{num_restored} (#{100.0 * num_restored / num_scanned}%) messages." if num_restored > 0 end index.save if opts[:optimize] puts "Optimizing index..." optt = time { index.optimize unless opts[:dry_run] } puts "Optimized index of size #{index.size} in #{optt}s." end rescue Redwood::FatalSourceError => e $stderr.puts "Sorry, I couldn't communicate with a source: #{e.message}" rescue Exception => e File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace } raise ensure Redwood::finish index.unlock end sup-mainline/bin/sup-sync-back000077500000000000000000000136671166154264000166560ustar00rootroot00000000000000#!/usr/bin/env ruby require 'rubygems' require 'uri' require 'tempfile' require 'trollop' require "sup"; Redwood::check_library_version_against "git" fail "not working yet" ## save a message 'm' to an open file pointer 'fp' def save m, fp m.source.each_raw_message_line(m.source_info) { |l| fp.print l } end def die msg $stderr.puts "Error: #{msg}" exit(-1) end def has_any_from_source_with_label? index, source, label query = { :source_id => source.id, :label => label, :limit => 1, :load_spam => true, :load_deleted => true, :load_killed => true } index.num_results_for(query) != 0 end opts = Trollop::options do version "sup-sync-back (sup #{Redwood::VERSION})" banner <* where * is zero or more source URIs. If no sources are given, sync back all usual sources. You almost certainly want to run sup-sync --changed after this command. Running this does not change the index. Options include: EOS opt :drop_deleted, "Drop deleted messages.", :default => false, :short => "d" opt :move_deleted, "Move deleted messages to a local mbox file.", :type => String, :short => :none opt :drop_spam, "Drop spam messages.", :default => false, :short => "s" opt :move_spam, "Move spam messages to a local mbox file.", :type => String, :short => :none opt :with_dotlockfile, "Specific dotlockfile location (mbox files only).", :default => "/usr/bin/dotlockfile", :short => :none opt :dont_use_dotlockfile, "Don't use dotlockfile to lock mbox files. Dangerous if other processes modify them concurrently.", :default => false, :short => :none opt :verbose, "Print message ids as they're processed." opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n" opt :version, "Show version information", :short => :none conflicts :drop_deleted, :move_deleted conflicts :drop_spam, :move_spam end unless opts[:drop_deleted] || opts[:move_deleted] || opts[:drop_spam] || opts[:move_spam] puts < 0 || num_moved > 0 out_fp.close unless opts[:dry_run] unless opts[:dry_run] || (num_dropped == 0 && num_moved == 0) deleted_fp.flush if deleted_fp spam_fp.flush if spam_fp unless opts[:dont_use_dotlockfile] puts "Locking #{source.file_path}..." system "#{opts[:dotlockfile]} -l #{source.file_path}" puts "Writing #{source.file_path}..." FileUtils.cp out_fp.path, source.file_path puts "Unlocking #{source.file_path}..." system "#{opts[:dotlockfile]} -u #{source.file_path}" end end end unless opts[:dry_run] deleted_fp.close if deleted_fp spam_fp.close if spam_fp end $stderr.puts "Done." unless modified_sources.empty? $stderr.puts "You should now run: sup-sync --changed #{modified_sources.join(' ')}" end rescue Exception => e File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace } raise ensure Redwood::finish index.unlock end sup-mainline/bin/sup-tweak-labels000077500000000000000000000100261166154264000173410ustar00rootroot00000000000000#!/usr/bin/env ruby require 'rubygems' require 'trollop' require "sup"; Redwood::check_library_version_against "git" class Float def to_s; sprintf '%.2f', self; end def to_time_s infinite? ? "unknown" : super end end class Numeric def to_time_s i = to_i sprintf "%d:%02d:%02d", i / 3600, (i / 60) % 60, i % 60 end end def time startt = Time.now yield Time.now - startt end opts = Trollop::options do version "sup-tweak-labels (sup #{Redwood::VERSION})" banner <* where * is zero or more source URIs. Supported source URI schemes can be seen by running "sup-add --help". Options: EOS opt :add, "One or more labels (comma-separated) to add to every message from the specified sources", :default => "" opt :remove, "One or more labels (comma-separated) to remove from every message from the specified sources, if those labels are present", :default => "" opt :query, "A Sup search query", :type => String text < :none opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n" opt :version, "Show version information", :short => :none end opts[:verbose] = true if opts[:very_verbose] add_labels = opts[:add].to_set_of_symbols "," remove_labels = opts[:remove].to_set_of_symbols "," Trollop::die "nothing to do: no labels to add or remove" if add_labels.empty? && remove_labels.empty? Redwood::start index = Redwood::Index.init index.lock_interactively or exit begin index.load source_ids = if opts[:all_sources] Redwood::SourceManager.sources else ARGV.map do |uri| Redwood::SourceManager.source_for uri or Trollop::die "Unknown source: #{uri}. Did you add it with sup-add first?" end end.map { |s| s.id } Trollop::die "nothing to do: no sources" if source_ids.empty? query = "(" + source_ids.map { |id| "source_id:#{id}" }.join(" OR ") + ")" if add_labels.empty? ## if all we're doing is removing labels, we can further restrict the ## query to only messages with those labels query += " (" + remove_labels.map { |l| "label:#{l}" }.join(" OR ") + ")" end query += ' ' + opts[:query] if opts[:query] parsed_query = index.parse_query query parsed_query.merge! :load_spam => true, :load_deleted => true, :load_killed => true ids = Enumerator.new(index, :each_id, parsed_query) num_total = index.num_results_for parsed_query $stderr.puts "Found #{num_total} documents across #{source_ids.length} sources. Scanning..." num_changed = num_scanned = 0 last_info_time = start_time = Time.now ids.each do |id| num_scanned += 1 m = index.build_message id old_labels = m.labels.dup m.labels += add_labels m.labels -= remove_labels unless m.labels == old_labels num_changed += 1 puts "From #{m.from}, subject: #{m.subj}" if opts[:very_verbose] puts "#{m.id}: {#{old_labels.to_a.join ','}} => {#{m.labels.to_a.join ','}}" if opts[:verbose] puts if opts[:very_verbose] index.update_message_state m unless opts[:dry_run] end if Time.now - last_info_time > 60 last_info_time = Time.now elapsed = last_info_time - start_time pctdone = 100.0 * num_scanned.to_f / num_total.to_f remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone) $stderr.puts "## #{num_scanned} (#{pctdone}%) read; #{elapsed.to_time_s} elapsed; #{remaining.to_time_s} remaining" end end $stderr.puts "Scanned #{num_scanned} / #{num_total} messages and changed #{num_changed}." unless num_changed == 0 $stderr.puts "Optimizing index..." index.optimize unless opts[:dry_run] end rescue Exception => e File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace } raise ensure index.save Redwood::finish index.unlock end sup-mainline/bugs/000077500000000000000000000000001166154264000144245ustar00rootroot00000000000000sup-mainline/bugs/issue-0240b36671ecb019e57ef27e0901bff055385371.yaml000066400000000000000000000012231166154264000235650ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: messages with unparseable date headers are being discarded entirely desc: it's better to forge the date headers and keep the messages type: :bugfix component: sup release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 03:57:25.399978 Z references: [] id: 0240b36671ecb019e57ef27e0901bff055385371 log_events: - - 2008-03-07 03:57:25.400014 Z - William Morgan - created - "" - - 2008-03-07 03:57:29.249827 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-08d6bae05fa885bf6fcae39f864eb923c1e9a79e.yaml000066400000000000000000000014701166154264000244300ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: reply-from hook desc: "hook for setting the from: address of a reply programmatically" type: :feature component: hooks release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-06-19 17:58:26.142289 Z references: [] id: 08d6bae05fa885bf6fcae39f864eb923c1e9a79e log_events: - - 2008-06-19 17:58:27.334371 Z - William Morgan - created - "" - - 2008-06-19 17:58:40.526270 Z - William Morgan - changed status from unstarted to in_progress - branch reply-from-hook, merged into next - - 2008-07-30 23:41:56.898257 Z - William Morgan - closed with disposition fixed - merged into master git_branch: reply-from-hook sup-mainline/bugs/issue-09479a2ada22c2a0d76427e12ef2514d4753d070.yaml000066400000000000000000000007121166154264000236340ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: forwarded messages should be threaded under original desc: "" type: :feature component: threading release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-06-07 23:45:11.606455 Z references: [] id: 09479a2ada22c2a0d76427e12ef2514d4753d070 log_events: - - 2008-06-07 23:45:12.746568 Z - William Morgan - created - "" sup-mainline/bugs/issue-15738247f939d20f8f202f80ccb85d9ad92101e0.yaml000066400000000000000000000007531166154264000236670ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: command to reload hooks desc: useful for debugging hooks without having to restart sup each time type: :feature component: hooks release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-09 06:09:46.127686 Z references: [] id: 15738247f939d20f8f202f80ccb85d9ad92101e0 log_events: - - 2008-05-09 06:09:46.803222 Z - William Morgan - created - "" sup-mainline/bugs/issue-182841e15d6909892adf43678bae03597ce10519.yaml000066400000000000000000000013611166154264000235370ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: external mime viewing logic not quite right desc: |- weird things happen depending on the specifics on whether you have certain binaries in your path, or you implement the mime-view hook, you can get very weird behavior type: :bugfix component: hooks release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 04:05:53.956188 Z references: [] id: 182841e15d6909892adf43678bae03597ce10519 log_events: - - 2008-03-07 04:05:53.956222 Z - William Morgan - created - "" - - 2008-03-07 04:06:02.239869 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-1a1527438c2d198eae9a264ce9e6b847854d9837.yaml000066400000000000000000000016751166154264000237230ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: attachment name searchability desc: "" type: :feature component: indexing release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-25 03:53:44.524558 Z references: [] id: 1a1527438c2d198eae9a264ce9e6b847854d9837 log_events: - - 2008-05-25 03:53:45.177580 Z - William Morgan - created - "" - - 2008-05-25 03:54:05.978412 Z - William Morgan - changed status from unstarted to in_progress - branch attachments, merged into next - - 2008-05-31 17:09:04.254381 Z - William Morgan - commented - see {issue 65506670167642cc581956bc1b25c26b5bff215b} and {issue 7a68c1e7120a8540c7c51c6095f4815918d16641} - - 2008-06-19 18:20:41.656527 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-2312263b6a2b7de6ae1ec4ab315c7829763e61be.yaml000066400000000000000000000012361166154264000240640ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: sup-sync-back "nothing to do" error message not informative desc: should tell the user that one of the four magic options are required type: :bugfix component: sup-sync-back release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 03:37:41.693484 Z references: [] id: 2312263b6a2b7de6ae1ec4ab315c7829763e61be log_events: - - 2008-03-07 03:37:41.693520 Z - William Morgan - created - "" - - 2008-03-07 03:37:46.110644 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-23658477a445c2e61405fecb4cb641a2298caba6.yaml000066400000000000000000000015131166154264000240060ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: wide character ncurses support desc: "" type: :feature component: curses release: reporter: William Morgan status: :in_progress disposition: creation_time: 2008-04-22 22:43:23.153185 Z references: [] id: 23658477a445c2e61405fecb4cb641a2298caba6 log_events: - - 2008-04-22 22:43:24.808717 Z - William Morgan - created - "" - - 2008-04-22 22:45:52.511820 Z - William Morgan - changed status from unstarted to in_progress - |- Branch 'ncurses-widechar' has been merged into next. Branch "ncursesw" now has a copy of the ncurses 0.9.2 gem with wide character modifications, and a script "run-this-for-sup.sh" to build and install it (assuming you're running from git, of course.) sup-mainline/bugs/issue-2673f091c15dd90222a59621a1842d4ef0a743f7.yaml000066400000000000000000000007161166154264000235720ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: make sup-sync-back work on IMAP folders desc: "" type: :feature component: sup-sync-back release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-10-14 01:14:08.690909 Z references: [] id: 2673f091c15dd90222a59621a1842d4ef0a743f7 log_events: - - 2008-10-14 01:14:09.898338 Z - William Morgan - created - "" git_branch: sup-mainline/bugs/issue-2a0363cdf9d25edfa2a04b21299a538365e8b319.yaml000066400000000000000000000014761166154264000240200ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: gpg mode hook desc: |- need a hook for controlling the default setting of the gpg mode (none, sign, sign & encrypt) in reply-mode, based on the gpg mode of the original message. type: :feature component: hooks release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-03-07 02:42:29.391022 Z references: [] id: 2a0363cdf9d25edfa2a04b21299a538365e8b319 log_events: - - 2008-03-07 02:42:29.391058 Z - William Morgan - created - "" - - 2008-04-20 21:18:14.263736 Z - William Morgan - assigned to release 0.6 from 0.5 - "" - - 2008-07-31 00:54:02.960978 Z - William Morgan - unassigned from release 0.6 - "" git_branch: sup-mainline/bugs/issue-2e74aa6843feee4daefe740b6e3f1fc54ff4bfcb.yaml000066400000000000000000000011161166154264000247040ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: oldest-first thread ordering desc: "" type: :feature component: indexing release: reporter: Matt Liggett status: :unstarted disposition: creation_time: 2008-03-14 18:33:01.603318 Z references: - http://rubyforge.org/pipermail/sup-talk/2008-March/001271.html id: 2e74aa6843feee4daefe740b6e3f1fc54ff4bfcb log_events: - - 2008-03-14 18:33:01.603569 Z - William Morgan - created - "" - - 2008-03-14 18:33:31.116057 Z - William Morgan - added reference 1 - "" sup-mainline/bugs/issue-314f0cdac8d1998c46759a4ebef9077999bcef09.yaml000066400000000000000000000007061166154264000242240ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: new user welcome screen the first time you start up desc: "" type: :feature component: curses release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-03-07 02:44:24.399133 Z references: [] id: 314f0cdac8d1998c46759a4ebef9077999bcef09 log_events: - - 2008-03-07 02:44:24.399167 Z - William Morgan - created - "" sup-mainline/bugs/issue-3408c200a5f47f92d12b5c063a00ce891c2ba4ce.yaml000066400000000000000000000011211166154264000240350ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: "email header parsing: space doesn't need to follow colon" desc: "" type: :bugfix component: sup release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 04:31:55.733379 Z references: [] id: 3408c200a5f47f92d12b5c063a00ce891c2ba4ce log_events: - - 2008-03-07 04:31:55.733416 Z - William Morgan - created - "" - - 2008-03-07 04:31:59.580856 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-3441fb8b7f955d625633d06fa0bf67a9afab046e.yaml000066400000000000000000000006761166154264000241720ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: saving a message as a draft drops attachments desc: "" type: :bugfix component: sup release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-25 02:14:13.362087 Z references: [] id: 3441fb8b7f955d625633d06fa0bf67a9afab046e log_events: - - 2008-05-25 02:14:14.224040 Z - William Morgan - created - "" sup-mainline/bugs/issue-38d6f805b0c8bad013ec73f56e6245c890528591.yaml000066400000000000000000000016421166154264000236730ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: "'m' in edit-message-mode should prompt for a to: with a default" desc: |- the current behavior is to just go ahead and compose the message, which is irritating if you're just trying to compose a message incidental to having highlighted someone's email address. type: :bugfix component: curses release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-03-07 02:37:38.905689 Z references: [] id: 38d6f805b0c8bad013ec73f56e6245c890528591 log_events: - - 2008-03-07 02:37:38.905723 Z - William Morgan - created - "" - - 2008-04-20 21:44:06.473431 Z - William Morgan - assigned to release 0.6 from 0.5 - "" - - 2008-05-25 03:47:42.600153 Z - William Morgan - closed issue with disposition fixed - fixed in master sup-mainline/bugs/issue-3b25f1d56b9be533edaf232b9e60dc24e00cba0b.yaml000066400000000000000000000014501166154264000243430ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: maildir speedups desc: caching mtimes, using dir mtimes as an upper bound on file mtimes type: :feature component: maildir release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-25 02:30:42.010965 Z references: [] id: 3b25f1d56b9be533edaf232b9e60dc24e00cba0b log_events: - - 2008-05-25 02:30:42.815974 Z - William Morgan - created - "" - - 2008-05-25 02:30:59.062438 Z - William Morgan - changed status from unstarted to in_progress - branch maildir-speedups. merged into next. - - 2008-06-19 18:09:27.239553 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-42ab0840f9a1924f1c0561e8ddcf7e6988543ba0.yaml000066400000000000000000000013451166154264000240230ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: query normalization breaking disjunctive queries, date modifers, etc desc: "" type: :bugfix component: indexing release: "0.5" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-03-07 03:39:26.683059 Z references: [] id: 42ab0840f9a1924f1c0561e8ddcf7e6988543ba0 log_events: - - 2008-03-07 03:39:26.683093 Z - William Morgan - created - "" - - 2008-03-07 03:50:16.796313 Z - William Morgan - changed status from unstarted to in_progress - "" - - 2008-04-20 22:10:39.075075 Z - William Morgan - closed issue with disposition fixed - "" sup-mainline/bugs/issue-46df983ccdb75408a37b3911472d4015664a3cf6.yaml000066400000000000000000000010631166154264000236620ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: curses interface generally sluggish desc: |- moving cursors around, etc is sluggish. should be faster. will require profiling. might require dipping into the C level. type: :bugfix component: curses release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-19 23:38:35.608104 Z references: [] id: 46df983ccdb75408a37b3911472d4015664a3cf6 log_events: - - 2008-05-19 23:38:36.229747 Z - William Morgan - created - "" sup-mainline/bugs/issue-47aab6443b6c107c3067cdb614186099db570acf.yaml000066400000000000000000000022121166154264000240010ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: before-add-hook not applied to sent messages desc: |- they're not being polled in the regular way but in a vestigal irregular way type: :bugfix component: sup release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 03:54:10.773413 Z references: - http://rubyforge.org/pipermail/sup-talk/2008-March/001259.html - http://rubyforge.org/pipermail/sup-talk/2008-February/001203.html id: 47aab6443b6c107c3067cdb614186099db570acf log_events: - - 2008-03-07 03:54:10.773449 Z - William Morgan - created - "" - - 2008-03-07 03:54:15.699906 Z - William Morgan - changed status from unstarted to in_progress - "" - - 2008-03-08 22:02:45.840451 Z - William Morgan - changed status from in_progress to fixed - merged down to master. - - 2008-03-08 22:16:00.346710 Z - William Morgan - added reference 1 - "" - - 2008-03-08 22:22:02.528552 Z - William Morgan - added reference 2 - "" sup-mainline/bugs/issue-4af242013994ae557e431ba350a92c4f9e1739ef.yaml000066400000000000000000000015341166154264000237420ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: sup-sync shouldn't save the index and sources if an error occurred desc: |- If the error was caused by a particular message, saving the source file will move the pointer past the message, so it will never get added. type: :bugfix component: sup-sync release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 04:14:07.913103 Z references: [] id: 4af242013994ae557e431ba350a92c4f9e1739ef log_events: - - 2008-03-07 04:14:07.913140 Z - William Morgan - created - "" - - 2008-03-07 04:14:12.599051 Z - William Morgan - changed status from unstarted to fixed - "" - - 2008-03-09 18:29:51.789364 Z - William Morgan - changed description - "" sup-mainline/bugs/issue-4daa2721dac8dfeb8730ee081f73b6c62702bd3e.yaml000066400000000000000000000012131166154264000243000ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: last message of every source is returned twice during polling desc: http://rubyforge.org/pipermail/sup-talk/2008-April/001358.html type: :bugfix component: sup release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-01 01:11:50.650800 Z references: [] id: 4daa2721dac8dfeb8730ee081f73b6c62702bd3e log_events: - - 2008-05-01 01:11:51.434589 Z - William Morgan - created - "" - - 2008-07-31 00:54:39.589377 Z - William Morgan - unassigned from release 0.6 - "" git_branch: sup-mainline/bugs/issue-4e501973cea5bd1f28739ae4cea98edce8249895.yaml000066400000000000000000000021031166154264000242050ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: thread joining is not preserved when reindexing desc: | the current thread joining just adds references to the index entries. that's fine but if the messages are reindexed, the references obviously won't be there. i think we need to add some separate blob of information somewhere that maintains these references, which sup-sync is aware of. if we're going down the bdb route for state preservation, that might be an obvious place to put this too, because it's essentially a hashtable keyed on message ids. type: :bugfix component: indexing release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-03-14 18:39:59.697902 Z references: - http://rubyforge.org/pipermail/sup-talk/2008-March/001270.html id: 4e501973cea5bd1f28739ae4cea98edce8249895 log_events: - - 2008-03-14 18:39:59.698163 Z - William Morgan - created - "" - - 2008-03-14 18:40:26.559418 Z - William Morgan - added reference 1 - "" sup-mainline/bugs/issue-5348fec2b1112250e241afc7467de29e5691d1be.yaml000066400000000000000000000011111166154264000240010ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: imap header caching desc: |- imap headers aren't cached at all. that would speed up the initial connection, at least for servers that didn't set uid_validity to the current time (blearf). type: :feature component: imap release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-03-07 04:26:31.474463 Z references: [] id: 5348fec2b1112250e241afc7467de29e5691d1be log_events: - - 2008-03-07 04:26:31.474497 Z - William Morgan - created - "" sup-mainline/bugs/issue-57668c69d0190d6e849309834d4ad1d215efa779.yaml000066400000000000000000000011431166154264000236300ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: make sup-sync-back work on Maildir folders desc: |- possibly we could abstract things entirely between mbox and maildir, but it might just be easiest to have a sup-sync-back-maildir or a big if statement. type: :feature component: sup-sync-back release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-06-12 19:24:51.772444 Z references: [] id: 57668c69d0190d6e849309834d4ad1d215efa779 log_events: - - 2008-06-12 19:24:53.668373 Z - William Morgan - created - "" sup-mainline/bugs/issue-5795c3c1b47e88f7261f57f31d33fe15ad08465d.yaml000066400000000000000000000011021166154264000237520ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: flat (gmail-style) version of thread-view-mode desc: |- make thread-view-mode show a flat, chronological list of messages instead of a tree, based on some configuration variable. type: :feature component: curses release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-25 02:38:06.098950 Z references: [] id: 5795c3c1b47e88f7261f57f31d33fe15ad08465d log_events: - - 2008-05-25 02:38:06.823848 Z - William Morgan - created - "" sup-mainline/bugs/issue-5fab957dcd16f1da8962fe5b1f3a58d970315deb.yaml000066400000000000000000000013111166154264000243250ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: extra-contact-addresses hook for lbdb (etc.) integration desc: |- add an extra-contact-addresses hook for inserting addresses into the tab-completion list for To:, Cc:, etc. entries. type: :feature component: hooks release: "0.5" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-04-20 20:43:06.972853 Z references: [] id: 5fab957dcd16f1da8962fe5b1f3a58d970315deb log_events: - - 2008-04-20 20:43:08.667355 Z - William Morgan - created - "" - - 2008-04-20 20:43:20.678566 Z - William Morgan - closed issue with disposition fixed - "" sup-mainline/bugs/issue-60d86dd32054533a6206f698033ec668af6a7574.yaml000066400000000000000000000015011166154264000235260ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: killed threads showing up in inbox-mode desc: this goddamn problem is recurring type: :bugfix component: indexing release: reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-04-25 19:28:51.369257 Z references: [] id: 60d86dd32054533a6206f698033ec668af6a7574 log_events: - - 2008-04-25 19:28:52.476687 Z - William Morgan - created - "" - - 2008-07-31 00:54:38.916308 Z - William Morgan - unassigned from release 0.6 - "" - - 2008-11-21 14:23:17.566852 Z - Nicolas Pouillard - closed with disposition fixed - |- Loading options was not given to load_thread_for_message in ThreadIndexMode.add_or_unhide. git_branch: sup-mainline/bugs/issue-61949ec83770b5d46f89eff21799968187012cce.yaml000066400000000000000000000011361166154264000236520ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: \127 should be handled like backspace (it's the 70's all over again) desc: "" type: :bugfix component: curses release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 04:29:46.043812 Z references: [] id: 61949ec83770b5d46f89eff21799968187012cce log_events: - - 2008-03-07 04:29:46.043850 Z - William Morgan - created - "" - - 2008-03-07 04:29:52.233706 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-65506670167642cc581956bc1b25c26b5bff215b.yaml000066400000000000000000000017431166154264000236000ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: attachment markers in thread-index-mode desc: show a little @ if the message has an attachment type: :feature component: curses release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-25 03:53:10.005404 Z references: [] id: 65506670167642cc581956bc1b25c26b5bff215b log_events: - - 2008-05-25 03:53:10.994290 Z - William Morgan - created - "" - - 2008-05-25 03:53:26.388023 Z - William Morgan - changed status from unstarted to in_progress - branch attachments, merged into next - - 2008-05-31 17:09:17.710631 Z - William Morgan - commented - see {issue 1a1527438c2d198eae9a264ce9e6b847854d9837} and {issue 7a68c1e7120a8540c7c51c6095f4815918d16641} - - 2008-06-19 18:20:33.912937 Z - William Morgan - closed issue with disposition fixed - "" sup-mainline/bugs/issue-658389418b5f0038cc3e6bc20fd3fd1566eb7111.yaml000066400000000000000000000012611166154264000237360ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: add a startup hook desc: "" type: :feature component: hooks release: "0.5" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-03-07 04:03:52.999956 Z references: [] id: 658389418b5f0038cc3e6bc20fd3fd1566eb7111 log_events: - - 2008-03-07 04:03:52.999992 Z - William Morgan - created - "" - - 2008-03-07 04:03:56.880347 Z - William Morgan - changed status from unstarted to in_progress - "" - - 2008-04-20 20:45:20.047253 Z - William Morgan - closed issue with disposition fixed - "" sup-mainline/bugs/issue-69f785cddcc6e09ef0a357151373b3aa923d5e3f.yaml000066400000000000000000000011471166154264000241710ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: escape filenames in call to run-mailcap desc: otherwise, filenames with spaces don't work type: :bugfix component: sup release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 04:10:43.771843 Z references: [] id: 69f785cddcc6e09ef0a357151373b3aa923d5e3f log_events: - - 2008-03-07 04:10:43.771878 Z - William Morgan - created - "" - - 2008-03-07 04:10:48.789189 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-6c053cca2eb05af486a2d09c6772fd5bd0cca444.yaml000066400000000000000000000010011166154264000242650ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: cache threading desc: |- thread information should be cached so that it doesn't have to be recomputed each time. type: :feature component: threading release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-19 23:39:19.716625 Z references: [] id: 6c053cca2eb05af486a2d09c6772fd5bd0cca444 log_events: - - 2008-05-19 23:39:20.190260 Z - William Morgan - created - "" sup-mainline/bugs/issue-6e0d634de74b2eb8297174ecd408b3810ba9351b.yaml000066400000000000000000000012571166154264000240200ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: imap connection sharing desc: | current behavior is a separate connection for each folder, which is kinda silly. potentially want to still keep a separate connection for polling, but that might be micro-optimization, especially given that the whole ruby imap library seems quite slow. type: :feature component: imap release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-03-07 04:25:12.351934 Z references: [] id: 6e0d634de74b2eb8297174ecd408b3810ba9351b log_events: - - 2008-03-07 04:25:12.351966 Z - William Morgan - created - "" sup-mainline/bugs/issue-6e7960514f66ee67da083bc7bb5632d5808fc607.yaml000066400000000000000000000016161166154264000237640ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: "'!!' will load all threads in current search" desc: |- can be dangerous, but sometimes you know there aren't a million and you just want them all loaded, e.g. to apply some mass tagging operation. with the cancel-search feature, can always be canceled if onerous. type: :feature component: sup release: "0.5" reporter: Marcus Williams status: :closed disposition: fixed creation_time: 2008-03-07 04:17:44.706909 Z references: [] id: 6e7960514f66ee67da083bc7bb5632d5808fc607 log_events: - - 2008-03-07 04:17:44.706948 Z - William Morgan - created - "" - - 2008-03-07 04:17:48.834972 Z - William Morgan - changed status from unstarted to fixed - "" - - 2008-03-11 06:52:15.604233 Z - William Morgan - changed description - "" sup-mainline/bugs/issue-7456c2d8fbd5de4dac651f6f4e9756f577497e01.yaml000066400000000000000000000014231166154264000241370ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: edit-as-new doesn't preserve replyto and references headers desc: "" type: :bugfix component: sup release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-31 17:07:05.126884 Z references: [] id: 7456c2d8fbd5de4dac651f6f4e9756f577497e01 log_events: - - 2008-05-31 17:07:07.008637 Z - William Morgan - created - "" - - 2008-05-31 17:07:22.611383 Z - William Morgan - changed status from unstarted to in_progress - in branch edit-as-new-fix, merged into next - - 2008-06-19 18:22:24.036557 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-76802330c4fdd091e8b1dd08dcc29ed432f003d4.yaml000066400000000000000000000014301166154264000240570ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: in-buffer search always shifts the screen, even when unnecessary desc: "" type: :bugfix component: curses release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-01 22:34:01.983057 Z references: [] id: 76802330c4fdd091e8b1dd08dcc29ed432f003d4 log_events: - - 2008-05-01 22:34:02.958758 Z - William Morgan - created - "" - - 2008-05-01 22:34:24.254778 Z - William Morgan - changed status from unstarted to in_progress - branch find-in-buffer-fix, merged into next - - 2008-05-25 04:19:45.168367 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-799771a6a435dcad66dc80e7e051d91d24d005b1.yaml000066400000000000000000000014341166154264000240150ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: IMAP server restart crashes sup desc: |- this very bizarre backtrace: --- SystemExit from thread: main closed stream /usr/lib/ruby/1.8/openssl/buffering.rb:237:in `select' ./lib/sup/buffer.rb:31:in `nonblocking_getch' bin/sup:227 wtf? There's no reason that nonblocking_getch would be calling the openssl stuff, and openssl's buffering.rb doesn't mention select at all. Weird. type: :bugfix component: imap release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-19 23:17:32.271870 Z references: [] id: 799771a6a435dcad66dc80e7e051d91d24d005b1 log_events: - - 2008-05-19 23:17:33.615525 Z - William Morgan - created - "" sup-mainline/bugs/issue-7a68c1e7120a8540c7c51c6095f4815918d16641.yaml000066400000000000000000000016521166154264000234460ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: attachment markers in thread-view-mode desc: |- i'd like to see them in thread-view-mode as well (c.f. {issue 65506670167642cc581956bc1b25c26b5bff215b} and {issue 1a1527438c2d198eae9a264ce9e6b847854d9837}) type: :feature component: curses release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-31 17:08:28.081944 Z references: [] id: 7a68c1e7120a8540c7c51c6095f4815918d16641 log_events: - - 2008-05-31 17:08:28.710190 Z - William Morgan - created - "" - - 2008-05-31 17:08:50.679595 Z - William Morgan - changed status from unstarted to in_progress - on branch attachments as well, remerged into next - - 2008-06-19 18:20:56.657318 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-7c77e757321c2639daea013824ad1a14099815b1.yaml000066400000000000000000000013411166154264000235650ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: in-buffer searches should move buffer horizontally when necessary desc: "" type: :feature component: curses release: "0.5" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-03-07 03:56:17.653639 Z references: [] id: 7c77e757321c2639daea013824ad1a14099815b1 log_events: - - 2008-03-07 03:56:17.653674 Z - William Morgan - created - "" - - 2008-03-07 03:56:21.870750 Z - William Morgan - changed status from unstarted to in_progress - "" - - 2008-04-20 20:44:07.668281 Z - William Morgan - closed issue with disposition fixed - "" sup-mainline/bugs/issue-7d8474dfeeefaa50151c3ce48bee6b686d36a216.yaml000066400000000000000000000015211166154264000243240ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: reply-to mode hook desc: |- need a hook for selecting the default setting of the reply-to horizontal selector type: :feature component: hooks release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-06-04 03:04:10.253690 Z references: [] id: 7d8474dfeeefaa50151c3ce48bee6b686d36a216 log_events: - - 2008-06-04 03:04:10.945071 Z - William Morgan - created - "" - - 2008-06-04 03:04:21.284329 Z - William Morgan - changed status from unstarted to in_progress - branch 'reply-to-hook', merged into next - - 2008-07-30 23:41:50.393799 Z - William Morgan - closed with disposition fixed - merged into master git_branch: reply-to-hook sup-mainline/bugs/issue-829b449c51fca9a39047d00fabc552cc110c69b2.yaml000066400000000000000000000012721166154264000240630ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: don't crash when people.txt is corrupted desc: |- This was a debug check, but if Sup crashes when writing out people.txt, this will prevent it from ever starting again! type: :bugfix component: sup release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 04:07:56.208521 Z references: [] id: 829b449c51fca9a39047d00fabc552cc110c69b2 log_events: - - 2008-03-07 04:07:56.208558 Z - William Morgan - created - "" - - 2008-03-07 04:21:04.553157 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-82c80f6dc2ce7b10b9e8f503d68253ced0ee8a1b.yaml000066400000000000000000000014531166154264000243200ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: gpg generates invalid signature when :edit_signature is on desc: |- when :edit_signature is on and there's a signature, gpg signatures are invalid, ccording to mutt and other clients. sup itself thinks they're fine. type: :bugfix component: crypto release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-19 21:19:23.267668 Z references: [] id: 82c80f6dc2ce7b10b9e8f503d68253ced0ee8a1b log_events: - - 2008-05-19 21:19:24.611826 Z - William Morgan - created - "" - - 2008-05-19 21:19:49.649415 Z - William Morgan - closed issue with disposition fixed - turns out it was a newline issue. patch directly applied to master. sup-mainline/bugs/issue-8a5cf9242ca60fa6c81091e425f734b4fb03e41a.yaml000066400000000000000000000041421166154264000240630ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: index speedup desc: | I've just merged in a changeset that makes Sup store message body content in the Ferret index. (They've always been indexed, but now they're stored as well.) This means that changing the labels on a message can be a copy operation of the previous Ferret document, rather than requiring downloading and parsing the original message to create a new Ferret document. So, this should have two effects: 1. The Ferret index size will expand by about 50%. Sorry. 2. Tweaking message labels should be much, much faster, since the message no longer has to be downloaded from the source in order to change the labels. If you've ever tried to label a large IMAP thread, you no longer have to wait 5 minutes just to save. :) The index size increase is unfortunate, but it's something that has to happen anyways if we want search-results-mode to have matching text in the snippets, which is in the future TODO. The change was made in such a way that it's incrementally applied whenever a message is saved or changed in the Ferret index. So, if you want the above behavior on all messages immediately, you must do sup-sync --all on a source (which will require downloading each message). Otherwise, you will get the slow behavior (message body needs to be downloaded from the source) the first time you save a message after merging this change, and the fast behavior (no downloading required) on all subsequent times. type: :feature component: indexing release: "0.5" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-03-07 02:48:23.885656 Z references: [] id: 8a5cf9242ca60fa6c81091e425f734b4fb03e41a log_events: - - 2008-03-07 02:48:23.885693 Z - William Morgan - created - "" - - 2008-03-07 02:48:50.979828 Z - William Morgan - changed status from unstarted to in_progress - "" - - 2008-04-20 22:10:33.970635 Z - William Morgan - closed issue with disposition fixed - "" sup-mainline/bugs/issue-8aa7ea95f066fd0668452093b85903bd142905c9.yaml000066400000000000000000000013231166154264000236120ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: "'q' asks and 'Q' quits without asking" desc: "" type: :feature component: curses release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-20 03:25:17.109472 Z references: [] id: 8aa7ea95f066fd0668452093b85903bd142905c9 log_events: - - 2008-05-20 03:25:19.265580 Z - William Morgan - created - "" - - 2008-05-25 02:13:32.219668 Z - William Morgan - closed issue with disposition fixed - fixed in master - - 2008-05-25 02:13:40.344453 Z - William Morgan - assigned to release 0.6 from unassigned - "" sup-mainline/bugs/issue-8c0e627c500f679badca28f60ba76998fd65d46a.yaml000066400000000000000000000014521166154264000242010ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: workaround for rubymail quoting bug in pgp MIME header desc: gpg MIME headers are being double-quoted due to a rubymail bug type: :bugfix component: sup release: "0.5" reporter: Jan Spakula status: :closed disposition: fixed creation_time: 2008-03-07 03:35:36.731751 Z references: - http://rubyforge.org/pipermail/sup-talk/2008-February/001222.html id: 8c0e627c500f679badca28f60ba76998fd65d46a log_events: - - 2008-03-07 03:35:36.731787 Z - William Morgan - created - "" - - 2008-03-07 03:36:24.938159 Z - William Morgan - changed status from unstarted to fixed - "" - - 2008-03-08 22:22:59.515414 Z - William Morgan - added reference 1 - "" sup-mainline/bugs/issue-8e825caee33a6ac144580bf44d0d3060ad162394.yaml000066400000000000000000000011071166154264000237740ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: From lines detected over-aggressively desc: |- mbox lines starting with "From " should only be considered new-message delimiters if they have a valid email address, date, etc. type: :bugfix component: mbox release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-11-10 03:56:07.002467 Z references: [] id: 8e825caee33a6ac144580bf44d0d3060ad162394 log_events: - - 2008-11-10 03:56:07.002940 Z - William Morgan - created - "" git_branch: sup-mainline/bugs/issue-91e1549102c0bfa2c201476d9618f7d234d1a626.yaml000066400000000000000000000012061166154264000235600ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: gpg should use exact match for email address desc: otherwise substring matches can select the wrong key type: :bugfix component: crypto release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-25 02:15:58.006265 Z references: [] id: 91e1549102c0bfa2c201476d9618f7d234d1a626 log_events: - - 2008-05-25 02:15:59.100203 Z - William Morgan - created - "" - - 2008-05-25 02:16:07.730483 Z - William Morgan - closed issue with disposition fixed - fixed in master sup-mainline/bugs/issue-9f7e28de46d74f7f1e445ae75ea4e230c7473374.yaml000066400000000000000000000011301166154264000240410ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: threads with unsent draft messages are now shown in red desc: "" type: :feature component: curses release: "0.5" reporter: Nicolas Pouillard status: :closed disposition: fixed creation_time: 2008-03-07 04:30:53.909487 Z references: [] id: 9f7e28de46d74f7f1e445ae75ea4e230c7473374 log_events: - - 2008-03-07 04:30:53.909522 Z - William Morgan - created - "" - - 2008-03-07 04:31:03.704713 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-a1a3427de5e8d4f74c0620f99e97ed92d21e924c.yaml000066400000000000000000000015551166154264000241240ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: sup-config name guessing logic can generate nil and crash desc: "" type: :bugfix component: sup release: "0.5" reporter: Jean-Hadrien CHABRAN status: :closed disposition: fixed creation_time: 2008-03-09 17:45:46.095924 Z references: - http://rubyforge.org/pipermail/sup-talk/2008-March/001260.html id: a1a3427de5e8d4f74c0620f99e97ed92d21e924c log_events: - - 2008-03-09 17:45:46.095961 Z - William Morgan - created - "" - - 2008-03-09 17:46:21.702965 Z - William Morgan - added reference 1 - "" - - 2008-03-09 17:46:45.446763 Z - William Morgan - changed reporter - "" - - 2008-03-09 17:47:06.797832 Z - William Morgan - changed status from unstarted to fixed - fixed in master sup-mainline/bugs/issue-a1e622dbae0e1841b4d9a376d419aed1d91460e0.yaml000066400000000000000000000015021166154264000241310ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: non-ascii characters in message id need to be normalized desc: apparently this happens. in spam email, of course. type: :bugfix component: sup release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-11 23:04:01.078305 Z references: [] id: a1e622dbae0e1841b4d9a376d419aed1d91460e0 log_events: - - 2008-05-11 23:04:01.677838 Z - William Morgan - created - "" - - 2008-05-11 23:21:40.281018 Z - William Morgan - changed status from unstarted to in_progress - branch non-ascii-message-id, merged into next - - 2008-06-19 18:09:04.143173 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-a533480a30a18c3e823dbe20b759e1dcb32ca2b9.yaml000066400000000000000000000014211166154264000241220ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: forward/reply without saving in the editor drops all newlines desc: "" type: :bugfix component: sup release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-04-23 01:31:16.072859 Z references: [] id: a533480a30a18c3e823dbe20b759e1dcb32ca2b9 log_events: - - 2008-04-23 01:31:16.640737 Z - William Morgan - created - "" - - 2008-04-23 01:39:10.304801 Z - William Morgan - changed status from unstarted to in_progress - branch unedited-newlines, merged into next - - 2008-05-25 04:22:44.178693 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-a68148169baa3838051f4bdb4c175e11cbf7f143.yaml000066400000000000000000000011371166154264000240100ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: all ferret access needs to be wrapped in a mutex desc: |- concurrent access breaks things. at least, that's what I *think* is going on here. http://rubyforge.org/pipermail/sup-talk/2008-April/001333.html type: :bugfix component: indexing release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-01 01:09:39.706808 Z references: [] id: a68148169baa3838051f4bdb4c175e11cbf7f143 log_events: - - 2008-05-01 01:09:40.747646 Z - William Morgan - created - "" sup-mainline/bugs/issue-aae5ae6378afa9bd2a8e1b15d28ba7ccef867791.yaml000066400000000000000000000014251166154264000244700ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: remove email->name mapping desc: it doesn't work and wouldn't buy that much even if it did type: :bugfix component: sup release: reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-05-19 23:42:25.910550 Z references: [] id: aae5ae6378afa9bd2a8e1b15d28ba7ccef867791 log_events: - - 2008-05-19 23:42:26.490587 Z - William Morgan - created - "" - - 2008-07-31 00:54:39.921596 Z - William Morgan - unassigned from release 0.6 - "" - - 2008-11-22 16:31:27.450146 Z - Nicolas Pouillard - closed with disposition fixed - This mapping and the PersonManager are now removed. git_branch: sup-mainline/bugs/issue-ad82aa00f4064fc7e1332cee0dae2c2ae95bb217.yaml000066400000000000000000000013331166154264000243400ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: add more vi keys desc: "" type: :feature component: curses release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-04-26 22:23:43.675951 Z references: [] id: ad82aa00f4064fc7e1332cee0dae2c2ae95bb217 log_events: - - 2008-04-26 22:23:44.484689 Z - William Morgan - created - "" - - 2008-04-26 22:24:00.893661 Z - William Morgan - changed status from unstarted to in_progress - branch more-vi-keys. in next. - - 2008-05-25 04:12:57.577438 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-b1f1579fd8350d8add15c5cb588169acfdc5ea24.yaml000066400000000000000000000014531166154264000243300ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: offer to delete lockfile after trying to kill owner process desc: |- often the lockfile points to a dead process, so repeatedly offering to kill it isn't all that useful. type: :feature component: sup release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-04-14 01:25:59.111165 Z references: [] id: b1f1579fd8350d8add15c5cb588169acfdc5ea24 log_events: - - 2008-04-14 01:26:00.135062 Z - William Morgan - created - "" - - 2008-05-19 23:40:28.102694 Z - William Morgan - assigned to release 0.6 from unassigned - "" - - 2008-07-31 00:54:38.573917 Z - William Morgan - unassigned from release 0.6 - "" git_branch: sup-mainline/bugs/issue-b80aa39ef3b8d33bd57e4988c55d89c7c0df5c96.yaml000066400000000000000000000012311166154264000242700ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: spurious messages appearing in inbox desc: |- this is because ThreadSet is claiming that non-relevant videos are actually relevant type: :bugfix component: indexing release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 03:55:29.353904 Z references: [] id: b80aa39ef3b8d33bd57e4988c55d89c7c0df5c96 log_events: - - 2008-03-07 03:55:29.353940 Z - William Morgan - created - "" - - 2008-03-07 03:55:34.495965 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-bc03bc702f41e6a9687b52d3e32db29132c0f65a.yaml000066400000000000000000000013161166154264000240570ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: add a mark-as-spam hook desc: |- a simple hook that triggers when a message is marked as spam, so that users can trigger additional stuff. type: :feature component: hooks release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-07-31 00:50:51.282526 Z references: [] id: bc03bc702f41e6a9687b52d3e32db29132c0f65a log_events: - - 2008-07-31 00:50:52.114135 Z - William Morgan - created - "" - - 2008-07-31 00:51:10.671706 Z - William Morgan - closed with disposition fixed - branch mark-as-spam-hook, merged into master git_branch: sup-mainline/bugs/issue-bdd4415a9d4c8fd3602500111bf9268aa7c7c6a4.yaml000066400000000000000000000014241166154264000240620ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: configurable colors desc: All colors should be user-configurable. type: :feature component: curses release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-06-04 02:27:15.458560 Z references: [] id: bdd4415a9d4c8fd3602500111bf9268aa7c7c6a4 log_events: - - 2008-06-04 02:27:16.721829 Z - William Morgan - created - "" - - 2008-06-04 02:27:27.256556 Z - William Morgan - changed status from unstarted to in_progress - branch 'colors', merged into next. - - 2008-07-30 23:41:33.553377 Z - William Morgan - closed with disposition fixed - merged into master git_branch: color sup-mainline/bugs/issue-bff2527210b3aacae2f74029e5856fed82f1689c.yaml000066400000000000000000000023151166154264000241640ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: wide characters break screen clearing desc: |- if you look at a message with wide characters using the new wide-char-aware ncurses library, there will often be snippets of the previous screen immediately to the right of the end of the lines that have wide characters in them. some kind of line length issue maybe? (because everything is done in terms of bytes still. thanks ruby!) type: :bugfix component: curses release: reporter: William Morgan status: :closed disposition: :wontfix creation_time: 2008-04-26 21:35:31.519359 Z references: [] id: bff2527210b3aacae2f74029e5856fed82f1689c log_events: - - 2008-04-26 21:35:32.384516 Z - William Morgan - created - "" - - 2008-04-26 21:44:04.152193 Z - William Morgan - unassigned from release 0.6 - depends on {issue 23658477a445c2e61405fecb4cb641a2298caba6} and that's not necessarily destined for 0.6 yet. - - 2008-04-28 02:36:51.698817 Z - William Morgan - closed issue with disposition wontfix - dup of {issue c48f7fc58bba0b38ff6ae14cca01b08a5a7a6c33}. you'd think i'd'a remembered. sup-mainline/bugs/issue-c48f7fc58bba0b38ff6ae14cca01b08a5a7a6c33.yaml000066400000000000000000000011731166154264000244400ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: wide characters screw up line cursor display desc: |- in a message with wide characters, the screen isn't cleared properly, or something. probably due to the # of characters for something being calculated wrong (bytes instead of chars). type: :bugfix component: curses release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-04-23 02:05:47.272610 Z references: [] id: c48f7fc58bba0b38ff6ae14cca01b08a5a7a6c33 log_events: - - 2008-04-23 02:05:49.360399 Z - William Morgan - created - "" sup-mainline/bugs/issue-c52f9762bc24a8f45863eb2e7beefa4201db34e8.yaml000066400000000000000000000011331166154264000242400ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: add a --compose option to spawn a compose-message buffer on startup desc: "" type: :feature component: sup release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 04:28:32.198492 Z references: [] id: c52f9762bc24a8f45863eb2e7beefa4201db34e8 log_events: - - 2008-03-07 04:28:32.198527 Z - William Morgan - created - "" - - 2008-03-07 04:28:37.471873 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-c660ddfa9d633501140dd199bdfd7cd9fed5df0b.yaml000066400000000000000000000011101166154264000244520ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: ctrl-g should interrupt thread search operation desc: "" type: :feature component: sup release: "0.5" reporter: Marcus Williams status: :closed disposition: fixed creation_time: 2008-03-07 04:15:47.155992 Z references: [] id: c660ddfa9d633501140dd199bdfd7cd9fed5df0b log_events: - - 2008-03-07 04:15:47.156031 Z - William Morgan - created - "" - - 2008-03-07 04:15:52.274258 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-cef3096582de268c050f78223eb6a22ac2599606.yaml000066400000000000000000000021141166154264000236620ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: ruby 1.8.7 breaks sup in weird ways desc: |- --- ArgumentError from thread: main wrong number of arguments (2 for 1) /home/benjamin/projects/sup/lib/sup/index.rb:422:in `respond_to?' /home/benjamin/projects/sup/lib/sup/index.rb:422:in `flatten' /home/benjamin/projects/sup/lib/sup/index.rb:422:in `load_sources' /home/benjamin/projects/sup/lib/sup/index.rb:108:in `load' /home/benjamin/projects/sup/lib/sup/util.rb:497:in `send' /home/benjamin/projects/sup/lib/sup/util.rb:497:in `method_missing' /home/benjamin/projects/sup/bin/sup:122 type: :bugfix component: sup release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-06-04 17:39:39.013305 Z references: [] id: cef3096582de268c050f78223eb6a22ac2599606 log_events: - - 2008-06-04 17:39:39.670176 Z - William Morgan - created - "" - - 2008-06-19 17:57:46.648682 Z - William Morgan - closed issue with disposition fixed - fixed directly in master sup-mainline/bugs/issue-cf09ec6ec7c35d7d8c002b0521f97b6e94dc9b3e.yaml000066400000000000000000000013471166154264000243340ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: invalid gecos can cause sup-config to crash desc: "" type: :bugfix component: sup release: "0.5" reporter: Jean-Hadrien CHABRAN status: :closed disposition: fixed creation_time: 2008-03-14 18:54:32.560987 Z references: - http://rubyforge.org/pipermail/sup-talk/2008-March/001260.html id: cf09ec6ec7c35d7d8c002b0521f97b6e94dc9b3e log_events: - - 2008-03-14 18:54:32.561241 Z - William Morgan - created - "" - - 2008-03-14 18:54:52.486259 Z - William Morgan - added reference 1 - "" - - 2008-03-14 18:55:10.323790 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-cfbfc65dc90280fa5ecc63094af01d2a47ff0c6e.yaml000066400000000000000000000011611166154264000244460ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: exception thrown when a forwarded attachment is not a known mime type desc: a minor typo type: :bugfix component: sup release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-04-23 01:23:06.368926 Z references: [] id: cfbfc65dc90280fa5ecc63094af01d2a47ff0c6e log_events: - - 2008-04-23 01:23:07.968757 Z - William Morgan - created - "" - - 2008-04-23 01:23:14.995087 Z - William Morgan - closed issue with disposition fixed - fixed in master sup-mainline/bugs/issue-d131464e921aefc35571c119aac4d9f1decdebae.yaml000066400000000000000000000017411166154264000244440ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: blank message-id headers are treated as valid and crash indexing desc: |- specifically, they trigger the "just added message to index" debug exception because the id consists of nothing but spaces. header parsing needs to be fixed to not grab headers that are empty. type: :bugfix component: mbox release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-04-26 21:07:33.463910 Z references: [] id: d131464e921aefc35571c119aac4d9f1decdebae log_events: - - 2008-04-26 21:07:34.221325 Z - William Morgan - created - "" - - 2008-04-26 21:41:55.731750 Z - William Morgan - changed status from unstarted to in_progress - branch header-parsing-fix. merged into next. - - 2008-05-25 04:19:15.986573 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/issue-d994a360c9cb2a6e12a734962a39ffbc6486a725.yaml000066400000000000000000000021271166154264000240320ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: flags need a separate backup outside of the ferret index desc: | Ferret still occasionally barfs and corrupts its own index. Currently all user state is stored in the index and only in the index, so that means you lose big-time if that happens. You can sup-dump your labels, but really, how often are you going to do that. Sup should maintain a separate backup of all labels in some fast on-disk hashtable (message ids to label sets). Bdb comes to mind. type: :feature component: sup release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-03-07 04:22:50.236621 Z references: [] id: d994a360c9cb2a6e12a734962a39ffbc6486a725 log_events: - - 2008-03-07 04:22:50.236657 Z - William Morgan - created - "" - - 2008-04-20 21:44:51.655741 Z - William Morgan - assigned to release 0.6 from 0.5 - "" - - 2008-07-31 00:54:37.881077 Z - William Morgan - unassigned from release 0.6 - "" git_branch: sup-mainline/bugs/issue-d9e6be1b524c6c0a5c31c9c468bda170c2a8cb58.yaml000066400000000000000000000017741166154264000243140ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: temp files disappear on sup crash desc: |- because we're using the Tempfile library, Sup crashes mean that tempfiles containing message bodies are lost. Sup needs to manage its own tempfiles. type: :bugfix component: sup release: reporter: "Marko Myllym\xC3\xA4ki " status: :unstarted disposition: creation_time: 2008-03-07 04:59:51.404664 Z references: - http://rubyforge.org/pipermail/sup-talk/2008-February/001174.html id: d9e6be1b524c6c0a5c31c9c468bda170c2a8cb58 log_events: - - 2008-03-07 04:59:51.404701 Z - William Morgan - created - "" - - 2008-03-11 06:20:47.540967 Z - William Morgan - added reference 1 - "" - - 2008-04-20 21:45:00.615452 Z - William Morgan - assigned to release 0.6 from 0.5 - "" - - 2008-07-31 00:54:38.222035 Z - William Morgan - unassigned from release 0.6 - "" git_branch: sup-mainline/bugs/issue-e24df153080c6e7a16335018b04d70d9381258b8.yaml000066400000000000000000000010571166154264000235120ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: Pulling new threads should not shift the cursor. desc: |- In thread-view-index, the selected thread should not change due to additions of new threads. type: :bugfix component: curses release: reporter: Nicolas Pouillard status: :unstarted disposition: creation_time: 2008-04-21 08:26:00.191881 Z references: [] id: e24df153080c6e7a16335018b04d70d9381258b8 log_events: - - 2008-04-21 08:26:03.807376 Z - Nicolas Pouillard - created - "" sup-mainline/bugs/issue-e43b18777ea3aef3566bd80acd126e9ef8a5883a.yaml000066400000000000000000000020571166154264000242600ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: long message ids (>255 characters) never get matched by ferret desc: | this is a ferret bug. Apparently, constructing a TermQuery object with a field value of more than 255 characters never successfully matches. This is not a good long-term solution. A good one would be to take the SHA1 of every message id instead. That will require an index rebuild, so I will save that patch until later. type: :bugfix component: indexing release: "0.5" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-03-07 03:40:55.475449 Z references: [] id: e43b18777ea3aef3566bd80acd126e9ef8a5883a log_events: - - 2008-03-07 03:40:55.475485 Z - William Morgan - created - "" - - 2008-03-07 03:50:18.590242 Z - William Morgan - changed status from unstarted to in_progress - "" - - 2008-04-20 22:10:44.010446 Z - William Morgan - closed issue with disposition fixed - "" sup-mainline/bugs/issue-e7739718b4dbf49bbd3dd47133affbf7cb1e2361.yaml000066400000000000000000000012571166154264000243300ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: "maildir speedup: don't scan directory except when polling" desc: |- lots of useless scanning. removing it should make things faster for large maildirs. type: :feature component: maildir release: "0.5" reporter: William Morgan status: :closed disposition: fixed creation_time: 2008-03-07 04:20:32.735159 Z references: [] id: e7739718b4dbf49bbd3dd47133affbf7cb1e2361 log_events: - - 2008-03-07 04:20:32.735194 Z - William Morgan - created - "" - - 2008-03-07 04:20:37.257919 Z - William Morgan - changed status from unstarted to fixed - "" sup-mainline/bugs/issue-e9c2f66a7ff4fb4525c2719e77ac8eedf3835dfd.yaml000066400000000000000000000007761166154264000244460ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: force hook reload feature desc: |- would be nice for hook debugging. otherwise you have to restart sup each time. type: :feature component: hooks release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-05-19 23:19:38.202269 Z references: [] id: e9c2f66a7ff4fb4525c2719e77ac8eedf3835dfd log_events: - - 2008-05-19 23:19:38.757600 Z - William Morgan - created - "" sup-mainline/bugs/issue-f767a9d2071da7b0f66698ce74e642bf347be96b.yaml000066400000000000000000000011351166154264000241300ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: mbox file handle closing desc: |- currently an open file handle is maintained for every single mbox folder. (well, every one that's accessed by polling or by opening a message therefrom.) that is plum crazy. type: :feature component: mbox release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-03-07 04:27:35.146273 Z references: [] id: f767a9d2071da7b0f66698ce74e642bf347be96b log_events: - - 2008-03-07 04:27:35.146307 Z - William Morgan - created - "" sup-mainline/bugs/issue-fd7c7a7d7caf41ff20e7d10ca3f074fc02c14a5b.yaml000066400000000000000000000014761166154264000244440ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: thread joining doesn't always work desc: | sometimes it works, and sometimes it doesn't, and i haven't found the pattern yet. also, UpdateManager isn't being called properly (maybe even needs a custom event). e.g. if you join in search-results-mode, the results aren't joined in inbox-mode. type: :bugfix component: indexing release: reporter: William Morgan status: :unstarted disposition: creation_time: 2008-04-29 00:09:38.366801 Z references: [] id: fd7c7a7d7caf41ff20e7d10ca3f074fc02c14a5b log_events: - - 2008-04-29 00:09:38.998592 Z - William Morgan - created - "" - - 2008-07-31 00:54:39.251862 Z - William Morgan - unassigned from release 0.6 - "" git_branch: sup-mainline/bugs/issue-fdfc906e8f4f6eb10f1ebdf39c416415d9ab6af9.yaml000066400000000000000000000013721166154264000244770ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/issue title: archive-and-mark-read command in inbox-mode desc: "" type: :feature component: curses release: "0.6" reporter: William Morgan status: :closed disposition: :fixed creation_time: 2008-04-26 23:32:53.791207 Z references: [] id: fdfc906e8f4f6eb10f1ebdf39c416415d9ab6af9 log_events: - - 2008-04-26 23:32:57.083084 Z - William Morgan - created - "" - - 2008-04-26 23:33:12.980220 Z - William Morgan - changed status from unstarted to in_progress - branch read-and-archive. in next. - - 2008-05-25 04:14:48.307896 Z - William Morgan - closed issue with disposition fixed - merged into master sup-mainline/bugs/project.yaml000066400000000000000000000026471166154264000167670ustar00rootroot00000000000000--- !ditz.rubyforge.org,2008-03-06/project name: sup version: 0.0.1 components: - !ditz.rubyforge.org,2008-03-06/component name: sup - !ditz.rubyforge.org,2008-03-06/component name: threading - !ditz.rubyforge.org,2008-03-06/component name: indexing - !ditz.rubyforge.org,2008-03-06/component name: curses - !ditz.rubyforge.org,2008-03-06/component name: hooks - !ditz.rubyforge.org,2008-03-06/component name: sup-sync - !ditz.rubyforge.org,2008-03-06/component name: sup-sync-back - !ditz.rubyforge.org,2008-03-06/component name: maildir - !ditz.rubyforge.org,2008-03-06/component name: imap - !ditz.rubyforge.org,2008-03-06/component name: mbox - !ditz.rubyforge.org,2008-03-06/component name: crypto releases: - !ditz.rubyforge.org,2008-03-06/release name: "0.5" status: :released release_time: 2008-04-22 15:55:47.323776 Z log_events: - - 2008-03-07 02:37:54.903172 Z - William Morgan - created - "" - - 2008-04-22 15:55:47.323829 Z - William Morgan - released - "" - !ditz.rubyforge.org,2008-03-06/release name: "0.6" status: :released release_time: 2008-08-04 02:48:44.154676 Z log_events: - - 2008-04-20 21:17:04.443432 Z - William Morgan - created - "" - - 2008-08-04 02:48:44.154704 Z - William Morgan - released - "" sup-mainline/contrib/000077500000000000000000000000001166154264000151245ustar00rootroot00000000000000sup-mainline/contrib/colorpicker.rb000066400000000000000000000033161166154264000177700ustar00rootroot00000000000000require 'rubygems' begin require 'ncursesw' rescue LoadError require 'ncurses' end Ncurses.initscr Ncurses.noecho Ncurses.cbreak Ncurses.start_color Ncurses.curs_set 0 Ncurses.move 0, 0 Ncurses.clear Ncurses.refresh cc = Ncurses.COLORS Ncurses::keypad(Ncurses::stdscr, 1) Ncurses::mousemask(Ncurses::ALL_MOUSE_EVENTS | Ncurses::REPORT_MOUSE_POSITION, []) fail "color count is #{cc}, expected 256" unless cc == 256 1.upto(255) do |c| Ncurses.init_pair(c, 0, c) end def cell y, x, c @map[[y,x]] = c Ncurses.attron(Ncurses.COLOR_PAIR(c)) Ncurses.mvaddstr(y, x, " ") Ncurses.attroff(Ncurses.COLOR_PAIR(c)) end def handle_click y, x c = @map[[y,x]] or return name = case c when 0...16 c.to_s when 16...232 'c' + (c-16).to_s(6).rjust(3,'0') when 232...256 'g' + (c-232).to_s end Ncurses.mvaddstr 11, 0, "#{name} " Ncurses.attron(Ncurses.COLOR_PAIR(c)) 10.times do |i| 20.times do |j| y = 13 + i x = j Ncurses.mvaddstr(y, x, " ") end end Ncurses.attroff(Ncurses.COLOR_PAIR(c)) end @map = {} @fg = @bg = 0 begin 16.times do |i| cell 0, i, i end 6.times do |i| 6.times do |j| 6.times do |k| c = 16 + 6*6*i + 6*j + k y = 2 + j x = 7*i + k cell y, x, c end end end 16.times do |i| c = 16 + 6*6*6 + i cell 9, i, c end handle_click 0, 0 Ncurses.refresh while (c = Ncurses.getch) case c when 113 #q break when Ncurses::KEY_MOUSE mev = Ncurses::MEVENT.new Ncurses.getmouse(mev) case(mev.bstate) when Ncurses::BUTTON1_CLICKED handle_click mev.y, mev.x end end Ncurses.refresh end ensure Ncurses.endwin end sup-mainline/contrib/completion/000077500000000000000000000000001166154264000172755ustar00rootroot00000000000000sup-mainline/contrib/completion/_sup.zsh000066400000000000000000000121321166154264000207700ustar00rootroot00000000000000#compdef sup sup-add sup-config sup-dump sup-sync sup-sync-back sup-tweak-labels sup-recover-sources # vim: set et sw=2 sts=2 ts=2 ft=zsh : # TODO: sources completion: maildir://some/dir, mbox://some/file, ... # for sup-add, sup-sync, sup-sync-back, sup-tweak-labels (( ${+functions[_sup_cmd]} )) || _sup_cmd() { _arguments -s : \ "(--list-hooks -l)"{--list-hooks,-l}"[list all hooks and descriptions, and quit]" \ "(--no-threads -n)"{--no-threads,-n}"[turn off threading]" \ "(--no-initial-poll -o)"{--no-initial-poll,-o}"[Don't poll for new messages when starting]" \ "(--search -s)"{--search,-s}"[search for this query upon startup]:Query: " \ "(--compose -c)"{--compose,-c}"[compose message to this recipient upon startup]:Email: " \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_add_cmd]} )) || _sup_add_cmd() { _arguments -s : \ "(--archive -a)"{--archive,-a}"[automatically archive all new messages from this source]" \ "(--unusual -u)"{--unusual,-u}"[do not automatically poll for new messages from this source]" \ "(--labels -l)"{--labels,-l}"[set of labels to apply to all messages from this source]:Labels: " \ "(--force-new -f)"{--force-new,-f}"[create a new account for this source, even if one already exists]" \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_config_cmd]} )) || _sup_config_cmd() { _arguments -s : \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_dump_cmd]} )) || _sup_dump_cmd() { _arguments -s : \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_recover_sources_cmd]} )) || _sup_recover_sources_cmd() { _arguments -s : \ "--archive[automatically archive all new messages from this source]" \ "--scan-num[number of messages to scan per source]:" \ "--unusual[do not automatically poll for new messages from this source]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_sync_cmd]} )) || _sup_sync_cmd() { # XXX Add only when --restore is given: (--restored -r) # Add only when --changed or--all are given: (--start-at -s) _arguments -s : \ "--new[operate on new messages only]" \ "(--changed -c)"{--changed,-c}"[scan over the entire source for messages that have been deleted, altered, or moved from another source]" \ "(--restored -r)"{--restored,-r}"[operate only on those messages included in a dump file as specified by --restore which have changed state]" \ "(--all -a)"{--all,-a}"[operate on all messages in the source, regardless of newness or changedness]" \ "(--start-at -s)"{--start-at,-s}"[start at a particular offset]:Offset: " \ "--asis[if the message is already in the index, preserve its state, otherwise, use default source state]" \ "--restore[restore message state from a dump file created with sup-dump]:File:_file" \ "--discard[discard any message state in the index and use the default source state]" \ "(--archive -x)"{--archive,-x}"[mark messages as archived when using the default source state]" \ "(--read -e)"{--read,-e}"[mark messages as read when using the default source state]" \ "--extra-labels[apply these labels when using the default source state]:Labels: " \ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \ "(--optimize -o)"{--optimize,-o}"[as the final operation, optimize the index]" \ "--all-sources[scan over all sources]" \ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_sync_back_cmd]} )) || _sup_sync_back_cmd() { _arguments -s : \ "(--drop-deleted -d)"{--drop-deleted,-d}"[drop deleted messages]" \ "--move-deleted[move deleted messages to a local mbox file]:File:_file" \ "(--drop-spam -s)"{--drop-spam,-s}"[drop spam messages]" \ "--move-spam[move spam messages to a local mbox file]:File:_file" \ "--with-dotlockfile[specific dotlockfile location (mbox files only)]:File:_file" \ "--dont-use-dotlockfile[don't use dotlockfile to lock mbox files]" \ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } (( ${+functions[_sup_tweak_labels_cmd]} )) || _sup_tweak_labels_cmd() { _arguments -s : \ "(--add -a)"{--add,-a}"[which labels to add to every message from the specified sources]:Labels: " \ "(--remove -r)"{--remove,-r}"[which labels to remove from every message from the specified sources]:Labels: " \ "--all-sources[scan over all sources]" \ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \ "--version[show version information]" \ "(--help -h)"{--help,-h}"[show help]" } _call_function ret _${words[1]//-/_}_cmd return ret sup-mainline/devel/000077500000000000000000000000001166154264000145635ustar00rootroot00000000000000sup-mainline/devel/console.sh000066400000000000000000000000601166154264000165550ustar00rootroot00000000000000#!/bin/sh irb -I lib -r devel/start-console.rb sup-mainline/devel/count-loc.sh000066400000000000000000000001541166154264000170220ustar00rootroot00000000000000#!/bin/sh egrep ".rb$" Manifest.txt | xargs cat | grep -v "^ *$"|grep -v "^ *#"|grep -v "^ *end *$"|wc -l sup-mainline/devel/load-index.rb000066400000000000000000000002161166154264000171330ustar00rootroot00000000000000require 'sup' puts "loading index..." @index = Redwood::Index.new @index.load @i = @index.index puts "loaded index of #{@i.size} messages" sup-mainline/devel/profile.rb000066400000000000000000000005041166154264000165470ustar00rootroot00000000000000require 'rubygems' require 'ruby-prof' require "redwood" result = RubyProf.profile do Redwood::ThreadSet.new(ARGV.map { |fn| Redwood::MBox::Scanner.new fn }).load_n_threads 100 end printer = RubyProf::GraphHtmlPrinter.new(result) File.open("profile.html", "w") { |f| printer.print(f, 1) } puts "report in profile.html" sup-mainline/devel/start-console.rb000066400000000000000000000000721166154264000177040ustar00rootroot00000000000000require 'sup' include Redwood start Index.init Index.load sup-mainline/doc/000077500000000000000000000000001166154264000142315ustar00rootroot00000000000000sup-mainline/doc/FAQ.txt000066400000000000000000000111031166154264000153750ustar00rootroot00000000000000Sup FAQ ------- Q: What is Sup? A: A console-based email client for people with a lot of email. Q: What does Sup stand for? A: "What's up?" Q: Sup looks like a text-based Gmail. A: First I stole their ideas. Then I improved them. Q: Why not just use Gmail? A: I hate ads, I hate using a mouse, and I hate non-programmability and non-extensibility. Also, Gmail doesn't let you use a monospace font, which is just lame. Also, Gmail encourages top-posting. THIS CANNOT BE TOLERATED! 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. Q: How does Sup deal with spam? A: You can manually mark messages as spam, which prevents them from showing up in future searches. Later, you can run a batch process to remove such messages from your sources. That's as far as Sup goes. Spam filtering should be done by a dedicated tool like SpamAssassin. Q: How do I delete a message? A: Why delete? Unless it's spam, you might as well just archive it. Q: C'mon, really now! A: Ok, press the 'd' key. Q: But I want to delete it for real, not just add a 'deleted' flag in the index. I want it gone from disk! A: Currently, for mbox sources, there is a batch deletion tool that will strip out all messages marked as spam or deleted. Q: How well does Sup play with other mail clients? A: Not well at all. If messages have been moved, deleted, or altered due to some other client, Sup will have to rebuild its index for that message source. For example, for mbox files, reading a single unread message changes the offsets of every file on disk. Rather than rescanning every time, Sup assumes sources don't change except by having new messages added. If that assumption is violated, you'll have to sync the index. Q: How do I back up my index? A: Since the contents of the messages are recoverable from their sources using sup-sync, all you need to back up is the message state. To do this, simply run: sup-dump > This will save all message state in a big text file, which you should probably compress. Q: How do I restore the message state I saved in my state dump? A: Run: sup-sync [+] --restored --restore where was created as above. Q: Xapian crashed and I can't read my index. Luckily I made a state dump. What should I do? Q: How do I rebuild the index completely? A: Run: rm -rf ~/.sup/xapian # omg wtf sup-sync --all-sources --all --restore Voila! A brand new index. Q: I want to move messages from one source to another. (E.g., my primary inbox is an mbox file, and I want to move some of those messages to a Maildir.) How do I do that while preserving message state? A: Move the messages from the source to the target using whatever tool you'd like. Mutt's a good one. :) Then run: sup-sync --changed Note that if you sup-sync only one source at a time, depending on the order in which you do it, the messages may be treated as missing and then deleted from the index, which means that their states will be lost when you sync the other source. So do them both in one go. Q: What are all these "Redwood" references I see in the code? A: That was Sup's original name. (Think pine, elm. Although I was a Mutt user, I couldn't think of a good progression there.) But it was taken by another project on RubyForge, and wasn't that original, and was too long to type anyways. Common Problems --------------- P: I get some error message from Rubymail about frozen strings when importing messages with attachments. S: The current solution is to directly modify RubyMail. Change line 159 of multipart.rb to: chunk = chunk[0..start] This is because RubyMail hasn't been updated since like Ruby 1.8.2. Please bug Matt Armstrong. P: I see this error: /usr/local/lib/ruby/1.8/yaml.rb:133:in `transfer': allocator undefined for Bignum (TypeError) S: You need to upgrade to Ruby 1.8.5. YAML in earlier versions can't parse BigNums, but Sup relies on that for Maildir. P: When I run Sup remotely and view an HTML attachment, an existing Firefox on the *local* machine is redirected to the attachment file, which it can't find (since it's on the remote machine). How do I view HTML attachments in this environment? S: Put this in your ~/.mailcap on the machine you run Sup on: text/html; /usr/bin/firefox -a sup '%s'; description=HTML Text; test=test -n "$DISPLAY"; nametemplate=%s.html sup-mainline/doc/Hooks.txt000066400000000000000000000047721166154264000160670ustar00rootroot00000000000000Sup's Hook System ----------------- Sup can be easily customized via its hook system, which allows custom user code to be injected into Sup's execution path by "hooking" the code onto pre-defined events. When those events occur, the code is executed. To see which hooks are available, simply run sup -l. Each hook sits in a file in ~/.sup/hooks/. Hooks are written in Ruby, and require no class or method definitions, just the executable code itself. Information passes from Sup to the hook code via Ruby variables (actually method calls), and from the hook code back to Sup via a return value. The values of variables persists across calls to the same hook, but is NOT available to other hooks. To make the value of a variable available to other hooks, use the get and set methods. Each hook description lists the variables and return value expected, if any. The following special functions are available to hooks: * say msg Displays the string msg to the user at the bottom of the screen. * log msg Adds the string msg to the log, which the user can access via the buffer list. * ask_yes_or_no question Prompts the user with the string question for a yes or no response. Returns true if the user answered yes, false otherwise. * get key Gets the cross-hook value associated with key (which is typically a string). If there is no value for a given key, nil is returned. * set key value Sets the cross-hook value associated with key to value. key is typically a string, while value can be whatever type it needs to be, including nil. Some example hooks: before-poll: ## runs fetchmail before polling if (@last_fetchmail_time || Time.now) < Time.now - 60 say "Running fetchmail..." system "fetchmail >& /dev/null" say "Done running fetchmail." end @last_fetchmail_time = Time.now mime-decode: ## turn text/html attachments into plain text, unless they are part ## of a multipart/alternative pair unless sibling_types.member? "text/plain" case content_type when "text/html" `/usr/bin/w3m -dump -T #{content_type} '#{filename}'` end end startup: ## runs a background task @bgtask_pid = fork if @bgtask_pid set 'bgtask_pid' @bgtask_pid Process.detach(@bgtask_pid) # so we don't have to wait on it when we go to kill it else exec "background-task args 2&>1 >> /tmp/logfile" end after-poll: ## kills the background task after the first poll @bgtask_pid = get 'bgtask_pid' Process.kill("TERM", @bgtask_pid) unless @bgtask_pid == nil set 'bgtask_pid' nil sup-mainline/doc/NewUserGuide.txt000066400000000000000000000313241166154264000173430ustar00rootroot00000000000000Welcome 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 :, 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! sup-mainline/doc/Philosophy.txt000066400000000000000000000067511166154264000171410ustar00rootroot00000000000000Should an email client have a philosophy? For many people, email is one of our primary means of communication, and email archives are an integral part of our long-term memory. Something so important ought to warrant a little thought. Here's Sup's philosophy. Using "traditional" email clients today is increasingly problematic. Anyone who's on a high-traffic mailing list knows this. My ruby-talk folder is 430 megs and Mutt sits there for 60 seconds while it opens it. Keeping up with the all the new traffic is impossible, even with Mutt's excellent threading features, simply because there's so much of it. A single thread can span several pages in the folder index view alone! And Mutt is probably the fastest, most mailing-list aware email client out there. God help me if I try and use Thunderbird. The problem with traditional clients like Mutt is that they deal with individual pieces of email. This places a high mental cost on the user for each incoming email, by forcing them to ask: Should I keep this email, or delete it? If I keep it, where should I file it? I've spent the last 10 years of my life laboriously hand-filing every email message I received and feeling a mild sense of panic every time an email was both "from Mom" and "about school". The massive amounts of email that many people receive, and the cheap cost of storage, have made these questions both more costly and less useful to answer. Contrast that with using Gmail. As a long-time Mutt user, I was blown away when I first saw someone use Gmail. They treated their email differently from how I ever had. They never filed email and they never deleted it. They relied on an immediate, global, full-text search, and thread-level tagging, to do everything I'd ever done with Mutt, but with a trivial cost to the user at message receipt time. From Gmail I learned that making certain operations quantitatively easier (namely, search) resulted in a qualitative improvement in usage. I also learned how thread-centrism was advantageous over message-centrism when message volume was high: most of the time, a message and its context deserve the same treatment. I think it's to the Gmail designers' credit that they started with a somewhat ad-hoc idea (hey, we're really good at search engines, so maybe we can build an email client on top of one) and managed to build something that was actually better than everything else out there. At least, that's how I imagine in happened. Maybe they knew what they were doing from the start. Unfortunately, there's a lot to Gmail I can't tolerate (top posting, HTML mail, one-level threads, and ads come to mind, never mind the fact that it's not FOSS). Thus Sup was born. Sup is based on the following principles, which I stole directly from Gmail: - An immediately accessible and fast search capability over the entire email archive eliminates most of the need for folders, and most of the necessity of deleting email. - Labels eliminate what little need for folders search doesn't cover. - A thread-centric approach to the UI is much more in line with how people operate than dealing with individual messages is. In the vast majority of cases, a message and its context should be subject to the same treatment. Sup is also based on many ideas from mutt and Emacs and vi, having to do with the fantastic productivity of a console- and keyboard-based application, the usefulness of multiple buffers, the necessity of handling multiple email accounts, etc. But those are just details! Try it and let me know what you think. sup-mainline/lib/000077500000000000000000000000001166154264000142325ustar00rootroot00000000000000sup-mainline/lib/sup.rb000066400000000000000000000271301166154264000153710ustar00rootroot00000000000000require 'rubygems' require 'syck' require 'yaml' require 'zlib' require 'thread' require 'fileutils' require 'gettext' require 'curses' require 'rmail' begin require 'fastthread' rescue LoadError end class Object ## this is for debugging purposes because i keep calling #id on the ## wrong object and i want it to throw an exception def id raise "wrong id called on #{self.inspect}" end 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}" } end YAML.add_domain_type("#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}", path) do |type, val| klass.new(*props.map { |p| val[p] }) end end end module Redwood VERSION = "git" BASE_DIR = ENV["SUP_BASE"] || File.join(ENV["HOME"], ".sup") CONFIG_FN = File.join(BASE_DIR, "config.yaml") COLOR_FN = File.join(BASE_DIR, "colors.yaml") SOURCE_FN = File.join(BASE_DIR, "sources.yaml") LABEL_FN = File.join(BASE_DIR, "labels.txt") CONTACT_FN = File.join(BASE_DIR, "contacts.txt") DRAFT_DIR = File.join(BASE_DIR, "drafts") SENT_FN = File.join(BASE_DIR, "sent.mbox") LOCK_FN = File.join(BASE_DIR, "lock") SUICIDE_FN = File.join(BASE_DIR, "please-kill-yourself") HOOK_DIR = File.join(BASE_DIR, "hooks") SEARCH_FN = File.join(BASE_DIR, "searches.txt") LOG_FN = File.join(BASE_DIR, "log") YAML_DOMAIN = "masanjin.net" YAML_DATE = "2006-10-01" ## record exceptions thrown in threads nicely @exceptions = [] @exception_mutex = Mutex.new attr_reader :exceptions def record_exception e, name @exception_mutex.synchronize do @exceptions ||= [] @exceptions << [e, name] end end def reporting_thread name if $opts[:no_threads] yield else ::Thread.new do begin yield rescue Exception => e record_exception e, name end end end end module_function :reporting_thread, :record_exception, :exceptions ## one-stop shop for yamliciousness def save_yaml_obj o, fn, safe=false, backup=false o = if o.is_a?(Array) o.map { |x| (x.respond_to?(:before_marshal) && x.before_marshal) || x } elsif o.respond_to? :before_marshal o.before_marshal else o end mode = if File.exists? fn File.stat(fn).mode else 0600 end if backup backup_fn = fn + '.bak' if File.exists?(fn) && File.size(fn) > 0 File.open(backup_fn, "w", mode) do |f| File.open(fn, "r") { |old_f| FileUtils.copy_stream old_f, f } f.fsync end end File.open(fn, "w") do |f| f.puts o.to_yaml f.fsync end elsif safe safe_fn = "#{File.dirname fn}/safe_#{File.basename fn}" File.open(safe_fn, "w", mode) do |f| f.puts o.to_yaml f.fsync end FileUtils.mv safe_fn, fn else File.open(fn, "w", mode) do |f| f.puts o.to_yaml f.fsync end end end def load_yaml_obj fn, compress=false o = if File.exists? fn if compress Zlib::GzipReader.open(fn) { |f| YAML::load f } else YAML::load_file fn end end if o.is_a?(Array) o.each { |x| x.after_unmarshal! if x.respond_to?(:after_unmarshal!) } else o.after_unmarshal! if o.respond_to?(:after_unmarshal!) end o end def managers %w(HookManager SentManager ContactManager LabelManager AccountManager DraftManager UpdateManager PollManager CryptoManager UndoManager SourceManager SearchManager IdleManager).map { |x| Redwood.const_get x.to_sym } end def start managers.each { |x| fail "#{x} already instantiated" if x.instantiated? } FileUtils.mkdir_p Redwood::BASE_DIR $config = load_config Redwood::CONFIG_FN @log_io = File.open(Redwood::LOG_FN, 'a') Redwood::Logger.add_sink @log_io Redwood::HookManager.init Redwood::HOOK_DIR Redwood::SentManager.init $config[:sent_source] || 'sup://sent' Redwood::ContactManager.init Redwood::CONTACT_FN Redwood::LabelManager.init Redwood::LABEL_FN Redwood::AccountManager.init $config[:accounts] Redwood::DraftManager.init Redwood::DRAFT_DIR Redwood::SearchManager.init Redwood::SEARCH_FN managers.each { |x| x.init unless x.instantiated? } end def finish Redwood::LabelManager.save if Redwood::LabelManager.instantiated? Redwood::ContactManager.save if Redwood::ContactManager.instantiated? Redwood::SearchManager.save if Redwood::SearchManager.instantiated? Redwood::Logger.remove_sink @log_io managers.each { |x| x.deinstantiate! if x.instantiated? } @log_io.close @log_io = nil $config = nil end ## not really a good place for this, so I'll just dump it here. ## ## a source error is either a FatalSourceError or an OutOfSyncSourceError. ## the superclass SourceError is just a generic. def report_broken_sources opts={} return unless BufferManager.instantiated? broken_sources = SourceManager.sources.select { |s| s.error.is_a? FatalSourceError } unless broken_sources.empty? BufferManager.spawn_unless_exists("Broken source notification for #{broken_sources.join(',')}", opts) do TextMode.new(< ENV["EDITOR"] || "/usr/bin/vim -f -c 'setlocal spell spelllang=en_us' -c 'set filetype=mail'", :thread_by_subject => false, :edit_signature => false, :ask_for_from => false, :ask_for_to => true, :ask_for_cc => true, :ask_for_bcc => false, :ask_for_subject => true, :account_selector => true, :confirm_no_attachments => true, :confirm_top_posting => true, :jump_to_open_message => true, :discard_snippets_from_encrypted_messages => false, :load_more_threads_when_scrolling => true, :default_attachment_save_dir => "", :sent_source => "sup://sent", :archive_sent => true, :poll_interval => 300, :wrap_width => 0, :slip_rows => 0, :col_jump => 2 } if File.exists? filename config = Redwood::load_yaml_obj filename abort "#{filename} is not a valid configuration file (it's a #{config.class}, not a hash)" unless config.is_a?(Hash) default_config.merge config else require 'etc' require 'socket' name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first rescue nil name ||= ENV["USER"] email = ENV["USER"] + "@" + begin Socket.gethostbyname(Socket.gethostname).first rescue SocketError Socket.gethostname end config = { :accounts => { :default => { :name => name, :email => email, :alternates => [], :sendmail => "/usr/sbin/sendmail -oem -ti", :signature => File.join(ENV["HOME"], ".signature"), :gpgkey => "" } }, } config.merge! default_config begin Redwood::save_yaml_obj config, filename, false, true rescue StandardError => e $stderr.puts "warning: #{e.message}" end config end end module_function :save_yaml_obj, :load_yaml_obj, :start, :finish, :report_broken_sources, :check_library_version_against, :load_config, :managers end require "sup/util" 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 ## determine encoding and character set $encoding = Locale.current.charset $encoding = "UTF-8" if $encoding == "utf8" if $encoding debug "using character set encoding #{$encoding.inspect}" else warn "can't find character set by using locale, 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/update" require "sup/message-chunks" require "sup/message" require "sup/source" require "sup/mbox" require "sup/maildir" require "sup/person" require "sup/account" require "sup/thread" require "sup/interactive-lock" require "sup/index" require "sup/textfield" require "sup/colormap" require "sup/label" require "sup/contact" require "sup/tagger" 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/sent" require "sup/search" require "sup/modes/search-list-mode" require "sup/idle" $:.each do |base| d = File.join base, "sup/share/modes/" Redwood::Mode.load_all_modes d if File.directory? d end sup-mainline/lib/sup/000077500000000000000000000000001166154264000150415ustar00rootroot00000000000000sup-mainline/lib/sup/account.rb000066400000000000000000000043151166154264000170250ustar00rootroot00000000000000module Redwood class Account < Person attr_accessor :sendmail, :signature, :gpgkey def initialize h raise ArgumentError, "no name for account" unless h[:name] raise ArgumentError, "no email for account" unless h[:email] super h[:name], h[:email] @sendmail = h[:sendmail] @signature = h[:signature] @gpgkey = h[:gpgkey] end # Default sendmail command for bouncing mail, # deduced from #sendmail def bounce_sendmail sendmail.sub(/\s(\-(ti|it|t))\b/) do |match| case $1 when '-t' then '' else ' -i' end end end end class AccountManager include Singleton attr_accessor :default_account def initialize accounts @email_map = {} @accounts = {} @regexen = {} @default_account = nil add_account accounts[:default], true accounts.each { |k, v| add_account v, false unless k == :default } end def user_accounts; @accounts.keys; end def user_emails; @email_map.keys.select { |e| String === e }; end ## must be called first with the default account. fills in missing ## values from the default account. def add_account hash, default=false raise ArgumentError, "no email specified for account" unless hash[:email] unless default [:name, :sendmail, :signature, :gpgkey].each { |k| hash[k] ||= @default_account.send(k) } end hash[:alternates] ||= [] [:name, :signature].each { |x| hash[x].force_encoding Encoding::UTF_8 if hash[x].respond_to? :encoding } a = Account.new hash @accounts[a] = true if default raise ArgumentError, "multiple default accounts" if @default_account @default_account = a end ([hash[:email]] + hash[:alternates]).each do |email| next if @email_map.member? email @email_map[email] = a end hash[:regexen].each do |re| @regexen[Regexp.new(re)] = a end if hash[:regexen] end def is_account? p; is_account_email? p.email end def is_account_email? email; !account_for(email).nil? end def account_for email if(a = @email_map[email]) a else @regexen.argfind { |re, a| re =~ email && a } end end def full_address_for email a = account_for email Person.full_address a.name, email end end end sup-mainline/lib/sup/buffer.rb000066400000000000000000000552411166154264000166460ustar00rootroot00000000000000require 'etc' require 'thread' begin require 'ncursesw' rescue LoadError require 'ncurses' end if defined? Ncurses module Ncurses def rows lame, lamer = [], [] stdscr.getmaxyx lame, lamer lame.first end def cols lame, lamer = [], [] stdscr.getmaxyx lame, lamer lamer.first end def curx lame, lamer = [], [] stdscr.getyx lame, lamer lamer.first end def mutex; @mutex ||= Mutex.new; end def sync &b; mutex.synchronize(&b); end ## magically, this stuff seems to work now. i could swear it didn't ## before. hm. def nonblocking_getch ## INSANTIY ## it is NECESSARY to wrap Ncurses.getch in a select() otherwise all ## background threads will be BLOCKED. (except in very modern versions ## of libncurses-ruby. the current one on ubuntu seems to work well.) if IO.select([$stdin], nil, nil, 0.5) if Redwood::BufferManager.shelled? # If we get input while we're shelled, we'll ignore it for the # moment and use Ncurses.sync to wait until the shell_out is done. Ncurses.sync { nil } else Ncurses.getch end end end ## pretends ctrl-c's are ctrl-g's def safe_nonblocking_getch nonblocking_getch rescue Interrupt KEY_CANCEL end module_function :rows, :cols, :curx, :nonblocking_getch, :safe_nonblocking_getch, :mutex, :sync remove_const :KEY_ENTER remove_const :KEY_CANCEL KEY_ENTER = 10 KEY_CANCEL = 7 # ctrl-g KEY_TAB = 9 end end module Redwood class InputSequenceAborted < StandardError; end class Buffer attr_reader :mode, :x, :y, :width, :height, :title, :atime bool_reader :dirty, :system bool_accessor :force_to_top, :hidden def initialize window, mode, width, height, opts={} @w = window @mode = mode @dirty = true @focus = false @title = opts[:title] || "" @force_to_top = opts[:force_to_top] || false @hidden = opts[:hidden] || false @x, @y, @width, @height = 0, 0, width, height @atime = Time.at 0 @system = opts[:system] || false end def content_height; @height - 1; end def content_width; @width; end def resize rows, cols return if cols == @width && rows == @height @width = cols @height = rows @dirty = true mode.resize rows, cols end def redraw status if @dirty draw status else draw_status status end commit end def mark_dirty; @dirty = true; end def commit @dirty = false @w.noutrefresh end def draw status @mode.draw draw_status status commit @atime = Time.now end ## s nil means a blank line! def write y, x, s, opts={} return if x >= @width || y >= @height @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight]) s ||= "" maxl = @width - x # 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] end def clear @w.clear end def draw_status status write @height - 1, 0, status, :color => :status_color end def focus @focus = true @dirty = true @mode.focus end def blur @focus = false @dirty = true @mode.blur end end class BufferManager include Singleton attr_reader :focus_buf ## we have to define the key used to continue in-buffer search here, because ## it has special semantics that BufferManager deals with---current searches ## are canceled by any keypress except this one. CONTINUE_IN_BUFFER_SEARCH_KEY = "n" HookManager.register "status-bar-text", <" entries. Variables: none Return value: an array of email address strings. EOS def initialize @name_map = {} @buffers = [] @focus_buf = nil @dirty = true @minibuf_stack = [] @minibuf_mutex = Mutex.new @textfields = {} @flash = nil @shelled = @asking = false @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/ @sigwinch_happened = false @sigwinch_mutex = Mutex.new end def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end def buffers; @name_map.to_a; end def shelled?; @shelled; end def focus_on buf return unless @buffers.member? buf return if buf == @focus_buf @focus_buf.blur if @focus_buf @focus_buf = buf @focus_buf.focus end def raise_to_front buf @buffers.delete(buf) or return if @buffers.length > 0 && @buffers.last.force_to_top? @buffers.insert(-2, buf) else @buffers.push buf end focus_on @buffers.last @dirty = true end ## we reset force_to_top when rolling buffers. this is so that the ## human can actually still move buffers around, while still ## programmatically being able to pop stuff up in the middle of ## drawing a window without worrying about covering it up. ## ## if we ever start calling roll_buffers programmatically, we will ## have to change this. but it's not clear that we will ever actually ## do that. def roll_buffers bufs = rollable_buffers bufs.last.force_to_top = false raise_to_front bufs.first end def roll_buffers_backwards bufs = rollable_buffers return unless bufs.length > 1 bufs.last.force_to_top = false raise_to_front bufs[bufs.length - 2] end def rollable_buffers @buffers.select { |b| !(b.system? || b.hidden?) || @buffers.last == b } end def handle_input c if @focus_buf if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0] @focus_buf.mode.cancel_search! @focus_buf.mark_dirty end @focus_buf.mode.handle_input c end end def exists? n; @name_map.member? n; end def [] n; @name_map[n]; end def []= n, b raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n) raise ArgumentError, "title must be a string" unless n.is_a? String @name_map[n] = b end def completely_redraw_screen return if @shelled ## this magic makes Ncurses get the new size of the screen Ncurses.endwin Ncurses.stdscr.keypad 1 Ncurses.curs_set 0 Ncurses.refresh @sigwinch_mutex.synchronize { @sigwinch_happened = false } debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}" status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock Ncurses.sync do @dirty = true Ncurses.clear draw_screen :sync => false, :status => status, :title => title end end def draw_screen opts={} return if @shelled status, title = if opts.member? :status [opts[:status], opts[:title]] else raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false get_status_and_title @focus_buf # must be called outside of the ncurses lock end ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls) print "\033]0;#{title}\07" if title && @in_x Ncurses.mutex.lock unless opts[:sync] == false ## disabling this for the time being, to help with debugging ## (currently we only have one buffer visible at a time). ## TODO: reenable this if we allow multiple buffers false && @buffers.inject(@dirty) do |dirty, buf| buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols #dirty ? buf.draw : buf.redraw buf.draw status dirty end ## quick hack if true buf = @buffers.last buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols @dirty ? buf.draw(status) : buf.redraw(status) end draw_minibuf :sync => false unless opts[:skip_minibuf] @dirty = false Ncurses.doupdate Ncurses.refresh if opts[:refresh] Ncurses.mutex.unlock unless opts[:sync] == false end ## if the named buffer already exists, pops it to the front without ## calling the block. otherwise, gets the mode from the block and ## creates a new buffer. returns two things: the buffer, and a boolean ## indicating whether it's a new buffer or not. def spawn_unless_exists title, opts={} new = if @name_map.member? title raise_to_front @name_map[title] unless opts[:hidden] false else mode = yield spawn title, mode, opts true end [@name_map[title], new] end def spawn title, mode, opts={} raise ArgumentError, "title must be a string" unless title.is_a? String realtitle = title num = 2 while @name_map.member? realtitle realtitle = "#{title} <#{num}>" num += 1 end width = opts[:width] || Ncurses.cols height = opts[:height] || Ncurses.rows - 1 ## since we are currently only doing multiple full-screen modes, ## use stdscr for each window. once we become more sophisticated, ## we may need to use a new Ncurses::WINDOW ## ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0), ## (opts[:left] || 0)) w = Ncurses.stdscr b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system] mode.buffer = b @name_map[realtitle] = b @buffers.unshift b if opts[:hidden] focus_on b unless @focus_buf else raise_to_front b end b end ## requires the mode to have #done? and #value methods def spawn_modal title, mode, opts={} b = spawn title, mode, opts draw_screen until mode.done? c = Ncurses.safe_nonblocking_getch next unless c # getch timeout break if c == Ncurses::KEY_CANCEL begin mode.handle_input c rescue InputSequenceAborted # do nothing end draw_screen erase_flash end kill_buffer b mode.value end def kill_all_buffers_safely until @buffers.empty? ## inbox mode always claims it's unkillable. we'll ignore it. return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable? kill_buffer @buffers.last end true end def kill_buffer_safely buf return false unless buf.mode.killable? kill_buffer buf true end def kill_all_buffers kill_buffer @buffers.first until @buffers.empty? end def kill_buffer buf raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf buf.mode.cleanup @buffers.delete buf @name_map.delete buf.title @focus_buf = nil if @focus_buf == buf if @buffers.empty? ## TODO: something intelligent here ## for now I will simply prohibit killing the inbox buffer. else raise_to_front @buffers.last end end def ask_with_completions domain, question, completions, default=nil ask domain, question, default do |s| s.force_encoding 'UTF-8' if s.methods.include?(:encoding) completions.select { |x| x =~ /^#{Regexp::escape s}/iu }.map { |x| [x, x] } end end def ask_many_with_completions domain, question, completions, default=nil ask domain, question, default do |partial| prefix, target = case partial when /^\s*$/ ["", ""] when /^(.*\s+)?(.*?)$/ [$1 || "", $2] else 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] } end end def ask_many_emails_with_completions domain, question, completions, default=nil 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) prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ") prefix.force_encoding 'UTF-8' if prefix.methods.include?(:encoding) completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] } end end def ask_for_filename domain, question, default=nil, allow_directory=false answer = ask domain, question, default do |s| if s =~ /(~([^\s\/]*))/ # twiddle directory expansion full = $1 name = $2.empty? ? Etc.getlogin : $2 dir = Etc.getpwnam(name).dir rescue nil if dir [[s.sub(full, dir), "~#{name}"]] else users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u| [s.sub("~#{name}", "~#{u}"), "~#{u}"] end end else # regular filename completion Dir["#{s}*"].sort.map do |fn| suffix = File.directory?(fn) ? "/" : "" [fn + suffix, File.basename(fn) + suffix] end end end if answer answer = if answer.empty? spawn_modal "file browser", FileBrowserMode.new elsif File.directory?(answer) && !allow_directory spawn_modal "file browser", FileBrowserMode.new(answer) else File.expand_path answer end end answer end ## returns an array of labels def ask_for_labels domain, question, default_labels, forbidden_labels=[] default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS default = default_labels.to_a.join(" ") default += " " unless default.empty? # here I would prefer to give more control and allow all_labels instead of # user_defined_labels only applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase } answer = ask_many_with_completions domain, question, applyable_labels, default return unless answer user_labels = answer.to_set_of_symbols user_labels.each do |l| if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l) BufferManager.flash "'#{l}' is a reserved label!" return end end user_labels end def ask_for_contacts domain, question, default_contacts=[] default = default_contacts.is_a?(String) ? default_contacts : default_contacts.map { |s| s.to_s }.join(", ") default += " " unless default.empty? recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] } contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] } completions = (recent + contacts).flatten.uniq completions += HookManager.run("extra-contact-addresses") || [] answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default if answer answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) } end end def ask_for_account domain, question completions = AccountManager.user_emails answer = BufferManager.ask_many_emails_with_completions domain, question, completions, "" answer = AccountManager.default_account.email if answer == "" AccountManager.account_for Person.from_address(answer).email if answer end ## for simplicitly, we always place the question at the very bottom of the ## screen def ask domain, question, default=nil, &block raise "impossible!" if @asking raise "Question too long" if Ncurses.cols <= question.length @asking = true @textfields[domain] ||= TextField.new tf = @textfields[domain] completion_buf = nil status, title = get_status_and_title @focus_buf Ncurses.sync do tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block @dirty = true # for some reason that blanks the whole fucking screen draw_screen :sync => false, :status => status, :title => title tf.position_cursor Ncurses.refresh end while true c = Ncurses.safe_nonblocking_getch next unless c # getch timeout break unless tf.handle_input c # process keystroke if tf.new_completions? kill_buffer completion_buf if completion_buf shorts = tf.completions.map { |full, short| short } prefix_len = shorts.shared_prefix.length mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len completion_buf = spawn "", mode, :height => 10 draw_screen :skip_minibuf => true tf.position_cursor elsif tf.roll_completions? completion_buf.mode.roll draw_screen :skip_minibuf => true tf.position_cursor end Ncurses.sync { Ncurses.refresh } end kill_buffer completion_buf if completion_buf @dirty = true @asking = false Ncurses.sync do 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) } end def ask_getch question, accept=nil raise "impossible!" if @asking accept = accept.split(//).map { |x| x.ord } if accept status, title = get_status_and_title @focus_buf Ncurses.sync do draw_screen :sync => false, :status => status, :title => title Ncurses.mvaddstr Ncurses.rows - 1, 0, question Ncurses.move Ncurses.rows - 1, question.length + 1 Ncurses.curs_set 1 Ncurses.refresh end @asking = true ret = nil done = false until done key = Ncurses.safe_nonblocking_getch or next if key == Ncurses::KEY_CANCEL done = true elsif accept.nil? || accept.empty? || accept.member?(key) ret = key done = true end end @asking = false Ncurses.sync do Ncurses.curs_set 0 draw_screen :sync => false, :status => status, :title => title end ret end ## returns true (y), false (n), or nil (ctrl-g / cancel) def ask_yes_or_no question case(r = ask_getch question, "ynYN") when ?y.ord, ?Y.ord true when nil nil else false end end ## turns an input keystroke into an action symbol. returns the action ## if found, nil if not found, and throws InputSequenceAborted if ## the user aborted a multi-key sequence. (Because each of those cases ## should be handled differently.) ## ## this is in BufferManager because multi-key sequences require prompting. def resolve_input_with_keymap c, keymap action, text = keymap.action_for c while action.is_a? Keymap # multi-key commands, prompt key = BufferManager.ask_getch text unless key # user canceled, abort erase_flash raise InputSequenceAborted end action, text = action.action_for(key) if action.has_key?(key) end action end def minibuf_lines @minibuf_mutex.synchronize do [(@flash ? 1 : 0) + (@asking ? 1 : 0) + @minibuf_stack.compact.size, 1].max end end def draw_minibuf opts={} m = nil @minibuf_mutex.synchronize do m = @minibuf_stack.compact m << @flash if @flash m << "" if m.empty? unless @asking # to clear it end Ncurses.mutex.lock unless opts[:sync] == false Ncurses.attrset Colormap.color_for(:none) 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) end Ncurses.refresh if opts[:refresh] Ncurses.mutex.unlock unless opts[:sync] == false end def say s, id=nil new_id = nil @minibuf_mutex.synchronize do new_id = id.nil? id ||= @minibuf_stack.length @minibuf_stack[id] = s end if new_id draw_screen :refresh => true else draw_minibuf :refresh => true end if block_given? begin yield id ensure clear id end end id end def erase_flash; @flash = nil; end def flash s @flash = s draw_screen :refresh => true end ## a little tricky because we can't just delete_at id because ids ## are relative (they're positions into the array). def clear id @minibuf_mutex.synchronize do @minibuf_stack[id] = nil if id == @minibuf_stack.length - 1 id.downto(0) do |i| break if @minibuf_stack[i] @minibuf_stack.delete_at i end end end draw_screen :refresh => true end def shell_out command @shelled = true Ncurses.sync do Ncurses.endwin system command Ncurses.stdscr.keypad 1 Ncurses.refresh Ncurses.curs_set 0 end @shelled = false end private def default_status_bar buf " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}" end def default_terminal_title buf "Sup #{Redwood::VERSION} :: #{buf.title}" end def get_status_and_title buf opts = { :num_inbox => lambda { Index.num_results_for :label => :inbox }, :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] }, :num_total => lambda { Index.size }, :num_spam => lambda { Index.num_results_for :label => :spam }, :title => buf.title, :mode => buf.mode.name, :status => buf.mode.status } statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf) term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf) [statusbar_text, term_title_text] end def users unless @users @users = [] while(u = Etc.getpwent) @users << u.name end end @users end end end sup-mainline/lib/sup/client.rb000066400000000000000000000033431166154264000166470ustar00rootroot00000000000000require 'sup/protocol' module Redwood class Client < EM::P::RedwoodClient def initialize *a @next_tag = 1 @cbs = {} super *a end def mktag &b @next_tag.tap do |x| @cbs[x] = b @next_tag += 1 end end def rmtag tag @cbs.delete tag end def query qstr, offset, limit, raw, &b tag = mktag do |type,tag,args| if type == 'message' b.call args else fail unless type == 'done' b.call nil rmtag tag end end send_message 'query', tag, 'query' => qstr, 'offset' => offset, 'limit' => limit, 'raw' => raw end def count qstr, &b tag = mktag do |type,tag,args| b.call args['count'] rmtag tag end send_message 'count', tag, 'query' => qstr end def label qstr, add, remove, &b tag = mktag do |type,tag,args| b.call rmtag tag end send_message 'label', tag, 'query' => qstr, 'add' => add, 'remove' => remove end def add raw, labels, &b tag = mktag do |type,tag,args| b.call rmtag tag end send_message 'add', tag, 'raw' => raw, 'labels' => labels end def thread msg_id, raw, &b tag = mktag do |type,tag,args| if type == 'message' b.call args else fail unless type == 'done' b.call nil rmtag tag end end send_message 'thread', tag, 'message_id' => msg_id, 'raw' => raw end def receive_message type, tag, args cb = @cbs[tag] or fail "invalid tag #{tag.inspect}" cb[type, tag, args] end end end sup-mainline/lib/sup/colormap.rb000066400000000000000000000172311166154264000172060ustar00rootroot00000000000000module Curses COLOR_DEFAULT = -1 NUM_COLORS = `tput colors`.to_i MAX_PAIRS = `tput pairs`.to_i def self.color! name, value const_set "COLOR_#{name.to_s.upcase}", value end ## numeric colors Curses::NUM_COLORS.times { |x| color! x, x } if Curses::NUM_COLORS == 256 ## xterm 6x6x6 color cube 6.times { |x| 6.times { |y| 6.times { |z| color! "c#{x}#{y}#{z}", 16 + z + 6*y + 36*x } } } ## xterm 24-shade grayscale 24.times { |x| color! "g#{x}", (16+6*6*6) + x } end end module Redwood class Colormap @@instance = nil DEFAULT_COLORS = { :status => { :fg => "white", :bg => "blue", :attrs => ["bold"] }, :index_old => { :fg => "white", :bg => "default" }, :index_new => { :fg => "white", :bg => "default", :attrs => ["bold"] }, :index_starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :index_draft => { :fg => "red", :bg => "default", :attrs => ["bold"] }, :labellist_old => { :fg => "white", :bg => "default" }, :labellist_new => { :fg => "white", :bg => "default", :attrs => ["bold"] }, :twiddle => { :fg => "blue", :bg => "default" }, :label => { :fg => "yellow", :bg => "default" }, :message_patina => { :fg => "black", :bg => "green" }, :alternate_patina => { :fg => "black", :bg => "blue" }, :missing_message => { :fg => "black", :bg => "red" }, :attachment => { :fg => "cyan", :bg => "default" }, :cryptosig_valid => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :cryptosig_valid_untrusted => { :fg => "yellow", :bg => "blue", :attrs => ["bold"] }, :cryptosig_unknown => { :fg => "cyan", :bg => "default" }, :cryptosig_invalid => { :fg => "yellow", :bg => "red", :attrs => ["bold"] }, :generic_notice_patina => { :fg => "cyan", :bg => "default" }, :quote_patina => { :fg => "yellow", :bg => "default" }, :sig_patina => { :fg => "yellow", :bg => "default" }, :quote => { :fg => "yellow", :bg => "default" }, :sig => { :fg => "yellow", :bg => "default" }, :to_me => { :fg => "green", :bg => "default" }, :starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :starred_patina => { :fg => "yellow", :bg => "green", :attrs => ["bold"] }, :alternate_starred_patina => { :fg => "yellow", :bg => "blue", :attrs => ["bold"] }, :snippet => { :fg => "cyan", :bg => "default" }, :option => { :fg => "white", :bg => "default" }, :tagged => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :draft_notification => { :fg => "red", :bg => "default", :attrs => ["bold"] }, :completion_character => { :fg => "white", :bg => "default", :attrs => ["bold"] }, :horizontal_selector_selected => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :horizontal_selector_unselected => { :fg => "cyan", :bg => "default" }, :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] }, :system_buf => { :fg => "blue", :bg => "default" }, :regular_buf => { :fg => "white", :bg => "default" }, :modified_buffer => { :fg => "yellow", :bg => "default", :attrs => ["bold"] }, :date => { :fg => "white", :bg => "default"}, :size_widget => { :fg => "white", :bg => "default"}, } def initialize raise "only one instance can be created" if @@instance @@instance = self @color_pairs = {[Curses::COLOR_WHITE, Curses::COLOR_BLACK] => 0} @users = [] @next_id = 0 reset yield self if block_given? end def reset @entries = {} @highlights = { :none => highlight_sym(:none)} @entries[highlight_sym(:none)] = highlight_for(Curses::COLOR_WHITE, Curses::COLOR_BLACK, []) + [nil] end def add sym, fg, bg, attr=nil, highlight=nil raise ArgumentError, "color for #{sym} already defined" if @entries.member? sym raise ArgumentError, "color '#{fg}' unknown" unless (-1...Curses::NUM_COLORS).include? fg raise ArgumentError, "color '#{bg}' unknown" unless (-1...Curses::NUM_COLORS).include? bg attrs = [attr].flatten.compact @entries[sym] = [fg, bg, attrs, nil] if not highlight highlight = highlight_sym(sym) @entries[highlight] = highlight_for(fg, bg, attrs) + [nil] end @highlights[sym] = highlight end def highlight_sym sym "#{sym}_highlight".intern end def highlight_for fg, bg, attrs hfg = case fg when Curses::COLOR_BLUE Curses::COLOR_WHITE when Curses::COLOR_YELLOW, Curses::COLOR_GREEN fg else Curses::COLOR_BLACK end hbg = case bg when Curses::COLOR_CYAN Curses::COLOR_YELLOW when Curses::COLOR_YELLOW Curses::COLOR_BLUE else Curses::COLOR_CYAN end attrs = if fg == Curses::COLOR_WHITE && attrs.include?(Curses::A_BOLD) [Curses::A_BOLD] else case hfg when Curses::COLOR_BLACK [] else [Curses::A_BOLD] end end [hfg, hbg, attrs] end def color_for sym, highlight=false sym = @highlights[sym] if highlight return Curses::COLOR_BLACK if sym == :none raise ArgumentError, "undefined color #{sym}" unless @entries.member? sym ## if this color is cached, return it fg, bg, attrs, color = @entries[sym] return color if color if(cp = @color_pairs[[fg, bg]]) ## nothing else ## need to get a new colorpair @next_id = (@next_id + 1) % Curses::MAX_PAIRS @next_id += 1 if @next_id == 0 # 0 is always white on black id = @next_id debug "colormap: for color #{sym}, using id #{id} -> #{fg}, #{bg}" Curses.init_pair id, fg, bg or raise ArgumentError, "couldn't initialize curses color pair #{fg}, #{bg} (key #{id})" cp = @color_pairs[[fg, bg]] = Curses.color_pair(id) ## delete the old mapping, if it exists if @users[cp] @users[cp].each do |usym| warn "dropping color #{usym} (#{id})" @entries[usym][3] = nil end @users[cp] = [] end end ## by now we have a color pair color = attrs.inject(cp) { |color, attr| color | attr } @entries[sym][3] = color # fill the cache (@users[cp] ||= []) << sym # record entry as a user of that color pair color end def sym_is_defined sym return sym if @entries.member? sym end ## Try to use the user defined colors, in case of an error fall back ## to the default ones. def populate_colormap user_colors = if File.exists? Redwood::COLOR_FN debug "loading user colors from #{Redwood::COLOR_FN}" Redwood::load_yaml_obj Redwood::COLOR_FN end Colormap::DEFAULT_COLORS.merge(user_colors||{}).each_pair do |k, v| fg = begin Curses.const_get "COLOR_#{v[:fg].to_s.upcase}" rescue NameError warn "there is no color named \"#{v[:fg]}\"" Curses::COLOR_GREEN end bg = begin Curses.const_get "COLOR_#{v[:bg].to_s.upcase}" rescue NameError warn "there is no color named \"#{v[:bg]}\"" Curses::COLOR_RED end attrs = (v[:attrs]||[]).map do |a| begin Curses.const_get "A_#{a.upcase}" rescue NameError warn "there is no attribute named \"#{a}\", using fallback." nil end end.compact highlight_symbol = v[:highlight] ? :"#{v[:highlight]}_color" : nil symbol = (k.to_s + "_color").to_sym add symbol, fg, bg, attrs, highlight_symbol end end def self.instance; @@instance; end def self.method_missing meth, *a Colormap.new unless @@instance @@instance.send meth, *a end # Performance shortcut def self.color_for *a; @@instance.color_for *a; end end end sup-mainline/lib/sup/contact.rb000066400000000000000000000032021166154264000170160ustar00rootroot00000000000000module Redwood class ContactManager include Singleton def initialize fn @fn = fn ## maintain the mapping between people and aliases. for contacts without ## aliases, there will be no @a2p entry, so @p2a.keys should be treated ## as the canonical list of contacts. @p2a = {} # person to alias @a2p = {} # alias to person @e2p = {} # email to person if File.exists? fn IO.foreach(fn) do |l| l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}" aalias, addr = $1, $2 update_alias Person.from_address(addr), aalias end end end def contacts; @p2a.keys end def contacts_with_aliases; @a2p.values.uniq end def update_alias person, aalias=nil if(old_aalias = @p2a[person]) # remove old alias @a2p.delete old_aalias @e2p.delete old_aalias.email end @p2a[person] = aalias unless aalias.nil? || aalias.empty? @a2p[aalias] = person @e2p[person.email] = person end end ## this may not actually be called anywhere, since we still keep contacts ## around without aliases to override any fullname changes. def drop_contact person aalias = @p2a[person] @p2a.delete person @e2p.delete person.email @a2p.delete aalias if aalias end def contact_for aalias; @a2p[aalias] end def alias_for person; @p2a[person] end def person_for email; @e2p[email] end def is_aliased_contact? person; !@p2a[person].nil? end def save File.open(@fn, "w") do |f| @p2a.sort_by { |(p, a)| [p.full_address, a] }.each do |(p, a)| f.puts "#{a || ''}: #{p.full_address}" end end end end end sup-mainline/lib/sup/crypto.rb000066400000000000000000000347101166154264000167130ustar00rootroot00000000000000begin require 'gpgme' rescue LoadError end module Redwood class CryptoManager include Singleton class Error < StandardError; end OUTGOING_MESSAGE_OPERATIONS = OrderedHash.new( [:sign, "Sign"], [:sign_and_encrypt, "Sign and encrypt"], [:encrypt, "Encrypt only"] ) HookManager.register "gpg-options", < true} to encrypting a message, but who knows). Variables: operation: what operation will be done ("sign", "encrypt", "decrypt" or "verify") options: a dictionary of values to be passed to GPGME Return value: a dictionary to be passed to GPGME EOS HookManager.register "sig-output", < GPGME::PROTOCOL_OpenPGP}) true rescue GPGME::Error false end rescue NameError false end unless @gpgme_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 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", {:operation => "sign", :options => {}}) || {} if gpg_opts[:passphrase_callback].nil? if ENV['GPG_AGENT_INFO'].nil? @not_working_reason = ["Environment variable 'GPG_AGENT_INFO' not set, is gpg-agent running?", "If gpg-agent is running, try $ export `cat ~/.gpg-agent-info`"] return end gpg_agent_socket_file = ENV['GPG_AGENT_INFO'].split(':')[0] unless File.exist?(gpg_agent_socket_file) @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} does not exist"] return end s = File.stat(gpg_agent_socket_file) unless s.socket? @not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} is not a socket"] return end end end end def have_crypto?; @not_working_reason.nil? 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", {:operation => "sign", :options => gpg_opts}) || gpg_opts begin sig = GPGME.detach_sign(format_payload(payload), gpg_opts) 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 # 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.") end envelope = RMail::Message.new envelope.header["Content-Type"] = 'multipart/signed; protocol=application/pgp-signature' envelope.add_part payload signature = RMail::Message.make_attachment sig, "application/pgp-signature", nil, "signature.asc" envelope.add_part signature envelope end def encrypt from, to, payload, sign=false return unknown_status(@not_working_reason) unless @not_working_reason.nil? gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true} if sign gpg_opts.merge!(gen_sign_user_opts(from)) gpg_opts.merge!({:sign => true}) end gpg_opts = HookManager.run("gpg-options", {:operation => "encrypt", :options => gpg_opts}) || gpg_opts recipients = to + [from] begin cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts) 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 # 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.") end encrypted_payload = RMail::Message.new encrypted_payload.header["Content-Type"] = "application/octet-stream" encrypted_payload.header["Content-Disposition"] = 'inline; filename="msg.asc"' encrypted_payload.body = cipher control = RMail::Message.new control.header["Content-Type"] = "application/pgp-encrypted" control.header["Content-Disposition"] = "attachment" control.body = "Version: 1\n" envelope = RMail::Message.new envelope.header["Content-Type"] = 'multipart/encrypted; protocol=application/pgp-encrypted' envelope.add_part control envelope.add_part encrypted_payload envelope end def sign_and_encrypt from, to, payload encrypt from, to, payload, true end def verified_ok? verify_result valid = true unknown = false all_output_lines = [] all_trusted = true verify_result.signatures.each do |signature| output_lines, trusted = sig_output_lines signature all_output_lines << output_lines all_output_lines.flatten! all_trusted &&= trusted err_code = GPGME::gpgme_err_code(signature.status) if err_code == GPGME::GPG_ERR_BAD_SIGNATURE valid = false elsif err_code != GPGME::GPG_ERR_NO_ERROR valid = false unknown = true end end if valid || !unknown summary_line = simplify_sig_line(verify_result.signatures[0].to_s, all_trusted) end if all_output_lines.length == 0 Chunk::CryptoNotice.new :valid, "Encrypted message wasn't signed", all_output_lines elsif valid if all_trusted Chunk::CryptoNotice.new(:valid, summary_line, all_output_lines) else Chunk::CryptoNotice.new(:valid_untrusted, summary_line, all_output_lines) end elsif !unknown Chunk::CryptoNotice.new(:invalid, summary_line, all_output_lines) else unknown_status all_output_lines end end def verify payload, signature, detached=true # both RubyMail::Message objects return unknown_status(@not_working_reason) unless @not_working_reason.nil? gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP} gpg_opts = HookManager.run("gpg-options", {:operation => "verify", :options => gpg_opts}) || gpg_opts ctx = GPGME::Ctx.new(gpg_opts) sig_data = GPGME::Data.from_str signature.decode if detached signed_text_data = GPGME::Data.from_str(format_payload(payload)) plain_data = nil else signed_text_data = nil plain_data = GPGME::Data.empty end begin ctx.verify(sig_data, signed_text_data, plain_data) rescue GPGME::Error => exc return unknown_status [gpgme_exc_msg(exc.message)] end begin self.verified_ok? ctx.verify_result rescue ArgumentError => exc return unknown_status [gpgme_exc_msg(exc.message)] end end ## returns decrypted_message, status, desc, lines def decrypt payload, armor=false # a RubyMail::Message object return unknown_status(@not_working_reason) unless @not_working_reason.nil? gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP} gpg_opts = HookManager.run("gpg-options", {: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 begin ctx.decrypt_verify(cipher_data, plain_data) rescue GPGME::Error => exc return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", gpgme_exc_msg(exc.message)) end begin sig = self.verified_ok? ctx.verify_result rescue ArgumentError => exc sig = unknown_status [gpgme_exc_msg(exc.message)] end plain_data.seek(0, IO::SEEK_SET) output = plain_data.read output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding ## TODO: test to see if it is still necessary to do a 2nd run if verify ## fails. # ## check for a valid signature in an extra run because gpg aborts if the ## signature cannot be verified (but it is still able to decrypt) #sigoutput = run_gpg "#{payload_fn.path}" #sig = self.old_verified_ok? sigoutput, $? if armor msg = RMail::Message.new # 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) end msg.body = output else # It appears that some clients use Windows new lines - CRLF - but RMail # splits the body and header on "\n\n". So to allow the parse below to # succeed, we will convert the newlines to what RMail expects output = output.gsub(/\r\n/, "\n") # This is gross. This decrypted payload could very well be a multipart # element itself, as opposed to a simple payload. For example, a # multipart/signed element, like those generated by Mutt when encrypting # and signing a message (instead of just clearsigning the body). # Supposedly, decrypted_payload being a multipart element ought to work # out nicely because Message::multipart_encrypted_to_chunks() runs the # decrypted message through message_to_chunks() again to get any # children. However, it does not work as intended because these inner # payloads need not carry a MIME-Version header, yet they are fed to # RMail as a top-level message, for which the MIME-Version header is # required. This causes for the part not to be detected as multipart, # hence being shown as an attachment. If we detect this is happening, # we force the decrypted payload to be interpreted as MIME. 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 msg = RMail::Parser.read output end end notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display" [notice, sig, msg] end private def unknown_status lines=[] Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines end def gpgme_exc_msg msg err_msg = "Exception in GPGME call: #{msg}" info err_msg err_msg end ## here's where we munge rmail output into the format that signed/encrypted ## PGP/GPG messages should be def format_payload payload payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n") end # remove the hex key_id and info in () def simplify_sig_line sig_line, trusted sig_line.sub!(/from [0-9A-F]{16} /, "from ") if !trusted sig_line.sub!(/Good signature/, "Good (untrusted) signature") end sig_line end def sig_output_lines signature # It appears that the signature.to_s call can lead to a EOFError if # the key is not found. So start by looking for the key. ctx = GPGME::Ctx.new begin from_key = ctx.get_key(signature.fingerprint) if GPGME::gpgme_err_code(signature.status) == GPGME::GPG_ERR_GENERAL first_sig = "General error on signature verification for #{signature.fingerprint}" elsif signature.to_s first_sig = signature.to_s.sub(/from [0-9A-F]{16} /, 'from "') + '"' else first_sig = "Unknown error or empty signature" end rescue EOFError from_key = nil first_sig = "No public key available for #{signature.fingerprint}" end time_line = "Signature made " + signature.timestamp.strftime("%a %d %b %Y %H:%M:%S %Z") + " using " + key_type(from_key, signature.fingerprint) + "key ID " + signature.fingerprint[-8..-1] output_lines = [time_line, first_sig] trusted = false if from_key # first list all the uids if from_key.uids.length > 1 aka_list = from_key.uids[1..-1] aka_list.each { |aka| output_lines << ' aka "' + aka.uid + '"' } end # now we want to look at the trust of that key if signature.validity != GPGME::GPGME_VALIDITY_FULL && signature.validity != GPGME::GPGME_VALIDITY_MARGINAL output_lines << "WARNING: This key is not certified with a trusted signature!" output_lines << "There is no indication that the signature belongs to the owner" output_lines << "Full fingerprint is: " + (0..9).map {|i| signature.fpr[(i*4),4]}.join(":") else trusted = true end # finally, run the hook output_lines << HookManager.run("sig-output", {:signature => signature, :from_key => from_key}) end return output_lines, trusted end def key_type key, fpr return "" if key.nil? subkey = key.subkeys.find {|subkey| subkey.fpr == fpr || subkey.keyid == fpr } return "" if subkey.nil? case subkey.pubkey_algo when GPGME::PK_RSA then "RSA " when GPGME::PK_DSA then "DSA " when GPGME::PK_ELG then "ElGamel " when GPGME::PK_ELG_E then "ElGamel " end end # logic is: # if gpgkey set for this account, then use that # elsif only one account, then leave blank so gpg default will be user # else set --local-user from_email_address def gen_sign_user_opts from account = AccountManager.account_for from account ||= AccountManager.default_account if !account.gpgkey.nil? opts = {:signers => account.gpgkey} elsif AccountManager.user_emails.length == 1 # only one account opts = {} else opts = {:signers => from} end opts end end end sup-mainline/lib/sup/draft.rb000066400000000000000000000047441166154264000164770ustar00rootroot00000000000000module Redwood class DraftManager include Singleton attr_accessor :source def initialize dir @dir = dir @source = nil end def self.source_name; "sup://drafts"; end def self.source_id; 9999; end def new_source; @source = DraftLoader.new; end def write_draft offset = @source.gen_offset fn = @source.fn_for_offset offset File.open(fn, "w") { |f| yield f } PollManager.poll_from @source end def discard m raise ArgumentError, "not a draft: source id #{m.source.id.inspect}, should be #{DraftManager.source_id.inspect} for #{m.id.inspect}" unless m.source.id.to_i == DraftManager.source_id Index.delete m.id File.delete @source.fn_for_offset(m.source_info) rescue Errono::ENOENT UpdateManager.relay self, :single_message_deleted, m end end class DraftLoader < Source attr_accessor :dir yaml_properties 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 id; DraftManager.source_id; end def to_s; DraftManager.source_name; end def uri; DraftManager.source_name; end def poll ids = get_ids ids.each do |id| if id >= @cur_offset @cur_offset = id + 1 yield :add, :info => id, :labels => [:draft, :inbox], :progress => 0.0 end end end def gen_offset i = 0 while File.exists? fn_for_offset(i) i += 1 end i end def fn_for_offset o; File.join(@dir, o.to_s); end def load_header offset File.open(fn_for_offset(offset)) { |f| parse_raw_email_header f } end def load_message offset raise SourceError, "Draft not found" unless File.exists? fn_for_offset(offset) File.open fn_for_offset(offset) do |f| RMail::Mailbox::MBoxReader.new(f).each_message do |input| return RMail::Parser.read(input) end end end def raw_header offset ret = "" File.open fn_for_offset(offset) do |f| until f.eof? || (l = f.gets) =~ /^$/ ret += l end end ret end def each_raw_message_line offset File.open(fn_for_offset(offset)) do |f| yield f.gets until f.eof? end end def raw_message offset IO.read(fn_for_offset(offset)) end def start_offset; 0; end def end_offset ids = get_ids ids.empty? ? 0 : (ids.last + 1) end private def get_ids Dir.entries(@dir).select { |x| x =~ /^\d+$/ }.map { |x| x.to_i }.sort end end end sup-mainline/lib/sup/hook.rb000066400000000000000000000057301166154264000163330ustar00rootroot00000000000000module Redwood class HookManager class HookContext def initialize name @__say_id = nil @__name = name @__cache = {} end def say s if BufferManager.instantiated? @__say_id = BufferManager.say s, @__say_id BufferManager.draw_screen else log s end end def log s info "hook[#@__name]: #{s}" end def ask_yes_or_no q if BufferManager.instantiated? BufferManager.ask_yes_or_no q else print q gets.chomp.downcase == 'y' end end def get tag HookManager.tags[tag] end def set tag, value HookManager.tags[tag] = value end def __run __hook, __filename, __locals __binding = binding __lprocs, __lvars = __locals.partition { |k, v| v.is_a?(Proc) } eval __lvars.map { |k, v| "#{k} = __locals[#{k.inspect}];" }.join, __binding ## we also support closures for delays evaluation. unfortunately ## we have to do this via method calls, so you don't get all the ## semantics of a regular variable. not ideal. __lprocs.each do |k, v| self.class.instance_eval do define_method k do @__cache[k] ||= v.call end end end ret = eval __hook, __binding, __filename BufferManager.clear @__say_id if @__say_id @__cache = {} ret end end include Singleton @descs = {} class << self attr_reader :descs end def initialize dir @dir = dir @hooks = {} @contexts = {} @tags = {} Dir.mkdir dir unless File.exists? dir end attr_reader :tags def run name, locals={} hook = hook_for(name) or return context = @contexts[hook] ||= HookContext.new(name) result = nil fn = fn_for name begin result = context.__run hook, fn, locals rescue Exception => e log "error running #{fn}: #{e.message}" log e.backtrace.join("\n") @hooks[name] = nil # disable it BufferManager.flash "Error running hook: #{e.message}" if BufferManager.instantiated? end result end def self.register name, desc @descs[name] = desc end def print_hooks f=$stdout puts < e #debug "disabled hook for '#{name}': #{e.message}" nil end end @hooks[name] end def fn_for name File.join @dir, "#{name}.rb" end def log m info("hook: " + m) end end end sup-mainline/lib/sup/horizontal-selector.rb000066400000000000000000000021541166154264000213770ustar00rootroot00000000000000module 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 sup-mainline/lib/sup/idle.rb000066400000000000000000000013541166154264000163060ustar00rootroot00000000000000require 'thread' module Redwood class IdleManager include Singleton IDLE_THRESHOLD = 60 def initialize @no_activity_since = Time.now @idle = false @thread = nil end def ping if @idle UpdateManager.relay self, :unidle, Time.at(@no_activity_since) @idle = false end @no_activity_since = Time.now end def start @thread = Redwood::reporting_thread("checking for idleness") do while true sleep 1 if !@idle and Time.now.to_i - @no_activity_since.to_i >= IDLE_THRESHOLD UpdateManager.relay self, :idle, Time.at(@no_activity_since) @idle = true end end end end def stop @thread.kill if @thread @thread = nil end end end sup-mainline/lib/sup/index.rb000066400000000000000000000632051166154264000165030ustar00rootroot00000000000000ENV["XAPIAN_FLUSH_THRESHOLD"] = "1000" require 'xapian' require 'set' require 'fileutils' require 'monitor' 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 if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,1]) < 0 fail "Xapian version 1.2.1 or higher required" end module Redwood # This index implementation uses Xapian for searching and storage. It # tends to be slightly faster than Ferret for indexing and significantly faster # for searching due to precomputing thread membership. class Index include InteractiveLock STEM_LANGUAGE = "english" INDEX_VERSION = '4' ## dates are converted to integers for xapian, and are used for document ids, ## so we must ensure they're reasonably valid. this typically only affect ## spam. MIN_DATE = Time.at 0 MAX_DATE = Time.at(2**31-1) HookManager.register "custom-search", < 0, :max_age => nil @sync_worker = nil @sync_queue = Queue.new @index_mutex = Monitor.new end def lockfile; File.join @dir, "lock" end def lock debug "locking #{lockfile}..." begin @lock.lock rescue Lockfile::MaxTriesLockError raise LockError, @lock.lockinfo_on_disk end end def start_lock_update_thread @lock_update_thread = Redwood::reporting_thread("lock update") do while true sleep 30 @lock.touch_yourself end end end def stop_lock_update_thread @lock_update_thread.kill if @lock_update_thread @lock_update_thread = nil end def unlock if @lock && @lock.locked? debug "unlocking #{lockfile}..." @lock.unlock end end def load SourceManager.load_sources load_index end def save debug "saving index and sources..." FileUtils.mkdir_p @dir unless File.exists? @dir SourceManager.save_sources save_index end def load_index path = File.join(@dir, 'xapian') if File.exists? path @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_OPEN) db_version = @xapian.get_metadata 'version' db_version = '0' if db_version.empty? if false info "Upgrading index format #{db_version} to #{INDEX_VERSION}" @xapian.set_metadata 'version', INDEX_VERSION elsif db_version != INDEX_VERSION fail "This Sup version expects a v#{INDEX_VERSION} index, but you have an existing v#{db_version} index. Please run sup-dump to save your labels, move #{path} out of the way, and run sup-sync --restore." end else @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_CREATE) @xapian.set_metadata 'version', INDEX_VERSION @xapian.set_metadata 'rescue-version', '0' end @enquire = Xapian::Enquire.new @xapian @enquire.weighting_scheme = Xapian::BoolWeight.new @enquire.docid_order = Xapian::Enquire::ASCENDING end def add_message m; sync_message m, true end def update_message m; sync_message m, true end def update_message_state m; sync_message m, false end def save_index info "Flushing Xapian updates to disk. This may take a while..." @xapian.flush end def contains_id? id synchronize { find_docid(id) && true } end def contains? m; contains_id? m.id end def size synchronize { @xapian.doccount } end def empty?; size == 0 end ## Yields a message-id and message-building lambda for each ## message that matches the given query, in descending date order. ## You should probably not call this on a block that doesn't break ## rather quickly because the results can be very large. def each_id_by_date query={} each_id(query) { |id| yield id, lambda { build_message id } } end ## Return the number of matches for query in the index def num_results_for query={} xapian_query = build_xapian_query query matchset = run_query xapian_query, 0, 0, 100 matchset.matches_estimated end ## yield all messages in the thread containing 'm' by repeatedly ## querying the index. yields pairs of message ids and ## message-building lambdas, so that building an unwanted message ## can be skipped in the block if desired. ## ## only two options, :limit and :skip_killed. if :skip_killed is ## true, stops loading any thread if a message with a :killed flag ## is found. def each_message_in_thread_for m, opts={} # TODO thread by subject return unless doc = find_doc(m.id) queue = doc.value(THREAD_VALUENO).split(',') msgids = [m.id] seen_threads = Set.new seen_messages = Set.new [m.id] while not queue.empty? thread_id = queue.pop next if seen_threads.member? thread_id return false if opts[:skip_killed] && thread_killed?(thread_id) seen_threads << thread_id docs = term_docids(mkterm(:thread, thread_id)).map { |x| @xapian.document x } docs.each do |doc| msgid = doc.value MSGID_VALUENO next if seen_messages.member? msgid msgids << msgid seen_messages << msgid queue.concat doc.value(THREAD_VALUENO).split(',') end end msgids.each { |id| yield id, lambda { build_message id } } true end ## Load message with the given message-id from the index def build_message id entry = synchronize { get_entry id } return unless entry locations = entry[:locations].map do |source_id,source_info| source = SourceManager[source_id] raise "invalid source #{source_id}" unless source Location.new source, source_info end m = Message.new :locations => locations, :labels => entry[:labels], :snippet => entry[:snippet] # Try to find person from contacts before falling back to # generating it from the address. mk_person = lambda { |x| Person.from_name_and_email(*x.reverse!) } entry[:from] = mk_person[entry[:from]] entry[:to].map!(&mk_person) entry[:cc].map!(&mk_person) entry[:bcc].map!(&mk_person) m.load_from_index! entry m end ## Delete message with the given message-id from the index def delete id synchronize { @xapian.delete_document mkterm(:msgid, id) } end ## Given an array of email addresses, return an array of Person objects that ## have sent mail to or received mail from any of the given addresses. def load_contacts email_addresses, opts={} contacts = Set.new num = opts[:num] || 20 each_id_by_date :participants => email_addresses do |id,b| break if contacts.size >= num m = b.call ([m.from]+m.to+m.cc+m.bcc).compact.each { |p| contacts << [p.name, p.email] } end contacts.to_a.compact[0...num].map { |n,e| Person.from_name_and_email n, e } end ## Yield each message-id matching query EACH_ID_PAGE = 100 def each_id query={} offset = 0 page = EACH_ID_PAGE xapian_query = build_xapian_query query while true ids = run_query_ids xapian_query, offset, (offset+page) ids.each { |id| yield id } break if ids.size < page offset += page end end ## Yield each message matching query def each_message query={}, &b each_id query do |id| yield build_message(id) end end # wrap all future changes inside a transaction so they're done atomically def begin_transaction synchronize { @xapian.begin_transaction } end # complete the transaction and write all previous changes to disk def commit_transaction synchronize { @xapian.commit_transaction } end # abort the transaction and revert all changes made since begin_transaction def cancel_transaction synchronize { @xapian.cancel_transaction } end ## xapian-compact takes too long, so this is a no-op ## until we think of something better def optimize end ## Return the id source of the source the message with the given message-id ## was synced from def source_for_id id synchronize { get_entry(id)[:source_id] } end ## Yields each tearm in the index that starts with prefix def each_prefixed_term prefix term = @xapian._dangerous_allterms_begin prefix lastTerm = @xapian._dangerous_allterms_end prefix until term.equals lastTerm yield term.term term.next end nil end ## Yields (in lexicographical order) the source infos of all locations from ## the given source with the given source_info prefix def each_source_info source_id, prefix='', &b prefix = mkterm :location, source_id, prefix each_prefixed_term prefix do |x| yield x[prefix.length..-1] end end class ParseError < StandardError; end ## parse a query string from the user. returns a query object ## that can be passed to any index method with a 'query' ## argument. ## ## raises a ParseError if something went wrong. def parse_query s query = {} subs = HookManager.run("custom-search", :subs => s) || s begin subs = SearchManager.expand subs rescue SearchManager::ExpansionError => e raise ParseError, e.message end subs = subs.gsub(/\b(to|from):(\S+)\b/) do field, value = $1, $2 email_field, name_field = %w(email name).map { |x| "#{field}_#{x}" } if(p = ContactManager.contact_for(value)) "#{email_field}:#{p.email}" elsif value == "me" '(' + AccountManager.user_emails.map { |e| "#{email_field}:#{e}" }.join(' OR ') + ')' else "(#{email_field}:#{value} OR #{name_field}:#{value})" end end ## gmail style "is" operator subs = subs.gsub(/\b(is|has):(\S+)\b/) do field, label = $1, $2 case label when "read" "-label:unread" when "spam" query[:load_spam] = true "label:spam" when "deleted" query[:load_deleted] = true "label:deleted" else "label:#{$2}" end end ## labels are stored lower-case in the index subs = subs.gsub(/\blabel:(\S+)\b/) do label = $1 "label:#{label.downcase}" end ## if we see a label:deleted or a label:spam term anywhere in the query ## string, we set the extra load_spam or load_deleted options to true. ## bizarre? well, because the query allows arbitrary parenthesized boolean ## expressions, without fully parsing the query, we can't tell whether ## the user is explicitly directing us to search spam messages or not. ## e.g. if the string is -(-(-(-(-label:spam)))), does the user want to ## search spam messages or not? ## ## so, we rely on the fact that turning these extra options ON turns OFF ## the adding of "-label:deleted" or "-label:spam" terms at the very ## final stage of query processing. if the user wants to search spam ## messages, not adding that is the right thing; if he doesn't want to ## search spam messages, then not adding it won't have any effect. query[:load_spam] = true if subs =~ /\blabel:spam\b/ query[:load_deleted] = true if subs =~ /\blabel:deleted\b/ query[:load_killed] = true if subs =~ /\blabel:killed\b/ ## gmail style attachments "filename" and "filetype" searches subs = subs.gsub(/\b(filename|filetype):(\((.+?)\)\B|(\S+)\b)/) do field, name = $1, ($3 || $4) case field when "filename" debug "filename: translated #{field}:#{name} to attachment:\"#{name.downcase}\"" "attachment:\"#{name.downcase}\"" when "filetype" debug "filetype: translated #{field}:#{name} to attachment_extension:#{name.downcase}" "attachment_extension:#{name.downcase}" 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 else raise ParseError, "can't understand date #{datestr.inspect}" end end end ## limit:42 restrict the search to 42 results subs = subs.gsub(/\blimit:(\S+)\b/) do lim = $1 if lim =~ /^\d+$/ query[:limit] = lim.to_i '' else raise ParseError, "non-numeric limit #{lim.inspect}" end end debug "translated query: #{subs.inspect}" qp = Xapian::QueryParser.new qp.database = @xapian qp.stemmer = Xapian::Stem.new(STEM_LANGUAGE) qp.stemming_strategy = Xapian::QueryParser::STEM_SOME qp.default_op = Xapian::Query::OP_AND qp.add_valuerangeprocessor(Xapian::NumberValueRangeProcessor.new(DATE_VALUENO, 'date:', true)) NORMAL_PREFIX.each { |k,info| info[:prefix].each { |v| qp.add_prefix k, v } } BOOLEAN_PREFIX.each { |k,info| info[:prefix].each { |v| qp.add_boolean_prefix k, v, info[:exclusive] } } begin xapian_query = qp.parse_query(subs, Xapian::QueryParser::FLAG_PHRASE|Xapian::QueryParser::FLAG_BOOLEAN|Xapian::QueryParser::FLAG_LOVEHATE|Xapian::QueryParser::FLAG_WILDCARD) rescue RuntimeError => e raise ParseError, "xapian query parser error: #{e}" end debug "parsed xapian query: #{xapian_query.description}" raise ParseError if xapian_query.nil? or xapian_query.empty? query[:qobj] = xapian_query query[:text] = s query end def save_thread t t.each_dirty_message do |m| if @sync_worker @sync_queue << m else update_message_state m end m.clear_dirty end end def start_sync_worker @sync_worker = Redwood::reporting_thread('index sync') { run_sync_worker } end def stop_sync_worker return unless worker = @sync_worker @sync_worker = nil @sync_queue << :die worker.join end def run_sync_worker while m = @sync_queue.deq return if m == :die update_message_state m # Necessary to keep Xapian calls from lagging the UI too much. sleep 0.03 end end 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 MAX_TERM_LENGTH = 245 # Xapian can very efficiently sort in ascending docid order. Sup always wants # to sort by descending date, so this method maps between them. In order to # handle multiple messages per second, we use a logistic curve centered # around MIDDLE_DATE so that the slope (docid/s) is greatest in this time # period. A docid collision is not an error - the code will pick the next # smallest unused one. DOCID_SCALE = 2.0**32 TIME_SCALE = 2.0**27 MIDDLE_DATE = Time.gm(2011) def assign_docid m, truncated_date t = (truncated_date.to_i - MIDDLE_DATE.to_i).to_f docid = (DOCID_SCALE - DOCID_SCALE/(Math::E**(-(t/TIME_SCALE)) + 1)).to_i while docid > 0 and docid_exists? docid docid -= 1 end docid > 0 ? docid : nil end # XXX is there a better way? def docid_exists? docid begin @xapian.doclength docid true rescue RuntimeError #Xapian::DocNotFoundError raise unless $!.message =~ /DocNotFoundError/ false end end def term_docids term @xapian.postlist(term).map { |x| x.docid } end def find_docid id docids = term_docids(mkterm(:msgid,id)) fail unless docids.size <= 1 docids.first end def find_doc id return unless docid = find_docid(id) @xapian.document docid end def get_id docid return unless doc = @xapian.document(docid) doc.value MSGID_VALUENO end def get_entry id return unless doc = find_doc(id) doc.entry end def thread_killed? thread_id not run_query(Q.new(Q::OP_AND, mkterm(:thread, thread_id), mkterm(:label, :Killed)), 0, 1).empty? end def synchronize &b @index_mutex.synchronize &b end def run_query xapian_query, offset, limit, checkatleast=0 synchronize do @enquire.query = xapian_query @enquire.mset(offset, limit-offset, checkatleast) end end def run_query_ids xapian_query, offset, limit matchset = run_query xapian_query, offset, limit matchset.matches.map { |r| r.document.value MSGID_VALUENO } end Q = Xapian::Query def build_xapian_query opts labels = ([opts[:label]] + (opts[:labels] || [])).compact neglabels = [:spam, :deleted, :killed].reject { |l| (labels.include? l) || opts.member?("load_#{l}".intern) } pos_terms, neg_terms = [], [] pos_terms << mkterm(:type, 'mail') pos_terms.concat(labels.map { |l| mkterm(:label,l) }) pos_terms << opts[:qobj] if opts[:qobj] pos_terms << mkterm(:source_id, opts[:source_id]) if opts[:source_id] pos_terms << mkterm(:location, *opts[:location]) if opts[:location] if opts[:participants] participant_terms = opts[:participants].map { |p| [:from,:to].map { |d| mkterm(:email, d, (Redwood::Person === p) ? p.email : p) } }.flatten pos_terms << Q.new(Q::OP_OR, participant_terms) end neg_terms.concat(neglabels.map { |l| mkterm(:label,l) }) pos_query = Q.new(Q::OP_AND, pos_terms) neg_query = Q.new(Q::OP_OR, neg_terms) if neg_query.empty? pos_query else Q.new(Q::OP_AND_NOT, [pos_query, neg_query]) end end def sync_message m, overwrite doc = synchronize { find_doc(m.id) } existed = doc != nil doc ||= Xapian::Document.new do_index_static = overwrite || !existed old_entry = !do_index_static && doc.entry snippet = do_index_static ? m.snippet : old_entry[:snippet] entry = { :message_id => m.id, :locations => m.locations.map { |x| [x.source.id, x.info] }, :date => truncate_date(m.date), :snippet => snippet, :labels => m.labels.to_a, :from => [m.from.email, m.from.name], :to => m.to.map { |p| [p.email, p.name] }, :cc => m.cc.map { |p| [p.email, p.name] }, :bcc => m.bcc.map { |p| [p.email, p.name] }, :subject => m.subj, :refs => m.refs.to_a, :replytos => m.replytos.to_a, } if do_index_static doc.clear_terms doc.clear_values index_message_static m, doc, entry end index_message_locations doc, entry, old_entry index_message_threading doc, entry, old_entry index_message_labels doc, entry[:labels], (do_index_static ? [] : old_entry[:labels]) doc.entry = entry synchronize do unless docid = existed ? doc.docid : assign_docid(m, truncate_date(m.date)) # Could be triggered by spam warn "docid underflow, dropping #{m.id.inspect}" return end @xapian.replace_document docid, doc end m.labels.each { |l| LabelManager << l } true end ## Index content that can't be changed by the user def index_message_static m, doc, entry # Person names are indexed with several prefixes person_termer = lambda do |d| lambda do |p| doc.index_text p.name, PREFIX["#{d}_name"][:prefix] if p.name doc.index_text p.email, PREFIX['email_text'][:prefix] doc.add_term mkterm(:email, d, p.email) end end person_termer[:from][m.from] if m.from (m.to+m.cc+m.bcc).each(&(person_termer[:to])) # Full text search content subject_text = m.indexable_subject body_text = m.indexable_body doc.index_text subject_text, PREFIX['subject'][:prefix] doc.index_text body_text, PREFIX['body'][:prefix] m.attachments.each { |a| doc.index_text a, PREFIX['attachment'][:prefix] } # Miscellaneous terms doc.add_term mkterm(:date, m.date) if m.date doc.add_term mkterm(:type, 'mail') doc.add_term mkterm(:msgid, m.id) m.attachments.each do |a| a =~ /\.(\w+)$/ or next doc.add_term mkterm(:attachment_extension, $1) end # Date value for range queries date_value = begin Xapian.sortable_serialise m.date.to_i rescue TypeError Xapian.sortable_serialise 0 end doc.add_value MSGID_VALUENO, m.id doc.add_value DATE_VALUENO, date_value end def index_message_locations doc, entry, old_entry old_entry[:locations].map { |x| x[0] }.uniq.each { |x| doc.remove_term mkterm(:source_id, x) } if old_entry entry[:locations].map { |x| x[0] }.uniq.each { |x| doc.add_term mkterm(:source_id, x) } old_entry[:locations].each { |x| (doc.remove_term mkterm(:location, *x) rescue nil) } if old_entry entry[:locations].each { |x| doc.add_term mkterm(:location, *x) } end def index_message_labels doc, new_labels, old_labels return if new_labels == old_labels added = new_labels.to_a - old_labels.to_a removed = old_labels.to_a - new_labels.to_a added.each { |t| doc.add_term mkterm(:label,t) } removed.each { |t| doc.remove_term mkterm(:label,t) } end ## Assign a set of thread ids to the document. This is a hybrid of the runtime ## search done by the Ferret index and the index-time union done by previous ## versions of the Xapian index. We first find the thread ids of all messages ## with a reference to or from us. If that set is empty, we use our own ## message id. Otherwise, we use all the thread ids we previously found. In ## the common case there's only one member in that set, but if we're the ## missing link between multiple previously unrelated threads we can have ## more. XapianIndex#each_message_in_thread_for follows the thread ids when ## searching so the user sees a single unified thread. def index_message_threading doc, entry, old_entry return if old_entry && (entry[:refs] == old_entry[:refs]) && (entry[:replytos] == old_entry[:replytos]) children = term_docids(mkterm(:ref, entry[:message_id])).map { |docid| @xapian.document docid } parent_ids = entry[:refs] + entry[:replytos] parents = parent_ids.map { |id| find_doc id }.compact thread_members = SavingHash.new { [] } (children + parents).each do |doc2| thread_ids = doc2.value(THREAD_VALUENO).split ',' thread_ids.each { |thread_id| thread_members[thread_id] << doc2 } end thread_ids = thread_members.empty? ? [entry[:message_id]] : thread_members.keys thread_ids.each { |thread_id| doc.add_term mkterm(:thread, thread_id) } parent_ids.each { |ref| doc.add_term mkterm(:ref, ref) } doc.add_value THREAD_VALUENO, (thread_ids * ',') end def truncate_date date if date < MIN_DATE debug "warning: adjusting too-low date #{date} for indexing" MIN_DATE elsif date > MAX_DATE debug "warning: adjusting too-high date #{date} for indexing" MAX_DATE else date end end # Construct a Xapian term def mkterm type, *args case type when :label PREFIX['label'][:prefix] + args[0].to_s.downcase when :type PREFIX['type'][:prefix] + args[0].to_s.downcase when :date PREFIX['date'][:prefix] + args[0].getutc.strftime("%Y%m%d%H%M%S") when :email case args[0] when :from then PREFIX['from_email'][:prefix] when :to then PREFIX['to_email'][:prefix] else raise "Invalid email term type #{args[0]}" end + args[1].to_s.downcase when :source_id PREFIX['source_id'][:prefix] + args[0].to_s.downcase when :location PREFIX['location'][:prefix] + [args[0]].pack('n') + args[1].to_s when :attachment_extension PREFIX['attachment_extension'][:prefix] + args[0].to_s.downcase when :msgid, :ref, :thread PREFIX[type.to_s][:prefix] + args[0][0...(MAX_TERM_LENGTH-1)] else raise "Invalid term type #{type}" end end end end class Xapian::Document def entry Marshal.load data end def entry=(x) self.data = Marshal.dump x end def index_text text, prefix, weight=1 term_generator = Xapian::TermGenerator.new term_generator.stemmer = Xapian::Stem.new(Redwood::Index::STEM_LANGUAGE) term_generator.document = self term_generator.index_text text, weight, prefix end alias old_add_term add_term def add_term term if term.length <= Redwood::Index::MAX_TERM_LENGTH old_add_term term, 0 else warn "dropping excessively long term #{term}" end end end sup-mainline/lib/sup/interactive-lock.rb000066400000000000000000000036121166154264000206330ustar00rootroot00000000000000require 'fileutils' module Redwood ## wrap a nice interactive layer on top of anything that has a #lock method ## which throws a LockError which responds to #user, #host, #mtim, #pname, and ## #pid. module InteractiveLock def pluralize number_of, kind; "#{number_of} #{kind}" + (number_of == 1 ? "" : "s") end def time_ago_in_words time secs = (Time.now - time).to_i mins = secs / 60 time = if mins == 0 pluralize secs, "second" else pluralize mins, "minute" end end DELAY = 5 # seconds def lock_interactively stream=$stderr begin Index.lock rescue Index::LockError => e stream.puts < e stream.puts "I couldn't lock the index. The lockfile might just be stale." stream.print "Should I just remove it and continue? (y/n) " stream.flush if $stdin.gets =~ /^\s*y(es)?\s*$/i FileUtils.rm e.path stream.puts "Let's try that one more time." begin Index.lock true rescue Index::LockError => e end end end end stream.puts "Sorry, couldn't unlock the index." unless success success end end end end sup-mainline/lib/sup/keymap.rb000066400000000000000000000066071166154264000166650ustar00rootroot00000000000000module Redwood class Keymap HookManager.register "keybindings", <" when :up then "" when :left then "" when :right then "" when :page_down then "" when :page_up then "" when :backspace then "" when :home then "" when :end then "" when :enter, :return then "" when :tab then "tab" when " " then "" else Curses::keyname(keysym_to_keycode(k)) end end def add action, help, *keys entry = [action, help, keys] @order << entry keys.each do |k| kc = Keymap.keysym_to_keycode k raise ArgumentError, "key '#{k}' already defined (as #{@map[kc].first})" if @map.include? kc @map[kc] = entry end end def delete k kc = Keymap.keysym_to_keycode(k) return unless @map.member? kc entry = @map.delete kc keys = entry[2] keys.delete k @order.delete entry if keys.empty? end def add! action, help, *keys keys.each { |k| delete k } add action, help, *keys end def add_multi prompt, key kc = Keymap.keysym_to_keycode(key) if @map.member? kc action = @map[kc].first raise "existing action is not a keymap" unless action.is_a?(Keymap) yield action else submap = Keymap.new add submap, prompt, key yield submap end end def action_for kc action, help, keys = @map[kc] [action, help] end def has_key? k; @map[k] end def keysyms; @map.values.map { |action, help, keys| keys }.flatten; end def help_lines except_for={}, prefix="" lines = [] # :( @order.each do |action, help, keys| valid_keys = keys.select { |k| !except_for[k] } next if valid_keys.empty? case action when Symbol lines << [valid_keys.map { |k| prefix + Keymap.keysym_to_string(k) }.join(", "), help] when Keymap lines += action.help_lines({}, prefix + Keymap.keysym_to_string(keys.first)) end end.compact lines end def help_text except_for={} lines = help_lines except_for llen = lines.max_of { |a, b| a.length } lines.map { |a, b| sprintf " %#{llen}s : %s", a, b }.join("\n") end def self.run_hook global_keymap modes = Hash[Mode.keymaps.map { |klass,keymap| [Mode.make_name(klass.name),klass] }] locals = { :modes => modes, :global_keymap => global_keymap, } HookManager.run 'keybindings', locals end end end sup-mainline/lib/sup/label.rb000066400000000000000000000036211166154264000164470ustar00rootroot00000000000000module Redwood class LabelManager include Singleton ## labels that have special semantics. user will be unable to ## add/remove these via normal label mechanisms. RESERVED_LABELS = [ :starred, :spam, :draft, :unread, :killed, :sent, :deleted, :inbox, :attachment ] ## labels that will typically be hidden from the user HIDDEN_RESERVED_LABELS = [ :starred, :unread, :attachment ] def initialize fn @fn = fn labels = if File.exists? fn IO.readlines(fn).map { |x| x.chomp.intern } else [] end @labels = {} @new_labels = {} @modified = false labels.each { |t| @labels[t] = true } end def new_label? l; @new_labels.include?(l) end ## all labels user-defined and system, ordered ## nicely and converted to pretty strings. use #label_for to recover ## the original label. def all_labels ## uniq's only necessary here because of certain upgrade issues (RESERVED_LABELS + @labels.keys).uniq end ## all user-defined labels, ordered ## nicely and converted to pretty strings. use #label_for to recover ## the original label. def user_defined_labels @labels.keys end ## reverse the label->string mapping, for convenience! def string_for l if RESERVED_LABELS.include? l l.to_s.capitalize else l.to_s end end def label_for s l = s.intern l2 = s.downcase.intern if RESERVED_LABELS.include? l2 l2 else l end end def << t raise ArgumentError, "expecting a symbol" unless t.is_a? Symbol unless @labels.member?(t) || RESERVED_LABELS.member?(t) @labels[t] = true @new_labels[t] = true @modified = true end end def delete t if @labels.delete(t) @modified = true end end def save return unless @modified File.open(@fn, "w") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } } @new_labels = {} end end end sup-mainline/lib/sup/logger.rb000066400000000000000000000035521166154264000166520ustar00rootroot00000000000000require "sup" require 'stringio' require 'thread' module Redwood ## simple centralized logger. outputs to multiple sinks by calling << on them. ## also keeps a record of all messages, so that adding a new sink will send all ## previous messages to it by default. class Logger include Singleton LEVELS = %w(debug info warn error) # in order! def initialize level=nil level ||= ENV["SUP_LOG_LEVEL"] || "info" self.level = level @mutex = Mutex.new @buf = StringIO.new @sinks = [] end def level; LEVELS[@level] end def level=(level); @level = LEVELS.index(level) || raise(ArgumentError, "invalid log level #{level.inspect}: should be one of #{LEVELS * ', '}"); end def add_sink s, copy_current=true @mutex.synchronize do @sinks << s s << @buf.string if copy_current end end def remove_sink s; @mutex.synchronize { @sinks.delete s } end def remove_all_sinks!; @mutex.synchronize { @sinks.clear } end def clear!; @mutex.synchronize { @buf = StringIO.new } end LEVELS.each_with_index do |l, method_level| define_method(l) do |s| if method_level >= @level send_message format_message(l, Time.now, s) end end end ## send a message regardless of the current logging level def force_message m; send_message format_message(nil, Time.now, m) end private ## level can be nil! def format_message level, time, msg prefix = case level when "warn"; "WARNING: " when "error"; "ERROR: " else "" end "[#{time.to_s}] #{prefix}#{msg.rstrip}\n" end ## actually distribute the message def send_message m @mutex.synchronize do @sinks.each { |sink| sink << m } @buf << m end end end ## include me to have top-level #debug, #info, etc. methods. module LogsStuff Logger::LEVELS.each { |l| define_method(l) { |s| Logger.instance.send(l, s) } } end end sup-mainline/lib/sup/maildir.rb000066400000000000000000000125461166154264000170170ustar00rootroot00000000000000require 'uri' module Redwood class Maildir < Source include SerializeLabelsNicely MYHOSTNAME = Socket.gethostname ## remind me never to use inheritance again. yaml_properties :uri, :usual, :archived, :id, :labels def initialize uri, usual=true, archived=false, id=nil, labels=[] super uri, usual, archived, id @expanded_uri = Source.expand_filesystem_uri(uri) uri = URI(@expanded_uri) raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir" raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" if uri.host raise ArgumentError, "maildir URI must have a path component" unless uri.path @dir = uri.path @labels = Set.new(labels || []) @mutex = Mutex.new @mtimes = { 'cur' => Time.at(0), 'new' => Time.at(0) } end def file_path; @dir end def self.suggest_labels_for path; [] end def is_source_for? uri; super || (uri == @expanded_uri); end def store_message date, from_email, &block stored = false new_fn = new_maildir_basefn + ':2,S' Dir.chdir(@dir) do |d| tmp_path = File.join(@dir, 'tmp', new_fn) new_path = File.join(@dir, 'new', new_fn) begin sleep 2 if File.stat(tmp_path) File.stat(tmp_path) rescue Errno::ENOENT #this is what we want. begin File.open(tmp_path, 'wb') do |f| yield f #provide a writable interface for the caller f.fsync end File.link tmp_path, new_path stored = true ensure File.unlink tmp_path if File.exists? tmp_path end end #rescue Errno... end #Dir.chdir stored end def each_raw_message_line id with_file_for(id) do |f| until f.eof? yield f.gets end end end def load_header id with_file_for(id) { |f| parse_raw_email_header f } end def load_message id with_file_for(id) { |f| RMail::Parser.read f } end def raw_header id ret = "" with_file_for(id) do |f| until f.eof? || (l = f.gets) =~ /^$/ ret += l end end ret end def raw_message id with_file_for(id) { |f| f.read } end ## XXX use less memory def poll @mtimes.each do |d,prev_mtime| subdir = File.join @dir, d debug "polling maildir #{subdir}" raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir mtime = File.mtime subdir next if prev_mtime >= mtime @mtimes[d] = mtime old_ids = benchmark(:maildir_read_index) { Enumerator.new(Index.instance, :each_source_info, self.id, "#{d}/").to_a } new_ids = benchmark(:maildir_read_dir) { Dir.glob("#{subdir}/*").map { |x| File.basename x }.sort } added = new_ids - old_ids deleted = old_ids - new_ids debug "#{old_ids.size} in index, #{new_ids.size} in filesystem" debug "#{added.size} added, #{deleted.size} deleted" added.each_with_index do |id,i| yield :add, :info => File.join(d,id), :labels => @labels + maildir_labels(id) + [:inbox], :progress => i.to_f/(added.size+deleted.size) end deleted.each_with_index do |id,i| yield :delete, :info => File.join(d,id), :progress => (i.to_f+added.size)/(added.size+deleted.size) end end nil end def maildir_labels id (seen?(id) ? [] : [:unread]) + (trashed?(id) ? [:deleted] : []) + (flagged?(id) ? [:starred] : []) end def draft? id; maildir_data(id)[2].include? "D"; end def flagged? id; maildir_data(id)[2].include? "F"; end def passed? id; maildir_data(id)[2].include? "P"; end def replied? id; maildir_data(id)[2].include? "R"; end def seen? id; maildir_data(id)[2].include? "S"; end def trashed? id; maildir_data(id)[2].include? "T"; end def mark_draft id; maildir_mark_file id, "D" unless draft? id; end def mark_flagged id; maildir_mark_file id, "F" unless flagged? id; end def mark_passed id; maildir_mark_file id, "P" unless passed? id; end def mark_replied id; maildir_mark_file id, "R" unless replied? id; end def mark_seen id; maildir_mark_file id, "S" unless seen? id; end def mark_trashed id; maildir_mark_file id, "T" unless trashed? id; end def valid? id File.exists? File.join(@dir, id) end private def new_maildir_basefn Kernel::srand() "#{Time.now.to_i.to_s}.#{$$}#{Kernel.rand(1000000)}.#{MYHOSTNAME}" end def with_file_for id fn = File.join(@dir, id) begin File.open(fn, 'rb') { |f| yield f } rescue SystemCallError, IOError => e raise FatalSourceError, "Problem reading file for id #{id.inspect}: #{fn.inspect}: #{e.message}." end end def maildir_data id id =~ %r{^([^:]+):([12]),([DFPRST]*)$} [($1 || id), ($2 || "2"), ($3 || "")] end ## not thread-safe on msg def maildir_mark_file msg, flag orig_path = @ids_to_fns[msg] orig_base, orig_fn = File.split(orig_path) new_base = orig_base.slice(0..-4) + 'cur' tmp_base = orig_base.slice(0..-4) + 'tmp' md_base, md_ver, md_flags = maildir_data msg md_flags += flag; md_flags = md_flags.split(//).sort.join.squeeze new_path = File.join new_base, "#{md_base}:#{md_ver},#{md_flags}" tmp_path = File.join tmp_base, "#{md_base}:#{md_ver},#{md_flags}" File.link orig_path, tmp_path File.unlink orig_path File.link tmp_path, new_path File.unlink tmp_path @ids_to_fns[msg] = new_path end end end sup-mainline/lib/sup/mbox.rb000066400000000000000000000112031166154264000163300ustar00rootroot00000000000000require 'uri' require 'set' module Redwood class MBox < Source BREAK_RE = /^From \S+ (.+)$/ include SerializeLabelsNicely yaml_properties :uri, :usual, :archived, :id, :labels attr_reader :labels ## uri_or_fp is horrific. need to refactor. def initialize uri_or_fp, usual=true, archived=false, id=nil, labels=nil @mutex = Mutex.new @labels = Set.new((labels || []) - LabelManager::RESERVED_LABELS) case uri_or_fp when String @expanded_uri = Source.expand_filesystem_uri(uri_or_fp) uri = URI(@expanded_uri) raise ArgumentError, "not an mbox uri" unless uri.scheme == "mbox" raise ArgumentError, "mbox URI ('#{uri}') cannot have a host: #{uri.host}" if uri.host raise ArgumentError, "mbox URI must have a path component" unless uri.path @f = nil @path = uri.path else @f = uri_or_fp @path = uri_or_fp.path @expanded_uri = "mbox://#{@path}" end super uri_or_fp, usual, archived, id end def file_path; @path end def is_source_for? uri; super || (uri == @expanded_uri) end def self.suggest_labels_for path ## heuristic: use the filename as a label, unless the file ## has a path that probably represents an inbox. if File.dirname(path) =~ /\b(var|usr|spool)\b/ [] else [File.basename(path).downcase.intern] end end def ensure_open @f = File.open @path, 'rb' if @f.nil? end private :ensure_open def go_idle @mutex.synchronize do return if @f.nil? or @path.nil? @f.close @f = nil end end def load_header offset header = nil @mutex.synchronize do ensure_open @f.seek offset header = parse_raw_email_header @f end header end def load_message offset @mutex.synchronize do ensure_open @f.seek offset begin ## don't use RMail::Mailbox::MBoxReader because it doesn't properly ignore ## "From" at the start of a message body line. string = "" until @f.eof? || MBox::is_break_line?(l = @f.gets) string << l end RMail::Parser.read string rescue RMail::Parser::Error => e raise FatalSourceError, "error parsing mbox file: #{e.message}" end end end def raw_header offset ret = "" @mutex.synchronize do ensure_open @f.seek offset until @f.eof? || (l = @f.gets) =~ /^\r*$/ ret << l end end ret end def raw_message offset ret = "" each_raw_message_line(offset) { |l| ret << l } ret end def store_message date, from_email, &block need_blank = File.exists?(@path) && !File.zero?(@path) File.open(@path, "ab") do |f| f.puts if need_blank f.puts "From #{from_email} #{date.asctime}" yield f end end ## apparently it's a million times faster to call this directly if ## we're just moving messages around on disk, than reading things ## into memory with raw_message. ## ## i hoped never to have to move shit around on disk but ## sup-sync-back has to do it. def each_raw_message_line offset @mutex.synchronize do ensure_open @f.seek offset until @f.eof? || MBox::is_break_line?(l = @f.gets) yield l end end end def default_labels [:inbox, :unread] end def poll first_offset = first_new_message offset = first_offset end_offset = File.size @f while offset and offset < end_offset yield :add, :info => offset, :labels => (labels + default_labels), :progress => (offset - first_offset).to_f/end_offset offset = next_offset offset end end def next_offset offset @mutex.synchronize do ensure_open @f.seek offset nil while line = @f.gets and not MBox::is_break_line? line offset = @f.tell offset != File.size(@f) ? offset : nil end end ## TODO optimize this by iterating over allterms list backwards or ## storing source_info negated def last_indexed_message benchmark(:mbox_read_index) { Enumerator.new(Index.instance, :each_source_info, self.id).map(&:to_i).max } end ## offset of first new message or nil def first_new_message next_offset(last_indexed_message || 0) end def self.is_break_line? l l =~ BREAK_RE or return false time = $1 begin ## hack -- make Time.parse fail when trying to substitute values from Time.now Time.parse time, 0 true rescue NoMethodError, ArgumentError warn "found invalid date in potential mbox split line, not splitting: #{l.inspect}" false end end class Loader < self yaml_properties :uri, :usual, :archived, :id, :labels end end end sup-mainline/lib/sup/message-chunks.rb000066400000000000000000000217611166154264000203120ustar00rootroot00000000000000require 'tempfile' ## 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", < 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 Config::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 sup-mainline/lib/sup/message.rb000066400000000000000000000566701166154264000170300ustar00rootroot00000000000000require 'time' module Redwood ## a Message is what's threaded. ## ## it is also where the parsing for quotes and signatures is done, but ## that should be moved out to a separate class at some point (because ## i would like, for example, to be able to add in a ruby-talk ## specific module that would detect and link to /ruby-talk:\d+/ ## sequences in the text of an email. (how sweet would that be?) class Message SNIPPET_LEN = 80 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i ## some utility methods class << self def normalize_subj s; s.gsub(RE_PATTERN, ""); end def subj_is_reply? s; s =~ RE_PATTERN; end def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end end QUOTE_PATTERN = /^\s{0,4}[>|\}]/ BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/ SIG_PATTERN = /(^(- )*-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/ GPG_SIGNED_START = "-----BEGIN PGP SIGNED MESSAGE-----" GPG_SIGNED_END = "-----END PGP SIGNED MESSAGE-----" GPG_START = "-----BEGIN PGP MESSAGE-----" GPG_END = "-----END PGP MESSAGE-----" GPG_SIG_START = "-----BEGIN PGP SIGNATURE-----" GPG_SIG_END = "-----END PGP SIGNATURE-----" MAX_SIG_DISTANCE = 15 # lines from the end DEFAULT_SUBJECT = "" DEFAULT_SENDER = "(missing sender)" MAX_HEADER_VALUE_SIZE = 4096 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :cc, :bcc, :labels, :attachments, :list_address, :recipient_email, :replyto, :list_subscribe, :list_unsubscribe bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content attr_accessor :locations ## if you specify a :header, will use values from that. otherwise, ## will try and load the header from the source. def initialize opts @locations = opts[:locations] or raise ArgumentError, "locations can't be nil" @snippet = opts[:snippet] @snippet_contains_encrypted_content = false @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?) @labels = Set.new(opts[:labels] || []) @dirty = false @encrypted = false @chunks = nil @attachments = [] ## we need to initialize this. see comments in parse_header as to ## why. @refs = [] #parse_header(opts[:header] || @source.load_header(@source_info)) end def decode_header_field v 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) end def parse_header encoded_header header = SavingHash.new { |k| decode_header_field encoded_header[k] } @id = '' if header["message-id"] mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"] @id = sanitize_message_id mid end if (not @id.include? '@') || @id.length < 6 @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header) #from = header["from"] #debug "faking non-existent message-id for message from #{from}: #{id}" end @from = Person.from_address(if header["from"] header["from"] else name = "Sup Auto-generated Fake Sender " #debug "faking non-existent sender for message #@id: #{name}" name end) @date = case(date = header["date"]) when Time date when String begin Time.parse date rescue ArgumentError => e #debug "faking mangled date header for #{@id} (orig #{header['date'].inspect} gave error: #{e.message})" Time.now end else #debug "faking non-existent date header for #{@id}" Time.now end @subj = header["subject"] ? header["subject"].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"] ## before loading our full header from the source, we can actually ## have some extra refs set by the UI. (this happens when the user ## joins threads manually). so we will merge the current refs values ## in here. refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first } @refs = (@refs + refs).uniq @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first } @replyto = Person.from_address header["reply-to"] @list_address = if header["list-post"] address = if header["list-post"] =~ /mailto:(.*?)[>\s$]/ $1 elsif header["list-post"] =~ /@/ header["list-post"] # just try the whole fucking thing end address && Person.from_address(address) elsif header["x-mailing-list"] Person.from_address header["x-mailing-list"] end @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"] @source_marked_read = header["status"] == "RO" @list_subscribe = header["list-subscribe"] @list_unsubscribe = header["list-unsubscribe"] end ## Expected index entry format: ## :message_id, :subject => String ## :date => Time ## :refs, :replytos => Array of String ## :from => Person ## :to, :cc, :bcc => Array of Person def load_from_index! entry @id = entry[:message_id] @from = entry[:from] @date = entry[:date] @subj = entry[:subject] @to = entry[:to] @cc = entry[:cc] @bcc = entry[:bcc] @refs = (@refs + entry[:refs]).uniq @replytos = entry[:replytos] @replyto = nil @list_address = nil @recipient_email = nil @source_marked_read = false @list_subscribe = nil @list_unsubscribe = nil end def add_ref ref @refs << ref @dirty = true end def remove_ref ref @dirty = true if @refs.delete ref end attr_reader :snippet def is_list_message?; !@list_address.nil?; end def is_draft?; @labels.member? :draft; end def draft_filename raise "not a draft" unless is_draft? source.fn_for_offset source_info end ## sanitize message ids by removing spaces and non-ascii characters. ## also, truncate to 255 characters. all these steps are necessary ## to make the index happy. of course, we probably fuck up a couple ## valid message ids as well. as long as we're consistent, this ## should be fine, though. ## ## also, mostly the message ids that are changed by this belong to ## spam email. ## ## an alternative would be to SHA1 or MD5 all message ids on a regular basis. ## don't tempt me. def sanitize_message_id mid; mid.gsub(/(\s|[^\000-\177])+/, "")[0..254] end def clear_dirty @dirty = false end def has_label? t; @labels.member? t; end def add_label l l = l.to_sym return if @labels.member? l @labels << l @dirty = true end def remove_label l l = l.to_sym return unless @labels.member? l @labels.delete l @dirty = true end def recipients @to + @cc + @bcc end def labels= l raise ArgumentError, "not a set" unless l.is_a?(Set) raise ArgumentError, "not a set of labels" unless l.all? { |ll| ll.is_a?(Symbol) } return if @labels == l @labels = l @dirty = true end def chunks load_from_source! @chunks end def location @locations.find { |x| x.valid? } || raise(OutOfSyncSourceError.new) end def source location.source end def source_info location.info end ## this is called when the message body needs to actually be loaded. def load_from_source! @chunks ||= begin ## we need to re-read the header because it contains information ## that we don't store in the index. actually i think it's just ## the mailing list address (if any), so this is kinda overkill. ## i could just store that in the index, but i think there might ## be other things like that in the future, and i'd rather not ## bloat the index. ## actually, it's also the differentiation between to/cc/bcc, ## so i will keep this. rmsg = location.parsed_message parse_header rmsg.header message_to_chunks rmsg rescue SourceError, SocketError, RMail::EncodingUnsupportedError => e warn "problem reading message #{id}" [Chunk::Text.new(error_message.split("\n"))] end end def error_message < [Location.new(source, source_info)] m.load_from_source! m end private ## here's where we handle decoding mime attachments. unfortunately ## but unsurprisingly, the world of mime attachments is a bit of a ## mess. as an empiricist, i'm basing the following behavior on ## observed mail rather than on interpretations of rfcs, so probably ## this will have to be tweaked. ## ## the general behavior i want is: ignore content-disposition, at ## least in so far as it suggests something being inline vs being an ## attachment. (because really, that should be the recipient's ## decision to make.) if a mime part is text/plain, OR if the user ## decoding hook converts it, then decode it and display it ## inline. for these decoded attachments, if it has associated ## filename, then make it collapsable and individually saveable; ## otherwise, treat it as regular body text. ## ## everything else is just an attachment and is not displayed ## inline. ## ## so, in contrast to mutt, the user is not exposed to the workings ## of the gruesome slaughterhouse and sausage factory that is a ## mime-encoded message, but need only see the delicious end ## product. def multipart_signed_to_chunks m if m.body.size != 2 warn "multipart/signed with #{m.body.size} parts (expecting 2)" return end payload, signature = m.body if signature.multipart? warn "multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}" return end ## this probably will never happen if payload.header.content_type && payload.header.content_type.downcase == "application/pgp-signature" warn "multipart/signed with payload content type #{payload.header.content_type}" return end if signature.header.content_type && signature.header.content_type.downcase != "application/pgp-signature" ## unknown signature type; just ignore. #warn "multipart/signed with signature content type #{signature.header.content_type}" return end [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact end def multipart_encrypted_to_chunks m if m.body.size != 2 warn "multipart/encrypted with #{m.body.size} parts (expecting 2)" return end control, payload = m.body if control.multipart? warn "multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}" return end if payload.header.content_type && payload.header.content_type.downcase != "application/octet-stream" warn "multipart/encrypted with payload content type #{payload.header.content_type}" return end if control.header.content_type && control.header.content_type.downcase != "application/pgp-encrypted" warn "multipart/encrypted with control content type #{signature.header.content_type}" return end notice, sig, decryptedm = CryptoManager.decrypt payload if decryptedm # managed to decrypt children = message_to_chunks(decryptedm, true) [notice, sig].compact + children else [notice] end end ## takes a RMail::Message, breaks it into Chunk:: classes. def message_to_chunks m, encrypted=false, sibling_types=[] if m.multipart? chunks = case m.header.content_type.downcase when "multipart/signed" multipart_signed_to_chunks m when "multipart/encrypted" multipart_encrypted_to_chunks m end unless chunks sibling_types = m.body.map { |p| p.header.content_type } chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact end chunks elsif m.header.content_type && m.header.content_type.downcase == "message/rfc822" encoding = m.header["Content-Transfer-Encoding"] if m.body body = case encoding when "base64" m.body.unpack("m")[0] when "quoted-printable" m.body.unpack("M")[0] when "7bit", "8bit", nil m.body else raise RMail::EncodingUnsupportedError, encoding.inspect end body = body.normalize_whitespace payload = RMail::Parser.read(body) from = payload.header.from.first ? payload.header.from.first.format : "" to = payload.header.to.map { |p| p.format }.join(", ") cc = payload.header.cc.map { |p| p.format }.join(", ") subj = decode_header_field(payload.header.subject) || DEFAULT_SUBJECT subj = Message.normalize_subj(subj.gsub(/\s+/, " ").gsub(/\s+$/, "")) msgdate = payload.header.date from_person = from ? Person.from_address(decode_header_field(from)) : nil to_people = to ? Person.from_address_list(decode_header_field(to)) : nil cc_people = cc ? Person.from_address_list(decode_header_field(cc)) : nil [Chunk::EnclosedMessage.new(from_person, to_people, cc_people, msgdate, subj)] + message_to_chunks(payload, encrypted) else debug "no body for message/rfc822 enclosure; skipping" [] end elsif m.header.content_type && m.header.content_type.downcase == "application/pgp" && m.body ## apparently some versions of Thunderbird generate encryped email that ## does not follow RFC3156, e.g. messages with X-Enigmail-Version: 0.95.0 ## they have no MIME multipart and just set the body content type to ## application/pgp. this handles that. ## ## TODO: unduplicate code between here and multipart_encrypted_to_chunks notice, sig, decryptedm = CryptoManager.decrypt m.body if decryptedm # managed to decrypt children = message_to_chunks decryptedm, true [notice, sig].compact + children else [notice] end else filename = ## first, paw through the headers looking for a filename. ## RFC 2183 (Content-Disposition) specifies that disposition-parms are ## separated by ";". So, we match everything up to " and ; (if present). if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|\z)/m $1 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|\z)/im $1 ## haven't found one, but it's a non-text message. fake ## it. ## ## TODO: make this less lame. elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/i extension = case m.header["Content-Type"] when /text\/html/ then "html" when /image\/(.*)/ then $1 end ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".") end ## if there's a filename, we'll treat it as an attachment. if filename ## filename could be 2047 encoded filename = Rfc2047.decode_to $encoding, filename # add this to the attachments list if its not a generated html # attachment (should we allow images with generated names?). # Lowercase the filename because searches are easier that way @attachments.push filename.downcase unless filename =~ /^sup-attachment-/ add_label :attachment unless filename =~ /^sup-attachment-/ content_type = (m.header.content_type || "application/unknown").downcase # sometimes RubyMail gives us nil [Chunk::Attachment.new(content_type, filename, m, sibling_types)] ## otherwise, it's body text else ## Decode the body, charset conversion will follow either in ## inline_gpg_to_chunks (for inline GPG signed messages) or ## a few lines below (messages without inline GPG) body = m.body ? m.decode : "" ## Check for inline-PGP chunks = inline_gpg_to_chunks body, $encoding, (m.charset || $encoding) return chunks if chunks if m.body ## 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) else body = "" end text_to_chunks(body.normalize_whitespace.split("\n"), encrypted) end end end ## looks for gpg signed (but not encrypted) inline messages inside the ## message body (there is no extra header for inline GPG) or for encrypted ## (and possible signed) inline GPG messages 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? msg = RMail::Message.new msg.body = gpg.join("\n") body = Iconv.easy_decode(encoding_to, encoding_from, body) lines = body.split("\n") sig = lines.between(GPG_SIGNED_START, GPG_SIG_START) startidx = lines.index(GPG_SIGNED_START) endidx = lines.index(GPG_SIG_END) before = startidx != 0 ? lines[0 .. startidx-1] : [] after = endidx ? lines[endidx+1 .. lines.size] : [] payload = RMail::Message.new payload.body = sig[1, sig.size-2].join("\n") return [text_to_chunks(before, false), CryptoManager.verify(nil, msg, false), message_to_chunks(payload), text_to_chunks(after, false)].flatten.compact end gpg = lines.between(GPG_START, GPG_END) # between does not check if GPG_END actually exists if !gpg.empty? && !lines.index(GPG_END).nil? msg = RMail::Message.new msg.body = gpg.join("\n") startidx = lines.index(GPG_START) before = startidx != 0 ? lines[0 .. startidx-1] : [] after = lines[lines.index(GPG_END)+1 .. lines.size] notice, sig, decryptedm = CryptoManager.decrypt msg, true chunks = if decryptedm # managed to decrypt children = message_to_chunks(decryptedm, true) [notice, sig].compact + children else [notice] end return [text_to_chunks(before, false), chunks, text_to_chunks(after, false)].flatten.compact end end ## parse the lines of text into chunk objects. the heuristics here ## need tweaking in some nice manner. TODO: move these heuristics ## into the classes themselves. def text_to_chunks lines, encrypted state = :text # one of :text, :quote, or :sig chunks = [] chunk_lines = [] nextline_index = -1 lines.each_with_index do |line, i| if i >= nextline_index # look for next nonblank line only when needed to avoid O(n²) # behavior on sequences of blank lines if nextline_index = lines[(i+1)..-1].index { |l| l !~ /^\s*$/ } # skip blank lines nextline_index += i + 1 nextline = lines[nextline_index] else nextline_index = lines.length nextline = nil end end case state when :text newstate = nil ## the following /:$/ followed by /\w/ is an attempt to detect the ## start of a quote. this is split into two regexen because the ## original regex /\w.*:$/ had very poor behavior on long lines ## like ":a:a:a:a:a" that occurred in certain emails. if line =~ QUOTE_PATTERN || (line =~ /:$/ && line =~ /\w/ && nextline =~ QUOTE_PATTERN) newstate = :quote elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE newstate = :sig elsif line =~ BLOCK_QUOTE_PATTERN newstate = :block_quote end if newstate chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty? chunk_lines = [line] state = newstate else chunk_lines << line end when :quote newstate = nil if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN) chunk_lines << line elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE newstate = :sig else newstate = :text end if newstate if chunk_lines.empty? # nothing else chunks << Chunk::Quote.new(chunk_lines) end chunk_lines = [line] state = newstate end when :block_quote, :sig chunk_lines << line end if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/ @snippet ||= "" @snippet += " " unless @snippet.empty? @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ") oldlen = @snippet.length @snippet = @snippet[0 ... SNIPPET_LEN].chomp @snippet += "..." if @snippet.length < oldlen @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages] @snippet_contains_encrypted_content = true if encrypted end end ## final object case state when :quote, :block_quote chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty? when :text chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty? when :sig chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty? end chunks end end class Location attr_reader :source attr_reader :info def initialize source, info @source = source @info = info end def raw_header source.raw_header info end def raw_message source.raw_message info end ## much faster than raw_message def each_raw_message_line &b source.each_raw_message_line info, &b end def parsed_message source.load_message info end def valid? source.valid? info end def == o o.source.id == source.id and o.info == info end def hash [source.id, info].hash end end end sup-mainline/lib/sup/mode.rb000066400000000000000000000057611166154264000163230ustar00rootroot00000000000000require 'open3' module Redwood class Mode attr_accessor :buffer @@keymaps = {} def self.register_keymap keymap=nil, &b keymap = Keymap.new(&b) if keymap.nil? @@keymaps[self] = keymap end def self.keymap @@keymaps[self] || register_keymap end def self.keymaps @@keymaps end def initialize @buffer = nil end def self.make_name s; s.gsub(/.*::/, "").camel_to_hyphy; end def name; Mode.make_name self.class.name; end def self.load_all_modes dir Dir[File.join(dir, "*.rb")].each do |f| $stderr.puts "## loading mode #{f}" require f end end def killable?; true; end def unsaved?; false end def draw; end def focus; end def blur; end def cancel_search!; end def in_search?; false end def status; ""; end def resize rows, cols; end def cleanup @buffer = nil end def resolve_input c ancestors.each do |klass| # try all keymaps in order of ancestry next unless @@keymaps.member?(klass) action = BufferManager.resolve_input_with_keymap c, @@keymaps[klass] return action if action end nil end def handle_input c action = resolve_input(c) or return false send action true end def help_text used_keys = {} ancestors.map do |klass| km = @@keymaps[klass] or next title = "Keybindings from #{Mode.make_name klass.name}" s = < e m = "Error writing file: #{e.message}" info m BufferManager.flash m false end end def pipe_to_process command Open3.popen3(command) do |input, output, error| err, data, * = IO.select [error], [input], nil unless err.empty? message = err.first.read if message =~ /^\s*$/ warn "error running #{command} (but no error message)" BufferManager.flash "Error running #{command}!" else warn "error running #{command}: #{message}" BufferManager.flash "Error: #{message}" end return end data = data.first data.sync = false # buffer input yield data data.close # output will block unless input is closed ## BUG?: shows errors or output but not both.... data, * = IO.select [output, error], nil, nil data = data.first if data.eof BufferManager.flash "'#{command}' done!" nil else data.read end end end end end sup-mainline/lib/sup/modes/000077500000000000000000000000001166154264000161505ustar00rootroot00000000000000sup-mainline/lib/sup/modes/buffer-list-mode.rb000066400000000000000000000022741166154264000216460ustar00rootroot00000000000000module Redwood class BufferListMode < LineCursorMode register_keymap do |k| k.add :jump_to_buffer, "Jump to selected buffer", :enter k.add :reload, "Reload buffer list", "@" k.add :kill_selected_buffer, "Kill selected buffer", "X" end def initialize regen_text super end def lines; @text.length end def [] i; @text[i] end def focus reload # buffers may have been killed or created since last view set_cursor_pos 0 end protected def reload regen_text buffer.mark_dirty end def regen_text @bufs = BufferManager.buffers.reject { |name, buf| buf.mode == self || buf.hidden? }.sort_by { |name, buf| buf.atime }.reverse width = @bufs.max_of { |name, buf| buf.mode.name.length } @text = @bufs.map do |name, buf| base_color = buf.system? ? :system_buf_color : :regular_buf_color [[base_color, sprintf("%#{width}s ", buf.mode.name)], [:modified_buffer_color, (buf.mode.unsaved? ? '*' : ' ')], [base_color, " " + name]] end end def jump_to_buffer BufferManager.raise_to_front @bufs[curpos][1] end def kill_selected_buffer reload if BufferManager.kill_buffer_safely @bufs[curpos][1] end end end sup-mainline/lib/sup/modes/completion-mode.rb000066400000000000000000000025701166154264000215740ustar00rootroot00000000000000module 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 sup-mainline/lib/sup/modes/compose-mode.rb000066400000000000000000000031711166154264000210660ustar00rootroot00000000000000module 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 sup-mainline/lib/sup/modes/console-mode.rb000066400000000000000000000046621166154264000210710ustar00rootroot00000000000000require '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 << < people BufferManager.spawn "new message", mode mode.edit_message end end def select p = @contacts[curpos] or return multi_select [p] end def multi_search people mode = PersonSearchResultsMode.new people BufferManager.spawn "search for #{people.map { |p| p.name }.join(', ')}", mode mode.load_threads :num => mode.buffer.content_height end def search p = @contacts[curpos] or return multi_search [p] end def reload @tags.drop_all_tags @num = nil load end def load_in_background Redwood::reporting_thread("contact manager load in bg") do load update BufferManager.draw_screen end end def load @num ||= (buffer.content_height * 2) @user_contacts = ContactManager.contacts_with_aliases num = [@num - @user_contacts.length, 0].max BufferManager.say("Loading #{num} contacts from index...") do recentc = Index.load_contacts AccountManager.user_emails, :num => num @contacts = (@user_contacts + recentc).sort_by { |p| p.sort_by_me }.uniq end end protected def update regen_text buffer.mark_dirty if buffer end def update_text_for_line line @text[line] = text_for_contact @contacts[line] buffer.mark_dirty if buffer end def text_for_contact p aalias = ContactManager.alias_for(p) || "" [[:tagged_color, @tags.tagged?(p) ? ">" : " "], [:none, sprintf("%-#{@awidth}s %-#{@nwidth}s %s", aalias, p.name, p.email)]] end def regen_text @awidth, @nwidth = 0, 0 @contacts.each do |p| aalias = ContactManager.alias_for(p) @awidth = aalias.length if aalias && aalias.length > @awidth @nwidth = p.name.length if p.name && p.name.length > @nwidth end @text = @contacts.map { |p| text_for_contact p } end end end sup-mainline/lib/sup/modes/edit-message-async-mode.rb000066400000000000000000000062001166154264000230770ustar00rootroot00000000000000module Redwood class EditMessageAsyncMode < LineCursorMode HookManager.register "async-edit", < to have the file path copied to the clipboard.", "", "When you have finished editing, select this buffer and press 'E'.",] super() end def lines; @text.length end def [] i @text[i] end def killable? if file_being_edited? if !BufferManager.ask_yes_or_no("It appears the file is still being edited. Are you sure?") return false end end @parent_edit_mode.edit_message_async_resume true true end def unsaved? !file_being_edited? && !file_has_been_edited? end protected def edit_finished if file_being_edited? if !BufferManager.ask_yes_or_no("It appears the file is still being edited. Are you sure?") return false end end @parent_edit_mode.edit_message_async_resume BufferManager.kill_buffer buffer true end def path_to_clipboard if system("which xsel > /dev/null 2>&1") # linux/unix path IO.popen('xsel --clipboard --input', 'r+') { |clipboard| clipboard.puts(@file_path) } BufferManager.flash "Copied file path to clipboard." elsif system("which pbcopy > /dev/null 2>&1") # mac path IO.popen('pbcopy', 'r+') { |clipboard| clipboard.puts(@file_path) } BufferManager.flash "Copied file path to clipboard." else BufferManager.flash "No way to copy text to clipboard - try installing xsel." end end def run_async_hook HookManager.run("async-edit", {:file_path => @file_path}) end def file_being_edited? # check for common editor lock files vim_lock_file = File.join(File.dirname(@file_path), '.'+File.basename(@file_path)+'.swp') emacs_lock_file = File.join(File.dirname(@file_path), '.#'+File.basename(@file_path)) return true if File.exist?(vim_lock_file) || File.exist?(emacs_lock_file) false end def file_has_been_edited? File.mtime(@file_path) > @orig_mtime end end end sup-mainline/lib/sup/modes/edit-message-mode.rb000066400000000000000000000457341166154264000220030ustar00rootroot00000000000000require '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", < 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"] =~ /?$/ @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 HookManager.run "before-edit", :header => @header, :body => @body 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 edit_message old_from = @header["From"] if @account_selector @file = Tempfile.new "sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}" @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first @file.puts @file.puts @body.join("\n") @file.close editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi" mtime = File.mtime @file.path BufferManager.shell_out "#{editor} #{@file.path}" @edited = true if File.mtime(@file.path) > mtime return @edited unless @edited header, @body = parse_file @file.path @header = header - NON_EDITABLE_HEADERS 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 @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.close @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 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 " into # "=?utf-8?q?b=C3=A6lammet_mitt?= 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 $config[:edit_signature] @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"] =~ /?$/ $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 += sig_lines.join("\n") unless $config[:edit_signature] ## 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 <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 sup-mainline/lib/sup/modes/file-browser-mode.rb000066400000000000000000000044661166154264000220310ustar00rootroot00000000000000require 'pathname' module Redwood ## meant to be spawned via spawn_modal! class FileBrowserMode < LineCursorMode RESERVED_ROWS = 1 register_keymap do |k| k.add :back, "Go back to previous directory", "B" k.add :view, "View file", "v" k.add :select_file_or_follow_directory, "Select the highlighted file, or follow the directory", :enter k.add :reload, "Reload file list", "R" end bool_reader :done attr_reader :value def initialize dir="." @dirs = [Pathname.new(dir).realpath] @done = false @value = nil regen_text super :skip_top_rows => RESERVED_ROWS end def cwd; @dirs.last end def lines; @text.length; end def [] i; @text[i]; end protected def back return if @dirs.size == 1 @dirs.pop reload end def reload regen_text jump_to_start buffer.mark_dirty end def view name, f = @files[curpos - RESERVED_ROWS] return unless f && f.file? begin BufferManager.spawn f.to_s, TextMode.new(f.read.ascii) rescue SystemCallError => e BufferManager.flash e.message end end def select_file_or_follow_directory name, f = @files[curpos - RESERVED_ROWS] return unless f if f.directory? && f.to_s != "." if f.readable? @dirs.push f reload else BufferManager.flash "Permission denied - #{f.realpath}" end else begin @value = f.realpath.to_s @done = true rescue SystemCallError => e BufferManager.flash e.message end end end def regen_text @files = begin cwd.entries.sort_by do |f| [f.directory? ? 0 : 1, f.basename.to_s] end rescue SystemCallError => e BufferManager.flash "Error: #{e.message}" [Pathname.new("."), Pathname.new("..")] end.map do |f| real_f = cwd + f name = f.basename.to_s + case when real_f.symlink? "@" when real_f.directory? "/" else "" end [name, real_f] end size_width = @files.max_of { |name, f| f.human_size.length } time_width = @files.max_of { |name, f| f.human_time.length } @text = ["#{cwd}:"] + @files.map do |name, f| sprintf "%#{time_width}s %#{size_width}s %s", f.human_time, f.human_size, name end end end end sup-mainline/lib/sup/modes/forward-mode.rb000066400000000000000000000046261166154264000210730ustar00rootroot00000000000000module Redwood class ForwardMode < EditMessageMode ## TODO: share some of this with reply-mode def initialize opts={} header = { "From" => AccountManager.default_account.full_address, } header["Subject"] = if opts[:message] "Fwd: " + opts[:message].subj elsif opts[:attachments] "Fwd: " + opts[:attachments].keys.join(", ") end 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] body = if opts[:message] forward_body_lines(opts[:message]) elsif opts[:attachments] ["Note: #{opts[:attachments].size.pluralize 'attachment'}."] end super :header => header, :body => body, :attachments => opts[:attachments] end def self.spawn_nicely opts={} to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ") 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]) attachment_hash = {} attachments = opts[:attachments] || [] if(m = opts[:message]) m.load_from_source! # read the full message in. you know, maybe i should just make Message#chunks do this.... attachments += m.chunks.select { |c| c.is_a?(Chunk::Attachment) && !c.quotable? } end attachments.each do |c| mime_type = MIME::Types[c.content_type].first || MIME::Types["application/octet-stream"].first attachment_hash[c.filename] = RMail::Message.make_attachment c.raw_content, mime_type.content_type, mime_type.encoding, c.filename end mode = ForwardMode.new :message => opts[:message], :to => to, :cc => cc, :bcc => bcc, :attachments => attachment_hash title = "Forwarding " + if opts[:message] opts[:message].subj elsif attachments attachment_hash.keys.join(", ") else "something" end BufferManager.spawn title, mode mode.edit_message end protected def forward_body_lines m ["--- Begin forwarded message from #{m.from.mediumname} ---"] + m.quotable_header_lines + [""] + m.quotable_body_lines + ["--- End forwarded message ---"] end end end sup-mainline/lib/sup/modes/help-mode.rb000066400000000000000000000004121166154264000203440ustar00rootroot00000000000000module Redwood class HelpMode < TextMode def initialize mode, global_keymap title = "Help for #{mode.name}" super < :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 sup-mainline/lib/sup/modes/label-list-mode.rb000066400000000000000000000074651166154264000214630ustar00rootroot00000000000000module Redwood class LabelListMode < LineCursorMode register_keymap do |k| k.add :select_label, "Search by label", :enter k.add :reload, "Discard label list and reload", '@' k.add :jump_to_next_new, "Jump to next new thread", :tab k.add :toggle_show_unread_only, "Toggle between showing all labels and those with unread mail", 'u' end HookManager.register "label-list-filter", < 0 } || (0 ... curpos).find { |i| @labels[i][1] > 0 } if n ## jump there if necessary jump_to_line n unless n >= topline && n < botline set_cursor_pos n else BufferManager.flash "No labels messages with unread messages." end end def focus reload # make sure unread message counts are up-to-date end def handle_added_update sender, m reload end protected def toggle_show_unread_only @unread_only = !@unread_only reload end def reload regen_text buffer.mark_dirty if buffer end def regen_text @text = [] labels = LabelManager.all_labels counted = labels.map do |label| string = LabelManager.string_for label total = Index.num_results_for :label => label unread = (label == :unread)? total : Index.num_results_for(:labels => [label, :unread]) [label, string, total, unread] end if HookManager.enabled? "label-list-filter" counts = HookManager.run "label-list-filter", :counted => counted else counts = counted.sort_by { |l, s, t, u| s.downcase } end width = counts.max_of { |l, s, t, u| s.length } tmax = counts.max_of { |l, s, t, u| t } umax = counts.max_of { |l, s, t, u| u } if @unread_only counts.delete_if { | l, s, t, u | u == 0 } end @labels = [] counts.map do |label, string, total, unread| ## if we've done a search and there are no messages for this label, we can delete it from the ## list. BUT if it's a brand-new label, the user may not have sync'ed it to the index yet, so ## don't delete it in this case. ## ## this is all a hack. what should happen is: ## TODO make the labelmanager responsible for label counts ## and then it can listen to labeled and unlabeled events, etc. if total == 0 && !LabelManager::RESERVED_LABELS.include?(label) && !LabelManager.new_label?(label) debug "no hits for label #{label}, deleting" LabelManager.delete label next end fmt = HookManager.run "label-list-format", :width => width, :tmax => tmax, :umax => umax if !fmt fmt = "%#{width + 1}s %5d %s, %5d unread" end @text << [[(unread == 0 ? :labellist_old_color : :labellist_new_color), sprintf(fmt, string, total, total == 1 ? " message" : "messages", unread)]] @labels << [label, unread] yield i if block_given? end.compact BufferManager.flash "No labels with unread messages!" if counts.empty? && @unread_only end def select_label label, num_unread = @labels[curpos] return unless label LabelSearchResultsMode.spawn_nicely label end end end sup-mainline/lib/sup/modes/label-search-results-mode.rb000066400000000000000000000021361166154264000234420ustar00rootroot00000000000000module Redwood class LabelSearchResultsMode < ThreadIndexMode def initialize labels @labels = labels opts = { :labels => @labels } opts[:load_deleted] = true if labels.include? :deleted opts[:load_spam] = true if labels.include? :spam super [], opts end register_keymap do |k| k.add :refine_search, "Refine search", '|' end def refine_search label_query = @labels.size > 1 ? "(#{@labels.join('||')})" : @labels.first query = BufferManager.ask :search, "refine query: ", "+label:#{label_query} " return unless query && query !~ /^\s*$/ SearchResultsMode.spawn_from_query query end def is_relevant? m; @labels.all? { |l| m.has_label? l } end def self.spawn_nicely label label = LabelManager.label_for(label) unless label.is_a?(Symbol) case label when nil when :inbox BufferManager.raise_to_front InboxMode.instance.buffer else b, new = BufferManager.spawn_unless_exists("All threads with label '#{label}'") { LabelSearchResultsMode.new [label] } b.mode.load_threads :num => b.content_height if new end end end end sup-mainline/lib/sup/modes/line-cursor-mode.rb000066400000000000000000000103651166154264000216660ustar00rootroot00000000000000module 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 sup-mainline/lib/sup/modes/log-mode.rb000066400000000000000000000025271166154264000202060ustar00rootroot00000000000000require 'stringio' module Redwood ## a variant of text mode that allows the user to automatically follow text, ## and respawns when << is called if necessary. class LogMode < TextMode register_keymap do |k| k.add :toggle_follow, "Toggle follow mode", 'f' end ## if buffer_name is supplied, this mode will spawn a buffer ## upon receiving the << message. otherwise, it will act like ## a regular buffer. def initialize autospawn_buffer_name=nil @follow = true @autospawn_buffer_name = autospawn_buffer_name @on_kill = [] super() end ## register callbacks for when the buffer is killed def on_kill &b; @on_kill << b end def toggle_follow @follow = !@follow if @follow jump_to_line(lines - buffer.content_height + 1) # leave an empty line at bottom end buffer.mark_dirty end def << s if buffer.nil? && @autospawn_buffer_name BufferManager.spawn @autospawn_buffer_name, self, :hidden => true, :system => true end s.split("\n").each { |l| super(l + "\n") } # insane. different << semantics. if @follow follow_top = lines - buffer.content_height + 1 jump_to_line follow_top if topline < follow_top end end def status super + " (follow: #@follow)" end def cleanup @on_kill.each { |cb| cb.call self } self.text = "" super end end end sup-mainline/lib/sup/modes/person-search-results-mode.rb000066400000000000000000000003441166154264000236700ustar00rootroot00000000000000module Redwood class PersonSearchResultsMode < ThreadIndexMode def initialize people @people = people super [], { :participants => @people } end def is_relevant? m; @people.any? { |p| m.from == p }; end end end sup-mainline/lib/sup/modes/poll-mode.rb000066400000000000000000000004521166154264000203660ustar00rootroot00000000000000module Redwood class PollMode < LogMode def initialize @new = true super "poll for new messages" end def poll unless @new @new = false self << "\n" end self << "Poll started at #{Time.now}\n" PollManager.do_poll { |s| self << (s + "\n") } end end end sup-mainline/lib/sup/modes/reply-mode.rb000066400000000000000000000160741166154264000205620ustar00rootroot00000000000000module Redwood class ReplyMode < EditMessageMode REPLY_TYPES = [:sender, :recipient, :list, :all, :user] TYPE_DESCRIPTIONS = { :sender => "Sender", :recipient => "Recipient", :all => "All", :list => "Mailing list", :user => "Customized" } HookManager.register "attribution", < @m ## sanity check that selection is a Person (or we'll fail below) ## don't check that it's an Account, though; assume they know what they're ## doing. if hook_reply_from && !(hook_reply_from.is_a? Person) info "reply-from returned non-Person, using default from." hook_reply_from = nil end ## determine the from address of a reply. ## if we have a value from a hook, use it. from = if hook_reply_from hook_reply_from ## otherwise, try and find an account somewhere in the list of to's ## and cc's and look up the corresponding name form the list of accounts. ## if this does not succeed use the recipient_email (=envelope-to) instead. ## this is for the case where mail is received from a mailing lists (so the ## To: is the list id itself). if the user subscribes via a particular ## alias, we want to use that alias in the reply. elsif(b = (@m.to.collect {|t| t.email} + @m.cc.collect {|c| c.email} + [@m.recipient_email] ).find { |p| AccountManager.is_account_email? p }) a = AccountManager.account_for(b) Person.new a.name, b ## if all else fails, use the default else AccountManager.default_account end ## now, determine to: and cc: addressess. we ignore reply-to for list ## messages because it's typically set to the list address, which we ## explicitly treat with reply type :list to = @m.is_list_message? ? @m.from : (@m.replyto || @m.from) ## next, cc: cc = (@m.to + @m.cc - [from, to]).uniq ## one potential reply type is "reply to recipient". this only happens ## in certain cases: ## if there's no cc, then the sender is the person you want to reply ## to. if it's a list message, then the list address is. otherwise, ## the cc contains a recipient. useful_recipient = !(cc.empty? || @m.is_list_message?) @headers = {} @headers[:recipient] = { "To" => cc.map { |p| p.full_address }, } if useful_recipient ## typically we don't want to have a reply-to-sender option if the sender ## is a user account. however, if the cc is empty, it's a message to ## ourselves, so for the lack of any other options, we'll add it. @headers[:sender] = { "To" => [to.full_address], } if !AccountManager.is_account?(to) || !useful_recipient @headers[:user] = {} not_me_ccs = cc.select { |p| !AccountManager.is_account?(p) } @headers[:all] = { "To" => [to.full_address], "Cc" => not_me_ccs.map { |p| p.full_address }, } unless not_me_ccs.empty? @headers[:list] = { "To" => [@m.list_address.full_address], } if @m.is_list_message? refs = gen_references @headers.each do |k, v| @headers[k] = { "From" => from.full_address, "To" => [], "Cc" => [], "Bcc" => [], "In-reply-to" => "<#{@m.id}>", "Subject" => Message.reify_subj(@m.subj), "References" => refs, }.merge v end types = REPLY_TYPES.select { |t| @headers.member?(t) } @type_selector = HorizontalSelector.new "Reply to:", types, types.map { |x| TYPE_DESCRIPTIONS[x] } hook_reply = HookManager.run "reply-to", :modes => types @type_selector.set_to( if types.include? type_arg type_arg elsif types.include? hook_reply hook_reply elsif @m.is_list_message? :list elsif @headers.member? :sender :sender else :recipient end) @bodies = {} @headers.each do |k, v| @bodies[k] = body HookManager.run "before-edit", :header => v, :body => @bodies[k] end super :header => @headers[@type_selector.val], :body => @bodies[@type_selector.val], :twiddles => false add_selector @type_selector end protected def move_cursor_right super if @headers[@type_selector.val] != self.header self.header = @headers[@type_selector.val] self.body = @bodies[@type_selector.val] unless @edited rerun_crypto_selector_hook update end end def move_cursor_left super if @headers[@type_selector.val] != self.header self.header = @headers[@type_selector.val] self.body = @bodies[@type_selector.val] unless @edited rerun_crypto_selector_hook update end end def reply_body_lines m attribution = HookManager.run("attribution", :message => m) || default_attribution(m) lines = attribution.split("\n") + m.quotable_body_lines.map { |l| "> #{l}" } lines.pop while lines.last =~ /^\s*$/ lines end def default_attribution m "Excerpts from #{@m.from.name}'s message of #{@m.date}:" end def handle_new_text new_header, new_body if new_body != @bodies[@type_selector.val] @bodies[@type_selector.val] = new_body @edited = true end old_header = @headers[@type_selector.val] if new_header.size != old_header.size || old_header.any? { |k, v| new_header[k] != v } @type_selector.set_to :user self.header = @headers[:user] = new_header update end end def gen_references (@m.refs + [@m.id]).map { |x| "<#{x}>" }.join(" ") end def edit_field field edited_field = super if edited_field && edited_field != "Subject" @type_selector.set_to :user update end end end end sup-mainline/lib/sup/modes/resume-mode.rb000066400000000000000000000017401166154264000207210ustar00rootroot00000000000000module Redwood class ResumeMode < EditMessageMode def initialize m @m = m @safe = false header, body = parse_file m.draft_filename header.delete "Date" super :header => header, :body => body, :have_signature => true rescue Errno::ENOENT DraftManager.discard @m BufferManager.flash "Draft deleted outside of sup." end def unsaved?; !@safe end def killable? return true if @safe case BufferManager.ask_yes_or_no "Discard draft?" when true DraftManager.discard @m BufferManager.flash "Draft discarded." true when false if edited? DraftManager.write_draft { |f| write_message f, false } DraftManager.discard @m BufferManager.flash "Draft saved." end true else false end end def send_message if super DraftManager.discard @m @safe = true end end def save_as_draft @safe = true DraftManager.discard @m if super end end end sup-mainline/lib/sup/modes/scroll-mode.rb000066400000000000000000000163751166154264000207310ustar00rootroot00000000000000module 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 sup-mainline/lib/sup/modes/search-list-mode.rb000066400000000000000000000134161166154264000216420ustar00rootroot00000000000000module Redwood class SearchListMode < LineCursorMode register_keymap do |k| k.add :select_search, "Open search results", :enter k.add :reload, "Discard saved search list and reload", '@' k.add :jump_to_next_new, "Jump to next new thread", :tab k.add :toggle_show_unread_only, "Toggle between showing all saved searches and those with unread mail", 'u' k.add :delete_selected_search, "Delete selected search", "X" k.add :rename_selected_search, "Rename selected search", "r" k.add :edit_selected_search, "Edit selected search", "e" k.add :add_new_search, "Add new search", "a" end HookManager.register "search-list-filter", < 0 } || (0 ... curpos).find { |i| @searches[i][1] > 0 } if n ## jump there if necessary jump_to_line n unless n >= topline && n < botline set_cursor_pos n else BufferManager.flash "No saved searches with unread messages." end end def focus reload # make sure unread message counts are up-to-date end def handle_added_update sender, m reload end protected def toggle_show_unread_only @unread_only = !@unread_only reload end def reload regen_text buffer.mark_dirty if buffer end def regen_text @text = [] searches = SearchManager.all_searches counted = searches.map do |name| search_string = SearchManager.search_string_for name begin query = Index.parse_query search_string total = Index.num_results_for :qobj => query[:qobj] unread = Index.num_results_for :qobj => query[:qobj], :label => :unread rescue Index::ParseError => e BufferManager.flash "Problem: #{e.message}!" total = 0 unread = 0 end [name, search_string, total, unread] end if HookManager.enabled? "search-list-filter" counts = HookManager.run "search-list-filter", :counted => counted else counts = counted.sort_by { |n, s, t, u| n.downcase } end n_width = counts.max_of { |n, s, t, u| n.length } tmax = counts.max_of { |n, s, t, u| t } umax = counts.max_of { |n, s, t, u| u } s_width = counts.max_of { |n, s, t, u| s.length } if @unread_only counts.delete_if { | n, s, t, u | u == 0 } end @searches = [] counts.each do |name, search_string, total, unread| fmt = HookManager.run "search-list-format", :n_width => n_width, :tmax => tmax, :umax => umax, :s_width => s_width if !fmt fmt = "%#{n_width + 1}s %5d %s, %5d unread: %s" end @text << [[(unread == 0 ? :labellist_old_color : :labellist_new_color), sprintf(fmt, name, total, total == 1 ? " message" : "messages", unread, search_string)]] @searches << [name, unread] end BufferManager.flash "No saved searches with unread messages!" if counts.empty? && @unread_only end def select_search name, num_unread = @searches[curpos] return unless name SearchResultsMode.spawn_from_query SearchManager.search_string_for(name) end def delete_selected_search name, num_unread = @searches[curpos] return unless name reload if SearchManager.delete name end def rename_selected_search old_name, num_unread = @searches[curpos] return unless old_name new_name = BufferManager.ask :save_search, "Rename this saved search: ", old_name return unless new_name && new_name !~ /^\s*$/ && new_name != old_name new_name.strip! unless SearchManager.valid_name? new_name BufferManager.flash "Not renamed: " + SearchManager.name_format_hint return end if SearchManager.all_searches.include? new_name BufferManager.flash "Not renamed: \"#{new_name}\" already exists" return end reload if SearchManager.rename old_name, new_name set_cursor_pos @searches.index([new_name, num_unread])||curpos end def edit_selected_search name, num_unread = @searches[curpos] return unless name old_search_string = SearchManager.search_string_for name new_search_string = BufferManager.ask :search, "Edit this saved search: ", (old_search_string + " ") return unless new_search_string && new_search_string !~ /^\s*$/ && new_search_string != old_search_string reload if SearchManager.edit name, new_search_string.strip set_cursor_pos @searches.index([name, num_unread])||curpos end def add_new_search search_string = BufferManager.ask :search, "New search: " return unless search_string && search_string !~ /^\s*$/ name = BufferManager.ask :save_search, "Name this search: " return unless name && name !~ /^\s*$/ name.strip! unless SearchManager.valid_name? name BufferManager.flash "Not saved: " + SearchManager.name_format_hint return end if SearchManager.all_searches.include? name BufferManager.flash "Not saved: \"#{name}\" already exists" return end reload if SearchManager.add name, search_string.strip set_cursor_pos @searches.index(@searches.assoc(name))||curpos end end end sup-mainline/lib/sup/modes/search-results-mode.rb000066400000000000000000000032661166154264000223720ustar00rootroot00000000000000module Redwood class SearchResultsMode < ThreadIndexMode def initialize query @query = query super [], query end register_keymap do |k| k.add :refine_search, "Refine search", '|' k.add :save_search, "Save search", '%' end def refine_search text = BufferManager.ask :search, "refine query: ", (@query[:text] + " ") return unless text && text !~ /^\s*$/ SearchResultsMode.spawn_from_query text end def save_search name = BufferManager.ask :save_search, "Name this search: " return unless name && name !~ /^\s*$/ name.strip! unless SearchManager.valid_name? name BufferManager.flash "Not saved: " + SearchManager.name_format_hint return end if SearchManager.all_searches.include? name BufferManager.flash "Not saved: \"#{name}\" already exists" return end BufferManager.flash "Search saved as \"#{name}\"" if SearchManager.add name, @query[:text].strip end ## a proper is_relevant? method requires some way of asking the index ## if an in-memory object satisfies a query. i'm not sure how to do ## that yet. in the worst case i can make an in-memory index, add ## the message, and search against it to see if i have > 0 results, ## but that seems pretty insane. def self.spawn_from_query text begin query = Index.parse_query(text) return unless query short_text = text.length < 20 ? text : text[0 ... 20] + "..." mode = SearchResultsMode.new query BufferManager.spawn "search: \"#{short_text}\"", mode mode.load_threads :num => mode.buffer.content_height rescue Index::ParseError => e BufferManager.flash "Problem: #{e.message}!" end end end end sup-mainline/lib/sup/modes/text-mode.rb000066400000000000000000000031431166154264000204040ustar00rootroot00000000000000module Redwood class TextMode < ScrollMode attr_reader :text register_keymap do |k| k.add :save_to_disk, "Save to disk", 's' k.add :pipe, "Pipe to process", '|' end def initialize text="", filename=nil @text = text @filename = filename update_lines buffer.mark_dirty if buffer super() end def save_to_disk fn = BufferManager.ask_for_filename :filename, "Save to file: ", @filename save_to_file(fn) { |f| f.puts text } if fn end def pipe command = BufferManager.ask(:shell, "pipe command: ") return if command.nil? || command.empty? output = pipe_to_process(command) do |stream| @text.each { |l| stream.puts l } end if output BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii) else BufferManager.flash "'#{command}' done!" end end def text= t @text = t update_lines if buffer ensure_mode_validity buffer.mark_dirty end end def << line @lines = [0] if @text.empty? @text << line @lines << @text.length if buffer ensure_mode_validity buffer.mark_dirty end end def lines @lines.length - 1 end def [] i return nil unless i < @lines.length @text[@lines[i] ... (i + 1 < @lines.length ? @lines[i + 1] - 1 : @text.length)].normalize_whitespace # (@lines[i] ... (i + 1 < @lines.length ? @lines[i + 1] - 1 : @text.length)).inspect end private def update_lines pos = @text.find_all_positions("\n") pos.push @text.length unless pos.last == @text.length - 1 @lines = [0] + pos.map { |x| x + 1 } end end end sup-mainline/lib/sup/modes/thread-index-mode.rb000066400000000000000000000646501166154264000220060ustar00rootroot00000000000000require '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", < 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 sup-mainline/lib/sup/modes/thread-view-mode.rb000066400000000000000000000717731166154264000216550ustar00rootroot00000000000000module 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", < $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 =~ // 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 =~ // 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 }.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}"]]] when nil [[[:missing_message_color, "#{prefix}"]]] 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 sup-mainline/lib/sup/person.rb000066400000000000000000000060261166154264000167000ustar00rootroot00000000000000module Redwood class Person attr_accessor :name, :email def initialize name, email raise ArgumentError, "email can't be nil" unless email @name = if name name = name.strip.gsub(/\s+/, " ") name =~ /^(['"]\s*)(.*?)(\s*["'])$/ ? $2 : name name.gsub('\\\\', '\\') end @email = email.strip.gsub(/\s+/, " ").downcase end def to_s; "#@name <#@email>" end # def == o; o && o.email == email; end # alias :eql? :== # def hash; [name, email].hash; end def shortname case @name when /\S+, (\S+)/ $1 when /(\S+) \S+/ $1 when nil @email else @name end end def longname if @name && @email "#@name <#@email>" else @email end end def mediumname; @name || @email; end def Person.full_address name, email if name && email if name =~ /[",@]/ "#{name.inspect} <#{email}>" # escape quotes else "#{name} <#{email}>" end else email end end def full_address Person.full_address @name, @email end ## when sorting addresses, sort by this def sort_by_me case @name when /^(\S+), \S+/ $1 when /^\S+ \S+ (\S+)/ $1 when /^\S+ (\S+)/ $1 when nil @email else @name end.downcase end ## return "canonical" person using contact manager or create one if ## not found or contact manager not available def self.from_name_and_email name, email ContactManager.instantiated? && ContactManager.person_for(email) || Person.new(name, email) end def self.from_address s return nil if s.nil? ## try and parse an email address and name name, email = case s when /(.+?) ((\S+?)@\S+) \3/ ## ok, this first match cause is insane, but bear with me. email ## addresses are stored in the to/from/etc fields of the index in a ## weird format: "name address first-part-of-address", i.e. spaces ## separating those three bits, and no <>'s. this is the output of ## #indexable_content. here, we reverse-engineer that format to extract ## a valid address. ## ## we store things this way to allow searches on a to/from/etc field to ## match any of those parts. a more robust solution would be to store a ## separate, non-indexed field with the proper headers. but this way we ## save precious bits, and it's backwards-compatible with older indexes. [$1, $2] when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/ a, b = $1, $2 [a.gsub('\"', '"'), b] when /<((\S+?)@\S+?)>/ [$2, $1] when /((\S+?)@\S+)/ [$2, $1] else [nil, s] end from_name_and_email name, email end def self.from_address_list ss return [] if ss.nil? ss.split_on_commas.map { |s| self.from_address s } end ## see comments in self.from_address def indexable_content [name, email, email.split(/@/).first].join(" ") end def eql? o; email.eql? o.email end def hash; email.hash end end end sup-mainline/lib/sup/poll.rb000066400000000000000000000154661166154264000163500ustar00rootroot00000000000000require 'thread' module Redwood class PollManager include Singleton HookManager.register "before-add-message", < 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 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] end def poll_unusual return if @polling @polling = true @poll_sources = SourceManager.unusual_sources num, numi = poll_with_sources @polling = false [num, numi] end def start @thread = Redwood::reporting_thread("periodic poll") do while true sleep @delay / 2 poll if @last_poll.nil? || (Time.now - @last_poll) >= @delay end end end def stop @thread.kill if @thread @thread = nil end def do_poll total_num = total_numi = 0 from_and_subj = [] from_and_subj_inbox = [] loaded_labels = Set.new @mutex.synchronize do @poll_sources.each do |source| begin yield "Loading from #{source}... " rescue SourceError => e warn "problem getting messages from #{source}: #{e.message}" next end num = 0 numi = 0 poll_from source do |action,m,old_m,progress| if action == :delete yield "Deleting #{m.id}" elsif action == :add if old_m new_locations = (m.locations - old_m.locations) if not new_locations.empty? yield "Message at #{new_locations[0].info} is an update of an old message. Updating labels from #{old_m.labels.to_a * ','} => #{m.labels.to_a * ','}" else yield "Skipping already-imported message at #{m.locations[-1].info}" end else yield "Found new message at #{m.source_info} with labels #{m.labels.to_a * ','}" loaded_labels.merge m.labels num += 1 from_and_subj << [m.from && m.from.longname, m.subj] if (m.labels & [:inbox, :spam, :deleted, :killed]) == Set.new([:inbox]) from_and_subj_inbox << [m.from && m.from.longname, m.subj] numi += 1 end end else fail end end yield "Found #{num} messages, #{numi} to inbox." unless num == 0 total_num += num total_numi += numi end 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 ## like Source#poll, but yields successive Message objects, which have their ## 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 end end end source.go_idle rescue SourceError => e warn "problem getting messages from #{source}: #{e.message}" end end def handle_idle_update sender, idle_since; @should_clear_running_totals = false; end def handle_unidle_update sender, idle_since; @should_clear_running_totals = true; clear_running_totals; end def clear_running_totals; @running_totals = {:num => 0, :numi => 0, :loaded_labels => Set.new}; end end end sup-mainline/lib/sup/protocol.rb000066400000000000000000000073201166154264000172310ustar00rootroot00000000000000require 'eventmachine' require 'socket' require 'stringio' require 'yajl' class EM::P::Redwood < EM::Connection VERSION = 1 ENCODINGS = %w(marshal json) attr_reader :debug def initialize *args @state = :negotiating @version_buf = "" @debug = false super end def receive_data data if @state == :negotiating @version_buf << data if i = @version_buf.index("\n") l = @version_buf.slice!(0..i) receive_version *parse_version(l.strip) x = @version_buf @version_buf = nil @state = :established connection_established receive_data x end else @filter.decode(data).each do |msg| puts "#{self.class.name} received: #{msg.inspect}" if @debug validate_message *msg receive_message *msg end end end def connection_established end def send_version encodings, extensions fail if encodings.empty? send_data "Redwood #{VERSION} #{encodings * ','} #{extensions.empty? ? :none : (extensions * ',')}\n" end def send_message type, tag, params={} fail "attempted to send message during negotiation" unless @state == :established puts "#{self.class.name} sent: #{[type, tag, params].inspect}" if @debug validate_message type, tag, params send_data @filter.encode([type,tag,params]) end def receive_version l fail "unimplemented" end def receive_message type, tag, params fail "unimplemented" end private def validate_message type, tag, params fail unless type.is_a? String or type.is_a? Symbol fail unless tag.is_a? String or tag.is_a? Integer fail unless params.is_a? Hash end def parse_version l l =~ /^Redwood\s+(\d+)\s+([\w,]+)\s+([\w,]+)$/ or fail "unexpected banner #{l.inspect}" version, encodings, extensions = $1.to_i, $2, $3 encodings = encodings.split ',' extensions = extensions.split ',' extensions = [] if extensions == ['none'] fail unless version == VERSION fail if encodings.empty? [encodings, extensions] end def create_filter encoding case encoding when 'json' then JSONFilter.new when 'marshal' then MarshalFilter.new else fail "unknown encoding #{encoding.inspect}" end end class JSONFilter def initialize @parser = Yajl::Parser.new :check_utf8 => false end def decode chunk parsed = [] @parser.on_parse_complete = lambda { |o| parsed << o } @parser << chunk parsed end def encode *os os.inject('') { |s, o| s << Yajl::Encoder.encode(o) } end end class MarshalFilter def initialize @buf = '' @state = :prefix @size = 0 end def decode chunk received = [] @buf << chunk begin if @state == :prefix break unless @buf.size >= 4 prefix = @buf.slice!(0...4) @size = prefix.unpack('N')[0] @state = :data end fail unless @state == :data break if @buf.size < @size received << Marshal.load(@buf.slice!(0...@size)) @state = :prefix end until @buf.empty? received end def encode o data = Marshal.dump o [data.size].pack('N') + data end end end class EM::P::RedwoodServer < EM::P::Redwood def post_init send_version ENCODINGS, [] end def receive_version encodings, extensions fail unless encodings.size == 1 fail unless ENCODINGS.member? encodings.first @filter = create_filter encodings.first end end class EM::P::RedwoodClient < EM::P::Redwood def receive_version encodings, extensions encoding = (ENCODINGS & encodings).first fail unless encoding @filter = create_filter encoding send_version [encoding], [] end end sup-mainline/lib/sup/rfc2047.rb000066400000000000000000000036651166154264000164670ustar00rootroot00000000000000## from: http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/101949 # $Id: rfc2047.rb,v 1.4 2003/04/18 20:55:56 sam Exp $ # MODIFIED slightly by William Morgan # # An implementation of RFC 2047 decoding. # # This module depends on the iconv library by Nobuyoshi Nakada, which I've # heard may be distributed as a standard part of Ruby 1.8. Many thanks to him # for helping with building and using iconv. # # Thanks to "Josef 'Jupp' Schugt" for pointing out an error with # stateful character sets. # # Copyright (c) Sam Roberts 2004 # # 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})} def Rfc2047.is_encoded? s; s =~ WORD end # Decodes a string, +from+, containing RFC 2047 encoded words into a target # character set, +target+. See iconv_open(3) for information on the # supported target encodings. If one of the encoded words cannot be # converted to the target encoding, it is left in its encoded form. def Rfc2047.decode_to(target, from) from = from.gsub(WORDSEQ, '\1') out = from.gsub(WORD) do |word| charset, encoding, text = $1, $2, $3 # B64 or QP decode, as necessary: case encoding when 'b', 'B' #puts text text = text.unpack('m*')[0] #puts text.dump when 'q', 'Q' # RFC 2047 has a variant of quoted printable where a ' ' character # can be represented as an '_', rather than =32, so convert # any of these that we find before doing the QP decoding. text = text.tr("_", " ") text = text.unpack('M*')[0] # Don't need an else, because no other values can be matched in a # WORD. end Iconv.easy_decode(target, charset, text) end end end sup-mainline/lib/sup/search.rb000066400000000000000000000035751166154264000166450ustar00rootroot00000000000000module Redwood class SearchManager include Singleton class ExpansionError < StandardError; end def initialize fn @fn = fn @searches = {} if File.exists? fn IO.foreach(fn) do |l| l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}" @searches[$1] = $2 end end @modified = false end def all_searches; return @searches.keys.sort; end def search_string_for name; return @searches[name]; end def valid_name? name; name =~ /^[\w-]+$/; end def name_format_hint; "letters, numbers, underscores and dashes only"; end def add name, search_string return unless valid_name? name @searches[name] = search_string @modified = true end def rename old, new return unless @searches.has_key? old search_string = @searches[old] delete old if add new, search_string end def edit name, search_string return unless @searches.has_key? name @searches[name] = search_string @modified = true end def delete name return unless @searches.has_key? name @searches.delete name @modified = true end def expand search_string expanded = search_string.dup until (matches = expanded.scan(/\{([\w-]+)\}/).flatten).empty? if !(unknown = matches - @searches.keys).empty? error_message = "Unknown \"#{unknown.join('", "')}\" when expanding \"#{search_string}\"" elsif expanded.size >= 2048 error_message = "Check for infinite recursion in \"#{search_string}\"" end if error_message warn error_message raise ExpansionError, error_message end matches.each { |n| expanded.gsub! "{#{n}}", "(#{@searches[n]})" if @searches.has_key? n } end return expanded end def save return unless @modified File.open(@fn, "w") { |f| @searches.sort.each { |(n, s)| f.puts "#{n}: #{s}" } } @modified = false end end end sup-mainline/lib/sup/sent.rb000066400000000000000000000021141166154264000163350ustar00rootroot00000000000000module Redwood class SentManager include Singleton attr_reader :source, :source_uri def initialize source_uri @source = nil @source_uri = source_uri end def source_id; @source.id; end def source= s raise FatalSourceError.new("Configured sent_source [#{s.uri}] can't store mail. Correct your configuration.") unless s.respond_to? :store_message @source_uri = s.uri @source = s end def default_source @source = SentLoader.new @source_uri = @source.uri @source end def write_sent_message date, from_email, &block @source.store_message date, from_email, &block PollManager.poll_from @source end end class SentLoader < MBox yaml_properties def initialize @filename = Redwood::SENT_FN File.open(@filename, "w") { } unless File.exists? @filename super "mbox://" + @filename, true, $config[:archive_sent] end def file_path; @filename end def to_s; 'sup://sent'; end def uri; 'sup://sent' end def id; 9998; end def labels; [:inbox, :sent]; end def default_labels; []; end def read?; true; end end end sup-mainline/lib/sup/server.rb000066400000000000000000000050751166154264000167030ustar00rootroot00000000000000require 'sup/protocol' module Redwood class Server < EM::P::RedwoodServer def initialize index super @index = index end def receive_message type, tag, params if respond_to? :"request_#{type}" send :"request_#{type}", tag, params else send_message 'error', tag, 'description' => "invalid request type #{type.inspect}" end end def request_query tag, a q = @index.parse_query a['query'] query q, a['offset'], a['limit'], a['raw'] do |r| send_message 'message', tag, r end send_message 'done', tag end def request_count tag, a q = @index.parse_query a['query'] c = count q send_message 'count', tag, 'count' => c end def request_label tag, a q = @index.parse_query a['query'] label q, a['add'], a['remove'] send_message 'done', tag end def request_add tag, a add a['raw'], a['labels'] send_message 'done', tag end def request_thread tag, a thread a['message_id'], a['raw'] do |r| send_message 'message', tag, r end send_message 'done', tag end private def result_from_message m, raw mkperson = lambda { |p| { :email => p.email, :name => p.name } } { 'summary' => { 'message_id' => m.id, 'date' => m.date, 'from' => mkperson[m.from], 'to' => m.to.map(&mkperson), 'cc' => m.cc.map(&mkperson), 'bcc' => m.bcc.map(&mkperson), 'subject' => m.subj, 'refs' => m.refs, 'replytos' => m.replytos, 'labels' => m.labels.map(&:to_s), }, 'raw' => raw ? m.raw_message : nil, } end def query query, offset, limit, raw c = 0 @index.each_message query do |m| next if c < offset break if c >= offset + limit if limit yield result_from_message(m, raw) c += 1 end nil end def count query @index.num_results_for query end def label query, remove_labels, add_labels @index.each_message query do |m| remove_labels.each { |l| m.remove_label l } add_labels.each { |l| m.add_label l } @index.update_message_state m end nil end def add raw, labels SentManager.source.store_message Time.now, "test@example.com" do |io| io.write raw end PollManager.poll_from SentManager.source do |sym,m,old_m,progress| next unless sym == :add m.labels = labels end nil end def thread msg_id, raw msg = @index.build_message msg_id @index.each_message_in_thread_for msg do |id, builder| m = builder.call yield result_from_message(m, raw) end end end end sup-mainline/lib/sup/source.rb000066400000000000000000000153761166154264000167020ustar00rootroot00000000000000require "sup/rfc2047" module Redwood class SourceError < StandardError def initialize *a raise "don't instantiate me!" if SourceError.is_a?(self.class) super end end class OutOfSyncSourceError < SourceError; end class FatalSourceError < SourceError; end class Source ## Implementing a new source should be easy, because Sup only needs ## to be able to: ## 1. See how many messages it contains ## 2. Get an arbitrary message ## 3. (optional) see whether the source has marked it read or not ## ## In particular, Sup doesn't need to move messages, mark them as ## 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. ## ## To write a new source, subclass this class, and implement: ## ## - start_offset ## - end_offset (exclusive!) (or, #done?) ## - load_header offset ## - load_message offset ## - raw_header offset ## - raw_message offset ## - check (optional) ## - 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. ## OutOfSyncSourceErrors should be used for problems that a call to ## sup-sync will fix (namely someone's been playing with the source ## from another client); FatalSourceErrors can be used for anything ## else (e.g. the imap server is down or the maildir is missing.) ## ## 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. bool_accessor :usual, :archived attr_reader :uri attr_accessor :id 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 @uri = uri @usual = usual @archived = archived @id = id end ## overwrite me if you have a disk incarnation (currently used only for sup-sync-back) def file_path; nil end def to_s; @uri.to_s; end def == o; o.uri == uri; end def is_source_for? uri; uri == @uri; end def read?; false; end ## release resources that are easy to reacquire. it is called ## after processing a source (e.g. polling) to prevent resource ## leaks (esp. file descriptors). def go_idle; end ## Yields values of the form [Symbol, Hash] ## add: info, labels, progress ## delete: info, progress def poll unimplemented end def valid? info true end ## utility method to read a raw email header from an IO stream and turn it ## into a hash of key-value pairs. minor special semantics for certain headers. ## ## THIS IS A SPEED-CRITICAL SECTION. Everything you do here will have a ## significant effect on Sup's processing speed of email from ALL sources. ## Little things like string interpolation, regexp interpolation, += vs <<, ## all have DRAMATIC effects. BE CAREFUL WHAT YOU DO! def self.parse_raw_email_header f header = {} last = nil while(line = f.gets) case line ## these three can occur multiple times, and we want the first one when /^(Delivered-To|X-Original-To|Envelope-To):\s*(.*?)\s*$/i; header[last = $1.downcase] ||= $2 ## regular header: overwrite (not that we should see more than one) ## TODO: figure out whether just using the first occurrence changes ## anything (which would simplify the logic slightly) when /^([^:\s]+):\s*(.*?)\s*$/i; header[last = $1.downcase] = $2 when /^\r*$/; break # blank line signifies end of header else if last header[last] << " " unless header[last].empty? header[last] << line.strip end end end %w(subject from to cc bcc).each do |k| v = header[k] or next next unless Rfc2047.is_encoded? v header[k] = begin Rfc2047.decode_to $encoding, v rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e #debug "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}" v end end header end protected ## convenience function def parse_raw_email_header f; self.class.parse_raw_email_header f end def Source.expand_filesystem_uri uri uri.gsub "~", File.expand_path("~") end end ## if you have a @labels instance variable, include this ## to serialize them nicely as an array, rather than as a ## nasty set. module SerializeLabelsNicely def before_marshal # can return an object c = clone c.instance_eval { @labels = (@labels.to_a.map { |l| l.to_s }).sort } c end def after_unmarshal! @labels = Set.new(@labels.map { |s| s.to_sym }) end end class SourceManager include Singleton def initialize @sources = {} @sources_dirty = false @source_mutex = Monitor.new end def [](id) @source_mutex.synchronize { @sources[id] } end def add_source source @source_mutex.synchronize do raise "duplicate source!" if @sources.include? source @sources_dirty = true max = @sources.max_of { |id, s| s.is_a?(DraftLoader) || s.is_a?(SentLoader) ? 0 : id } source.id ||= (max || 0) + 1 ##source.id += 1 while @sources.member? source.id @sources[source.id] = source end end def sources ## favour the inbox by listing non-archived sources first @source_mutex.synchronize { @sources.values }.sort_by { |s| s.id }.partition { |s| !s.archived? }.flatten end def source_for uri expanded_uri = Source.expand_filesystem_uri(uri) sources.find { |s| s.is_source_for? expanded_uri } end def usual_sources; sources.find_all { |s| s.usual? }; end def unusual_sources; sources.find_all { |s| !s.usual? }; end def load_sources fn=Redwood::SOURCE_FN source_array = Redwood::load_yaml_obj(fn) || [] @source_mutex.synchronize do @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten] @sources_dirty = false end end def save_sources fn=Redwood::SOURCE_FN @source_mutex.synchronize do if @sources_dirty Redwood::save_yaml_obj sources, fn, false, true end @sources_dirty = false end end end end sup-mainline/lib/sup/tagger.rb000066400000000000000000000022511166154264000166370ustar00rootroot00000000000000module Redwood class Tagger def initialize mode, noun="thread", plural_noun=nil @mode = mode @tagged = {} @noun = noun @plural_noun = plural_noun || (@noun + "s") end def tagged? o; @tagged[o]; end def toggle_tag_for o; @tagged[o] = !@tagged[o]; end def tag o; @tagged[o] = true; end def untag o; @tagged[o] = false; end def drop_all_tags; @tagged.clear; end def drop_tag_for o; @tagged.delete o; end def apply_to_tagged action=nil targets = @tagged.select_by_value num_tagged = targets.size if num_tagged == 0 BufferManager.flash "No tagged threads!" return end noun = num_tagged == 1 ? @noun : @plural_noun unless action c = BufferManager.ask_getch "apply to #{num_tagged} tagged #{noun}:" return if c.nil? # user cancelled action = @mode.resolve_input c end if action tagged_sym = "multi_#{action}".intern if @mode.respond_to? tagged_sym @mode.send tagged_sym, targets else BufferManager.flash "That command cannot be applied to multiple threads." end else BufferManager.flash "Unknown command #{c.to_character}." end end end end sup-mainline/lib/sup/textfield.rb000066400000000000000000000142541166154264000173640ustar00rootroot00000000000000module Redwood ## a fully-functional text field supporting completions, expansions, ## history--everything! ## ## writing this fucking sucked. if you thought ncurses was some 1970s ## before-people-knew-how-to-program bullshit, wait till you see ## ncurses forms. ## ## completion comments: completion is done emacs-style, and mostly ## depends on outside support, as we merely signal the existence of a ## new set of completions to show (#new_completions?) or that the ## current list of completions should be rolled if they're too large ## to fill the screen (#roll_completions?). ## ## in sup, completion support is implemented through BufferManager#ask ## and CompletionMode. class TextField def initialize @i = nil @history = [] @completion_block = nil reset_completion_state end bool_reader :new_completions, :roll_completions attr_reader :completions def value; @value || get_cursed_value end def activate window, y, x, width, question, default=nil, &block @w, @y, @x, @width = window, y, x, width @question = question @completion_block = block @field = Ncurses::Form.new_field 1, @width - question.length, @y, @x + question.length, 0, 0 if @field.respond_to? :opts_off @field.opts_off Ncurses::Form::O_STATIC @field.opts_off Ncurses::Form::O_BLANK end @form = Ncurses::Form.new_form [@field] @value = default || '' Ncurses::Form.post_form @form set_cursed_value @value end def position_cursor @w.attrset Colormap.color_for(:none) @w.mvaddstr @y, 0, @question Ncurses.curs_set 1 Ncurses::Form.form_driver @form, Ncurses::Form::REQ_END_FIELD Ncurses::Form.form_driver @form, Ncurses::Form::REQ_NEXT_CHAR if @value && @value =~ / $/ # fucking RETARDED end def deactivate reset_completion_state @form.unpost_form @form.free_form @field.free_field @field = nil Ncurses.curs_set 0 end def handle_input c ## short-circuit exit paths case c when Ncurses::KEY_ENTER # submit! @value = get_cursed_value @history.push @value unless @value =~ /^\s*$/ @i = @history.size return false when Ncurses::KEY_CANCEL # cancel @value = nil return false when Ncurses::KEY_TAB # completion return true unless @completion_block if @completions.empty? v = get_cursed_value c = @completion_block.call v if c.size > 0 @value = c.map { |full, short| full }.shared_prefix(true) set_cursed_value @value position_cursor end if c.size > 1 @completions = c @new_completions = true @roll_completions = false end else @new_completions = false @roll_completions = true end return true end reset_completion_state @value = nil d = case c when Ncurses::KEY_LEFT Ncurses::Form::REQ_PREV_CHAR when Ncurses::KEY_RIGHT Ncurses::Form::REQ_NEXT_CHAR when Ncurses::KEY_DC Ncurses::Form::REQ_DEL_CHAR when Ncurses::KEY_BACKSPACE, 127 # 127 is also a backspace keysym Ncurses::Form::REQ_DEL_PREV when ?\C-a.ord, Ncurses::KEY_HOME nop Ncurses::Form::REQ_BEG_FIELD when ?\C-e.ord, Ncurses::KEY_END Ncurses::Form::REQ_END_FIELD when ?\C-k.ord Ncurses::Form::REQ_CLR_EOF when ?\C-u.ord set_cursed_value cursed_value_after_point Ncurses::Form.form_driver @form, Ncurses::Form::REQ_END_FIELD nop Ncurses::Form::REQ_BEG_FIELD when ?\C-w.ord while action = remove_extra_space Ncurses::Form.form_driver @form, action end Ncurses::Form.form_driver @form, Ncurses::Form::REQ_PREV_CHAR Ncurses::Form.form_driver @form, Ncurses::Form::REQ_DEL_WORD when Ncurses::KEY_UP, Ncurses::KEY_DOWN unless !@i || @history.empty? value = get_cursed_value #debug "history before #{@history.inspect}" @i = @i + (c == Ncurses::KEY_UP ? -1 : 1) @i = 0 if @i < 0 @i = @history.size if @i > @history.size @value = @history[@i] || '' #debug "history after #{@history.inspect}" set_cursed_value @value Ncurses::Form::REQ_END_FIELD end else c end Ncurses::Form.form_driver @form, d if d true end private def reset_completion_state @completions = [] @new_completions = @roll_completions = @clear_completions = false end ## ncurses inanity wrapper ## ## DO NOT READ THIS CODE. YOU WILL GO MAD. def get_cursed_value return nil unless @field x = Ncurses.curx Ncurses::Form.form_driver @form, Ncurses::Form::REQ_VALIDATION v = @field.field_buffer(0).gsub(/^\s+|\s+$/, "") ## cursor <= end of text if x - @question.length - v.length <= 0 v else # trailing spaces v + (" " * (x - @question.length - v.length)) end end def remove_extra_space return nil unless @field Ncurses::Form.form_driver @form, Ncurses::Form::REQ_VALIDATION x = Ncurses.curx v = @field.field_buffer(0).gsub(/^\s+|\s+$/, "") v_index = x - @question.length # at start of line if v_index < 1 nil ## cursor <= end of text elsif v_index < v.length # is the character before the cursor a space? if v[v_index-1] == ?\s # if there is a non-space char under cursor then go back if v[v_index] != ?\s Ncurses::Form::REQ_PREV_CHAR # otherwise delete the space else Ncurses::Form::REQ_DEL_PREV end else nil end elsif v_index == v.length # at end of string, with non-space before us nil else # trailing spaces Ncurses::Form::REQ_PREV_CHAR end end def set_cursed_value v @field.set_field_buffer 0, v end def cursed_value_after_point point = Ncurses.curx - @question.length get_cursed_value[point..-1] end ## this is almost certainly unnecessary, but it's the only way ## i could get ncurses to remember my form's value def nop Ncurses::Form.form_driver @form, " ".ord Ncurses::Form.form_driver @form, Ncurses::Form::REQ_DEL_PREV end end end sup-mainline/lib/sup/thread.rb000066400000000000000000000314111166154264000166350ustar00rootroot00000000000000## Herein lies all the code responsible for threading messages. It's ## basically an online version of the JWZ threading algorithm: ## http://www.jwz.org/doc/threading.html ## ## I didn't implement it for efficiency, but thanks to our search ## engine backend, it's typically not applied to very many messages at ## once. ## ## At the top level, we have a ThreadSet, which represents a set of ## threads, e.g. a message folder or an inbox. Each ThreadSet contains ## zero or more Threads. A Thread represents all the message related ## to a particular subject. Each Thread has one or more Containers. A ## Container is a recursive structure that holds the message tree as ## determined by the references: and in-reply-to: headers. Each ## Container holds zero or one messages. In the case of zero messages, ## it means we've seen a reference to the message but haven't (yet) ## seen the message itself. ## ## A Thread can have multiple top-level Containers if we decide to ## group them together independent of tree structure, typically if ## (e.g. due to someone using a primitive MUA) the messages have the ## same subject but we don't have evidence from in-reply-to: or ## references: headers. In this case Thread#each can optionally yield ## a faked root object tying them all together into one tree ## structure. require 'set' module Redwood class Thread include Enumerable attr_reader :containers def initialize ## ah, the joys of a multithreaded application with a class called ## "Thread". i keep instantiating the wrong one... raise "wrong Thread class, buddy!" if block_given? @containers = [] end def << c @containers << c end def empty?; @containers.empty?; end def empty!; @containers.clear; end def drop c; @containers.delete(c) or raise "bad drop"; end ## unused def dump f=$stdout f.puts "=== start thread with #{@containers.length} trees ===" @containers.each { |c| c.dump_recursive f; f.puts } f.puts "=== end thread ===" end ## yields each message, its depth, and its parent. the message yield ## parameter can be a Message object, or :fake_root, or nil (no ## message found but the presence of one deduced from other ## messages). def each fake_root=false adj = 0 root = @containers.find_all { |c| c.message && !Message.subj_is_reply?(c.message.subj) }.argmin { |c| c.date } if root adj = 1 root.first_useful_descendant.each_with_stuff do |c, d, par| yield c.message, d, (par ? par.message : nil) end elsif @containers.length > 1 && fake_root adj = 1 yield :fake_root, 0, nil end @containers.each do |cont| next if cont == root fud = cont.first_useful_descendant fud.each_with_stuff do |c, d, par| ## special case here: if we're an empty root that's already ## been joined by a fake root, don't emit yield c.message, d + adj, (par ? par.message : nil) unless fake_root && c.message.nil? && root.nil? && c == fud end end end def first; each { |m, *o| return m if m }; nil; end def has_message?; any? { |m, *o| m.is_a? Message }; end def dirty?; any? { |m, *o| m && m.dirty? }; end def date; map { |m, *o| m.date if m }.compact.max; end def snippet with_snippets = select { |m, *o| m && m.snippet && !m.snippet.empty? } first_unread, * = with_snippets.select { |m, *o| m.has_label?(:unread) }.sort_by { |m, *o| m.date }.first return first_unread.snippet if first_unread last_read, * = with_snippets.sort_by { |m, *o| m.date }.last return last_read.snippet if last_read "" end def authors; map { |m, *o| m.from if m }.compact.uniq; end def apply_label t; each { |m, *o| m && m.add_label(t) }; end def remove_label t; each { |m, *o| m && m.remove_label(t) }; end def toggle_label label if has_label? label remove_label label false else apply_label label true end end def set_labels l; each { |m, *o| m && m.labels = l }; end def has_label? t; any? { |m, *o| m && m.has_label?(t) }; end def each_dirty_message; each { |m, *o| m && m.dirty? && yield(m) }; end def direct_participants map { |m, *o| [m.from] + m.to if m }.flatten.compact.uniq end def participants map { |m, *o| [m.from] + m.to + m.cc + m.bcc if m }.flatten.compact.uniq end def size; map { |m, *o| m ? 1 : 0 }.sum; end def subj; argfind { |m, *o| m && m.subj }; end def labels; inject(Set.new) { |s, (m, *o)| m ? s | m.labels : s } end def labels= l raise ArgumentError, "not a set" unless l.is_a?(Set) each { |m, *o| m && m.labels = l.dup } end def latest_message inject(nil) do |a, b| b = b.first if a.nil? b elsif b.nil? a else b.date > a.date ? b : a end end end def to_s "" end def sort_key m = latest_message m ? [-m.date.to_i, m.id] : [-Time.now.to_i, ""] end end ## recursive structure used internally to represent message trees as ## described by reply-to: and references: headers. ## ## the 'id' field is the same as the message id. but the message might ## be empty, in the case that we represent a message that was referenced ## by another message (as an ancestor) but never received. class Container attr_accessor :message, :parent, :children, :id, :thread def initialize id raise "non-String #{id.inspect}" unless id.is_a? String @id = id @message, @parent, @thread = nil, nil, nil @children = [] end def each_with_stuff parent=nil yield self, 0, parent @children.each do |c| c.each_with_stuff(self) { |cc, d, par| yield cc, d + 1, par } end end def descendant_of? o if o == self true else @parent && @parent.descendant_of?(o) end end def == o; Container === o && id == o.id; end def empty?; @message.nil?; end def root?; @parent.nil?; end def root; root? ? self : @parent.root; end ## skip over any containers which are empty and have only one child. we use ## this make the threaded display a little nicer, and only stick in the ## "missing message" line when it's graphically necessary, i.e. when the ## missing message has more than one descendent. def first_useful_descendant if empty? && @children.size == 1 @children.first.first_useful_descendant else self end end def find_attr attr if empty? @children.argfind { |c| c.find_attr attr } else @message.send attr end end def subj; find_attr :subj; end def date; find_attr :date; end def is_reply?; subj && Message.subj_is_reply?(subj); end def to_s [ "<#{id}", (@parent.nil? ? nil : "parent=#{@parent.id}"), (@children.empty? ? nil : "children=#{@children.map { |c| c.id }.inspect}"), ].compact.join(" ") + ">" end def dump_recursive f=$stdout, indent=0, root=true, parent=nil raise "inconsistency" unless parent.nil? || parent.children.include?(self) unless root f.print " " * indent f.print "+->" end line = "[#{thread.nil? ? ' ' : '*'}] " + #"[#{useful? ? 'U' : ' '}] " + if @message message.subj ##{@message.refs.inspect} / #{@message.replytos.inspect}" else "" end f.puts "#{id} #{line}"#[0 .. (105 - indent)] indent += 3 @children.each { |c| c.dump_recursive f, indent, false, self } end end ## A set of threads, so a forest. Is integrated with the index and ## builds thread structures by reading messages from it. ## ## If 'thread_by_subj' is true, puts messages with the same subject in ## one thread, even if they don't reference each other. This is ## helpful for crappy MUAs that don't set In-reply-to: or References: ## headers, but means that messages may be threaded unnecessarily. ## ## The following invariants are maintained: every Thread has at least one ## Container tree, and every Container tree has at least one Message. class ThreadSet attr_reader :num_messages bool_reader :thread_by_subj def initialize index, thread_by_subj=true @index = index @num_messages = 0 ## map from message ids to container objects @messages = SavingHash.new { |id| Container.new id } ## map from subject strings or (or root message ids) to thread objects @threads = SavingHash.new { Thread.new } @thread_by_subj = thread_by_subj end def thread_for_id mid; @messages.member?(mid) && @messages[mid].root.thread end def contains_id? id; @messages.member?(id) && !@messages[id].empty? end def thread_for m; thread_for_id m.id end def contains? m; contains_id? m.id end def threads; @threads.values end def size; @threads.size end def dump f=$stdout @threads.each do |s, t| f.puts "**********************" f.puts "** for subject #{s} **" f.puts "**********************" t.dump f end end ## link two containers def link p, c, overwrite=false if p == c || p.descendant_of?(c) || c.descendant_of?(p) # would create a loop #puts "*** linking parent #{p.id} and child #{c.id} would create a loop" return end #puts "in link for #{p.id} to #{c.id}, perform? #{c.parent.nil?} || #{overwrite}" return unless c.parent.nil? || overwrite remove_container c p.children << c c.parent = p ## if the child was previously a top-level container, it now ain't, ## so ditch our thread and kill it if necessary prune_thread_of c end private :link def remove_container c c.parent.children.delete c if c.parent # remove from tree end private :remove_container def prune_thread_of c return unless c.thread c.thread.drop c @threads.delete_if { |k, v| v == c.thread } if c.thread.empty? c.thread = nil end private :prune_thread_of def remove_id mid return unless @messages.member?(mid) c = @messages[mid] remove_container c prune_thread_of c end def remove_thread_containing_id mid return unless @messages.member?(mid) c = @messages[mid] t = c.root.thread @threads.delete_if { |key, thread| t == thread } end ## load in (at most) num number of threads from the index def load_n_threads num, opts={} @index.each_id_by_date opts do |mid, builder| break if size >= num unless num == -1 next if contains_id? mid m = builder.call load_thread_for_message m, :skip_killed => opts[:skip_killed], :load_deleted => opts[:load_deleted], :load_spam => opts[:load_spam] yield size if block_given? end end ## loads in all messages needed to thread m ## may do nothing if m's thread is killed def load_thread_for_message m, opts={} good = @index.each_message_in_thread_for m, opts do |mid, builder| next if contains_id? mid add_message builder.call end add_message m if good end ## merges in a pre-loaded thread def add_thread t raise "duplicate" if @threads.values.member? t t.each { |m, *o| add_message m } end ## merges two threads together. both must be members of this threadset. ## does its best, heuristically, to determine which is the parent. def join_threads threads return if threads.size < 2 containers = threads.map do |t| c = @messages.member?(t.first.id) ? @messages[t.first.id] : nil raise "not in threadset: #{t.first.id}" unless c && c.message c end ## use subject headers heuristically parent = containers.find { |c| !c.is_reply? } ## no thread was rooted by a non-reply, so make a fake parent parent ||= @messages["joining-ref-" + containers.map { |c| c.id }.join("-")] containers.each do |c| next if c == parent c.message.add_ref parent.id link parent, c end true end def is_relevant? m m.refs.any? { |ref_id| @messages.member? ref_id } end ## the heart of the threading code def add_message message el = @messages[message.id] return if el.message # we've seen it before #puts "adding: #{message.id}, refs #{message.refs.inspect}" el.message = message oldroot = el.root ## link via references: (message.refs + [el.id]).inject(nil) do |prev, ref_id| ref = @messages[ref_id] link prev, ref if prev ref end ## link via in-reply-to: message.replytos.each do |ref_id| ref = @messages[ref_id] link ref, el, true break # only do the first one end root = el.root key = if thread_by_subj? Message.normalize_subj root.subj else root.id end ## check to see if the subject is still the same (in the case ## that we first added a child message with a different ## subject) if root.thread if @threads.member?(key) && @threads[key] != root.thread @threads.delete key end else thread = @threads[key] thread << root root.thread = thread end ## last bit @num_messages += 1 end end end sup-mainline/lib/sup/time.rb000066400000000000000000000035321166154264000163270ustar00rootroot00000000000000class Time Redwood::HookManager.register "time-to-nice-string", < self, :from => from) || default_to_nice_s(from) end def default_to_nice_s from=Time.now if year != from.year strftime "%b %Y" elsif month != from.month strftime "%b %e" else if is_the_same_day? from strftime("%l:%M%p").downcase # emulate %P (missing on ruby 1.8 darwin) elsif is_the_day_before? from "Yest." + nearest_hour.strftime("%l%p").downcase # emulate %P else strftime "%b %e" end end end end sup-mainline/lib/sup/undo.rb000066400000000000000000000015741166154264000163420ustar00rootroot00000000000000module Redwood ## Implements a single undo list for the Sup instance ## ## The basic idea is to keep a list of lambdas to undo ## things. When an action is called (such as 'archive'), ## a lambda is registered with UndoManager that will ## undo the archival action class UndoManager include Singleton def initialize @@actionlist = [] end def register desc, *actions, &b actions = [*actions.flatten] actions << b if b raise ArgumentError, "need at least one action" unless actions.length > 0 @@actionlist.push :desc => desc, :actions => actions end def undo unless @@actionlist.empty? actionset = @@actionlist.pop actionset[:actions].each { |action| action.call } BufferManager.flash "undid #{actionset[:desc]}" else BufferManager.flash "nothing more to undo!" end end def clear @@actionlist = [] end end end sup-mainline/lib/sup/update.rb000066400000000000000000000015211166154264000166470ustar00rootroot00000000000000module Redwood ## Classic listener/broadcaster paradigm. Handles communication between various ## parts of Sup. ## ## Usage note: don't pass threads around. Neither thread nor message equality is ## defined anywhere in Sup beyond standard object equality. To communicate ## something about a particular thread, just pass a representative message from ## it around. ## ## (This assumes that no message will be a part of more than one thread within a ## single "view". Luckily, that's true.) class UpdateManager include Singleton def initialize @targets = {} end def register o; @targets[o] = true; end def unregister o; @targets.delete o; end def relay sender, type, *args meth = "handle_#{type}_update".intern @targets.keys.each { |o| o.send meth, sender, *args unless o == sender if o.respond_to? meth } end end end sup-mainline/lib/sup/util.rb000066400000000000000000000404061166154264000163470ustar00rootroot00000000000000require 'thread' require 'lockfile' require 'mime/types' require 'pathname' require 'set' require 'enumerator' require 'benchmark' ## time for some monkeypatching! class Symbol unless method_defined? :to_proc def to_proc proc { |obj, *args| obj.send(self, *args) } end end end class Lockfile def gen_lock_id Hash[ 'host' => "#{ Socket.gethostname }", 'pid' => "#{ Process.pid }", 'ppid' => "#{ Process.ppid }", 'time' => timestamp, 'pname' => $0, 'user' => ENV["USER"] ] end 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 def lockinfo_on_disk h = load_lock_id IO.read(path) h['mtime'] = File.mtime path h['path'] = path h end def touch_yourself; touch path end end class Pathname def human_size s = begin size rescue SystemCallError return "?" end s.to_human_size end def human_time begin ctime.strftime("%Y-%m-%d %H:%M") rescue SystemCallError "?" end end end ## more monkeypatching! module RMail class EncodingUnsupportedError < StandardError; end class Message def self.make_file_attachment fn bfn = File.basename fn t = MIME::Types.type_for(bfn).first || MIME::Types.type_for("exe").first make_attachment IO.read(fn), t.content_type, t.encoding, bfn.to_s end def charset if header.field?("content-type") && header.fetch("content-type") =~ /charset="?(.*?)"?(;|$)/i $1 end end def self.make_attachment payload, mime_type, encoding, filename a = Message.new a.header.add "Content-Disposition", "attachment; filename=#{filename.inspect}" a.header.add "Content-Type", "#{mime_type}; name=#{filename.inspect}" a.header.add "Content-Transfer-Encoding", encoding if encoding a.body = case encoding when "base64" [payload].pack "m" when "quoted-printable" [payload].pack "M" when "7bit", "8bit", nil payload else raise EncodingUnsupportedError, encoding.inspect end a end end class Serialize ## Don't add MIME-Version headers on serialization. Sup sometimes want's to serialize ## message parts where these headers are not needed and messing with the message on ## serialization breaks gpg signatures. The commented section shows the original RMail ## code. def calculate_boundaries(message) calculate_boundaries_low(message, []) # unless message.header['MIME-Version'] # message.header['MIME-Version'] = "1.0" # end end end class Header ## Be more cautious about invalid content-type headers ## the original RMail code calls ## value.strip.split(/\s*;\s*/)[0].downcase ## without checking if split returned an element # This returns the full content type of this message converted to # lower case. # # If there is no content type header, returns the passed block is # executed and its return value is returned. If no block is passed, # the value of the +default+ argument is returned. def content_type(default = nil) if value = self['content-type'] and ct = value.strip.split(/\s*;\s*/)[0] return ct.downcase else if block_given? yield else default end end end end end class Range ## only valid for integer ranges (unless I guess it's exclusive) def size last - first + (exclude_end? ? 0 : 1) end end class Module def bool_reader *args args.each { |sym| class_eval %{ def #{sym}?; @#{sym}; end } } end def bool_writer *args; attr_writer(*args); end def bool_accessor *args bool_reader(*args) bool_writer(*args) end def defer_all_other_method_calls_to obj class_eval %{ def method_missing meth, *a, &b; @#{obj}.send meth, *a, &b; end def respond_to?(m, include_private = false) @#{obj}.respond_to?(m, include_private) end } end end class Object def ancestors ret = [] klass = self.class until klass == Object ret << klass klass = klass.superclass end ret end ## "k combinator" def returning x; yield x; x; end unless method_defined? :tap def tap; yield self; self; end end ## clone of java-style whole-method synchronization ## assumes a @mutex variable ## TODO: clean up, try harder to avoid namespace collisions def synchronized *methods methods.each do |meth| class_eval <<-EOF alias unsynchronized_#{meth} #{meth} def #{meth}(*a, &b) @mutex.synchronize { unsynchronized_#{meth}(*a, &b) } end EOF end end def ignore_concurrent_calls *methods methods.each do |meth| mutex = "@__concurrent_protector_#{meth}" flag = "@__concurrent_flag_#{meth}" oldmeth = "__unprotected_#{meth}" class_eval <<-EOF alias #{oldmeth} #{meth} def #{meth}(*a, &b) #{mutex} = Mutex.new unless defined? #{mutex} #{flag} = true unless defined? #{flag} run = #{mutex}.synchronize do if #{flag} #{flag} = false true end end if run ret = #{oldmeth}(*a, &b) #{mutex}.synchronize { #{flag} = true } ret end end EOF end end def benchmark s, &b ret = nil times = Benchmark.measure { ret = b.call } debug "benchmark #{s}: #{times}" ret end 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 end end def camel_to_hyphy self.gsub(/([a-z])([A-Z0-9])/, '\1-\2').downcase end def find_all_positions x ret = [] start = 0 while start < length pos = index x, start break if pos.nil? ret << pos start = pos + 1 end ret end ## a very complicated regex found on teh internets to split on ## commas, unless they occurr within double quotes. def split_on_commas normalize_whitespace().split(/,\s*(?=(?:[^"]*"[^"]*")*(?![^"]*"))/) end ## ok, here we do it the hard way. got to have a remainder for purposes of ## tab-completing full email addresses def split_on_commas_with_remainder ret = [] state = :outstring pos = 0 region_start = 0 while pos <= length newpos = case state when :escaped_instring, :escaped_outstring then pos else index(/[,"\\]/, pos) end if newpos char = self[newpos] else char = nil newpos = length end case char when ?" state = case state when :outstring then :instring when :instring then :outstring when :escaped_instring then :instring when :escaped_outstring then :outstring end when ?,, nil state = case state when :outstring, :escaped_outstring then ret << self[region_start ... newpos].gsub(/^\s+|\s+$/, "") region_start = newpos + 1 :outstring when :instring then :instring when :escaped_instring then :instring end when ?\\ state = case state when :instring then :escaped_instring when :outstring then :escaped_outstring when :escaped_instring then :instring when :escaped_outstring then :outstring end end pos = newpos + 1 end remainder = case state when :instring self[region_start .. -1].gsub(/^\s+/, "") else nil end [ret, remainder] end def wrap len ret = [] s = self while s.length > len cut = s[0 ... len].rindex(/\s/) if cut ret << s[0 ... cut] s = s[(cut + 1) .. -1] else ret << s[0 ... len] s = s[len .. -1] end end ret << s end def normalize_whitespace gsub(/\t/, " ").gsub(/\r/, "") end unless method_defined? :ord def ord self[0] end end unless method_defined? :each def each &b each_line &b end end ## takes a list of words, and returns an array of symbols. typically used in ## Sup for translating Xapian's representation of a list of labels (a string) ## to an array of label symbols. ## ## split_on will be passed to String#split, so you can leave this nil for space. def to_set_of_symbols split_on=nil; Set.new split(split_on).map { |x| x.strip.intern } end class CheckError < ArgumentError; end def check begin fail "unexpected encoding #{encoding}" if respond_to?(:encoding) && !(encoding == Encoding::UTF_8 || encoding == Encoding::ASCII) fail "invalid encoding" if respond_to?(:valid_encoding?) && !valid_encoding? rescue raise CheckError.new($!.message) end end def ascii out = "" each_byte do |b| if (b & 128) != 0 out << "\\x#{b.to_s 16}" else 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 end unless method_defined? :ascii_only? def ascii_only? size.times { |i| return false if self[i] & 128 != 0 } return true end end end class Numeric def clamp min, max if self < min min elsif self > max max else self end end def in? range; range.member? self; end def to_human_size if self < 1024 to_s + "b" elsif self < (1024 * 1024) (self / 1024).to_s + "k" elsif self < (1024 * 1024 * 1024) (self / 1024 / 1024).to_s + "m" else (self / 1024 / 1024 / 1024).to_s + "g" end end end class Fixnum def to_character if self < 128 && self >= 0 chr else "<#{self}>" end end unless method_defined?(:ord) def ord self end end ## hacking the english language def pluralize s to_s + " " + if self == 1 s else if s =~ /(.*)y$/ $1 + "ies" else s + "s" end end end end class Hash def - o Hash[*self.map { |k, v| [k, v] unless o.include? k }.compact.flatten_one_level] end def select_by_value v=true select { |k, vv| vv == v }.map { |x| x.first } end end module Enumerable def map_with_index ret = [] each_with_index { |x, i| ret << yield(x, i) } ret end def sum; inject(0) { |x, y| x + y }; end def map_to_hash ret = {} each { |x| ret[x] = yield(x) } ret end # like find, except returns the value of the block rather than the # element itself. def argfind ret = nil find { |e| ret ||= yield(e) } ret || nil # force end def argmin best, bestval = nil, nil each do |e| val = yield e if bestval.nil? || val < bestval best, bestval = e, val end end best end ## returns the maximum shared prefix of an array of strings ## optinally excluding a prefix def shared_prefix caseless=false, exclude="" return "" if empty? prefix = "" (0 ... first.length).each do |i| c = (caseless ? first.downcase : first)[i] break unless all? { |s| (caseless ? s.downcase : s)[i] == c } next if exclude[i] == c prefix += first[i].chr end prefix end def max_of map { |e| yield e }.max end ## returns all the entries which are equal to startline up to endline def between startline, endline select { |l| true if l == startline .. l == endline } end end unless Object.const_defined? :Enumerator Enumerator = Enumerable::Enumerator end class Array def flatten_one_level inject([]) { |a, e| a + e } end def to_h; Hash[*flatten]; end def rest; self[1..-1]; end def to_boolean_h; Hash[*map { |x| [x, true] }.flatten]; end def last= e; self[-1] = e end def nonempty?; !empty? end end ## simple singleton module. far less complete and insane than the ruby standard ## library one, but it automatically forwards methods calls and allows for ## constructors that take arguments. ## ## classes that inherit this can define initialize. however, you cannot call ## .new on the class. To get the instance of the class, call .instance; ## to create the instance, call init. module Singleton module ClassMethods def instance; @instance; end def instantiated?; defined?(@instance) && !@instance.nil?; end def deinstantiate!; @instance = nil; end def method_missing meth, *a, &b raise "no #{name} instance defined in method call to #{meth}!" unless defined? @instance ## if we've been deinstantiated, just drop all calls. this is ## useful because threads that might be active during the ## cleanup process (e.g. polling) would otherwise have to ## special-case every call to a Singleton object return nil if @instance.nil? # Speed up further calls by defining a shortcut around method_missing if meth.to_s[-1,1] == '=' # Argh! Inconsistency! Setters do not work like all the other methods. class_eval "def self.#{meth}(a); @instance.send :#{meth}, a; end" else class_eval "def self.#{meth}(*a, &b); @instance.send :#{meth}, *a, &b; end" end @instance.send meth, *a, &b end def init *args raise "there can be only one! (instance)" if instantiated? @instance = new(*args) end end def self.included klass klass.private_class_method :allocate, :new klass.extend ClassMethods end end ## acts like a hash with an initialization block, but saves any ## newly-created value even upon lookup. ## ## for example: ## ## class C ## attr_accessor :val ## def initialize; @val = 0 end ## end ## ## h = Hash.new { C.new } ## h[:a].val # => 0 ## h[:a].val = 1 ## h[:a].val # => 0 ## ## h2 = SavingHash.new { C.new } ## h2[:a].val # => 0 ## h2[:a].val = 1 ## h2[:a].val # => 1 ## ## important note: you REALLY want to use #member? to test existence, ## because just checking h[anything] will always evaluate to true ## (except for degenerate constructor blocks that return nil or false) class SavingHash def initialize &b @constructor = b @hash = Hash.new end def [] k @hash[k] ||= @constructor.call(k) end defer_all_other_method_calls_to :hash end class OrderedHash < Hash alias_method :store, :[]= alias_method :each_pair, :each attr_reader :keys def initialize *a @keys = [] a.each { |k, v| self[k] = v } end def []= key, val @keys << key unless member?(key) super end def values; keys.map { |k| self[k] } end def index key; @keys.index key end def delete key @keys.delete key super end def each; @keys.each { |k| yield k, self[k] } end end ## easy thread-safe class for determining who's the "winner" in a race (i.e. ## first person to hit the finish line class FinishLine def initialize @m = Mutex.new @over = false end def winner? @m.synchronize { !@over && @over = true } 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 sup-mainline/protocol.md000066400000000000000000000072431166154264000156550ustar00rootroot00000000000000Redwood Protocol ================ The server begins by sending a line of the form `Redwood `, where `ver` is the major protocol version (1), encodings is a comma-separated list of supported message encodings (e.g. `json,bert,marshal`), and `extensions` is a comma-separated list of protocol extensions. The server must advertise at least one encoding. A zero-length list of extensions is represented by `none`. The client replies in the same format, with the restrictions that the protocol version must match, `encodings` and `extensions` must be subsets of what the server advertised, and there must be exactly 1 encoding specified. Requests and responses are represented as `[type, params]`, where `type` is a lowercase string corresponding to one of the message types specified below and `params` is a dictionary with string keys. Requests -------- There may be zero or more replies to a request. Multiple requests may be issued concurrently. There is an implicit, optional, opaque `tag` parameter to every request which will be returned in all replies to the request to aid clients in keeping multiple requests in flight. `tag` may be an arbitrary datastructure and for the purposes of Cancel defaults to nil. ### Query Send a Message response for each hit on `query` starting at `offset` and sending a maximum of `limit` Messages responses. `raw` controls whether the raw message text is included in the response. #### Parameters * `query`: Query * `offset`: int * `limit`: int * `raw`: boolean #### Responses * multiple Message * one Done after all Messages ### Count Send a count reply with the number of hits for `query`. #### Parameters * `query`: Query #### Responses * one Count ### Label Modify the labels on all messages matching `query`. First removes the labels in `remove` then adds those in `add`. #### Parameters * `query`: Query * `add`: string list * `remove`: string list #### Responses * one Done ### Add Add a message to the database. `raw` is the normal RFC 2822 message text. #### Parameters * `raw`: string * `labels`: string list #### Responses * one Done ### Stream Sends a Message response whenever a new message that matches `query` is added with the Add request. This request will not terminate until a corresponding Cancel request is sent. #### Parameters * `query`: Query #### Responses multiple Message ### Cancel Cancels all active requests with tag `target`. This is only required to be implemented for the Stream request. #### Parameters * `target`: string #### Responses one Done Responses --------- ### Done Signifies that a request has completed successfully. ### Message Represents a query result. If `raw` is present it is the raw message text that was previously a parameter to the Add request. #### Parameters * `summary`: Summary * `raw`: optional string ### Count `count` is the number of messages matched. #### Parameters * `count`: int ### Error #### Parameters * `type`: string * `message`: string Datatypes --------- ### Query Recursive prefix-notation datastructure describing a boolean condition. Where `a` and `b` are Queries and `field` and `value` are strings, a Query can be any of the following: * `[:and, a, b, ...]` * `[:or, a, b, ...]` * `[:not, a, b]` * `[:term, field, value]` ### Summary * `message_id`: string * `date`: time * `from`: Person * `to`, `cc`, `bcc`: Person list * `subject`: string * `refs`: string list * `replytos`: string list * `labels`: string list ### Person * `name`: string * `email`: string TODO ---- * Protocol negotiation - Version - Compression (none, gzip, ...) * Specify string encodings sup-mainline/release-script.txt000066400000000000000000000007641166154264000171560ustar00rootroot00000000000000Just 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- vi lib/sup.rb bin/* # and bump version numbers in all files # ... git add, commit, etc rake gem rake tarball gem push pkg/ # now using gemcutter git publish-branch rake upload_webpage sup-mainline/sup-files.rb000066400000000000000000000007271166154264000157260ustar00rootroot00000000000000SUP_LIB_DIRS = %w(lib lib/sup lib/sup/modes) SUP_EXECUTABLES = %w(sup sup-add sup-cmd sup-config sup-dump sup-import-dump sup-recover-sources sup-server sup-sync sup-sync-back sup-tweak-labels) SUP_EXTRA_FILES = %w(CONTRIBUTORS README.txt LICENSE History.txt ReleaseNotes) SUP_FILES = SUP_EXTRA_FILES + SUP_EXECUTABLES.map { |f| "bin/#{f}" } + SUP_LIB_DIRS.map { |d| Dir["#{d}/*.rb"] }.flatten if $0 == __FILE__ # if executed from commandline puts SUP_FILES end sup-mainline/sup-version.rb000066400000000000000000000007101166154264000163010ustar00rootroot00000000000000## allow people who use development versions by running "rake gem" ## and installing the resulting gem it to be able to do this. (gem ## versions must be in dotted-digit notation only and can be passed ## with the REL environment variable to "rake gem"). SUP_VERSION = if ENV['REL'] ENV['REL'] else $:.unshift 'lib' # force loading from ./lib/ if it exists require 'sup' if Redwood::VERSION == "git" "999" else Redwood::VERSION end end sup-mainline/test/000077500000000000000000000000001166154264000144435ustar00rootroot00000000000000sup-mainline/test/dummy_source.rb000066400000000000000000000017741166154264000175140ustar00rootroot00000000000000#!/usr/bin/ruby require 'sup' require 'stringio' require 'rmail' require 'uri' module Redwood class DummySource < Source attr_accessor :messages def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[] super uri, last_date, usual, archived, id @messages = nil end def start_offset 0 end def end_offset # should contain the number of test messages -1 return @messages ? @messages.length - 1 : 0 end def load_header offset Source.parse_raw_email_header StringIO.new(raw_header(offset)) end def load_message offset RMail::Parser.read raw_message(offset) end def raw_header offset ret = "" f = StringIO.new(@messages[offset]) until f.eof? || (l = f.gets) =~ /^$/ ret += l end ret end def raw_message offset @messages[offset] end def each_raw_message_line offset ret = "" f = StringIO.new(@messages[offset]) until f.eof? yield f.gets end end end end # vim:noai:ts=2:sw=2: sup-mainline/test/test_header_parsing.rb000066400000000000000000000065121166154264000210060ustar00rootroot00000000000000#!/usr/bin/ruby require 'test/unit' require 'sup' require 'stringio' include Redwood class TestMBoxParsing < Test::Unit::TestCase def setup end def teardown end def test_normal_headers h = Source.parse_raw_email_header StringIO.new(< To: Sally EOS assert_equal "Bob ", h["from"] assert_equal "Sally ", h["to"] assert_nil h["message-id"] end def test_multiline h = Source.parse_raw_email_header StringIO.new(< Subject: one two three four five six To: Sally References: Seven: Eight EOS assert_equal "one two three four five six", h["subject"] assert_equal "Sally ", h["to"] assert_equal " ", h["references"] end def test_ignore_spacing variants = [ "Subject:one two three end\n", "Subject: one two three end\n", "Subject: one two three end \n", ] variants.each do |s| h = Source.parse_raw_email_header StringIO.new(s) assert_equal "one two three end", h["subject"] end end def test_message_id_ignore_spacing variants = [ "Message-Id: \n", "Message-Id: \n", ] variants.each do |s| h = Source.parse_raw_email_header StringIO.new(s) assert_equal "", h["message-id"] end end def test_blank_lines h = Source.parse_raw_email_header StringIO.new("") assert_equal nil, h["message-id"] end def test_empty_headers variants = [ "Message-Id: \n", "Message-Id:\n", ] variants.each do |s| h = Source.parse_raw_email_header StringIO.new(s) assert_equal "", h["message-id"] end end def test_detect_end_of_headers h = Source.parse_raw_email_header StringIO.new(< To: a dear friend EOS assert_equal "Bob ", h["from"] assert_nil h["to"] h = Source.parse_raw_email_header StringIO.new(< \r To: a dear friend EOS assert_equal "Bob ", h["from"] assert_nil h["to"] h = Source.parse_raw_email_header StringIO.new(< \r\n\r To: a dear friend EOS assert_equal "Bob ", h["from"] assert_nil h["to"] end def test_from_line_splitting l = MBox.new StringIO.new(< To: a dear friend Hello there friend. How are you? From sea to shining sea From bob@bob.com I get only spam. From bob@bob.com From bob@bob.com (that second one has spaces at the endj This is the end of the email. EOS offset, labels = l.next assert_equal 0, offset offset, labels = l.next assert_nil offset end def test_more_from_line_splitting l = MBox.new StringIO.new(< To: a dear friend Hello there friend. How are you? From bob@bob.com Mon Apr 27 12:56:19 2009 From: Bob To: a dear friend Hello again! Would you like to buy my products? EOS offset, labels = l.next assert_not_nil offset offset, labels = l.next assert_not_nil offset offset, labels = l.next assert_nil offset end end sup-mainline/test/test_message.rb000066400000000000000000000433051166154264000174600ustar00rootroot00000000000000#!/usr/bin/ruby require 'test/unit' require 'sup' require 'stringio' require 'dummy_source' # override File.exists? to make it work with StringIO for testing. # FIXME: do aliasing to avoid breaking this when sup moves from # File.exists? to File.exist? class File def File.exists? file # puts "fake File::exists?" if file.is_a?(StringIO) return false end # use the different function File.exist?(file) end end module Redwood class TestMessage < Test::Unit::TestCase def setup end def teardown end def test_simple_message message = < Envelope-to: fake_receiver@localhost Delivery-date: Sun, 09 Dec 2007 21:48:19 +0200 Received: from fake_sender by localhost.localdomain with local (Exim 4.67) (envelope-from ) id 1J1S8R-0006lA-MJ for fake_receiver@localhost; Sun, 09 Dec 2007 21:48:19 +0200 Date: Sun, 9 Dec 2007 21:48:19 +0200 Mailing-List: contact example-help@example.invalid; run by ezmlm Precedence: bulk List-Id: List-Post: List-Help: List-Unsubscribe: List-Subscribe: Delivered-To: mailing list example@example.invalid Delivered-To: moderator for example@example.invalid From: Fake Sender To: Fake Receiver Subject: Re: Test message subject Message-ID: <20071209194819.GA25972@example.invalid> References: MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline In-Reply-To: User-Agent: Sup/0.3 Test message! EOS source = DummySource.new("sup-test://test_simple_message") source.messages = [ message ] source_info = 0 sup_message = Message.new( {:source => source, :source_info => source_info } ) sup_message.load_from_source! # see how well parsing the header went to = sup_message.to # "to" is an Array containing person items # there should be only one item assert_equal(1, to.length) # sup doesn't do capitalized letters in email addresses assert_equal("fake_receiver@localhost", to[0].email) assert_equal("Fake Receiver", to[0].name) from = sup_message.from # "from" is just a simple person item assert_equal("fake_sender@example.invalid", from.email) assert_equal("Fake Sender", from.name) subj = sup_message.subj assert_equal("Re: Test message subject", subj) list_subscribe = sup_message.list_subscribe assert_equal("", list_subscribe) list_unsubscribe = sup_message.list_unsubscribe assert_equal("", list_unsubscribe) list_address = sup_message.list_address assert_equal("example@example.invalid", list_address.email) assert_equal("example", list_address.name) date = sup_message.date assert_equal(Time.parse("Sun, 9 Dec 2007 21:48:19 +0200"), date) id = sup_message.id assert_equal("20071209194819.GA25972@example.invalid", id) refs = sup_message.refs assert_equal(1, refs.length) assert_equal("E1J1Rvb-0006k2-CE@localhost.localdomain", refs[0]) replytos = sup_message.replytos assert_equal(1, replytos.length) assert_equal("E1J1Rvb-0006k2-CE@localhost.localdomain", replytos[0]) cc = sup_message.cc # there are no ccs assert_equal(0, cc.length) bcc = sup_message.bcc # there are no bccs assert_equal(0, bcc.length) recipient_email = sup_message.recipient_email assert_equal("fake_receiver@localhost", recipient_email) message_source = sup_message.source assert_equal(message_source, source) message_source_info = sup_message.source_info assert_equal(message_source_info, source_info) # read the message body chunks chunks = sup_message.load_from_source! # there should be only one chunk assert_equal(1, chunks.length) lines = chunks[0].lines # there should be only one line assert_equal(1, lines.length) assert_equal("Test message!", lines[0]) end def test_multipart_message message = < To: Fake Sender References: <20071209194819.GA25972example.invalid> In-Reply-To: <20071209194819.GA25972example.invalid> Date: Sun, 09 Dec 2007 22:33:37 +0200 Message-Id: <1197232243-sup-2663example.invalid> User-Agent: Sup/0.3 Content-Type: multipart/mixed; boundary="=-1197232418-506707-26079-6122-2-=" MIME-Version: 1.0 --=-1197232418-506707-26079-6122-2-= Content-Type: text/plain; charset=utf-8 Content-Disposition: inline Excerpts from Fake Sender's message of Sun Dec 09 21:48:19 +0200 2007: > Test message! Thanks for the message! --=-1197232418-506707-26079-6122-2-= Content-Disposition: attachment; filename="HACKING" Content-Type: application/octet-stream; name="HACKING" Content-Transfer-Encoding: base64 UnVubmluZyBTdXAgbG9jYWxseQotLS0tLS0tLS0tLS0tLS0tLS0tCkludm9r ZSBpdCBsaWtlIHRoaXM6CgpydWJ5IC1JIGxpYiAtdyBiaW4vc3VwCgpZb3Un bGwgaGF2ZSB0byBpbnN0YWxsIGFsbCBnZW1zIG1lbnRpb25lZCBpbiB0aGUg UmFrZWZpbGUgKGxvb2sgZm9yIHRoZSBsaW5lCnNldHRpbmcgcC5leHRyYV9k ZXBzKS4gSWYgeW91J3JlIG9uIGEgRGViaWFuIG9yIERlYmlhbi1iYXNlZCBz eXN0ZW0gKGUuZy4KVWJ1bnR1KSwgeW91J2xsIGhhdmUgdG8gbWFrZSBzdXJl IHlvdSBoYXZlIGEgY29tcGxldGUgUnVieSBpbnN0YWxsYXRpb24sCmVzcGVj aWFsbHkgbGlic3NsLXJ1YnkuCgpDb2Rpbmcgc3RhbmRhcmRzCi0tLS0tLS0t LS0tLS0tLS0KCi0gRG9uJ3Qgd3JhcCBjb2RlIHVubGVzcyBpdCByZWFsbHkg YmVuZWZpdHMgZnJvbSBpdC4gVGhlIGRheXMgb2YKICA4MC1jb2x1bW4gZGlz cGxheXMgYXJlIGxvbmcgb3Zlci4gQnV0IGRvIHdyYXAgY29tbWVudHMgYW5k IG90aGVyCiAgdGV4dCBhdCB3aGF0ZXZlciBFbWFjcyBtZXRhLVEgZG9lcy4K LSBJIGxpa2UgcG9ldHJ5IG1vZGUuCi0gVXNlIHt9IGZvciBvbmUtbGluZXIg YmxvY2tzIGFuZCBkby9lbmQgZm9yIG11bHRpLWxpbmUgYmxvY2tzLgoK --=-1197232418-506707-26079-6122-2-= Content-Disposition: attachment; filename="Manifest.txt" Content-Type: text/plain; name="Manifest.txt" Content-Transfer-Encoding: quoted-printable HACKING History.txt LICENSE Manifest.txt README.txt Rakefile bin/sup bin/sup-add bin/sup-config bin/sup-dump bin/sup-recover-sources bin/sup-sync bin/sup-sync-back --=-1197232418-506707-26079-6122-2-=-- EOS source = DummySource.new("sup-test://test_multipart_message") source.messages = [ message ] source_info = 0 sup_message = Message.new( {:source => source, :source_info => source_info } ) sup_message.load_from_source! # read the message body chunks chunks = sup_message.load_from_source! # this time there should be four chunks: first the quoted part of # the message, then the non-quoted part, then the two attachments assert_equal(4, chunks.length) assert_equal(chunks[0].class, Redwood::Chunk::Quote) assert_equal(chunks[1].class, Redwood::Chunk::Text) assert_equal(chunks[2].class, Redwood::Chunk::Attachment) assert_equal(chunks[3].class, Redwood::Chunk::Attachment) # further testing of chunks will happen in test_message_chunks.rb # (possibly not yet implemented) end def test_broken_message_1 # an example of a broken message, missing "to" and "from" fields message = < Envelope-to: fake_receiver@localhost Delivery-date: Sun, 09 Dec 2007 21:48:19 +0200 Received: from fake_sender by localhost.localdomain with local (Exim 4.67) (envelope-from ) id 1J1S8R-0006lA-MJ for fake_receiver@localhost; Sun, 09 Dec 2007 21:48:19 +0200 Date: Sun, 9 Dec 2007 21:48:19 +0200 Subject: Re: Test message subject Message-ID: <20071209194819.GA25972@example.invalid> References: MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline In-Reply-To: User-Agent: Sup/0.3 Test message! EOS source = DummySource.new("sup-test://test_broken_message_1") source.messages = [ message ] source_info = 0 sup_message = Message.new( {:source => source, :source_info => source_info } ) sup_message.load_from_source! to = sup_message.to # there should no items, since the message doesn't have any # recipients -- still not nil assert_equal(0, to.length) # from will have bogus values from = sup_message.from # very basic email address check assert_match(/\w+@\w+\.\w{2,4}/, from.email) assert_not_nil(from.name) end def test_broken_message_2 # an example of a broken message, no body at all message = < From: Fake Sender To: Fake Receiver Envelope-to: fake_receiver@localhost Delivery-date: Sun, 09 Dec 2007 21:48:19 +0200 Received: from fake_sender by localhost.localdomain with local (Exim 4.67) (envelope-from ) id 1J1S8R-0006lA-MJ for fake_receiver@localhost; Sun, 09 Dec 2007 21:48:19 +0200 Date: Sun, 9 Dec 2007 21:48:19 +0200 Subject: Re: Test message subject Message-ID: <20071209194819.GA25972@example.invalid> References: MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline In-Reply-To: User-Agent: Sup/0.3 EOS source = DummySource.new("sup-test://test_broken_message_1") source.messages = [ message ] source_info = 0 sup_message = Message.new( {:source => source, :source_info => source_info } ) sup_message.load_from_source! # read the message body chunks: no errors should reach this level chunks = nil assert_nothing_raised() do chunks = sup_message.load_from_source! end # the chunks list should be empty assert_equal(0, chunks.length) end def test_multipart_message_2 message = < Envelope-to: fake_receiver@localhost Delivery-date: Wed, 14 Jun 2006 19:22:54 +0300 Received: from localhost ([127.0.0.1] helo=localhost.localdomain) by localhost.localdomain with esmtp (Exim 4.60) (envelope-from ) id 1FqXk3-0006jM-48 for fake_receiver@localhost; Wed, 14 Jun 2006 18:57:15 +0300 Received: from pop.gmail.com by localhost.localdomain with POP3 (fetchmail-6.3.2) for (single-drop); Wed, 14 Jun 2006 18:57:15 +0300 (EEST) X-Gmail-Received: 8ee0fe5f895736974c042c8eaf176014b1ba7b88 Delivered-To: fake_receiver@localhost Received: by 10.49.8.16 with SMTP id l16cs11327nfi; Sun, 26 Mar 2006 19:31:56 -0800 (PST) Received: by 10.66.224.8 with SMTP id w8mr2172862ugg; Sun, 26 Mar 2006 19:31:56 -0800 (PST) Received: from foobar.math.fu-berlin.de (foobar.math.fu-berlin.de [160.45.45.151]) by mx.gmail.com with SMTP id j3si553645ugd.2006.03.26.19.31.56; Sun, 26 Mar 2006 19:31:56 -0800 (PST) Received-SPF: neutral (gmail.com: 160.45.45.151 is neither permitted nor denied by best guess record for domain of vim-mac-return-3938-fake_receiver=localhost@vim.org) Message-Id: <44275cac.74a494f1.315a.ffff825cSMTPIN_ADDED@mx.gmail.com> Received: (qmail 24265 invoked by uid 200); 27 Mar 2006 02:32:39 -0000 Mailing-List: contact vim-mac-help@vim.org; run by ezmlm Precedence: bulk Delivered-To: mailing list vim-mac@vim.org Received: (qmail 7913 invoked from network); 26 Mar 2006 23:37:34 -0000 Received: from cpe-138-217-96-243.vic.bigpond.net.au (HELO vim.org) (138.217.96.243) by foobar.math.fu-berlin.de with SMTP; 26 Mar 2006 23:37:34 -0000 From: fake_sender@example.invalid To: vim-mac@vim.org Subject: Mail Delivery (failure vim-mac@vim.org) Date: Mon, 27 Mar 2006 10:29:39 +1000 MIME-Version: 1.0 Content-Type: multipart/related; type="multipart/alternative"; boundary="----=_NextPart_000_001B_01C0CA80.6B015D10" X-Priority: 3 X-MSMail-Priority: Normal ------=_NextPart_000_001B_01C0CA80.6B015D10 Content-Type: multipart/alternative; boundary="----=_NextPart_001_001C_01C0CA80.6B015D10" ------=_NextPart_001_001C_01C0CA80.6B015D10 Content-Type: text/plain; charset="iso-8859-1" Content-Transfer-Encoding: quoted-printable ------=_NextPart_001_001C_01C0CA80.6B015D10 Content-Type: text/html; charset="iso-8859-1" Content-Transfer-Encoding: quoted-printable If the message will not displayed automatically,
follow the link to read the delivered message.

Received message is available at:
www.vim.org/inbox/vim-mac/read.php?sessionid-18559
 
------=_NextPart_001_001C_01C0CA80.6B015D10-- ------=_NextPart_000_001B_01C0CA80.6B015D10-- EOS source = DummySource.new("sup-test://test_multipart_message_2") source.messages = [ message ] source_info = 0 sup_message = Message.new( {:source => source, :source_info => source_info } ) sup_message.load_from_source! # read the message body chunks assert_nothing_raised() do chunks = sup_message.load_from_source! end end def test_blank_header_lines message = < X-Original-To: nobody@localhost Delivered-To: nobody@localhost.eng.widget.com Received: from localhost (localhost.localdomain [127.0.0.1]) by soquel.eng.widget.com (Postfix) with ESMTP id 609BC13C0DB1 for ; Thu, 19 Mar 2009 13:43:21 -0700 (PDT) MIME-Version: 1.0 Received: from pa-excas-vip.widget.com [10.16.67.200] by localhost with IMAP (fetchmail-6.2.5) for nobody@localhost (single-drop); Thu, 19 Mar 2009 13:43:21 -0700 (PDT) Received: from pa-exht01.widget.com (10.113.81.167) by pa-excaht11.widget.com (10.113.81.197) with Microsoft SMTP Server (TLS) id 8.1.311.2; Thu, 19 Mar 2009 13:42:30 -0700 Received: from mailman2.widget.com (10.16.64.159) by pa-exht01.widget.com (10.113.81.167) with Microsoft SMTP Server id 8.1.336.0; Thu, 19 Mar 2009 13:42:30 -0700 Received: by mailman2.widget.com (Postfix) id 47095AE30856; Thu, 19 Mar 2009 13:42:29 -0700 (PDT) Received: from countchocula.widget.com (localhost.localdomain [127.0.0.1]) by mailman2.widget.com (Postfix) with ESMTP id 5F782ABC5948; Thu, 19 Mar 2009 13:42:28 -0700 (PDT) Received: from mailhost4.widget.com (mailhost4.widget.com [10.16.67.124]) by mailman2.widget.com (Postfix) with ESMTP id 6CDCCABC5948 for ; Thu, 19 Mar 2009 13:42:26 -0700 (PDT) Received: by mailhost4.widget.com (Postfix) id 2364AC9AC4; Thu, 19 Mar 2009 13:42:26 -0700 (PDT) Received: from pa-exht01.widget.com (pa-exht01.widget.com [10.113.81.167]) by mailhost4.widget.com (Postfix) with ESMTP id 17A68C9AC3 for ; Thu, 19 Mar 2009 13:42:26 -0700 (PDT) Received: from PA-EXMBX04.widget.com ([10.113.81.142]) by pa-exht01.widget.com ([10.113.81.167]) with mapi; Thu, 19 Mar 2009 13:42:26 -0700 From: Some User To: "monitor-list@widget.com" Sender: "monitor-list-bounces@widget.com" Date: Thu, 19 Mar 2009 13:42:25 -0700 Subject: Looking for a mac Thread-Topic: Looking for a mac Thread-Index: AQHJqNM1xIqqjNRWuUCUBaxzPFK5eQ== Message-ID: List-Help: List-Subscribe: , List-Unsubscribe: , Accept-Language: en-US Content-Language: en-US X-MS-Exchange-Organization-AuthAs: Anonymous X-MS-Exchange-Organization-AuthSource: pa-exht01.widget.com X-MS-Has-Attach: X-Auto-Response-Suppress: All X-MS-TNEF-Correlator: acceptlanguage: en-US delivered-to: monitor-list@widget.com errors-to: monitor-list-bounces@widget.com list-id: engineering monitor related x-mailman-version: 2.1.8 x-beenthere: monitor-list@widget.com x-original-to: monitor-list@mailman2.widget.com list-post: list-archive: Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: quoted-printable Hi all, Just wondering if anybody can lend me a mac to reproduce PR 384931 ? Thanks. Michael= EOS source = DummySource.new("sup-test://test_blank_header_lines") source.messages = [ message ] source_info = 0 sup_message = Message.new( {:source => source, :source_info => source_info } ) sup_message.load_from_source! # See how well parsing the message ID went. id = sup_message.id assert_equal("D3C12B2AD838B44DA9D6B2CA334246D011E72A73A4@PA-EXMBX04.widget.com", id) # Look at another header field whose first line was blank. list_unsubscribe = sup_message.list_unsubscribe assert_equal(", " + "", list_unsubscribe) end # TODO: test different error cases, malformed messages etc. # TODO: test different quoting styles, see that they are all divided # to chunks properly end end # vim:noai:ts=2:sw=2: sup-mainline/test/test_server.rb000066400000000000000000000050501166154264000173350ustar00rootroot00000000000000#!/usr/bin/ruby # encoding: utf-8 require 'test/unit' require 'iconv' require 'stringio' require 'tmpdir' require 'fileutils' require 'thread' require 'eventmachine' require 'sup' require 'sup/server' Thread.abort_on_exception = true module EM # Run the reactor in a new thread. This is useful for using EventMachine # alongside synchronous code. It is recommended to use EM.error_handler to # detect when an exception terminates the reactor thread. def self.spawn_reactor_thread fail "reactor already started" if EM.reactor_running? q = ::Queue.new Thread.new { EM.run { q << nil } } q.pop end # Stop the reactor and wait for it to finish. This is the counterpart to #spawn_reactor_thread. def self.kill_reactor_thread fail "reactor is not running" unless EM.reactor_running? fail "current thread is running the reactor" if EM.reactor_thread? EM.stop EM.reactor_thread.join end end class QueueingClient < EM::P::RedwoodClient def initialize super @q = Queue.new @readyq = Queue.new end def receive_message type, tag, params @q << [type, tag, params] end def connection_established @readyq << nil end def wait_until_ready @readyq.pop end def read @q.pop end alias write send_message end class TestServer < Test::Unit::TestCase def setup port = rand(1000) + 30000 EM.spawn_reactor_thread @path = Dir.mktmpdir socket_path = File.join(@path, 'socket') Redwood::HookManager.init File.join(@path, 'hooks') Redwood::SourceManager.init Redwood::SourceManager.load_sources File.join(@path, 'sources.yaml') Redwood::Index.init @path Redwood::SearchManager.init File.join(@path, 'searches') Redwood::Index.load @server = EM.start_server socket_path, Redwood::Server, Redwood::Index.instance @client = EM.connect socket_path, QueueingClient @client.wait_until_ready end def teardown FileUtils.rm_r @path if passed? puts "not cleaning up #{@path}" unless passed? %w(Index SearchManager SourceManager HookManager).each do |x| Redwood.const_get(x.to_sym).deinstantiate! end EM.kill_reactor_thread end def test_invalid_request @client.write 'foo', '1' check @client.read, 'error', '1' end def test_query @client.write 'query', '1', 'query' => 'type:mail' check @client.read, 'done', '1' end def check resp, type, tag, args={} assert_equal type.to_s, resp[0] assert_equal tag.to_s, resp[1] args.each do |k,v| assert_equal v, resp[2][k.to_s] end end end sup-mainline/www/000077500000000000000000000000001166154264000143105ustar00rootroot00000000000000sup-mainline/www/index.html000066400000000000000000000171711166154264000163140ustar00rootroot00000000000000 Sup

Sup

“Finally a mail client that does what we want, how we want it.”
“Every other client we've tried is intolerable.”
“It's just what I wanted, but I hadn't realized what I wanted until I saw it.”
“Sup is almost to the point where I could jump ship from mutt.”
“I was previously intrigued by a gmail-styled mutt-killer written in Ruby, but having actually spent a few hours reading the docs, and trying out all the keys, and reading the docs again, and trying the keys out again, and then actually engaging in a bunch of practice usage runs, I now officially can't fucking wait for the future of this thing; it has my full attention.”

Sup is a console-based email client for people with a lot of email. It supports tagging, very fast full-text search, automatic contact- list management, custom code insertion via a hook system, and more. If you're the type of person who treats email as an extension of your long-term memory, Sup is for you.

Sup makes it easy to:

  • Handle massive amounts of email.
  • Mix email from different sources: mbox files and maildirs. For remote sources (IMAP, IMAPS, ssh+file), use another tool (offlineimap, fetchmail, rsync) to grab local copies.
  • Instantaneously search over your entire email collection. Search over body text, or use a query language to combine search predicates in any way.
  • Handle multiple accounts. Replying to email sent to a particular account will use the correct SMTP server, signature, and from address.
  • Add custom code to handle certain types of messages or to handle certain types of text within messages.
  • Organize email with user-defined labels, automatically track recent contacts, and much more!

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

Screenshots

  • Sup screenshot 1
  • Sup screenshot 2
  • Sup screenshot 3
  • Sup screenshot 4
  • Sup screenshot 5
  • Sup screenshot 6

Documentation

Please read the README, the FAQ, the new user guide and the philosophical statement.

Please also read and contribute to the Sup wiki.

Status

The current version of Sup is 0.12.1, released 2011-01-23. This is a beta release. It supports mbox and Maildir mailstores.

To be notified by email of Sup releases, subscribe to the sup-announce mailing list. One email per release.

Sup news can often be see on William's blog.

Bug reports

Find a problem with Sup? Or have a feature you'd like to see? Submit it to the official Sup issue tracker.

Getting it

You can download Sup releases from the Sup RubyForge page. If you have RubyGems installed, simply command your computer to "gem install sup".

If you're interested in development, you can clone the git repository like so: git clone git://gitorious.org/sup/mainline.git. You can also browse the Sup Gitorious repository. You may consider subscribing to the sup-devel list (below).

Make some new friends

If you'd like to meet hot single Sup users in your area, try the sup-talk mailing list (archives).

If you'd like to meet some grumpy old programmers to talk about Sup internals with, try the sup-devel mailing list (archives).

Credit

Sup is brought to you by William Morgan and the following honorable contributors:

  • William Morgan
  • Rich Lane
  • Ismo Puustinen
  • Nicolas Pouillard
  • Eric Sherman
  • Michael Stapelberg
  • Ben Walton
  • Mike Stipicevic
  • Marcus Williams
  • Lionel Ott
  • Tero Tilus
  • Ingmar Vanhassel
  • Mark Alexander
  • Gaute Hope
  • Christopher Warrington
  • W. Trevor King
  • Gaudenz Steinlin
  • Richard Brown
  • Marc Hartstein
  • Sascha Silbe
  • Israel Herraiz
  • Anthony Martinez
  • Hamish Downer
  • Bo Borgerson
  • William Erik Baxter
  • Michael Hamann
  • Grant Hollingworth
  • Adeodato Simó
  • Daniel Schoepe
  • Jason Petsod
  • Steve Goldman
  • Edward Z. Yang
  • Decklin Foster
  • Cameron Matheson
  • Carl Worth
  • Jeff Balogh
  • Andrew Pimlott
  • Alex Vandiver
  • Peter Harkins
  • Kornilios Kourtis
  • Giorgio Lando
  • Damien Leone
  • Benoît PIERRE
  • Alvaro Herrera
  • Jonah
  • Adam Lloyd
  • Todd Eisenberger
  • ian
  • Steven Walter
  • ian
  • Jon M. Dugan
  • Gregor Hoffleit
  • Stefan Lundström
  • Kirill Smelkov

Sup is made possible by the Xapian search engine, and by Matt Armstrong and his tragically abandoned RubyMail package.

sup-mainline/www/main.css000066400000000000000000000006261166154264000157520ustar00rootroot00000000000000body { background: white; color: #404040; margin-bottom: 2em; margin-left: auto; margin-right: auto; width: 90%; } a, a:visited { border-bottom: 1px dotted #03c; background: inherit; color: #03c; text-decoration: none; } #screenshots { margin: 0; padding: 0; } #screenshots li { display: inline; list-style: none; } #screenshots a { border-bottom: none; } #screenshots img { border: 0; } sup-mainline/www/ss1.png000066400000000000000000002667451166154264000155500ustar00rootroot00000000000000PNG  IHDR) IDATxy\K剠PJ?TTX/ jzUZh-(*Z (7AB}ݦ,I}| 3|tyA@t= 7Oݞ& 1nxaOكʐ!C曤pCC?<)))>>~ܹ:::Zɓ29lmmE{b1Ҳ;v<{z/_DR;vаqum…:s̱cf͚Փe˖=zٳ)s̙?V2eʛ7o$P(y&Y~VW-b;w]֙Dڢۓ=ܢCo6m) 3f̄ t .ܹsͱc&Mukh4㻳[[[e}ԨQV0a3MIIwްaX,{|\]]<ʻ…  `bb2x'Op+++###{{ϛ7;411ʪv>~>kYHuuBY_+pܸqI_ N;-z="yݰ}nffRH$JMM@9*3f̨C2##`0 lllT4 2,b)S`555.|>B'JU$|;|kڵkW^&tf͒ٳX,nmmt'%I}}}YY~7OSSb}UTTܾ}[WWܛEYh<%%%%%%bO8pjl'[{U9ݸqcssX,TBpӥK2 D>@FF˗/WXl...F 8Bվد>( W3#{l*3`ImllTydreeɓGUQQaoo ,X`ՎXcmhh`0tzxx!C4{KHHLKKstt,..۶m]޽{fff^^^+Wr8IIIt:ܹsnnnǎ?ݻ_|LwFktȀ􊋋ܼW^8ݧB{Z,O4iȑ#>}y|Ne T*o/jzWw}GP f\nbbo{pS.{Aooo'nذS냲p<<;;M͙3ѣG80Doݺ&<22ȟ`ԨQCFFFh޽{2}A׋Dj@b UdVVV}ee?P($H{4hPAA^WW,688jժU c֭t:=((-Ǫ ?44TGG'44B드t:аB-ZԔܺu+::zϞ=#G\^^NTJ>:::1j999/_qgaa+={BBBP=W=^x1++|Jsz5xN)JjjP(o߾PTuYsz̙۷?zd>}Ylo_+K'>>p…&p 6N`ddi&5j@ dlmm-Ɍ)]*J$g&M!2`0fffg++9sHk׮-^Ia86A?tuu9Ba2ꖆD"p8臎)h6U_~e펎b_U7|D"H$RЁnٲ}@ vvvAAA<133Z[FcUUԥ .ܹS"<<++ 'G{r!ʞS o_׫s{tn9+{N'N{nggg===]]]DTlo_+K'%%T*5$$d2ٳ8ףI&ݾ}uphJ011IKKr .7Wc{hy<ŒJRW^ЫWT8Ί+h4cIIɨQ>seزVG)/_Af jԲ:33tݺuUUU)))BPr\}}U_4Νem R{:fu5 ꦏ_?/H| {@k!ʞS:izm`0/_Bݗ/_B\zeׯ Da;KBA}$~:EAa8N:gϞ-++AY8&ݰk޽6o!^Q`fffmm=~LA__\&hfmذ~ѢEͯ^B+4iiiL&sݺuQQQm0a… ڊ-+ g0555Ghvvv}bl6~WwZZZ\b6lj*X|)INN622І iH$"ϟcPu % ƆD"L<9//O,;;;&pyyǏ,X`ii9uTϘ1COO믿vJy)=d2d2JE(5@;k֬p==O?Qv_4˷sJ" (ʢEC)l?6L6 oߺ|N||СC;H)f͚k*<:?G044LJJJHHnxʔ)ܺu7|yfںu+,]t̙g5lsR*xb̘1萭mZZڑ(ʕ+;11'&&r\bfT* <Ÿ?w*"o FKMMEĉ+d2U*zM.eee\.W/Dٸqc@@@ | L&oذ!>>ܹsǎ;w.zT3f`!ǁ>~p8vm~2|G0wZN|Up7ʧyD;vK$6Z rVXʮ9mϵkװJ<ȑ#/g(|NWZd2y< ###;ھptt|omme0UUU7AePXX( N >X"jdP(;1b?E;88tQܸqE n_zvswzyyuESN B[DpAtr̺"e*ns=<<O <: Ĺ+Ю FR+S_ D7L@@@@@c0An8((СCQ*gݺu4iȐ!|MRRRvT^;&&icڶp\q͛7(s^Vs.%::zܸqI!00СCgΜsvv̙ICLFf#_Z$++ss1MMMwDt;;;X!_~eCCrPSS\Ν;ȍP(tr?"<{'O⻤eA|2솆rlvzz:FC鴗V&7=x`uKlر\.L:q\2qh)TWW?GR"H$e5#::.j9*;16VGdeeֲX,D"oݺeeeA=ijjjmmE~>F,߾}!!Ʋ2ti4ѨT,?}+**0[yjjj/^ ۷ow^8ϟ@~mZZZ[l~aEbT {{{| !!!++ y9U&/#Skt @Y<X]]D"QSSӷ~ K/eץ~www@) ?TUf2{pXWx]<6\TT=ŋ_x錌YjUXX_l  HK,9ra0p8H+mmm7mf-Z cbb N2dHSz"hkTYYiaaq…}[*~왺ss%&&644|w ڠyƍH#mii) S:GAA)P(fnn(sssO<@f(%WJx...7otqq9|pgꉥeHHP(>N_~>))ik׺޻wkʕ8J>,kЛCBBNs9s fll^zYXX!C$$$d2ƌp>|ؿwwŋ=ȑ#9998 ۍcǦ͜9?G7EY<_xA}}}lvCCJo@vIs] Iqq15o޼ׯ__v =Th2cp_]Μ9,0eץ-Z$)55PTood#.]Dlb9zJTjaa?WfwMW7eGG#FXYYm߾=''H[uhniiyӧOb1 A hyƍqO>]rS֬YӝZ---122B)Z^Z$=}TWWfM"H8BPܴ54999շnRE Nd2뫊X,nllH!n޼px<^ZZСC# e.r2YrD"T߼yc```hhHPpɭ[XGt eǎVVVh_{=[[[ٳ… 999ҥKT*UY}***8@R'Mw݁;vO>iiicǎ ӦM{捿H$B' Vۣ7z~z ٙ8qbAAD;v۷/Vh e{kql/e.''' }}_=Th^)}2#%Ƣ?|ѢEl6Ě5kV^M'N%ɺTlZXXL Сwh|kCCC4[лwo@rQP^[]iuQ(ߍ(--}vbb@ 8wA\.MMM0ۏ] %ǹ/n X(677___wbf݋v0_l򵵵b XRR2j(L} 㡋Eзo_777vG~Ȍ;w.ZO>A-`XMD2AFQ|@R>{젠 ss󐐐S8d2d2zaB!K~lddMӓ;ߥ(,tHa{?~zSNE纺}ǁ\޽{7oތMZM6%%%8pիW)UUUo޹sѣGCBBS6q鯾 N>n3"??ƍ'NJl۳dɒ??rWWğ7o^zzɬعs;:L&?/∈HtyL^..RT~;/&&&œ'O.**D8D! 6l(++9sfMM|CCCДlUUzwtqqٻw/dꕱv;w17??>kmmE_|E}}}PPӧOMF" " IDATRVV܌ק噛;v ?_u6m]EEE]]]qq1\͛5k444痕Q(l!L555hm2!!%+kpʳĉ"իWl6ު.{Ӿ]pJOO_-m?(io9smڴ]mׯ_uBi2, b1#T\񪬂KZDEYNr'Oh3I7Kz///___GG6CO>:}tO嵶zyyuE~~~=NlL&C>~lggLoܸ\?/_655E$ b4>hX-h'3JnI`` DYqn" gh7L@@@@@= D7L@@@@@c0An8((СCQ*Yj}Yn]'MZp!l?vجY;)˕JeeeX Lt'e  nnnOuqrr¼"3LB_Wŷ5 ::zܸqF[lѣGϞ=Tn3gNU[ ӻBִK>++/LexxxۨΟ?=~i'>TX7Fqp,Rf?eAf cU2>< (88v RRM* W  % 4+k߼2jv@ 4͛7(''GLLL y߿tL2eʔ)O SLaX2z(_&2[nmnn>zٓYƍ[v/^Y" yma̻w~ .ܼy쌲s+t<oɒ%t:=33ĉL=}i;w-Z4lذI&QabhF_,kj[7H+5e.X n݂ {߹P?R'6K$'NfddXZZ~QQQ萱 O#3f8|0FH$)))Y\tw}gmm# ϝ;ri4کS&L/Jv9k,kk디͛7kP> 8pڴi_}յkӿ\Lfcc⒟/lmm}}}^WϞ=;tPFL{?T^TT~XVLQJYּj9pѣ6dpɅZ<f t@YoI`el6 :4($81PVlF/3@682Hy0: %W @eQ Á( z 8հs'X[Ca!1,XޠOc=H"H @ާav3@^tص -V73a`ɠ~¶mb% qq*Kfxlm`.hn^`F04223?oi [@~PV6} eggϟ?/^ĴV"##mllVZ-[H$Rxx%K9?o>D4ӧϋ/П ,Xz#70&&`!CRD";::޸qܼCdɃ+ŋ/677 455]`*Z<| l+G[u)=<< Tĉx8hx rXZZB̭B~{{Çwv[]s|T[*\\\n޼rϟgggWVV9s>>8?Ckk[_|Y3sBs|Ԓ[KHHxŮ]Nj``ohhpB@+4¾k>?vXW 4:Q\J  zNo#8ޫF~%kKbb_ -B8p~45nJooHND"t Gm{ƌс1c~|?r> LBɠbp0GXڿ7---^(!EGGcĄf\ǏǴHhhl"d(JJRӒT ^~L>])Ӊ'޽YOOOWWW" ={XYY͙3'''G*^vmHG山~L,K$>̞_~e펎H[,T&=D"yL|tnn]PPЃCU333ebժrï^|LŃ&L& j:PCSU[Dμy%NPRRRLI H <ƶ$;qDLR&iecܹsܶlقu&&&111ΖdAAAJ=z```0uT*jgg0-[@LB(իWuuu͛we˖+~7$}>(22R&͜93%%嫯:|!ZҔ#G2W^؃dL;wp&kkk1c*"Jl_0Іj[{[J:T86bkk[QQakkK":괗[Wh'lllD"L&knnDgϞ5jTLL̫WO6B t8[ܡ %ljn)})$K+~%sV DlUO9711p!dW hxG]@tgaH @W p`$`0@*$ 'G.łB=z=+χ$HJ bcaDtL @ $}+uwwÍ=Zx1F}n=-Nc6mjjp'͛#""Tz;v Q\&8%ѣuttJdB"Ƀ+666>ydKK֭[]x lVUbʕ˖-h;wıt+C52;322 F'* % ƆD"L<9//RX0Lf'a4h_|D" +**rss֯_?xࠠ\kWB;K,0`@tt4ɼr zjee/ڣG/)_ rbÇW^踸̚5y͚57n\.wqqovVPx̙#Gl۶JVWWڵ PWWȃYhnn #wdffJ$ϟmpÇt+9s={vӦM6mŘ5BNNN GgϞa+ʐH$hL&ʉ*斟#pH/f׮]ƍ :u*CCCϝ;bL&>pe6c(MLLNHs܌ Ԗ/_njjfggJhUmj1D}~,0`mp, C}(S)*6. ep)4!)b@R(i%21 ^pN %l:xPku5DL&46ҁ0> $45;Ọ%O+Vpd0jDpbǥnxx=a?QÇMM߁8+Np A,\G ?6nKK(,^ n߆;A(?H d2Y(bX#F]a||CQG>yd߾}=eܸqd֭=mH !CzڈD3g`^E'm^v{}@|'l6=m `g^^^``ƎMp{;Saaakkkpp7Ӏ #ܬ㞶<*+!+C w[D*bd2% P 䱒rm<ďG/ 0A0͓PX" 1n a zh)))_5MVs D899!~HnƌEc\SVV4e2Y{ nyihh(--Emmm[ZZ\nppS6o\RRrͮ-::zܸqSO̙ 4ڵk2Etۧ]ZO<==ܹ[? zڈ3w.|Eu+Lu봓ft+++;ﯮӧOtttkk,--Te̙3e2ɓ;'''[[[:=W\a0t1:?… ---m۶k׮|=xOI \zСC7o\gˌ3d^rrAnݺ+MK BP]Z!М\^`b[0{GÈwN_UUb ;iҤDT`kk7nD":)) 3%%%%%%byQf0<!fff<O*DT$W3feee޽!222^|b ظqcqq1hׯ BT*>}YA$-_,66vԩ3gδ/p Hׯ_>??=⣬@kkk^^Ɖ7oޠro޼aX}bDbx ,tM!8Da=T3p[nh4HԄ))ӷo߼<$XVV:((1;&pX>>m'=z@ha-qm.nRa$Hر`n ΀F 7ްk$ñ@\# I#H΅B;!|8O`* qqa|?7o[mۀB(!hXbb .C߆ {Br2ƂyG`fp$$&Νo=6qqz5qq0kV*BhHJDy S֭ ۶)piw\2s7|֭#Gde˖\qP^^^SSE<~XEDy9w Bߟeaݻw߻w$$$xzz9::ɻMV(P=))?77N;wرc8ȼ+dڵ333ZrfBw=w\]]]bbbCCw}"^ZZ7BBB9uGAAVR'O))i;vlJJzG5P#q!WX+Tv$3߅WA"a5-|yAcP+Bb G a%Xse:ˮʛH}cg7ؐaM |1,ѥfgCd?\4c89mb|}ի +W69t(lKDaa$Ò%p y7"#a>p 2"#!%|::l\ 0{vZh8ە [1T \addy@r#GCw3f 3&rhGmذ!228\wLfg̘411P> mppժU ֭[tzPPB!DݻA ~wLMO]T*Bp8H#G2޽{aaa.醆r6)_O0t+744DB~lhh8{,?uN655ůojѾD֬(cooZ[[oݺ5??)@XXXee3g.^?_TE@ y5P\ @PR_ޠ {)|6#+p rAj#=+A \ #)N* \!$pz= LŠW^ %ifd@}= #Ft $92U ##+W@ ~/ӱ~eH& 뼿0믓&M{wcdԄV\]]TDw؁ܑ͛79KKK?+V,]tԩ`aan bB2옌]NNH$z.ǣ2{pႥeNN@ tJ,4#BAd2L 333! IDATHvzf\YfJJ Bqrr U;_&́nFh_ )3!e&|&7 A] dC;&IoevZD8> U_V̥R @+ )\ N5A!AGo{NIt9;kkkf#ɰJorX,Vaaѣ}||;w{SS˭㳻wnڴI(&&&ՋbIRTګW/իWKK2s{tV ( J 3OGqiegŊ4ѱdԨQW 8𥥥oNLLΝk#y\n{` h_O0QF̢=gϞ-++yՅ ߽9޽{#ʪ*uTVN2-"ҷo_Uҿ[19U˷ b]"'ަc3B"W8Gqo[3ayu~zw`#:LwkTc`QO;y!:D^ϯâN~6 b|HJ5k`jaĿfp8 m lܥt[6%C*^w믿}%Njdddff6eʔ,;w|||FʌB9wLv)SM0ɓ'*ڹ{|]]ݤ$4f2֭H$lf0555Ghvvv}ٴw,fxa„ ׯ^XƦoذA];Ц5kְXر;6tRZ"U.Szaggd2x;;CZYYcᖖK.0`ڵkY,իW[[[R40L2LR5OEEEnnn]].?t:}AAA!!!hJ!|14 Do$ʢԴ@>x8 ` րݻ08* ^ `Do0ևSȞ`ȩKj`P03Çz8h`h ܂vwԆ \ I@0u*LrL(-}y oAT}9{TVV/ءݛ>VsӧO>}7CN;~T*=zvJ҅ }QQQK,)((+//wuuMOO_~0o޼t???&YQQsNwwwtH,GDDD BU'##EGG BTvDuuu&&&ľ޽{_xп#G`Ϛ5kсXP/Ǜ9s+PBp >ٳgnnnGUN=A+;,c!~*BlhT*dQȄ!d0 SoÏ.p*ᗧs "ppaf`mUXA_^7CæMW;VUoΝp(hh̑# ǏÁPY rb#`NGÖ!B&BX,Fu1bD'?0>>+V1nܸQRR"߁egg~: K=zɓ'D !Czf뾃gO*++lӧ{ z~~~=Nxyy:99ɩ588X__ƍ=mz7k8zn@1=mEgٱc7!ܹ///H?\ٰm۶=gxOt̙mC᯿kbRGo" Ct= D7L@@@@@c0FKII0G=00svvV=My򬬬˗SVV&3?K7y撒7oj@ttq:lٲG={6>>><<y 9s хNB&Ql755rR ١J$X,u%'Hjkk-OW3.I~Z*d2~ lڝt>δsr P}%.U'P(?yZZZ Zdkk<8=.;ո:88ݻ.]b_~Q#,^zС7oj{;)SX,V^.ǎ;x)( Mi#رc%˗gΜ"##ѫ}W|C{@@@RRRQQa ۿL~<,,L>S2}v4uҥK W^_D"]~b8Q&;ʳ+Soݺ5r6sP:::˖-[rٳby) F377߿BBBTTyv9s!11ȻR<͛...I666b^{%ѣvvv7o~ e-gƌϟ?+**ԞoK/r j]{4K-|>FFFڄyWk 돉Ivv?WWWuj߾}gϞx~~~6lkjڪ}븓UUUÇI166\fP( N6msE~-KQs'/JoիRiXXXpp͛5ȻyDDDzzLJ BZTT6mty&3gi߉\+WJ⫨H$JHHh .\HJJ@[M~<;сGSYwjz\.>^ڽ{^v>@ff&566nŋ#GI]v-XήW^3g\]]ǎtd! Qׯ_GG%KoȐ!gΜyWI733KLL L~gD RR~>)))Ǐ4i矃M=m433YX,mDBs^C.i3۬_>##:iSZZ/8p`ʔ) x)Wfffd>믿om6q%K]m;V[ 5ja9Tz***jRRRϞ=9ݻw$N4Μ9SSSC Os85Ϝ ''GUH,B&bܗZFIbbƍ]]]*\9577777>|x; ʳkuo,,,b3B<999>>~„ ͦ 3.}2qĒK.=zNNNR}[j,uMXgg!C]~~``֭[kkklvee%|QB"ueXLCM/((PTAN2 ĤSNԔR{eeee`````@| rq^gkQ6Q5Cyس~@333 暚\N'éje.++{𡷷k״ީ*^QQQWWGehՆZ9&VVV,dYr{{{X,>eڞfO<1sLjժl+\yzz BC#r<;ooB6d2HN6N4;wrr;v\.wuup8pjjGH7xdL&444dW\H$,Ԅ;w4{ARSS.]zD_ݻwM"\~>-"77?LOO_bENNÇE"mx4Мj@"d22fI_N.iOQQ#iwѶbqGGGP(\*++d2s:w\]]M.Ls5c-j?ia aC% {X3概,˗}}}YR+.˯^:f333cc#FܿHff;wrssgΜiccﯸ6lgg7{l77'NYYYYUUU]]/ibbbRRRlYPr)`ɒ%eeennn7n^rsU۶OM*ۮDWI^-l6{ܸq&&&GuM:SqqqwÆ J ۛ`ۗQy{VSzKQo޽i޼y\.OJJ244|4;#MvZ;; gg篾,X`ԩ...aaas̡O\ccsܘi ϻj ,{yo'+++99hժUz0anvڶ̙3mҥeee'OԴS ={xŊaaaRرcpiv}S^:A(Eo@v ca駟'; &&&111;vh><ܔkגLMM.\/_4;wjjjjjj***6mZeeL&Ddŋꈑ|j{wNqmm-qU^z}ƍ#ܽ{ٹA=##cΝǏн&?5\hQii)UA?gFF@ hW`nnzꨨCٳgdDS:ży͛J'={!`0eee6lr<{L.+~7N1|2VciiMcdeee֖:88LRS&{4iRff&}.4K."d%h|i*W΋nx6???''.ԟݻ߾}["H$gϞ7UkMׯS*ٳdP(f^gM~jj7MGw}ݰ`nn.$Ɍ3^/t~8PubEEE~U^nshÿjs~>ׯ_y;H}YWll[n4=ݾ}[ؚdΝ;[li9z;uD6i%M ѣS@@=}v꿈~4?i{dpZ/EǏ/..&P˗-[rݰ=zrOJѣG=/}7G=z4ݰ=zwzѣGK#v$|էO*|G[sθ={L4:փDq OO˗/S_kØ1c RH$-xťKM!_F6\}LQF`izo޽ >bϟ󣢢ONMN7d퓒>}ܶegњGF&ֿTWVh9r,`a$^X=%] IӱpaK̵c&ٹsپ}1cƌC;vlf#:gSj?fX: ޽ޞMuqqqrr͠999۶mܹ޽{]]]O8lHKUz٥K?J .\P:u*[`U|}}GIɓÆ ֒C|7|z$666vDV1fmS􆐜 n~vn811T)fI0ː̒Rz\.766 Z>@߿͍I"7ҬbZgww[ZZZ[[⊊/e嗚OO.]޽>2*,,,,,,**j}_TZPP_sΜ9TP7J^&h?رc/^x5+w@X|s&""r,&5Va]1!jBP%K`lObcu @ Bd$VUmY!!KDF6oj`DEGXTӮ]ӧ#F`͚]]wʕ5> 7ߠ%vϟR a0AAA-xSLG 1޹sݠаk׮iiiTʈ#ݻyfm[>qÇWUU2LCa4JO2yyySLԩQ|G2L*>}T رc#Gl-Ȩ_~)))|WͳG=dȐ,2 )r+Qyxx9sc׮]?~zvv6 8uԼ۷okRo+s՟6_? N>Fh<#[ ; "sNB* OJMȮh!:j=S8xB)_8xC+Mvfv Ž(Is*l؀ P_~h(x1E}=#795ݿ?""0gRPqB4> =wQvUUUÇR===O8! O:EM&&&`0>>>ڈ,f͚UQQqYsmذ??}~XZeSRR(u0Uŋ4˝0an2dptt\t) MN48e_"DDDTUU͞=;;;;...""űm? eX$mBpȐ!766f0ҩ333\nܸq~Zt)}梢}t( IDATmڴߟdFEEX@dŚ^6m6moQ}AS}TnKO[O~׊3Cj (k&L 4 tl}&M <\L|<-W^9P-lt}CXMT4\bm ׮A$\FL jj 1 ##6tëWc,i7|JJ 0p 5 hODKK8:@F*^^ _b/077g0IIISNp8wi׭[')PBooXزeԩSo޼)~>P EYi򇁁AMMMJ^paB!%V_uQQW0r͛7@*EϗX,&m2cc5k2dP(ҩɌP(lܹs7o\RRBY,k[XXHiÇرLꃦr[Z[l! c3gj&L c|~)Nk̘j>D7+$ZJ>vsxNFlnh r6f(fƄJ6eVV`@(=ԩ``n=`a;;׸CS:O>k\tƍeee9u+//733KMM=zGBCCIkț@ tR544,--z*IϪ 0/ʚ7o^QQѬY@[ԖKN6-// ۷OqMHHHP:JS&ev+'S6?,OS j(ad;/JQY+" p0{@e=Ʋ7T`k DRl0V+v-"#1r$Fd$ɯC`6};kׂROM'Op.8 RRj`duVkŚ5};={Kiov2b4>}ӧOV mPS .C[%_^_j_;˗_~9hР? "[Һv} qaG%ɀش FI }7?Yik8Ω ٖXWW'l_slb[9tXݰ=zxgGߢG=zwzѣGKC ѣG=/ }7G=z4:b7lmm}aJqO>ru 00pΝqqq{4it޽{7nEap%yS =jƍ o+A־ 뭷?/QQQThF5|>5}t0uT"3:\O~ǭ)a޽Kba6I077'X+/_&7E"ѱcLjpg߾}޽Kkjj"""Kwر:I_b.]@ ޽;Mv͢MmuZ˺>#[8|?"A[c#vÄ=z(ʵ-9so>f̘1c4o!77wJrrĉ{vreٳK.jԝ]gϞSx Zwȑ!'O6lJ_@pرpl߾}Ϣ{y.]}immD")--{6oLd(h[ӧb62:3v؋/j3{QqqP(,++5ꀾ替{uuuaaaBB› ߵnkGS`nnA]Ο?okkۣGD"HHO" Hzꥩׯ[ȑ#ѣ999Nsww֭֭[={%OggI&) wN=GsUz+V4kΜ9TiuuuH_ZT 躺:L&7+++yiC?ix }U91cg<;ڷx&z`ϋ|7 1X9h_+ O` }8[6 Gl=5F70VPQ맩 ffbC&TzOyxՠqFT|qvϟR a0AAA-xSLG 1޹sGn]vMKK24p8:3gxxxڵׯ_&HNw@ȑ#'Oѣ?8tCS 4Z󳲲qqq]t&\]k;loo:rHJܼʕ+yԡ\;699:tO>{졹4K[.8Xf7; "sNB* OJ ,xC<%gXh 0Xt|8blly PX]]})///Sbb㣍a֬YgϞ2]-ߗdD&믿ngg~{?~<''0:::%%5[.]*d2P(y$88ѣG,K h+Bz\SS#fϞ=pLVYYȑ#SRRbƍ]]])aPTZSSC ϣrQ_䧛7ofyi+?Uከ PH殍ڴ6&33méj(++{𡷷#۬ӧgݺuJךixWsʶ5H988dee988P|"J U~[Z9tQOJΝ?޹sRDI{e2P^^^SS윑K.ي9Ɇ{t}.\LryeeX,_ |r"ԡs=DGrǎ?ï}Z\\Lҭ t钥ի8PVVi,f ֌39| #4KHS}$KyOUp'fE@H!J["bP8MbJp5oP/܆Dzu+a%Ar98()Ms4V^@%u(+Ç#]kXK&+WBУaCB{-,,ƌve___///mf?þ}nܸQ$ƚ>mJNN.**ZjU^&Lʕ+ϝ;GZ51zzr:CdϞ=[\\b gg簰0Tz1*'xiU"fƍ711=z[Oq,,, ɩ^ 쬶fϞ^\\| 2⏊rrr߿m333jf+Nky򪫫E"Ѽy󬭭?Ú4Lֻw#Gk̙˖-r[nm h*7'''::gff޹s'77w̙666.<~899D &''?{LqIeddfg͚E/RSޯ~-//oNNN{Q^> 祥~jy%(CiRԉU0uX<$h1O!N`j ^!{㆛sÂ֦xhh&  dа˗ //PQy9nG̟Y[Su%޽`޽۷o>x Ӎ7AL&s]vݻwolllll,y5&iӦ̝;Lh3<4i)iė.] 6mH$"W^E(EϞ=;%%/33gϞΝ[jŅf+_P=R_~|>O>ILLⲲvܹq˗/d}w^kTDh?RSSG#jڵkC \t#G=zBqG_[[KT2.\XRR2a„Gy/\)믿V2=uԢ5kddd0L&Ç8@_\aa%KJKKݻf/W-j׌3Ν;W\\7ۗW{韗Z6id*oIYR j(ad;/JQY(a1v^8c0c+qA3v߄@o W@l,ň6n܀#(G۷C w!&kׂR++,,~S4ann.$ ?h0po 5]pO_=S;\\\ZijeeeQdΝ=ڿ+K}쀬ɽ_/?g@@@=ȲPiiQ!w҇h_Rmòn 4lIڵk{ԩSiiD")++۲ev=ZSSC*н };ݬ7vߍ?~|qq1Q^^lٲSSdV y7{C%յz1 ð]1H}7G=z4^ѷhѣG=aݰ=zwzѣGKC ѣG=/ [[[>|RӧO ܹsg\\ܞ={&MD}{C#nݺ]xQ"rT6bĈuE\tImlWa+CzX?~<*=zҧN6_wT<==/_L'Ap^ghO?ocD1ՕDȱ3?EӨ-,x`lڄ>j@׮PΌĞ=8|ƔѣR6ڵkxxxmmS nffnݺ%q){{5kݻgϞZA'O/r'N?|=  ׯjv-@Ϟ=tҚcǎ=z;mlljObƄCOc- h"#q,-ѿ-33χ޽5?X"ࠠ 4 p>BxXJ7j/tR+}Q!'OAsS I? IDAT'hxKtBSO>}immD")--{RH$Rz0aωUUUn_q4LSnNՍݻwS"r@>X[[:uJ$d2DJSu}Μ9d G.⊊ FMWxe"k_ZZ{y.]zɂ |gd8ɾZ5]MeggwixWET|ڷ /F|7 1X9AqWLM>-5cxzAhRuV?ļ c0`WСFj׮_ Ol2< G` /oR=tSS#* |>@گ]|%"#aYΈď?СqIN<vt/Hl6T\罼͛j0Ex)SˣK__;whdhhصk״4h!DIIbcc'OlmQ7Z.3޶8;;،5Gۼy3B:tÇ>++m#M!FFFKIIͶF k߿j[4ՇKʕ+ ZhДٳg\ëJKKL&/Vh_U~ݻ?~W^RƆsMh:}yzz={㥦mذԆϵ|= 9'!akW'mrdWi_Sv5bB)nlRl3qVa* i5Xpu /NDh(-4jzh(x1E}=^dvq X;mPSPa:7 |}tNvI骪Ç?{{zzYF( SNM6HLLx">Y***̳{~~׉̻&L6lذzdydSAA sξrʯ #/iX,-ozjTyf*Cff&566VnH$211!ݻtMM$%U;4Y--[ȉ'Jo_unfffG_m۶M8qɒ%hMud}ah '/uTU q5?ČD{n5 t.u BTJp0x0>d1l#9k@j]j*tqyb ܜ`Pj3$))iԩ.YniӦ[,*ʼ2d̻*ӦMPNة7n}GՎ&5JXYY%f"[XX:ks5T!瞟oaaA-i\pŔ>`b{Hi$.V*zj!#OtH%(>}v3#v L堏l4"@.G"c ( ҤN;A"B!@)YLL+AC\>BnwR@fffAA5\SS#9U*SU0RVVCooo___YӧO۷ukJWU杆oSN{z>'''&d2 lH$:y򤇇̙3Y,֪U\ A*&'''''߿uic4(m啩iÒT]]sm@ hb4 EoUJt&jCMM͂ y<ӧO Bd$ԦkoTU?%;4kB7(++d2:88PbD&Nf>ƾܢCӹsg;wa^bs' ?,QȡJ4kڿsM& Px_n6$IG0r,.}piLM24tm*gW^õ|C*!#*n@*U8"re 5 Ug rk($U|}qF# L {X3zpe___//˔̲f>þ}nܸQ$)62lݺ5&&5,,je[*3NrsQblliFl(.;eʔ &XZZN<߿;Wvvv5kVeegϪZ˒CD[fׄ\.z1čGq} NNN:w4aĈݺu6@iJTK/..677p8: P@pܹW_}uT?f0C ԩ{Gik->ٳ+Vpvv JǎB6P/AiJ NËj[5.6ܑb-6!]Cv#UBtbVa+FȬĄ/IUC^b`Ĵ͕9^^`2+6Y9o㣏UY[C6TUS'تXM ࡲ<oo@U: Jջ`0vAp8~))=|-ILL̎;d27eڵ'ۧwﯭvT䡵5x⺺|i'ݻׯ_oT'>~Ç?3uuud"qH{wN~.xxx\~8diii>>>͖ ##C 9s>7n\k,.\0::/_MvN62o:.S9999d_?rpp8{,)- cbbȤ4s~讲O<Uz[3lm~ _oS:h4)Mu"Ǫj"hM^qÎX6x!&}G`Vne-tn!Cy3oߋmmimcBjM75EP~11ر|АN@P?qM 45I֭T3޴ ;WXW`nn.$Ɍ3^/@󇆁azX,VTTT.]Ϫ=YlYZZvX5EYJ`b8 }ͤCoggg_/Cí[jkk|a87Nz6ő#8uټIi=zѣ祡֣G=z^&nX=zyia=zѣ祡ѣGFGIJYOQ谭ݻ_|7ʰ_zuLL _d*T X|9''u=Ϝ9c۶m:ȠᚕCX|yiiizz:P]]-ANH ٲe {!7n322=eIIIO>ڟ6 w@}> )Spp1>][4K"xEoG@렇0G:A(^pAK?D";w fΜ٬ٳgs?~jۅw}ŋ˗/KWٺukAAYXXM6UVV&$$8cƌ)++vvv O2d1xz砧=HNFsat3̜_B73[ir[Dh(>:n7Ha$eHH fbcc#}sXJD-OK45"8QQ֎ ?KiZ8$$`-ZM2@fff^^%{J7E\~_733{W<==^666'ODʤțҥKtt4VO h[dG9x𠳳30`#ɭxСү( ;>}RQ砰0??…:Jp8uuugΜصkMwex/}ǰ r t΀i3t0ͱ DAZ~st=u}}q$BChQLq01Ap0f B Ŷmi)jrxA@H a"x2E+,^l̚~VsebUPQm @h(x1E}=HƎ WUU%''+F166d˖-G 'O\??=<<\L:8Ƀ6Ζ7''ȭk/3Nj~PpjizKܼy|ĉKJJRRR=zW^yeĉDL3gμ:+I),,$DI?]PʤCUĜ:e"%?L ܻA0c@{FHgBiSߍS~ ffX66s{BYƖNss @c X6rTYYʚ_k+p ;Y_ !Q, gffP555rp8z{{;::^vM"P=۾}{}}}}}}BB²e d*UUU}݊+8NL89zʪʊ ܺ39|򠠠FQS(f,5(RП ,Xh`2ɉ\>qĘ"jM7d2ۋb\^YY)Hd26Mg=Y3HsOJh;YzY{Ua1Dr98(J:Xy-z9hqԴ0H췆zam+1r$(e\_ #ѹ39ď\qp@OJ*YkTGA27oћFz [b<| R)`JI;)Mڨ % ݻolllaa1fj˾^^^Hᝡ!&Ic:>[F,%(HPPHEQ=!& 7^JbHP IƊHЀFT@:K?.-ܛ>癙gfΙ3;wy`lOOϢ"s0a /Tw7KKK+,,\x/񼨃ܺ* Nc2t;ϟ'''m޼yС3f̐?mjjR[N1~=zH$kkk6f6l0sssoqy!1?tpp gϞDF#6"̚5+vNj }G!/W7Ѷp 0wX0aajFj &`,(ſj 1Q7B&ãG9iiHM_Ryz:|}bSB>cXhV_&8jӲ/^A9cIt4 `TU!5K/04RED""""##GGIII666mU @LL !v=hР#GDFFkVk``pȑ-[Ξ=[Uooo>ݻw6ʶmۖ`0(--O***sssMMMuV:|mm 9 ,(((hnn.((ߦ@H? BǪwǏWTTW\С~߼yx MHHͭrn}e[Ǔ䯪244|є)S>|ٳG$_;}jo+!*,*#pM o!2Jy,|ACTZk 4C*C$Tْ = aȯv"1?>:q#<&aHc߾>=CWŶmFX>}i/RA_8p@n dg?==DF", ΁Eh(6l@Q B@ky 4nnؽ6!/O3$vԖK(Dh(&Mji046b^;`)% 8pu;Ko\N899}'hm pMB^"<~w\W5\F^)O<|OwڕVҮ&qƩͦ4X'NZr/Z566J܎[^];=y䣏>t˝kll,(<醽C-:T?K:t O=[4ᴥ%N\~G. ]Q!D!Ol9Km  dgg>ׯ/_\XYYxb?7mڔAbF]pرc۶msrr2dDBz:߫o,,,֭[cBF>==X-9{lqqD{ IDATqVV+2dH>}49 Mi<sӧkkk֯_/Fϝ;˗/wPvbԩ{1bDjjb[;v ...:9SÐX_w6"J$'C[ 2Rttǧa;wL2]iPPPttѣGY,{{'zsܘ###qqqPLss޼yĈA8NHHH~~K7ntH$"NK&S%w*Vȫsf/\PXX?3{,...$$>hb_"~BB;L̙3OAF!%%[.6+uuu9993f<{8pV84ѣMMMRT$? MUv$N...njjjnn{SRRb1{\O{;w$N_v!#lٲbj*e̙BP(477 3gΨ3pk׮\$UWW&)Uo' |Pt>rLӠ1 |*NǛ xhckN1?lۢ4ec狅!S6;ZGM0f >"ډ7B i'>hc˖2~<ǎ!* `E4|5/i4ڊ+V^moo?o<yyyEEEr!&// rM>>z鯿\xl{{K.Cc1vؓ'ORqff&IzttOrr2駟 yPU*KJJ,,,޽knnN?hgg=b:ɱˎ}ovȑ֏=4iҖ-[HTU^UXK.577O0O>J<~N8 SJ_U<?~O>G%2Ibt^}q/.bES~vsH0]!{! *-ׁww 8@$,>a,ttlDu3BZ%=#1<4aaXk޾64\[[,tb\]]Ϟ=+.\BΝ;h4&"K$~ڵk٤RϜ9o8q~jhhmnnSSSC($>~ 33I[;PΝ;ݻwB8I)jQYHPѤ&<7B(piydM Ĭr9r o oN)` DE!* W%w uuG~RF}7 ŋ7nh4Kuuuqqqp8GGU[[{}9,X6**jԨQ?/1GȪQJU2$wBpĉ}Q;obb@$zj/̬˫ Hw{@ $''Θ1#!!Abbb"+߿<##ÇϞ=ٳff F{YJXrO==zSSSV]]*UDR__OA\^+))inV+v/--%t!!! ӓH$D~W{zTJ,+J e@Yg"[PP@<ʿjyC*Ae,VkA׃!u`+A(m8[pH>k92 Vk0b͚Ct=@1eRz!Dploxi-rt: ^^/;Z&8*Vϯ> '\HJJill믿Ԛuqq׿ &&رc&O\SгgYf=}t„ iiifddx{{ 2ի7oj{U ̙3f̘u߿J]NSSٳVZ5mڴ[n5559::.X@(ת˫RAff&ƃD"qqqٹsgttڵkom윞7o$+rsso߾`"#GwWz{{ϟ޽{ذaPoҵm ddd477]C244|є)S>|JKK?䓊\SSӺ:"*?UW[Hdee>|X$=}v$vTʶmۖ`0mTڦ"!y¹G BvKe`XD6+P Y]s{"LkQiFt:6yb;XT_"á4 1?,y5 4#zkt#<&aHc>55ƦMؾɓ!1,Y>1Jacll, bվpssdn0̨>}߿ՎP&6mP)z 7wALLLW7#%%UNMMN5~~~ r.]j(^l]E~>^J)hvp8Mv>}:'βZ~}W{D:1τCW`+5 SPPPPPt Ԣ4EWBM]5 SPPPPPt4LAAAAAet3#""|HdXpٷ~WtzϞ=-[&?Ӏ$][fϞ=p@Eu˗/keMoݺEMU}49CU<ܹsӵVVVׯ;wn/_iؗ.]:~8&ǘ:8_xrjty:u+Fܹ3eʔvljjb '''Tz޽#G{{;w._8ݗٳgŊ$opH =_w%?cѢE)))픚ʬtzDDDzz:IOBaXXD"`aa_ 0 77wϞ=Ǐ>}P(LII!꟤z100Xxȑ#t^WWgccw@,ISSlذ_Hҵeڵfffϟ5kVnnnHH}Cr U8ի۷ωƕxbXbbcc333rBuۮg߿޽{--- 5 m*?WZշoߒ"XL h]all6100FXbJu^^^QQ\+--d&wߍ >>D@  [xڵk߷o;vZYnY[[Y& yŊ^(:⒑A;>>>z鯿G:w.믿7oGXXL&+..x<*T7hU-$(^\W:k۶mʒ%Kegg{83''G2!ۇxܾ%A1?I+5k/Y#O&aÆb.rJ''' r'^W$|͝;w.[L":^`ɒ%aaamoⵊti699yĉzY@PWWw;wxzzh,)ի;vغuK-Zꪘ2eJqqMHII!֮\m044ܺuk]]>8Jyrewd~_'$$BL#9rcE"ӧ;]`2̎6yڵcȑm?r,#'OO>H,((8zuV\o>r4:(.9'"ӷo9r$Txƍ~奉~U%㨵xrgΜH$999r1?E"QIIIaa@ ٳ'Z [n ϻ_177wpp8uD"ϗ׃}\xqƍ$illL@=z ?>ñ600?tᩴo߾cƌvWWQQ_T!j888$$$8;;[XXʗ$ J&i -_qJB~5((H.* ѝH$"FҎ Arr3Udbb $۷O:Ev;))i…mw(Em}*7vN?JKN;IP,b577/]tB޽{WE).?G}m$^++[*++ x?R)_bM}NVqBb_OMM N744ԭ(BZU]]Mz+))6d2SZZ +2++k̘1666 bWo;P^^׷285%'|׮]144ܰae2ٌ3\]u9 M6i& NwJ0Jd ulss;%K:gϞdvYI޾Ǜn(K$cq @ Ǐ79))I&S#ɔ^ܟSrG~M\ؘ S H$/g#Fb uC"++ȑ#Ǐ0̬,rOH쨺.?Z՛8Џ?ޒH$m+s炂BCC+**~%##cVVVYYY_۷9r穩g0S K0lǶM Ɍӧ+O;v0aO7ox]KTh!!3%%M x}x/%xXs@òe螚c\H̙w_ CBplX cJ< Ed$>a2<֯?I֭D ֮۷tV<頇wn;a|  A__Ѳ۶SZJ۝d2CS$w}Iw3I0o4xO.vyEMԪJ`댹LU+fTY_9gf~9@7F^`}nZIJ8>T>MvemWN奒sU?<;xNoJEx8>LS'$Iv4ܣVW ` X01[>;2⠫&M–-ؾS1FFd28:7QT|].JR.GQXؒHEEзo˭%m=qqΆF~psb\v 'L[DSGa!: 6܂F0n,,dbt?od;bx3gB_ͭ%gu5âE06??$'C.+4nU1f rrN4Oƕ[4J&MdJ|e֑L5ؒ%)bɢr1>髃'ǟ|88'_yާF޳+l}yirRyv%[ T<:꫷ӍL]re?!ᅬ#8,==q8QZ&褃 {'OJСѫlrk>zzOFÇc -`c@ZVVY\?UQWײt9?ǏmLX`=m;kXoz35XƖ!Q<} &/-`hbcf֒nfBJ~ iYbػҸUEFNFYh445ޞmѡ)ce_7K ѳzu"4Js4]IXJY =)SdAo]9L\2D~}䵷c'X|6$'YdROЊ 4J&Z1l|Hv I}zVJsxNѫWꄱ1h4TW᪮nm33 wQTѣ;g@pQD47c–&%Z>NJZHʥ 6|>@EWO;lQ?h+T!D"ŚCL h-)Ct3^F\`a3s~@N'3m H{sXJI]x[)EUR:hoƋSΝY,&Ν@:\#[szy!6HdZfYmSAe%u r&8F^J[Xal  mD;O=z|v\.5]fJNǾ}d׮w!>/8v ~*"sx05/Noje\:ĭjNfT&U{y4pewaQ-j*!P_ ŷ⥵GK0/lba\(*P{s7锋 }9t߽*7JĭX6"bxϣR|1$џUo|8Ϸ߿gtchhW 2O>_upܹ_|v*زeKnn]PPD"ꈵnpΝӧOZYY_/Ѿ} pcZ~xnYɼyN:uu޺4|Ν)SSS+V899I{9rD ܹs卍\={VX~SI#F8s*~j&Jn޼ٿ3fOnaa! SRRh"cc㔔ˍՋ|>_٭QFrQFɧwG}4tPLVVVW_޽{gL&ؘX}ɓ'?88W^{쩩ѹs>?oo<J"ٳgiii U ݻ2+++<IY[[􈈈RU5]lf !mGi/^,Y,Vllwff&O}7|5cc㶉4mŊW'V򊊊F8\+--d&A__>:zX3è@  [xڵkg͚Egbb"ǻzj;k֬_dÇGt 1|Q:m46r?0""y<^hhh}}=xFcǎUV577\m...ou<߿?|8ޜ昘8::>yDm7|sΝ˖-H$^陓Ch^>8p`۔sᨨOc}[8_5k׮_/^'J#?JӕI o]r%NOmgmmf͚CԵOr;^^^ΝxW'U$44㕖>|s}W?d27lP\\rW\daa< o&eXgϞuuu.\ >s玧'Hϟ?mjjb0 ݻwsrrd2Y]]]|||~trgΜH$999))):xEB>|ncccNY,@nnnGaBL&{5)rL;b<\.cbÛ7o>|pdddJJᏄ[n ϻqoMdy&~mѣG4e˖%K4L*׮] =<IyU*GUE"QIIIaa@ ٳ'4/nܸ?i4Z /tCϟp DT޽{O<9((H7olH$D\\\d2A^^~$ĮJ\\KF)KJJJKKGEtyCCիW[XXEFF궺@H*U^=&&& @ԉ aaaǏ8qn MM N744$ҥK   \ݻwI,#h^>?>8h ++lrU 99vƌ ,X0p/B{&UtzyH$j姶ベ 5 F$_QOv4'UꏪrIRD$KR===ti8//DZ__/8Nii)#oʬ1c$$$谤 fffmȜ8ӧ 7lذDL6c WWW$JgllLv󚣯fcbb5_~ Hh4ͰnnssstttttM&Mti#L&Jw`t݂ăqY___QQ?:tGv jMMMb1qu@?nܸ椤$rHVd &ܿwcƌyYGHR)ɔs…_|&7UUUV jG m$s<:i&֧v~:]:כV".} .^80@ HOOeX&&&SNwiX?>7559::SRSS.]jbb\6(jΥC0*yEDD\222R*&''IIIZ...7o&J:.9$$977WkM6UWW'OSzzzbx͚5BCC?zK"(،=Z$:uCLǏ|ٳgϝ;ZQQmB2A`ll,e(((~ݻB͛7'Q忥eG Aff&+]윞Y233>}{}:%IoΝf@pʕ$_h~СCV~ ,,lѢE{eDU_+Ei}`ꇤ'CbK vu `Ǐ/x uGê`2QQQ}0ysvZ0v2tvDG^))) u; ooD LG;B= S100 t:Rرc:MAA) vva ZJiˠa .)(((((x|9!}v[lx 899`Μ9>U*Hʶ ݎ3f| qVٷo_xx8əJg8p`[(h˛w7 AAAɷn̟?NwP|~rr2qV!CDϟ??q!CzzzJ9r{gkkwQm_Ch~l26M(u7^}go"J"p j;իWtgMxl6믿naAّ+@ ~z||ұI̻*J2Ӊsv!2ݝ%뭊!Cӧ#'xџ;wn/_w588~-&&p֬Y6luӹ qvv^z&Gz𔔔=z(8ʭ8 ~gnn>n8a^5]%k-"qccc@@/^wߑs;~z;wL.d IDATbnܹs\.wϞ=+V 9 >;;;;;[(J]JeUɪCl;:OF[+$Ç7mt- QF[Dv`|B0,,L"h+7Vv[7|||n޼Iծ444x{{wX)2L"՝9s֖:!ז{'ԍ***_N+gر‚dpKvVzժU}-)))**Jڵkŋ%K?ի~7sDq@Uh+/obbG :T&}W͜tPIٻwo`` `jjz 6|hѢK:uJC26yPBZDF[:@]\\܎@"G}E΅F7N*GvJN\+Lf333?R$ի׶m֭[W[[KCvV}K, dW_=o<{{{www0LmdPZ?O6f\?&J׍vyf,**Ζ+xzzt2gbbQ {+ndddHHɓf&iG-w&޶N8QB"}OOO4S%˼+EQ\\Qeuرc...l6744ܺuk]]5yT*orL&Mq\K(eҤI:vԩS#""?P-'4MQ0$IO9}ȑ#;]SQnԩS$??_.Ӥ ~WPPpu֭\r߾}>}&GyyXܣGr?UVq˫ӏ\駟 ϻz;nzҥEe!oG-wQŋ7n(!ݎ?>ñ600?tZw(ʶ˃+ʶw=}Dggg rʛLLLD"Q(5'fbb*-ݾ}_~DuĵQ&7i#|b4_EuBX$k3+%%%-\TÛrss~A>(֏ϟ744\zEZZZdd$-uCG1>.]:`Pr޽3~_~d2׮] -EҾ}3ڵk2vіn= 啔WId+++ƌcccһZw(ʶ˃+ʶwv'B";ofk׮zݍFBS`P=Bb@ۢ?JCl!C{$gUו?2WW[p8|:9%KZXX:r?:9k֬PWo8mnniӦI&ێT3ƍknnNJJ.C knnnnnqi4B>h} .^(#ݎdEF1  qFZwr~*ʶw??YfiRHdh t* 접BDґep9z[[[>aU(O'?uԤI<<< FϞ=ǏuNƍ377gXSNMIIQ;&*ʭkKyyӧOgΜrվ'w^^^޹EU=0 0pQGPH XLDKDm -O^qFGG”"TuS/D)))/((0y 2dɒ%qqq)))W!:;v>8.P(gPΝ;,`sA*n۶NIIQEEE]Q쒟`L:uҥ,1---##>fؿ>׼аpgÇ{yy?})Mc*]֭[5_A233D*.Yz "L&_8Me@7<՗>444444픦Miנa^ihhhhhzSڵƍ7oaÆD4̻NɳS`P6iW_1:;; fTEݣΝ;gυBaDD+$o%󄅅1̧rh&$DDD{mٲy7H~ .9r$={iWy큢x牭ƽo4L gcIZ5ؒe!5k@hPII`2ѿ?,p{} ό TjxȑC yJҤ;IVV֦M-ZD̯s1X/|۷oS秖 z$%%EEEYXX,_W3jԨC?޼3ŋ_~SL!MKK8qS a*yyyƟ,Ο?ȑ#K,t鈈kzxx̚5Ip76(@:,nBqoGx8nS苫aSٳG3QP([t)W l߾=22N}2)ϮS{d}C Bʕ+D's=qę3g:::d|R[_9 D߯JӧO?{޵0`SFFR?|PPЉ'oCBB'ϮOz3 `…\.7??d:W^o~ԧ-NW,ʕ+]]]kjj 1kw~${WVVVWWk /}Qӆ =umpBh%vj b_FN/[8(l@eH1aE:t=(ף 0 I|)n9ȔȯD!5G)xJ &ׯcn<LJ˗j1s&!!?pB.\ J%YO??C523[@yS\fN岲*ͰW\1;;]uwSȆS}DmjɰfW̵T*MLL _zP(",]ΪU/^o߾cvd25WE\~ݠ~5:e 3''G$Y/ ;LHHDD`y **bʼn7:Ǚ3g<_~L~"2rCG/ @\ABċPՊF?v4%[!JGBCp U"1G8`0(<R$&"<WC(Dj"b!&l<=HQLߝ[ZZ4)䗳0 cDA!myvjnm 7Iv[|ÇO,;N/q/]y.899 >V*%%%C X:usPPf2@%$(ٵeɯL%@̻N|#GJeyyNя3bcc I @D#8WȔ8v`igJ0a`!ΖYQjUKpo'6M@ a?OdDE=L???>>8zR)Z[G^.^DI j%<9!ճr9jjPY MMOg}) ##cڵL x~4^=5|pa=bwdW4#*! qR)8)-Ѹooj5ll`HSG_=UT*XPx4\VVVSSCz)䗛 Ǎ7hРQzזy׉<;|lrfyĉs纺?.ÉٹsgNNZ={UT@Cw+++&c(//%>ϛ7'2| yvmYx}S],L$جg/++ag.r*f? 1SV97E6||J bg|K0[97LokZ 9`N@O?ӆ]XuJddd}˙xuJy7jnmp3 BCC0%yWJeCC1Jj[[`%%%&L`ggLyTVV*na dcc8}to\__ߝiB][D|y) p_R ݻצkǐ"Q*)YN#`̈́ߓ5 v%f@^ggJ0n(8cGE [+8i|[Yif[+ &CZHR ur_͍# tJy7 n}#GS%b3\.///' ֭ͩ Z(HHHx_z%RYq"mR֭[^^^kxx8˭"e˼]$:er-,, ŪU@̻>Y/R$ܿ8E?Nt*>{JXCj:YKvyy],l6B'iaF]^^~qv緷Nў%$$;;|[)MǾ/li2z4ѷɾGAAAAynJXw4,,K&/ƿm|?~2~iizqvJ S7a^ihhhhhz z54>i&O//og2e˖QMVիWؽ{73 [nu떶6Ν+**Zre7 JKK Q GJM bbbKJJl6vttvhCOA_~e. 7o|{i [paw6`)))ԡ=/\P\\䢧MV[[T*e2>3au''33UoLە67a#G2).JO>}!eeemڴiѢEF3gu9dVVV'OV* GݳgOjjjϟ\]]B⋚fH3gpǦہ@[.$ FLLLqqŋׯ_?~)S_zֹsn۶>cJ$˗XޮSW0Dc ۷#<7n)wᬬ.6SSS߿b BL(+V0Ȩ(P8|eeeUUU,App+W~KNN{?h0mmmuu|`aaAF?.oǏsCioGGG\/޼yS ;vܾ}{ʔ)6l:}<@wyyy|{n?KJ |u޼yGvvv6phjjbٶ=Ϯ:::q888XXXXXX|]J"*d'JP9$ W#}.@n\]]g̘d2322՟π(Wg{?/_,J' Ð<ɳ1^9~xeROyYCKp`.?οiXTu`NK!|qCz:@8$MGtl TUHtL鲲֦Vy<^mm-Gjjj*,,7nܠANR/&쯿:uaÆ 6̘b,,,y>&&&22`TUUL4EjzDԽ BRYXXӼVKJJݻW\\wǏ^0 x)//%>ϛ7ݻ)/o۷o7/s=leeE3`Z,˿wm)ծ"%4H**JtbGijj"p8p r\ѣG`0t^wQ!.@nϟ2dڵk+**233e2Sϗafokk[rʕ+oqqq:KȺ+ud1hkقR ]&60سPCR.e!$ V>L$جk HhAAK| NY&ѧW222H-?TzYfY[[M6MAZfffppp@@1ivXg[YYÜ3\\\lԩS d`gׯ߼yf͚/3fXL&ڵk/&1UWW;vP^ttZZZ:;;d׭[wI #007WElݷnjQ #ݪPϐ!ClllOF9dvvvv~ݣ=J /899%''<%K?裦* 2'Nׯ]vȐ!k֬Q*flif󺣾z vw Bnq@F3ηx:77wРAb< V*qqqW&)WsuvvFGG{xx,X@&: gT))){|jkk?ƫW۷_;'O mƌC|P(VX!˿{L%䭷7n̙3;)۷o塡uuu|@@9w\[[ە+WΘ1C_;PjooO!̆N!qqq)))o]nܸԜ;w.77iӦMsS"H&<:::Ν;gp\.ۯJaa!®L޻vZHHHYY٨QN<~zS ijjg3|Ͱgv,--/]D$Z3p߾}rݻ---7oΝ{ĉv.@v9n96vHAK(jzk5X?ɳ@w,JB#QX7_b4+YH Ay3s@9!G|" ڵ8xc`d<2d2BA+`\Ay_(ɹw^DD3_%cgoWthJyy7o<… } $&ٿ%KTfΜpT"hTddImVPP#;99%%%,--=z+W < 3gk|>)11 d2/aF;3ٳgO4744OgΜ1OHf?s߾;v SΝ;bMQ &###b1۳gZM|Ѡl޼933~{z6qa'^0*st0-П%ihՈ9w4F8`KGݰޏW_=S$BM I0o &a#G2pVӑJOtҥK|_VVVEGGw_؅cFEE%%%^zD"rx־K閖^^^O ::w͚5=5k2jԨCCgO>}ԩ;v())qttTJKK۹sǵUv^`K/PRR2p#F}^$//ThxfٳS3}Æ C V*cwE౱~ؾFm-!ݍwᬬSv###===U*˗SRRRP([t)W l߾=22΢"Y355p֭ׯj7|~xi+GGO>dᥥ۷o'BO8q̙2,????::E(2]vQJ g2ׯ_߽{7bgg|ѣG꺺>̼IE zki*ٳÆ ={ӘO~YcT3@],)oܹ8y?pMHݖ7^z066vbgS Xp!Ͽ~v++>H&%&&*sBnK[+LR{]reD7kΜ91҇ TXX2hР+Vaw<%6}Qӆo?=umpBh%vj b_FN/[8(l@eH1aEC {6=1'J`,Bt8_÷`f#2%+_H)cJXY@H8|rPTҟ~~Bkkdf"$nO~Z GGؾ#2Pp2RR b@l \.y>LQ|}6|ԩ. !+V0Ȩ(PHxʪH!+Wt3۝;w(XaÆW۱cDze˘LӉDTzjP%zpppZZH$E"˪U"""$Idd$>c 6lٲ{o׮]ŋAAAAAA/^4c(**"dz{{_vͤ_i=ׯߗ_~fRX!Dg;{xx^~;Ç[YY7S }CԶCWW-[\R"l*999"ɓ:eׯ J=hvvvs qqqK,Q*Dʪ"RI%((hSeaI*DU(jDhACP@ t$MQ:D`R%s~CF՟ %Hȥ r XAu5,[OO8:([`JH$  0DTBON!!!mm Q0`mmsQTښ@|`#Dŋb'(p8O`'33xbIIZnmmt钛Oo߾ @*dx<~Ϗ7ׯ!PZZjޒ ;;;((MCλGgg')rՃejI~p8^z 6D"e˖if.++ֺ2x>{ASs )q|Δ` BCE^PR\*,@M*|ϢQZDJCrԠR)P!A >>8zR)Z[Gם]4k޽2 RmF, .x<9 F'P` W*CCCjMYYOqppMJJRr1xʕ={MMMUUUSfÞ-b;[#ĉWO3cSD}8?߼ysӦM ֤~_CrJ"qwwrttlhhg7\9EbbbEEĉ'Mt1vnnnf2~w.v L^fB̑kMH7K R\>~4^=5@pä&wdW4􌔨ĵF8V[z(P~&IoijGhVxVjd 1Aؙ gF~57E6||n. Ю!ڠVCm-xhp٫ƗwǏ'>KҫWΚ5nڴi/_&sffff0VVVMGt…cƌٺuL&#qr92eʔ++O8"3Q`YYZ%&Qqʃ }];;;|>S3f !.Hju`hhhƮXB;ݤ-,,^y啉'=zԘM5}jKMp'OeWƘ{̙@___acc nnn$2z]POB\b$ CCqjcc`zRyM֯_?5~Zͻ+W_]]|>Ś1cF^^0;s̸qも_!gϦwA@c';!USrSU+F '`W9`YbU=~{"H3y[+K0-P 5`k`dék9MoUXaAkKKXY5:Jq*f͂50m4;NZ'>G'k׮e˖ٳGR?!sFDDbc彽I=T[YY͜9=066\ 9sZ3+Mu։bBQ\\L %(ʽ{FDD\_}ą dzlXI<>˖-r "//^@\~IgR~D")--~[I_&!Joݺ EIIIn"xyyQf;v7oުUR'7|>55z]賟_SRs7n܈urrrIII bժUd qqqyBCC ;ܺuݻoݻҢ׮][~saa=ܹsbv)Y=r=6!!(jƂZ ֏3 حݻӈf XL -8]m Sl)f Rb](T(nƾ[{ E|$S"!qPXjG]{Ó'w}ok-Þ=P8}۶A&ٳ;rhl6СCk|35a3`X{O|9֯_?o޼ޮEϳ~C&i:ƒ4jY;#*NNHJš5 TƎEt4DZ$0K@3*bh6/N#G2dS JOtS .p}ȨQjL~oۜ9s+}sǏ܅Xr.]joolܸqܹω}QCC_ 駟zyyEEEyDcPNO9) 2׾ IDATڗ^z)==ˠɱ;̢t3iVHE8=UzO};,-Z|52pVVԩS<قʛ+^蒮OJT*\ B\>Yu+WTUU);vҊ'N!n*:eu̒m7ӧ={ڟAɳ/fozĉYf:t"LNbP\ܸq#rDd~$' >CSD=O8q̙2,????pBammr֭O?Ԍ곷իW;88RʼY[[BG+h9sL<-5]6V8Ե =ؕ5}98^ s<lCkp⠰I" ;Ŀx l{bNh`YkpD7 GdJW"忐RƲ&G)x, aL&_0oGd$<=Re<4rC&cG;;,_ѣV}zgçN rʛ1a{͓)UQQxD'N-}tl6L&ڵkF>}}޽?vV=\~]K}hiiYUUEYYYٿͰݤZ$%$$D"HtXCoǎ˖-c2a':Y*&&&^Z(B ,+&&hq[BBH$ݷoH$ .^Z?._|7odP슴? @T c0qYX /@U+(j<;n( 9hCQ9T\B4a3>b r͚DpqUDǫ+` 2QQ x{3fc2v£qN---yyy&M"SL7|NNN߿?02C\[V绻9rDT)'$'''''I}߻ El>2>S?.괇2@`mm35~͆#mmmg' ŋ%%%juss#ryMMMeeT*mjj߿ǥ73 rwwy& ?Í7̮*n:4\: S)ׇ懨x%ZTaaʰ;FO>Ar2A>g???1^Y[GB*Ek+ ;1 a (-ţq:ddd]1r&ɛMwjvؑecc3cƌ?P.!-N(mb:i&///޽{xb!IȥO7(n6DMvc# JRi^^gϞM~:Ih遰B1͛6#J%aى= uvvV666rH8ۉ@,755UUUiL? cժUGFFu0 LԘ9spa:S.yY8VhRpE ݦ ra/^4vAml(3.r# Hǃpyz}tpG Hp`xT,F74(8:كvi6*onyĉs纺׆X#xhhkbb6M|:18:E-n<rƠR)`ccCդFT4hA=xoSjA `}c*ĢpΝ999jz>>z#2 SKao rF+ud1h>ZD*59O8=سPCR.e!$ V>L|xuJ t9+b39PU΍:|c:X, X[@ڠVCm-x&Of# 5nLO?/; 222H3+#Q*)YN鏫j>X33F 3%7AB1#"4v&XYif[+ &CZfER uֆNxh=NJq*f͂50mIh(VV`0`a++ҸM7ׇTT*>w Mr<>>!ɗ_~)RRR߿_PP`E}rȑ#ԅSȪ/jvSeu"JoݺesT* g#%`^gϞ_JҥK[zӧOo۶M&={;JYrj;1u։bBQ\\L܊q)ƇC]9aͥpNN#}d8OqHAc` VAavk*ӈf XLu.I)qDW.b *7EcRW"Vt z)I(CAQxD,\x J݋\ӧ3}.,[={R!/]$0r9 {v'O6ް@eB@^qVXzz ҥK/^Sf:ttE/k3ɉ|}9 3cD[H/T`;w$#xTmuMΝaÆgP_~/ZhΝڽ{[oiaaa .NݒL5sLB:666$$Ĥ֮]kF5dͤ _3Ę 7n> w1Aa.EAv + Ĝ:7sGc   tJ˕H/ a"0-qf &ׯcnPzGd$<=Re@*E_^ :u* j&.++"þ_r EsS޿ߘK/jժxXDbc:eM=D]B4S0c 6lٲ{o׮]ƭ999 .|w9RWgTZ6~zXs0vZfiivׯGa[[[5l0NNNjoǎ˖-c2=*;UV/^x߾}cǎ%٭>;4~t /dRA]Ŋ&{zz:::RS_~}W8/իW~aEEYg"9s vEe *Į^,,IRE~5hJ=:?"'|x 2 Q!=ԅ.\:4H@"V!" zY "#N---yyy&M"S(d0 ieuT;&r!!H AyZMՊb[ڧkiWZKUWj-WV[AE `Tl D%F$@h_|%9=ߙ3̙=ztGGP(kq%%%?Q!JT2l6{{{eVTT8do߾QFyzzڹ՗X,^zuKKˎ;ҡT*M:HeeR$Y@nܸ u7\XXXWWKKKI(v]P(aaa?1 +//'GNtq2O:jF#G=jZd6 c2= 0{bcc.]x[[.eH&OLz^I `FSm#GnAxxp abhg[ipp6\z@830h1ۺ)JwF0ap& oʂ,XLþ}׃q:,˜1p0 G£껓bŊ;v_dϞ=(D" rP@dd`C|g_ )?N9X.Nn!9"/^w;wֵmiisNMMC 9J#!]T*5L.l_:t5k _IRrf!pXXح[Z[[ǎAh咙adBv;D4644_)keCvCdMMM<O,3lAjXX,\.N@ ezsw(ۗGۋdʔ)JOXp9 `Z^\tqȦ݇c fPzù_ӯ*®΃5p8ko+Hi0dݼ4#[HMU`ab8x^peeeMM 9 PVVtyG z}pppEE 81c@VUU,Xw%2q89y'+ Ȕ:::rʕ+Lr!:Z.\~O?MNN^t|I鶶6B ]0*j͚5;b*krܧ~zϞ=z~ԩ< : JwFBՑ@[8ta] fGpϗ~|OIcccUx,pHZ=܃`@ `*fXy`-t=7L2Qq;G+󡩃Zt: fhȧvʕ@Wdl^822ّIiOlsxTk"\FV{gȏ3˞v;ߏh4CPhSO=Ehvtt8e옐 ("_)==}q1OdO=Ԇ F#ѩ[:`:0&$$޽{n6l <%uuu'NTJ>ަ[8d >>mB t0p#8^$we^Iavo&FkZ`@ȃqa1 BGw5CqrA <Zxa=@*C8J50w4Hr\,==LRհnmm0y< .0aa[8dzjZZV|( %tSJʶn:k֬%K ǏOv"x盛o߶WM|wQ1 vxY @ڳ/2-찪zhXYV[/¬0`phXXϖK0)< <=@ge ̞ 7'tPXd\aIIh޽LǷoaNXB KJ<==8l/_M6P ++k"gaHw@ KZFQUUuw]KIIIkkkpW1mڴRWF ~߄L8Otͺb#x<Ú[dVFPꫯv9O?]~- a],(7਽A<&phR@ wa@ pF m;rۯ_vZPTV. #G|W,?ARR3؆sٲeZ/'wN9vIls~'3f̈ۺukyyCnnmێ;|MPs;jZ&ug87q?{]ϟ]͟2X,ܾOϜ9ų2!!!ׯONN&vU*7nLIIa{}۷GĉL8f7Lȶ455]|9!!8H'^[[l}SB ELtrtsg-| 1cɓ'QQ߻w͛7Z,Z[[KJJM̀tr⍮GDD̞=["\v.N-ZZSShfͱ:?yAcXn]CC@ d4@@ H$pii͛7/Xϗ/_nshp?WJz@<`{1\Ip>0Dmw4XB eZȼ橰h意'|˿φO8AʉP\WVVj4r˗/ׯD"w^pp+JǏOXɆ@hhu-ZaS)a'n?.5իsޟ}ٞ={y.Br׮]t!@'Ow.ڑ j5Дqx[l~S:] P~,ׯ7oaIIIAGdɒ7|sc*RB@ X|ͷGG;o&2juFF^WjFsmR#::ܙ՘Py8lF30/0 $= ih`(of 777?s3gDGGÉGd;`=ztGGP()[odee׏1ȆF#??ttg+DR)lx?a„UVjH4|JR(d`:?Sg7vdѾD.2UUU#Z@W/yT͛7 mS9uV5G g**)vC՚LjЯ_?⧢"bNǏSd t*MsLLɓ'ɭDөlFSm#GnAxxp abhg_컓b RnAٳ2, @$9]P@dd`9u3gΈD~A6,,sEF)a'nҙ.gF* ӧOKRӧO̊zxxɓ'cjYȏ$$$SRrrvکRO-}ZWiOzz !93xQQbP(|绬.0`@]]ntr⍮NZ;w^z%RicrJA׷&8;,#A|||qq1~ˉITTzvaIrP(>}zII 9E^9^^^= ]QQQ9wA󳉱5-0T ǯ41 BG5CJlOt:ŋt:NQF+B:p@rrm0 ϷGƜX,`26oLܒ;*?Ni2S{w^ii1aخ]-ZOOyyիiiieeed8ό N00vq4r{WVVg8eO ܿ???>2'Oo[n555 2V.??AjdL6^V5,L ;ߪ RX#xzz8pY~+v@xx3/ 3#{(6W"zhuM<ٵyiiifr,Wԇ1bDUUٳgm'u+;uܸq=iJKKY;`РArb\ni$v! STNv!au4A_ϡˋ}ߊ6l~eŅdo__ߤ$@`496…v!XnR/_$ߣ7|yۢk4)@ [@@ #6P7@ @0@ n/vr<''gWJ_N!!!k׮ݷo߮]_{2̙3x㍷z˞r_}UV_ܶm#L&?~m4ӻI&99V?%&&Ξ=VX Of?ۑ1cdfffee9Xx>FF ƎLرrr@*븫pQ}aÆ;K(g.]JOO5M Qɯ,+&&f̙Vӟn޼iߧDۍ<trC [K8n۶رc?b;e˖i/XBZ;k>>)))#GX,.]"CBB֯_LT*7nܘk "//ðӧOGGG3t'-[TWWGVvL7 _lhܲeb{+W:ugywr/\p1xW ard֬YVQQAϐRn=̘1ɓ651͔1%%%ӦM#-Y@ @ 6]J3N>66v̙~~~F짫ҥK 뛘/WTTr q=>O'kO7R^ |U31_mf G)0,T {~ãk"kE"3Ə];R))0r$X,pןX9h/NJ8q"""Ȃ 8NJJŋCBB+++5 {LL˗Yl@\Ѱ@ tt~|=sdK#2s>`pRra1u2ةXr r )cd/j5%ڟ ))x1[9t)xn7\\\sϑGB1c>l0ZZZ=At̙hp8,u6..?::Yѝ ݸqC) fL7X,^zuKKˎ;۷oԨQG'R;ȭ3 Q*&u֭^ٳ*++J%s] wܹr[ZZ .`?e}rW󡓵Ffd ER5m&X 1pg0`b!@;e0^ & ݄qAYYYw`xطz0!7XS(1ca0ןs砼pZZ%Ii[b)W"H8)l8{lbbL& DPq|ƍ۽{V=yd\\Czb4vֱ֭czxxZ t&2dU*!ʎ;wri ìgˠ]T*%g}YCkkkCCCN8AJB*Č:A~ԨQ 8D"Bǽ짬/6{>t~۬W(;'f5p8koD+H0ټ4#=HMbb18x'ӁϨQ mnm8d2dd@744EEE?:tYܹÐÇ\.駟޳g^:u?K~&0\-{\JхBZԽ;DDA.ɟ5trbx۶mp/1cwáˍJZE82ԶD:$Ė&%t@|O+ҟdl^82C&FG.m\8/`Ii777//:Jr劓vJ+Wt:/%/4rs\ 蠓gW/7ի^Y#`3`(l/ca `@h#-`Syxzf=6oOO頰HbIIh޽LǷoaNX㤩 aJl_~xp\H0z8 K N=-+]d'8Vs`ĈUUUgϞ8%%%]&]ŴiJKK\[栔G#F@U<~=҈Rrv(6lp Y;Gz&p hR@ wa@ pF mnX.^R)"BBB֮]o߾]v%&&ڳI_KWդ3dffر#''GJ%[jj'&MbDZ'&&&Ξ= ENNN#y󈈈o)ݡZaРA+Vpytqo]dDs|7vJIIv){5S6==}ڴiq=M}aiذaYp/_~ҥt__?AowZd;id+O>Y0gʕ+_ݶm۱c5 222%%壏>ˈ!CPtqoZ"88855ð,Z6z$r|ҤI=]P׷vgΜrfRz_>99WTnܸ1%%: E^^9ӧa:t:q:p9q@'?.J?1nڴSg'o̘1ɓU#q'tJСC322_[[[KJJMo>'$ 6_pرc+ 2dÆ x9ԾD"ӧO0OGll̙3FcII,X,&ݻ/_ # yfBQVVy  >>>˙oǍꫯ߿?AOMM}a@@@HH۾}+W J?e6^pBMM N뒟ߚ5k***tΐ?'./]r{' N8ERֻRѐۦ\| ZEǦ0|:8l8q@'?9ݾ}hd·N޵.5UJL:kX"L0aɒ%7og/tG͡}ZjZV}Ct -[̙3gҥ!!!N 0 J/L9zϛ7ð$h4o&˙Ç/]o3go}}_cbbrssjŋ| Jr۝7 vuiܸq[n?>#3;e>td8__:q=psssqq:3gDGGYfVtOg g8< fۛ؏.AT l69qDkaJR$6{AMqVegJΝ;W^^xKKKQQ Wˤ@ZZݻwYRRm3SZhsGiiiaNOb1U̝;7,,h4*s1gÇ]ylKr|ĉOI_7C(뉓e2CI+++kjjfYϳVzJOO'>Ϛ5Ν; {G~;::rʕ+Lr!'2ߍ±dԩݟ1D" g2jUUU999 ,xw gbFz^,XCҁxFw4b˷mv_|1V2tqeV1bīJh&ReL`(**4iRGGŋm6eccҝ6_~.CCWҟ,q@'J4A^^,]XXnF 88XPD3f=z!1|IpVp8zRqzyƦCj Ü'(+ PWWd7Lhj5quh4CPH*D7ˋTfΟq//exɓ'rALL\.궂肂eDFFs8Hΐ`v111]<턹^P=2CG . JY/&%%t:{:gDho~ut.'4Le?~W_}E~]vǕJ%l...^{aخ]-Z4" (IDATw^g7w~Wa0nܸR:ujqq1印Jr38m۶M69r$777555##^^^D2G㍡}~~~G}d4O<70OISSSvvʕ+u:l_d)MnܸqΝޱcիiiieees.uVSS@ (++YVYY֭[g͚dpq?з;АNs(8Z_8UЂ8xJ&gҰMAVV{(D6k,w[۷/00݆>رc7n~^} vt/… {"gDov"uÈ9"ȞY |~O5֭[w^@pO`h,3A a?ܾ@ cP7@ @0@ nb19ZnꞷݻkZF3H\7t_M| J[--^?&Wb7ؼ9t)ml4?܍^3!\=!quK<0nGUR`w$`xɲeA_3Sw^|W"6W_}y²h? x ~^K\ڃ@vhl61}MwĈ?I$Cz,n{e*yFqc ѧڗ!ԩf|nmo4߼KqΞm9t^(~QȜ9f/$WPF|ݺ3+׵ f>>%8"BX ˋ|r4ϵXsZ^SKju&wuwtXOC<9ʢI|}7햽{U w\˕Ooui~^m^֣3ݻgЄw \.@IS۷Oj\.Ξm~ __ڵ<#r9f3~x?}2lZZ΍s;v $<{e&X:Li@}"8ł?s~{'-mL0t.tTwtt4N_o~minO4sZT*ȑn+taCHL7U?߲lY]>t:jћ|6<{b`+WZO7n&k |Yl2MCx^0ttX7nM|!MJu@Lp̹}NO{mOM8>>&k?߾ tϟNz9DeaʔﴶbMMk>5,9:ZFܹf2~40P؞rMXM&PQ(Ÿn'XXVk޵o)|>s옎ۼƄEzŋ~h{ hQZӕ+Mph#|pí[N5M" -%;)BͿ܌UVR;w:  9J *ʛÁ={ajrdW_i|R YYƓ'U*10؜jlo_':E._BLgl?ǖ-f|K 庑FvzB*ta {99ɞ\x޴IC'˵a Wׯ/.W_ X>"^_~9r0'Do?$ãsWvYSc҂ٌs8unVcG ~[n}줬QK`ȟ~0`np||4@mi s}(2O @{L}b!!% n\qa,^dY}魷2B!xxpnjH`ή;}ݡ^{օKzzuҔaxNN=jݻϜ{vu5t;[v`Tn6}ۺ\YaCHJJ@JJf:Znl TU(\w܌1Oپ$.L&<7wŔq{wJs8)v.B Pp]GA z1cHAn1cMa w p C- ɦM~*q}@ G=F  C@ 16@ DPAa@ pF mn@ a@ pF mn@ a@ pF mn@ a@ pF mn@ yB56 ~̌>wIENDB`sup-mainline/www/ss2.png000066400000000000000000001122531166154264000155310ustar00rootroot00000000000000PNG  IHDR) IDATxyXW?o (E=h̘]x5&AдF%&q4F3(c\qL28U e-PAEv覛Q ]uh(Ƀzϩ:]*zB!rppuA!]NR ` @P\ :o%%K0p^>x3DUlΝ;*z1!;c.̟ggsVj$*<ܼɓT" !!:jRahd5|8P Æ!#vc6 A+KEV:N_~IPq㐒bCBг'2oFO?ծ瞓~ǍAlڶEh(08qL&80[7d.m!d\DTVb%dgCG׮BqGGG 'uCvV[~AnԩP*ѥK}{[dgCR)!D&\^.1=^ P{QOտ^Gv0PQ77.\}( ҍ:.!?^^R_V6uAD-q6 |7R!;Çׯ/ !n/իUOFF_Dm~s\ޫ=5wvcx녔 NNBq PQcDFZ*[>W;w{㖫o(!6h4|0޲#BCXq1k/j޳ZQ5k0o B}//ok Ÿm_W7j4P(PTfP* NxVc0#1xp}p _g |q戍tX7'Chø3\ޫRm\]Q] q ό8 3^{ |S+T>w]TTi<ڀBU^F@@E_G%%z=Dhh^ \_;zC@yymFƩSHMɄJֿ_{ sm˫_V8|/_g>PFɓ8qGTeq_9byEs츅!) ))6]޳/bbT@a8|xEADvgvyE3 st3н;F#cu޹oo ztL 7$'Cl|E_?ZѻwLJ%fش ?YЮ^{ |ww8:w=o/Ć P?4ZBL .E8ΝJ/ܹزpp@h(^}Ub&Nڵ'Q^n==z];ۦ*,ψܐq㄄} Rfu5Pvވ3};ZԠO&DF>t~2;qllق28:"4/Q~t4_`Kп?FBxAqjw@@.5>m~ܶ NN?NY1w._n5Bxqt&.]~46m)ܚ32jo.(@jM9i&ZEi"ͲeU[ؾ };zT?^W [mBtQBG8v!BZLP0}7L!ȆaB!D64 B!2 {{{W]R 8gb>}\4B!k,={֙{>!By2dȌ34Mjjŋ[8!42}tsZ7B!> hK9B!X>[.''@߾}Ν+˗mfu9!ؐ> (31dŊB! 0!";h Ctt} B=lH5J!{I!4L!ȆaB!D64 B!iB MÄBlh&BdC0!" !4L!ȆaB!D64 B!iB MÄBlh&BdC0!" !4L!ȆaB!D64 B!iB MÄBlh&BdC0!"ǖoL녧 gWߴLs /8=xN88:3܏R!< ZlXg "z2 @*\{gsSfsUM!Ҥ9!WZ_FnGa/yBx nRȯ0=kOs۩PS3 tFtrûaP9@qS& .\lIE:j E7w*A^9&ciHgRbnQ-CG \QT5(bM9!&~9>JF^&B +O~m@U}(?7 r?;^ B| ʪ=9v|4>2 r`!]YJZ)w=o+ƼʈqV3uo|gok BBixӸU6VG-wľkQáZ6+]ѽ= c 2 5֭0p5~n0Ԡ nE(B#AE1O՞V:;Ƅ O@B .X'7w5*(WkՆ*֯D[5k}\pu}Wa]9pwͶ;1AjOUCs5o!&e~+P]SpIKz <9.dB;gsKFڿ]Zݥn}48k4 uk|ڿ?^ZJb 위{e8w~?Q6u'ě"j NDM uG37x&$gn,(!)  4 Ű.0UdSS&l;uF 4NpP q FDa"B#gPMhX>RFB!R YB!iBM =S9n!t6L!ȆaB!D64 B!iBix_Y5kcNJ*- `K˖-zuo2;v0ILLܲeW_}fKK4=0Ƴ,큘@VOLLl5bŊ>}te ˵4w˖-}O_}j779sd2ݿ>2sA9::^xq˖-eeeBCCCgΜi0juRRرc/_vZFV999-X@'$$!C̘1CѤ^xѼZ\\\nnnǎرVۭ֭[F+n׮]ddd߾}kjjΜ9@LLL``GxxԩS333KB+!ݤ;::n޼BUfϞ]QQo͚5BBGq"Ďaiii~~~n8 _h9o[nܹ</ZݻzaZ^zÇfҥ_ɶu1!SRR9`ڴiW3o޼nݺݻw/''`0lܸQpFy mqX(n|x@@Bpss[zuxx-}ٱc5/_\V+nݺk׎[;{I.X<44t˖-NNN˗/_pZ޽{|!EZgg?**Jx(e?PR)˗DFF*ʀ={XxEj.ZW .]Eixcǎ={P՞V=k׮}縿.\.rϖGF5>Ӡc7|7B˅wŊ&զT7nܪUs׮]}k׮/r|#nb#~ܹ;CCC?c;o nSs OhР={UWWBWWW߻w/;;[o^|6WW˗nٲŖѧN&ի\|wwƌTv j:88x߾}:СCOMg8 6RSS^ @H|?1B1bDRR-Gv?^C~ ŕ+WΟ?ۥK6?cP;vl?~իWg͚u 6,X^[d G~s'|p¨ 6Tcy<_{5ont|=޶6bw㰼ǽfehsF+d2eJL&KVV8:|//smݺ@ll,7+( N*@MM .s888nR``S41_۩Ӷm[BwwwF FPfz***Jh+璒B7z'Nwwwqqq_[(~|;_|`0ܻw/77wcN3 orr믿޽{w^wInCϞ=/_lKSmߏxӧr$x(s7<>o[;qX^jY.ZhӦM)))&iҤIWM&Sk UUUw޽{%K^xoK,iayqm-K._~111կu-qFÍ0F^d2pwwgO|p__SNqH+l~W9J2$$>9x௿ѿ:.99yԨQUUUO澌U*]]] öm-Zdxl$q֬Y^^^'Nܷo؍5KFs! 5?S/3z}{aq=תL&S6mZRRҦMooo[zg|||TUUL&N f,[>wb{nieddp ^_ӝ?~ĉw$/3g$)++ի[n8ǃ$/CRRRXXؐ!C+Ғ˷}<۫UN߾}]\\̙3}ӧ ;6|#F;v[2gΜ~a޽QQQѳg;Cx?bcXXXppM֯_?yF{)SNjcv=zPա7oN͝;ƕy)J'''BleqؾǽV%Άw޽dɒ"믿6ܷܹs?P?~/dh ٳg{nyBBŒ3֭[\TTtir[WZ7h0o뭷 f4mBy樨[Ԝ={?Fm"""͛w]vF+* PbEEE׮' ;~ 0c~K9C |yyyC {ׯTtA_w999櫎ߏBo?v7\jUyyyyyΝ;cbb-ZdСCkV: ccc nܸѦMn ZΝ;oݺfi-' /ׯ1c> f{#tquiʝ҄"ҥK_~e[a_|ݐfϞ1Q,wJBݻqHf}}'np {zzp#ohYrw[4[Q#""BR^pA҄B,4!"' !4L!ȆaB!D6N7 hۋ\WX1f̘oGԳ٫_eFzHnmmoGUWEݾ}[h /xϼyuv޽qF8^N-p& nܸ1**qܸqBN0s̘Ǐol޼Y3J{V/++jZWDGGGDDTUUEFFJ(Qdl;v|.\XRRauaaaj͝;WPDFFΟ?W^1ܿUVF1""]PT*բE޽=ܵo߾^^^B\r{Y=\ %%E6z4ev o^bbb3gOs玴~r,ܸq4 IDAT#FdddXgϟ?Ν;fJHH߿×, v{Z8> J̛^JKKl;畜=~4hPç7JN?0R2▟_]]}޽lNWXXؾ}{r^M˖->@a $1ov ѣϜ9c2***8D^]cǎqqcwQ300p޽F۩ _0> Կg}xEz9w_ݻz??'O8Ex3-5L+a| ym4H̛ƙ]i%gMh'Y3?{xxt:wCFv Y=ؐl_xSNÇ߬~Gn{.}A,w .Z^;su:]rrQN> Z8mh^ww|4ءؚ>[m0wÌμilO+-!=;ozmF;yחΚaԨQj_LMMfM^aaa*jgϞ>GZZ{K&ի'O>w\ZZڔ)Sln{.=vСCCBB KHH߭MTxFiۭ+潙׳ǿ=vGq1n c vɓ'+J??򮦏gm^Ǔ'=φ靅83J-6=Pzmv/!5}]//;wWfpҥK;t萞ev{$u^zݸqݻoaFvQ+v ozzƍ_~hNFPhf{xvZ{˴zҼ7Szۿׯ_/..VTB\_ ZΝ;oݺf]Ƴe2,+W|n֖.aҥ/ܭhlٍ~@Q%BUGkݻqH. -3$$_~EqFwbO*]zڵk/_>qD[."|J{&W|߿iӦ#G027ElllaaaHH… ׭[E]RaFwLS۱cG//"shFmKS K{ƍwO7&&&00#<<|ԩ\Zr!BiӦ3+Sќ>}.FnQkN}]O#% iyW4+W,,,RTyyy\z+xvQv֭[yyy PyѣGO0Kקr2;_jٳ ~~~k֬,//;;vϞ=ISSSSZZzzjҤI4̈%8a)))G0mڴիW3)_œg^ FwÌ4QiuryUUUG3n[n8::ׯꍏj۷oj9Nqo̙3gΜ+V[N-9&Lܹ%BĦy-_h\1ر{キp’C}Yfm߾}썂8[ /˻t3gs"feev9._ƹxyƍݻw:bQ0Bdosx'D)Ӱ4ԟ:uJsYzbMmT[n5)6f\ƺ;C[TxǏ'Ps!ҼK(7M='99˯O? 4HPۻ{}hȰ:ƹQy'OfddL䀀F;1bB1bĈ$S5Qee[';ni5Ζ8\]Tzsr.}4;Xbt۫=nnnնtƲbQ(׮];|0jnQ ^ػwovvJ{.DTw pWjZ-O.Jqe56> /2eJL&KVV|9qDxx f͌kMȞ['VvV(VlB"iXlwvzKrݶo{***lb h4 #jM|VPs۱|qśc>t:N(iF גXiӦ4iҤ'ZƇ1~ Ӈ{)nא.@7=zܼy;NFs0Hq+On=~'v$墴44JmdggƖ\#6:rȏ>YVwX%^ +4QFyzz_|155d2 3///##hfu\I_^b_dVVdjӦNe|'))),,lȐ!i3t9//r;w-%0888=sG޷or0/PաV Bgv;%4Biy5wmvuG]|9((%6#Glx*m6Oͮw̘1%%% "bI(_h\pw}+==}ΝBxǿ[Æ 3++D[\\{%K _;sx?O(**v횹i⃂xnkVUUeff6r_PPpƍ6mڰ Bgv{jZ[mbaذLSr^ѣG׮]|'ZH$NByӘ3}J+H3.Tϼyuv޽`5%FҀ ŇQPzB+| mPs^け"!y^b4^^b ^ZzÇf͚եK/\b"ɓSRRݻST0K/uԩov_I9޽kK "wÇP(nnnWJ2 `Ϟ=3k׮}縿.\.'..?TT b対>}ر\2**JTve;Fqrrj ,ɼ.^xѢE#W^k \B^6h~\\܆ 8 ˗jBѭ[v1j|MpV}饗 <VX1i$qƭZ l$66vǎ={V===Bd|㓷^QJRb NΝ;ر?44?n4===ӱcG Gh^z}g\jWW׆)lOc0a¶mۚ#ΓCyA3Ҙ*#*4o9{5 ?IHKlnHKnoy!i̅0 +4Ygg!:>-땀_Z ͻŲB;w|' .ڰaCq8eʔXgΜ1L佰d?~|\\ıΛƜ7ݷP9Qiyj箨(**HHKlnHKnoy!i̅VfWi%|B!c< i'#K yXShc{ӧ69rȆ K+c}v2J[}yݛĆ)ӰP:hnx1M--eqrϰ 5͸ebӀ ^܅Wtv\os3ov{ag#m| ^yk{- wzg͚5qD.0gԩ O%o<Сg/lrQZ(4;eoii-ӌ ɓ'+J???s{Wlp+J#^Mc.D(}={\Ya72eԩS%7]6>^}Ӽ˵;Faaa6mZ~ɓ{- oX>XرcC Q(...!!!r}wI9ff1L--eqF96lj;wuVZZc4ŦP9BimFBEGlBi̅g+4육CVWWݻ]v#a| ^yo_wv7\jUyyyyyΝ;cbb-ZT\\ly*,|F|OOO߸q/g/cƌ))).k&yr2,͞=1 mxӰ;;;>,M"W ?R@@wۭgHHְՓaӧ۷O8!KH뗟?c SǛB{(*Jm4YF9}ƍ%](-rڵkٷoߚ3gؒ^~dL-L/oB{}GG͛7?޼NNN , FY;Ζ+Oi;vϞ=-S#!Ҏcǎ5zBJZhݻw ˋ]HWZoƈnV񉎎䖧hZGwmʕͫ7npwܙ5kVBBBmߴFt:]BB̙3cbb 9s̙3gŊzs G mWtt۷g͚}ڲ ̝;WPDFFΟ?W^p]V_VVjZ9"Q=zgΜ?Ν;CCC7nh^_h1 ,,lZv yҥEEE'fo8nP<%wvt&!q"}STnݲ-W\l$??޽{:}B~|^R)-mezpOO{۷o.;;Y믿tDl:zQمҒYVۧJKK:4dCҘ~M;HΛ^h+55E ˗//--ݲeK Bӎw{QVVZn iͤ_vss.A7DMMO\ROŎ҆[rW$eOW(׮];|ytҳ %g,G( sۏiy 7F<>/00ԩSAAA^^^\gfo^xB~gG\SW yI+**Djn>]v^iùsGUcy ;}zxxFix_D](-Xeee&=77{:إmP(tgϞ[ݒfcEST5 '!#pvvh4_(J'''Bd> 0OOOJ5~gF{ ϿyɓJ__UVVF{С#G裏,"0k7"6=PZrt:'NV^|3gΘ_=8.Hn;iݻw6l"..nܹͷsyyy4 ‘~6._ ŋ=f̘ÇڵMl… K.СCzz-[BŦ߰aVݹs[l9E0۶m7oѣGwH=rȆ)))۶mfnDBzvbm޼9**j֭555gϞ/J?(vMIwWZMTƓe5ۜ']S2,48={vǏ47J$k& Jcǎ]BZfXJMM-// Wd 3$$_~i)H&cǦ!&=ի4RzE*)(I&$M݄i !"YS/JB!)h&BdC0!" !4L!ȆaB!D64 B!iB MÄBlh&BdC0!" !4L!ȆaB!D64 B!iB MÄBlh&BdC0!" !4L!ȆaB!D64 B!iB MÄBlh&BdC0!" !4L!ȆaB!D64 B!iB MÄBlh&BdC0!" !4L!ȆaB!D6흘233s߾}ΝkJ#OqF B!.)0'660$$d…֭KKK\ٳg%ByDIkjjJKK?SOM4G=a///^sNNjժٳgWTT[fMdddyy!Cf̘hRSS/^h.cǎZ[nn3MTB!w7n޽;NKHH9sfLLԩSdee <[',,ܹsRRRZߨ۷oϚ5klz# !Vpee'OfddL䀀nyĈ ň#z{{w3Mo$! I(m\]]]]] _~SLСdrqq9qDxx/(m۶ 7BHkciG7ohѢM6LI&s>ԩSQ`II FSTTFB!nnn=ѣ1++d2i&,,IIIaaaC a_1rHnnn HB!5~6vڪL󯕊w޽dɒ"믿?}tDDDQQѵk ⼽5Cbb`[o 6h4lB!Ar7By YB!iB MÄBlh&BdC0!" !4L!ȆaB!D64 B!iB MÄBlh&BdC0!" !4L!Ȇu% IDATaB!D64 B!iB MÄBlh&BdC0!" !4L!ȆaB!D6-VӄtEaNZkqpt@{gƯN+6nkzr_>,BiZh6AxBgܝugsrZ^W'z6WBZ&*sBp ]uWNtƌ~8!../2tEUX*pq~G%.bK*T>`d䕳iyʐS \Y=0ixBoDj6vnT8!q.זrG~iY;ࣁ;8*,B'tE[56Tmi(l;HS{qnX~5x7 %@gD@DĺS@o;+[ň nsHk=:#N#4j,{S{n) A{HD4ºaI\͇Z JBWKܢ:cQhQ!0p5~nA~sezT‘ņU P  +0v1Rs[xPCmo#5W@gDA%"/)gc+A&< )˿~ܬ@\?miSWKݙk Jp0W'։pUax=lMEyjxPYϢ*B60.*_?%l/!yIsw࿟ |+YTJj'WW. W ^U>݀ <~o̝+;#6W',Mr&`ROwķF7fz_B\2QY]w& 6^ ԎQY^XArzhX 7 1' apt@V1L@'?jm桅9 jGZk%"&ݢu6mCG@$\-6\|6"`^(f`y68Aq0}UHxY"p:_[D F C f˅pA9cܽ cUP_ j|Νވ?*b#~, *pm,OBd3(X&<&fĬr7BȣgP0G$uO{r7B#EQ=F<\ z#^ !Ap -jB/GJVB!OBMÄBlh&Bdݢp-bπBfk-囀}0lih%p?_f13d_<a{%Z<vn ,`.,\Vj+vg- \ozF`-l0 3T7NT" ^n]QhmqC PVT:p xdžcyX %c4 ;Nǀ@>B75ppt@p*O3xMxdžLpH27Uwvbp xO@  \@B*| 2o.oc10Xeܷ coC!u_UX-_c;xjo_{㖫o(#-R@ݴWCEkk@=0ZK-Ψ4@?@'>~FB@;oy1ڣ?/[4(|Eu@٠_2ʋǺMfAh[ψpQ?бfF@+lQ†x4@PjbŸ-!'~o<~<f| ` @0? cU+!br 7mU?`&v oO/ sVjL~*`0lDcI`1@ VT jIUOĦ4#p ળ?؝]n 7:w?n] g#oG5ؔ&8$RC;h}iq U)QVV 08ۀC;j{8;8%D`@ #@  42L 蠏h|"4g!4` /N O7rwL8 9fn@ tF:2 4l: $HPj ly&;8 21 eQ@ m{apYhxCr]]aaMpS#l `r(x=C@x 8_ .ZR8 D<!}b{ U3 9pA({ PCz BbP$u*X *=R7 PPdo@  H7-o{I o@ 40&-(5O܃A.`Yso+Ζ0Q R~ *= `E h&@ N !T)b> ʕp|~p& ㎗ 9 MaDt| +pS yeV;gJ@ E] @Aqp@8bSz.+]`a|og%w 'VHD@QvkNmᅬu-ow+@|#} @7o<d +Z |Ξ5n"ޮPvRkG,?߃*  ߚ|ae(5VӤ(W\HOE=M\ &Wcu#-KO3ԩa #ߔ0.A+,}7%qĦJ-Pj=h*O A)gE0y"{QY# B T y5tkU__*N- E 7iE >V ~h|@ 8 OXJ-^c0!%od9OWX8B*g}eϫ!zHĠ3Ru4d%&g',Yu$07,l#@xL4@ O0d&i{k;%KTUU}9H䲽S|w,##իƒѣG:tʕ#G?~F3cPdFyf^/J{セ}4]QQ~Jw~^repppqq6rM ??@q%T9l)S777?~_m~Vj`0z??2.~o<y:%/^=iҤua.ЉpĦ4s5ܥKHs5P(-Z4w\J{ٳ 㦅}sss,m卍۷oEN޽{w(..\[$''ƽE]t{={44//V1(p >r4|9`G>.x?ر]†xz:Z<[++{7nP* 偁D!EQjͭVDBQT]]ߺ:www=@ܢÐ;{`F#O>9(::tݻT*y&ĉ4M޽{b/BBBΜ9]UU:t-..ۻ`ǎJbU?xzzq>>T՗.]z7bT*}Ο?o[(###  0aBAAA~~ĉ3SP(޽Kt.]7 ruu}w~Jiy~Ï9?{7mڴiӦX" 1xX=;;{С(u`ϟϵ6u\~hx@a[^tCѮml%݈ͫ*--U*zk͛7O>}Æ ...uuu999mܹs.\xĉ/_CjhOrrD"x:nѢEG%%%'NXfF9yw}'###66vǎ… _ŦQ߸q^$1ϐ~zhh͛7,++t[l>/ IDAT*E{tsS\#GO0k֬={o3@+ZV ^o5Tqm痷ʕ+j 2J^߾}iX~=d oG{8ų ϟh-xxxl۶-!!/k>բ '99ٳGɓ'6/Xw~󃂂vm裏>D"  Œ%K_۷offfzPLJ~W (@ سgOppG/ƍWdddPPEQR4555::)ONN^fH$(jWX>߳gϬ,c=̷PvIKKk=55u̙Ə333bb5z葙m6cvHJJ3g5Gr@L>`ͯM61;^fq0S ۡzBCCqU#˗/E+8?~ϫVz7Ǎzj|/se&ɓܨ@a~A߹sy 8p*CG.D{t]\.-V6Fk^^^!!!7qVYoN8Fzb⤤LJ~i&S`C{5Z{ɶ3TVV6551FQQQqqiNsww޽;[sIdzX!eU'_W?5>#6暚5552 Dcg#J-AmTjYE6Fk̷'3c87'#m?quܹiӦ C{5Z{ɶ3z1lvСCnnnqqq;v0<-!sssgϞhrӧΟ~k>c?\ tXaH$% S6*C 5.Օ9/ܷ9(?px\UU{셳dmYf{{{Rmk<@ʶ[//O뱗=R.]:f̘smmdžxVչ#FPTΝ'_ ^7V5/#6+++ܹlH_|)DVcgFPZZY#zQYYi1vrkUUUnݚ0aϗzc/%mC\EEEoݺuƍ&L0c!ϟߖ.|PHQ rdۭǧׯ RhMmǶxΎ6lXvvvYOTlQXzb8"")Fڞ\qГiii ,x饗zNF&Uu>>--͛]taʹC3O[HD"Qff3<l`޼y'e˖M:Vt:l^< KyyyJҸ0v|'f;AAA^^^x-z @xb Mgɋ-ǐOK:OOϹsD"F?^|=!x <={G NO4@ Niߔ&e@ Aa@ Y @pd&ie@ Aa@ Y @pd&ie@ Aa@ Y @pd&ie@ Aa@ Y @pd&ie@ Aa@ Y @pd&i8hNJJk¬YǯZjן}{JROMs2%&&*LNN;v-q@\ˎ*NLĵiU '<<<===33Ӣl.VǏ1´$::zi|m cYv-Ϗ////u:}W(2lǎ^pž-Z-$$>p3>Nb,qbG;~}8q.YO{Ng{g֯_oZx[=zҚJ:K>8hjZ`0zJeW} 67443 $\}&L%C >}D"˻r励իWϛ7rڵkcbbJ%v?cH$HԩS{e>^repppqqڵk .]T#G?~F۵kZT{ۗ銊[LfN*ONN.//֭w]]N~F"Bpɒ%fzu*e?pVRRRjjjE"Qeeezzzcc#?㎷`0?XŋCBB<=='MT\\n:L=(?ڏ_pt-[`Zn E``{*++:b855ȑ#?̚5gϞhf5nܸ'OˠT*Ǝ{nےGk|ڜO}VRRҜ9s9rժUd>O$P(T(rdoJYnݟ'?b(훙٪099yӦM>>>d͚5333b{PFFFQ%JSSS}_|X,(*00_?B'''YF$Q|rX dsqqYbEll,EQ6 +)))֭ `6;(b1S̙3M?MKK2d5p[T2X?^)))|>gϞYYYVn[_~󃂂vmj|{葙m6q{ 6_޸q5l e_<o>^} \\\rrrΝ;|?s.\ĉL</==]-Z_ϹsΝ[WWoĉk֬h4'O0s{̩Zti]]Nߙ)ryllD"t.\8~8]g03_#&--mՅFQqe1K.}ޅv[ǝ;v0 .\믭uǗ5צM Ů]ݻoqkiii ,x饗zN,sΜ9WV*Jr׮]/NHH`.:jkwt.]E Nsܹ"H㏗/_vEHMMmӠr'8)M S @ 8 42L  0@ NAOJZ*''2%==] tuΜ9Ɨ)Szꕒ[suqq}6ڝSZsprrٳg=i<==l 11 D̛[?šlo+/0S|x eceNRRK8yӃd_v8K~;n;K~ߙ.Y/^ N%wV|:]A cƫYqvZ>_^^_ڜ<[YeQd ""bʔ)Ǐ׿eE+eQv+**AFFEq3ftb8''gر׮]cg˪*0  ^oo222^j,=zСCW\_ӫɓ'/Q(R{׷o_+**֯__YY x3#kInONqF2==xmǚ_pOXS(?5.v޽ ϟh*xxxl۶-!!\B0k`PEKO7 /^zΜ9z~ܹϟOQTLLL\\\@@o͔.^￟1c|PRRb-e˖եgƴ4BQ^^sNBa=T QQQT(qqqXD <`^{{{Z޼y3/^0i$xBHKKkjjR( 8'be˖X@0n8ٳ Ё))) .TTJJJf͚y^x#j?WRTTԫW/Ӓ^zEݿ֬Y;w0`_uw}7##C0{nYY W4( ??E͝;WR`S\[n|ɇ~%T@vY+eee7o4*" 6Ν;v\m5_a:/=m8n6G 9rdnninnn6}oxe-ʕ1bjSUUUV}aiiZa\c*͹IΝׯ_gᤤ$fga{ѣd1t:www) S\ Onn.GrԩA&/O0A)'Pi^rٌRDEEAy T<`^rgh4rӶc/PSasYx2Z___.ޮrEY7S9Wl+4M5ʕ+ofEW+g&hv?%ێAReeeeeeyyy-]t̘1Ǜy*nJr+MOOOZ' 'T<ڵf#FTsY)-~WL ;F6<;;{С(uo]ݹr嬲Ϸcxl+ׄ  'NhDoYY-[)՘'JC~-UJE4+7veʭ3XO3b///Xkac|bvqwTyvvvddaò[}B(' -^WWìaGɆnٲeԩ-RǎkugJvN+gUVWǚ^ׯ_ y,X<=x`|||ZZZuu͛7tb5>Q('$NpL Y{Nr6W~at]XXk.pOl'?⁵]|U~ƍzHTXXڶobbwãGa _QX"<˛:;)))FrO˖-:u QXz\aaaNF-stHJJ\.߳g? tDȦ4@ NlJL2L  0@ N,@ 8 Zj\5mڴUaPPPbb Lw޽{~wyLL:A֮]_gffΞ=X 陙IIImo߾}޽̋8&'';̙2e o b\[i'l';N&%%5;xa;>Ǐ߻wQU)&&f޽:o1>^gffMYǃw ;v6(We;TqŎvv(?pjڣY⍕^z|>?,, %aJ/ -(ypܺ7FU\쳌WKF=tЕ+W+**AFFFyy9W07lh=fA qtk^*޽ ϟhKxxxl۶-!!#EllTTTb???\^XXh|q&<ɓ'wM$ɹslق͎c~7|rXLQT``Sn 6ydnêU222MHHXd DDDl߾](._?YYY2ݬc}ʒ!222(((Tms͚5"˗#Mff)J6lxw@$}w -tMKKRI7%%eݺuB,X`>O$P(T( 111!!y~jj̙3{jSU?qLNN޴i5kִsƑٔvqqYbEll2;\-44400дH Ŕ)S=.٧O^^^;DC:zްݤ9s昗/9`L^Zjժ7|{ܸqW{l?~対ɓm6fS5c woqAU.2(Olٲ;wر#///++ (BeQrxv{+*BIDAT(Sqtss[|ycc1?mشS^\~JJJbbի?.gԘ՟ <ˆŎy(>@TTW]0?3g 6,$$_5ba oc)pЦ `'n v޼ysIIȑ#?:tVr#˰3 ;FνN89s&,,ۛ`v򢨨(::ٔݻT*yD" pz!W7RL^;Ennٳ5\.?}4xg{_WWWSSSVV3(jѢEۑ#G,p2ȭd[aqzysS755UWW۷)Svڥh{9VBmn2(rXaGɹQ݈]d1\zuƍ[x+07p4=jԨ+WGFF޾}3f\b ث~y3owZa-T^ TXjunn#T*չsPgcXj0d 8hS%[Gzszyv?9j(ɾ}ƌ3dPصkב#Gw2(r ;J^2vgeeZyONvf#}„ 'N4n b.v'N4zX̼_\em/Tyvvvddaò-i~ì:tׯ_H}t;⠫a<8Fn% Fzyvvڭ[z۷;wMJuJ>}\MM;ƃw ~uշ߶[y%)鞳jhjÆ^ Zݟ|͚ s_{d>IͩEt@r"09'(ӟյNpǜ ^.Z-=x qYꙐW7U[>Y|Օ }X5JeGuMO͆ܶ;zjoee%~^.:]h8Jwюɒ%ŃK׮ + E7NI)ٷ?dv(p=.}x"//_vmms{gܣFy=4 ҸX[&` =H)q[~7 `//!#.ׯU@$͞훐 [vu6n v([ RMMe^LH+ y}t'ܫOP{y @KI)Q ={׬ ͸M|U/9ZS>(zQ,[C&ug>RA҅gnϷx˖;wao5mOE66yGRR>T/4|A`ժKy}A6 $MܳwoW_-sGK҅ aWbuexЫL̡nn*^8rgCKż8w7@Kl l?}ϳϺ\}sRʀ/Du\:Kjϕ+<կ[zGs/"=[[{@{B{떪RW_E]*5_~YSFPB#:BǭVwX]KI2m]mէj4?nM ŅV_^j b1# ߸r|~۷$aꔔ]0*]`0пbEo-~~B__aiZ&bf/ʕfT^O f@eK+/jkS 9(ۯ~N[_w՘#@.pUݕ] ^}W_2蜜+3sk~<~> _]qr3*O\=c<7*Gsx]B\*,8XLphsp@w|h^/Dwמo5eWM^20mb|oi1ܿ_-:{,%־mC/Ì_Gc+W$~ZZII~n,ɓ( cίֹE? `M[Q-(Pv&=۷BSPj@CQ`0"N𡖢B[]cˊRi0[XR*_({W?rh ݺ g KtclHM-MM-fd/̬{}pޮZD.k㖠^L Bg~b8~_?3}[KS(X Ǖ hŋ}ӦdJ,+2ֻ? XL→P,Z?~gU6&VCARVsw36PU=uRlYp$Qh!j\lkzx.ezW,VKtMK@p3ft {',&4',42L  0@ N,@ 8 42L  0@ N,@ 8 42L  0@ N,@ 8 42L  0@ N,@ 8 OXm )i,uIENDB`sup-mainline/www/ss3.png000066400000000000000000001256521166154264000155410ustar00rootroot00000000000000PNG  IHDR) IDATxyxU?M6,"BM@peT\( TnFAE3Ȣ^ "8dQ@&K I(뜮tC8r{:[u|]@P( E0M.n( BqqzzAP( B* + B4B;LuS kӷ!w0t_X[!P \GsȆXH[38a> Pw d5sއ'+'A!\HHd'" 0N5(;uȎ! p!gC.| E+G8 -f"$A &Vg(a8`s+L !`E*;EoG. &ؤ 8 鹰ha+9.ǜA2|iHU nZ.e1Hl0߂cP--66Qh.hࠞ>zZ?SٰhaGR*TDG= πŀ! ;` $@4av8i5e5ډ)};>[Qv.n;h6c{ `.+`~whݬ"9A=(wd0eX$xr! cO0M_ "aRh>_ǒzdmE-OBDPŰg" kԾh/Bj_l.8oxX1o`!QC;Xp_ aX{ T_•CB< bkU0럳AUUv:d' {P -B50A2~ C>BQz*C@R("x?1L,ȇ HPxl0ҙJ/hbdE \ /}Ka9`x 30A T@k/F@mXQ 6@Nc1 CFh %ِ(j_S$'B:<-vSRn`t.bI] CA8\ T,_.v5%*Nu aPc}*;NصGv(*؞ xrgB+,0"Jaoj };-_WQv.(;uKhF  `kzvz1׭!+o#zMk5~eBS6Pfg஀mε A 0WrE]Oݾ ʯேs`=ͯ& n_Ba B0w һ*CBP( BP  BN B-@^+8_ٺTv /jB64pa9dC,Z$Dc$` }Rõ/ޅjjC<~Ż>kӠPm;~5T̾~Dx[=|?C!|`HSs!QjǁvI7,7.0¿!K׃p4o! hbd|B8mY`X_& "ANbZ? dEv3B?X}ڱh8ujGQ.-0Y;aiX| &h ›ʠՊˡ*̫L%_?`0J[Rˮ+_8 pZBAɮ\| ? _|E}& C~jZ  )f>MVxB*qA38 !&R‡p:ipZ=/[vǗ~! :CA5tWdEv=! FWgYtM&Bh\Qŗ42E4Nm (IpKb* dz 3C&>,H& AYȆs0L)b} /A X c`!=¦\Puxkycj bH<ֿn}"oވSԟK`6\o=) ԇz^x7៕g(̇2C~K俨>4!K_G$ /JwV"V8{}i~Ѹ't%80{ h/\A4H 4Hmod1藖l0߂cP"h_D @/GvC᜾?ek_"ٰhaGR*.>[$)"  b /jQݭz )@Hh{^FWzow|I'0!nU ՏH^>Mtު<0]?Eizu%B2NhQZ[(,6$e `A8 K"9t YE-=?4}RE FuEhgk+ \8 W8_қybIkhe٤VI}T0bIX!z~/K?`1A᝝*[^n>M,5C-\#lh>1Mw^/qjGa8B.~F. [8}}MpUMZAa5L"Goa7L3XL*-PD邲U /B;|}$xׇ~ȆC\(+Ŀo[ZWOY.8Wg"t*-,(_Y_ĮAyVR%.OLe+8਩#|yW^Hk@X,6mr8{_r9zo.x (Օ!^&E$@}9xՁ\.]˶>| 0C;Xp_ a nMTv׿)C`#d !C8cr6l(O!YPA, o>NEY5@!|HySl⼺V9(~!iw|b.o/:Ʃ?X ~w ^lXVJX1!'ʟ oP10Ыb]C/hY9zSM)5ΐHX)P]*("nJľ_B vkoC($ } ?Ah = װv;}+Tj'M DL:/[S/0NAg ~~DtfB=x,[aXoey](S K=(bHD.i/:Ʃ@_2ε xr*a!FB < ϫB бk`3K8BzA? ^y[=U V(j:<8Сi 'j4?*"(pYh#-V[P=,ADO-`l BTV( "| H B* + B4TV( "h>H=? vZڊ}7|_B6 3`awEȆ+L`7M&~0؂}q{ eSV?#ra"xV~ fFګ* k&Ax)ˈ3-7 ճ\a8(n4h$e@lǠ jPs4tQ~إVeu[aKIZͰ.t1359k "َ@?mc< p[`2}6Mw#6mD{64쳩HޔX }S0O,<!Pk,Mt\C͖k+_E3^C!TWɰ  q`8 y#Qi`۠!(K!O2>ۥ^pH5A+(qȂΐw(|}AOd&x W E_싐 ipZ─`?&EveEʶw Ir"YxS@)tgoOshoyhP\dʿ2eSѾQm/\?¥Ra!H][5*4:Ax ހ; ˵^x7ំ+-8=wyL|`y~g!x^X@:<ѐc]D@` C^"/Pf#SEɦyg0ZO@,Q3`1`r*rvezyUKbx0>`[QB{e$+nՖ?%2EHn{a?!5Z=N([,kz KƩ_d%Rӡ!k&q˰"a0C8ڒ_ƻv15Ov} !nG/sjm+AgxH6T["lWvdѮ^E]}QG]*;>ڕNmpL,齣ȟ~p8`AFƮO"S//Bvn?7DH_C++3uSB;H4<Yur *HƩ_d%n8 iIKa-| @C_=ʠ@Etەo azB)'>\Ymi^  vK>JR}vw^Ⱦ6|r&ԿF^!ERnẳ&p+_;v "¢t4]|z4Y[xwUB/ӿ+B= ADrv[d&KmkTTyG,ۮ-\,+',կoWoA(7~TkWEp#kخ?'AײHQ;h/[\d?2(c Òrσ) n( ;vѮBxNqw>dC5ށFpޠ`տOڷ^IP o`jDdEζd5VT~26e;^h'hA$@?1@g 2>xt 2FzAC+ne# 6ľvw^DH%4\K ݥ%:FT08h-!3< KC/ɳKlpx ΖK@;_/a&Ε\ CA8\E$Y}?kzDqJc^Q |u a xr^BmXJao%|P3GQ=:N75w x/h`mQԏfCWVAO/=C ^Lc'Eݖ`ʁEPpD +E * _:@knjQZP(°BP(ACaBP( BP ! /SaYw8Cݐ wtj۬#`<|PyG)es?^[ճwQm^^53 4XeAok v.o F[s'~j\ԕ7!K6(Y /yBm@5F|eM j!|~ "(KsS0*$m~ňH&3k "9htP\dD# )ԯ\i}eMH4]"#ox2K%SDE9.| ~U|mOs)da(\[/'EMHQ;ڗ S[E2\KA5t{ddEHǃ\i}eMH].O4ǵ]Yxɼd= @,)G/_rísq-*VKIyyۑg?y۾Z%2dEEؗd(eh sg'x˕H>vɡKdE2v ǵ,|^g (k_akE@r1ܾ[Ѻ}Gҫ0^tTk[YA"@ZHZ$}n J"=̫katx˕H>vɡ'޲y-x}9zŬ̓rvl!oGv Ed5Le1<]n J"80fz˧[=GЎ"9rA)n}9y+G/ֱ[yҙ}_(hQ>AD@~,W H&]C$s אS"m*!V(Y˞#hG}t{DC2-"l=>_,qоIggNޔ0St uE QR9hSyp#2J^?!o/[vɡ۔IOTi6E2/"o߰U` Q>tg?jGWǮ=MupdW(7oJNQ BQwvѪQ B0%kd E0P BP  B* + B4TV( "hԡW j M1zyrt@`ž>/!{Z0wt[dUq7S&K`+dC# w ja39 -IUxʪ29dCr$}0 VykzQ3L[ OV۸)~?aZoinb !֬Q8 GDFWG)1h'৚*w2h# Pᨮ&B9n`|B8mY"bX_& iQ)H~%rD%0W_ h!N60*$m1UQ5ȉȌz # D{64충-YX 1R IDAT}S0Oc,<!Pk,o9zQϼfSm.0/| %b-a\]ZMd_~.ꟙ DhH>h`U5n/p*$ӷžܽ0΋ql^S /@8B&0 @kC=8hͺat>wuٰHZ$'.v&3lx'П\.-mKBqo4߿6 5KTv["'r88 p&ƒ5a2)`h_~.sRX Ð_"t A>%3Yݰ]{a*#/y$fB <_)oz |(3ԃv/p9\n=mEզrh2ៀX6gbUʒx.Eؗ3`~}E#H/ )nS9q+,%r7 HBo1(oi/Bvn?7 @+8VB>] 0Lv/ylS)0xyU@Hh^G q8ZSPnsȢj=ֶtEզrh2 ZLUH6/ypJ_(nW Ү,v n`ze N&E71/- 2SnS9q?4i7 _ËK>틐Mk7)rt,pV;@ ek8ul&)^W3Bl!>K/Yxm1_Zv[ټ$gX/ WvU&owo!^p?jdQZ Hqn솕pJkhφve%d+l T_]Yp N/g| XS9[r_K-?0 ֏rނ]$(Նr$$Ar}vw^~s@SCh)8LX}H_@v`2,~ t3\g| =/vF ȾQ%c& C~w. R7Ċs?΃;OHڮL-Bv ̆KW< @%kxSMn!6e< A,˘Kk`im( `"<{ R;nK+rǗ¥Ra!vPT8 τ10zym<n g "xDH3b>eիSKm\"mSC<-/ْ1/s [_R$v|&bzC`̀t(,F"$ʦej?- 췭*TxQz:Zp)/h(S;~B&T-UD.>akG=PT+1%c.g2.Im dށ`A^`$sU싐+rǗ}&z=gʳBpD-Sl?.>ԥE"yjp=| I*]]yp\omg䦄T3PD\VhXm[,to86L߆0btm +_Q`jB+rƗȾv_-y:~[WBH.EiwȖkVW@9x_2ֱ+.-Lܔ^C!JSH6=2"(ca~,6Y[~ v~ VC7 Y/Bԯˡ_"A93 * +N-1@;_/S;^ V&]ypSx9dMca&Άu]lʘGs&I] /}Ka9`x 30A T@&"nCaگˡ_!Ӱ!ﷻD(%Ņ1/J^M@u6dX{mT1XQEiŅ1Oz_ݎ9J n*@˿V( Ê v;? t;H, _^B BP(jK BP B* + B4+Zq̻RsՁ,ӭǩ_PP(.^j,P/Nlln(ԈWD2!q37`:lU0~m^\֔ (.璆u #&uPF^N$BCؕ KIIu GΑurN>rDq]Ob4Q1{gd4 ^tiN d7/QP(='8S#C=/Oxn=.md\0ߤU  p"RRV Y4W11N>=ǨOX%~!e_0u 3fgsS{܁a_΃˘ܱAP(u, eP+4 NF"BȊ( <G"Hq9yŬO$YN⭝PZҽ$7' Qk²}Wp 흝E6BI9n7Hj((Q8CU BQEkzY? 7`&v؊s%iMu-q?P_H# xl$길_) &{y+܄ԓs?Nj#~/K BQG~O8UpoǚMګ_MdGn\_*叉Grpօ=)xjo^#" :ܢ_vWdK .߱[XWP(6.J]Hv!z\Z[hސ_\Ln@X=5>:G8P~˿#[OD #֞-Qs4# "4 $aP+\ap~ tJ?@f4(*ǍJV( w6FzzϔW3Л|-{ d? 񼭌ɂ[`q7ScAr:}xꇐ[l>fI_ εjsYs;Ѐ0Hr&Ԉ=R( EՊf]3uPP( GǮ=j㢴BP( * + B4ja,1N( D + B4TV( "h0P( EPaXP(QK_њ2e_sʕ/,XpΝۺuoYTF{;/o7z-w}K,Yb0nܸkgݶm6ms=fͪ {ア/7?ֿ3JKK۴iӪU&{8z{se >m۶w}Z3fL֭֮]{ND:EyrP[lJzzzhhh&MFWMlqjj_~ٳgL4)337,++SL9tP-&MT^^k={LMMMOOhԨQfffoŊ!!!]v2Ңڷo3[ߺuk$\?og c\oqq5k6l0vX].ɓlْ-''?wPd"%%s_~.y삦Ҋr}$(--矷oޮ];-;\zƍ5k=uTDDDf͒r2d-_RR_r%_ãӁu}ïZћ7o_>JNN뮻.䒂/sO`9r޽{ڵk3rrró$$&&nȑ#YYY>6ӧ}k.-QR >C]tqݧNz粲v&Mtԩf͚j*44t޼y;vճ@f&Lкu'O?~ӎ7nxܸq]vYEEŖ-[ǔ߿UFbbbBBʕ+˳֭[7`gat?Hsrrȑ#"""֯_ 7sA0^$MNjľh|mwSy{sIƝ$)_7oݻ=_k3hN6-33sLmrʔ)GhaÆ/ƒ>zX-2uoqᩩݺu{W{ ߿M6.aÆf1b[]tMIJJz׻v*/ZdSNov޽].WTTԕW^)O_2mڴ谰\K1cƜ9s€GyG/cܸq!!!mڴy뭷v${9V#FL:5""rnݺq>L4^ر#M1cCBBZhdϧiii/RBBBtt>__KTnZZڳ>rNO>9y䈈~+v]4?D{_b_鸖[NZnmL U999yaaaSN}'""",YthMÆ MNjľh|mw?SS~.*7ETG>|)))wu)S`Q6|-Z`%KQQQ.70}÷~{A۝aÆ6mX15y5^"?dȐ 6lٲvl޼Yӻwz+??tҥɡ.xlذXnEΈ$$$knٲeȰR ~QF͛7:]$##c߾}@qqqvv661..}qyyѣG볲mۖ$2☍7}GDDO>)..[bE>}Ifffff7ҴiDDH޽{:dLq9}tiiɓ';V\\ӤI}853NƝx׃۷C)SFta[SRRf̘QJ4tQs_ r5,_|ا5qqʕ>3j 鱱Ig\.WqqqTTԹsϪZ~~~DDDDDDU}v5jhrrrbbb|_X|yTTTjjj||,Xpy:uT[ݙ3g?sssq<$$X:r.+77RvϞ={ы-:}W_}u7Ѿo1-kzgR"/۽fƩw?׸z{1B[T߰aæM߿)OML)**Zdɒ%Kz뮻nҥvcbbN>}bm???vdff111 GMKKӎPM<>HƋ1Ɲ}[=zZ;;_oDرcn{СvK?ߍmE鐐0U^0U(ȇ~8tP|u',,I&C qLXXXhhÇnw hܹs 4HHH0&wРA=ڵk۫W/ym~̙m۶=ڃ83c`qqqÆ 4hذW\a`Zo"u֬Y3vkպy;zsvm!!!IIIy9~qq;nVٰaöl"7r<',,L{!--6h"!!!22o߾7t>I~/D%??SNqfQ?o۷o۶oRhZz7OzRk=/8pwΟ?>|ĉ kΜ={vɒ%O=TnnnYYiS/f͚g}䫯zAo7mڴpB/s='N,..ϵ;{Qܹs^x~ׯ--M{^zeee'N+"))ieee[n/ۭg/RJJ믿~ȑm۶Y]u^e޼yǏ_`AEE֭[y/駟֎~튊?O֭[92::UJ5/B2~LqPϢR^^p1cL0a͚5qgxk۷SN?q#m۶w}'}ҤIɞ?SSSO={v>}_|+5C- #G,,,;wn˖-3225j+&M:uTfZj:o޼;vmڴiժU۷o?k,-۶m_xᅄ={;wy/?|qƯɓV\Y^^nݺ8íZ9sرc ٳg7󑑑#Gݻwhh]ϟo>0cƌ𬬬tk+ 47[vA߿ݽ?*+++** yӧ}k.OzZZZfffbbb|||nnٳϞ= $&&nȑ#YYYZ~v5kʕ+1jԨ-Zt<ڲ3dȐ:|W?EvgggGEE8 FφcbbN8QQQ9sV5p)))Gg޽̙3G]^^>f8pa|+ϟ?4klĉcƌ)**7nk׮ݶm[iӦ=Ν5j?4i}oo߾GiѢE˹k,X0gΜ믿^ӏnڴ)%%qI֭g̘1a„nIK8qѣGGhѢ={z2{qq??UV};w .K2qigNz衇^zo=..Nnq;zLf9~CZju饗FFF9nǏ?x̙/^x5klB8ji~i~krerBT4nܸL{^[\\aÆm޼YK=s=Yӽsrrӿ͛oܸ E?SVnz|ҤI 66[Kޜ9s&??E-B˖-+or('Z֮= nQF:t(>>[oOE˗/g $~hNHHHح[;v,[l׮]oSN=crBQK}iذvܲe;';tDDD 80...<͎֭[8Пi#F 0`ſn޽{=JIIg}vyy3\qUW]u7Ǔ'O^pΝ;vҥ,skpZZ-ܒܼy󂂂z7x\`AQQў={ZjUΝ;gΜ٩S]v=suw>ydbb]w~w$'':tg\[s='N0?hѢomRR҉'6m>׿uǎwޛիWO0aoVgZYY?_|3g^jՙ3gnָ}{&LHLL,**z7֭[qUSSSsrrN`Æ 6?oh]vSN:uiӦO2z@PopA֎ugۂC; .(袋> fΜy-4qCgܹcƌqW_}555yիW>uh?e痿5ɓ]v 񂸩!F7p;wn.]^z%W]7j]]B}^tpٙ?~.oVϞ=/^$[ouĈmۢ XEiA"<9 Aw@3W_' 44#_͜9t$] x qTta>ͣU+z*kYG W߭?8}={B5Éx }M\]='3v,whζ?x0dBTӴlɲeTTcX˯B_VȃǢE/1< ϛGN9TU1i\:{s22ʢMyj'OhI{gO>ȑSPW@j*#GdZ}]~SZukFwMk2jTu_ʾ*7No8َXU?M[f͢}{ 5kf IL$3㫯x1**()!+ `Hƍ5};W_Ѳ%W^IvQ̙޽tm󧴔+ض*cD9p3YH~>ӧd Z&Z"3 \\[ٲ+))!3~Φanm';4e;n3cW_]|9_;]07ma"磏9'!eCA\zn9jޥ6?_qJJڕټlj2LU;wrVWo^J?y"S.ǓbH<Ӧ ˗&|LŠ,X7a4!'',X~w3w=GUA%=rr8r Y$,֍t6U޴oon-A_?//22 Q^Cu+Os8~ȰaA?3k|>~K^~ؾ-[2E`i83^|>:t+ؑD;jgGM5*TWs(e h>7G$%Q^N:]K}U_;JK1I\4f3l͛ɶmyeA\[3l1 %G˖F 5d ru۶q$'Ӽ9>۷$>552bDWݺ$8=i[n;dDkˆCIL$.^py̟s1ujA 1( t"L۶TUqt Kb"\vY8݋ct{cGFbz]$ry`f|g4o^;`nʾ>}څЫb}w˄ AԠcuO$'S^Ny9IIɟѣQZJnA;V{gN+!>ϱcնiM >OӦ _՜8#2`@W"^Μݛga~7Q KY4eBiJB|uuO wHJKkH׮dg~~.@];9x y˖TVRUEZ<9;OSRhٳzT?^رڛOL̐!g٬@k]N'l.ڷ'#!mJM~[*Ui)Wy)SHJ}੧oAfX),7%!tv(/g{z6QTDEǒ{PYY{]YI=ly& A (-ef֮}WH|b92K]PymJof 1v۷Mc0֭iٸ1i[T7+{x(/g:235حqq:u?mpGy3;vPSCi);v?nr2-Z'gSڵc ^xLSQf#ش-[jWנ/$55ӰڷE *+)+#7t_>mAAYә4}яXɓIH`gLѧyygWV++Y8"GFFEʾ*;X,ΜOzEU?zc]wm<))4kƷő#gwc_ȨQ32>^y *237! Xŋ3)SX溺F8{KY7e5mU^)l ()h֌ A^>Vq]ѳw_׮].\xmE#AA3ѫO];vԩ#  Fakq_= BEiknK.:thA0+.//?~׭[w M؝8sLbbbAp۶mڴ޽{{D]AVuu_>ĉ6mzW  D}DKA61  4ydA!Ӱ  D A!j4, QCaAA2 BԐiXAL 5dA!Ӱ  D A!j4, QCaAA2 BԐiXAL 5dA!Ӱ  D A!j4, QCaAA2 BԐiXAFh;gp:Zuo]\Cx%q'}iߒ9q{p˷MnD h"05,B~1=Rx2NUv.{ZRXrq[6gHA29\Ÿ? ŝSг#9/&9;GΦg_:'ӱ%'x}NhsI%#< ։,>x} N鎛֊ɗ=%*ZY\w[RQ͎|~!|5Ab<ɹZ;[dnMg/%%ӭ-xf;v]^AAOLOHG [d˗Py_! Z6gʫwⷛuL\CE5R r)C$ޚF Wh+cpnIaduQ#3=>-ė@r z#ZwD9~˛>-Kk$.ʝTPY͟>ap:ʹ5Zu68嶴-ٞ?~Bdǡ }@q9GPJ̎C|ZP^MQ zbj־I8Su,#\؞G6Q}ls~ݫ2L [m]ak;>m X\R,Rʫ)\Y/RQMG+%V;ջ?OD-KK#-4{qc/ҚA ~$5Ъ9#|u9* P1v5cp0=WAWul>[y]}iWUu K=<#+HmErsȹjnY۱$%pMؚߥ  q[u܅[ɺ'Pke]Ή2ߢ)ϗ_>wyc7>X09F+qhAЫh 8b m'AѫX\Bzk-LvHA+1(-o=HL7wSaAL1Pl B#BAAAAaAA2 B8'`<< *~A`"bga-E05̃[}a2&«o1ʂ8pVfCp)mP`"$ K/a -J gCBHVL'(#dgu3<֭,< <[zI筯 IDATXC<\wO[*@?x& 8ϱkaTʾÐ|H#mXFy (OImG`1ΐ>X 2aTٶ)9;/a+x *ĸ u}~ 0V4^%\ 7+}?k\0mA[y I_ u9p2H%ttjavf,X I: A.eF[?߄2x~ 1C@I`3PK`m="]0v` vkX]a9 )#6yrlm#}4 qnQgokM]MhO7ȇe_HdZ$KixZD}%-ROl#4|7BS^[TRէ~j7Cp0L:h]\hA<`J%Dʠ94c3|pN('ru4%CzyEP^W\ Ɵ%P)Ɵ)U v_ƂUb# W/E{pG!JNr{!Mn־<)FUx%kiÅʥOh+f`kUo'ů.{LNa /A'c)M. !jmII0={:𫺱 gH0 |?kݠi'pԸySK OkZB%TYj_c[? OdqHK⸬}0FպsM)5*$DųE8KgѴ~KaO36U!?\hkSS.U}Gհ'Ɵ[ 徿tt`R9xn oMJina{W>D Rr|g7a\b骞W¿a$(`rT.`Y"zNŦ۫v88e9g40ڔzw P &| ᘆ_pNA)E^JC)!AU;6Rfv_S^rU?JYXsW1y5\B<I=m)2H2HLFJ dA3XV'y0 :@O8e*&2X cNm[?O|@3lyk34$ڙI 0~li[϶T7,΀legi=\@iIc_4xއ0h B[ S롱?r.8-4OMP@ݦr_A"[Ii02@7W`!' v#WZԔKUcZRaq=WbIO0<ڞ2<}.["|2ZIi5AB!zt@L.J +\m7"A&,GgzF:3-yd`c{QZAX3# 2 6C!r A$µ5;Ap.2 Ho)%xA(- QCaAA2 BԐiXALȆq0~ FK9Piy6\ɤ8vCzSm9L(A]R@;m0Tr*yv`3 ('] hd5rV:}(Ͱ1}A9\,G5PiV/q0X6>EU:U3'Wr*qU\i%< q^ PgL "2WR @Xs;*nUhC[MکYSa3(/ ۠I\ 14aTr*yw/n  F.*^sJ Tax ! h>y+o;qO ޸q,,8}`׃zӠS츒+ȌJ姟<yNFZFywixk򺲃Ɇ(3`־*nUl_.H\gM=d} ,\}/z7iӍlzw?TeS9 oY0Ƅx ƎøIpH780n-$]ոɄV*j 9sqۘiZҍdAf(x1vgGC6/l4 dLq9f9 {d'+C5̃4OSM|zpcdCk_iV̄.0@+r*:A9|0if]Oh~,;V~ 5wiV6\#g-2]gǙ<ɇ0́[Y>x$ony I}{dUr*?V B `-ovW𘡴_im/z۱Eݶ]C!ЏҤůWRb3xSW_؎*9q~Onz^㳭 u7H7*l n5\ Rnt+p/<|uNUޚ8@WX|8h| __;׻L[Yg"8x%v\9Xu^QɹXzhG+߀ɐ9sm{֛ގ-Ur>0WGUmƉx"z$rG#O CG#C=r\U.;h\^/AW,x+, z> A̫d=Cb2xVYrnk`3S&+t^sg77t=3a+ky=[leUq.\] `/ Ԏ@Uozʼnf@\z6E$-5b 7jj?#`lq".4r>D 0l:QH^uhxs;G87S nеL 9,%xAh̵OEiAA2 BԐiXAL 5 v·[HuJUSz, FÏCQ< W4G:kݘKj Mmp p K+WrʟUpV7 ! a7-Z& [ocxnoL1õ8 '50}0pi ow7i0Ɔ 5P ydaO qz0Prp;Mlxi Bg12 ZÍ4a[yp2*n0X5|#M)=gwZbaiV{W0r̙xjl2neUrxP Lq'(#Ɔ*9 #_cP%HCM~U­=0B)~J(@-Uj#t7_ӾxT#aȁp>TTqqUmQճ+;x~|{1F O[yp?VjLJnfxِhZ ;G a3Trd۫`F:pE{eUX׭\~T~n r!ʛFݞo@Ldȁg&~\=a,$S жA=c:շ5TH%wr>pPXUPW/U=7c$C pF/ᲃx.Ӱ@^NƒE/ y>LJnj(<^BopbIGPpOXOw%3KvUA㧟R -) 9tq6oͯz\k[ݶ{XھNQIJq$)͸d[?vF١pfCVɭ]x@QW*4w%T#M(642hdJp JCxǁ-Y{W2㸔mWՏ*4~R1 b' Sk~}$C r\U+ݶ{XھQݧBcqcqɶ~TE/\ XM\ ahXQt L\ɛ[ew@q;G&C1$2nA3LzX a^.ne]ɶkG*?Av` aުߚ_? 0bcEVmgCȂfQ9*i4 VUqqUUA]nOqtf-Ohr8[?wTh`CL#)(hGqY>7 ~ʛgB\$"a l'ޘhGqEsr禼 x;2 9FCc?Zxæ,J BtEiAA&2 BԐiXAL 5dU&?3Y0x5Ɵypk6"LDx5pW5WYcgbnFjXgR \3ɦNxPG$fÆ:^QbwB(=``I6?5|Ypw8d[lKjWo’3+mgp0=L.A+Px]ej[Ma2 x a+wε 7BtυpA  Fͯe Dy?Ѯb{~:ÍȀ`kd÷k+Vo!8Ƕe8z 8k})nބ;79vu- vPWj/ȰWJ\# 2aܢ+( p<W@!lWn]^V]vrm W\eC{Y IDATBH16]UY R! Zk_eG3ުM{P:ΠU,=p V!ɬ鿶q¶?\]S3yHed?XXe84s0b1m7@[1T>P_T ;쫂5˶52neU+˪s߽747 dڼ­<+˪+cm&\qYLTG`>倶Uv4㭞b@|}0 r c&XƉ srS_!To0e2ixZ608P^#=r\(!2<އ Pl_Taw+mk_%WTVzpwݰ(4;v epA=Xe5'w4oCsĕq+P˕c/0Ce$l>ơoU<i,5[zq>brQzqR)L/O]'4VRɆ3 yPhmzj L4z_R{v/0>Xh 80R `дU2nmʃʪU-C:[d"](@Y-Upt6%ÈUV]㏟z;Po%,q'@c/o PnСxb o:h -!N-l7b;4<t2.=(TRSׅIhf:}kdQ/`), aQMY!WA1 ()”JJKCصAUKS*vW**FBq&[.ӝAe5%?a+xl=n|8>Uv?VE2t5 ;R\O[%h vqI:Z=+6[So"7ai}kdGf5P ;L_|<DNi|zMيJ7̀D@ upa@ey־ U{eme]wʃʪ !Oñyv?߹Ɵ'Lqm@:4Gֿʎ~ -(&lJ8o7,mL^ 14b.yq8 ^5p WYā C2Q5ޗ2H٬4n= @Q>gg}maw%߭+˪i 0‡ ~\ſYu?rG)ox !#ӶUv4mP0.3:, )ts~sѮDa)f(ը/S ~܆aw(W^HJ6jp7^fHY+Rl GۍH `{427 4-(}N12bSbF%2GDKӠiǿ iXhZN"Il挔Y!t?}RZA dA!Ӱ  D A!j450Τvƹ 1Q;حC`"36 `߄;7`ְFXPɤ!1? ?'kb!}ƱT 2alK>`mWM+} YWPemӌ?Qul:w RauςP Y}jJ"NdQ1A.Twcekd75޶˞;G/̅(Xȇ f*42xm(L[xEM]Mmnoy @'X@mGh ~lW@GH`T{wv :>i\A^W;0 ۴khWcߍ&(8(xCg-!=E#MvRt%n Csk<ȞTF-WoL)j8Gٞ`715?Xp 80RlT{P*42x/^H|FXGogI1RX!܉H5~S Ƈ3[o UZ5N?CՎZǟ~Z=W~jP*U9d(rSIi_}7v4<t2v#\Oݛ|s dKMv7lZmo|@ÕlZ*7 w .5IE'O-5*$t:UjE-@uU?~[UIh-zTS5آj/U[;Pe1Cl_MAvĢttgvX&\go @eϓ|b5=+w?W?`oFq0 i(Oϩ Ud6ރ\mJ }G Րo'h~ M/P W&Oqc7AT0vQcx~[4eށ"(uiz2\kEmI\ 1aElwegOAHo+Q<&2X cL:zmW2~2h_ . fҸQvdp,,8} V4UʤBh aJ Tȁ|d:׷o1(%7juOq~_rp2Lq㏪8>S5آi/BȀLj_[vzl'kVxj>GX ̓ @LEb9Vq؎? FØ{^hBc ˼gB+ް ygD>"쑷 0HC;6"i-J ->+^p[ηr A$µAѰw'xhFA?Ȣ  DYAh"Ӱ  D A!j4, QCvF69z}0 8̥{OM=szNOsg>STSOUs~Poý0رO8K .$xɒ2Q%r`܂8Z4T+](dµfy]:T5$8gp =q8 }`~/NB x-8Ր^I\}qr fg8}xK"Dq. D@X:I83$z6l?l;Y[DZYYx 7!r@te{Cµz3|~eL` )ڗ+ %7Qr%!GhB Fi?9^g ȂvԠ<I=@`5 mp .x^/No`$BA)?HI/WWv7) 2`eȾn k~%2^\A `"|j, X6!ETQpE|:xɒ}{D9P23:OQ<߃: w߇Qp A < 0#lrƁQ 4< ua'L4p]!gDAqNW8qM1*(d4v_jDWүdD7N4^v(67oO`̶qp'8;۬Ȟs+,ēe_z8 .gۮxpk%73`1Ѿ@-rsB[E %7]"9r ?S% r-%' >coBxV*G(m #0AuP,݃p_PSukX"-AT{0ݸhΚ b'2~\[&8x{"WT')%H!"b-˔̵pQȞK !+i, _}xH΂kjPLԢ)xɒȾH=\+NN[H Lס zH찷 s`9<?tK>h iazsq%iwɸ!.K-$A%;.1WI~Kx}~QlJ I݈w'Yw)혒 G.[-I/=[r챰>6# G!1v`-@+lz#lH׾<"xU.o+KnJv^d_$QKȳb6Bxpb |` G))0N6p9C]>A#5!wQWv"6C Z6}I\pXY޻4=pU^j%( /dRb_a|;rys]YrS"9w\h^$Uf/8 {G HCXBl=8  |[i6s Y0jSƕ%ㆈ?jߍd_ϺY1a9t|UiDޏtѭݕ’B0&%Oa5<% ~xBI_.JaQĂ \^@MÊKIdQGqϔ9|'qk "ySZP( 5 + B74P( EPӰBP(q#|F43a2292[(Si2jSZ$| %pHv6璔e,t_l@x,V'X 8'R?< *uq(B 8 p9֍+|E#tj(]160~DXO%Tx|.%2 p$mi$ keMd$ejsv:O`cV&)baAԤnp6|.Q~l"af yC#ɿExrEo=a: ^)9X<I] y4W 7؃j3gL:!dZBÉw;J\7#Γo}֐:Ӯ(~~l"aψjilG@Cp4BePD9 +'\^4H WX ކ^}l9|cd:'>UtC~p oaD2ڇHQ?=dQ}Vp& qmBpW~V -Gl o%c(P {tqz 6A p;pX#+T_#ep<[H<$C.B7.LzPm6|3dYo%k??Ia ~ر:^ҏK~$1Nq`aG?dأg} 𦱅c|bzs୎4_H)7@qDridDWte\Je.V"PMLH&sQ()&2c8 ]Ό3)`YȽBq\țEb_Ѹ,n Ʒum'|<7[_ yx9qe+<?noG[WtM*H]_ 2xn~(~r%|%k8i*bA&e\zVd-D+bx=JqS;.|fYp'Lȅ<_› B8oiFu!C( BqM + Bo4P( EPӰBP(q#ҩښ5K=8eMMٳ>}ŊUW6(=gq萫x,t7tO{ee%hΦ31rv}lzKJۿ֮XPm7ɷ+9vs 2s0{筷f؁6ueEsfUyL|a鮻}U9OzS+ټLuر[ZܹeÇ?tI̜Y_?&zpY-EпbǭMxォb˅Co1r=:k>Ǖxez{U{ý{'-_77i|i˥ZU D׃>ۧodve NI[ϛq-YN]۷7>̜Yx< -zJ/{46ZZd_/Whj]qE0i J6qOug dwޙ;eJ^egƌxcfiiLvkvm̙S䓽Om^-OC//^{،\iiy8wnM7eaŊ{'l;G݁v4*~hkZ/-⋧xf޼g?zG5M<"c?׈Meegt$K?Eٿツil_(>E 2u=E 5IDATw`[gl?2";q A<|Q|gB4l*Onn|ѣu0߷e߾QϙDEXw>+띑hh%$Z[QGD4φ[o3fmn54x ;f1"3Б#|)yXXxc_{=.3&c~7X_US0@YY~x充5kjM;bϗ{䈻{Mm[il^V,/F[ݻ=%%_K\sM6ms t{4o?ڪ]w]zaa… HI~EQfJ%Y}|Ԩ}eeUUG)9r_ D~RS՞ngYY@;uri?\d=-o~r /D/Nͻ>k=z߿p5yOQ}EE,_~;55n5vGfV kuޖS&On%s+s0c8f;0eJ탷l8e˖3]tc+Z5kjvokϦM {4^%K*˗wpCi[*ty׮kn RT8hPjvvK/޿Μ>w&elLx:QbJJR.rځ()IJسYd{򤧼s#q茜ӧ^~M%ڹIR~97<},^ٲ;w6%=aϢ~;Ȕuުu:mݖ LmGq$?Ǭsw^DsoҤ T[0K^FB׾v_?]n_g5v[K/-Q\2gNQM*OvVVzӧ=}yž]wV7j-xݺY cfdd$h۶sFTC\|gEEkVVµv=ػE}|>23mjPU͞bK⟺Ti~K4 ݖ`?v~ش|>[%7FQW,?+U &ejMoz'NY)oxz}<1gVF.EEN~ӑ# .aC='fn]}$?Ǭ.vD]\v6XaLU68W=wNi7;rm6,#QSp$&~]Ǵݻ ӧWVyfCzߩSJOM7G7_]5Mf˜_~ٖln\.MT/Qy/üy=4֦:M_R(U0KKxq^[Rr_z+5hJOFq$'OE@BHKs_W?;.aC}IIi'N:jjB¹pؒ_ ySR0 =[,X333/;aBfR9Ç]iinINl;xUYq2#guNSo-ܑgϞfS6mj=Q#-M۱;}z~AAԩy>DlV;o_ɓLq,%~}^+spoa2bDݳg˥iok;;uYXߛoκ\6gNY{ E]T4hh57n-6\-%>~|Fx#y曵$;Һ~6EIN;ru]3 f*/yS[1[ Oǎ|mW0$߱q>w=PQ~)55ghh^ځ泷x?qym?(Xvi;s&զM vg+~IɽIID DUuo$LL?H-E_O`E0[+Hяz/7oW;^{w',_VNKsݽO>))-8>7J/ZT,kWɼy=+^7fU}l;"/Uvmj4ۭ}i4]?YcGɒ%}?p%EX>дi_{meW&'xfٲSQGDdi;&O>aE |oJ3gs2a+^ހB!C`@ѭ ] ɉ.2 ]3򊗷ˀBu2B .!0")) T*UkkkvvѣG;;;N$1LKK˕+W=I\]]-Z?I&OҥK$CH/>Y  Cs3|xOTR8~6n|L@S=N}<~ه7n믿~Ӈз3ϳ_%&SLLLYYSddBN 233sww|^xqqkʕd"=^ԟP{;A>z }Է+}>jϿKUξ*رcE"QNNGwV}٦} 7>WeXTvuuݻw/77͍Hp8AAAƍc2H$dmmbE"gAݻw͚5`nn駟_\6mbX\.W$@zz'1BT^~=%%E*Tㅆ>\R޽^BZ,ܹ˃ }@ddd]]3LLLӮSС0f }zAA]`„ ˖-r999d9͛7uTg.{5얪ߩ11~w<ݻK@`gg㿽}llں|r}TUU/'MTZZJ >>^"@p?Yf  wvv^x1}f͚ejjz+V$&&d^9sL"1""|cǎ%7Ϟ=+ßtl;K^DP[ Ar2B55e ty寿+  uH"5#1T*HJ:2 PY fCx8= p>:h @$_%[ee)|Ą<p~_섃!%֬ =ώ_Tn:ggsjOb :@hgh~jGGիW1Kzo ݺuL&矉t~cWe8:::999)))''ȑ#`aa1nܸ/B"tuu>}ח|tƍc04IKKsT*mkk'L@\>pA@YYqjK_Wd2JUTT666nnn}B(--!rrr@*666Qe!8BB~@,a2ŕlm`b0y2\*] )T o l6x0s&dekk01{{pt6`DpuxPWWG5{ !- ~ fW͆v Z T R)]] %%bAe%LO %PK (=J%͛p4Oy葭adE9bD9 c@H з{Q̙ckkR8دU*o8PmLJ\T*zc￟?HwyW=m\aDxÇ/X %%d1J*ِ}}cT9eʔk׮iISRD"T* Zhmzrχ N+-}8&`GKݱ`j ǃ6S3. @,ަW T*ho;!, &LΜߴxzhF kj o o Gp̙8')K8p ++KRޏ4ړdeeeÆ S?_T(di Y'i^+d.{n2l6hteq/iu@>|ƍwy?~<ٔ)S|>~7rrrTN%ӳGiii'N4iRZZ6%kmm0` "Jlo\~>#G@ggJ"K]}}}ii)q3H5'`Bc#XZر@|llkoR)?>>@{.8sgp +hUCfp,HøqT rpv>|5=zi@JCm-)05 7ǯ ɼJ0`%"}9urԩ_}„ &&&UUUf}}}53>ǨMo`j/ރN ѣa6hha`Ŋo76U 9@ ss`01x0† bYBH/T~>,,Ʉ^ztP R 0o]h("D"X c:=iĝh 0?pil'oߖ#GDEEb\~]b/'UN]fdd4o޼_~H?{lddd|||cccqq?Yg@(p\###H$#""`O:<+~񊗷]RRB D;=B;@@~!01ڛ<_43 URnytQZ6Foiq/J? qqq_~e=zt9998qB=)_tjL򧒒?|)J Q*[[)lSS YQSWjkk_}˫i=ݵܘ~|[|'|R__K/mذ!66Z攔w|Щz<^JegguuuvvvL&3111//<$$dĈJ)))R7((H.쌌ӧڵ*66e ᄄ;v -[rsrr6U>g϶d999ZK]]]Ͷstt,,,5j݈_ilg;;0*\~rP(;wliiѦAF5eʔ[n:u?@a4W(fee]p͛:t޽666"uРA{]fMss3QO?ti&rE"Tꕉ{':| 6( {nzzOMM UPㅆ>\R޽^>^P_xQP߿Ϟ=fff@/T5kիWX(z-۶mQrT~Ը@  iܺ}͙3k=3C~T*mlldgΜJmmm -jjj*++RiSS%@ /lͭ@,ؐvʕRJ֖JKWN4D Mz3www?uB(//鵜~e2J***rtt)m6n(˅BaTT?QTTD,111˗/n˰vxE_熆Lv9cccHKKuԻロ`nn}{{{Pzz5jԜ9slmmU*ù>GbqSSSUU^m7Bv&#ۓQoJ%PxEEEeee d;w,YB\{y>>!!!ٿKaaaeeJ wpp8q{d0WFP(Ȏ#.bvJSLvH_Jcl׋j|޺uO>Yjպu)x{-\㵷{{{i,ogg#G9󣢢^ӧOոzڈpQQQWVoccѣG92//'>7,Jlo\~]7 5kJrvv~/WUU.De:&y}J5`Kk544[Ǝcƌr򬬬>>&.[7nܘ3gqi*z;Gq|>b͚5+;;8'N4)--M]0ƆL$>w9raÆUVV;vlƍYF49rtvvT^ h9?477߸qw!ʋ?zO6m޼yo ۷o͛7q F/T%&&^:))ITfggWڼƆ8{UYYCBQ__L~1, mll\H$į šCRSS>L1#GDEEb\~]bC۾}AJJʃnܸA|lnn>s̙3g؊<==׮]K|1"֫gFFF7660͛7{X,^^UTTرC&]|L}˗/GGG]r'H4%rr<;;۟8lٲ{⌌k׮RoK.?^ ,<^wBVZE0<^hK0}]vyzz2 ### رc.B/Wa5WWWvV>oD}H .>|x֬Y "۰Fl~Iϋٰ fX2͛7 ]+../맪Хxd۶m...mmm0tYzaEiB 4B!dH #B0B!d0 #B7O2"?Ǔ[fddOExvD"mʕ݃/XC}Ei`nnz<= W*?B=og^CCѣG@.h}䩆a~D3L"!GDh!Cm8w?/ ;aFFFF2eJjj*U8s4t koggRSSSUU%O?4aKk֬Q*!!!,+&&fG!kggRDߵkWQQr=z֭ͭ ޻w/̙3uÇ{eee\.7&&fܹ_u755ݰaCMMMRRROwtt2eSSSFFFqqqmz-LV]]ϸ4hy殮M6͜9x,vxxBAm=4FNjz;h?77"442BEDwVN_0Tݷm۶qF\. ~ᇎr~4_NR(彮/=ځ* S_ pu°SxEEEeeeg@zxy"1q%(X^keXpzxðSU*77w>>>!!!ٿKaa!9 :Q~]_[XX6`x#BwQpZBa!˳>>:88~ApϦ5}CCCII[oellG}=NjczRzE>aaoܸޛϜ9s&_v Go_)))2D IDAT?00B!W ϟ?z*466.XҥK~-OD!b}}%yzz[n߾}(G 4B!zL wG $Ŵ_vo)%B!/@{{J`ee<.!g@& 6LͶϼT!_£>u믿>aKKˀ"=--LJ`p8o!B:ytk׌͛/酅_hQDDT*xbNNf!FXB! #,!B0B!d0 #B0B!d0 #B0B!d0 #B&)QXXhЂ!BaHdɒp##P !`?)JBҒ;g2w.]"S@lllmmY5Bk7nܸxnݺ}eggs8///tBxG!^hWnF~7 ۷oohh@"ۨB!y.\o_zU(U]];B!qE[[[hnnUU:B!x622266633 T(E鴴4???p||| Bp͚5ϲ!Ћw]]]~mvv6ϟooo/J/^xItDUUUqqqϾ>!Ћ/ox@!-B=c #B0B!d0 #B0B!d0 #B0B!d0",٭XȨ⫯ϧy֭[322~Gurrڳg… d-")) T*UkkkvvѣG;;;TLMM=Z__}a2fg---D nںu+xyy,^*xWddK/dddjժ-X,VppryZZW_}Rp#F̛7oСGD"&iiirʶ6KZYY߿ {G\?{{{޻wy~z;< `0n߾-:;;=<<0攔' 32''HBg=yBa}}}1c[[[:޽{d/T^^#]vyyy?bjɒ%aaafff[lihhxbn/JSSS333̡S rF655}z/^ԩSOo}Bp\'4hݭpmB0++… 0o `2,'''%%E*u;Scoo/\\\sT*mkk'LGhUSSSYY)J,--u'GsS}}=q X"<<|Æ ---L&̌zDFF0 TjffjeeEHOOBBBEEE@@kvܹ6NǏ_PP kjjjkkǏ7nܨ~ћ*]{Ν333 MJJz![^H$l6fSDXJ4\麽~i5jԜ9slmmU*ù>LxoSSEڼy㏇5F]]6cr ,X,&ZRzQJ%/5R422~Uz"466^x1**xP(bȩ\.S? 677j|J3'DxÇ/X %%<}?r| kjjz\I^gg#G9󣢢^ӧOm?J*߽knnH$NNNeee0d+n]Sfffׯ?p@VVJz7#TlrĊmڧiTP'DRYXXN;w P~!C[[[O6qfv?gXfΦ뤹ƍ3'W4???p|||w0F=CPf]z@>OS'ɟ4btҥK_rvv*]K#G$v٩R@ۏSLl67ɡ?[MOO5kdz HOO[vu{abbB\511111ѾZD"$S711a2WT 'ߢ򩯯/--{Ά\nTTT*-(('Ϟ=X\\<`7oތ-,,:jBB²ekjj*322 ߿hѢTzE򛳊;vd˗/?~F]PPPRR2RI'M0O0_%innVA%N.+˳{Ih1//oӦMօ_jժ(ۏ5*::?RqW -WP:t(888,,,55So۷9r$**J,wڙjk׮}W W8ǵP(rFFF"H.GDDu}GWWNJJR*_}~=;U;Ÿv@+^_oѕ˱c<瓩'^8巀 |9#+^ޔ>QFUVV>C픛6mqѣG<̓BR/۶m#֌m-Trʧ={^! i\F!P^K!2B .!2B  NxQt?=TaiϞ={ڴi|>)!!'ellcKOLL"##?(<<*:;;tvv;vLRixɟX,VppryZZW_}EM^ fæ/_%K8;;mٲv`iӦ4˗/O_z 6VWWa0'>vRgөڟ|Ӕ&vӘ?<|M^ xhnnyՉ~Nz >*L7Mua bxΝ!}}},X~ҥofaccckkkuhddd4jԨ)SSJ5kÇܹDpeرc0"ER$=<<ȇ\.xB=؜?^PקO4ŋS .x-2ϩSmٲ*;UiPɓ?󶶶ɓ'cLjHyyyÆ #T=}6l qtsQOzu?7oi&rE"!lVVVFG/_?CҨ={L&! (-UQ3TTBU:Nu=މțPPPO~N*'U>TF?U---<<K@`ffF<9$$$$$d޽@#>>رcYYY۩'ڵfqfhwqqټysWWצMfΜI{ wzDr 8ŧNzwͷocooOrJii)ؠGn3zRIYYY~988544vQ .".^n:ppp6mZdd$[[[ˉZ'OTPWRܹsʕ /_1c~6켖Koojj"6kjj"b޹sgɒ%՗_~X\\L**^˯lvll,IRJedd޹s磏>bXk֬Ynݞ={9N{{Gaa!qy5"̳k{^PtUUUeee΃p8T9ЌQF͙3VRq8"xx8nK{TO\/[l!Q&F?UqE!CД5$N8P3TK5z=Eh}5|=[[[铰۹W  _ CϡJ%СCwĉXrO6-55jwJqP^^. -ZTRR=g>qܪw)WTvW_}UPtTTTY\\\]]vZ,NN?WZuBJTcǎKTݻwӤ_|9:::--ʕ+=T:{lddd|||cccqqȬ***RSSw!._|q^tCGrݻG *4V҈ih_>^t:MLLfϞ SP(ҔSc>TF?M?Ӱ\CM>TaeޠWKKN o4Pc4fSwa mvd0FFFݗO bٛ7o6t)r1CϼB.\P^^>k֬k2s}>_ܨQ*++ ]0BBAAAAAP8 IDATK l۶m...mmm0tY^F! /oB .!2B .!k^TGj +HDDZre[[9ƍ՟ȣ_CpWٯ<$$d̘1>~ѣGC8;;'My^\;wGFF=zT.cLLLYYSddBHR rQ۞={½{ G'Z_pлpz~uӧ[[[mmm?y}T`0ׯ~P(v˗/O_z 6VWWkSkuD R̓z{z wuuuuu)JBU([[[b"l8}J޽{nnnD"U㸸&kkkU__/h>C 4h޽k֬!mnn駟_^=jfJEQ/Zm4IpiKU/5~uR>|=z?؜?^PקO4h7}6l ݻ>>>555AAArfgddL>w0a²e˸\nNN6مTUUXݣq5xB ܿ={퉸X,kCGAL8T%z Kܫ˗'$$t`P/TrՅ۷oΜ9|> K _zU&o!:::999)))''8 w 9kzzq胵܉kXu@V*á á엪^ i~={L0aܸqD~1cIT r]'' h333b~khhꪩJMMMDiWW|>ԩS {?<@4rSN ۷oۢ>UuRI*|zBBBEEE@@kvܹ6@^H$l6fՕ;︹d2GG3*:Cp=`8te{: W^~TT;w\rerrrCC˗g̘AS<QXX׮]#7"0e񃑑ؿ"zr ~4HyigrʜpOϔ .HO<9| ЇG&%XYY<&o,J333Lyڵ^ˣTau+ák/W=8RdXd4cyy9pѢE%%%4{!ǻw… y<^{{7ꈫM:/A<Ѽ9ggPhggall~7rrrH$=MKK8qI+MXuP(\f6Ya8tbá_=¡P߯|֬Yvvv3f R 9s&yۼYRR2lذcǎmܸ1660N%P͓6544[Ǝ'O<@ν=L6m޼yo ۷o͛7_5ȳ77ڂ}׮]ąDZ/SгBFXrssiEw…ÇϚ5+!!A$a( >o<-Z_,=/m8_AAAAAK񈕕1LWgB4B!o2B .!2B .!2B .!2B .!2B .!2B .!2B .!2B .!2B .!2B .!0BaԖUVH.Z?~򬼽MMMݻjC L<9$$D,M;,]jzfffQXX+Lʕ+.A |OOx޸q1cz$?}R >x֭[WLLŋ$so[[[SSSOԔZ;cc;v888,]TPaXǏiii_}}QmOk葿b?_53}[?fff}ŋO:'i)))>2dȶm۞$v655}@$\.wڵ|LQ߯@ HJJz mh9cll ^CCѣG@.ma^~e''͖o߾JWWȇ~v`iӦ4HLӧO>}L̜9sƌ/--&7o^GGG%K8;;mٲŋ@nTS>_{/rl|/_m6b]`C{{Ks4n8&ASjddd]]3LLLˋkjjfX"aӦM,D"HOO?qxÇTݻwToݺuVVVK,;wnYYٮ]hcnn2bRyT$lvFF |tuuuuudB0++…ޝ5qB!!U jUjiZrXJ-Zn[ZRԊ^(A!@XcH!s dՄ]9NX_L~rqqq{\.h45v~Uqfl__t7~OO-[755#""*OKKq/ǣ_JRTU\\\\\ed<{>쳆{ޘڍizr&Lö~#|R>_׮]svv zw1xРA111ϏL& ˗/WT;w+ ݱcիW|H$ ]]]׭[ںlٲ ڵ\& ѥqqq^^^3Cr>qNLonݒF:u\n~~'s΍5*''Sf„ &LiqqqjqppŽzJLL|} p%K|gc޽{D۷o tGzۍizB=ulОhM8q_VXA޹s>{###Sqegg?^555ȑ#}zȑFի;<#59R~Ǡ jֻwT޽{wg֝qΞ=Vu:իW=<< N[o}ݻ3gܼyS544dggτH:& rJIIIw}a\obtV{mz 6]tB=m^x.!믿 22 * zXoRRRrɓ'Y/->~Æ ?;.d2{4㡞=ztllL&zNZZZJ|H #JOWz >l7=uӧOSx3o޼ɩkΐVZE1s8J% ʮO(J> d=* %IAA޽{<֫S<"R)^zQnkk.Qb{f\.ڨ[dv_ɓVX:tӧ;;;t:@p-"!Ey{{j3gΐb>z!#PW@PP%NGvر5k osutttqq1뾾6m֦տ+W666K.MX .P3f_P[ǩ 'N!|mĉV2K<6l.F7n`Jڍizrٶc0Ң)م H$SRWٹӔhڵNJ'''NzҒ&׮]/ҏ3tRtHyThcckZ-EꠏBX.`v'N5kWYYիw}9Nꫯw%RǎҒC,$zp8x[. U#u~hдnݺuQFQ⯩)**3fٳgy^l޼y~~~6m9ZZZZZZ233yKZ1]Q(Pwrrڿ?]nܸӉPwT[[T*K]Rzꩧ;>L4=S9Ya[?| ?* ߽{G}4mڴAQ噙G @mmm~~Eb2 Ǝ+|ImN*vxȐ!...Ң騫B=0uT>ooo?iҤϛPߤIzeccVTTнJ.rm3rBpZښݺuKمSRlnn{cƌ 4XY;!3OuuקMr=<< . CӶ_ʱcǞ}Yc 2 uVZM5;477?ֶ!!!r<s8kkkjTogurrcǎn.]j0N=i$P'LN9s浿ر^zxwɓ';;;7v;=a[?| ?~W^yy湺.^x˖-111W+**ڵkٳ9N8A KJJ?Ν;mmm EVVTXX~zDRTTJ߹s'##c۶mjԩS| PO܈D"Fk=!&{졞ikk5NjjjO>gkkR!gjժ{QqqqRT$YYY%''k4˗2L&޾}{Ν)QZZڵk F?<_Vo߾ ǩpڵ:"Ak3T:ILLd',RjkkEB999aaa /ALcEEEqqq*j۶mKMMt|C[ﶶ9sp8_~9<<޽{c:~K*v󰰰d++˗/wzS=L c*<<|Z6##귄v;=\/B,(gzcω?~@nݺٳg[:pBKGz ǗY"OMBF>z5B=!>>/ɉzEZ_Be/J#BO,(BY!0BY !ŘH$ >|FLcʔŋR/Nzh'tɔ)Sbcc?ETjDY|}}?~ܸq|Maaac}~]?=}jaf0l2@~YfQbJMHMcǎzt҃wGPN/vvv.++ͥd2cdrz̥b?#zWBNkvpBgg>ǻᄏqrS5ڎE-Gƍ{)#lmmG}v*]'NJ l3&L6ܹswޥ49=xzLKCm;wPpRRRYYٶmۜW9͟?_$uz*+LMS`F󳲲^z˗/@ee%wqq(**:t(UΔVZUYYIVٳ%**ݻr\Pzfee5tбcfdd,YdϞ=.]?0aBppO?ro\\ܹs?3gOSsNTZTT\__sΥKR/.uppOV^~77؈D"ӧ[rdmmrJZԿTB?weZ/L󵷷_du:]ee|PUUeBgڵ =ժzd^ ,X`ȑ<ŋ)))J?cӦMЯB?=7VTTtz3%pAAAee%KO.qssrrwki㣢ZZZ:ڌSGTk׮QRiBa޽ tLC\ZZڷo_{Է~۴sٰM@*RgΝdE6S=T xzzΘ1*Yzuyy9gyF"gq̙ .]p"""===̙+L&wYpaRRRǃ-8}+6 IDAT7oޞ={_rg@!\.%6lؖ-[/^j@.)BBBn޼I\&%$$(J >\֭S( Z644ĉZvv]]]OLi'O|7Cg6cwܑd^ )wzL|򰰰Bo?k Ao4'(+V$%%988lٲ%66Օ;].iM0}􎧰JީP:|BJ0ֻwT:$gφT4yyyT7Ju=X㓞jo߾M%! l{キfFvڟ~իn(66z3r1͗%5oŶ;::9JСCt ҩ__&u},A{rӳO>;TZf*.|mmybs=7i6]4ٰkGr=Moz+g[[bmmmVVW(555rj0M}7VRx+tj^8MwsszjII rʕsR @W+..2dvruM4_2::' hѢEjã |",gI$j&__ߢ""''n'|=* %IAA޽{fWdl:ZNݭRBlF?=A~ewbr\.wذaC ),,<|ŋ ;޽jݩ!ݽNf̘TmztJ++Ç*ʉ'FwŔ^/c*imzё c8 "##ƍ믿t_xm̘17n`p,\]ӹhZzQoht T*Uvvرc[ZZrrrB|u:]A^.yҥ>(<<<&&VmllqkfooOm]QWҒ&׮]/:t\Qpk׮{_ko`h9~D`'N9sxr漼W_}^*N8ј zMM(š] MTiiOn]1N'===KKK]6`:Rlnn4h]17iꫫ_>m4.1|pǩhΝ;;aaaiӦO>-\N]o? 666'OͥN+ 33s̘1!!!̢Ύ( Rǝiii_ׯ?eee_5k6nL8'wȐ!T疖NGe}OfdEX,5jig6o9~ 'O/wވ?XfggӷқPT؄LԦnmmMOO7eeeZ1'JE"UrrFY|9Vݷo_XXXTTTFFƁz!7ie2Yjjjiii~~>uXq9rȑ#ԑիW T\\\^^lٲ7_jUBB½{%… ֭o]VWWgccC=oН;w222mۦVO:7ߐRjuBB–-[N:nݺ3gtYj{S쳆*y%yXe;}qqqqqqI$M6y{{3sVǘOvNӪwBHWΪ0'fʹ~)"6./SLٗ/_~ ~#F Ŵ\lepN2O>?lƦ@6w~Kquuݸqo]__O~[r9~ 44J)A^ޮ)NH^^^QQQ--- !޽{D۷o}5 ܵkWdd$ǣg&ǒOvNIlJCraKo:qiiOۮ!t謘ΝRZ͛y닰\l׳E^Ǐmvv6?#G$e_ Be3]8ٳjʆAЭR  vzdeeUUU)|:~~ncoےMbkk*Y٦6t&~vSaJΊiy qMNͥzMM Cyi}z_LOO'兿Ά]8b.[QQw@:w-u(J>[ZZ&Ν"~w0"}}*Nr?mkk#sS7@ppJ"}4deeooϏ7ȑ#>:krB=tz1M743!m{WxO5i8ҳWUUݼyloo?|pcңr11-Rlnn4hPBq1OTt:\YZZzڵr˥7B9L>;Hƴ\5vXX'MgpהD]6\>}zƍ;}tO.]ڝ95uRKI4!HB?7aX&;vt۴B:tכN 6۴LǙa@oZo֗^z$gOHHXlѣZWn{Z. KH/jq0~'LP___\\l0FV[UUU[[K_&,xښn0"K$T S(vfi=k;ꫯwޭj322:wTi`c;СC׭[Gw[[ O ӣaIWfOB_}ճ>Ǐ?'jF<g_b4x۷ooŃ{Y-6LئgJgT!VP(WXe˖XWWWﰝ5}=SzmrmbӌO;4Κi[[[!oc\./))ӧ@ 0eVwM_ϔ^v4:4Κi훚iB=xr :dȐÇ_xkS+۬Mc6M=0f1_M%$G!'Μ9?JLLpyLe;=kz&fmsi!5S=6lflB @??\bu؞m޼ w|Rztz#ŝ;w0sL[gjժ7n|w ꧄tr'M/S鬩_>>ݯGm߾Z?\rssmmm;0",v̙ѣs9z͂|<{\.wvvX\VVFq|}}O:988DDD<3mmmϟOMMUTrʕ+juRRV_du:]ee|PUUEu``YܚNl]rcIuuukkݻwT*UMMM޽nB{E {{{_|YPHRz&ZT*>|Vy&doMXmOO>}uD"P(? E^gI$jѣB0::Z"Dmݏ j}:xIMO0o.W\;w.usf}ZT|>O_U8%=:' :}tgggN'nݺE)Ey{{j3gОx,;99UgRB ~;w@ :w` wwf}?uʊ5&^ٰkGr+**ȡv{MM U^SSC'$[#+<rÆ 2dHaaÇ/^HOTt:GGGTNtG}Z---iiiiiibxڵ/CtWx>v;tFVBGҕBԱ#uq Qeee:n/^tss3f̍7 SgjOL;99T*|n.F$ W^{stW_}ߟ0=!~J=vؖzbړiwE]ACC~RSSSTT4fwwgR1OjA'ݯ߄ݿ>Gc&^VVVÇOMMU*'N,//X.W$$̵XĉgΜDEE%&&^p1Xw@N:O4TN7--M T!C\\\tH#%ǣT* u.ӧO7xV__ogg'J NIG.SI|~`` UX]]}iӦq\ÇRW\.ZwVVVTϴi O+$^cǎ|>ҤIyyy.J^bfɹZڪ$ IDATښݺuK7w'ǟ9f̘L1'9&7襁6??ѢE Р 4~V r?~&LD̈́aeB{rgiik  M͛M"{{{z?'ߚk=2ɓ';#GMyK.ٳ'22r޽mmm_~%)S9MV'$$lٲ)4Mnngv5{˗T'NPwLG۷/,,,***##ꂃ[[[Ӎi;wdddl۶MV:uo!Ljժ{Q剉2,554??s5kп#0a _zuРA˖-oIRHdeeh/_N}Ԟv֫p(55<1a^paݺuԳ)))PWWvZBhOjcklllg;'!B;JRRwikkP(ۖL ''',,LPtSLLfpdd抇 mb۞LZmUUUmm-}cm%$$,[lZ8-@?SG=lmmg}ҁtO||-֭[7{lKGz ǗYko,@~bq@@H zx{{F!EX~TgANNNaaa666j~pႥ#B_|EׇxBH?(BY^F!, aBbpF!,aBb,Ð~/X,>tPff)Sϟ?@DD /m6H޼z%rӒwEkf5K~͞=;I똘A"8)͛7geeϬ qF6g*G?Ȳ,GNHHy󦛛ߪb?#3r1>ݽyt\lӒ >>>=T9[v gΜ9F#dq|}}O:5rdLi٦_f”)2BpҤIIIIT۷oIa+++|GQQ}f* ئ;_BnBwg6=㷶^rZNJJjL,pi:ůD"ٴiwII YOXL8M߹sT*-**JNNކOLLܹsg̘QRRBTNhmvϴݱYf555],!m8l1_fz?=sLHHHHHH,uҰUZ{2&y7~ئڮ[NP$$$PGOyyL&KHHP*2L&c0ڵ+22mzviB6lؖ-[/^j'$$dO?T&Qc->]'4ϴݱ3wРA111,XbŊ;wЕ?f "==}ŊIII[luuu%|6&Л~mk*A0rܹ۷oϧ ?Y^s1>=4 t% 7lАBi2o*RIe(1~V_~ZV=z4 y O6 ;3mwotL7n\vvu:]SSS| ]zm_ KJJ<==#_# 7> _fBaccÇ 6ЛB3\tv`֞)M79yU{9{D"67}:\R߄Y[7 6?o>N^WWB3>]'4ϴ=3ɉ)c{<rÆ 2dHaaÇ/^H6m8Se!27nhmm8p`~~~sss׽͛Kk 1WwJݛvtG}S՚>]9SgŴ]s;'SчZńxgBJNL۝#Sy|kkk\Xس'Μ9?JLLpa iÙ/Mw ٦nll<~ r&ڳe4-]ԘMHKζʹ4@믃IN*Ҵb.,44T,L<977:M'c<ڇUvrg曙pA@@66zɓǎ3;& gJ̄)=Se鯿wyɩ*--pkZMk* ˄"J%̈́z1>j:!!a˖-EEElܹs'##c۶mjԩS| ab׋i`j8٦O .[ٹ(%%`n OvDEAkk ?zv`协Әd;߰kbxnބ[{vz[0߇%Y)Ad$44UNCT̝ oGk?Ow/tx1QeQi}>-f><`NH`ϒq2y2deѣ0c_A0r%CZZر\\`G1M=;@hia1_[ 8Ac+KΆڵrΆW^.]{{j~<=*+VRY |„Lx`m `e\.mjظ_//X23Q7> .\\]GZ[aNhr>._w@*e\.رP] 9957! ")~ejRݻ %%S k`zv x%テbXvpuww୷HLw{VR 2sk1l,] ~~K~Ξ˹sp (C0_sdj&L~d hmT*2xipU;Uӧ}x]&vuuw/XYȑ-76jJA&k?b*?epv u 4X\\mv6cĽ\x-HI.>!!n݂ӗ@l, -11=RR 5.c  ` pvn/ON HLO?[[ػpz׼ƍ#V}feC햔} ˙M| 9bb@$dHN6 ttr ^ 9^|o"WLе~4 t773~!v6O 76Z 7nTU ]Lݳt:ػ-kbe=߻{6m?^vKoiYY0u*$'CRzv6 c0 R)D0b0z4ܼy`?=45`oxP_r9WSOkk+r|pZZ`ʔΟA$kk>rsA15AUUp:L\.k ['Aq1̘\.{~rB/wg;zwFX/ҵ2ivv_}*x~ gۙmΆ_WW_>W9be]~y˙|>ņgm<W/RzG89 ۭ޽ttl/a*'24" PY~A^̥kdjsӨUvv0m< l|7NNV{Ylu;;\\ d|Jѱѱ}JBe]E"P@j_卍 ,'I55AA#+V}WEi[[x x wNeΜn=DXAi^-G4vrA-B~Cʙڍ0_pϚ8Ȁ!CΜGz֋ezۙm1A5\ #F@9 ld%SCXm^orGdߎ}; 0wíр'p8YY 7E ^5%BJ0b|Y,݃Cv@V頭 Z[ IЯ> gj7wf*uH$ V eeg*gj7|W/hl*Sa8ti2ajl8E"SK/͛ByMkga[?ျ;ܺO=Q`||6 T*hhcvo~+V/_=P1_k9Sdߎ˅L:tp7ހGGhozc@L [O?~5%J 0=J!0Д )p \ --0j= ?"ۺS? &…cb!;\2ԩcĀX >>w `rHN7߄#2m7|O?}llalR•+nn7%.@6ɀǃD| Lڙm\.ç0~`g|55 o?[ʴv6>/zczgҥ`ez- n0lTWOÛo(glg k-ƣ-eg-JzvLOlq`xcѣc v`eBB,Inl|=Ol<ow2#:RS~ j50t' BB4B!d7LA!aBbpF!,aBb6 '''߿?)))~JŽDebo޼yoΙ_~k$b!KsIOOȠKd2^:cD(PeG!,}qrr;w3JJJvA@ X`ȑ#y<ŋSRRJ%/Yd:>%dӦM%%%۷o#*ɼJKK2888DDD<3mmmϟOMMUTwܹǏ̙3}||n~zH Ooٔ!k0(!!!((]/J;7nD2lʕTܹs7l9_)zRH$ڶm㔩?""/JYf|>~֭o=_b̙3;^|X޼y3//?rJjhhL B!S^fVtpT*P(?zP(H${m+IVr j555t k}D"Q&VP2&&B!diazKjkk`ڵOv)---iiiiiibxڵ/CjfH$FVHDPTt:GGNJ ptt>jtBru\(B3|QZT6773k/Za,GQh ---:P3S=UUU7o|~U*UaaԩS{Ó&M:<\.>vvvRkqqqK.%!>gZv߾}aaaQQQܹVPdeeGddH$h4'O$WTOBB²eFjoݺe0={DFFݻ-77/ʿUV%$$ܻwΎʝ;w222mۦVO:7IRB!dF!c!2 B!a!B!a!B!a!B!a!B!a!B!a!B!a!B!a!B!a!B!a!B!a!B!a!B!a!B!a! 6?o>!ꮁ~!z Nj!0BY !0BY !0BY  +t!EφB!a!B!B(:sێٱ̌?vlB1cFv 5Ɩ&۹kh{kkZ /\&L)*U˚f8PzRiZ f\.&X:5]IW 'aɒFOI-1"YoaC\JII:٦9Rr?ar!#ZgC"NKu# ut䵵ΟWd7zb.׵,կlg1&ME歳\Y|yR5+oo76n&Ԍ#q}}m v/1:^Zpr $8۶k zMX=筷\oP͛w?Y3NܖYh-_kx2ZxA_;X_s0>p8 ښT[w…ի=^zڪMwLƍuu?hҥ5kJxyn祗ZbމO׮_o:;qp@KK[%NN͛eoeht'N(ﴴ߶s44hwvvVz7T*]QQSff޽6Ul0`<7^F$AH@4 H`^D$Q"OPPl;c0`k]_q2v;kB _Ƿ=3 Ƨwomd9M\w;lKO.uOٳMkތ-oz@@aDcyW^vCH#g00~(KTݾܾfwvX0eTӍ%KG6ͬPP;w֗sNn.(0WQt3f#u^KMNf ko8!߇@t{e|^$u4ûbENhX2hd8N8v&鸑Cjdaacj5 V-_ޣiR~H׹sMsP-i&;)$!^Hֻܾ@@e7;lK׆wWŋ]WV׮5f7iգGݙKV(jR- IDATرe'N4)/A:pJݻSM3PyO4DŽTc#{7~ÇwWY޿ܹ)SʫF^;]~@Ք*#̰aZʚp LעE^/vO=ue̘-[)>q+zYJ<ҨuPPcSSgzdyyXO?mLJj?I#7oo_ҹs^(cnrAV6j͚t6ѷƷ#Ry߳>gεՁ+9ΤzHId~??sϝkGre;/55IvXQ3jTiuunNȑKVCg=~IڇINI 1.$کGh˗Fl D^ЉK~?Feߺw2e icPUG׀tE)EE+>$2D~uPu:*ti:PUPPx䚛ڔ-O.*8ZY>08vpnNl6UE'Tl8ĥ|̢ߎ󓴎Z'iX XgN?J4Di}]nJ#'Cn *èz&Dk4M.At#hF%< ;ӧ=v{h4IIϚ26&3sW/ܹV;}yԶVEj).+|IIž5%E1oㄓ'w}ȌZ?f^g63g'3 $0>.VzG]O|۷ێfMWهj4 W ׂLU۷w;F!PNn4SZyx^S?5+Ks >̎q<7S33[45qj5]\5lXRe('[W˲† .d/_3|y/^[{VQQ>ez~S()wv찳0aTtbqyUO7o{hJJ7l_ -t7ֺܞ=^/'goH޿?+7WWUZؾo Xm6Ք) _R|ՕVTÑƝ ̫xAJSTynl_#F֯ qڇE\更GmB gOKA4#FhSS1 lܘiSZMK| az/z=jzu~Ǎ{sgj6oMԲeՉn}`|AϷGjB!zK5xHB=?tB% aB(a0 #B i!JL!P`F!0B!0B4B% aB(a0 #B i!JL!P`F!0B!0BItBN!7@~IENDB`sup-mainline/www/ss5.png000066400000000000000000001731551166154264000155440ustar00rootroot00000000000000PNG  IHDR) IDATx{TSW7 `(! /t( * hkU*ԀhHU(ZR*R;"}чDKWCa $^=779$ NWfo;g? `&< c0 3)d`0.x`0 f0`0 55uѢEzO}fy>_PP?P(흥CbbbƧŒH$K.UՕgϞ>* 3T#&&}f|˗/zD"yAffrSXX{QWϷrttܼyy[ =Ddgg[ZZvW\\ P}szxxttt7,AnnGfϞ2<K. 8;;ڵ+22̙3𰰰>E T*S7ˢ%::Z(&&&8po輡) '9Րd666݊eÆ %%%&HR.{ncccccccqF322BԄ9qƩST*P(ʊ{)èO޽{ooo_WWϫT*Ju֭ԍlnnnnn31>$==>"Jݻ(Q߈?>Cw1 F%$$hppppp2$$NoQI7>|boo0o]9NGGGXXXPPP{{|4oqOEDD͖-[~킂ݯ5;w**//oӦMBp{͙3G79s/L###DRw}g$III>3H_VV͛5D"#JO<)J\KQN=?\*&%%];Çkrwwf @䭡'!!!77wݺu|>ߔ`0f4r׿ ]tiɒ%VVVԯd+V Jlg}R+H XXXWWWضXRI\}駶G}I:B/>D*TV^^-vvv^z)--M*nٲ:իۛ [b.p!TRl >En|DV_x3fZ[['UUU~]vEΞ=ᴴ48aNuuuZh |ooF!M7::_,^dr*꧟~V__ѣ۷ooow}755UVgddٳ믿5T*KJJy睼<4WWWT{0gHIIXXXT*;;;EAYYYjj*Hp,,,J%R$f^r]\\lmmǔ9s8hӦM{ObX[nMNNfe]L6s5ko.}V\bb|߿p'N`{lvzz: ((HRiZTPQFSO擂/.))I 444?~X}!bl6{ddd…֭svvjmmm{EGGX,3w\{{{gggg||ܔа>rR K!ί'OXYYٙ&@OODۻ555O歳U(Μ9v"m``dh4e {ѽh0.byhhHrn%N`Bظlٲ3fՙrXLL.722222RYYk. SfS8(J/xDɅ .uhmm̚5 򆪏*0t'###O>}4߳gkv%#|W;;ݻw;v^ծY#V|;w-[fk n\.Ij5߂ qDlٲxxLVSS_b'T,--b nݺUZZzf3,Jwvvu66dX?>O̬F)++{cxիlUnܸAԬ"Ȕ7.X+c 7kkkN^|X[[[[[S'غu+_0jkkkWZeggf_}UM\v-9266Cg@UUUDDn i}r|ҊO7,X022j*4W^l6{ժUr\iӧbX. ]֭͛#51}}}_v"|>ŊdpDQӟvcD~g2jFkk7x@܆ؘR:s)VSZp޽{O>d|||Æ ītnF-9LΧ~ *..H$ӦM{MzqPPħ˹s;h***oy#OQN E>7K>[lp8jZ&֭[Nψolĉ$11nrOHwߕ=b0j Eff'wncL 3j ybvn>xbo]a2L`0ǃ[~oiL 3 L xQ`0O Lx`0 f0`0 55pG2CO7--FG1H///[[ۨ(Q/Z߸qvСCnnn111FӂbI$KʳgR UUOs* p˗/zD"yUnrrr ]]]uoeeyfsm0 oܱcG___qq1mi1w\SvfFGR***jkkLҥKλv튌IOHH7oVy{{{ B X|xMoQ\?a_}ՠdddtww RyaD7np8r\wClT}R}=u׼=ؘm:99ۛEGG_Q@JJJOOԄXYYܺua"sJROO^4TIK_dɦMj5ͮ~޽KO*Gu}_ya0rHpt I5mmm6bDF A/>T/~_x0[ZZ&$$.NƧ# jooO U"QtI7>|ٲeo]PP5==}Ν&**//oӦMBp{A_o333311qdd$<<ȳT*%݂>??͛7k4DB]9''G*vwwƧO FFFN>}i>gϞ^{ҥK)GGG>vvvw>vX}}V]f >zH._Ν;nnn˖-{C!988jxyM-(A> ;888_$-Gu0x_y1(M_QnHuuX,DHj)(,,,_kkkkkkzzy0CWSڵ_8rXTTTSS*>"""Ъ*꼑֧(G'tI 3xddDZ_y>fWZ%˵Z-lp[[V>}X,5ڵkn޼n:_S#|>ŊdD}||Ը\z{{[ZZBBB&TSu|:~/ƼFry/J ~D"Q*FףXp޽{O>d|||Æ z^^E!O?OESO.Ν;vFS䍴>E9),;e8Zdu־}AccSO<9}={JZPhjjinn~mۨ ddd899q8iӦ۷S'Ss{:;;766Ata<F9qD"ILLKNNζm^z%FWh/ϝn|IZNC}1f0˗/V)6,a0X_0SL X_Ei `&(`0da `& < c0 3ii`0I jLYYK 뛖FHQJ#ף?~ddmTT(-ZroܸQ\\Ll@Soiiy!77ib$ҥKjueeٳg󆪏*G' 9q_"y\X;v+..3-Νan ȣ(T*UEEEmmm\\)Q\ti``y׮]gΜL5z,*>]Babb݁7T}T9Y>'ο~Æ %%%ݐ_#vvvzpd2hbd'9h@TUU]p?tDW_}5(("hƍG.nT*J==={{{ s|aoo0oE7T}8F' '99oۦMybFGQPşT*MJJB(;JpuuMOO߹sf7''G*vwwIyT*JR… {A J[(o߾#66ɓFQ?T>acce˖~l@陙822 >|gʤHs|444$$$[O1Id+V J(555 `S$K]<@'^|^<%%%;;[$_r4Wnoo'Ƨ \QTAAA~~~###l6N7T}8F' "44ƍZӧp_{r9(U*O?wжCjkkUUUU``nMﯹڏbI&8 /44LӋ/XZZhZZZ'j^hbƬ*www.))h4rt?/sT*KJJy睼<4WWWl3KoeeeĢի\.ĽO4(}=.Ƨd3gYmʕ)))&Ƨn}}}/_>qE}} IDATlfAAA*Jբ򆪏*7"|ҍO7x<:')F轄Tv =44flHAQLV>iq޽hX=w\lnnFՇP( TQ0?|R_%%% Ǐ]$qx*#Jm=~^0o :;;[[[B̙3mmmiTXE6Bظlٲ3fՙrF7e0%X,@_S .띐D|jm yxxf͚WPyCGScO擂~gggB^j!rW1o,L|[yV]|;wܖ-[|wvQ/')`ddӧO{y.]D78L ̅xkii',Xp֭;whZY'.++{cR]]-E")+Ҥy }= kkkښ">^!""',, {_v-9266ԄπxZUUE7(Pn>) Zxŋt=]Pv+lUrlzI1W'd```<׮]pu֑C'Usrn:ݻ( / ,㑑Vk\]lM"5/bʕssso߾MFQzpƺo\O n%R4(<… '6lC.*/\՟~)|,zt9w\\\ܱc4MEE "o)I'f'l|[om߾]R} i.v['O:Ŭ(~0 yaEEšCFGG\O&Ǐo۶a8##ɉL6-??_Vo߾m۶^zIR*G9 +))1Z5_;9Zd3\Tzԩ7o爂t^0*=2a y^\|dܹs!..N/Sa `0H͛qn̞=n/^l⭻S&wJc0 Ƽz%MMML 7o޼y䶁FGG۷oOv^`0 fR L&x`0 f0`01 4;8|> ^iii FG;⣈_hqFqq1"MCbbbƧŒH$K.UՕgϞ>* 3y(%̓&℘8ٳg[,?S0&&Q ]|Sޝ8o6~,ٻwo||<|lԘd:|>… gΜ~:__ϛ+8ݼy>cB__?yۡCmF;88W_*4Q899l~̙3u֣ **|tp!{{{>hʕ7T}T9n|f4 \.… p?CM@&+ݶ5q8gΜ LV@w7 =EW؂83VԄBH5B0+++..ӧwwÇS8PyJ1ԭ)a¯@S/ JKKƧKHHHQQ`eeeHHt򆪏*G' ת[[[oڴIV_ݻPoQ\NT(b^dooo~VfO)M=vm߾}а߿IOQy D7np8r\wnWWW@T*> ...]]]j*U*zzz5Q&:gΜ+W2W* N=J4]Ln:>>bJӧ:;;.]zX,nhh0k5_5/_~6abm/_LKݻ'NЕƱLHH(**2k.#,,LTGDDHO(A>Q@znnL&28>)PGijjbˋbر#55n{q@קRZ[[9NZZϝ;ݻwlll۶mÓɁ?YZ>)QP>~X*Λ7oǎRT>i)z_ Zo}}}}}=\{'3fF5۷ooiiȘ5kցLW&rMWWW73E000 VXAPhkjjH yORzyyyJJJvvH$ hiW^N*~1OJ aptҼS1n>gOUzJR(cxڵV;88X[[;{l"Hmm-\ ͳ5WQL5M=]hI7>|R0YZuH?ӦM@VՕCCCl6f4̨jzyӂNхn/g=`||HZJZZP*  UVP8sL[[[ FaR3m5kRh Eccef̘QWWV7ȣn~3o", {yyxvX}}V]f8 /<~(̨:PhZ u ZPi_fe^C,|`ʫ㪥X,^`[JKKܹC=Y&83V,j uuuX,DHj)(Pzg[wpppqq !t5׮]#GEEE5553*""944:o)QI+>|R040!mmmZvbXW^yUVrb)O@>}=7̧ܼ[n4WIڵk--- Dz{{[ZZBBBġMdʕssso߾m۠4tr8)4ׯ_H$Jf(<ޙBSӭk4UWWC80">]Ν;w1FSQQi}rR(I7Y &[Ǔ'ON>gRVG8Anݺo>@x)fEa)O>|XQQqС+W|7fނJJJ֤ߌ '''3mڴ|Z}vRԩS7o4z *~NNζm^z%Fc㼉|weee^ KBffK_̿666Ν#*qqq݊g6,a0 ̛7ի={6ݚ/^[t_̶U`o4557o޼ysr@$ /o߾=-zEi `&(`0da `& < c0 3ii`0I jHYYK ~/~%8kFQ̟??226**h|-r7n(..&]d} }߷oߦM>C *6..Δ(jjj.]400k׮3g>@`z,*>]Babb݁T7T}T9Y>Qd2hsH};;;RɹIİ=5=ņ JJJ***L,b{AӋwݴiS``՝;w>#L@MMMXXޙF YYYqqqO>>|8>>bmGgb|n bxxn2 €DRTT488888XYY7TPQ(PA>I%'OG'3hgdd䫯 s8ׯëg[_dɦMj5ͮ~޽{~CBI16{Xw}zѣNNN+?y<^tt[[[aPmJJJOOԱXYYܺuqb1߆R%H||їD"=֭[-,,㓒B!\nkk$tbD5z;;;GGGZz4"j|J[zzzfff&&&SԇZ!T*JL7mڔ, nsń~ؼ,**ڹs-[rssM1~}}}_|ŋ|fx+R~'s/Wf<B.U*QeYYYjj*HHzjtt4uqq5qOQ=]3J_Ϡˤ+2l̙k֬wss[reJJca JjQyCGM^>ƧOfj͈ ۷0b:t<iӦ.BߩDZׯtww~ 6ǞƧ_R-<)Fܩp8FUEW)(nMbcc[[[ի?3BAVZHO0o1 FI7ohooj2¥E"ȔiR]35mmmmmmM@Obccj] GjjjBg@UUUDDshhhUUuHS@V|u “B7ԹSO7l.:uP/-PwqL,;vڵkMiX,,+""B&.o1FQz缼7=z_n~bS_~]"(J 5SQ,\p޽'|2>>a:h=ݺFr xuu5t3i)ܹsqqqǎh47`֧('"t%>>^TzߐKzó֭B/AIAI1͛7gee :u*99yo޻wscc#qO0o1ذ` ׭c0SlX`0oML)`%1^`0 fR L&x`0 f0`01 4;攕Q~뛖eJGQ̟??226**h|-r7n(..&]d|N5~Z~0b{AS (hm5EMMMXX!!>>~7n܀zpPGwww?|p||]BBB+++CBB>*t3') j===~oo/ 33SPۛ9Z?a_}ՠtt...]]]j5sTI6{Xw}a ߆5ԤD"BoٺuE|||RRP(+mmm۬7445~"ŇOgO666[ly tָܹs``ܑTk=?9s/L:z~Y$yџ?Ç_5QH7?3**;;{Æ Bpɒ%"(//Iۃ< LF5Ԥ d+V%l6>ST_}H$O,,,M,At5z!z򔔔lH~1ԯ^xj.pORl?iP)E7t3' Z /}[[[u7񯭭kEUUU_Z&8 [w6ScX{{{h4ZϋѣG&n݅ʧa)ƙܹs˖-;`0aQ 5TbXXXlRI\\z5::庸ښFge]L6s5kԻ\2%%ٿ/PfAAA*Jբ򆪏*7"|ҍO7|vvvIII !obl6{ddTk޽hpΝ :zj C-J)JU=*eXqpn/̯iT[XCE6 iZ. r P(-[6cƌ:S3ȣ~1o", {yyxB… zx.CCCYfCTPQn|`ddӧO{y.]rttx<?zH._Ν;nnn˖-{:yj~k:z‹fq*V^gMN IDAT1m,Jj'.++{cxիlUnܸAԬ"ȔiR<5rmmmmmmM@O/bccj] GjjjBg@UUUDDn i}r|ҊO7,X:qGFFZW^l6{ժUr\բptڵ 7o\ny/߿vZKKKwww?2$左7/8#;?yڵ>>>xo#W(m5c \.wrr-((زee2ٳgׯ_H$J_4z .ܻw/|'oذBSk4hp`rISO.Ν;vFS䍴>E9),oٲjL[n۷O 466:u Pj훚|||?~m6atRԩS7o4ZGO7?_qcYYYçNJNN޽{&% Eff'wnc0lX`'fϞ o/6E 3ar4x<ltt/K+1 4`0 1 44`0̤a `& +@jj(;B UUNi>g 6TTT| ԣ7xC rx¼IROO^3=uVBppp_{n</::zPKzPW\]D7np8r\wnWWW@T*>77}AT~~>… r|@sxxX.Ĩ<,Yo=}ᄏx"E{PB哴`aj7)":;;mbqCC^#]<@'^|^><<ɓ߿x"`LHH(**2\0OGGG">En|PS:U*U^^ަMBa;::bccO< ;;; DpppKK PNNT*>yT*s0@/j…zKR-===333GFF?J999CCCRT*9"osޯc<$''o۴i;Q/|yF0Id+V J(t555 `S$K]<@'^|^<%%%;;[$_r4Wnoo'Ƨ \STAAA~~~###l6HO(A>S׮]kiijg899⋥EW{cX|[E@q:;joo71ݝζ6www6MZ[[{ VSf> |Rwllc,Qzjtt4uqq5q\=wYY/ SbX$"Mף_~)"""\\\lll|||$]Mkע~ȑ#cccQQQMMM wvv i}r|ҊO7tAmmmZvď---p^ge˖WVV2kBGi4eS>}:afΜKLè<*++/^laaakkxb`O??w`L0JX-T ~D"Q*FףXp޽{O>d|||Æ z^^4xaT]]駟§̢Os;vLTTT߀)FZ|ҍo|R@z?y{Q*jtqqsrrmK/i4bɓ',q{(Tw3##ɉL6-??_Vo߾:Ç+**:4::zx(2|}}uscc|[om߾]R}7Q,K.dT` K̯޽{znxb_L5X,VaaY&!̯ 6,a0//{r|xxiryo޼i̿jydff~SOmmm/%n`~;O LxQ`0O Lx`0 f0`0 55Uo@YYl?O7--MO3APy###lmmGh".;<<|ƍbbEzKKCO %H.]V+++Ϟ=K7T}T9 T>gO@KwoYoeeyAݧhDbccZ痔LߛRHqEGG[YYZ)),,tuu `|Q$&&}cΆ7ر`MSΝan ȣ(T*UEEEmmm\\)Q\ti``y׮]gΜL5z,*>]Babb݁7T}T9Y>]ݽYgT*rzRpRaÆ D5k;c)?رc|MooDC sd2hck4B-Ȩo EMMMXX^IB0+++...~x J#ףhnnnnn31>P/EB1<x򆪏*G' IKbbgWWWggZmIO <=>)))===PaeeUPPp-hoo0o>bJӧ:;;.]  &zF믿ׯC0ҋqqq1Tm/_|E5w=q℮0eBBBQQ p:::”Je{{{DD|4oqOERRR{{{FFƌ3}]bb͛cTjbO bȑ#MMMl6zPcDD͖-[FGGB!k?''{z4E044ox 1> b-.\XRR[/*?rTIE5z^߹sg&NϟB4Jp֯_9% |_lI%%%?{@ʏ"0/*[Bko^䚬t ,Z:ڭ +cUڲKPQlG^bhrB$&9yÃG'=<~8<9I 󳣣N ׿s>Z^xa~R[6gb$%%}wexxحuWiii\lDt 8Xw}vy=00%TyȣGI{ׯ7n,^o0\zjooop@ۧv{oo̵?߫aZo ̋zvڵkrtI#2k}>~jj***ȼOAkN=s(g2~_MOOCC\xxnw19?y' @|0NOO{-?mf1ft|6===ׯ_`A{{;ϋjQz.w.92==->...44̙3LŋKKK^371LE6TnQx\$O' 5`^5^'|<{ٳgRG}_| kf\סU|Τu:̏7nknb~'(Nf.bB Ǐu{^<.~.1π?ĉ7o޴[lYwhfAPPj駟P]zb1z^}UViii\VY5}= ///___Nj/}[XNjO>g:ݙr|̏_>55K=zd%/@Ѽş .߆'''׿5O>:? 500`ϟX---M*D,Fc?|=*v~Zo@GG3 0[n)QEi1z/㯿zzzz۶mM=I/o"D"@8HSOʅ N8aٚXǴɓ y?T*kjjZmgg'0f~c~@QQQwH$VU0kf;uBPTE'3_~Jt]]]uF؟'OD.]2wMLLt\)j1>o:11:?httj/@8\0?`I$yUWW[BXC K!//oΝe%%%Q[n~իy5gDgΜN5,Q(xbqSTzj Hǎ ;7ot87<7L [YMMM} /ÇGEE;w˯+CsZ- Bx(MP('a BP< S( 1mBP((..fvbhhhpLJ'OsO111JE)J|츸8Q\288xbbuuu̶<4۷ow[xT&&fժU*m}RPZoTn1zpVPy' V>-))qrF7~tUUUo޼%""D"f_}L&3 Վg޽Zl| d>crgh&i472-u[[[jj*+55d "V{}299yڵaHSojZ'!>)7kn18IZGx\58J=s^ǸxY5___oxk jZכLN?&gFO~9 `X,:ѣGfh4Wv|R%K.]d|<>9w߫a䆆bfvZnnnpp\.ǯPZu]Fp-[j###7mTTTı>?%%%SNxzpNy'-;bt(yz:zy~"ݻjQ"vh4:*IalN)Lά󍴟ron1P4Ex|>smX(]6d``@1rGh4_~\>/6{ZuT}LOOD"@ =s rRL}ius׳êWsݳjYspuC[(| Q󍴟r%u+vT>lpp`F슀9YP ihhxWcZV%''k IDATsYJ gpAeeer?? NB~z3;fXrrrz{{QyҀ஠$O')x8y׻ê㪗移gդU_8.RP9i? ѼEc^1 뭷|ϮW!###$oݺP(FGG~T@/|믿޶mFSqAl6xUՌ[[M=>)ZoLn( 8^&O'u^am}=JWc`ݳjQ9`4:={+6qš3fGά[ǕJeMMVd+Ϭ35,Q(sS(sRɓ'ܹSLLL2 ACPyJs"??ʕo߮c]䡩ܾ}DD"Bn:ry|nQ(Py#Pϗ\ a[nA|j_'$$CmI1 q9g?k&s#PYY +ۿ`pƙKFGGsmyFsZE[[˗?sl6>)111** `0@*7v yGKX4 WNZ블JeppPpcpύb{A34(222VmrLL̑#G6QQQG8PyJsqC=h4NLL8z4I5111VWTn듒^[[;>>>>>ҒooPǣQ$#Onflxx8""B&:jY!}BBBT*Ջ/쳰j{>*ԸPDDD(XV4̸2<>>q>5߈Gf͚;vXVXV7o|]Xt^QrJ醆V+\-d>crgUx8+MMMtuU<00044n:hOKK5r~&8Շ333ύ| ३.(("H322FGGZmVVkn1u'i}yqt4ؒr!';+DWAAdڵkWhhv >͛7oބ˛\i?~T*-[~R#&}) u5.}}}eee-*//o͏`͚5|ӧOKJJkQ󍨟3J_/"hp毾J& yjGw^V[VV`O>Q21GjYh47ndZ0䶶TWjj*Q#Tªojj***LNN^v-xfd#ԿZlO \3)))IIIbΚxL8IZGx\5ݬqH{{ԔnuҼIII}b~:~tV{055uʕիW v l}}}5eZM&Sgg|p3|#'`0X,N#l4CBBx+T;k>Rtɒ%.]lL>;հ[8bfMvZnnnpp\.GF1dxFYp-[I56m***XߟD@ccS3qX,.--fݎ u...44̙3LŋKKK^371LEOTnQx\$O')( q|"((9/ {>:!&n;Ӥ3 Z"9KC[(|ǕQ󍴟gnyڎ 6 a;]0g7҂k^}UMViii\VY5}= 8僂r_BBBFF\'!_~=ǎX,99971(Py'͓fl6wwwgff3?|tDXMS2IOKKJ"(++KY91LO>MHH`Zz}___zz: 00KE *g|#:z`[o#|vEݼJ3122Lb6֭[ btt7AQy8m6\<j .S .8qf5771ig'i}ADь/U*UMMNbDz0D2o޼jZXXC把W!Фwww8p <7vcZH\U]]{ŋ̟59o=HTVVo0\q&bҥ\vFG\GҼggg;eĨTreGPǣ1IZ_h<4 WNZ블JeppPDX,p?h`1fE[[[FFәM9rH^^=**ѣJ#ף@iQ!zy杇>&&fժU*m}Rkkk[ZZ xT; Ty䉂UӍҌ GDDdQG;xQT_|Q=x@&$#븒}]D CD"DR]] hmmx"jQ}gaaa===p|TqP*ZViqd2Yyyy||||`3j3J_hޮYfǎVU,͛7߽{'WvT>r\Rt! W Ϙ0yGްR充 brr2???ijjJNNv҃߻woLL \bNKK5?> $$dŊ7n \.ɜ<}3g߿5IZۻuqµ>)!!!dpp0###%%EFGG:S-y'?OJr޽pjwرo߾~9866*jrr233x1L]v}\Nu\7oT*[0]vT*`G8[H>~nڼ|rx+T;*{>|pΝ(A3&7|v}Q Ռ266h6nȴ`mmm//T.%F 3>U/y硩7ZUpg6SRR&''b157:nq̓>rԏ{\o0\zj.jgl>ޚ2Z&i>8əu_x[`X,:ѣGfh4Wv|R%K.]d|<>9wfn5(EB6ڵkrߟNyǐYqռj###7mTTTı>?%%%SNgzX\ZZ HII1vxTۈ$O[\5ݬq4{ 0::h"nGqkYa䉏O@@3Vn4FG3)L 6i>8əu__ ̛7ǼBӀva8^nojt:ڌ&ƞ/X\5(}=y'Ņ9sixbiiӋcy|sёXh\9A:Վ5OyҌEPPs^0?|tBXMnwZIgdZEXspcoIc+=(P9i?у\ k;*X688`0 #|vE,J ohhxWc6YV%''sYfc(Pk⤗GiI5ׯ_yƱc,KNNNoo/FCkkkVVV```xx Z[[iGʓ>i4<'fswwwff\._~=#GGդ͟?1%4T*4 |ŚӄE]q\rF7~ '(x+vT>owTTsvPqfcɷnR(n c(X5M=I/Ѽ 硑Gq…'Nlf0&71`$/H(0q"WKJUSS麺E󱬬,,,L"̛7jbФ?|bjjիpФwww8p <ǏW*555ZYp>"HP2y7JJJ< A^^Ν;= aBP(?o/^ ?,JW^#s>k BBCCSSS~mww{D]P( #Ei BP< S( 1mBP(Ao Bx @qq, 3G*>}y /޺u+?VՍDu\>>>!!!jKjiQ8Y㮯Spύ@ee%ܾqeg".]ea~Qzfƍyyy\hkk|Xxx|}9WStE'%777&&FR (QA:Վ5OsPhj6pU%\ǥT*dคH_?筧 ʍb{A34(222VmrLL̑#G6QQQG-S(ݻw=Gc%>U/E811$ĬZ^ROJzzzmmxKKKzz:rCjGʓ>crg۰[8+cccfƍL F֖ JMM"Y8Y3Uͪojj***LNN^v-xfd#ԿZlO \3)))IIIbΚxL8IZGx\5ݬqH{{ԔnuҼs/ XwY,ׯk=9=Ig7 SSSW\Yz5G3m6[__oMDVz4:H/g<\-`0X,N#l4CBBx+T;k>Rtɒ%.]lL>;o3Kq "!F|ڵ`\qP'=5J_cȬ]Fp-[j###7mTTTı>?%%%SNgzX\ZZ HII1vxTۈ$O[\5ݬq4{ 0::h"nGqkYa䉏O@@3Vn4FG3)L 6i>8əu__ ̛7ǼBӀva8^nojt:ڌ&ƞ/X\(}=_OOOD"@ =s rRL}71(Py'͓fDtݝ+ׯ_Ha5cccgLɀ&=--M*D,F_fd2=}4!!i}}}@/mr)Q󍴟- `[o#|vE*fcddm[ o}=_~_===m6k'f'XZ͸cr…'Nlf0&71`$/H(0q"xӿKJUSS麺E󱬬,,,L"̛7jbФ?|bjjիpФwww8p <ǏW*555ZYp>"HP2y7JJJ< A^^Ν;= aBP(?o/^ ?,JW^#s>k BBCCSSS~mww{D]P( #Ei BP< S( 1mBP(Ao Bx E= IDAT@qq, 3G*Pʳ\ a[nA|j_'$$CmI1 q9g?kssz 7߿`0lLҥK6V=5J_l6777߸q#//K}mmm/_ ϝ;j곳ĨTr%*Pǣ1IZ_̼ ګfZYu\J288IvK(s#`XЌc/8 3Usȑ<{TTѣG1{(}={ݻwXqC=h4NLL8z4I5111VWTn듒^[[;>>>>>ҒooPǣQ$#Onflxx8""B&:jYA+!!!*_tcrgUJ2 l6gtA]7?LOOD"R}فu]z oݺݐLp'Fff&oWSxSN9J9\x{{I H$Z6++ 57:nq̓><񤥥;vW,C ԌK$.\ƖX,CeffNɴk׮Џ?|t͛7o޼ 7a4SSS111??~T*-[~R#?}) u5.}}}eee-*//o͏`͚5|ӧOKJJkQ󍨟sUUǛ7orHbiW_}% BٻwV-++[`'|¨nϘyG#Zn5㬌i472-mr[[[jj*+55d ⤧fV5^299yڵHSojZ'!>)pl6$%%MNNb+:kn1u'i}yqtj!SSSvI>CbqRRw}gX_?Q?qM:+`rի9___oxk jZכLN?&gFO~9 `X,:ѣGfh4Wv|R%K.]d|<>9w߫aq "!F|ڵ`\qP'=5J_cȬ]Fp-[j###7mTTTı>?%%%SNgzX\ZZ HII1vxTۈ$O[\5ݬq`. -|&'O>cE FhtT;bٜSYi?, ܄͛c^Yi@0/l|xaa5:>bFg ,hooy1W=5J_>GE" Ņ9sixbiiӋc>####&)::h" u<kI$myĜ̏=Vn۝VDiҙYVy%ۡ->XJ TΨFOu3^7bmG 0aϮEi5 *|&괴d.+Ҭzj/cSI/%222: 98vbEAkkkVVV```xx Z[[iGʓ>i4<@+Vfswwwff\._~=#GGդ͟?1%4T*4 |ŚӄE]q\rF7~ '(x+vT>owTTsvjX(8CGG31[n)QE1z/㯿zzzz۶mnNzyOjq '… yyy'Nl=`LncYIZ_|\QQ155uU8Eyhһ8<PlSN) J 7Eڳg+bq )9ci?ѓ[ǕJeMMVd+Ϭ35,Q(s7xӽP~ܹӽ ( fcRt?;wa BPp˿o=".JS( 4BP(ކ) B6LP(Ǡa BP<ug #JOν>J] C]]6D,]4::n`ȣ(0k(._<66dgg;wggg;eQI͍QTʎPGc`͓>X,p?h`1fE[[[FFS䘘#Gmܣ=q*4(}= U⪗"zhphjcbbVZU__R'%==v|||||%==PG@IZG(֬Y;DFFO(4Dp___&7?|hd"HWWW;>֘{$OT"##:$$Iuu5ŋQyR|DDRjzmdGw}W"ttt8nU 1;)````AAev?駟zLʆ#""d2(J\./,,T(nISSSrrW~~޽{cbbJ wZZZWWG !!!+Vq8ՇrLtdffӧϜ9s}\b4555˵wAAAmmk}RBBB$`FFFJJV?b u<[$#O ;v?sGgǧOR*{B"?|1vPocǎ}ļLlli>㭪R*çOV*̣(<'+?V*UUU&IT*JZy޽>ܹsG9u=,,,ܹsӧWZ\ f͚/b>>>͛Ju+fT>:(yޅ"++oݻv:y$r@llÇU*$۰[8+cccfƍL F֖ JMM"Y8jB_?CXMMMEEEk׮όlD7|S: QI &f9%%%))irrR,]YsC,,,>>fqѠj^7LQQQ#QT}X^x4>55e{{{~ҭfECCCqq1h&_v-77788X.s#I#2k}GE" Ņ9sixbiiӉ?Ȉd,ZPrCj'i}<1;5ޮyA4U>_.BCCb󹓧c}R<^^,>p9wPq9a`&''Ϟ={YTG/_Q͢񆆆W_}>kjuZZZrr2iVZ[[7lڊύxL; TDIҒzj///իWwU#/7kRT,7pˊ9'dlllkIz/==ПEnAǵw}~:~}K/Anm7wjX(8CGG F|-B1::(X/`}QJJbt" dD"Z.( կ~^mϙg.\,zjXP(gP^~|lٲk׮ ;4BAi˿;N-wܙi"`o3.JS( 4BP(ކ) B6LP(Ǡa BP<uGM#JO`[n^ F}Ln߸~PWW಍3K.0?X5(}= |ƍ<.Q]|yll,<<>>w૩vˢꓒR rCj'i}~yօb۶m.]jnntG~!y2s=>8՟9w>X,pƩȣ(ݻw='%>UE811x(yhcbbVZU__R'%==v|||||%==PG@IZG(nwǎVU,͛7߽{Q:ÇFL&z}uuk ݷoߒ%KBCCsss~~{)}=J:uUw/Ky!H$HH```AAev?駟zLQ f\DDDD(XV빘XϤ|oԔaS 1.^#G<@gS}n=33gΜ>s "{{{ֺ.N'%$$D" fddhh#Pc1Oު*R9<<|iR j*u0g"܆Qkh47ndZ0z궶TWjj*Q#Tª[ojj***LNN^v-xf"ԿZUAZ c6SRR&''b1-57:nq̓>V /bu}^NXXX|||}}fRs}&f(͵[eCCCqq1hS_v-77788X.s;I#2k}GxkMW0GgϞ={T*裏^˗/7.Wxg'\4BCCjuZZZrr2iV_ӽ<f~~~.\`6PBve˖ ^vMgsm|RBP(wyǩΝ;+WOLLܾ}vU+OH$R(֭Z---ϟ:Վ'<9 M+ ߃f℘9!Ogl("texu 3>w૩wJꓒR rCj'i}~yΜ_~۶m.]jnntGG7F-gGߙ`Xx(lAy222 VvLL̑#G~'@TTѣG1NFGq޽{+Q!u(Ɖ ƣCSϪF'%==v|||||%==PG@IZG(wǎVU,͛7߽{7lodÇFL&Dz۷oɒ%ov  IDAT?DT`DYf q=;@d2Yyyy|||ѣG/!99wߕH$rJ醆V+Z Ac_RPe˖O? GDDdQ&Ըx7CԔ줭`n 1ۦuuuq9jB_?CXu뙙O>ssTSJ'%$$D" fddhh#Pc1O}ZT{ T`A=_9d͚5_|ݻ}||233a͛7J{}Ν;?s-uPR EVVݻwuIeÇTI&Ը܆4ƍv-55啚Eq~&8Շ֛*++׮] 4k}RBlNIIIJJpWtPc1O?iX\ZZ HII1vxTۈ$O'Oi#̛7 ZvQ¬BL&X,E rfBо {O+W+ɺ+Y9== 0z``@1hY~ ۹|^U#sCpϑiH5L}<鑑Xh\B:Վ5Oyb;z*?_|2iq̸,Jgnhh`XxZNKKKNN"ͪQxyy?^|}}}}}1tAAAYYYr/!!!##?jQZiT}feeoذv1 뭷\ʊk2utॗ^IW(]3ouGGF}-B1::(p|_OOOo۶ 8m6\TH$0ƕ-S .8qf5771ig'i}AsLSɓg~GV/ @~Cd~E!Hª>pJYYYXXD"7o^uuj-,,?~\ThN./CY/`}bX.]~?'HVFa?@( կ~^P(K^^'T}?? .0Ksf\԰DP(2/^ ?6,JW^#lٲk׮ ; Kwqjs玧 [MMM}BtpVwvvvvv ط]P( #Ei BP< S( 1mBP(Ao Bx @qqN% 3G*+WOLLܾ}vۻ"22rn! źuVkKK񹡎G@ɣ>Ga JCs֖_aV9r$//履~DEE=z4??T@iQzݻwS["zh`<4111VWTn듒^[[;>>>>>ҒPǣQ$#O_oذ7ސdSSSLÇFL&z}uuk ݷoߒ%KBCCsss~~{=F gGJ266Vz.f}!00`ٲevOz= 99wߕH$ GDDdѣGB*g'ٰaË/xUy7L455%''K$FVm=zZZZWWGF 3>Uy3gܿ9H5)H$sk !$$@bKJKR j[Xֵ@]e]>~?-]ʷATjtrS!@rqiɜlÇwa$ӓ +"7ub',D, ;J#jٳbŊkd2˅ |aEoqq\./++p f(&֞B gG===+W,++9s&NX. _{UV۷=hhhK͇m۶MPbQ3&---o^zuIIs='Ha0<<ظ`B[][[p8 t$KK<@Rӧv7g9~ѢEݶjRL V;>>.j㤹cbB[RϟX__o;%kF3BЩOsM}DDX,|_ NT>5m j4N@hh(BNmL3͓awׯ߻woCCl^x?8 ob)WpF~iYj3JώjG]HvwrF ?|Ç%oSO}K***kAΤ4暠pNQkkjjHj)(8ǃx<GQJ啞.JBatttjj*a;wvT}TWW{zz%''WWWSF=E; T3͓ atuuI&%&&Z~'H$gijjj'T[-<*gՎ ttt$%%<==띛s=g. Hӡ{||l6_rf ^OhdɒoFPڽs7 3\455 '… jPy }=G}tÆ cǎL_|BSҭF8!SgJyyyVV޽{FcUU|LPɴCL>44t~[V ^ .]ڸqO[[/ Gi'T[-8$ <)`0S 8< c0 40`00`0NoHee%$#HweWLLLAA]I#PyŴiӖ,Y斑a>{L$^xȑ#2i,\.( `v3gffΝ;`09s> =*O~t(f̘) _~/N{g3<<|ҥ`˗bj5oȲ7t^G, 6lΆ_5&G"?~ѣO:8GQDEE%$$w#ThQDGG ^z Jgddlݺرcv3W^)**J^JJ lGN{IdZ)n[GQTT4qyN:ryM2eΪ|_ǡ3%<13\zM/655XBd۷oʺ{. ((hǎNFGqիW.Q!u(TQ»BS/fΜYQQP(gJRRҡCFFFFFFΜ9PۣQdZE(~~:[b`555O?+W߶mJFwڵQQQbxٲe?|gg'O}}@@ƍ|Gii)hfϞzX\'N>W:ul}_ׁ@\\/d ͛}||j;A**^@}/º x 4eʔbLrY3pg(5O\ꩻz{{ ]DbbbKK MF U}n=--׮]#1z.zC:l3ã'555>>NWDn)2OYYu>~[nxMwV?b\_VV&XbQL(}[ryqqFrb?׋pBÂtPkZj߾}m@CC\.']R>,,l۶m b||ɹqʕ+c{c ~nnnOOʕ+fΜI@RRRܾ}{%%%=D"Va -zڄIHH#YXjB_/XՇO>k׮9sZi:-UgP(hq@WQ' =EXɴ>1iF>wܲeD"T*ussFgqȤ3/gHIIˣYzlpAaa! >>^՚fTnQv#ʓi}vnR[NS0~:z1 h@ |t/8P@@^,D>|999>>>---JL&h"aNTVS ãVT*fa6C'AoooggL& vss[v3 QktuuzjJ6z)Y[l5(}=N}L&>k#""bJ?^XXhuSj4N@hh(BNmLt2qt.TGX c+P8P?qo5C׋pC 0>>~ÇK$~:y$0.HT*2 QC+TUUl\.766611q.]V#GLJQk:TVV.-j=uMMMbbb\\iR<x5(XֽӥRP(NMM$L5uuu?sN^ގςtOOO??jHhGʓQ}yR04(='H$gijjG?`yB'Md^zGgq=tn>=^ӧOnqrT*]rܠ d``#)) I#<xYvdɒoFPQ=;w(3Jsm<8(.\Tv߇SQ<6l_;vd2znh4IEZMM?O-h)3<++k޽F >ȍt{vR(dZ!yg֭.]qF;^0ܸqHӝ={2z|z'nwyxx F|ܼyKii`ͥ_RR"9w\p̙>:7vta13BpÆ k$H$Ǐ=zbbb>CG4(}=w >h___@RRRK/vv[;vn}+EEERKIIPۣ)~/iL!<@vEEEg1 }O:rx3Dq_s[寿:u# p NzL6s̊ Ba>S:422222r̙$BՎ',Dky<J#pBNDٶmJ:wڵQQQbxٲe?|gg'O(=JSѣQɀ7|R@uuTVVVvk׮ SM=]zClSӓ +"7ub',D, ;J#깦 IDATjٳbŊkd2˅ |aoqq\./++p f(&=?i;u֭[rXr\nw hy2?EΤKNN΍7V\gۛ%[Psss{zzV\YVV6sL:u}iii}իKJJ{9DB|0Bs nll\`BMHHp8:%NT_/XՇO>k׮9sMa4-UgP(hq@WW' =EXɴ><)pT#-???f)!f5@ Oj'֞BSom;--OPuBbhwjvbx* SRQa7^o z{{;;;e2Ypp; jYh!]]]}}}l3ZR͟??00vJ[2J_Ol>ML&5b}Ǐ/,,:!j5MHHHgg' 44DrCmj6OIѰûݻl6/^xƌVX c+P8P#?q8DSAoN}44gx?Q$)Jx ޞ8b{rӧOtREE_m6/fI}f&$[Ꚛĸ8:3Ҥ:e }= _<Q'ҭ{yyKRP =j2~dΝz>##U鞞~~~ԹnOю'L`Bia ]]]fyҤI}'$@ xg,3%Q&XAz*vIh߹y>sNt_T^vgrAAA@GGGRRn))))K,o EIIIkk+Q0JTsM488Ht> m 233jOtz> ǎ3L/"`[7pRUSSAzL)//ڻwh<(r#ݞojKҥK7nikk;x E,Oԣף'mN޸qHӝ={vnzĉvD)r&D.{L$^xȑ#IJ,4\.( `v3gffΝ;`09s> =*OYWeknZSSs)F?e{\ޯH!=.K_3fdff ׯS@&fusc+`׮]pu)#G,̈z($$ı,KJJJn޼7::駟֬Ys]7X;EV[UUu,:Q֞>>[lrF7zTk׮˖-{;;; 4)ٳWXa0AMMO?}XiBJ ",,`0g(3뎪r\8޽{7>}j?&);;ÙÇwuuΝ;ٳĖ^#?… е V!RHKK>:u7ߚzOO+W߿Rrb[^СCpuxLIMMU[c:uurHwCݴiڵk3333Bѱy-[P( 0{wyglllӦMV6TΨh?:b?ND֭χH>>>JiB޼ys``;Cnҟ)r϶; 6obnll\`BMHHp8:%F8HLQB[?}t^^ޮ]̙~41/ZV5JZ)pNƎ Z:in)2OYIl6?><<ؘ.> 66O?uuu=i)ж#M=/T*:?5k%0MX)h4V*IdCP*z͛ZVRy{{Wv|$ITTԉ'FcOO3f'& )4Ν[lH$Jnnn4ȳ5ӯO mll ^xq}}=SM}@@@JJJ^^c# Zl6rCmjUL3.sh3nnn2x4P힏4uuXhQE_SOlruuuwwGNJ)LF`?EΤ~!@7| pqqaѯP4/lqzy0ƙZgMAWWW__!ݤPTyF~}L&>k#""bǏZPj4N@hh(DNmL3͓)ׯ߻woCCl^x?ʯ" [=Bng'`0%w͙Q~>a"mGˊD"R ,:c-Τ4ƙZgMMee%̢P$&&љ&ȳ5s8x<>n+==]* T8OTS_WW#;w,NOOKNN΍t{v<g'SuuuI&%&&.Whִ4'JϟO|%uXhm󉉉秧766,^F366M0տ)Q~˙F~EڎGT^vgrAAAD>϶80g;w(3ΚASh/\V~gk~G7l>vdz)4+ݺh'ؚM=E}geeݻh4VUUgnON EL;$OCCC~j`~EᆱP(%%%rt3)0 … 7m` V\`6,a0 &<<Y"̚5[z',?7`0D@,%t:'|=b`0 )Ii ` 1 qx`0 ia`0 Ϸ]~ޑH$|2\O1&&(Rȣ(PkT}ُ=H$x#GeYh\nQQQ@@ӇgffΝ;`09s> =*OY31. <<|ҥ?Ț[Ԝ:uOWiiZfe)^F__?1S\v˕[NT9r@ggFP(Lwu8|$=_~ãr!nh6n=<~[nEMO@ Վ'''ƍ+Wܳg u}QjInll\`BMMHHp8:%F U}^yyyv튋3gHShѢn+!E;#ĝVpUtPSԱeLȓ[M7fR__fs{{~^ﯫHS?tM:)_~RtYv05eFlș1Ov9;Rn޼jU*7~j'G"DEE8qh48?<,jQTVVs-[L$IR777kZiQzLZ8xz` Z?^XX(HH۩oEK5MHHHgg' 44ΜrCmj6Of~ /// jfՌ;M:q^^^_4%ۡ-~A͙Q~:PG`دHQ"HTls3)pxee?&$&&љ&SQ4(M=J/啞.JBatttjj*'a;wvTݔlNOOKNN΍t{v<g'SPq0ҧkִ4'JϟO|%դO40%DDmih4he``#)) iMTΨt?goQW|Jkמ}Y.Dlss7(8ASS щ).\Tv? SQj)4+h'ؚ ~_5,(//ڻwhπ)r#ݞ<ֿyRhƙT*狣͛7zxx \:,47nܨ***tgϞ Mzkk ڈFq .P\\f͚yF'L)9S7(=~i"mȧD.|>?33sܹ̙3|unQ(Py"ϟD] OAxxҥK5[n9u=RWWWooW_}$54HRxW~b[gA?7v+[nRãP]] ռtwmkk+--r@ \=00@mN8.ϖ-["##;;;-iQ~Rף`ogϞb jjj~+WLT*U(aaa}}}8?SFџI_wT}wrww\V{n9}tAAժM&Svv6χ3Շ;wٳg---4FP#gB} CR퉑>uԿo5W\ٿö]zCvӓVHsCmOQ.y2"Ojw.j;;;=<< rqXXئMzƍ.'k(XzFYjX,޺u9$&:&]d2[rԩS׭['-EzSZllM׮]I}A܎͛7nٲzi~̞=w۴iUAFJIDAT31Oι?lhh~׭[m~}||J%~jG哓ݽywyP;?SFݟm_wlޢeW3Npcc  mrmmmBB$$$Б,A,5CHO۵kW\\ܜ9sF6FEuwwړP팀wZ6>>>66v||\ UIsCmOQ.y2"Ojl5ݤqH}}N3VwRl @駟:vGqkI/JNg͚@odddEEh 쨩h4V*Id3 T*z}__͛7ZJfѯPH$'Nƞ"g`w7lW32??$&;wnٲe"H*\#J#׳8dŋ3lfӦM111SN߿(xVk6QG%RhU*U[[_V#!ԧd>""B,8ph9~xaaD"!m9-h4!!!P8 =ZXMlaI'`0`w;/h֡9Ӄ31O-oI _eE"RƱlgR~MMILL3#Mף@iQz^+==]* T8OTS_WW#;wv)R]]痜\]]M(Py24O4LAZ5--Irhc5Ó&M"Lɀ&=11Q"FxE5fll,::hHJJxzz~hq131OG[,i;*Ryڵg}8?⨜ݻaGi NLMpBffZIPFB_TsMXF#<ngAyyyVV޽{FcUU|LPɴ}͓B3wU(kii!G7opqq)--5 uXhoܸQUUUTTΞ= &uÆ ~~~mmmu\ѸLBQUUE(..^fͼyFҜ)tEE_SSRR"R d2_˯~z9=A1'~W(Q0RFw?y[g~֭;sٽyPr/V* ׯ~~06f:sfoǞ}l΍ 6l\F_)+Ʉ]wPyzyNH\'1̝ڜw/_;o'0{sFlAkOwHuu#11ӦSZק|yG hi!ɓCCFWWVkh޾?@>ر^]Ej"3ψ˗?LMܗ/. 8//~d:do i']6lpᇃ%˗޾k9" Hx..4}l}_aBq4ӆ ]V>@ʬ,iZww6'|1~ptҥ>φ7oy!GRSΛPo?H\]9<:<G6|ըNgjMsxQPWq~eKO曱CQQCZX`Ӧiwjk/_jk/]qȳgHvqO%'_?nwuiTxKFE"/FD/]?K/]ݱ& $?y24WWNt@QV8 OƱ}b=[uŮ)ĸ{{wcg Yppp떾K{ڵА!(OZ? #ޮݳga}59uΛJa>??OLybbwO>Q=̷Qj9ܹcسVr+oh,]G&$Dh?cc),L@^o803vFbgL4//P twcWnB o!'Nyl3:jLM=| Ҧ]4:m{aa7tqrLp;Z Å ϹzuzKT?)ujŅ3}{iiDeet^^"W_ EΌsG_U5Wn-{>^:ݐ1_i+RO"Vӭ{EΙA_m d6h68?z@>* _p5zxp "k@]]ږ[$m-Z _1SOGtʕ~?ر[;dM6ot!$*q|3@$ f tv L(괴nԊD=6 α5rcc7?͵{>ul ?Z`? Sv{hh2nFGMcc&H"$Wk |LEQi}bL<{ad. ОvJrʕ~j2GhjҘՕ8|sѝ;o648VcoL/ܔJyEN>)#Crm XPT곳 Oף–LR?:j"FOߏ/_~~fJvފ+qzOwQ1n2|קp;w ]hp\8o|twaZBqpDАqt8>nzdA'_~h4=;קrJexUHOL&.fNG^-ݷߎݾ_P,&%MJ}u~Æ`//W'bz矫-OjG~[C&;$ [oJ={n=䕬k;x &OP癞3Ld2S Cp9A"OyOO ׭ \H<8hx}bFXYHmm̹s1EVOF̙wC{Pvli]\e'@u~"'rȉ{>7n~_BR?)1:j,+?r3_$8_ &Lzihho:ѐH\|Ν;0`~I3  `7<3ٻ`0̯cfj0 qx`0 ia`0a `1 qx`0 ia`0a `1 qx`0 ia`0a `1 qx`0 ia`0a ` `~AvbIENDB`sup-mainline/www/ss6.png000066400000000000000000000731041166154264000155360ustar00rootroot00000000000000PNG  IHDR) IDATx}Xg?'D\tk- .m֞BXXpŠO.-_JaJKuժV"[YoBBImʁbp~?x3!3y$"+XHD1<X4 /BL& ?O/Sե"΃T{Y~6Tbboʚ6mzgF#;V˔JX,.++HHoCRn& &''GGGs܆R~~~~ggT*x:NV2zp%Kl۶痖6m4VSSS^^n2dggsܒzcbbBFihhpT*V(Jz֭[E'O.,,dj.>_PP?~ov(d󫫫.]ļJ2$$ʕ+:j_4gJ2 +++55h4 c`` ֬Yӓ¼rQQRjwV*TN"dddp8=|RlmmeܹsJرcCZh4.Xcm޼yٲeV555!+bLx9997nܐӦMJ ۛښ{яA?7ٳgϞ=TX,޻w`?xBr9>sL?!ԩSg1CLޣG \A455BL&͛7G?ٳfn755rGW_}a69իW?5k֤رqJMڵk&stdO?j677k4 ܃~z.,,~:!dڴiBHvv6upL&@ q\6 |>$ 9^~ƍB{{:tfv;r1sDVK!///""r1/uyfyyym/ogg')A?a^OY:>e":=sAǏ~7Lt]g0vX,jX|7 !~~~֭[I3]<)))---RtѢEroڴɕ8R B*C B%#L wuu-]VD"5kc`})SꩧH$|>RL&S}}E|H$?~MMH;q[llD"x .Z FFFڵ[xp]:yܹH4sLj}a>P,..NJJ*,,ϟY?55uժUUUU~!^__[oIr*))IOO/++lwJ%ɄBZX,YYY/xu&LXZZOPPвe6oW^^zꜜ7NR鶷hʕg϶Z/_vϰ_~O?;Bi6! XlB` 0kX4 a i5\B3<?hAqq ~_x}HDdw;v===h ?Bbcc_}A{`G8p}P(Ql&px$"؁lB` 0kX4 a i5HA` 0kX4 a i5HA` 0kX4 a i5H"dʠ )&&>2emڴg[oϡr;Vٳ~9>UUUwy\FIT.((׿E&''GGGs܆R0iҤŒ.BVBɓ e2YccZ!$%% BFQRi@TnM T*!!!W\tVZZ*$$eΝHbŊS۷t:iZzuxxBB’%KZZZmF-noor%%%~~~iiiӦMl555&IP$''[,>_]]w%G?Nt{i<|>??~8!$%%%((߶Nai q0?W?}6<{쬬BG&(ʀTјF~w}K3gΜf*B{͛7/[jRΝS*ǎAHHH~~UF Ƭ֔ݻw?㎕GOffիWSRR{1 .IOOKJJf34)JV{nR9(w>|XTfffR󐑑p2335y<^NN΍7ryzziӤR)ø#^B3N1Ln|||ppB)..m/~tz\<~J=܍7bbb-b8::z޽ BN81w\jؓ'O:^W_utt#GDEEy{{3GpYlۛr9!D&}VYѸ%CH$ZX,ƍ4i!q*FC]7L7o2LGujGGG[[۵kL&SggtR?C}я^~r!H`٨=n8BHgg')ݒPS99r@ ̔J.\(++v: 1z"*6_frf1<.BZ"""!Ǐ^p\7pGGGkkkeeeFF~.*=I2LgΜyꩧFoݺXHu~~~e"QoBzg #.B#Ɗ DӧBFnwoL&S}}E|H$?~MMg6t≍ܵk{キxbF?~d>(?vھ}… 9R\\TXX뫫ϟ?OvwwxƁ=\xqݺu&Lhll,--U*L& ^^^jbdee1UTTrٳg[˗/;Gώ;Jeyy+WFBb:NlH̿x-[y澾իWqJtx<k׭[+x0Q|taaaaRCK$~Dr}$|OMMxf>x" p'mwtQ0kX4 a i5HA`OtZh<x%X4 a i5HA` 0kX4 a i5HA` 0kX4 a i5HA` 0kX4 0߷oZNT*U\\7xNgvY#ں짞zj4EjzϞ=ׯ>Û6mzg<2hBBBRRG{̙3u:T*J{֣>Cm߾@<?t>k׮/Bn& ϛ7矗JfYєLa;ݰaV---u%YfYf L\.P(^~'޺uرc_RƍZZZnMIJJ Z388x˗/u!D.oݺ5--7999::644 'N[oxx Errbqqq.]ڶm!/--mڴi6SLYzuxxBB’%KZZZqwCt?JjR^'O\XX(juOO?ㄐ~A}}}&..n޽\O sQd2'''^:88xɒ%t J]Ys̘13gμxŋg͚5hillÇJefffkk+!dʔ)W_ի Ν;ӹ\ s)cǎ ׯ_w {…>BR JMM5iii ߸qCT RT*9.nZ[[SRRv㏻2\.wƌ/^xx<^NN΍7ryzziӨp233=8τ"RjwޭT*Ρ;!~:NJHHH~~UFcm޼yٲeV555jtdzwッ ELLLqq$544̜9yf~7L>}tssn=sLhh+*|W֜1c?Y__?iҤK5MSS!d2ݼy2o޼3g[n?ޱruuN3 Թ5̙Cp8s̩&{ * . .qοL& OVkssFq%\\2tttt]vd2uvv?GFF:td2=z4&&q{={l60Gfի|5kw8\|Y.|"0}3fx'L`}}}/_g͚u%֦jg͚EPOEfBV7 |}}ksBHvv6uFpL&@ +C S999v:wn"##=8wTTGYf/axIWWׄ |'|rҤIgϞX,T焐A'%v;ù`ƥ*] B](2ʭ[ƌru#8`bXBb#zp]=;D~~~֭[I3]<)))---RtѢEؕ/!d~Nw˗/cƺةJviӦX"11111^rĉ'x"**FEEPձ111ԕRBHWWW]]ҥKE"!D" zcd2#nu:]ssܹs !"ŏ]fZ\T__h">/ϟ_SSXyvs<-66V"x ZVxbcc###w{-^xʔ)/~鐆~ޘΆ+**rsszbsL&szsK.9N[N3`Ɲ;w+YYY&/ddTRd2PV-KVV󩩩z_c⤤B^_]]=g^ZUUe|O>d  _TTrٳg[V?)0L.]peCIIIzzzYYfݿcyDŋ׭[7a„FǗlٲ͛7^:''A};~ufEEEݿ_~x={? @.766Θ1ÕN7lؠjOs2鄐y=Rl6k4G{``R rN0~tRCC#͝;>=qܹs0Cy Mz__h.PZZj0Ɲ8q[oBZ&:uQX*nܸ1,,e`9;yD+V:unooo߾}Nc@\ls̙3gӧ]4 ʶLիW/YjjmmMIIٽ{.{{{~ {'rʠCѕ2LR JMM5iii ޸qCT RT*9P(vܙrѸ# %%%fyyB+iٳs o\TTCӧO7773g΄Bd2YXXا~jZ= S^Bdz'{1?vZF!~ʠ2b8::z޽ u*:`0 #g2nܸI&BZZZF!zTl3^| &v___XZQ٫oܸJr6l <&*MOiy)B8d UrP !Vu`9;y>r@ ̔J.\(++s \:ڴih1]v@k׮s^x!22B- T qFG2 AAA---zr;"gDeq !#:wx#7ݸn̳hH$>$6Ĩ|oXRedd23uNL1c\˗vرcccct:]ssܹs !"h̙4}LuK:uj…"h„ ;u?Ҳ]]]uuuK..H$Yf ;JOOرce22l4v+զx6 l… aaataV[ZZ6ɓ'O~4$ 5MCCce _8q[;׿З'dggsܒzNM8xB:?uꔣT*ݸqcXXXKK֭[뛜rJKK Ctq2̏StT*V(Jz#͏K/--mڴi6H0KX\YYI^HTXX믻ҧZ^~dʠ ؁&$&&[2!!!~~~T)S?FGGs8@(Or8HTPP*;;{Ϟ=<!K$B;3wA;5uԡWT*Վ;d2P(ܲe˫<~ 61c(7|9~8]*j˖-<9wc~FGUmaڵ999T^{ͽ0u޽LTTTүRi4oߦ,1CW^~޼ygΜnrEpZA455BL&͛7ڻZ :\0X,޻w`?xB>9!N0?gϞ5vq}{2LGa߳`T Qի}O83O?si& N;eV7ølNJ= 3I7?tq/fw{~\^/ *rLT$t{v?Ӱ`yS??0CW^k„ V+_O=p\YNWW!$77w+] q͏Swg~\^#7;Z nZ"Hܘ{7?駟>cR222\K$Ǽy:ļ2]y'N<QQQ7**ЗP֞AOOرce2++;UWWtRQDB}M?Ctݙ׷b0n߾=eGd_hkjjTwg.]?s[e2sh۶mFe෕T*uZX,YYY|ccΝ;_y啬,_j4t3zjUUՖ-[fɓ'?7|⤤B^_]]}y⤛"罹Z?$l38FÝ0z\SF 0kX4 a i5HA` 0kX4 a i5HA` 0kX4 a i5H"dʠ )&&>2emڴg[oϡr;Vٳ~6hhhڵk5Tˮ{-y'HKK۲eKSSwTUUAAxxmlIT.((׿E&''GGGs܆R0iҤŒ.BVBɓ e2YccZ!$%% BFQRi@TnM T*!!!W\tVZZ*$$eΝHbŊS۷t:iZzuxxBB’%KZZZmF-noor%%%~~~iiiӦMl555&IP$''[,>_]]w%G?CM8xBPVBN:UYYI-J7n kiiql/]={vVVVaa#BJe@@@VVVjjhLKK#\~ヒ֙3gNss3 !=͛-[fZSSSs)cǎ $$$??ժUFqTcVVVkkkJJݻq#'33ի)))ŏ=c %%%fyJVݽ{R;ccc>T*333yp8iiiԚ</''ƍr<==}ڴiRn7n(ʢ"T*J#B Ν;ӹ\c{q !&899yK,a:ڿN4Ln|||ppB)..y/#~J=܍7bbb-b8::z޽ BN81w\jؓ'O:^W_utt#GDEEy{{3GpYlۛr9!D&}VYѸ%CH$ZX,ƍ4i!q*FC]7L7o2LGujGGG[[۵kL&SggZ :j{a؏OnnngΜ %?/CW^֬Ycǎay/n)?ر͛_MB3c2AOOϙ3g.]f6rӧzn.+z{{"06ƍ#tvvR흝bx-*9#GLTz…a>WPp-b.{l6//7oVwlZ_gP< qƌ/ v˗p󟗗A9~?l_8 !ϟOLLjZpy/?ᎎʌw]]]Tza2Μ9SOߺu˱gX.r6' wLP8"*lX100BhH$>e(`bVexp8̽aaFANNή]Ν;g_xH2oڴNax#HE:thp8^<1Tׯ_W*nҥ""Hf͚XĉO>9sN81XD.\X[[;=CtfꢷH$9s;[FHGG?xbooo\>ӧBFnwoL&S}}E|H$?~MM۽;V&~3f ˽|n;v~O?]v{/vttōxq"77W[, :ڿN Zl͛W^I*~Q<##nvݺuU˗/OIIa; Hf1{Phh(u;D"َ'n~"??_.۷oMTg6?쳋/wrQvG! i5HA` 0kX4 a0KrhG;ڇxf <MHA` 0kX4 a i5HA` 0kX4 a i5HA` 0kX4 a i5HA`ͨ :..ҥK۶m#&''GGGs܆R@7 tZ*$$eΝnK'&&&))I(j4G;uaBRƍZZZtm*ʐ+Wt:n=&Ok׮MOO8q+Rׯv5sμȡ?NgϞAO>dhh(D E{yG!|>_"0 vڜ>Ok/㪣Jڲe p8yyy*՞9NHH^~bӋtC)1cYWTTHRB~ 61c(7|yOOO p\t̆^Υۿ _JcL& l2nwIKK ݻw/.JG""=ppppyyF9~BCC]yRv5ݾqݻwT*\ӧO7773g G455BL&͛7:2LGqfgϞ5vɱ]@iiia8xd{utt]vd2uvv?^,GGGݻ`0r@ ̔J.\(++c#;l?wfQR=m6?!$;;nB8d===t@4~XLbTu#:AauTwvvR/F0n4͛_~ennns8]v;wn \7 v],kZBX,>vju)]h4VTTTTTH$g}qm/s?AkwádnnГNypN?խX, ߉yDCHmPGP() dtp=aJʚC=-J㏎E===cǎd1cp˗/cƺd_hkjj |>_P !Fn;2c0n߾=eʔAkw]WWW]]ҥK%ɬYEtഝ.~ar|̙n{ ݿ#E]t際ΝKDQ5*g#%\| Ǐ7L.]***r,zjUUՖ-[fɓ'?\^oX{- %%%eee6vs좢7o~wcǎe^_. BR[[{1Ǣ^~V￟jժ?}D }||z}uui;C;vP*W\snt\:*KV[,,78ۿntt槨hʕg϶Z$˗<<4,44Y"DEE}lG{4fg}vE#.J؄4 a ƓwJK$6m"DDD_>>>S]v}>sZrǏ_lYooe˖PFCbb?U Uq4Tѕ"C'sEqz ,:< <_AAAwߥeovtt|G!䩧f;Z#-SbYTUUqr?NX]wugy'ظqeΝkkksR]$Xbԩv}:LqxK/SANexXzuxxBB’%KZZZmݼyBHNNX,|>:..ҥKT]w/+ʗ_~D"ٻwo``uX)󞐐9NHHc2tҭR⨟_z饵k;SthPh: ;=NڇR(cƌ[f ϯJ~?];]tHe8舢`@wرz|-[F{===]Rt#: Ϗ? rɒ%NO]߃eލFcEEEEED"}gJ2tҭO~:u}+ Wx)'c8i;]tǿN wb*,i4.xd2alSte Mz#EOSSӔ)S7n\ {̻\.OOO رcEC3t}BÇn޼wߍ;vq;F9~ C'tഝ!~QTS^^^jbdee18#N%2lwT:iϺgϿ]{GGGRR./h]GЕyGwx<8)?2a i5Sz˞TG}kww3L,gFWfܕ#M6UWW?~<###SSS}||~;(/p e}{ϲhϿg?p@UUGzC?}7lؠj]dXEZt;-?\N)28]?#-3 'O\XX(j5\{2tFZ~ K,iiiݏ<;{ IDAT&ojzR3Z~ДPFiq:#-3FqO7MJJJR)qC+N׿{eዊUyfp+V*4fϿ%_{2ʌ3t52._}UGGl>rHTT7Ct:) ?QuO\SN8pl6/=lAe̸|c\@ `(N׿G<`Ty> aaAgBvh"w;,˭[ʿ;x,h33VxeU*UFF+{yy[r]F.~k3H$<o…V<3Cyy`φ({.\^` &&&ӕf(?>r]F.~7ʏ/^\n݄ 7ӕw?yYx:= P^؁ KWx0! i5HA` 0kXs=SZR=裎_/_>hӝTwZg/;;GFF㏎Z̳ZrǏ_lYoo+/IHHr{0!oLL=xX>>>2>C=}Q?>>UUUYgR)\Çw_0WJ,ᑖlذA:bAtcbbBFihh˼P(- ϯtUn{E"ъ+Nj۷oNӵӕXjUHHH[[- upBBB秧{{{UTT8yr sqޜRTT4B0<kP% ^{5]Rmٲq8W_}X\YYIUYpشi /@`͛7\_c<0K7SJa7oo])v59~xX,ƍ4i!qF,D" ?pjmmmul{,y<ޕ+W-۠tqn2LGϞ=k6v{SS\.gĉTE#BHllɓ'.|\.mS݊⎎Lܝ[nQo- 2yy nZ"](M&ә3gz)x[n \K09sN81h/~ N4 wf(@&1` 8~MLL+Rd2P(R%++Z4ze !r<==](Z,cǎQ;mg( cRY^^~ʕ:ǹl2.]1p~FTv!NNn~蔔l;j}SSSWZUUUR~mww7klla{$) g)))wCTT}i>nݺW^yeP#۳gC=JH`q$**믿4M__BPtAHBIpOzx݃?󕕕gwUVV>nt/X!44tڵU*U\\i(y+'77oQF`V;{Gz{{GDDz|:7Oat۩'7.pÆ Zt5|~@@\.oll1cNWДwt8f̘7|l6,z?CzBT*ݸqcXXXKKNMNNr !@RrNG]?"hŊSN۷ot Э)Jy=g\W_9Ұd*..nii ׯ_dxIllmۚ|:B>>>ʨ|+YFC~0?CD"ф ܹcZRtҥVoԺrZaaٳM7Z" ,!PK//˫GC&?gJ><>>~ӦMdiicfD ?:r!!!coݺށyzIc~;vlΝ'N Oar_in:LFfsxWZ7kr8:{CWE"QzzzpppMM͛ eX,V^^^EEӼy"##Y,իWwڥT*Bƍ{D"V-+++((h4!oooDXWW'ͬ)Ju<{yy-[,00djVJ*++8qh2׆GbŊ,3`H$z/^(HL>*:$;;[TJ$DbB;vHHH`X&Mbttc$IRRݻwBkŊ/VdKF3o޼+W+Vܽ{7..nӟ̉b ?C322-[V q޽{d r/._L:j-ҥK F|||RRR@@̙3M/aÆEaxb1ydGGDŽ iZ~h7g*4m_c4oFh>3gDDDDEEЗ:rrr^|EիWMAaaW_mll2Dw"\T*}}}{TVVFΙk4@yR>bBϟ&8((!$9aXuuuYY9r8_pAqM2NxΝ;7j(5jTQQ}{ki&\nxxѣG5Mgg~kλ?677kǏ6dzWW~!jjjS+~gu?F{ ۷o?5GdzP( ׿aݻw{F[_Z___.k~#OJo߾eƍW\1sxy0KMMM=JNN&$ Y^6m'ANNNd1%WWWB WW(d<|>_XV?8NIO?4k,@tFR<`ŕBmmmdڷX,gggqvqgg礤$Ht|JE}lw8M/]4gLfFg%ng/|> ޽{СK{uYir~d=jjjC')));wx"Azxx8BXx'#3g+]]]d`^o]<!BQUU5r~]p슦zST!d2BH  r777^eEjP(+зUwȓKԈD)S=zg03VsrrBw6 ;v޽{ĜK*.]W*ݽ7EJGGX,(ΞZ[[,X@JJ(_Q h\.=z4By0s1!EGGGEE3&[w>5MEEŔ)S\.Ϗ-))1OttP(p8'O.--0̊8yFZ&KǶyytttxxΝ;m6uT5Tsss۷Nk8[ra,sέ[N<==>E,ekqPkkAD&uttd O70Z6\믿7o;S__ox)""bǎ ,P~9(WNU*N24nh7n<40p7nKxoMbZ$##d3 ӛ/^{nTj#DU6ܺ7 3EEEr\T+WNU&Ne.U7opZZ_xRʕ+˗/_|wy|Ϟ=6_.sBp…III>.n]<<<a8MʩʡiL=}zo-~ƍYfq87h >y-XwJwvv^vMT믛7o/---'OLMMEemUƖ1*N2fgT*yohh "&&ի>>>#GsymmxR:**p_R-J.]jNwppD&Ls2U6&O>=|aÆ1 ''aÆ49U9t8Uwrzԩ/_.//6mLGda//3fx<6-˷nJf$</55ݝ,nhˆ#ڱcnj3VXhN*IӇȻC9tO>yeCC[`P/&`RNJ$a1= ^֯_?~G Dr }| ,cT#J'Nx3g϶VLJz ͛vǼ'-ngڵd\ٳg:# %rssi[N&Lt: Rŋ'N@M>=$$$333""b޼yzM8[lAedd( Hprynnnggypssp/))RL&Dmmm7o&6899͛7/22b]zu׮]Jfff~w?B(..>&O*++ ?~!$Ӄkjj E%''755yyyX ["r hO"2dHb#G b0|>?33s֬Y[JJʪỦ!777--͢{NJ>}:9;k.6\.P$!222lfBLfիSRRϧD*nڴ0>`{oݺu䳸%!o߳gO@@@DDħ~FM|ӧ\]"[0T*ݾ}X,x6m2l!gϞ#\P(qx4biZÆ 3!H222^ssN~CCFQ(Kd=gFFF)冇=zTtvv~QQQ&׺pV%"AddJN;wIDAT|pDDBݻwMHHؾ}e7o8p B(--|PTT$˕Jeyy9]2vFiiiɃM,9rðjbIJa4e loogX4䓫q'ONW8d>arjڢx<a߶6WWWk8!Lh4|K̙#ڠ۽qƬYIA|OO[nY]Ci N4y0zV( q޲6az&3EB777^eۈI!dBa!Hggg d]yP*AL\ﭵ!jd7..F$M2ѣ4jA\zgȑwܱ"?x#oB<QvazRE,З gɥ5#J.]jYXXXll+,ɺɗݻGΣr܈]!ƌ# \nlllYYyh4SL! Ɩ釴/X#BK/Dsm۶M:544&[m:u˗˧MfΉxCuѣG#|СC4؋SNmڴI՞9s//k?Yӳ̛b1yB6m̙3lv]]yS.رc---nrqq1[EEڵkE"QUUUAAKHHq&1*''gܹYYYmmmEEE.][hц T*J*((XreJJ yh4l!t[n566&&&sa9޲GaX+#cVXȈwР`O0 ǜ&{aa+)m ??!DDGGGii-}4)77b/Zy4cƌGVY͙3cǎa[{4 ä??d /דD"1 ^]6dah9wU֭['|%:Ν;/_&RP(dc\Ksۯ_K5>ӔwmOS->>qP UY{KD5557o6I0P*oooDXWW'y`8:6ݰrrssL6"^>pB%r[laĞe*ׯ?O4iÆ &2Z~)))dɇwBUJ۷biӦٳg^2P*7;888pg>X~7P9w%IFFYfeee|=پqHb|O&+ Ϝ9cfT=qGj4o6**~eSTT$˕Jeyyoϗz_hf4X| êʬO ?TݶrrrǍ/?~\СVo8[\~o]#***֮]+ cg-^xٲeNڷo_moop8UUUa}^^^BBB~~>㥥遦۵Ѽē8b e K111f͚3fE<TX2"888,,̺{<O __?Oo}OJOa,Ћ/x$lN-k0 ݘSٙy|BHUlҀڽ;ӓ}`_4 b3 ر5|];-YVV|6ڪm˾5oX8N(%;[off!w3"MWxߋC?##yV{GB(S.ijߗ^UVv[ww۶1cz:eͻw5s_xdĹsv( ~W,orֺ YVVy%s͏DW ~?ܬsB0ݍ>qFԩ'OpMwt`.ZnXˋrI]َ&ZwuΏ>j :eժ~~h7O)70,LIEݼٽ| 3%wDquhEq UVVko7~.8v[ZZ77Zs8L&S;xx֯{%>ɶzn0hBzqd2F-XEdvB(33 :ɉJK럜…U(ڶ-08ؑ`D,6Tkw֬/04۽;8 @|Bm5!''fRϚ5q(/W^]ڪ7 v}uKrqnuuKo?t"EaX)S<:;{**T&4TǕ룏 'jj4II4_++*+Fw  Sgz^=3,i,\yRuR+8p10nɴ?1y; 9t̙ܺdϙ#FϮ׮u'CB׮adkNMNGӘf\Qi H~9wj͈e˼BGrrbxz~J` ;\VV7ܹ.{g#*kj4rk\!D7G pHL3jԵynݾMyw}߾ZC/9;3uc\P "|6fqqgPw͚Ttw[2|fObZc0Os:**Taag YˋMVَy:XQ9/ys:~~WWI`N\.U䩰~{HN~%KJK?yٷ 0A=!tdd~V(G*~U흝X` W(dڲYg==cƸtڰ'ZZoZbqn4Z[ZMSm\oorFt`4d11γ:]uLLCYgoaa_}5//śo{c߾& W~rtd͏AKԩ+WTj5^W!dVɓmJ 9=ڕsƍ Z->L&zZ2yXNNqsr ѱc{\>`NJ C;`4BH' bvn!LFW9R0moǸ\B ''fWkWpd䃊_ݒQq ! #pd"Ps!$\\NNLw{ԤCd!d] ,jk5/gc~T14&oDw7qd2X_t:YR(B6M8pX7kfٳrn͚nn,//ocbioT*LƇu鹖;k4_с=Az.t,̝+0̙FMM^XGzzrLi8ym֬__}M>~QOT~jbzulW*1x&Mr?ލdwKx= sxxm{b4\_vq`D渊O޿?qVKw0ёccTHwV7eGkwqpqaWX{{_{ 0xC8k(06}v\Ra:nn&wuuuo^6Vo3CB?|T;BÈ5k|l 0Dqrb92lj2UffúG߷шJM 0Co2N47rs'?nmRw  +ԩ7iV&\qcU<<㘖!bŝk׺6lhXw˖I {ȾE[ &$x: t:ĉ6kQQ;ğtk~>I 8ܹb%+]xm 79::w9A+j֬9S<{È׻Ra  wtKInXa*xx>mϏݰNGX[y29G B֯T3Ѩφ uty)|>LIi5  wp(t 0 0 0 0 0 0 0 0;mHC4liIENDB`