[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
Re: [sup-devel] [PATCHv3] Saved Search Support
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.
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 | 16 +++
lib/sup/search.rb | 73 +++++++++++++
lib/sup/xapian_index.rb | 5 +
6 files changed, 295 insertions(+), 3 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..8f73659
--- /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
+ 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
diff --git a/lib/sup/modes/search-results-mode.rb b/lib/sup/modes/search-results-mode.rb
index 121e817..5b529a8 100644
--- a/lib/sup/modes/search-results-mode.rb
+++ b/lib/sup/modes/search-results-mode.rb
@@ -8,6 +8,7 @@ class SearchResultsMode < ThreadIndexMode
register_keymap do |k|
k.add :refine_search, "Refine search", '|'
+ k.add :save_search, "Save search", '%'
end
def refine_search
@@ -16,6 +17,21 @@ class SearchResultsMode < ThreadIndexMode
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 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
diff --git a/lib/sup/search.rb b/lib/sup/search.rb
new file mode 100644
index 0000000..0c63b06
--- /dev/null
+++ b/lib/sup/search.rb
@@ -0,0 +1,73 @@
+module 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
diff --git a/lib/sup/xapian_index.rb b/lib/sup/xapian_index.rb
index 8f29faf..37f9b4a 100644
--- a/lib/sup/xapian_index.rb
+++ b/lib/sup/xapian_index.rb
@@ -162,6 +162,11 @@ EOS
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}" }
--
1.6.6
_______________________________________________
Sup-devel mailing list
Sup-devel@rubyforge.org
http://rubyforge.org/mailman/listinfo/sup-devel