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 org.apache.commons.lang3.StringUtils;
022import org.apache.commons.lang3.time.StopWatch;
023import org.apache.logging.log4j.LogManager;
024import org.apache.logging.log4j.Logger;
025import org.apache.wiki.ajax.AjaxUtil;
026import org.apache.wiki.ajax.WikiAjaxDispatcherServlet;
027import org.apache.wiki.ajax.WikiAjaxServlet;
028import org.apache.wiki.api.core.Context;
029import org.apache.wiki.api.core.ContextEnum;
030import org.apache.wiki.api.core.Engine;
031import org.apache.wiki.api.core.Page;
032import org.apache.wiki.api.exceptions.FilterException;
033import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
034import org.apache.wiki.api.filters.BasePageFilter;
035import org.apache.wiki.api.search.SearchResult;
036import org.apache.wiki.api.spi.Wiki;
037import org.apache.wiki.event.WikiEvent;
038import org.apache.wiki.event.WikiEventManager;
039import org.apache.wiki.event.WikiPageEvent;
040import org.apache.wiki.pages.PageManager;
041import org.apache.wiki.parser.MarkupParser;
042import org.apache.wiki.references.ReferenceManager;
043import org.apache.wiki.util.ClassUtil;
044import org.apache.wiki.util.TextUtil;
045
046import jakarta.servlet.http.HttpServletRequest;
047import jakarta.servlet.http.HttpServletResponse;
048import java.io.IOException;
049import java.util.ArrayList;
050import java.util.Collection;
051import java.util.Collections;
052import java.util.Comparator;
053import java.util.HashMap;
054import java.util.Iterator;
055import java.util.List;
056import java.util.Locale;
057import java.util.Map;
058import java.util.Properties;
059import java.util.Set;
060import org.apache.wiki.api.plugin.Plugin;
061import org.apache.wiki.plugin.PluginManager;
062import static org.apache.wiki.search.SearchManager.PLUGIN_SEARCH;
063
064
065/**
066 *  Manages searching the Wiki.
067 *
068 *  @since 2.2.21.
069 */
070public class DefaultSearchManager extends BasePageFilter implements SearchManager {
071
072    private static final Logger LOG = LogManager.getLogger( DefaultSearchManager.class );
073
074    private SearchProvider m_searchProvider;
075
076    /**
077     *  Creates a new SearchManager.
078     *
079     *  @param engine The Engine that owns this SearchManager.
080     *  @param properties The list of Properties.
081     *  @throws FilterException If it cannot be instantiated.
082     */
083    public DefaultSearchManager( final Engine engine, final Properties properties ) throws FilterException {
084        initialize( engine, properties );
085        WikiEventManager.addWikiEventListener( m_engine.getManager( PageManager.class ), this );
086
087        // TODO: Replace with custom annotations. See JSPWIKI-566
088        WikiAjaxDispatcherServlet.registerServlet( JSON_SEARCH, new JSONSearch() );
089        WikiAjaxDispatcherServlet.registerServlet( PLUGIN_SEARCH, new PluginSearch() );
090    }
091
092    /**
093     *  Provides a JSON AJAX API to the JSPWiki Plugin discovery mechanism,
094     *  primarily used for [{}] based auto complete
095     */
096    public class PluginSearch implements WikiAjaxServlet {
097
098        public static final String AJAX_ACTION_PLUGINS = "plugins";
099        public static final int DEFAULT_MAX_RESULTS = 20;
100        public int maxResults = DEFAULT_MAX_RESULTS;
101
102        /** {@inheritDoc} */
103        @Override
104        public String getServletMapping() {
105            return PLUGIN_SEARCH;
106        }
107        public static class SimpleSnipData {
108            public String displayName;
109            public String snip;
110        }
111
112        /** {@inheritDoc} */
113        @Override
114        public void service( final HttpServletRequest req,
115                             final HttpServletResponse resp,
116                             final String actionName,
117                             final List< String > params ) throws IOException {
118            String result = "[]";
119            resp.setContentType("application/json");
120            if( actionName != null && StringUtils.isNotBlank( actionName ) ) {
121                if( actionName.equals( AJAX_ACTION_PLUGINS ) ) {
122                    LOG.debug( "Calling getPlugins() START" );
123                    PluginManager mgr = m_engine.getManager(PluginManager.class);
124                    final List< Plugin > plugins = mgr.getDiscoveredPlugins();
125                    List< SimpleSnipData > callResults = new ArrayList<>();
126                   
127                    Locale locale = req.getLocale();
128                    if (locale == null) {
129                        locale = Locale.getDefault();
130                    }
131                    final Locale sorter = locale;
132                    Collections.sort(plugins, new Comparator<Plugin>() {
133                        @Override
134                        public int compare(Plugin o1, Plugin o2) {
135                            return o1.getDisplayName(sorter).compareTo(o2.getDisplayName(sorter));
136                        }
137                    });
138                    for (Plugin p : plugins) {
139                        try {
140                            SimpleSnipData data = new SimpleSnipData();
141                            data.snip = p.getSnipExample();
142                            data.displayName = p.getDisplayName(locale);
143                            callResults.add(data);
144                        } catch (Throwable t) {
145                            LOG.warn("failed to get plugin informatiom from " + p.getClass().getCanonicalName(), t);
146                        }
147                    }
148                    LOG.debug("Calling getSuggestions() DONE. " + callResults.size() );
149                    result = AjaxUtil.toJson( callResults );
150                } 
151            }
152            LOG.debug( "result=" + result );
153            resp.getWriter().write( result );
154        }
155    }
156    
157    /**
158     *  Provides a JSON AJAX API to the JSPWiki Search Engine.
159     */
160    public class JSONSearch implements WikiAjaxServlet {
161
162        public static final String AJAX_ACTION_SUGGESTIONS = "suggestions";
163        public static final String AJAX_ACTION_PAGES = "pages";
164        public static final int DEFAULT_MAX_RESULTS = 20;
165        public int maxResults = DEFAULT_MAX_RESULTS;
166
167        /** {@inheritDoc} */
168        @Override
169        public String getServletMapping() {
170            return JSON_SEARCH;
171        }
172
173        /** {@inheritDoc} */
174        @Override
175        public void service( final HttpServletRequest req,
176                             final HttpServletResponse resp,
177                             final String actionName,
178                             final List< String > params ) throws IOException {
179            String result = "[]";
180            resp.setContentType("application/json");
181            if( StringUtils.isNotBlank( actionName ) ) {
182                if( params.isEmpty() ) {
183                    return;
184                }
185                final String itemId = params.get( 0 );
186                LOG.debug( "itemId=" + itemId );
187                if( params.size() > 1 ) {
188                    final String maxResultsParam = params.get( 1 );
189                    LOG.debug( "maxResultsParam=" + maxResultsParam );
190                    if( StringUtils.isNotBlank( maxResultsParam ) && StringUtils.isNumeric( maxResultsParam ) ) {
191                        maxResults = Integer.parseInt( maxResultsParam );
192                    }
193                }
194
195                if( actionName.equals( AJAX_ACTION_SUGGESTIONS ) ) {
196                    LOG.debug( "Calling getSuggestions() START" );
197                    final List< String > callResults = getSuggestions( itemId, maxResults );
198                    LOG.debug( "Calling getSuggestions() DONE. " + callResults.size() );
199                    result = AjaxUtil.toJson( callResults );
200                } else if( actionName.equals( AJAX_ACTION_PAGES ) ) {
201                    LOG.debug("Calling findPages() START");
202                    final Context wikiContext = Wiki.context().create( m_engine, req, ContextEnum.PAGE_VIEW.getRequestContext() );
203                    final List< Map< String, Object > > callResults = findPages( itemId, maxResults, wikiContext );
204                    LOG.debug( "Calling findPages() DONE. " + callResults.size() );
205                    result = AjaxUtil.toJson( callResults );
206                }
207            }
208            LOG.debug( "result=" + result );
209            resp.getWriter().write( result );
210        }
211
212        /**
213         *  Provides a list of suggestions to use for a page name. Currently, the algorithm just looks into the value parameter,
214         *  and returns all page names from that.
215         *
216         *  @param wikiName the page name
217         *  @param maxLength maximum number of suggestions
218         *  @return the suggestions
219         */
220        public List< String > getSuggestions( String wikiName, final int maxLength ) {
221            final StopWatch sw = new StopWatch();
222            sw.start();
223            final List< String > list = new ArrayList<>( maxLength );
224            if( !wikiName.isEmpty() ) {
225                // split pagename and attachment filename
226                String filename = "";
227                final int pos = wikiName.indexOf("/");
228                if( pos >= 0 ) {
229                    filename = wikiName.substring( pos ).toLowerCase();
230                    wikiName = wikiName.substring( 0, pos );
231                }
232
233                final String cleanWikiName = MarkupParser.cleanLink(wikiName).toLowerCase() + filename;
234                final String oldStyleName = MarkupParser.wikifyLink(wikiName).toLowerCase() + filename;
235                final Set< String > allPages = m_engine.getManager( ReferenceManager.class ).findCreated();
236               
237                int counter = 0;
238                for( final Iterator< String > i = allPages.iterator(); i.hasNext() && counter < maxLength; ) {
239                    final String p = i.next();
240                    final String pp = p.toLowerCase();
241                    if( pp.startsWith( cleanWikiName) || pp.startsWith( oldStyleName ) ) {
242                        list.add( p );
243                        counter++;
244                    }
245                }
246            }
247
248            sw.stop();
249            LOG.debug( "Suggestion request for {} done in {}", wikiName, sw );
250            return list;
251        }
252
253        /**
254         *  Performs a full search of pages.
255         *
256         *  @param searchString The query string
257         *  @param maxLength How many hits to return
258         *  @return the pages found
259         */
260        public List< Map< String, Object > > findPages( final String searchString, final int maxLength, final Context wikiContext ) {
261            final StopWatch sw = new StopWatch();
262            sw.start();
263
264            final List< Map< String, Object > > list = new ArrayList<>( maxLength );
265            if( !searchString.isEmpty() ) {
266                try {
267                    final Collection< SearchResult > c;
268                    if( m_searchProvider instanceof LuceneSearchProvider ) {
269                        c = ( ( LuceneSearchProvider )m_searchProvider ).findPages( searchString, 0, wikiContext );
270                    } else {
271                        c = m_searchProvider.findPages( searchString, wikiContext );
272                    }
273
274                    int count = 0;
275                    for( final Iterator< SearchResult > i = c.iterator(); i.hasNext() && count < maxLength; count++ ) {
276                        final SearchResult sr = i.next();
277                        final HashMap< String, Object > hm = new HashMap<>();
278                        hm.put( "page", sr.getPage().getName() );
279                        hm.put( "score", sr.getScore() );
280                        list.add( hm );
281                    }
282                } catch( final Exception e ) {
283                    LOG.info( "AJAX search failed; ", e );
284                }
285            }
286
287            sw.stop();
288            LOG.debug( "AJAX search complete in {}", sw );
289            return list;
290        }
291    }
292
293
294    /** {@inheritDoc} */
295    @Override
296    public void initialize( final Engine engine, final Properties properties ) throws FilterException {
297        m_engine = engine;
298        loadSearchProvider(properties);
299
300        try {
301            m_searchProvider.initialize( engine, properties );
302        } catch( final NoRequiredPropertyException | IOException e ) {
303            LOG.error( e.getMessage(), e );
304        }
305    }
306
307    private void loadSearchProvider( final Properties properties ) {
308        // See if we're using Lucene, and if so, ensure that its index directory is up-to-date.
309        final String providerClassName = TextUtil.getStringProperty( properties, PROP_SEARCHPROVIDER, DEFAULT_SEARCHPROVIDER );
310
311        try {
312            m_searchProvider = ClassUtil.buildInstance( "org.apache.wiki.search", providerClassName );
313        } catch( final ReflectiveOperationException e ) {
314            LOG.warn( "Failed loading SearchProvider, will use BasicSearchProvider.", e );
315        }
316
317        if( null == m_searchProvider ) {
318            m_searchProvider = new BasicSearchProvider();
319        }
320        LOG.debug( "Loaded search provider {}", m_searchProvider );
321    }
322
323    /** {@inheritDoc} */
324    @Override
325    public SearchProvider getSearchEngine()
326    {
327        return m_searchProvider;
328    }
329
330    /** {@inheritDoc} */
331    @Override
332    public void actionPerformed( final WikiEvent event ) {
333        if( event instanceof WikiPageEvent ) {
334            final String pageName = ( ( WikiPageEvent ) event ).getPageName();
335            if( event.getType() == WikiPageEvent.PAGE_DELETE_REQUEST ) {
336                final Page p = m_engine.getManager( PageManager.class ).getPage( pageName );
337                if( p != null ) {
338                    pageRemoved( p );
339                }
340            }
341            if( event.getType() == WikiPageEvent.PAGE_REINDEX ) {
342                final Page p = m_engine.getManager( PageManager.class ).getPage( pageName );
343                if( p != null ) {
344                    reindexPage( p );
345                }
346            }
347        }
348    }
349
350}