lib/sup/message.rb
lib/sup/mode.rb
lib/sup/modes/buffer-list-mode.rb
+lib/sup/modes/completion-mode.rb
lib/sup/modes/compose-mode.rb
lib/sup/modes/contact-list-mode.rb
lib/sup/modes/edit-message-mode.rb
lib/sup/modes/text-mode.rb
lib/sup/modes/thread-index-mode.rb
lib/sup/modes/thread-view-mode.rb
+lib/sup/modes/file-browser-mode.rb
lib/sup/person.rb
lib/sup/poll.rb
lib/sup/rfc2047.rb
p.url = p.paragraphs_of('README.txt', 0).first.split(/\n/)[2].gsub(/^\s+/, "")
p.changes = p.paragraphs_of('History.txt', 0..0).join("\n\n")
p.email = "wmorgan-sup@masanjin.net"
- p.extra_deps = [['ferret', '>= 0.10.13'], ['ncurses', '>= 0.9.1'], ['rmail', '>= 0.17'], 'highline', 'net-ssh', ['trollop', '>= 1.7'], 'lockfile']
+ p.extra_deps = [['ferret', '>= 0.10.13'], ['ncurses', '>= 0.9.1'], ['rmail', '>= 0.17'], 'highline', 'net-ssh', ['trollop', '>= 1.7'], 'lockfile', 'mime-types']
end
rule 'ss?.png' => 'ss?-small.png' do |t|
c.add :message_patina_color, Ncurses::COLOR_BLACK, Ncurses::COLOR_GREEN
c.add :alternate_patina_color, Ncurses::COLOR_BLACK, Ncurses::COLOR_BLUE
c.add :missing_message_color, Ncurses::COLOR_BLACK, Ncurses::COLOR_RED
- c.add :mime_color, Ncurses::COLOR_CYAN, Ncurses::COLOR_BLACK
+ c.add :attachment_color, Ncurses::COLOR_CYAN, Ncurses::COLOR_BLACK
c.add :quote_patina_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK
c.add :sig_patina_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK
c.add :quote_color, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK
Ncurses::A_BOLD
c.add :draft_notification_color, Ncurses::COLOR_RED, Ncurses::COLOR_BLACK,
Ncurses::A_BOLD
+ c.add :completion_character_color, Ncurses::COLOR_WHITE,
+ Ncurses::COLOR_BLACK, Ncurses::A_BOLD
end
log "initializing buffer manager"
Redwood::finish
stop_cursing
+ Redwood::log "stopped cursing"
if SuicideManager.instantiated? && SuicideManager.die?
- Redwood::log "I've been asked to commit sepuku. I obey!"
+ Redwood::log "I've been ordered to commit sepuku. I obey!"
end
case $exception
when nil
- Redwood::log "good night, sweet prince!"
+ Redwood::log "no fatal errors. good job, william."
Index.save
else
Redwood::log "oh crap, an exception"
for 0.0.9
---------
+_ bugfix: screwing the headers when editing causes a crash
_ bugfix: when one new message comes into an imap folder, we don't
catch it until a reload (sometimes?)
message indicating they're loaded to inbox (imap only?)
require "sup/modes/buffer-list-mode"
require "sup/modes/log-mode"
require "sup/modes/poll-mode"
+require "sup/modes/file-browser-mode"
+require "sup/modes/completion-mode"
require "sup/logger"
require "sup/sent"
+require 'etc'
require 'thread'
module Ncurses
module_function :rows, :cols, :nonblocking_getch, :mutex, :sync
- KEY_CANCEL = "\a"[0] # ctrl-g
+ KEY_ENTER = 10
+ KEY_CANCEL = ?\a # ctrl-g
+ KEY_TAB = 9
end
module Redwood
## 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|
+ true && @buffers.inject(@dirty) do |dirty, buf|
buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
- @dirty ? buf.draw : buf.redraw
+ #dirty ? buf.draw : buf.redraw
+ buf.draw
+ dirty
end
## quick hack
- if true
+ if false
buf = @buffers.last
buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
@dirty ? buf.draw : buf.redraw
end
draw_minibuf :sync => false unless opts[:skip_minibuf]
+
@dirty = false
Ncurses.doupdate
Ncurses.refresh if opts[:refresh]
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.nonblocking_getch
+ next unless c # getch timeout
+ break if c == Ncurses::KEY_CANCEL
+ mode.handle_input c
+ 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.
end
end
- ## not really thread safe.
- def ask domain, question, default=nil
+ ## returns an ARRAY of filenames!
+ def ask_for_filenames domain, question, default=nil
+ 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 =~ /^#{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
+ if answer.empty?
+ spawn_modal "file browser", FileBrowserMode.new
+ elsif File.directory?(answer)
+ spawn_modal "file browser", FileBrowserMode.new(answer)
+ else
+ [answer]
+ end
+ else
+ []
+ end
+ end
+
+ def ask domain, question, default=nil, &block
raise "impossible!" if @asking
+ @asking = true
@textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
tf = @textfields[domain]
+ completion_buf = nil
- ## this goddamn ncurses form shit is a fucking 1970's
- ## nightmare. jesus christ. the exact sequence of ncurses events
- ## that needs to happen in order to display a form and have the
- ## entire screen not disappear and have the cursor in the right
- ## place is TOO FUCKING COMPLICATED.
+ ## this goddamn ncurses form shit is a fucking 1970's nightmare.
+ ## jesus christ. the exact sequence of ncurses events that needs
+ ## to happen in order to display a form and have the entire screen
+ ## not disappear and have the cursor in the right place is TOO
+ ## FUCKING COMPLICATED.
Ncurses.sync do
- tf.activate question, default
+ tf.activate question, default, &block
@dirty = true
draw_screen :skip_minibuf => true, :sync => false
end
tf.position_cursor
Ncurses.sync { Ncurses.refresh }
- @asking = true
- while tf.handle_input(Ncurses.nonblocking_getch); end
- @asking = false
+ while true
+ c = Ncurses.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
+
+ prefix_len =
+ if tf.value =~ /\/$/
+ 0
+ else
+ File.basename(tf.value).length
+ end
+
+ mode = CompletionMode.new tf.completions.map { |full, short| short }, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
+ completion_buf = spawn "<completions>", 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
- ret = tf.value
+ Ncurses.sync { Ncurses.refresh }
+ end
+
Ncurses.sync { tf.deactivate }
+ kill_buffer completion_buf if completion_buf
@dirty = true
-
- ret
+ @asking = false
+ draw_screen
+ tf.value
end
## some pretty lame code in here!
end
@shelled = false
end
+
+private
+
+ def users
+ unless @users
+ @users = []
+ while(u = Etc.getpwent)
+ @users << u.name
+ end
+ end
+ @users
+ end
end
end
end
def save_index fn=File.join(@dir, "ferret")
- # don't have to do anything, apparently
+ # don't have to do anything, apparently
end
def contains_id? id
--- /dev/null
+module Redwood
+
+class CompletionMode < ScrollMode
+ INTERSTITIAL = " "
+
+ def initialize list, opts={}
+ @list = list
+ @header = opts[:header]
+ @prefix_len = opts[:prefix_len]
+ @lines = nil
+ super :slip_rows => 1, :twiddles => false
+ end
+
+ def lines
+ update_lines unless @lines
+ @lines.length
+ end
+
+ def [] i
+ update_lines unless @lines
+ @lines[i]
+ end
+
+ def roll; if at_bottom? then jump_to_start else page_down end end
+
+private
+
+ def update_lines
+ width = buffer.content_width
+ max_length = @list.map { |s| s.length }.max
+ num_per = buffer.content_width / (max_length + INTERSTITIAL.length)
+ @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
require 'tempfile'
require 'socket' # just for gethostname!
+require 'pathname'
+require 'rmail'
module Redwood
k.add :send_message, "Send message", 'y'
k.add :edit, "Edit message", 'e', :enter
k.add :save_as_draft, "Save as draft", 'P'
+ k.add :attach_file, "Attach a file", 'a'
+ k.add :delete_attachment, "Delete an attachment", 'd'
end
def initialize opts={}
@body = opts.delete(:body) || []
@body += sig_lines if $config[:edit_signature]
@attachments = []
+ @attachment_lines = {}
@message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{Socket.gethostname}>"
@edited = false
+ @message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{Socket.gethostname}>"
super opts
- update
+ regen_text
end
def lines; @text.length end
!edited? || BufferManager.ask_yes_or_no("Discard message?")
end
+ def attach_file
+ fn = BufferManager.ask_for_filenames :attachment, "File name (enter for browser): "
+ fn.each { |f| @attachments << Pathname.new(f) }
+ update
+ end
+
+ def delete_attachment
+ i = curpos - @top_lines
+ if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachments[i]}?")
+ @attachments.delete_at i
+ update
+ end
+ end
+
protected
def update
end
def regen_text
- @text = header_lines(@header - NON_EDITABLE_HEADERS) + [""] + @body
+ top = header_lines(@header - NON_EDITABLE_HEADERS) + [""]
+ @text = top + @body +
+ @attachments.map { |f| [[:attachment_color, "+ Attachment: #{f} (#{f.human_size})"]] }
+ @top_lines = top.size
@text += sig_lines unless $config[:edit_signature]
end
BufferManager.flash "Sending..."
begin
- IO.popen(acct.sendmail, "w") { |p| write_message p, true, date }
+ IO.popen(acct.sendmail, "w") { |p| write_full_message_to p }
rescue SystemCallError
end
if $? == 0
BufferManager.flash "Saved for later editing."
end
+ def write_full_message_to f
+ m = RMail::Message.new
+ @header.each { |k, v| m.header[k] = v.to_s unless v.to_s.empty? }
+ m.header["Date"] = Time.now.rfc2822
+ m.header["Message-Id"] = @message_id
+ m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
+ if @attachments.empty?
+ m.header["Content-Disposition"] = "inline"
+ m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
+ m.body = @body.join "\n"
+ m.body += sig_lines.join("\n") unless $config[:edit_signature]
+ else
+ body_m = RMail::Message.new
+ body_m.body = @body.join "\n"
+ body_m.body += sig_lines.join("\n") unless $config[:edit_signature]
+
+ m.add_part body_m
+ @attachments.each { |fn| m.add_attachment fn.to_s }
+ end
+ f.puts m.to_s
+ end
+
## 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
--- /dev/null
+require '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
+ 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.readlines.join)
+ 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.map { |name, f| f.human_size.length }.max
+ time_width = @files.map { |name, f| f.human_time.length }.max
+
+ @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
attr_reader :curpos
def initialize opts={}
- @cursor_top = @curpos = opts[:skip_top_rows] || 0
+ @cursor_top = @curpos = opts.delete(:skip_top_rows) || 0
@load_more_callbacks = []
@load_more_callbacks_m = Mutex.new
@load_more_callbacks_active = false
end
end
- def jump_to_home
+ def jump_to_start
super
set_cursor_pos @cursor_top
end
k.add :col_right, "Right one column", :right, 'l'
k.add :page_down, "Down one page", :page_down, 'n', ' '
k.add :page_up, "Up one page", :page_up, 'p', :backspace
- k.add :jump_to_home, "Jump to top", :home, '^', '1'
+ k.add :jump_to_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", '['
end
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 jump_to_home; jump_to_line 0; 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
- @topline = 0 if @topline < 0 # empty
+ @topline = @topline.clamp 0, [lines - 1, 0].max
@botline = [@topline + buffer.content_height, lines].min
end
message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
(chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. To edit, hit 'e'. <<<"]]] : [])
when Message::Attachment
- [[[:mime_color, "#{prefix}+ MIME attachment #{chunk.content_type}#{chunk.desc ? ' (' + chunk.desc + ')': ''}"]]]
+ [[[:attachment_color, "#{prefix}+ Attachment: #{chunk.content_type}#{chunk.desc ? ' (' + chunk.desc + ')': ''}"]]]
when Message::Text
t = chunk.lines
if t.last =~ /^\s*$/ && t.length > 1
module Redwood
+## a fully-functional text field supporting completions, expansions,
+## history--everything!
+##
+## 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
- attr_reader :value
-
def initialize window, y, x, width
@w, @x, @y = window, x, y
@width = width
@i = nil
@history = []
+
+ @completion_block = nil
+ reset_completion_state
end
- def activate question, default=nil
+ bool_reader :new_completions, :roll_completions
+ attr_reader :completions
+
+ ## when the user presses enter, we store the value in @value and
+ ## clean up all the ncurses cruft. before @value is set, we can
+ ## get the current value from ncurses.
+ def value; @field ? get_cur_value : @value end
+
+ def activate question, default=nil, &block
@question = question
@value = nil
+ @completion_block = block
@field = Ncurses::Form.new_field 1, @width - question.length,
@y, @x + question.length, 0, 0
@form = Ncurses::Form.new_form [@field]
@history[@i = @history.size] = default || ""
Ncurses::Form.post_form @form
- @field.set_field_buffer 0, @history[@i]
+ set_cur_value @history[@i]
end
def position_cursor
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
- if c == 10 # Ncurses::KEY_ENTER
- Ncurses::Form.form_driver @form, Ncurses::Form::REQ_VALIDATION
- @value = @history[@i] = @field.field_buffer(0).gsub(/^\s+|\s+$/, "").gsub(/\s+/, " ")
+ ## short-circuit exit paths
+ case c
+ when Ncurses::KEY_ENTER # submit!
+ @value = @history[@i] = get_cur_value
return false
- elsif c == Ncurses::KEY_CANCEL
+ when Ncurses::KEY_CANCEL # cancel
@history.delete_at @i
@i = @history.empty? ? nil : (@i - 1) % @history.size
@value = nil
return false
+ when Ncurses::KEY_TAB # completion
+ break unless @completion_block
+ if @completions.empty?
+ v = get_cur_value
+ c = @completion_block.call v
+ if c.size > 0
+ set_cur_value c.map { |full, short| full }.shared_prefix
+ 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
+
d =
case c
when Ncurses::KEY_LEFT
when ?\005
Ncurses::Form::REQ_END_FIELD
when Ncurses::KEY_UP
- @history[@i] = @field.field_buffer(0)
+ @history[@i] = @field.field_buffer 0
@i = (@i - 1) % @history.size
- @field.set_field_buffer 0, @history[@i]
+ set_cur_value @history[@i]
when Ncurses::KEY_DOWN
- @history[@i] = @field.field_buffer(0)
+ @history[@i] = @field.field_buffer 0
@i = (@i + 1) % @history.size
- @field.set_field_buffer 0, @history[@i]
+ set_cur_value @history[@i]
else
c
end
- Ncurses::Form.form_driver @form, d if d
- Ncurses.refresh
-
+ Ncurses::Form.form_driver @form, d
true
end
+
+private
+
+ def reset_completion_state
+ @completions = []
+ @new_completions = @roll_completions = @clear_completions = false
+ end
+
+ ## ncurses inanity wrapper
+ def get_cur_value
+ Ncurses::Form.form_driver @form, Ncurses::Form::REQ_VALIDATION
+ @field.field_buffer(0).gsub(/^\s+|\s+$/, "").gsub(/\s+/, " ")
+ end
+
+ ## ncurses inanity wrapper
+ def set_cur_value v
+ @field.set_field_buffer 0, v
+ Ncurses::Form.form_driver @form, Ncurses::Form::REQ_END_FIELD
+ end
+
end
end
require 'lockfile'
+require 'mime/types'
+require 'pathname'
## time for some monkeypatching!
class Lockfile
def touch_yourself; touch path end
end
+class Pathname
+ def human_size
+ s =
+ begin
+ size
+ rescue SystemCallError
+ return "?"
+ end
+
+ if s < 1024
+ s.to_s + "b"
+ elsif s < (1024 * 1024)
+ (s / 1024).to_s + "k"
+ elsif s < (1024 * 1024 * 1024)
+ (s / 1024 / 1024).to_s + "m"
+ else
+ (s / 1024 / 1024 / 1024).to_s + "g"
+ end
+ 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 add_attachment fn
+ bfn = File.basename fn
+ a = Message.new
+ t = MIME::Types.type_for(bfn).first || MIME::Types.type_for("exe").first
+
+ a.header.add "Content-Disposition", "attachment; filename=#{bfn}"
+ a.header.add "Content-Type", "#{t.content_type}; name=#{bfn}"
+ a.header.add "Content-Transfer-Encoding", t.encoding
+ a.body =
+ case t.encoding
+ when "base64"
+ [IO.read(fn)].pack "m"
+ when "quoted-printable"
+ [IO.read(fn)].pack "M"
+ else
+ raise EncodingUnsupportedError, t.encoding
+ end
+
+ add_part a
+ end
+ end
+end
+
class Range
## only valid for integer ranges (unless I guess it's exclusive)
def size
##
## i'm sure there's pithy comment i could make here about the
## superiority of lisp, but fuck lisp.
+ ##
+ ## addendum: apparently this is a "k combinator". whoda thunk it?
def returning x; yield x; x; end
## clone of java-style whole-method synchronization
end
best
end
+
+ ## returns the maximum shared prefix of an array of strings
+ ## optinally excluding a prefix
+ def shared_prefix exclude=""
+ return "" if empty?
+ prefix = ""
+ (0 ... first.length).each do |i|
+ c = first[i]
+ break unless all? { |s| s[i] == c }
+ next if exclude[i] == c
+ prefix += c.chr
+ end
+ prefix
+ end
end
class Array
def rest; self[1..-1]; end
def to_boolean_h; Hash[*map { |x| [x, true] }.flatten]; end
+
+ def last= e; self[-1] = e end
end
class Time