Search.java 8.6 KB
Newer Older
1 2
package hudson.search;

K
bug fix  
kohsuke 已提交
3
import hudson.util.EditDistance;
K
kohsuke 已提交
4
import org.kohsuke.stapler.Ancestor;
5
import org.kohsuke.stapler.QueryParameter;
K
kohsuke 已提交
6 7
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
8
import org.kohsuke.stapler.export.DataWriter;
9
import org.kohsuke.stapler.export.Exported;
10 11
import org.kohsuke.stapler.export.ExportedBean;
import org.kohsuke.stapler.export.Flavor;
K
bug fix  
kohsuke 已提交
12

13
import javax.servlet.ServletException;
14
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
K
kohsuke 已提交
15
import java.io.IOException;
16
import java.util.AbstractList;
17
import java.util.ArrayList;
K
bug fix  
kohsuke 已提交
18
import java.util.Collections;
19
import java.util.HashSet;
20
import java.util.List;
21
import java.util.Set;
22 23

/**
K
bug fix  
kohsuke 已提交
24 25
 * Web-bound object that serves QuickSilver-like search requests.
 *
26 27 28
 * @author Kohsuke Kawaguchi
 */
public class Search {
29
    public void doIndex(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException {
30 31 32
        List<Ancestor> l = req.getAncestors();
        for( int i=l.size()-1; i>=0; i-- ) {
            Ancestor a = l.get(i);
K
bug fix  
kohsuke 已提交
33 34 35 36 37
            if (a.getObject() instanceof SearchableModelObject) {
                SearchableModelObject smo = (SearchableModelObject) a.getObject();

                SearchIndex index = smo.getSearchIndex();
                String query = req.getParameter("q");
38 39 40 41 42 43 44
                if(query!=null) {
                    SuggestedItem target = find(index, query);
                    if(target!=null) {
                        // found
                        rsp.sendRedirect2(a.getUrl()+target.getUrl());
                        return;
                    }
K
bug fix  
kohsuke 已提交
45 46 47 48
                }
            }
        }

49 50 51
        // no exact match. show the suggestions
        rsp.setStatus(SC_NOT_FOUND);
        req.getView(this,"search-failed.jelly").forward(req,rsp);
K
bug fix  
kohsuke 已提交
52 53
    }

54
    /**
55 56 57 58 59
     * Used by OpenSearch auto-completion. Returns JSON array of the form:
     *
     * <pre>
     * ["queryString",["comp1","comp2",...]]
     * </pre>
K
kohsuke 已提交
60 61
     *
     * See http://developer.mozilla.org/en/docs/Supporting_search_suggestions_in_search_plugins
62
     */
63 64 65 66 67 68
    public void doSuggestOpenSearch(StaplerRequest req, StaplerResponse rsp, @QueryParameter("q")String query) throws IOException, ServletException {
        DataWriter w = Flavor.JSON.createDataWriter(null, rsp);
        w.startArray();
        w.value(query);

        w.startArray();
69 70
        for (SuggestedItem item : getSuggestions(req, query))
            w.value(item.getPath());
71 72 73
        w.endArray();
        w.endArray();
    }
74

75 76 77 78
    /**
     * Used by search box auto-completion. Returns JSON array.
     */
    public void doSuggest(StaplerRequest req, StaplerResponse rsp, @QueryParameter("query")String query) throws IOException, ServletException {
79
        Result r = new Result();
80 81
        for (SuggestedItem item : getSuggestions(req, query))
            r.suggestions.add(new Item(item.getPath()));
82 83 84 85

        rsp.serveExposedBean(req,r,Flavor.JSON);
    }

86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
    /**
     * Gets the list of suggestions that match the given query.
     *
     * @return
     *      can be empty but never null. The size of the list is always smaller than
     *      a certain threshold to avoid showing too many options. 
     */
    public List<SuggestedItem> getSuggestions(StaplerRequest req, String query) {
        Set<String> paths = new HashSet<String>();  // paths already added, to control duplicates
        List<SuggestedItem> r = new ArrayList<SuggestedItem>();
        for (SuggestedItem i : suggest(makeSuggestIndex(req), query)) {
            if(r.size()>20) break;
            if(paths.add(i.getPath()))
                r.add(i);
        }
        return r;
    }

104 105 106 107 108 109 110 111 112 113 114 115 116 117
    /**
     * Creates merged search index for suggestion.
     */
    private SearchIndex makeSuggestIndex(StaplerRequest req) {
        SearchIndexBuilder builder = new SearchIndexBuilder();
        for (Ancestor a : req.getAncestors()) {
            if (a.getObject() instanceof SearchableModelObject) {
                SearchableModelObject smo = (SearchableModelObject) a.getObject();
                builder.add(smo.getSearchIndex());
            }
        }
        return builder.make();
    }

118 119 120
    @ExportedBean
    public static class Result {
        @Exported
121
        public List<Item> suggestions = new ArrayList<Item>();
122 123 124 125 126 127 128 129 130 131 132 133
    }

    @ExportedBean(defaultVisibility=999)
    public static class Item {
        @Exported
        public String name;

        public Item(String name) {
            this.name = name;
        }
    }

134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
    private enum Mode {
        FIND {
            void find(SearchIndex index, String token, List<SearchItem> result) {
                index.find(token, result);
            }
        },
        SUGGEST {
            void find(SearchIndex index, String token, List<SearchItem> result) {
                index.suggest(token, result);
            }
        };

        abstract void find(SearchIndex index, String token, List<SearchItem> result);

    }

K
bug fix  
kohsuke 已提交
150 151 152 153 154
    /**
     * Performs a search and returns the match, or null if no match was found.
     */
    public static SuggestedItem find(SearchIndex index, String query) {
        List<SuggestedItem> r = find(Mode.FIND, index, query);
155 156 157 158 159 160 161
        if(r.isEmpty()) return null;
        else            return r.get(0);
    }

    public static List<SuggestedItem> suggest(SearchIndex index, final String tokenList) {

        class Tag implements Comparable<Tag>{
K
kohsuke 已提交
162 163 164
            final SuggestedItem item;
            final int distance;
            /** If the path to this suggestion starts with the token list, 1. Otherwise 0. */
K
kohsuke 已提交
165
            final int prefixMatch;
166 167

            Tag(SuggestedItem i) {
K
kohsuke 已提交
168
                item = i;
169
                distance = EditDistance.editDistance(i.getPath(),tokenList);
K
kohsuke 已提交
170
                prefixMatch = i.getPath().startsWith(tokenList)?1:0;
171 172 173
            }

            public int compareTo(Tag that) {
K
kohsuke 已提交
174
                int r = this.prefixMatch -that.prefixMatch;
K
kohsuke 已提交
175
                if(r!=0)    return -r;  // ones with head match should show up earlier
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193
                return this.distance-that.distance;
            }
        }

        List<Tag> buf = new ArrayList<Tag>();
        List<SuggestedItem> items = find(Mode.SUGGEST, index, tokenList);

        // sort them
        for( SuggestedItem i : items)
            buf.add(new Tag(i));
        Collections.sort(buf);
        items.clear();
        for (Tag t : buf)
            items.add(t.item);

        return items;
    }

194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
    static final class TokenList {
        private final String[] tokens;

        public TokenList(String tokenList) {
            tokens = tokenList.split("(?<=\\s)(?=\\S)");
        }

        public int length() { return tokens.length; }

        /**
         * Returns {@link List} such that its <tt>get(end)</tt>
         * returns the concatanation of [token_start,...,token_end]
         * (both end inclusive.)
         */
        public List<String> subSequence(final int start) {
            return new AbstractList<String>() {
                public String get(int index) {
                    StringBuilder buf = new StringBuilder();
                    for(int i=start; i<=start+index; i++ )
                        buf.append(tokens[i]);
                    return buf.toString().trim();
                }

                public int size() {
                    return tokens.length-start;
                }
            };
        }
    }

224
    private static List<SuggestedItem> find(Mode m, SearchIndex index, String tokenList) {
225 226 227 228 229 230 231
        TokenList tokens = new TokenList(tokenList);
        if(tokens.length()==0) return Collections.emptyList();   // no tokens given

        List<SuggestedItem>[] paths = new List[tokens.length()+1]; // we won't use [0].
        for(int i=1;i<=tokens.length();i++)
            paths[i] = new ArrayList<SuggestedItem>();

232 233 234 235
        List<SearchItem> items = new ArrayList<SearchItem>(); // items found in 1 step


        // first token
236 237 238 239 240 241 242 243
        int w=1;    // width of token
        for (String token : tokens.subSequence(0)) {
            items.clear();
            m.find(index,token,items);
            for (SearchItem si : items)
                paths[w].add(new SuggestedItem(si));
            w++;
        }
244 245

        // successive tokens
246 247 248 249 250 251 252 253 254 255 256 257
        for (int j=1; j<tokens.length(); j++) {
            // for each length
            w=1;
            for (String token : tokens.subSequence(j)) {
                // for each candidate
                for (SuggestedItem r : paths[j]) {
                    items.clear();
                    m.find(r.item.getSearchIndex(),token,items);
                    for (SearchItem i : items)
                        paths[j+w].add(new SuggestedItem(r,i));
                }
                w++;
258 259 260
            }
        }

261
        return paths[tokens.length()];
262 263
    }
}