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 _______________________________________________ Sup-devel mailing list Sup-devel@rubyforge.org http://rubyforge.org/mailman/listinfo/sup-devel