001/*
002    Licensed to the Apache Software Foundation (ASF) under one
003    or more contributor license agreements.  See the NOTICE file
004    distributed with this work for additional information
005    regarding copyright ownership.  The ASF licenses this file
006    to you under the Apache License, Version 2.0 (the
007    "License"); you may not use this file except in compliance
008    with the License.  You may obtain a copy of the License at
009
010       http://www.apache.org/licenses/LICENSE-2.0
011
012    Unless required by applicable law or agreed to in writing,
013    software distributed under the License is distributed on an
014    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015    KIND, either express or implied.  See the License for the
016    specific language governing permissions and limitations
017    under the License.
018 */
019package org.apache.wiki.search;
020
021import java.io.IOException;
022import java.util.ArrayList;
023import java.util.Collection;
024import java.util.HashMap;
025import java.util.Iterator;
026import java.util.List;
027import java.util.Map;
028import java.util.Properties;
029import java.util.Set;
030
031import javax.servlet.ServletException;
032import javax.servlet.http.HttpServletRequest;
033import javax.servlet.http.HttpServletResponse;
034
035import org.apache.commons.lang.StringUtils;
036import org.apache.commons.lang.time.StopWatch;
037import org.apache.log4j.Logger;
038import org.apache.wiki.WikiContext;
039import org.apache.wiki.WikiEngine;
040import org.apache.wiki.WikiPage;
041import org.apache.wiki.ajax.AjaxUtil;
042import org.apache.wiki.ajax.WikiAjaxDispatcherServlet;
043import org.apache.wiki.ajax.WikiAjaxServlet;
044import org.apache.wiki.api.exceptions.FilterException;
045import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
046import org.apache.wiki.api.exceptions.ProviderException;
047import org.apache.wiki.api.filters.BasicPageFilter;
048import org.apache.wiki.event.WikiEvent;
049import org.apache.wiki.event.WikiEventListener;
050import org.apache.wiki.event.WikiEventUtils;
051import org.apache.wiki.event.WikiPageEvent;
052import org.apache.wiki.modules.InternalModule;
053import org.apache.wiki.parser.MarkupParser;
054import org.apache.wiki.util.ClassUtil;
055import org.apache.wiki.util.TextUtil;
056
057/**
058 *  Manages searching the Wiki.
059 *
060 *  @since 2.2.21.
061 */
062public class SearchManager extends BasicPageFilter implements InternalModule, WikiEventListener {
063
064    private static final Logger log = Logger.getLogger(SearchManager.class);
065
066    private static final String DEFAULT_SEARCHPROVIDER  = "org.apache.wiki.search.LuceneSearchProvider";
067
068    /** Old option, now deprecated. */
069    private static final String PROP_USE_LUCENE        = "jspwiki.useLucene";
070
071    /**
072     *  Property name for setting the search provider. Value is <tt>{@value}</tt>.
073     */
074    public static final String PROP_SEARCHPROVIDER     = "jspwiki.searchProvider";
075
076    private SearchProvider    m_searchProvider;
077
078    /**
079     *  The name of the JSON object that manages search.
080     */
081    public static final String JSON_SEARCH = "search";
082
083    /**
084     *  Creates a new SearchManager.
085     *
086     *  @param engine The WikiEngine that owns this SearchManager.
087     *  @param properties The list of Properties.
088     *  @throws FilterException If it cannot be instantiated.
089     */
090    public SearchManager( WikiEngine engine, Properties properties )
091        throws FilterException
092    {
093        initialize( engine, properties );
094
095        WikiEventUtils.addWikiEventListener(m_engine.getPageManager(),
096                                            WikiPageEvent.PAGE_DELETE_REQUEST, this);
097
098        //TODO: Replace with custom annotations. See JSPWIKI-566
099        WikiAjaxDispatcherServlet.registerServlet( JSON_SEARCH, new JSONSearch() );
100    }
101
102    /**
103     *  Provides a JSON RPC API to the JSPWiki Search Engine.
104     */
105    public class JSONSearch implements WikiAjaxServlet
106    {
107        public static final String AJAX_ACTION_SUGGESTIONS = "suggestions";
108        public static final String AJAX_ACTION_PAGES = "pages";
109        public static final int DEFAULT_MAX_RESULTS = 20;
110        public int maxResults = DEFAULT_MAX_RESULTS;
111
112        @Override
113        public String getServletMapping() {
114            return JSON_SEARCH;
115        }
116
117        @Override
118        public void service(HttpServletRequest req, HttpServletResponse resp, String actionName, List<String> params)
119                throws ServletException, IOException {
120            String result = "";
121            if (StringUtils.isNotBlank(actionName)) {
122                if (params.size()<1) {
123                    return;
124                }
125                String itemId = params.get(0);
126                log.debug("itemId="+itemId);
127                if (params.size()>1) {
128                    String maxResultsParam  = params.get(1);
129                    log.debug("maxResultsParam="+maxResultsParam);
130                    if (StringUtils.isNotBlank(maxResultsParam) && StringUtils.isNumeric(maxResultsParam)) {
131                        maxResults = Integer.parseInt(maxResultsParam);
132                    }
133                }
134
135                if (actionName.equals(AJAX_ACTION_SUGGESTIONS)) {
136                    List<String> callResults = new ArrayList<>();
137                    log.debug("Calling getSuggestions() START");
138                    callResults = getSuggestions(itemId, maxResults);
139                    log.debug("Calling getSuggestions() DONE. "+callResults.size());
140                    result = AjaxUtil.toJson(callResults);
141                } else if (actionName.equals(AJAX_ACTION_PAGES)) {
142                    List<Map<String,Object>> callResults = new ArrayList<>();
143                    log.debug("Calling findPages() START");
144                    WikiContext wikiContext = m_engine.createContext(req, WikiContext.VIEW);
145                    if (wikiContext == null) {
146                        throw new ServletException("Could not create a WikiContext from the request "+req);
147                    }
148                    callResults = findPages(itemId, maxResults, wikiContext);
149                    log.debug("Calling findPages() DONE. "+callResults.size());
150                    result = AjaxUtil.toJson(callResults);
151                }
152            }
153            log.debug("result="+result);
154            resp.getWriter().write(result);
155        }
156
157        /**
158         *  Provides a list of suggestions to use for a page name.
159         *  Currently the algorithm just looks into the value parameter,
160         *  and returns all page names from that.
161         *
162         *  @param wikiName the page name
163         *  @param maxLength maximum number of suggestions
164         *  @return the suggestions
165         */
166        public List<String> getSuggestions( String wikiName, int maxLength )
167        {
168            StopWatch sw = new StopWatch();
169            sw.start();
170            List<String> list = new ArrayList<>(maxLength);
171
172            if( wikiName.length() > 0 )
173            {
174
175                // split pagename and attachment filename
176                String filename = "";
177                int pos = wikiName.indexOf("/");
178                if( pos >= 0 )
179                {
180                    filename = wikiName.substring( pos ).toLowerCase();
181                    wikiName = wikiName.substring( 0, pos );
182                }
183
184                String cleanWikiName = MarkupParser.cleanLink(wikiName).toLowerCase() + filename;
185
186                String oldStyleName = MarkupParser.wikifyLink(wikiName).toLowerCase() + filename;
187
188                Set< String > allPages = m_engine.getReferenceManager().findCreated();
189
190                int counter = 0;
191                for( Iterator< String > i = allPages.iterator(); i.hasNext() && counter < maxLength; )
192                {
193                    String p = i.next();
194                    String pp = p.toLowerCase();
195                    if( pp.startsWith( cleanWikiName) || pp.startsWith( oldStyleName ) )
196                    {
197                        list.add( p );
198                        counter++;
199                    }
200                }
201            }
202
203            sw.stop();
204            if( log.isDebugEnabled() ) log.debug("Suggestion request for "+wikiName+" done in "+sw);
205            return list;
206        }
207
208        /**
209         *  Performs a full search of pages.
210         *
211         *  @param searchString The query string
212         *  @param maxLength How many hits to return
213         *  @return the pages found
214         */
215        public List<Map<String,Object>> findPages( String searchString, int maxLength, WikiContext wikiContext )
216        {
217            StopWatch sw = new StopWatch();
218            sw.start();
219
220            List<Map<String,Object>> list = new ArrayList<>(maxLength);
221
222            if( searchString.length() > 0 ) {
223                try {
224                    Collection< SearchResult > c;
225
226                    if( m_searchProvider instanceof LuceneSearchProvider ) {
227                        c = ((LuceneSearchProvider)m_searchProvider).findPages( searchString, 0, wikiContext );
228                    } else {
229                        c = m_searchProvider.findPages( searchString, wikiContext );
230                    }
231
232                    int count = 0;
233                    for( Iterator< SearchResult > i = c.iterator(); i.hasNext() && count < maxLength; count++ )
234                    {
235                        SearchResult sr = i.next();
236                        HashMap<String,Object> hm = new HashMap<>();
237                        hm.put( "page", sr.getPage().getName() );
238                        hm.put( "score", sr.getScore() );
239                        list.add( hm );
240                    }
241                } catch(Exception e) {
242                    log.info("AJAX search failed; ",e);
243                }
244            }
245
246            sw.stop();
247            if( log.isDebugEnabled() ) log.debug("AJAX search complete in "+sw);
248            return list;
249        }
250    }
251
252
253    /**
254     *  This particular method starts off indexing and all sorts of various activities,
255     *  so you need to run this last, after things are done.
256     *
257     * @param engine the wiki engine
258     * @param properties the properties used to initialize the wiki engine
259     * @throws FilterException if the search provider failed to initialize
260     */
261    @Override
262    public void initialize(WikiEngine engine, Properties properties)
263        throws FilterException
264    {
265        m_engine = engine;
266
267        loadSearchProvider(properties);
268
269        try
270        {
271            m_searchProvider.initialize(engine, properties);
272        }
273        catch (NoRequiredPropertyException e)
274        {
275            log.error( e.getMessage(), e );
276        }
277        catch (IOException e)
278        {
279            log.error( e.getMessage(), e );
280        }
281    }
282
283    private void loadSearchProvider(Properties properties) {
284        //
285        // See if we're using Lucene, and if so, ensure that its
286        // index directory is up to date.
287        //
288        String useLucene = properties.getProperty(PROP_USE_LUCENE);
289
290        // FIXME: Obsolete, remove, or change logic to first load searchProvder?
291        // If the old jspwiki.useLucene property is set we use that instead of the searchProvider class.
292        if( useLucene != null )
293        {
294            log.info( PROP_USE_LUCENE+" is deprecated; please use "+PROP_SEARCHPROVIDER+"=<your search provider> instead." );
295            if( TextUtil.isPositive( useLucene ) )
296            {
297                m_searchProvider = new LuceneSearchProvider();
298            }
299            else
300            {
301                m_searchProvider = new BasicSearchProvider();
302            }
303            log.debug("useLucene was set, loading search provider " + m_searchProvider);
304            return;
305        }
306
307        String providerClassName = properties.getProperty( PROP_SEARCHPROVIDER, DEFAULT_SEARCHPROVIDER );
308
309        try {
310            Class<?> providerClass = ClassUtil.findClass( "org.apache.wiki.search", providerClassName );
311            m_searchProvider = (SearchProvider)providerClass.newInstance();
312        } catch( ClassNotFoundException | InstantiationException | IllegalAccessException e ) {
313            log.warn("Failed loading SearchProvider, will use BasicSearchProvider.", e);
314        }
315
316        if( null == m_searchProvider )
317        {
318            // FIXME: Make a static with the default search provider
319            m_searchProvider = new BasicSearchProvider();
320        }
321        log.debug("Loaded search provider " + m_searchProvider);
322    }
323
324    /**
325     *  Returns the SearchProvider used.
326     *
327     *  @return The current SearchProvider.
328     */
329    public SearchProvider getSearchEngine()
330    {
331        return m_searchProvider;
332    }
333
334    /**
335     *  Sends a search to the current search provider. The query is is whatever native format
336     *  the query engine wants to use.
337     *
338     * @param query The query.  Null is safe, and is interpreted as an empty query.
339     * @param wikiContext the context within which to run the search
340     * @return A collection of WikiPages that matched.
341     * @throws ProviderException If the provider fails and a search cannot be completed.
342     * @throws IOException If something else goes wrong.
343     */
344    public Collection< SearchResult > findPages( String query, WikiContext wikiContext )
345        throws ProviderException, IOException
346    {
347        if( query == null ) query = "";
348        return m_searchProvider.findPages( query, wikiContext );
349    }
350
351    /**
352     *  Removes the page from the search cache (if any).
353     *  @param page  The page to remove
354     */
355    public void pageRemoved(WikiPage page)
356    {
357        m_searchProvider.pageRemoved(page);
358    }
359
360    /**
361     *  Reindexes the page.
362     *
363     *  @param wikiContext {@inheritDoc}
364     *  @param content {@inheritDoc}
365     */
366    @Override
367    public void postSave( WikiContext wikiContext, String content )
368    {
369        //
370        //  Makes sure that we're indexing the latest version of this
371        //  page.
372        //
373        WikiPage p = m_engine.getPage( wikiContext.getPage().getName() );
374        reindexPage( p );
375    }
376
377    /**
378     *   Forces the reindex of the given page.
379     *
380     *   @param page The page.
381     */
382    public void reindexPage(WikiPage page)
383    {
384        m_searchProvider.reindexPage(page);
385    }
386
387    /**
388     *  If the page has been deleted, removes it from the index.
389     *
390     *  @param event {@inheritDoc}
391     */
392    @Override
393    public void actionPerformed(WikiEvent event)
394    {
395        if( (event instanceof WikiPageEvent) && (event.getType() == WikiPageEvent.PAGE_DELETE_REQUEST) )
396        {
397            String pageName = ((WikiPageEvent) event).getPageName();
398
399            WikiPage p = m_engine.getPage( pageName );
400            if( p != null )
401            {
402                pageRemoved( p );
403            }
404        }
405    }
406
407}