[sup-devel] [PATCHv2] Saved Search Support

Eric Sherman hyperbolist at gmail.com
Tue Jan 19 22:48:50 EST 2010


Start an index search with a \ backslash and press enter to get a list
of searches that were previously saved from search-results-mode with %
percent or added from search-list-mode directly.  Saved searches may be
used in other searches by enclosing their names in {} curly braces.
Search names may contain letters, numbers, underscores and dashes.

New Key Bindings
global
  \<CR> open search-list-mode
search-list-mode
  X     Delete selected search
  r     Rename selected search
  e     Edit selected search
  a     Add new search
search-results-mode
  %     Save search

New Hooks
search-list-filter
search-list-format

Search String Expansion
Include saved searches in other searches by enclosing their names in {}
curly braces.  The name and enclosing braces are replaced by the actual
search string and enclosing () parens.

    low_traffic: has:foo OR has:bar
    a_slow_week: {low_traffic} AND after:(7 days ago)

{a_slow_week} expands to "(has:foo OR has:bar) AND after:(7 days ago)"
and may be used in a global search, a refinement or another saved
search.  A search including the undefined {baz} will fail. To search for
a literal string enclosed in curly braces, escape the curly braces with
\ backslash: "\{baz\}".

There is no nesting limit and searches are always expanded completely
before they are turned into proper queries for the index.

Save File Format
Searches are read from ~/.sup/searches.txt on startup and saved at exit.
The format is "name: search_string".  Here's a silly example:

    core: {me} AND NOT {crap} AND NOT {weak}
    crap: is:leadlogger OR is:alert OR is:rzp
    me: to:me OR from:me
    recent: after:(14 days ago)
    top: {core} AND {recent}
    weak: is:feed OR is:list OR is:ham
---
 bin/sup                              |   11 ++-
 lib/sup.rb                           |    5 +
 lib/sup/modes/search-list-mode.rb    |  188 ++++++++++++++++++++++++++++++++++
 lib/sup/modes/search-results-mode.rb |   23 ++++-
 lib/sup/search.rb                    |   72 +++++++++++++
 5 files changed, 294 insertions(+), 5 deletions(-)
 create mode 100644 lib/sup/modes/search-list-mode.rb
 create mode 100644 lib/sup/search.rb

diff --git a/bin/sup b/bin/sup
index 8bf640b..fb19795 100755
--- a/bin/sup
+++ b/bin/sup
@@ -303,9 +303,14 @@ begin
       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: "
-      next unless query && query !~ /^\s*$/
-      SearchResultsMode.spawn_from_query query
+      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
diff --git a/lib/sup.rb b/lib/sup.rb
index e03a35d..b9dc749 100644
--- a/lib/sup.rb
+++ b/lib/sup.rb
@@ -50,6 +50,7 @@ module Redwood
   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")
 
   YAML_DOMAIN = "masanjin.net"
   YAML_DATE = "2006-10-01"
@@ -131,12 +132,14 @@ module Redwood
     Redwood::CryptoManager.init
     Redwood::UndoManager.init
     Redwood::SourceManager.init
+    Redwood::SearchManager.init Redwood::SEARCH_FN
   end
 
   def finish
     Redwood::LabelManager.save if Redwood::LabelManager.instantiated?
     Redwood::ContactManager.save if Redwood::ContactManager.instantiated?
     Redwood::BufferManager.deinstantiate! if Redwood::BufferManager.instantiated?
+    Redwood::SearchManager.save if Redwood::SearchManager.instantiated?
   end
 
   ## not really a good place for this, so I'll just dump it here.
@@ -341,6 +344,8 @@ 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"
 
 $:.each do |base|
   d = File.join base, "sup/share/modes/"
diff --git a/lib/sup/modes/search-list-mode.rb b/lib/sup/modes/search-list-mode.rb
new file mode 100644
index 0000000..076c3d9
--- /dev/null
+++ b/lib/sup/modes/search-list-mode.rb
@@ -0,0 +1,188 @@
+module 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", <<EOS
+Filter the search list, typically to sort.
+Variables:
+  counted: an array of counted searches.
+Return value:
+  An array of counted searches with sort_by output structure.
+EOS
+
+  HookManager.register "search-list-format", <<EOS
+Create the sprintf format string for search-list-mode.
+Variables:
+  n_width: the maximum search name width
+  tmax: the maximum total message count
+  umax: the maximum unread message count
+  s_width: the maximum search string width
+Return value:
+  A format string for sprintf
+EOS
+
+  def initialize
+    @searches = []
+    @text = []
+    @unread_only = false
+    super
+    UpdateManager.register self
+    regen_text
+  end
+
+  def cleanup
+    UpdateManager.unregister self
+    super
+  end
+
+  def lines; @text.length end
+  def [] i; @text[i] end
+
+  def jump_to_next_new
+    n = ((curpos + 1) ... lines).find { |i| @searches[i][1] > 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
+      expanded_search_string= SearchManager.expand search_string
+      if expanded_search_string
+        query = Index.parse_query expanded_search_string
+        total = Index.num_results_for :qobj => query[:qobj]
+        unread = Index.num_results_for :qobj => query[:qobj], :label => :unread
+      else
+        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
diff --git a/lib/sup/modes/search-results-mode.rb b/lib/sup/modes/search-results-mode.rb
index 121e817..14d42b5 100644
--- a/lib/sup/modes/search-results-mode.rb
+++ b/lib/sup/modes/search-results-mode.rb
@@ -8,14 +8,30 @@ class SearchResultsMode < ThreadIndexMode
 
   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] + " ")
+    text = BufferManager.ask :search, "refine query: ", (@query[:unexpanded_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[:unexpanded_text].strip
+  end
+
   ## a proper is_relevant? method requires some way of asking ferret
   ## 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
@@ -24,8 +40,11 @@ class SearchResultsMode < ThreadIndexMode
 
   def self.spawn_from_query text
     begin
-      query = Index.parse_query(text)
+      expanded_text = SearchManager.expand text
+      return unless expanded_text
+      query = Index.parse_query expanded_text
       return unless query
+      query[:unexpanded_text] = text
       short_text = text.length < 20 ? text : text[0 ... 20] + "..."
       mode = SearchResultsMode.new query
       BufferManager.spawn "search: \"#{short_text}\"", mode
diff --git a/lib/sup/search.rb b/lib/sup/search.rb
new file mode 100644
index 0000000..799ca89
--- /dev/null
+++ b/lib/sup/search.rb
@@ -0,0 +1,72 @@
+module Redwood
+
+class SearchManager
+  include Singleton
+
+  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
+        BufferManager.flash error_message
+        return false
+      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
-- 
1.6.6


More information about the Sup-devel mailing list