]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/search/SearchEngine.php
MediaWiki 1.16.1-scripts
[autoinstalls/mediawiki.git] / includes / search / SearchEngine.php
1 <?php
2 /**
3  * @defgroup Search Search
4  *
5  * @file
6  * @ingroup Search
7  */
8
9 /**
10  * Contain a class for special pages
11  * @ingroup Search
12  */
13 class SearchEngine {
14         var $limit = 10;
15         var $offset = 0;
16         var $prefix = '';
17         var $searchTerms = array();
18         var $namespaces = array( NS_MAIN );
19         var $showRedirects = false;
20
21         /**
22          * Perform a full text search query and return a result set.
23          * If title searches are not supported or disabled, return null.
24          * STUB
25          *
26          * @param $term String: raw search term
27          * @return SearchResultSet
28          */
29         function searchText( $term ) {
30                 return null;
31         }
32
33         /**
34          * Perform a title-only search query and return a result set.
35          * If title searches are not supported or disabled, return null.
36          * STUB
37          *
38          * @param $term String: raw search term
39          * @return SearchResultSet
40          */
41         function searchTitle( $term ) {
42                 return null;
43         }
44         
45         /** If this search backend can list/unlist redirects */
46         function acceptListRedirects() {
47                 return true;
48         }
49         
50         /**
51          * When overridden in derived class, performs database-specific conversions
52          * on text to be used for searching or updating search index.
53          * Default implementation does nothing (simply returns $string).
54          *
55          * @param $string string: String to process
56          * @return string
57          */
58         public function normalizeText( $string ) {
59                 return $string;
60         }
61
62         /**
63          * Transform search term in cases when parts of the query came as different GET params (when supported)
64          * e.g. for prefix queries: search=test&prefix=Main_Page/Archive -> test prefix:Main Page/Archive
65          */
66         function transformSearchTerm( $term ) {
67                 return $term;
68         }
69         
70         /**
71          * If an exact title match can be found, or a very slightly close match,
72          * return the title. If no match, returns NULL.
73          *
74          * @param $searchterm String
75          * @return Title
76          */
77         public static function getNearMatch( $searchterm ) {
78                 $title = self::getNearMatchInternal( $searchterm );
79                 
80                 wfRunHooks( 'SearchGetNearMatchComplete', array( $searchterm, &$title ) );
81                 return $title;
82         }
83         
84         /**
85          * Really find the title match.
86          */
87         private static function getNearMatchInternal( $searchterm ) {
88                 global $wgContLang;
89
90                 $allSearchTerms = array($searchterm);
91
92                 if ( $wgContLang->hasVariants() ) {
93                         $allSearchTerms = array_merge($allSearchTerms,$wgContLang->convertLinkToAllVariants($searchterm));
94                 }
95
96                 if( !wfRunHooks( 'SearchGetNearMatchBefore', array( $allSearchTerms, &$titleResult ) ) ) {
97                         return $titleResult;
98                 }
99
100                 foreach($allSearchTerms as $term) {
101
102                         # Exact match? No need to look further.
103                         $title = Title::newFromText( $term );
104                         if (is_null($title))
105                                 return null;
106
107                         if ( $title->getNamespace() == NS_SPECIAL || $title->isExternal() || $title->exists() ) {
108                                 return $title;
109                         }
110                         
111                         # See if it still otherwise has content is some sane sense
112                         $article = MediaWiki::articleFromTitle( $title );
113                         if( $article->hasViewableContent() ) {
114                                 return $title;
115                         }
116
117                         # Now try all lower case (i.e. first letter capitalized)
118                         #
119                         $title = Title::newFromText( $wgContLang->lc( $term ) );
120                         if ( $title && $title->exists() ) {
121                                 return $title;
122                         }
123
124                         # Now try capitalized string
125                         #
126                         $title = Title::newFromText( $wgContLang->ucwords( $term ) );
127                         if ( $title && $title->exists() ) {
128                                 return $title;
129                         }
130
131                         # Now try all upper case
132                         #
133                         $title = Title::newFromText( $wgContLang->uc( $term ) );
134                         if ( $title && $title->exists() ) {
135                                 return $title;
136                         }
137
138                         # Now try Word-Caps-Breaking-At-Word-Breaks, for hyphenated names etc
139                         $title = Title::newFromText( $wgContLang->ucwordbreaks($term) );
140                         if ( $title && $title->exists() ) {
141                                 return $title;
142                         }
143
144                         // Give hooks a chance at better match variants
145                         $title = null;
146                         if( !wfRunHooks( 'SearchGetNearMatch', array( $term, &$title ) ) ) {
147                                 return $title;
148                         }
149                 }
150
151                 $title = Title::newFromText( $searchterm );
152
153                 # Entering an IP address goes to the contributions page
154                 if ( ( $title->getNamespace() == NS_USER && User::isIP($title->getText() ) )
155                         || User::isIP( trim( $searchterm ) ) ) {
156                         return SpecialPage::getTitleFor( 'Contributions', $title->getDBkey() );
157                 }
158
159
160                 # Entering a user goes to the user page whether it's there or not
161                 if ( $title->getNamespace() == NS_USER ) {
162                         return $title;
163                 }
164
165                 # Go to images that exist even if there's no local page.
166                 # There may have been a funny upload, or it may be on a shared
167                 # file repository such as Wikimedia Commons.
168                 if( $title->getNamespace() == NS_FILE ) {
169                         $image = wfFindFile( $title );
170                         if( $image ) {
171                                 return $title;
172                         }
173                 }
174
175                 # MediaWiki namespace? Page may be "implied" if not customized.
176                 # Just return it, with caps forced as the message system likes it.
177                 if( $title->getNamespace() == NS_MEDIAWIKI ) {
178                         return Title::makeTitle( NS_MEDIAWIKI, $wgContLang->ucfirst( $title->getText() ) );
179                 }
180
181                 # Quoted term? Try without the quotes...
182                 $matches = array();
183                 if( preg_match( '/^"([^"]+)"$/', $searchterm, $matches ) ) {
184                         return SearchEngine::getNearMatch( $matches[1] );
185                 }
186
187                 return null;
188         }
189
190         public static function legalSearchChars() {
191                 return "A-Za-z_'.0-9\\x80-\\xFF\\-";
192         }
193
194         /**
195          * Set the maximum number of results to return
196          * and how many to skip before returning the first.
197          *
198          * @param $limit Integer
199          * @param $offset Integer
200          */
201         function setLimitOffset( $limit, $offset = 0 ) {
202                 $this->limit = intval( $limit );
203                 $this->offset = intval( $offset );
204         }
205
206         /**
207          * Set which namespaces the search should include.
208          * Give an array of namespace index numbers.
209          *
210          * @param $namespaces Array
211          */
212         function setNamespaces( $namespaces ) {
213                 $this->namespaces = $namespaces;
214         }
215
216         /**
217          * Parse some common prefixes: all (search everything)
218          * or namespace names
219          *
220          * @param $query String
221          */
222         function replacePrefixes( $query ){
223                 global $wgContLang;
224
225                 $parsed = $query;
226                 if( strpos($query,':') === false ) { // nothing to do
227                         wfRunHooks( 'SearchEngineReplacePrefixesComplete', array( $this, $query, &$parsed ) );
228                         return $parsed;
229                 }
230                 
231                 $allkeyword = wfMsgForContent('searchall').":";
232                 if( strncmp($query, $allkeyword, strlen($allkeyword)) == 0 ){
233                         $this->namespaces = null;
234                         $parsed = substr($query,strlen($allkeyword));
235                 } else if( strpos($query,':') !== false ) {
236                         $prefix = substr($query,0,strpos($query,':'));
237                         $index = $wgContLang->getNsIndex($prefix);
238                         if($index !== false){
239                                 $this->namespaces = array($index);
240                                 $parsed = substr($query,strlen($prefix)+1);
241                         }
242                 }
243                 if(trim($parsed) == '')
244                         $parsed = $query; // prefix was the whole query
245
246                 wfRunHooks( 'SearchEngineReplacePrefixesComplete', array( $this, $query, &$parsed ) );
247
248                 return $parsed;
249         }
250
251         /**
252          * Make a list of searchable namespaces and their canonical names.
253          * @return Array
254          */
255         public static function searchableNamespaces() {
256                 global $wgContLang;
257                 $arr = array();
258                 foreach( $wgContLang->getNamespaces() as $ns => $name ) {
259                         if( $ns >= NS_MAIN ) {
260                                 $arr[$ns] = $name;
261                         }
262                 }
263                 
264                 wfRunHooks( 'SearchableNamespaces', array( &$arr ) );
265                 return $arr;
266         }
267         
268         /**
269          * Extract default namespaces to search from the given user's
270          * settings, returning a list of index numbers.
271          *
272          * @param $user User
273          * @return Array
274          */
275         public static function userNamespaces( $user ) {
276                 global $wgSearchEverythingOnlyLoggedIn;
277                 
278                 // get search everything preference, that can be set to be read for logged-in users
279                 $searcheverything = false;
280                 if( ( $wgSearchEverythingOnlyLoggedIn && $user->isLoggedIn() )
281                     || !$wgSearchEverythingOnlyLoggedIn )
282                         $searcheverything = $user->getOption('searcheverything');
283                 
284                 // searcheverything overrides other options 
285                 if( $searcheverything )
286                         return array_keys(SearchEngine::searchableNamespaces());
287                 
288                 $arr = Preferences::loadOldSearchNs( $user );
289                 $searchableNamespaces = SearchEngine::searchableNamespaces();
290                 
291                 $arr = array_intersect( $arr, array_keys($searchableNamespaces) ); // Filter
292                 
293                 return $arr;
294         }
295         
296         /**
297          * Find snippet highlight settings for a given user
298          *
299          * @param $user User
300          * @return Array contextlines, contextchars 
301          */
302         public static function userHighlightPrefs( &$user ){
303                 //$contextlines = $user->getOption( 'contextlines',  5 );
304                 //$contextchars = $user->getOption( 'contextchars', 50 );
305                 $contextlines = 2; // Hardcode this. Old defaults sucked. :)
306                 $contextchars = 75; // same as above.... :P
307                 return array($contextlines, $contextchars);
308         }
309         
310         /**
311          * An array of namespaces indexes to be searched by default
312          * 
313          * @return Array 
314          */
315         public static function defaultNamespaces(){
316                 global $wgNamespacesToBeSearchedDefault;
317                 
318                 return array_keys($wgNamespacesToBeSearchedDefault, true);
319         }
320         
321         /**
322          * Get a list of namespace names useful for showing in tooltips
323          * and preferences
324          *
325          * @param $namespaces Array
326          */
327         public static function namespacesAsText( $namespaces ){
328                 global $wgContLang;
329                 
330                 $formatted = array_map( array($wgContLang,'getFormattedNsText'), $namespaces );
331                 foreach( $formatted as $key => $ns ){
332                         if ( empty($ns) )
333                                 $formatted[$key] = wfMsg( 'blanknamespace' );
334                 }
335                 return $formatted;
336         }
337         
338         /**
339          * Return the help namespaces to be shown on Special:Search
340          * 
341          * @return Array 
342          */
343         public static function helpNamespaces() {
344                 global $wgNamespacesToBeSearchedHelp;
345                 
346                 return array_keys( $wgNamespacesToBeSearchedHelp, true );
347         }
348         
349         /**
350          * Return a 'cleaned up' search string
351          *
352          * @param $text String
353          * @return String
354          */
355         function filter( $text ) {
356                 $lc = $this->legalSearchChars();
357                 return trim( preg_replace( "/[^{$lc}]/", " ", $text ) );
358         }
359         /**
360          * Load up the appropriate search engine class for the currently
361          * active database backend, and return a configured instance.
362          *
363          * @return SearchEngine
364          */
365         public static function create() {
366                 global $wgSearchType;
367                 $dbr = wfGetDB( DB_SLAVE );
368                 if( $wgSearchType ) {
369                         $class = $wgSearchType;
370                 } else {
371                         $class = $dbr->getSearchEngine();
372                 }
373                 $search = new $class( $dbr );
374                 $search->setLimitOffset(0,0);
375                 return $search;
376         }
377
378         /**
379          * Create or update the search index record for the given page.
380          * Title and text should be pre-processed.
381          * STUB
382          *
383          * @param $id Integer
384          * @param $title String
385          * @param $text String
386          */
387         function update( $id, $title, $text ) {
388                 // no-op
389         }
390
391         /**
392          * Update a search index record's title only.
393          * Title should be pre-processed.
394          * STUB
395          *
396          * @param $id Integer
397          * @param $title String
398          */
399         function updateTitle( $id, $title ) {
400                 // no-op
401         }
402         
403         /**
404          * Get OpenSearch suggestion template
405          * 
406          * @return String
407          */
408         public static function getOpenSearchTemplate() {
409                 global $wgOpenSearchTemplate, $wgServer, $wgScriptPath;
410                 if( $wgOpenSearchTemplate )     {       
411                         return $wgOpenSearchTemplate;
412                 } else { 
413                         $ns = implode( '|', SearchEngine::defaultNamespaces() );
414                         if( !$ns ) $ns = "0";
415                         return $wgServer . $wgScriptPath . '/api.php?action=opensearch&search={searchTerms}&namespace='.$ns;
416                 }
417         }
418         
419         /**
420          * Get internal MediaWiki Suggest template 
421          * 
422          * @return String
423          */
424         public static function getMWSuggestTemplate() {
425                 global $wgMWSuggestTemplate, $wgServer, $wgScriptPath;
426                 if($wgMWSuggestTemplate)                
427                         return $wgMWSuggestTemplate;
428                 else 
429                         return $wgServer . $wgScriptPath . '/api.php?action=opensearch&search={searchTerms}&namespace={namespaces}&suggest';
430         }
431 }
432
433 /**
434  * @ingroup Search
435  */
436 class SearchResultSet {
437         /**
438          * Fetch an array of regular expression fragments for matching
439          * the search terms as parsed by this engine in a text extract.
440          * STUB
441          *
442          * @return Array
443          */
444         function termMatches() {
445                 return array();
446         }
447
448         function numRows() {
449                 return 0;
450         }
451
452         /**
453          * Return true if results are included in this result set.
454          * STUB
455          *
456          * @return Boolean
457          */
458         function hasResults() {
459                 return false;
460         }
461
462         /**
463          * Some search modes return a total hit count for the query
464          * in the entire article database. This may include pages
465          * in namespaces that would not be matched on the given
466          * settings.
467          *
468          * Return null if no total hits number is supported.
469          *
470          * @return Integer
471          */
472         function getTotalHits() {
473                 return null;
474         }
475
476         /**
477          * Some search modes return a suggested alternate term if there are
478          * no exact hits. Returns true if there is one on this set.
479          *
480          * @return Boolean
481          */
482         function hasSuggestion() {
483                 return false;
484         }
485
486         /**
487          * @return String: suggested query, null if none
488          */
489         function getSuggestionQuery(){
490                 return null;
491         }
492
493         /**
494          * @return String: HTML highlighted suggested query, '' if none
495          */
496         function getSuggestionSnippet(){
497                 return '';
498         }
499         
500         /**
501          * Return information about how and from where the results were fetched,
502          * should be useful for diagnostics and debugging 
503          *
504          * @return String
505          */
506         function getInfo() {
507                 return null;
508         }
509         
510         /**
511          * Return a result set of hits on other (multiple) wikis associated with this one
512          *
513          * @return SearchResultSet
514          */
515         function getInterwikiResults() {
516                 return null;
517         }
518         
519         /**
520          * Check if there are results on other wikis
521          *
522          * @return Boolean
523          */
524         function hasInterwikiResults() {
525                 return $this->getInterwikiResults() != null;
526         }
527         
528
529         /**
530          * Fetches next search result, or false.
531          * STUB
532          *
533          * @return SearchResult
534          */
535         function next() {
536                 return false;
537         }
538
539         /**
540          * Frees the result set, if applicable.
541          */
542         function free() {
543                 // ...
544         }
545 }
546
547 /**
548  * This class is used for different SQL-based search engines shipped with MediaWiki
549  */
550 class SqlSearchResultSet extends SearchResultSet {
551         function __construct( $resultSet, $terms ) {
552                 $this->mResultSet = $resultSet;
553                 $this->mTerms = $terms;
554         }
555
556         function termMatches() {
557                 return $this->mTerms;
558         }
559
560         function numRows() {
561                 return $this->mResultSet->numRows();
562         }
563
564         function next() {
565                 if ($this->mResultSet === false )
566                         return false;
567
568                 $row = $this->mResultSet->fetchObject();
569                 if ($row === false)
570                         return false;
571                 return new SearchResult($row);
572         }
573
574         function free() {
575                 $this->mResultSet->free();
576         }
577 }
578
579 /**
580  * @ingroup Search
581  */
582 class SearchResultTooMany {
583         ## Some search engines may bail out if too many matches are found
584 }
585
586
587 /**
588  * @todo Fixme: This class is horribly factored. It would probably be better to
589  * have a useful base class to which you pass some standard information, then
590  * let the fancy self-highlighters extend that.
591  * @ingroup Search
592  */
593 class SearchResult {
594         var $mRevision = null;
595         var $mImage = null;
596
597         function __construct( $row ) {
598                 $this->mTitle = Title::makeTitle( $row->page_namespace, $row->page_title );
599                 if( !is_null($this->mTitle) ){
600                         $this->mRevision = Revision::newFromTitle( $this->mTitle );
601                         if( $this->mTitle->getNamespace() === NS_FILE )
602                                 $this->mImage = wfFindFile( $this->mTitle );
603                 }
604         }
605         
606         /**
607          * Check if this is result points to an invalid title
608          *
609          * @return Boolean
610          */
611         function isBrokenTitle(){
612                 if( is_null($this->mTitle) )
613                         return true;
614                 return false;
615         }
616         
617         /**
618          * Check if target page is missing, happens when index is out of date
619          * 
620          * @return Boolean
621          */
622         function isMissingRevision(){
623                 return !$this->mRevision && !$this->mImage;
624         }
625
626         /**
627          * @return Title
628          */
629         function getTitle() {
630                 return $this->mTitle;
631         }
632
633         /**
634          * @return Double or null if not supported
635          */
636         function getScore() {
637                 return null;
638         }
639
640         /**
641          * Lazy initialization of article text from DB
642          */
643         protected function initText(){
644                 if( !isset($this->mText) ){
645                         if($this->mRevision != null)
646                                 $this->mText = $this->mRevision->getText();
647                         else // TODO: can we fetch raw wikitext for commons images?
648                                 $this->mText = '';
649                         
650                 }
651         }
652         
653         /**
654          * @param $terms Array: terms to highlight
655          * @return String: highlighted text snippet, null (and not '') if not supported 
656          */
657         function getTextSnippet($terms){
658                 global $wgUser, $wgAdvancedSearchHighlighting;
659                 $this->initText();
660                 list($contextlines,$contextchars) = SearchEngine::userHighlightPrefs($wgUser);
661                 $h = new SearchHighlighter();
662                 if( $wgAdvancedSearchHighlighting )
663                         return $h->highlightText( $this->mText, $terms, $contextlines, $contextchars );
664                 else
665                         return $h->highlightSimple( $this->mText, $terms, $contextlines, $contextchars );
666         }
667         
668         /**
669          * @param $terms Array: terms to highlight
670          * @return String: highlighted title, '' if not supported
671          */
672         function getTitleSnippet($terms){
673                 return '';
674         }
675
676         /**
677          * @param $terms Array: terms to highlight
678          * @return String: highlighted redirect name (redirect to this page), '' if none or not supported
679          */
680         function getRedirectSnippet($terms){
681                 return '';
682         }
683
684         /**
685          * @return Title object for the redirect to this page, null if none or not supported
686          */
687         function getRedirectTitle(){
688                 return null;
689         }
690
691         /**
692          * @return string highlighted relevant section name, null if none or not supported
693          */
694         function getSectionSnippet(){
695                 return '';
696         }
697
698         /**
699          * @return Title object (pagename+fragment) for the section, null if none or not supported
700          */
701         function getSectionTitle(){
702                 return null;
703         }
704
705         /**
706          * @return String: timestamp
707          */
708         function getTimestamp(){
709                 if( $this->mRevision )
710                         return $this->mRevision->getTimestamp();
711                 else if( $this->mImage )
712                         return $this->mImage->getTimestamp();
713                 return '';                      
714         }
715
716         /**
717          * @return Integer: number of words
718          */
719         function getWordCount(){
720                 $this->initText();
721                 return str_word_count( $this->mText );
722         }
723
724         /**
725          * @return Integer: size in bytes
726          */
727         function getByteSize(){
728                 $this->initText();
729                 return strlen( $this->mText );
730         }
731         
732         /**
733          * @return Boolean if hit has related articles
734          */
735         function hasRelated(){
736                 return false;
737         }
738         
739         /**
740          * @return String: interwiki prefix of the title (return iw even if title is broken)
741          */
742         function getInterwikiPrefix(){
743                 return '';
744         }
745 }
746
747 /**
748  * Highlight bits of wikitext
749  * 
750  * @ingroup Search
751  */
752 class SearchHighlighter {       
753         var $mCleanWikitext = true;
754         
755         function SearchHighlighter($cleanupWikitext = true){
756                 $this->mCleanWikitext = $cleanupWikitext;
757         }
758         
759         /**
760          * Default implementation of wikitext highlighting
761          *
762          * @param $text String
763          * @param $terms Array: terms to highlight (unescaped)
764          * @param $contextlines Integer
765          * @param $contextchars Integer
766          * @return String
767          */
768         public function highlightText( $text, $terms, $contextlines, $contextchars ) {
769                 global $wgLang, $wgContLang;
770                 global $wgSearchHighlightBoundaries;
771                 $fname = __METHOD__;
772                 
773                 if($text == '')
774                         return '';
775                                 
776                 // spli text into text + templates/links/tables
777                 $spat = "/(\\{\\{)|(\\[\\[[^\\]:]+:)|(\n\\{\\|)";
778                 // first capture group is for detecting nested templates/links/tables/references
779                 $endPatterns = array(
780                         1 => '/(\{\{)|(\}\})/', // template
781                         2 => '/(\[\[)|(\]\])/', // image
782                         3 => "/(\n\\{\\|)|(\n\\|\\})/"); // table
783                          
784                 // FIXME: this should prolly be a hook or something
785                 if(function_exists('wfCite')){
786                         $spat .= '|(<ref>)'; // references via cite extension
787                         $endPatterns[4] = '/(<ref>)|(<\/ref>)/';
788                 }
789                 $spat .= '/';
790                 $textExt = array(); // text extracts
791                 $otherExt = array();  // other extracts
792                 wfProfileIn( "$fname-split" );
793                 $start = 0;
794                 $textLen = strlen($text);
795                 $count = 0; // sequence number to maintain ordering
796                 while( $start < $textLen ){
797                         // find start of template/image/table
798                         if( preg_match( $spat, $text, $matches, PREG_OFFSET_CAPTURE, $start ) ){
799                                 $epat = '';     
800                                 foreach($matches as $key => $val){
801                                         if($key > 0 && $val[1] != -1){
802                                                 if($key == 2){
803                                                         // see if this is an image link
804                                                         $ns = substr($val[0],2,-1);
805                                                         if( $wgContLang->getNsIndex($ns) != NS_FILE )
806                                                                 break;
807                                                         
808                                                 }
809                                                 $epat = $endPatterns[$key];
810                                                 $this->splitAndAdd( $textExt, $count, substr( $text, $start, $val[1] - $start ) );                                              
811                                                 $start = $val[1];
812                                                 break;
813                                         }
814                                 }
815                                 if( $epat ){
816                                         // find end (and detect any nested elements)
817                                         $level = 0; 
818                                         $offset = $start + 1;
819                                         $found = false;
820                                         while( preg_match( $epat, $text, $endMatches, PREG_OFFSET_CAPTURE, $offset ) ){
821                                                 if( array_key_exists(2,$endMatches) ){
822                                                         // found end
823                                                         if($level == 0){
824                                                                 $len = strlen($endMatches[2][0]);
825                                                                 $off = $endMatches[2][1];
826                                                                 $this->splitAndAdd( $otherExt, $count, 
827                                                                         substr( $text, $start, $off + $len  - $start ) );
828                                                                 $start = $off + $len;
829                                                                 $found = true;
830                                                                 break;
831                                                         } else{
832                                                                 // end of nested element
833                                                                 $level -= 1;
834                                                         }
835                                                 } else{
836                                                         // nested
837                                                         $level += 1;
838                                                 }
839                                                 $offset = $endMatches[0][1] + strlen($endMatches[0][0]);
840                                         }
841                                         if( ! $found ){
842                                                 // couldn't find appropriate closing tag, skip
843                                                 $this->splitAndAdd( $textExt, $count, substr( $text, $start, strlen($matches[0][0]) ) );
844                                                 $start += strlen($matches[0][0]);
845                                         }
846                                         continue;
847                                 }
848                         }
849                         // else: add as text extract
850                         $this->splitAndAdd( $textExt, $count, substr($text,$start) );
851                         break;
852                 }
853                 
854                 $all = $textExt + $otherExt; // these have disjunct key sets
855                 
856                 wfProfileOut( "$fname-split" );
857                 
858                 // prepare regexps
859                 foreach( $terms as $index => $term ) {
860                         // manually do upper/lowercase stuff for utf-8 since PHP won't do it
861                         if(preg_match('/[\x80-\xff]/', $term) ){
862                                 $terms[$index] = preg_replace_callback('/./us',array($this,'caseCallback'),$terms[$index]);
863                         } else {
864                                 $terms[$index] = $term;
865                         }
866                 }
867                 $anyterm = implode( '|', $terms );
868                 $phrase = implode("$wgSearchHighlightBoundaries+", $terms );
869
870                 // FIXME: a hack to scale contextchars, a correct solution
871                 // would be to have contextchars actually be char and not byte
872                 // length, and do proper utf-8 substrings and lengths everywhere,
873                 // but PHP is making that very hard and unclean to implement :(
874                 $scale = strlen($anyterm) / mb_strlen($anyterm);
875                 $contextchars = intval( $contextchars * $scale );
876                 
877                 $patPre = "(^|$wgSearchHighlightBoundaries)";
878                 $patPost = "($wgSearchHighlightBoundaries|$)"; 
879                 
880                 $pat1 = "/(".$phrase.")/ui";
881                 $pat2 = "/$patPre(".$anyterm.")$patPost/ui";
882                 
883                 wfProfileIn( "$fname-extract" );
884                 
885                 $left = $contextlines;
886
887                 $snippets = array();
888                 $offsets = array();             
889                 
890                 // show beginning only if it contains all words
891                 $first = 0;
892                 $firstText = '';
893                 foreach($textExt as $index => $line){
894                         if(strlen($line)>0 && $line[0] != ';' && $line[0] != ':'){
895                                 $firstText = $this->extract( $line, 0, $contextchars * $contextlines );
896                                 $first = $index;
897                                 break;
898                         }
899                 }
900                 if( $firstText ){
901                         $succ = true;
902                         // check if first text contains all terms
903                         foreach($terms as $term){
904                                 if( ! preg_match("/$patPre".$term."$patPost/ui", $firstText) ){
905                                         $succ = false;
906                                         break;
907                                 }
908                         }
909                         if( $succ ){
910                                 $snippets[$first] = $firstText;
911                                 $offsets[$first] = 0; 
912                         }
913                 }
914                 if( ! $snippets ) {             
915                         // match whole query on text 
916                         $this->process($pat1, $textExt, $left, $contextchars, $snippets, $offsets);
917                         // match whole query on templates/tables/images
918                         $this->process($pat1, $otherExt, $left, $contextchars, $snippets, $offsets);
919                         // match any words on text
920                         $this->process($pat2, $textExt, $left, $contextchars, $snippets, $offsets);
921                         // match any words on templates/tables/images
922                         $this->process($pat2, $otherExt, $left, $contextchars, $snippets, $offsets);
923                         
924                         ksort($snippets);
925                 }
926                 
927                 // add extra chars to each snippet to make snippets constant size
928                 $extended = array();                                            
929                 if( count( $snippets ) == 0){
930                         // couldn't find the target words, just show beginning of article
931                         $targetchars = $contextchars * $contextlines;
932                         $snippets[$first] = '';
933                         $offsets[$first] = 0;
934                 } else{
935                         // if begin of the article contains the whole phrase, show only that !! 
936                         if( array_key_exists($first,$snippets) && preg_match($pat1,$snippets[$first]) 
937                             && $offsets[$first] < $contextchars * 2 ){
938                                 $snippets = array ($first => $snippets[$first]);
939                         }
940                         
941                         // calc by how much to extend existing snippets
942                         $targetchars = intval( ($contextchars * $contextlines) / count ( $snippets ) );
943                 }  
944
945                 foreach($snippets as $index => $line){
946                         $extended[$index] = $line;
947                         $len = strlen($line);
948                         if( $len < $targetchars - 20 ){
949                                 // complete this line
950                                 if($len < strlen( $all[$index] )){
951                                         $extended[$index] = $this->extract( $all[$index], $offsets[$index], $offsets[$index]+$targetchars, $offsets[$index]);
952                                         $len = strlen( $extended[$index] );
953                                 }
954                                 
955                                 // add more lines
956                                 $add = $index + 1;
957                                 while( $len < $targetchars - 20 
958                                        && array_key_exists($add,$all) 
959                                        && !array_key_exists($add,$snippets) ){
960                                     $offsets[$add] = 0;
961                                     $tt = "\n".$this->extract( $all[$add], 0, $targetchars - $len, $offsets[$add] );
962                                         $extended[$add] = $tt;
963                                         $len += strlen( $tt );
964                                         $add++;                                         
965                                 }
966                         } 
967                 }
968                 
969                 //$snippets = array_map('htmlspecialchars', $extended);
970                 $snippets = $extended;
971                 $last = -1;
972                 $extract = '';
973                 foreach($snippets as $index => $line){
974                         if($last == -1) 
975                                 $extract .= $line; // first line
976                         elseif($last+1 == $index && $offsets[$last]+strlen($snippets[$last]) >= strlen($all[$last]))
977                                 $extract .= " ".$line; // continous lines
978                         else
979                                 $extract .= '<b> ... </b>' . $line;
980
981                         $last = $index;
982                 }
983                 if( $extract )
984                         $extract .= '<b> ... </b>';
985                 
986                 $processed = array();
987                 foreach($terms as $term){
988                         if( ! isset($processed[$term]) ){
989                                 $pat3 = "/$patPre(".$term.")$patPost/ui"; // highlight word  
990                                 $extract = preg_replace( $pat3,
991                                         "\\1<span class='searchmatch'>\\2</span>\\3", $extract );
992                                 $processed[$term] = true;
993                         }
994                 }
995                 
996                 wfProfileOut( "$fname-extract" );
997                 
998                 return $extract;
999         }
1000         
1001         /**
1002          * Split text into lines and add it to extracts array
1003          *
1004          * @param $extracts Array: index -> $line
1005          * @param $count Integer
1006          * @param $text String
1007          */
1008         function splitAndAdd(&$extracts, &$count, $text){
1009                 $split = explode( "\n", $this->mCleanWikitext? $this->removeWiki($text) : $text );
1010                 foreach($split as $line){
1011                         $tt = trim($line);
1012                         if( $tt )
1013                                 $extracts[$count++] = $tt;
1014                 }
1015         }
1016         
1017         /**
1018          * Do manual case conversion for non-ascii chars
1019          *
1020          * @param $matches Array
1021          */
1022         function caseCallback($matches){
1023                 global $wgContLang;
1024                 if( strlen($matches[0]) > 1 ){
1025                         return '['.$wgContLang->lc($matches[0]).$wgContLang->uc($matches[0]).']';
1026                 } else
1027                         return $matches[0];
1028         }
1029         
1030         /**
1031          * Extract part of the text from start to end, but by
1032          * not chopping up words
1033          * @param $text String
1034          * @param $start Integer
1035          * @param $end Integer
1036          * @param $posStart Integer: (out) actual start position
1037          * @param $posEnd Integer: (out) actual end position
1038          * @return String  
1039          */
1040         function extract($text, $start, $end, &$posStart = null, &$posEnd = null ){
1041                 global $wgContLang;             
1042                 
1043                 if( $start != 0)
1044                         $start = $this->position( $text, $start, 1 );
1045                 if( $end >= strlen($text) )
1046                         $end = strlen($text);
1047                 else
1048                         $end = $this->position( $text, $end );
1049                         
1050                 if(!is_null($posStart))
1051                         $posStart = $start;
1052                 if(!is_null($posEnd))
1053                         $posEnd = $end;
1054                 
1055                 if($end > $start)
1056                         return substr($text, $start, $end-$start);
1057                 else
1058                         return '';
1059         } 
1060         
1061         /**
1062          * Find a nonletter near a point (index) in the text
1063          *
1064          * @param $text String
1065          * @param $point Integer
1066          * @param $offset Integer: offset to found index
1067          * @return Integer: nearest nonletter index, or beginning of utf8 char if none
1068          */
1069         function position($text, $point, $offset=0 ){
1070                 $tolerance = 10;
1071                 $s = max( 0, $point - $tolerance );
1072                 $l = min( strlen($text), $point + $tolerance ) - $s;
1073                 $m = array();
1074                 if( preg_match('/[ ,.!?~!@#$%^&*\(\)+=\-\\\|\[\]"\'<>]/', substr($text,$s,$l), $m, PREG_OFFSET_CAPTURE ) ){
1075                         return $m[0][1] + $s + $offset;
1076                 } else{
1077                         // check if point is on a valid first UTF8 char
1078                         $char = ord( $text[$point] );
1079                         while( $char >= 0x80 && $char < 0xc0 ) {
1080                                 // skip trailing bytes
1081                                 $point++;
1082                                 if($point >= strlen($text))
1083                                         return strlen($text);
1084                                 $char = ord( $text[$point] );
1085                         }
1086                         return $point;
1087                         
1088                 }
1089         }
1090         
1091         /**
1092          * Search extracts for a pattern, and return snippets
1093          *
1094          * @param $pattern String: regexp for matching lines
1095          * @param $extracts Array: extracts to search   
1096          * @param $linesleft Integer: number of extracts to make
1097          * @param $contextchars Integer: length of snippet
1098          * @param $out Array: map for highlighted snippets
1099          * @param $offsets Array: map of starting points of snippets
1100          * @protected
1101          */
1102         function process( $pattern, $extracts, &$linesleft, &$contextchars, &$out, &$offsets ){
1103                 if($linesleft == 0)
1104                         return; // nothing to do
1105                 foreach($extracts as $index => $line){                  
1106                         if( array_key_exists($index,$out) )
1107                                 continue; // this line already highlighted
1108                                 
1109                         $m = array();
1110                         if ( !preg_match( $pattern, $line, $m, PREG_OFFSET_CAPTURE ) )
1111                                 continue;
1112                                 
1113                         $offset = $m[0][1];
1114                         $len = strlen($m[0][0]);
1115                         if($offset + $len < $contextchars)
1116                                 $begin = 0; 
1117                         elseif( $len > $contextchars)
1118                                 $begin = $offset;
1119                         else
1120                                 $begin = $offset + intval( ($len - $contextchars) / 2 );
1121                         
1122                         $end = $begin + $contextchars;
1123                         
1124                         $posBegin = $begin;
1125                         // basic snippet from this line
1126                         $out[$index] = $this->extract($line,$begin,$end,$posBegin);
1127                         $offsets[$index] = $posBegin;
1128                         $linesleft--;                   
1129                         if($linesleft == 0)
1130                                 return;
1131                 }
1132         }
1133         
1134         /** 
1135          * Basic wikitext removal
1136          * @protected
1137          */
1138         function removeWiki($text) {
1139                 $fname = __METHOD__;
1140                 wfProfileIn( $fname );
1141                 
1142                 //$text = preg_replace("/'{2,5}/", "", $text);
1143                 //$text = preg_replace("/\[[a-z]+:\/\/[^ ]+ ([^]]+)\]/", "\\2", $text);
1144                 //$text = preg_replace("/\[\[([^]|]+)\]\]/", "\\1", $text);
1145                 //$text = preg_replace("/\[\[([^]]+\|)?([^|]]+)\]\]/", "\\2", $text);
1146                 //$text = preg_replace("/\\{\\|(.*?)\\|\\}/", "", $text);
1147                 //$text = preg_replace("/\\[\\[[A-Za-z_-]+:([^|]+?)\\]\\]/", "", $text);
1148                 $text = preg_replace("/\\{\\{([^|]+?)\\}\\}/", "", $text);
1149                 $text = preg_replace("/\\{\\{([^|]+\\|)(.*?)\\}\\}/", "\\2", $text);
1150                 $text = preg_replace("/\\[\\[([^|]+?)\\]\\]/", "\\1", $text);           
1151                 $text = preg_replace_callback("/\\[\\[([^|]+\\|)(.*?)\\]\\]/", array($this,'linkReplace'), $text);
1152                 //$text = preg_replace("/\\[\\[([^|]+\\|)(.*?)\\]\\]/", "\\2", $text);
1153                 $text = preg_replace("/<\/?[^>]+>/", "", $text);
1154                 $text = preg_replace("/'''''/", "", $text);
1155                 $text = preg_replace("/('''|<\/?[iIuUbB]>)/", "", $text);
1156                 $text = preg_replace("/''/", "", $text);
1157                 
1158                 wfProfileOut( $fname );
1159                 return $text;
1160         }
1161         
1162         /**
1163          * callback to replace [[target|caption]] kind of links, if
1164          * the target is category or image, leave it
1165          *
1166          * @param $matches Array
1167          */
1168         function linkReplace($matches){
1169                 $colon = strpos( $matches[1], ':' ); 
1170                 if( $colon === false )
1171                         return $matches[2]; // replace with caption
1172                 global $wgContLang;
1173                 $ns = substr( $matches[1], 0, $colon );
1174                 $index = $wgContLang->getNsIndex($ns);
1175                 if( $index !== false && ($index == NS_FILE || $index == NS_CATEGORY) )
1176                         return $matches[0]; // return the whole thing 
1177                 else
1178                         return $matches[2];
1179                 
1180         }
1181
1182         /**
1183      * Simple & fast snippet extraction, but gives completely unrelevant
1184      * snippets
1185      *
1186      * @param $text String
1187      * @param $terms Array
1188      * @param $contextlines Integer
1189      * @param $contextchars Integer
1190      * @return String
1191      */
1192     public function highlightSimple( $text, $terms, $contextlines, $contextchars ) {
1193         global $wgLang, $wgContLang;
1194         $fname = __METHOD__;
1195
1196         $lines = explode( "\n", $text );
1197         
1198         $terms = implode( '|', $terms );
1199         $max = intval( $contextchars ) + 1;
1200         $pat1 = "/(.*)($terms)(.{0,$max})/i";
1201
1202         $lineno = 0;
1203
1204         $extract = "";
1205         wfProfileIn( "$fname-extract" );
1206         foreach ( $lines as $line ) {
1207             if ( 0 == $contextlines ) {
1208                 break;
1209             }
1210             ++$lineno;
1211             $m = array();
1212             if ( ! preg_match( $pat1, $line, $m ) ) {
1213                 continue;
1214             }
1215             --$contextlines;
1216             $pre = $wgContLang->truncate( $m[1], -$contextchars );
1217
1218             if ( count( $m ) < 3 ) {
1219                 $post = '';
1220             } else {
1221                 $post = $wgContLang->truncate( $m[3], $contextchars );
1222             }
1223
1224             $found = $m[2];
1225
1226             $line = htmlspecialchars( $pre . $found . $post );
1227             $pat2 = '/(' . $terms . ")/i";
1228             $line = preg_replace( $pat2,
1229               "<span class='searchmatch'>\\1</span>", $line );
1230
1231             $extract .= "${line}\n";
1232         }
1233         wfProfileOut( "$fname-extract" );
1234         
1235         return $extract;
1236     }
1237         
1238 }
1239
1240 /**
1241  * Dummy class to be used when non-supported Database engine is present.
1242  * @todo Fixme: dummy class should probably try something at least mildly useful,
1243  * such as a LIKE search through titles.
1244  * @ingroup Search
1245  */
1246 class SearchEngineDummy extends SearchEngine {
1247         // no-op
1248 }