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