Simpletest Coverage - modules/search/search.module

1 <?php
2 // $Id: search.module,v 1.303 2009/07/23 20:58:26 webchick Exp $
3
4 /**
5 * @file
6 * Enables site-wide keyword searching.
7 */
8
9 /**
10 * Matches Unicode character classes to exclude from the search index.
11 *
12 * See: http://www.unicode.org/Public/UNIDATA/UCD.html#General_Category_Values
13 *
14 * The index only contains the following character classes:
15 * Lu Letter, Uppercase
16 * Ll Letter, Lowercase
17 * Lt Letter, Titlecase
18 * Lo Letter, Other
19 * Nd Number, Decimal Digit
20 * No Number, Other
21 */
22 define('PREG_CLASS_SEARCH_EXCLUDE',
23 '\x{0}-\x{2f}\x{3a}-\x{40}\x{5b}-\x{60}\x{7b}-\x{bf}\x{d7}\x{f7}\x{2b0}-' .
24 '\x{385}\x{387}\x{3f6}\x{482}-\x{489}\x{559}-\x{55f}\x{589}-\x{5c7}\x{5f3}-' .
25 '\x{61f}\x{640}\x{64b}-\x{65e}\x{66a}-\x{66d}\x{670}\x{6d4}\x{6d6}-\x{6ed}' .
26 '\x{6fd}\x{6fe}\x{700}-\x{70f}\x{711}\x{730}-\x{74a}\x{7a6}-\x{7b0}\x{901}-' .
27 '\x{903}\x{93c}\x{93e}-\x{94d}\x{951}-\x{954}\x{962}-\x{965}\x{970}\x{981}-' .
28 '\x{983}\x{9bc}\x{9be}-\x{9cd}\x{9d7}\x{9e2}\x{9e3}\x{9f2}-\x{a03}\x{a3c}-' .
29 '\x{a4d}\x{a70}\x{a71}\x{a81}-\x{a83}\x{abc}\x{abe}-\x{acd}\x{ae2}\x{ae3}' .
30 '\x{af1}-\x{b03}\x{b3c}\x{b3e}-\x{b57}\x{b70}\x{b82}\x{bbe}-\x{bd7}\x{bf0}-' .
31 '\x{c03}\x{c3e}-\x{c56}\x{c82}\x{c83}\x{cbc}\x{cbe}-\x{cd6}\x{d02}\x{d03}' .
32 '\x{d3e}-\x{d57}\x{d82}\x{d83}\x{dca}-\x{df4}\x{e31}\x{e34}-\x{e3f}\x{e46}-' .
33 '\x{e4f}\x{e5a}\x{e5b}\x{eb1}\x{eb4}-\x{ebc}\x{ec6}-\x{ecd}\x{f01}-\x{f1f}' .
34 '\x{f2a}-\x{f3f}\x{f71}-\x{f87}\x{f90}-\x{fd1}\x{102c}-\x{1039}\x{104a}-' .
35 '\x{104f}\x{1056}-\x{1059}\x{10fb}\x{10fc}\x{135f}-\x{137c}\x{1390}-\x{1399}' .
36 '\x{166d}\x{166e}\x{1680}\x{169b}\x{169c}\x{16eb}-\x{16f0}\x{1712}-\x{1714}' .
37 '\x{1732}-\x{1736}\x{1752}\x{1753}\x{1772}\x{1773}\x{17b4}-\x{17db}\x{17dd}' .
38 '\x{17f0}-\x{180e}\x{1843}\x{18a9}\x{1920}-\x{1945}\x{19b0}-\x{19c0}\x{19c8}' .
39 '\x{19c9}\x{19de}-\x{19ff}\x{1a17}-\x{1a1f}\x{1d2c}-\x{1d61}\x{1d78}\x{1d9b}-' .
40 '\x{1dc3}\x{1fbd}\x{1fbf}-\x{1fc1}\x{1fcd}-\x{1fcf}\x{1fdd}-\x{1fdf}\x{1fed}-' .
41 '\x{1fef}\x{1ffd}-\x{2070}\x{2074}-\x{207e}\x{2080}-\x{2101}\x{2103}-\x{2106}' .
42 '\x{2108}\x{2109}\x{2114}\x{2116}-\x{2118}\x{211e}-\x{2123}\x{2125}\x{2127}' .
43 '\x{2129}\x{212e}\x{2132}\x{213a}\x{213b}\x{2140}-\x{2144}\x{214a}-\x{2b13}' .
44 '\x{2ce5}-\x{2cff}\x{2d6f}\x{2e00}-\x{3005}\x{3007}-\x{303b}\x{303d}-\x{303f}' .
45 '\x{3099}-\x{309e}\x{30a0}\x{30fb}-\x{30fe}\x{3190}-\x{319f}\x{31c0}-\x{31cf}' .
46 '\x{3200}-\x{33ff}\x{4dc0}-\x{4dff}\x{a015}\x{a490}-\x{a716}\x{a802}\x{a806}' .
47 '\x{a80b}\x{a823}-\x{a82b}\x{d800}-\x{f8ff}\x{fb1e}\x{fb29}\x{fd3e}\x{fd3f}' .
48 '\x{fdfc}-\x{fe6b}\x{feff}-\x{ff0f}\x{ff1a}-\x{ff20}\x{ff3b}-\x{ff40}\x{ff5b}-' .
49 '\x{ff65}\x{ff70}\x{ff9e}\x{ff9f}\x{ffe0}-\x{fffd}');
50
51 /**
52 * Matches all 'N' Unicode character classes (numbers)
53 */
54 define('PREG_CLASS_NUMBERS',
55 '\x{30}-\x{39}\x{b2}\x{b3}\x{b9}\x{bc}-\x{be}\x{660}-\x{669}\x{6f0}-\x{6f9}' .
56 '\x{966}-\x{96f}\x{9e6}-\x{9ef}\x{9f4}-\x{9f9}\x{a66}-\x{a6f}\x{ae6}-\x{aef}' .
57 '\x{b66}-\x{b6f}\x{be7}-\x{bf2}\x{c66}-\x{c6f}\x{ce6}-\x{cef}\x{d66}-\x{d6f}' .
58 '\x{e50}-\x{e59}\x{ed0}-\x{ed9}\x{f20}-\x{f33}\x{1040}-\x{1049}\x{1369}-' .
59 '\x{137c}\x{16ee}-\x{16f0}\x{17e0}-\x{17e9}\x{17f0}-\x{17f9}\x{1810}-\x{1819}' .
60 '\x{1946}-\x{194f}\x{2070}\x{2074}-\x{2079}\x{2080}-\x{2089}\x{2153}-\x{2183}' .
61 '\x{2460}-\x{249b}\x{24ea}-\x{24ff}\x{2776}-\x{2793}\x{3007}\x{3021}-\x{3029}' .
62 '\x{3038}-\x{303a}\x{3192}-\x{3195}\x{3220}-\x{3229}\x{3251}-\x{325f}\x{3280}-' .
63 '\x{3289}\x{32b1}-\x{32bf}\x{ff10}-\x{ff19}');
64
65 /**
66 * Matches all 'P' Unicode character classes (punctuation)
67 */
68 define('PREG_CLASS_PUNCTUATION',
69 '\x{21}-\x{23}\x{25}-\x{2a}\x{2c}-\x{2f}\x{3a}\x{3b}\x{3f}\x{40}\x{5b}-\x{5d}' .
70 '\x{5f}\x{7b}\x{7d}\x{a1}\x{ab}\x{b7}\x{bb}\x{bf}\x{37e}\x{387}\x{55a}-\x{55f}' .
71 '\x{589}\x{58a}\x{5be}\x{5c0}\x{5c3}\x{5f3}\x{5f4}\x{60c}\x{60d}\x{61b}\x{61f}' .
72 '\x{66a}-\x{66d}\x{6d4}\x{700}-\x{70d}\x{964}\x{965}\x{970}\x{df4}\x{e4f}' .
73 '\x{e5a}\x{e5b}\x{f04}-\x{f12}\x{f3a}-\x{f3d}\x{f85}\x{104a}-\x{104f}\x{10fb}' .
74 '\x{1361}-\x{1368}\x{166d}\x{166e}\x{169b}\x{169c}\x{16eb}-\x{16ed}\x{1735}' .
75 '\x{1736}\x{17d4}-\x{17d6}\x{17d8}-\x{17da}\x{1800}-\x{180a}\x{1944}\x{1945}' .
76 '\x{2010}-\x{2027}\x{2030}-\x{2043}\x{2045}-\x{2051}\x{2053}\x{2054}\x{2057}' .
77 '\x{207d}\x{207e}\x{208d}\x{208e}\x{2329}\x{232a}\x{23b4}-\x{23b6}\x{2768}-' .
78 '\x{2775}\x{27e6}-\x{27eb}\x{2983}-\x{2998}\x{29d8}-\x{29db}\x{29fc}\x{29fd}' .
79 '\x{3001}-\x{3003}\x{3008}-\x{3011}\x{3014}-\x{301f}\x{3030}\x{303d}\x{30a0}' .
80 '\x{30fb}\x{fd3e}\x{fd3f}\x{fe30}-\x{fe52}\x{fe54}-\x{fe61}\x{fe63}\x{fe68}' .
81 '\x{fe6a}\x{fe6b}\x{ff01}-\x{ff03}\x{ff05}-\x{ff0a}\x{ff0c}-\x{ff0f}\x{ff1a}' .
82 '\x{ff1b}\x{ff1f}\x{ff20}\x{ff3b}-\x{ff3d}\x{ff3f}\x{ff5b}\x{ff5d}\x{ff5f}-' .
83 '\x{ff65}');
84
85 /**
86 * Matches all CJK characters that are candidates for auto-splitting
87 * (Chinese, Japanese, Korean).
88 * Contains kana and BMP ideographs.
89 */
90 define('PREG_CLASS_CJK', '\x{3041}-\x{30ff}\x{31f0}-\x{31ff}\x{3400}-\x{4db5}' .
91 '\x{4e00}-\x{9fbb}\x{f900}-\x{fad9}');
92
93 /**
94 * Implement hook_help().
95 */
96 function search_help($path, $arg) {
97 switch ($path) {
98 case 'admin/help#search':
99 $output = '<p>' . t('The search module adds the ability to search for content by keywords. Search is often the only practical way to find content on a large site, and is useful for finding both users and posts.') . '</p>';
100 $output .= '<p>' . t('To provide keyword searching, the search engine maintains an index of words found in your site\'s content. To build and maintain this index, a correctly configured <a href="@cron">cron maintenance task</a> is required. Indexing behavior can be adjusted using the <a href="@searchsettings">search settings page</a>; for example, the <em>Number of items to index per cron run</em> sets the maximum number of items indexed in each pass of a <a href="@cron">cron maintenance task</a>. If necessary, reduce this number to prevent timeouts and memory errors when indexing.', array('@cron' => url('admin/reports/status'), '@searchsettings' => url('admin/settings/search'))) . '</p>';
101 $output .= '<p>' . t('For more information, see the online handbook entry for <a href="@search">Search module</a>.', array('@search' => 'http://drupal.org/handbook/modules/search/')) . '</p>';
102 return $output;
103 case 'admin/settings/search':
104 return '<p>' . t('The search engine maintains an index of words found in your site\'s content. To build and maintain this index, a correctly configured <a href="@cron">cron maintenance task</a> is required. Indexing behavior can be adjusted using the settings below.', array('@cron' => url('admin/reports/status'))) . '</p>';
105 case 'search#noresults':
106 return t('<ul>
107 <li>Check if your spelling is correct.</li>
108 <li>Remove quotes around phrases to search for each word individually. <em>bike shed</em> will often show more results than <em>&quot;bike shed&quot;</em>.</li>
109 <li>Consider loosening your query with <em>OR</em>. <em>bike OR shed</em> will often show more results than <em>bike shed</em>.</li>
110 </ul>');
111 }
112 }
113
114 /**
115 * Implement hook_theme().
116 */
117 function search_theme() {
118 return array(
119 'search_theme_form' => array(
120 'arguments' => array('form' => NULL),
121 'template' => 'search-theme-form',
122 ),
123 'search_block_form' => array(
124 'arguments' => array('form' => NULL),
125 'template' => 'search-block-form',
126 ),
127 'search_result' => array(
128 'arguments' => array('result' => NULL, 'type' => NULL),
129 'file' => 'search.pages.inc',
130 'template' => 'search-result',
131 ),
132 'search_results' => array(
133 'arguments' => array('results' => NULL, 'type' => NULL),
134 'file' => 'search.pages.inc',
135 'template' => 'search-results',
136 ),
137 'search_results_listing' => array(
138 'arguments' => array('title' => NULL, 'content' => NULL),
139 ),
140 );
141 }
142
143 /**
144 * Implement hook_permission().
145 */
146 function search_permission() {
147 return array(
148 'administer search' => array(
149 'title' => t('Administer search'),
150 'description' => t('Configure search administration settings.'),
151 ),
152 'search content' => array(
153 'title' => t('Search content'),
154 'description' => t('Search website content.'),
155 ),
156 'use advanced search' => array(
157 'title' => t('Use advanced search'),
158 'description' => t('Limit search results with additional criteria, such as specific content types. Could have performance implications.'),
159 ),
160 );
161 }
162
163 /**
164 * Implement hook_block_list().
165 */
166 function search_block_list() {
167 $blocks['form']['info'] = t('Search form');
168 // Not worth caching.
169 $blocks['form']['cache'] = BLOCK_NO_CACHE;
170 return $blocks;
171 }
172
173 /**
174 * Implement hook_block_view().
175 */
176 function search_block_view($delta = '') {
177 if (user_access('search content')) {
178 $block['content'] = drupal_get_form('search_block_form');
179 $block['subject'] = t('Search');
180 return $block;
181 }
182 }
183
184 /**
185 * Implement hook_menu().
186 */
187 function search_menu() {
188 $items['search'] = array(
189 'title' => 'Search',
190 'page callback' => 'search_view',
191 'access arguments' => array('search content'),
192 'type' => MENU_SUGGESTED_ITEM,
193 );
194 $items['admin/settings/search'] = array(
195 'title' => 'Search settings',
196 'description' => 'Configure relevance settings for search and other indexing options',
197 'page callback' => 'drupal_get_form',
198 'page arguments' => array('search_admin_settings'),
199 'access arguments' => array('administer search'),
200 'type' => MENU_NORMAL_ITEM,
201 );
202 $items['admin/settings/search/reindex'] = array(
203 'title' => 'Clear index',
204 'page callback' => 'drupal_get_form',
205 'page arguments' => array('search_reindex_confirm'),
206 'access arguments' => array('administer search'),
207 'type' => MENU_CALLBACK,
208 );
209 $items['admin/reports/search'] = array(
210 'title' => 'Top search phrases',
211 'description' => 'View most popular search phrases.',
212 'page callback' => 'dblog_top',
213 'page arguments' => array('search'),
214 'access arguments' => array('access site reports'),
215 'file path' => drupal_get_path('module', 'dblog'),
216 );
217
218 foreach (module_implements('search') as $module) {
219 $items['search/' . $module . '/%menu_tail'] = array(
220 'title callback' => 'module_invoke',
221 'title arguments' => array($module, 'search', 'name', TRUE),
222 'page callback' => 'search_view',
223 'page arguments' => array($module),
224 'access callback' => '_search_menu',
225 'access arguments' => array($module),
226 'type' => MENU_LOCAL_TASK,
227 'parent' => 'search',
228 );
229 }
230 return $items;
231 }
232
233 function _search_menu($name) {
234 return user_access('search content') && module_invoke($name, 'search', 'name');
235 }
236
237 /**
238 * Wipes a part of or the entire search index.
239 *
240 * @param $sid
241 * (optional) The SID of the item to wipe. If specified, $type must be passed
242 * too.
243 * @param $type
244 * (optional) The type of item to wipe.
245 */
246 function search_reindex($sid = NULL, $type = NULL, $reindex = FALSE) {
247 if ($type == NULL && $sid == NULL) {
248 module_invoke_all('search', 'reset');
249 }
250 else {
251 db_query("DELETE FROM {search_dataset} WHERE sid = %d AND type = '%s'", $sid, $type);
252 db_query("DELETE FROM {search_index} WHERE sid = %d AND type = '%s'", $sid, $type);
253 // Don't remove links if re-indexing.
254 if (!$reindex) {
255 db_query("DELETE FROM {search_node_links} WHERE sid = %d AND type = '%s'", $sid, $type);
256 }
257 }
258 }
259
260 /**
261 * Marks a word as dirty (or retrieves the list of dirty words). This is used
262 * during indexing (cron). Words which are dirty have outdated total counts in
263 * the search_total table, and need to be recounted.
264 */
265 function search_dirty($word = NULL) {
266 static $dirty = array();
267 if ($word !== NULL) {
268 $dirty[$word] = TRUE;
269 }
270 else {
271 return $dirty;
272 }
273 }
274
275 /**
276 * Implement hook_cron().
277 *
278 * Fires hook_update_index() in all modules and cleans up dirty words (see
279 * search_dirty).
280 */
281 function search_cron() {
282 // We register a shutdown function to ensure that search_total is always up
283 // to date.
284 register_shutdown_function('search_update_totals');
285
286 // Update word index
287 module_invoke_all('update_index');
288 }
289
290 /**
291 * This function is called on shutdown to ensure that search_total is always
292 * up to date (even if cron times out or otherwise fails).
293 */
294 function search_update_totals() {
295 // Update word IDF (Inverse Document Frequency) counts for new/changed words
296 foreach (search_dirty() as $word => $dummy) {
297 // Get total count
298 $total = db_result(db_query("SELECT SUM(score) FROM {search_index} WHERE word = '%s'", $word));
299 // Apply Zipf's law to equalize the probability distribution
300 $total = log10(1 + 1/(max(1, $total)));
301 db_merge('search_total')->key(array('word' => $word))->fields(array('count' => $total))->execute();
302 }
303 // Find words that were deleted from search_index, but are still in
304 // search_total. We use a LEFT JOIN between the two tables and keep only the
305 // rows which fail to join.
306 $result = db_query("SELECT t.word AS realword, i.word FROM {search_total} t LEFT JOIN {search_index} i ON t.word = i.word WHERE i.word IS NULL");
307 while ($word = db_fetch_object($result)) {
308 db_query("DELETE FROM {search_total} WHERE word = '%s'", $word->realword);
309 }
310 }
311
312 /**
313 * Simplifies a string according to indexing rules.
314 */
315 function search_simplify($text) {
316 // Decode entities to UTF-8
317 $text = decode_entities($text);
318
319 // Lowercase
320 $text = drupal_strtolower($text);
321
322 // Call an external processor for word handling.
323 search_invoke_preprocess($text);
324
325 // Simple CJK handling
326 if (variable_get('overlap_cjk', TRUE)) {
327 $text = preg_replace_callback('/[' . PREG_CLASS_CJK . ']+/u', 'search_expand_cjk', $text);
328 }
329
330 // To improve searching for numerical data such as dates, IP addresses
331 // or version numbers, we consider a group of numerical characters
332 // separated only by punctuation characters to be one piece.
333 // This also means that searching for e.g. '20/03/1984' also returns
334 // results with '20-03-1984' in them.
335 // Readable regexp: ([number]+)[punctuation]+(?=[number])
336 $text = preg_replace('/([' . PREG_CLASS_NUMBERS . ']+)[' . PREG_CLASS_PUNCTUATION . ']+(?=[' . PREG_CLASS_NUMBERS . '])/u', '\1', $text);
337
338 // The dot, underscore and dash are simply removed. This allows meaningful
339 // search behavior with acronyms and URLs.
340 $text = preg_replace('/[._-]+/', '', $text);
341
342 // With the exception of the rules above, we consider all punctuation,
343 // marks, spacers, etc, to be a word boundary.
344 $text = preg_replace('/[' . PREG_CLASS_SEARCH_EXCLUDE . ']+/u', ' ', $text);
345
346 return $text;
347 }
348
349 /**
350 * Basic CJK tokenizer. Simply splits a string into consecutive, overlapping
351 * sequences of characters ('minimum_word_size' long).
352 */
353 function search_expand_cjk($matches) {
354 $min = variable_get('minimum_word_size', 3);
355 $str = $matches[0];
356 $l = drupal_strlen($str);
357 // Passthrough short words
358 if ($l <= $min) {
359 return ' ' . $str . ' ';
360 }
361 $tokens = ' ';
362 // FIFO queue of characters
363 $chars = array();
364 // Begin loop
365 for ($i = 0; $i < $l; ++$i) {
366 // Grab next character
367 $current = drupal_substr($str, 0, 1);
368 $str = substr($str, strlen($current));
369 $chars[] = $current;
370 if ($i >= $min - 1) {
371 $tokens .= implode('', $chars) . ' ';
372 array_shift($chars);
373 }
374 }
375 return $tokens;
376 }
377
378 /**
379 * Splits a string into tokens for indexing.
380 */
381 function search_index_split($text) {
382 static $last = NULL;
383 static $lastsplit = NULL;
384
385 if ($last == $text) {
386 return $lastsplit;
387 }
388 // Process words
389 $text = search_simplify($text);
390 $words = explode(' ', $text);
391 array_walk($words, '_search_index_truncate');
392
393 // Save last keyword result
394 $last = $text;
395 $lastsplit = $words;
396
397 return $words;
398 }
399
400 /**
401 * Helper function for array_walk in search_index_split.
402 */
403 function _search_index_truncate(&$text) {
404 $text = truncate_utf8($text, 50);
405 }
406
407 /**
408 * Invokes hook_search_preprocess() in modules.
409 */
410 function search_invoke_preprocess(&$text) {
411 foreach (module_implements('search_preprocess') as $module) {
412 $text = module_invoke($module, 'search_preprocess', $text);
413 }
414 }
415
416 /**
417 * Update the full-text search index for a particular item.
418 *
419 * @param $sid
420 * A number identifying this particular item (e.g. node id).
421 *
422 * @param $type
423 * A string defining this type of item (e.g. 'node')
424 *
425 * @param $text
426 * The content of this item. Must be a piece of HTML text.
427 *
428 * @ingroup search
429 */
430 function search_index($sid, $type, $text) {
431 $minimum_word_size = variable_get('minimum_word_size', 3);
432
433 // Link matching
434 global $base_url;
435 $node_regexp = '@href=[\'"]?(?:' . preg_quote($base_url, '@') . '/|' . preg_quote(base_path(), '@') . ')(?:\?q=)?/?((?![a-z]+:)[^\'">]+)[\'">]@i';
436
437 // Multipliers for scores of words inside certain HTML tags. The weights are stored
438 // in a variable so that modules can overwrite the default weights.
439 // Note: 'a' must be included for link ranking to work.
440 $tags = variable_get('search_tag_weights', array(
441 'h1' => 25,
442 'h2' => 18,
443 'h3' => 15,
444 'h4' => 12,
445 'h5' => 9,
446 'h6' => 6,
447 'u' => 3,
448 'b' => 3,
449 'i' => 3,
450 'strong' => 3,
451 'em' => 3,
452 'a' => 10));
453
454 // Strip off all ignored tags to speed up processing, but insert space before/after
455 // them to keep word boundaries.
456 $text = str_replace(array('<', '>'), array(' <', '> '), $text);
457 $text = strip_tags($text, '<' . implode('><', array_keys($tags)) . '>');
458
459 // Split HTML tags from plain text.
460 $split = preg_split('/\s*<([^>]+?)>\s*/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
461 // Note: PHP ensures the array consists of alternating delimiters and literals
462 // and begins and ends with a literal (inserting $null as required).
463
464 $tag = FALSE; // Odd/even counter. Tag or no tag.
465 $link = FALSE; // State variable for link analyzer
466 $score = 1; // Starting score per word
467 $accum = ' '; // Accumulator for cleaned up data
468 $tagstack = array(); // Stack with open tags
469 $tagwords = 0; // Counter for consecutive words
470 $focus = 1; // Focus state
471
472 $results = array(0 => array()); // Accumulator for words for index
473
474 foreach ($split as $value) {
475 if ($tag) {
476 // Increase or decrease score per word based on tag
477 list($tagname) = explode(' ', $value, 2);
478 $tagname = drupal_strtolower($tagname);
479 // Closing or opening tag?
480 if ($tagname[0] == '/') {
481 $tagname = substr($tagname, 1);
482 // If we encounter unexpected tags, reset score to avoid incorrect boosting.
483 if (!count($tagstack) || $tagstack[0] != $tagname) {
484 $tagstack = array();
485 $score = 1;
486 }
487 else {
488 // Remove from tag stack and decrement score
489 $score = max(1, $score - $tags[array_shift($tagstack)]);
490 }
491 if ($tagname == 'a') {
492 $link = FALSE;
493 }
494 }
495 else {
496 if (isset($tagstack[0]) && $tagstack[0] == $tagname) {
497 // None of the tags we look for make sense when nested identically.
498 // If they are, it's probably broken HTML.
499 $tagstack = array();
500 $score = 1;
501 }
502 else {
503 // Add to open tag stack and increment score
504 array_unshift($tagstack, $tagname);
505 $score += $tags[$tagname];
506 }
507 if ($tagname == 'a') {
508 // Check if link points to a node on this site
509 if (preg_match($node_regexp, $value, $match)) {
510 $path = drupal_get_normal_path($match[1]);
511 if (preg_match('!(?:node|book)/(?:view/)?([0-9]+)!i', $path, $match)) {
512 $linknid = $match[1];
513 if ($linknid > 0) {
514 // Note: ignore links to uncacheable nodes to avoid redirect bugs.
515 $node = db_query('SELECT title, nid, vid FROM {node} WHERE nid = :nid', array(':nid' => $linknid))->fetchObject();
516 $link = TRUE;
517 $linktitle = $node->title;
518 }
519 }
520 }
521 }
522 }
523 // A tag change occurred, reset counter.
524 $tagwords = 0;
525 }
526 else {
527 // Note: use of PREG_SPLIT_DELIM_CAPTURE above will introduce empty values
528 if ($value != '') {
529 if ($link) {
530 // Check to see if the node link text is its URL. If so, we use the target node title instead.
531 if (preg_match('!^https?://!i', $value)) {
532 $value = $linktitle;
533 }
534 }
535 $words = search_index_split($value);
536 foreach ($words as $word) {
537 // Add word to accumulator
538 $accum .= $word . ' ';
539 $num = is_numeric($word);
540 // Check wordlength
541 if ($num || drupal_strlen($word) >= $minimum_word_size) {
542 // Normalize numbers
543 if ($num) {
544 $word = (int)ltrim($word, '-0');
545 }
546
547 // Links score mainly for the target.
548 if ($link) {
549 if (!isset($results[$linknid])) {
550 $results[$linknid] = array();
551 }
552 $results[$linknid][] = $word;
553 // Reduce score of the link caption in the source.
554 $focus *= 0.2;
555 }
556 // Fall-through
557 if (!isset($results[0][$word])) {
558 $results[0][$word] = 0;
559 }
560 $results[0][$word] += $score * $focus;
561
562 // Focus is a decaying value in terms of the amount of unique words up to this point.
563 // From 100 words and more, it decays, to e.g. 0.5 at 500 words and 0.3 at 1000 words.
564 $focus = min(1, .01 + 3.5 / (2 + count($results[0]) * .015));
565 }
566 $tagwords++;
567 // Too many words inside a single tag probably mean a tag was accidentally left open.
568 if (count($tagstack) && $tagwords >= 15) {
569 $tagstack = array();
570 $score = 1;
571 }
572 }
573 }
574 }
575 $tag = !$tag;
576 }
577
578 search_reindex($sid, $type, TRUE);
579
580 // Insert cleaned up data into dataset
581 db_query("INSERT INTO {search_dataset} (sid, type, data, reindex) VALUES (%d, '%s', '%s', %d)", $sid, $type, $accum, 0);
582
583 // Insert results into search index
584 foreach ($results[0] as $word => $score) {
585 // If a word already exists in the database, its score gets increased
586 // appropriately. If not, we create a new record with the appropriate
587 // starting score.
588 db_merge('search_index')->key(array(
589 'word' => $word,
590 'sid' => $sid,
591 'type' => $type,
592 ))->fields(array('score' => $score))->expression('score', 'score + :score', array(':score' => $score))
593 ->execute();
594 search_dirty($word);
595 }
596 unset($results[0]);
597
598 // Get all previous links from this item.
599 $result = db_query("SELECT nid, caption FROM {search_node_links} WHERE sid = %d AND type = '%s'", $sid, $type);
600 $links = array();
601 while ($link = db_fetch_object($result)) {
602 $links[$link->nid] = $link->caption;
603 }
604
605 // Now store links to nodes.
606 foreach ($results as $nid => $words) {
607 $caption = implode(' ', $words);
608 if (isset($links[$nid])) {
609 if ($links[$nid] != $caption) {
610 // Update the existing link and mark the node for reindexing.
611 db_query("UPDATE {search_node_links} SET caption = '%s' WHERE sid = %d AND type = '%s' AND nid = %d", $caption, $sid, $type, $nid);
612 search_touch_node($nid);
613 }
614 // Unset the link to mark it as processed.
615 unset($links[$nid]);
616 }
617 else {
618 // Insert the existing link and mark the node for reindexing.
619 db_query("INSERT INTO {search_node_links} (caption, sid, type, nid) VALUES ('%s', %d, '%s', %d)", $caption, $sid, $type, $nid);
620 search_touch_node($nid);
621 }
622 }
623 // Any left-over links in $links no longer exist. Delete them and mark the nodes for reindexing.
624 foreach ($links as $nid => $caption) {
625 db_query("DELETE FROM {search_node_links} WHERE sid = %d AND type = '%s' AND nid = %d", $sid, $type, $nid);
626 search_touch_node($nid);
627 }
628 }
629
630 /**
631 * Change a node's changed timestamp to 'now' to force reindexing.
632 *
633 * @param $nid
634 * The nid of the node that needs reindexing.
635 */
636 function search_touch_node($nid) {
637 db_query("UPDATE {search_dataset} SET reindex = %d WHERE sid = %d AND type = 'node'", REQUEST_TIME, $nid);
638 }
639
640 /**
641 * Implement hook_node_update_index().
642 */
643 function search_node_update_index($node) {
644 // Transplant links to a node into the target node.
645 $result = db_query("SELECT caption FROM {search_node_links} WHERE nid = %d", $node->nid);
646 $output = array();
647 while ($link = db_fetch_object($result)) {
648 $output[] = $link->caption;
649 }
650 if (count($output)) {
651 return '<a>(' . implode(', ', $output) . ')</a>';
652 }
653 }
654
655 /**
656 * Implement hook_node_update().
657 */
658 function search_node_update($node) {
659 // Reindex the node when it is updated. The node is automatically indexed
660 // when it is added, simply by being added to the node table.
661 search_touch_node($node->nid);
662 }
663
664 /**
665 * Implement hook_comment_insert().
666 */
667 function search_comment_insert($comment) {
668 // Reindex the node when comments are added.
669 search_touch_node($comment->nid);
670 }
671
672 /**
673 * Implement hook_comment_update().
674 */
675 function search_comment_update($comment) {
676 // Reindex the node when comments are changed.
677 search_touch_node($comment->nid);
678 }
679
680 /**
681 * Implement hook_comment_delete().
682 */
683 function search_comment_delete($comment) {
684 // Reindex the node when comments are deleted.
685 search_touch_node($comment->nid);
686 }
687
688 /**
689 * Implement hook_comment_publish().
690 */
691 function search_comment_publish($comment) {
692 // Reindex the node when comments are published.
693 search_touch_node($comment->nid);
694 }
695
696 /**
697 * Implement hook_comment_unpublish().
698 */
699 function search_comment_unpublish($comment) {
700 // Reindex the node when comments are unpublished.
701 search_touch_node($comment->nid);
702 }
703
704 /**
705 * Extract a module-specific search option from a search query. e.g. 'type:book'
706 */
707 function search_query_extract($keys, $option) {
708 if (preg_match('/(^| )' . $option . ':([^ ]*)( |$)/i', $keys, $matches)) {
709 return $matches[2];
710 }
711 }
712
713 /**
714 * Return a query with the given module-specific search option inserted in.
715 * e.g. 'type:book'.
716 */
717 function search_query_insert($keys, $option, $value = '') {
718 if (search_query_extract($keys, $option)) {
719 $keys = trim(preg_replace('/(^| )' . $option . ':[^ ]*/i', '', $keys));
720 }
721 if ($value != '') {
722 $keys .= ' ' . $option . ':' . $value;
723 }
724 return $keys;
725 }
726
727 /**
728 * Parse a search query into SQL conditions.
729 *
730 * We build two queries that matches the dataset bodies. @See do_search for
731 * more about these.
732 *
733 * @param $text
734 * The search keys.
735 * @return
736 * A list of six elements.
737 * * A series of statements AND'd together which will be used to provide all
738 * possible matches.
739 * * Arguments for this query part.
740 * * A series of exact word matches OR'd together.
741 * * Arguments for this query part.
742 * * A boolean indicating whether this is a simple query or not. Negative
743 * terms, presence of both AND / OR make this FALSE.
744 * * A boolean indicating the presence of a lowercase or. Maybe the user
745 * wanted to use OR.
746 */
747 function search_parse_query($text) {
748 $keys = array('positive' => array(), 'negative' => array());
749
750 // Tokenize query string
751 preg_match_all('/ (-?)("[^"]+"|[^" ]+)/i', ' ' . $text, $matches, PREG_SET_ORDER);
752
753 if (count($matches) < 1) {
754 return NULL;
755 }
756
757 // Classify tokens
758 $or = FALSE;
759 $warning = '';
760 $simple = TRUE;
761 foreach ($matches as $match) {
762 $phrase = FALSE;
763 // Strip off phrase quotes
764 if ($match[2]{0} == '"') {
765 $match[2] = substr($match[2], 1, -1);
766 $phrase = TRUE;
767 $simple = FALSE;
768 }
769 // Simplify keyword according to indexing rules and external preprocessors
770 $words = search_simplify($match[2]);
771 // Re-explode in case simplification added more words, except when matching a phrase
772 $words = $phrase ? array($words) : preg_split('/ /', $words, -1, PREG_SPLIT_NO_EMPTY);
773 // Negative matches
774 if ($match[1] == '-') {
775 $keys['negative'] = array_merge($keys['negative'], $words);
776 }
777 // OR operator: instead of a single keyword, we store an array of all
778 // OR'd keywords.
779 elseif ($match[2] == 'OR' && count($keys['positive'])) {
780 $last = array_pop($keys['positive']);
781 // Starting a new OR?
782 if (!is_array($last)) {
783 $last = array($last);
784 }
785 $keys['positive'][] = $last;
786 $or = TRUE;
787 continue;
788 }
789 // AND operator: implied, so just ignore it
790 elseif ($match[2] == 'AND' || $match[2] == 'and') {
791 $warning = $match[2];
792 continue;
793 }
794
795 // Plain keyword
796 else {
797 if ($match[2] == 'or') {
798 $warning = $match[2];
799 }
800 if ($or) {
801 // Add to last element (which is an array)
802 $keys['positive'][count($keys['positive']) - 1] = array_merge($keys['positive'][count($keys['positive']) - 1], $words);
803 }
804 else {
805 $keys['positive'] = array_merge($keys['positive'], $words);
806 }
807 }
808 $or = FALSE;
809 }
810
811 // Convert keywords into SQL statements.
812 $query = array();
813 $query2 = array();
814 $arguments = array();
815 $arguments2 = array();
816 $matches = 0;
817 $simple_and = FALSE;
818 $simple_or = FALSE;
819 // Positive matches
820 foreach ($keys['positive'] as $key) {
821 // Group of ORed terms
822 if (is_array($key) && count($key)) {
823 $simple_or = TRUE;
824 $queryor = array();
825 $any = FALSE;
826 foreach ($key as $or) {
827 list($q, $num_new_scores) = _search_parse_query($or, $arguments2);
828 $any |= $num_new_scores;
829 if ($q) {
830 $queryor[] = $q;
831 $arguments[] = "% $or %";
832 }
833 }
834 if (count($queryor)) {
835 $query[] = '(' . implode(' OR ', $queryor) . ')';
836 // A group of OR keywords only needs to match once
837 $matches += ($any > 0);
838 }
839 }
840 // Single ANDed term
841 else {
842 $simple_and = TRUE;
843 list($q, $num_new_scores, $num_valid_words) = _search_parse_query($key, $arguments2);
844 if ($q) {
845 $query[] = $q;
846 $arguments[] = "% $key %";
847 if (!$num_valid_words) {
848 $simple = FALSE;
849 }
850 // Each AND keyword needs to match at least once
851 $matches += $num_new_scores;
852 }
853 }
854 }
855 if ($simple_and && $simple_or) {
856 $simple = FALSE;
857 }
858 // Negative matches
859 foreach ($keys['negative'] as $key) {
860 list($q) = _search_parse_query($key, $arguments2, TRUE);
861 if ($q) {
862 $query[] = $q;
863 $arguments[] = "% $key %";
864 $simple = FALSE;
865 }
866 }
867 $query = implode(' AND ', $query);
868
869 // Build word-index conditions for the first pass
870 $query2 = substr(str_repeat("i.word = '%s' OR ", count($arguments2)), 0, -4);
871
872 return array($query, $arguments, $query2, $arguments2, $matches, $simple, $warning);
873 }
874
875 /**
876 * Helper function for search_parse_query();
877 */
878 function _search_parse_query(&$word, &$scores, $not = FALSE) {
879 $num_new_scores = 0;
880 $num_valid_words = 0;
881 // Determine the scorewords of this word/phrase
882 if (!$not) {
883 $split = explode(' ', $word);
884 foreach ($split as $s) {
885 $num = is_numeric($s);
886 if ($num || drupal_strlen($s) >= variable_get('minimum_word_size', 3)) {
887 $s = $num ? ((int)ltrim($s, '-0')) : $s;
888 if (!isset($scores[$s])) {
889 $scores[$s] = $s;
890 $num_new_scores++;
891 }
892 $num_valid_words++;
893 }
894 }
895 }
896 // Return matching snippet and number of added words
897 return array("d.data " . ($not ? 'NOT ' : '') . "LIKE '%s'", $num_new_scores, $num_valid_words);
898 }
899
900 /**
901 * Do a query on the full-text search index for a word or words.
902 *
903 * This function is normally only called by each module that support the
904 * indexed search (and thus, implements hook_update_index()).
905 *
906 * Results are retrieved in two logical passes. However, the two passes are
907 * joined together into a single query. And in the case of most simple
908 * queries the second pass is not even used.
909 *
910 * The first pass selects a set of all possible matches, which has the benefit
911 * of also providing the exact result set for simple "AND" or "OR" searches.
912 *
913 * The second portion of the query further refines this set by verifying
914 * advanced text conditions (such negative or phrase matches)
915 *
916 * @param $keywords
917 * A search string as entered by the user.
918 *
919 * @param $type
920 * A string identifying the calling module.
921 *
922 * @param $join1
923 * (optional) Inserted into the JOIN part of the first SQL query.
924 * For example "INNER JOIN {node} n ON n.nid = i.sid".
925 *
926 * @param $where1
927 * (optional) Inserted into the WHERE part of the first SQL query.
928 * For example "(n.status > %d)".
929 *
930 * @param $arguments1
931 * (optional) Extra SQL arguments belonging to the first query.
932 *
933 * @param $columns2
934 * (optional) Inserted into the SELECT pat of the second query. Must contain
935 * a column selected as 'calculated_score'.
936 * defaults to 'SUM(i.relevance) AS calculated_score'
937 *
938 * @param $join2
939 * (optional) Inserted into the JOIN par of the second SQL query.
940 * For example "INNER JOIN {node_comment_statistics} n ON n.nid = i.sid"
941 *
942 * @param $arguments2
943 * (optional) Extra SQL arguments belonging to the second query parameter.
944 *
945 * @param $sort_parameters
946 * (optional) SQL arguments for sorting the final results.
947 * Default: 'ORDER BY calculated_score DESC'
948 *
949 * @return
950 * An array of SIDs for the search results.
951 *
952 * @ingroup search
953 */
954 function do_search($keywords, $type, $join1 = '', $where1 = '1 = 1', $arguments1 = array(), $columns2 = 'SUM(i.relevance) AS calculated_score', $join2 = '', $arguments2 = array(), $sort_parameters = 'ORDER BY calculated_score DESC') {
955 $query = search_parse_query($keywords);
956
957 if ($query[2] == '') {
958 form_set_error('keys', format_plural(variable_get('minimum_word_size', 3), 'You must include at least one positive keyword with 1 character or more.', 'You must include at least one positive keyword with @count characters or more.'));
959 }
960 if ($query[6]) {
961 if ($query[6] == 'or') {
962 drupal_set_message(t('Search for either of the two terms with uppercase <strong>OR</strong>. For example, <strong>cats OR dogs</strong>.'));
963 }
964 }
965 if ($query === NULL || $query[0] == '' || $query[2] == '') {
966 return array();
967 }
968
969 // Build query for keyword normalization.
970 $conditions = "$where1 AND ($query[2]) AND i.type = '%s'";
971 $arguments1 = array_merge($arguments1, $query[3], array($type));
972 $join = "INNER JOIN {search_total} t ON i.word = t.word $join1";
973 if (!$query[5]) {
974 $conditions .= " AND ($query[0])";
975 $arguments1 = array_merge($arguments1, $query[1]);
976 $join .= " INNER JOIN {search_dataset} d ON i.sid = d.sid AND i.type = d.type";
977 }
978
979 // Calculate maximum keyword relevance, to normalize it.
980 $select = "SELECT SUM(i.score * t.count) AS calculated_score FROM {search_index} i $join WHERE $conditions GROUP BY i.type, i.sid HAVING COUNT(*) >= %d ORDER BY calculated_score DESC";
981 $arguments = array_merge($arguments1, array($query[4]));
982 $normalize = db_result(db_query_range($select, $arguments, 0, 1));
983 if (!$normalize) {
984 return array();
985 }
986 $columns2 = str_replace('i.relevance', '(' . (1.0 / $normalize) . ' * i.score * t.count)', $columns2);
987
988 // Build query to retrieve results.
989 $select = "SELECT i.type, i.sid, $columns2 FROM {search_index} i $join $join2 WHERE $conditions GROUP BY i.type, i.sid HAVING COUNT(*) >= %d";
990 $count_select = "SELECT COUNT(*) FROM ($select) n1";
991 $arguments = array_values(array_merge($arguments2, $arguments1, array($query[4])));
992
993 // Do actual search query
994 $result = pager_query("$select $sort_parameters", 10, 0, $count_select, $arguments);
995 $results = array();
996 while ($item = db_fetch_object($result)) {
997 $results[] = $item;
998 }
999 return $results;
1000 }
1001
1002 /**
1003 * Helper function for grabbing search keys.
1004 */
1005 function search_get_keys() {
1006 static $return;
1007 if (!isset($return)) {
1008 // Extract keys as remainder of path
1009 // Note: support old GET format of searches for existing links.
1010 $path = explode('/', $_GET['q'], 3);
1011 $keys = empty($_REQUEST['keys']) ? '' : $_REQUEST['keys'];
1012 $return = count($path) == 3 ? $path[2] : $keys;
1013 }
1014 return $return;
1015 }
1016
1017 /**
1018 * @defgroup search Search interface
1019 * @{
1020 * The Drupal search interface manages a global search mechanism.
1021 *
1022 * Modules may plug into this system to provide searches of different types of
1023 * data. Most of the system is handled by search.module, so this must be enabled
1024 * for all of the search features to work.
1025 *
1026 * There are three ways to interact with the search system:
1027 * - Specifically for searching nodes, you can implement hook_node_update_index()
1028 * and hook_node_search_result(). However, note that the search system already
1029 * indexes all visible output of a node, i.e. everything displayed normally
1030 * by hook_view() and hook_node_view(). This is usually sufficient. You should
1031 * only use this mechanism if you want additional, non-visible data to be
1032 * indexed.
1033 * - Implement hook_search(). This will create a search tab for your module on
1034 * the /search page with a simple keyword search form.
1035 * - Implement hook_update_index(). This allows your module to use Drupal's
1036 * HTML indexing mechanism for searching full text efficiently.
1037 *
1038 * If your module needs to provide a more complicated search form, then you need
1039 * to implement it yourself without hook_search(). In that case, you should
1040 * define it as a local task (tab) under the /search page (e.g. /search/mymodule)
1041 * so that users can easily find it.
1042 */
1043
1044 /**
1045 * Render a search form.
1046 *
1047 * @param $action
1048 * Form action. Defaults to "search".
1049 * @param $keys
1050 * The search string entered by the user, containing keywords for the search.
1051 * @param $type
1052 * The type of search to render the node for. Must be the name of module
1053 * which implements hook_search(). Defaults to 'node'.
1054 * @param $prompt
1055 * A piece of text to put before the form (e.g. "Enter your keywords")
1056 * @return
1057 * An HTML string containing the search form.
1058 */
1059 function search_form(&$form_state, $action = '', $keys = '', $type = NULL, $prompt = NULL) {
1060
1061 // Add CSS
1062 drupal_add_css(drupal_get_path('module', 'search') . '/search.css', array('preprocess' => FALSE));
1063
1064 if (!$action) {
1065 $action = url('search/' . $type);
1066 }
1067 if (is_null($prompt)) {
1068 $prompt = t('Enter your keywords');
1069 }
1070
1071 $form = array(
1072 '#action' => $action,
1073 '#attributes' => array('class' => 'search-form'),
1074 );
1075 $form['module'] = array('#type' => 'value', '#value' => $type);
1076 $form['basic'] = array('#type' => 'item', '#title' => $prompt, '#id' => 'edit-keys');
1077 $form['basic']['inline'] = array('#prefix' => '<div class="container-inline">', '#suffix' => '</div>');
1078 $form['basic']['inline']['keys'] = array(
1079 '#type' => 'textfield',
1080 '#title' => '',
1081 '#default_value' => $keys,
1082 '#size' => $prompt ? 40 : 20,
1083 '#maxlength' => 255,
1084 );
1085 // processed_keys is used to coordinate keyword passing between other forms
1086 // that hook into the basic search form.
1087 $form['basic']['inline']['processed_keys'] = array('#type' => 'value', '#value' => array());
1088 $form['basic']['inline']['submit'] = array('#type' => 'submit', '#value' => t('Search'));
1089
1090 return $form;
1091 }
1092
1093 /**
1094 * Form builder; Output a search form for the search block and the theme's search box.
1095 *
1096 * @ingroup forms
1097 * @see search_box_form_submit()
1098 * @see search-theme-form.tpl.php
1099 * @see search-block-form.tpl.php
1100 */
1101 function search_box(&$form_state, $form_id) {
1102 $form[$form_id] = array(
1103 '#title' => t('Search this site'),
1104 '#type' => 'textfield',
1105 '#size' => 15,
1106 '#default_value' => '',
1107 '#attributes' => array('title' => t('Enter the terms you wish to search for.')),
1108 );
1109 $form['submit'] = array('#type' => 'submit', '#value' => t('Search'));
1110 $form['#submit'][] = 'search_box_form_submit';
1111
1112 return $form;
1113 }
1114
1115 /**
1116 * Process a block search form submission.
1117 */
1118 function search_box_form_submit($form, &$form_state) {
1119 // The search form relies on control of the redirect destination for its
1120 // functionality, so we override any static destination set in the request,
1121 // for example by drupal_access_denied() or drupal_not_found()
1122 // (see http://drupal.org/node/292565).
1123 if (isset($_REQUEST['destination'])) {
1124 unset($_REQUEST['destination']);
1125 }
1126
1127 $form_id = $form['form_id']['#value'];
1128 $form_state['redirect'] = 'search/node/' . trim($form_state['values'][$form_id]);
1129 }
1130
1131 /**
1132 * Process variables for search-theme-form.tpl.php.
1133 *
1134 * The $variables array contains the following arguments:
1135 * - $form
1136 *
1137 * @see search-theme-form.tpl.php
1138 */
1139 function template_preprocess_search_theme_form(&$variables) {
1140 $variables['search'] = array();
1141 $hidden = array();
1142 // Provide variables named after form keys so themers can print each element independently.
1143 foreach (element_children($variables['form']) as $key) {
1144 $type = $variables['form'][$key]['#type'];
1145 if ($type == 'hidden' || $type == 'token') {
1146 $hidden[] = drupal_render($variables['form'][$key]);
1147 }
1148 else {
1149 $variables['search'][$key] = drupal_render($variables['form'][$key]);
1150 }
1151 }
1152 // Hidden form elements have no value to themers. No need for separation.
1153 $variables['search']['hidden'] = implode($hidden);
1154 // Collect all form elements to make it easier to print the whole form.
1155 $variables['search_form'] = implode($variables['search']);
1156 }
1157
1158 /**
1159 * Process variables for search-block-form.tpl.php.
1160 *
1161 * The $variables array contains the following arguments:
1162 * - $form
1163 *
1164 * @see search-block-form.tpl.php
1165 */
1166 function template_preprocess_search_block_form(&$variables) {
1167 $variables['search'] = array();
1168 $hidden = array();
1169 // Provide variables named after form keys so themers can print each element independently.
1170 foreach (element_children($variables['form']) as $key) {
1171 $type = $variables['form'][$key]['#type'];
1172 if ($type == 'hidden' || $type == 'token') {
1173 $hidden[] = drupal_render($variables['form'][$key]);
1174 }
1175 else {
1176 $variables['search'][$key] = drupal_render($variables['form'][$key]);
1177 }
1178 }
1179 // Hidden form elements have no value to themers. No need for separation.
1180 $variables['search']['hidden'] = implode($hidden);
1181 // Collect all form elements to make it easier to print the whole form.
1182 $variables['search_form'] = implode($variables['search']);
1183 }
1184
1185 /**
1186 * Perform a standard search on the given keys, and return the formatted results.
1187 */
1188 function search_data($keys = NULL, $type = 'node') {
1189
1190 if (isset($keys)) {
1191 if (module_hook($type, 'search')) {
1192 $results = module_invoke($type, 'search', 'search', $keys);
1193 if (isset($results) && is_array($results) && count($results)) {
1194 if (module_hook($type, 'search_page')) {
1195 return module_invoke($type, 'search_page', $results);
1196 }
1197 else {
1198 return theme('search_results', $results, $type);
1199 }
1200 }
1201 }
1202 }
1203 }
1204
1205 /**
1206 * Returns snippets from a piece of text, with certain keywords highlighted.
1207 * Used for formatting search results.
1208 *
1209 * @param $keys
1210 * A string containing a search query.
1211 *
1212 * @param $text
1213 * The text to extract fragments from.
1214 *
1215 * @return
1216 * A string containing HTML for the excerpt.
1217 */
1218 function search_excerpt($keys, $text) {
1219 // We highlight around non-indexable or CJK characters.
1220 $boundary = '(?:(?<=[' . PREG_CLASS_SEARCH_EXCLUDE . PREG_CLASS_CJK . '])|(?=[' . PREG_CLASS_SEARCH_EXCLUDE . PREG_CLASS_CJK . ']))';
1221
1222 // Extract positive keywords and phrases
1223 preg_match_all('/ ("([^"]+)"|(?!OR)([^" ]+))/', ' ' . $keys, $matches);
1224 $keys = array_merge($matches[2], $matches[3]);
1225
1226 // Prepare text
1227 $text = ' ' . strip_tags(str_replace(array('<', '>'), array(' <', '> '), $text)) . ' ';
1228 array_walk($keys, '_search_excerpt_replace');
1229 $workkeys = $keys;
1230
1231 // Extract a fragment per keyword for at most 4 keywords.
1232 // First we collect ranges of text around each keyword, starting/ending
1233 // at spaces.
1234 // If the sum of all fragments is too short, we look for second occurrences.
1235 $ranges = array();
1236 $included = array();
1237 $length = 0;
1238 while ($length < 256 && count($workkeys)) {
1239 foreach ($workkeys as $k => $key) {
1240 if (strlen($key) == 0) {
1241 unset($workkeys[$k]);
1242 unset($keys[$k]);
1243 continue;
1244 }
1245 if ($length >= 256) {
1246 break;
1247 }
1248 // Remember occurrence of key so we can skip over it if more occurrences
1249 // are desired.
1250 if (!isset($included[$key])) {
1251 $included[$key] = 0;
1252 }
1253 // Locate a keyword (position $p), then locate a space in front (position
1254 // $q) and behind it (position $s)
1255 if (preg_match('/' . $boundary . $key . $boundary . '/iu', $text, $match, PREG_OFFSET_CAPTURE, $included[$key])) {
1256 $p = $match[0][1];
1257 if (($q = strpos($text, ' ', max(0, $p - 60))) !== FALSE) {
1258 $end = substr($text, $p, 80);
1259 if (($s = strrpos($end, ' ')) !== FALSE) {
1260 $ranges[$q] = $p + $s;
1261 $length += $p + $s - $q;
1262 $included[$key] = $p + 1;
1263 }
1264 else {
1265 unset($workkeys[$k]);
1266 }
1267 }
1268 else {
1269 unset($workkeys[$k]);
1270 }
1271 }
1272 else {
1273 unset($workkeys[$k]);
1274 }
1275 }
1276 }
1277
1278 // If we didn't find anything, return the beginning.
1279 if (count($ranges) == 0) {
1280 return truncate_utf8($text, 256) . ' ...';
1281 }
1282
1283 // Sort the text ranges by starting position.
1284 ksort($ranges);
1285
1286 // Now we collapse overlapping text ranges into one. The sorting makes it O(n).
1287 $newranges = array();
1288 foreach ($ranges as $from2 => $to2) {
1289 if (!isset($from1)) {
1290 $from1 = $from2;
1291 $to1 = $to2;
1292 continue;
1293 }
1294 if ($from2 <= $to1) {
1295 $to1 = max($to1, $to2);
1296 }
1297 else {
1298 $newranges[$from1] = $to1;
1299 $from1 = $from2;
1300 $to1 = $to2;
1301 }
1302 }
1303 $newranges[$from1] = $to1;
1304
1305 // Fetch text
1306 $out = array();
1307 foreach ($newranges as $from => $to) {
1308 $out[] = substr($text, $from, $to - $from);
1309 }
1310 $text = (isset($newranges[0]) ? '' : '... ') . implode(' ... ', $out) . ' ...';
1311
1312 // Highlight keywords. Must be done at once to prevent conflicts ('strong' and '<strong>').
1313 $text = preg_replace('/' . $boundary . '(' . implode('|', $keys) . ')' . $boundary . '/iu', '<strong>\0</strong>', $text);
1314 return $text;
1315 }
1316
1317 /**
1318 * @} End of "defgroup search".
1319 */
1320
1321 /**
1322 * Helper function for array_walk in search_except.
1323 */
1324 function _search_excerpt_replace(&$text) {
1325 $text = preg_quote($text, '/');
1326 }
1327
1328 function search_forms() {
1329 $forms['search_theme_form']= array(
1330 'callback' => 'search_box',
1331 'callback arguments' => array('search_theme_form'),
1332 );
1333 $forms['search_block_form']= array(
1334 'callback' => 'search_box',
1335 'callback arguments' => array('search_block_form'),
1336 );
1337 return $forms;
1338 }
1339

Legend

Missed
lines code that were not excersized during program execution.
Covered
lines code were excersized during program execution.
Comment/non executable
Comment or non-executable line of code.
Dead
lines of code that according to xdebug could not be executed. This is counted as coverage code because in almost all cases it is code that runnable.