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.log4j.Logger;
023import org.apache.lucene.analysis.Analyzer;
024import org.apache.lucene.analysis.TokenStream;
025import org.apache.lucene.document.Document;
026import org.apache.lucene.document.Field;
027import org.apache.lucene.document.StringField;
028import org.apache.lucene.document.TextField;
029import org.apache.lucene.index.DirectoryReader;
030import org.apache.lucene.index.IndexReader;
031import org.apache.lucene.index.IndexWriter;
032import org.apache.lucene.index.IndexWriterConfig;
033import org.apache.lucene.index.IndexWriterConfig.OpenMode;
034import org.apache.lucene.index.Term;
035import org.apache.lucene.queryparser.classic.MultiFieldQueryParser;
036import org.apache.lucene.queryparser.classic.ParseException;
037import org.apache.lucene.queryparser.classic.QueryParser;
038import org.apache.lucene.search.IndexSearcher;
039import org.apache.lucene.search.Query;
040import org.apache.lucene.search.ScoreDoc;
041import org.apache.lucene.search.TermQuery;
042import org.apache.lucene.search.highlight.Highlighter;
043import org.apache.lucene.search.highlight.InvalidTokenOffsetsException;
044import org.apache.lucene.search.highlight.QueryScorer;
045import org.apache.lucene.search.highlight.SimpleHTMLEncoder;
046import org.apache.lucene.search.highlight.SimpleHTMLFormatter;
047import org.apache.lucene.store.Directory;
048import org.apache.lucene.store.SimpleFSDirectory;
049import org.apache.wiki.InternalWikiException;
050import org.apache.wiki.WatchDog;
051import org.apache.wiki.WikiBackgroundThread;
052import org.apache.wiki.WikiContext;
053import org.apache.wiki.WikiEngine;
054import org.apache.wiki.WikiPage;
055import org.apache.wiki.WikiProvider;
056import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
057import org.apache.wiki.api.exceptions.ProviderException;
058import org.apache.wiki.attachment.Attachment;
059import org.apache.wiki.attachment.AttachmentManager;
060import org.apache.wiki.auth.AuthorizationManager;
061import org.apache.wiki.auth.permissions.PagePermission;
062import org.apache.wiki.parser.MarkupParser;
063import org.apache.wiki.providers.WikiPageProvider;
064import org.apache.wiki.util.ClassUtil;
065import org.apache.wiki.util.FileUtil;
066import org.apache.wiki.util.TextUtil;
067
068import java.io.File;
069import java.io.IOException;
070import java.io.InputStream;
071import java.io.InputStreamReader;
072import java.io.StringReader;
073import java.io.StringWriter;
074import java.lang.reflect.Constructor;
075import java.util.ArrayList;
076import java.util.Collection;
077import java.util.Collections;
078import java.util.Date;
079import java.util.List;
080import java.util.Properties;
081
082
083/**
084 *  Interface for the search providers that handle searching the Wiki
085 *
086 *  @since 2.2.21.
087 */
088public class LuceneSearchProvider implements SearchProvider {
089
090    protected static final Logger log = Logger.getLogger(LuceneSearchProvider.class);
091
092    private WikiEngine m_engine;
093
094    // Lucene properties.
095
096    /** Which analyzer to use.  Default is StandardAnalyzer. */
097    public static final String PROP_LUCENE_ANALYZER      = "jspwiki.lucene.analyzer";
098
099    private static final String PROP_LUCENE_INDEXDELAY   = "jspwiki.lucene.indexdelay";
100    private static final String PROP_LUCENE_INITIALDELAY = "jspwiki.lucene.initialdelay";
101
102    private String m_analyzerClass = "org.apache.lucene.analysis.standard.ClassicAnalyzer";
103
104    private static final String LUCENE_DIR             = "lucene";
105
106    /** These attachment file suffixes will be indexed. */
107    public static final String[] SEARCHABLE_FILE_SUFFIXES = new String[] { ".txt", ".ini", ".xml", ".html", "htm", ".mm", ".htm",
108                                                                           ".xhtml", ".java", ".c", ".cpp", ".php", ".asm", ".sh",
109                                                                           ".properties", ".kml", ".gpx", ".loc", ".md", ".xml" };
110
111    protected static final String LUCENE_ID            = "id";
112    protected static final String LUCENE_PAGE_CONTENTS = "contents";
113    protected static final String LUCENE_AUTHOR        = "author";
114    protected static final String LUCENE_ATTACHMENTS   = "attachment";
115    protected static final String LUCENE_PAGE_NAME     = "name";
116    protected static final String LUCENE_PAGE_KEYWORDS = "keywords";
117
118    private String           m_luceneDirectory;
119    protected List<Object[]> m_updates = Collections.synchronizedList( new ArrayList<>() ); 
120
121    /** Maximum number of fragments from search matches. */
122    private static final int MAX_FRAGMENTS = 3;
123
124    /** The maximum number of hits to return from searches. */
125    public static final int MAX_SEARCH_HITS = 99_999;
126    
127    private static String c_punctuationSpaces = StringUtils.repeat(" ", MarkupParser.PUNCTUATION_CHARS_ALLOWED.length() );
128
129    /**
130     *  {@inheritDoc}
131     */
132    @Override
133    public void initialize(WikiEngine engine, Properties props)
134            throws NoRequiredPropertyException, IOException
135    {
136        m_engine = engine;
137
138        m_luceneDirectory = engine.getWorkDir()+File.separator+LUCENE_DIR;
139
140        int initialDelay = TextUtil.getIntegerProperty( props, PROP_LUCENE_INITIALDELAY, LuceneUpdater.INITIAL_DELAY );
141        int indexDelay   = TextUtil.getIntegerProperty( props, PROP_LUCENE_INDEXDELAY, LuceneUpdater.INDEX_DELAY );
142
143        m_analyzerClass = TextUtil.getStringProperty( props, PROP_LUCENE_ANALYZER, m_analyzerClass );
144        // FIXME: Just to be simple for now, we will do full reindex
145        // only if no files are in lucene directory.
146
147        File dir = new File(m_luceneDirectory);
148
149        log.info("Lucene enabled, cache will be in: "+dir.getAbsolutePath());
150
151        try
152        {
153            if( !dir.exists() )
154            {
155                dir.mkdirs();
156            }
157
158            if( !dir.exists() || !dir.canWrite() || !dir.canRead() )
159            {
160                log.error("Cannot write to Lucene directory, disabling Lucene: "+dir.getAbsolutePath());
161                throw new IOException( "Invalid Lucene directory." );
162            }
163
164            String[] filelist = dir.list();
165
166            if( filelist == null )
167            {
168                throw new IOException( "Invalid Lucene directory: cannot produce listing: "+dir.getAbsolutePath());
169            }
170        }
171        catch ( IOException e )
172        {
173            log.error("Problem while creating Lucene index - not using Lucene.", e);
174        }
175
176        // Start the Lucene update thread, which waits first
177        // for a little while before starting to go through
178        // the Lucene "pages that need updating".
179        LuceneUpdater updater = new LuceneUpdater( m_engine, this, initialDelay, indexDelay );
180        updater.start();
181    }
182
183    /**
184     *  Returns the handling engine.
185     *
186     *  @return Current WikiEngine
187     */
188    protected WikiEngine getEngine()
189    {
190        return m_engine;
191    }
192
193    /**
194     *  Performs a full Lucene reindex, if necessary.
195     *
196     *  @throws IOException If there's a problem during indexing
197     */
198    protected void doFullLuceneReindex() throws IOException {
199        File dir = new File(m_luceneDirectory);
200
201        String[] filelist = dir.list();
202
203        if( filelist == null ) {
204            throw new IOException( "Invalid Lucene directory: cannot produce listing: "+dir.getAbsolutePath());
205        }
206
207        try {
208            if( filelist.length == 0 ) {
209                //
210                //  No files? Reindex!
211                //
212                Date start = new Date();
213
214                log.info("Starting Lucene reindexing, this can take a couple of minutes...");
215
216                Directory luceneDir = new SimpleFSDirectory( dir.toPath() );
217                try( IndexWriter writer = getIndexWriter( luceneDir ) )
218                {
219                    Collection< WikiPage > allPages = m_engine.getPageManager().getAllPages();
220                    for( WikiPage page : allPages ) {
221                        
222                        try {
223                            String text = m_engine.getPageManager().getPageText( page.getName(), WikiProvider.LATEST_VERSION );
224                            luceneIndexPage( page, text, writer );
225                        } catch( IOException e ) {
226                            log.warn( "Unable to index page " + page.getName() + ", continuing to next ", e );
227                        }
228                    }
229
230                    Collection< Attachment > allAttachments = m_engine.getAttachmentManager().getAllAttachments();
231                    for( Attachment att : allAttachments ) {
232                        try {
233                            String text = getAttachmentContent( att.getName(), WikiProvider.LATEST_VERSION );
234                            luceneIndexPage( att, text, writer );
235                        } catch( IOException e ) {
236                            log.warn( "Unable to index attachment " + att.getName() + ", continuing to next", e );
237                        }
238                    }
239
240                }
241
242                Date end = new Date();
243                log.info( "Full Lucene index finished in " + (end.getTime() - start.getTime()) + " milliseconds." );
244            } else {
245                log.info("Files found in Lucene directory, not reindexing.");
246            }
247        } catch ( IOException e ) {
248            log.error("Problem while creating Lucene index - not using Lucene.", e);
249        } catch ( ProviderException e ) {
250            log.error("Problem reading pages while creating Lucene index (JSPWiki won't start.)", e);
251            throw new IllegalArgumentException("unable to create Lucene index");
252        } catch( Exception e ) {
253            log.error("Unable to start lucene",e);
254        }
255
256    }
257
258    /**
259     *  Fetches the attachment content from the repository.
260     *  Content is flat text that can be used for indexing/searching or display
261     *  
262     *  @param attachmentName Name of the attachment.
263     *  @param version The version of the attachment.
264     *  
265     *  @return the content of the Attachment as a String.
266     */
267    protected String getAttachmentContent( String attachmentName, int version )
268    {
269        AttachmentManager mgr = m_engine.getAttachmentManager();
270
271        try
272        {
273            Attachment att = mgr.getAttachmentInfo( attachmentName, version );
274            //FIXME: Find out why sometimes att is null
275            if(att != null)
276            {
277                return getAttachmentContent( att );
278            }
279        }
280        catch (ProviderException e)
281        {
282            log.error("Attachment cannot be loaded", e);
283        }
284        // Something was wrong, no result is returned.
285        return null;
286    }
287
288    /**
289     * @param att Attachment to get content for. Filename extension is used to determine the type of the attachment.
290     * @return String representing the content of the file.
291     * FIXME This is a very simple implementation of some text-based attachment, mainly used for testing.
292     * This should be replaced /moved to Attachment search providers or some other 'pluggable' way to search attachments
293     */
294    protected String getAttachmentContent( Attachment att )
295    {
296        AttachmentManager mgr = m_engine.getAttachmentManager();
297        //FIXME: Add attachment plugin structure
298
299        String filename = att.getFileName();
300
301        boolean searchSuffix = false;
302        for( String suffix : SEARCHABLE_FILE_SUFFIXES )
303        {
304            if( filename.endsWith( suffix ) )
305            {
306                searchSuffix = true;
307            }
308        }
309
310        String out = filename;
311        if( searchSuffix )
312        {
313            try( final InputStream attStream = mgr.getAttachmentStream( att ); final StringWriter sout = new StringWriter() ) {
314                FileUtil.copyContents( new InputStreamReader( attStream ), sout );
315                out = out + " " + sout.toString();
316            } catch( ProviderException | IOException e ) {
317                log.error("Attachment cannot be loaded", e);
318            }
319        }
320
321        return out;
322    }
323
324    /**
325     *  Updates the lucene index for a single page.
326     *
327     *  @param page The WikiPage to check
328     *  @param text The page text to index.
329     */
330    protected synchronized void updateLuceneIndex( final WikiPage page, final String text ) {
331        log.debug("Updating Lucene index for page '" + page.getName() + "'...");
332        pageRemoved( page );
333
334        // Now add back the new version.
335        try( final Directory luceneDir = new SimpleFSDirectory( new File( m_luceneDirectory ).toPath() );
336             final IndexWriter writer = getIndexWriter( luceneDir ) ) {
337            luceneIndexPage( page, text, writer );
338        } catch( final IOException e ) {
339            log.error("Unable to update page '" + page.getName() + "' from Lucene index", e);
340            // reindexPage( page );
341        } catch( final Exception e ) {
342            log.error("Unexpected Lucene exception - please check configuration!",e);
343            // reindexPage( page );
344        }
345
346        log.debug("Done updating Lucene index for page '" + page.getName() + "'.");
347    }
348
349
350    private Analyzer getLuceneAnalyzer() throws ProviderException
351    {
352        try
353        {
354            Class< ? > clazz = ClassUtil.findClass( "", m_analyzerClass );
355            Constructor< ? > constructor = clazz.getConstructor();
356            Analyzer analyzer = (Analyzer) constructor.newInstance();
357            return analyzer;
358        }
359        catch( Exception e )
360        {
361            String msg = "Could not get LuceneAnalyzer class " + m_analyzerClass + ", reason: ";
362            log.error( msg, e );
363            throw new ProviderException( msg + e );
364        }
365    }
366
367    /**
368     *  Indexes page using the given IndexWriter.
369     *
370     *  @param page WikiPage
371     *  @param text Page text to index
372     *  @param writer The Lucene IndexWriter to use for indexing
373     *  @return the created index Document
374     *  @throws IOException If there's an indexing problem
375     */
376    protected Document luceneIndexPage( final WikiPage page, final String text, final IndexWriter writer ) throws IOException {
377        if( log.isDebugEnabled() ) {
378            log.debug( "Indexing " + page.getName() + "..." );
379        }
380        
381        // make a new, empty document
382        final Document doc = new Document();
383
384        if( text == null ) {
385            return doc;
386        }
387        final String indexedText = text.replace( "__", " " ); // be nice to Language Analyzers - cfr. JSPWIKI-893
388
389        // Raw name is the keyword we'll use to refer to this document for updates.
390        Field field = new Field( LUCENE_ID, page.getName(), StringField.TYPE_STORED );
391        doc.add( field );
392
393        // Body text.  It is stored in the doc for search contexts.
394        field = new Field( LUCENE_PAGE_CONTENTS, indexedText, TextField.TYPE_STORED );
395        doc.add( field );
396
397        // Allow searching by page name. Both beautified and raw
398        final String unTokenizedTitle = StringUtils.replaceChars( page.getName(),
399                                                                  MarkupParser.PUNCTUATION_CHARS_ALLOWED,
400                                                                  c_punctuationSpaces );
401
402        field = new Field( LUCENE_PAGE_NAME,
403                           TextUtil.beautifyString( page.getName() ) + " " + unTokenizedTitle,
404                           TextField.TYPE_STORED );
405        doc.add( field );
406
407        // Allow searching by authorname
408        if( page.getAuthor() != null ) {
409            field = new Field( LUCENE_AUTHOR, page.getAuthor(), TextField.TYPE_STORED );
410            doc.add( field );
411        }
412
413        // Now add the names of the attachments of this page
414        try {
415            final List< Attachment > attachments = m_engine.getAttachmentManager().listAttachments( page );
416            String attachmentNames = "";
417
418            for( final Attachment att : attachments ) {
419                attachmentNames += att.getName() + ";";
420            }
421            field = new Field( LUCENE_ATTACHMENTS, attachmentNames, TextField.TYPE_STORED );
422            doc.add( field );
423
424        } catch( final ProviderException e ) {
425            // Unable to read attachments
426            log.error( "Failed to get attachments for page", e );
427        }
428
429        // also index page keywords, if available
430        if( page.getAttribute( "keywords" ) != null ) {
431            field = new Field( LUCENE_PAGE_KEYWORDS, page.getAttribute( "keywords" ).toString(), TextField.TYPE_STORED );
432            doc.add( field );
433        }
434        writer.addDocument(doc);
435
436        return doc;
437    }
438
439    /**
440     *  {@inheritDoc}
441     */
442    @Override
443    public void pageRemoved( final WikiPage page ) {
444        try( final Directory luceneDir = new SimpleFSDirectory( new File( m_luceneDirectory ).toPath() );
445             final IndexWriter writer = getIndexWriter( luceneDir ); ) {
446            final Query query = new TermQuery( new Term( LUCENE_ID, page.getName() ) );
447            writer.deleteDocuments( query );
448        } catch ( final Exception e ) {
449            log.error("Unable to remove page '" + page.getName() + "' from Lucene index", e);
450        }
451    }
452    
453    IndexWriter getIndexWriter( Directory luceneDir ) throws IOException, ProviderException {
454        IndexWriterConfig writerConfig = new IndexWriterConfig( getLuceneAnalyzer() );
455        writerConfig.setOpenMode( OpenMode.CREATE_OR_APPEND );
456        IndexWriter writer = new IndexWriter( luceneDir, writerConfig );
457        
458        // writer.setInfoStream( System.out );
459        return writer;
460    }
461    
462    /**
463     *  Adds a page-text pair to the lucene update queue.  Safe to call always
464     *
465     *  @param page WikiPage to add to the update queue.
466     */
467    @Override
468    public void reindexPage( WikiPage page ) {
469        if( page != null ) {
470            String text;
471
472            // TODO: Think if this was better done in the thread itself?
473
474            if( page instanceof Attachment ) {
475                text = getAttachmentContent( (Attachment) page );
476            } else {
477                text = m_engine.getPureText( page );
478            }
479
480            if( text != null ) {
481                // Add work item to m_updates queue.
482                Object[] pair = new Object[2];
483                pair[0] = page;
484                pair[1] = text;
485                m_updates.add(pair);
486                log.debug("Scheduling page " + page.getName() + " for index update");
487            }
488        }
489    }
490
491    /**
492     *  {@inheritDoc}
493     */
494    @Override
495    public Collection< SearchResult > findPages( String query, WikiContext wikiContext ) throws ProviderException {
496        return findPages( query, FLAG_CONTEXTS, wikiContext );
497    }
498
499    /**
500     *  Create contexts also.  Generating contexts can be expensive,
501     *  so they're not on by default.
502     */
503    public static final int FLAG_CONTEXTS = 0x01;
504
505    /**
506     *  Searches pages using a particular combination of flags.
507     *
508     *  @param query The query to perform in Lucene query language
509     *  @param flags A set of flags
510     *  @return A Collection of SearchResult instances
511     *  @throws ProviderException if there is a problem with the backend
512     */
513    public Collection< SearchResult > findPages( final String query, final int flags, final WikiContext wikiContext ) throws ProviderException {
514        ArrayList<SearchResult> list = null;
515        Highlighter highlighter = null;
516
517        try( final Directory luceneDir = new SimpleFSDirectory( new File( m_luceneDirectory ).toPath() );
518             final IndexReader reader = DirectoryReader.open( luceneDir ) ) {
519            final String[] queryfields = { LUCENE_PAGE_CONTENTS, LUCENE_PAGE_NAME, LUCENE_AUTHOR, LUCENE_ATTACHMENTS, LUCENE_PAGE_KEYWORDS };
520            final QueryParser qp = new MultiFieldQueryParser( queryfields, getLuceneAnalyzer() );
521            final Query luceneQuery = qp.parse( query );
522            final IndexSearcher searcher = new IndexSearcher( reader );
523
524            if( (flags & FLAG_CONTEXTS) != 0 ) {
525                highlighter = new Highlighter(new SimpleHTMLFormatter("<span class=\"searchmatch\">", "</span>"),
526                                              new SimpleHTMLEncoder(),
527                                              new QueryScorer(luceneQuery));
528            }
529
530            final ScoreDoc[] hits = searcher.search(luceneQuery, MAX_SEARCH_HITS).scoreDocs;
531            final AuthorizationManager mgr = m_engine.getAuthorizationManager();
532
533            list = new ArrayList<>(hits.length);
534            for ( int curr = 0; curr < hits.length; curr++ ) {
535                int docID = hits[curr].doc;
536                Document doc = searcher.doc( docID );
537                String pageName = doc.get(LUCENE_ID);
538                WikiPage page = m_engine.getPage(pageName, WikiPageProvider.LATEST_VERSION);
539
540                if( page != null ) {
541                    if( page instanceof Attachment ) {
542                        // Currently attachments don't look nice on the search-results page
543                        // When the search-results are cleaned up this can be enabled again.
544                    }
545
546                    final PagePermission pp = new PagePermission( page, PagePermission.VIEW_ACTION );
547                    if( mgr.checkPermission( wikiContext.getWikiSession(), pp ) ) {
548                        final int score = (int)(hits[curr].score * 100);
549
550                        // Get highlighted search contexts
551                        final String text = doc.get(LUCENE_PAGE_CONTENTS);
552    
553                        String[] fragments = new String[0];
554                        if( text != null && highlighter != null ) {
555                            TokenStream tokenStream = getLuceneAnalyzer().tokenStream(LUCENE_PAGE_CONTENTS, new StringReader(text));
556                            fragments = highlighter.getBestFragments(tokenStream, text, MAX_FRAGMENTS);
557                        }
558
559                        final SearchResult result = new SearchResultImpl( page, score, fragments );
560                        list.add(result);
561                    }
562                } else {
563                    log.error("Lucene found a result page '" + pageName + "' that could not be loaded, removing from Lucene cache");
564                    pageRemoved(new WikiPage( m_engine, pageName ));
565                }
566            }
567        } catch( final IOException e ) {
568            log.error("Failed during lucene search",e);
569        } catch( final ParseException e ) {
570            log.info("Broken query; cannot parse query: " + query, e);
571            throw new ProviderException( "You have entered a query Lucene cannot process [" + query + "]: " + e.getMessage() );
572        } catch( final InvalidTokenOffsetsException e ) {
573            log.error("Tokens are incompatible with provided text ",e);
574        }
575
576        return list;
577    }
578
579    /**
580     *  {@inheritDoc}
581     */
582    @Override
583    public String getProviderInfo()
584    {
585        return "LuceneSearchProvider";
586    }
587
588    /**
589     * Updater thread that updates Lucene indexes.
590     */
591    private static final class LuceneUpdater extends WikiBackgroundThread
592    {
593        protected static final int INDEX_DELAY    = 5;
594        protected static final int INITIAL_DELAY = 60;
595        private final LuceneSearchProvider m_provider;
596
597        private int m_initialDelay;
598
599        private WatchDog m_watchdog;
600
601        private LuceneUpdater( WikiEngine engine, LuceneSearchProvider provider,
602                               int initialDelay, int indexDelay )
603        {
604            super( engine, indexDelay );
605            m_provider = provider;
606            setName("JSPWiki Lucene Indexer");
607        }
608
609        @Override
610        public void startupTask() throws Exception
611        {
612            m_watchdog = getEngine().getCurrentWatchDog();
613
614            // Sleep initially...
615            try
616            {
617                Thread.sleep( m_initialDelay * 1000L );
618            }
619            catch( InterruptedException e )
620            {
621                throw new InternalWikiException("Interrupted while waiting to start.", e);
622            }
623
624            m_watchdog.enterState("Full reindex");
625            // Reindex everything
626            m_provider.doFullLuceneReindex();
627            m_watchdog.exitState();
628        }
629
630        @Override
631        public void backgroundTask() throws Exception
632        {
633            m_watchdog.enterState("Emptying index queue", 60);
634
635            synchronized ( m_provider.m_updates )
636            {
637                while( m_provider.m_updates.size() > 0 )
638                {
639                    Object[] pair = m_provider.m_updates.remove(0);
640                    WikiPage page = ( WikiPage ) pair[0];
641                    String text = ( String ) pair[1];
642                    m_provider.updateLuceneIndex(page, text);
643                }
644            }
645
646            m_watchdog.exitState();
647        }
648
649    }
650
651    // FIXME: This class is dumb; needs to have a better implementation
652    private static class SearchResultImpl
653        implements SearchResult
654    {
655        private WikiPage m_page;
656        private int      m_score;
657        private String[] m_contexts;
658
659        public SearchResultImpl( WikiPage page, int score, String[] contexts )
660        {
661            m_page  = page;
662            m_score = score;
663            m_contexts = contexts != null ? contexts.clone() : null;
664        }
665
666        @Override
667        public WikiPage getPage()
668        {
669            return m_page;
670        }
671
672        /* (non-Javadoc)
673         * @see org.apache.wiki.SearchResult#getScore()
674         */
675        @Override
676        public int getScore()
677        {
678            return m_score;
679        }
680
681
682        @Override
683        public String[] getContexts()
684        {
685            return m_contexts;
686        }
687    }
688}