source: trunk/include/functions_search.inc.php @ 17748

Last change on this file since 17748 was 17748, checked in by rvelices, 12 years ago

bug 2735: fix/improve non latin language tags

  1. non latin tags (greek/cyrillic...) are not sorted case-insesitive and group by letter view in tag list is not case insesitive
  2. quick searching tag names does not perform correctly accent folding (e.g. Köln and Koln do not match) and case insesitivity for non latin letters
  3. missing from remove_accents characters in romanian language (Latin Extended-B) ? c8 98 = LATIN CAPITAL LETTER S WITH COMMA BELOW ? c8 99 = LATIN SMALL LETTER S WITH COMMA BELOW ? c8 9a = LATIN CAPITAL LETTER T WITH COMMA BELOW ? c8 9b = LATIN SMALL LETTER T WITH COMMA BELOW
  4. str2url allow non latin letters in output only if the input does not contain any valid lating letter/digit. we should always allow non latin letters in output
  • Property svn:eol-style set to LF
File size: 19.0 KB
Line 
1<?php
2// +-----------------------------------------------------------------------+
3// | Piwigo - a PHP based photo gallery                                    |
4// +-----------------------------------------------------------------------+
5// | Copyright(C) 2008-2012 Piwigo Team                  http://piwigo.org |
6// | Copyright(C) 2003-2008 PhpWebGallery Team    http://phpwebgallery.net |
7// | Copyright(C) 2002-2003 Pierrick LE GALL   http://le-gall.net/pierrick |
8// +-----------------------------------------------------------------------+
9// | This program is free software; you can redistribute it and/or modify  |
10// | it under the terms of the GNU General Public License as published by  |
11// | the Free Software Foundation                                          |
12// |                                                                       |
13// | This program is distributed in the hope that it will be useful, but   |
14// | WITHOUT ANY WARRANTY; without even the implied warranty of            |
15// | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU      |
16// | General Public License for more details.                              |
17// |                                                                       |
18// | You should have received a copy of the GNU General Public License     |
19// | along with this program; if not, write to the Free Software           |
20// | Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, |
21// | USA.                                                                  |
22// +-----------------------------------------------------------------------+
23
24
25/**
26 * returns search rules stored into a serialized array in "search"
27 * table. Each search rules set is numericaly identified.
28 *
29 * @param int search_id
30 * @return array
31 */
32function get_search_array($search_id)
33{
34  if (!is_numeric($search_id))
35  {
36    die('Search id must be an integer');
37  }
38
39  $query = '
40SELECT rules
41  FROM '.SEARCH_TABLE.'
42  WHERE id = '.$search_id.'
43;';
44  list($serialized_rules) = pwg_db_fetch_row(pwg_query($query));
45
46  return unserialize($serialized_rules);
47}
48
49/**
50 * returns the SQL clause from a search identifier
51 *
52 * Search rules are stored in search table as a serialized array. This array
53 * need to be transformed into an SQL clause to be used in queries.
54 *
55 * @param array search
56 * @return string
57 */
58function get_sql_search_clause($search)
59{
60  // SQL where clauses are stored in $clauses array during query
61  // construction
62  $clauses = array();
63
64  foreach (array('file','name','comment','author') as $textfield)
65  {
66    if (isset($search['fields'][$textfield]))
67    {
68      $local_clauses = array();
69      foreach ($search['fields'][$textfield]['words'] as $word)
70      {
71        array_push($local_clauses, $textfield." LIKE '%".$word."%'");
72      }
73
74      // adds brackets around where clauses
75      $local_clauses = prepend_append_array_items($local_clauses, '(', ')');
76
77      array_push(
78        $clauses,
79        implode(
80          ' '.$search['fields'][$textfield]['mode'].' ',
81          $local_clauses
82          )
83        );
84    }
85  }
86
87  if (isset($search['fields']['allwords']))
88  {
89    $fields = array('file', 'name', 'comment', 'author');
90    // in the OR mode, request bust be :
91    // ((field1 LIKE '%word1%' OR field2 LIKE '%word1%')
92    // OR (field1 LIKE '%word2%' OR field2 LIKE '%word2%'))
93    //
94    // in the AND mode :
95    // ((field1 LIKE '%word1%' OR field2 LIKE '%word1%')
96    // AND (field1 LIKE '%word2%' OR field2 LIKE '%word2%'))
97    $word_clauses = array();
98    foreach ($search['fields']['allwords']['words'] as $word)
99    {
100      $field_clauses = array();
101      foreach ($fields as $field)
102      {
103        array_push($field_clauses, $field." LIKE '%".$word."%'");
104      }
105      // adds brackets around where clauses
106      array_push(
107        $word_clauses,
108        implode(
109          "\n          OR ",
110          $field_clauses
111          )
112        );
113    }
114
115    array_walk(
116      $word_clauses,
117      create_function('&$s','$s="(".$s.")";')
118      );
119
120    array_push(
121      $clauses,
122      "\n         ".
123      implode(
124        "\n         ".
125              $search['fields']['allwords']['mode'].
126        "\n         ",
127        $word_clauses
128        )
129      );
130  }
131
132  foreach (array('date_available', 'date_creation') as $datefield)
133  {
134    if (isset($search['fields'][$datefield]))
135    {
136      array_push(
137        $clauses,
138        $datefield." = '".$search['fields'][$datefield]['date']."'"
139        );
140    }
141
142    foreach (array('after','before') as $suffix)
143    {
144      $key = $datefield.'-'.$suffix;
145
146      if (isset($search['fields'][$key]))
147      {
148        array_push(
149          $clauses,
150
151          $datefield.
152          ($suffix == 'after'             ? ' >' : ' <').
153          ($search['fields'][$key]['inc'] ? '='  : '').
154          " '".$search['fields'][$key]['date']."'"
155
156          );
157      }
158    }
159  }
160
161  if (isset($search['fields']['cat']))
162  {
163    if ($search['fields']['cat']['sub_inc'])
164    {
165      // searching all the categories id of sub-categories
166      $cat_ids = get_subcat_ids($search['fields']['cat']['words']);
167    }
168    else
169    {
170      $cat_ids = $search['fields']['cat']['words'];
171    }
172
173    $local_clause = 'category_id IN ('.implode(',', $cat_ids).')';
174    array_push($clauses, $local_clause);
175  }
176
177  // adds brackets around where clauses
178  $clauses = prepend_append_array_items($clauses, '(', ')');
179
180  $where_separator =
181    implode(
182      "\n    ".$search['mode'].' ',
183      $clauses
184      );
185
186  $search_clause = $where_separator;
187
188  return $search_clause;
189}
190
191/**
192 * returns the list of items corresponding to the advanced search array
193 *
194 * @param array search
195 * @return array
196 */
197function get_regular_search_results($search, $images_where)
198{
199  global $conf;
200  $forbidden = get_sql_condition_FandF(
201        array
202          (
203            'forbidden_categories' => 'category_id',
204            'visible_categories' => 'category_id',
205            'visible_images' => 'id'
206          ),
207        "\n  AND"
208    );
209
210  $items = array();
211  $tag_items = array();
212
213  if (isset($search['fields']['tags']))
214  {
215    $tag_items = get_image_ids_for_tags(
216      $search['fields']['tags']['words'],
217      $search['fields']['tags']['mode']
218      );
219  }
220
221  $search_clause = get_sql_search_clause($search);
222
223  if (!empty($search_clause))
224  {
225    $query = '
226SELECT DISTINCT(id)
227  FROM '.IMAGES_TABLE.' i
228    INNER JOIN '.IMAGE_CATEGORY_TABLE.' AS ic ON id = ic.image_id
229  WHERE '.$search_clause;
230    if (!empty($images_where))
231    {
232      $query .= "\n  AND ".$images_where;
233    }
234    $query .= $forbidden.'
235  '.$conf['order_by'];
236    $items = array_from_query($query, 'id');
237  }
238
239  if ( !empty($tag_items) )
240  {
241    switch ($search['mode'])
242    {
243      case 'AND':
244        if (empty($search_clause))
245        {
246          $items = $tag_items;
247        }
248        else
249        {
250          $items = array_values( array_intersect($items, $tag_items) );
251        }
252        break;
253      case 'OR':
254        $before_count = count($items);
255        $items = array_unique(
256          array_merge(
257            $items,
258            $tag_items
259            )
260          );
261        break;
262    }
263  }
264
265  return $items;
266}
267
268
269function is_word_char($ch)
270{
271  return ($ch>='0' && $ch<='9') || ($ch>='a' && $ch<='z') || ($ch>='A' && $ch<='Z') || ord($ch)>127;
272}
273
274/**
275 * analyzes and splits the quick/query search query $q into tokens
276 * q='john bill' => 2 tokens 'john' 'bill'
277 * Special characters for MySql full text search (+,<,>,~) appear in the token modifiers.
278 * The query can contain a phrase: 'Pierre "New York"' will return 'pierre' qnd 'new york'.
279 */
280function analyse_qsearch($q, &$qtokens, &$qtoken_modifiers)
281{
282  $q = stripslashes($q);
283  $tokens = array();
284  $token_modifiers = array();
285  $crt_token = "";
286  $crt_token_modifier = "";
287  $state = 0;
288
289  for ($i=0; $i<strlen($q); $i++)
290  {
291    $ch = $q[$i];
292    switch ($state)
293    {
294      case 0:
295        if ($ch=='"')
296        {
297          $tokens[] = $crt_token; $token_modifiers[] = $crt_token_modifier;
298          $crt_token = ""; $crt_token_modifier = "q";
299          $state=1;
300        }
301        elseif ( $ch=='*' )
302        { // wild card
303          if (strlen($crt_token))
304          {
305            $crt_token .= $ch;
306          }
307          else
308          {
309            $crt_token_modifier .= '*';
310          }
311        }
312        elseif ( strcspn($ch, '+-><~')==0 )
313        { //special full text modifier
314          if (strlen($crt_token))
315          {
316            $tokens[] = $crt_token; $token_modifiers[] = $crt_token_modifier;
317            $crt_token = ""; $crt_token_modifier = "";
318          }
319          $crt_token_modifier .= $ch;
320        }
321        elseif (preg_match('/[\s,.;!\?]+/', $ch))
322        { // white space
323          if (strlen($crt_token))
324          {
325            $tokens[] = $crt_token; $token_modifiers[] = $crt_token_modifier;
326            $crt_token = ""; $crt_token_modifier = "";
327          }
328        }
329        else
330        {
331          $crt_token .= $ch;
332        }
333        break;
334      case 1: // qualified with quotes
335        switch ($ch)
336        {
337          case '"':
338            $tokens[] = $crt_token; $token_modifiers[] = $crt_token_modifier;
339            $crt_token = ""; $crt_token_modifier = "";
340            $state=0;
341            break;
342          default:
343            $crt_token .= $ch;
344        }
345        break;
346    }
347  }
348  if (strlen($crt_token))
349  {
350    $tokens[] = $crt_token;
351    $token_modifiers[] = $crt_token_modifier;
352  }
353
354  $qtokens = array();
355  $qtoken_modifiers = array();
356  for ($i=0; $i<count($tokens); $i++)
357  {
358    if (strstr($token_modifiers[$i], 'q')===false)
359    {
360      if ( substr($tokens[$i], -1)=='*' )
361      {
362        $tokens[$i] = rtrim($tokens[$i], '*');
363        $token_modifiers[$i] .= '*';
364      }
365    }
366    if ( strlen($tokens[$i])==0)
367      continue;
368    $qtokens[] = $tokens[$i];
369    $qtoken_modifiers[] = $token_modifiers[$i];
370  }
371}
372
373
374/**
375 * returns the LIKE sql clause corresponding to the quick search query
376 * that has been split into tokens
377 * for example file LIKE '%john%' OR file LIKE '%bill%'.
378 */
379function get_qsearch_like_clause($tokens, $token_modifiers, $field)
380{
381  $clauses = array();
382  for ($i=0; $i<count($tokens); $i++)
383  {
384    $token = trim($tokens[$i], '%');
385    if (strstr($token_modifiers[$i], '-')!==false)
386      continue;
387    if ( strlen($token)==0 )
388      continue;
389    $token = addslashes($token);
390    $token = str_replace( array('%','_'), array('\\%','\\_'), $token); // escape LIKE specials %_
391    $clauses[] = $field.' LIKE \'%'.$token.'%\'';
392  }
393
394  return count($clauses) ? '('.implode(' OR ', $clauses).')' : null;
395}
396
397/**
398 * returns the search results corresponding to a quick/query search.
399 * A quick/query search returns many items (search is not strict), but results
400 * are sorted by relevance unless $super_order_by is true. Returns:
401 * array (
402 * 'items' => array(85,68,79...)
403 * 'qs'    => array(
404 *    'matching_tags' => array of matching tags
405 *    'matching_cats' => array of matching categories
406 *    'matching_cats_no_images' =>array(99) - matching categories without images
407 *      ))
408 *
409 * @param string q
410 * @param bool super_order_by
411 * @param string images_where optional aditional restriction on images table
412 * @return array
413 */
414function get_quick_search_results($q, $super_order_by, $images_where='')
415{
416  global $user, $conf;
417
418  $search_results =
419    array(
420      'items' => array(),
421      'qs' => array('q'=>stripslashes($q)),
422    );
423  $q = trim($q);
424  if (empty($q))
425  {
426    return $search_results;
427  }
428 
429  analyse_qsearch($q, $tokens, $token_modifiers);
430
431  $q_like_field = '@@__db_field__@@'; //something never in a search
432  $q_like_clause = get_qsearch_like_clause($tokens, $token_modifiers, $q_like_field );
433
434  // Step 1 - first we find matches in #images table ===========================
435  $where_clauses='MATCH(i.name, i.comment) AGAINST( \''.$q.'\' IN BOOLEAN MODE)';
436  if (!empty($q_like_clause))
437  {
438    $where_clauses .= '
439    OR '. str_replace($q_like_field, 'CONVERT(file, CHAR)', $q_like_clause);
440    $where_clauses = '('.$where_clauses.')';
441  }
442  $where_clauses = array($where_clauses);
443  if (!empty($images_where))
444  {
445    $where_clauses[]='('.$images_where.')';
446  }
447  $where_clauses[] .= get_sql_condition_FandF
448      (
449        array( 'visible_images' => 'i.id' ), null, true
450      );
451  $query = '
452SELECT i.id,
453    MATCH(i.name, i.comment) AGAINST( \''.$q.'\' IN BOOLEAN MODE) AS weight
454  FROM '.IMAGES_TABLE.' i
455  WHERE '.implode("\n AND ", $where_clauses);
456
457  $by_weights=array();
458  $result = pwg_query($query);
459  while ($row = pwg_db_fetch_assoc($result))
460  { // weight is important when sorting images by relevance
461    if ($row['weight'])
462    {
463      $by_weights[(int)$row['id']] =  2*$row['weight'];
464    }
465    else
466    {//full text does not match but file name match
467      $by_weights[(int)$row['id']] =  2;
468    }
469  }
470
471
472  // Step 2 - search tags corresponding to the query $q ========================
473  $transliterated_tokens = array();
474  $token_tags = array();
475  foreach ($tokens as $token)
476  {
477    $transliterated_tokens[] = transliterate($token);
478    $token_tags[] = array();
479  }
480
481  // Step 2.1 - find match tags for every token in the query search
482  $all_tags = array();
483  $query = '
484SELECT id, name, url_name, COUNT(image_id) AS nb_images
485  FROM '.TAGS_TABLE.'
486    INNER JOIN '.IMAGE_TAG_TABLE.' ON id=tag_id
487  GROUP BY id';
488  $result = pwg_query($query);
489  while ($tag = pwg_db_fetch_assoc($result))
490  {
491    $transliterated_tag = transliterate($tag['name']);
492
493    // find how this tag matches query tokens
494    for ($i=0; $i<count($tokens); $i++)
495    {
496      if (strstr($token_modifiers[$i], '-')!==false)
497        continue;// ignore this NOT token
498      $transliterated_token = $transliterated_tokens[$i];
499
500      $match = false;
501      $pos = 0;
502      while ( ($pos = strpos($transliterated_tag, $transliterated_token, $pos)) !== false)
503      {
504        if (strstr($token_modifiers[$i], '*')!==false)
505        {// wildcard in this token
506          $match = 1;
507          break;
508        }
509        $token_len = strlen($transliterated_token);
510
511        $word_begin = $pos;
512        while ($word_begin>0)
513        {
514          if (! is_word_char($transliterated_tag[$word_begin-1]) )
515            break;
516          $word_begin--;
517        }
518
519        $word_end = $pos + $token_len;
520        while ($word_end<strlen($transliterated_tag) && is_word_char($transliterated_tag[$word_end]) )
521          $word_end++;
522
523        $this_score = $token_len / ($word_end-$word_begin);
524        if ($token_len <= 2)
525        {// search for 1 or 2 characters must match exactly to avoid retrieving too much data
526          if ($token_len != $word_end-$word_begin)
527            $this_score = 0;
528        }
529        elseif ($token_len == 3)
530        {
531          if ($word_end-$word_begin > 4)
532            $this_score = 0;
533        }
534
535        if ($this_score>0)
536          $match = max($match, $this_score );
537        $pos++;
538      }
539
540      if ($match)
541      {
542        $tag_id = (int)$tag['id'];
543        $all_tags[$tag_id] = $tag;
544        $token_tags[$i][] = array('tag_id'=>$tag_id, 'score'=>$match);
545      }
546    }
547  }
548  $search_results['qs']['matching_tags']=$all_tags;
549
550  // Step 2.2 - reduce matching tags for every token in the query search
551  $score_cmp_fn = create_function('$a,$b', 'return 100*($b["score"]-$a["score"]);');
552  foreach ($token_tags as &$tt)
553  {
554    usort($tt, $score_cmp_fn);
555    $nb_images = 0;
556    $prev_score = 0;
557    for ($j=0; $j<count($tt); $j++)
558    {
559      if ($nb_images > 200 && $prev_score > $tt[$j]['score'] )
560      {// "many" images in previous tags and starting from this tag is less relevent
561        $tt = array_slice( $tt, 0, $j);
562        break;
563      }
564      $nb_images += $all_tags[ $tt[$j]['tag_id'] ]['nb_images'];
565      $prev_score = $tt[$j]['score'];
566    }
567  }
568
569  // Step 2.3 - get the images for tags
570  for ($i=0; $i<count($token_tags); $i++)
571  {
572    $tag_ids = array();
573    foreach($token_tags[$i] as $arr)
574      $tag_ids[] = $arr['tag_id'];
575
576    if (!empty($tag_ids))
577    {
578      $query = '
579SELECT image_id
580  FROM '.IMAGE_TAG_TABLE.'
581  WHERE tag_id IN ('.implode(',',$tag_ids).')
582  GROUP BY image_id';
583      $result = pwg_query($query);
584      while ($row = pwg_db_fetch_assoc($result))
585      { // weight is important when sorting images by relevance
586        $image_id=(int)$row['image_id'];
587        @$by_weights[$image_id] += 1;
588      }
589    }
590  }
591
592  // Step 3 - search categories corresponding to the query $q ==================
593  $query = '
594SELECT id, name, permalink, nb_images
595  FROM '.CATEGORIES_TABLE.'
596    INNER JOIN '.USER_CACHE_CATEGORIES_TABLE.' ON id=cat_id
597  WHERE user_id='.$user['id'].'
598    AND MATCH(name, comment) AGAINST( \''.$q.'\' IN BOOLEAN MODE)'.
599  get_sql_condition_FandF (
600      array( 'visible_categories' => 'cat_id' ), "\n    AND"
601    );
602  $result = pwg_query($query);
603  while ($row = pwg_db_fetch_assoc($result))
604  { // weight is important when sorting images by relevance
605    if ($row['nb_images']==0)
606    {
607      $search_results['qs']['matching_cats_no_images'][] = $row;
608    }
609    else
610    {
611      $search_results['qs']['matching_cats'][$row['id']] = $row;
612    }
613  }
614
615  if ( empty($by_weights) and empty($search_results['qs']['matching_cats']) )
616  {
617    return $search_results;
618  }
619
620  // Step 4 - now we have $by_weights ( array image id => weight ) that need
621  // permission checks and/or matching categories to get images from
622  $where_clauses = array();
623  if ( !empty($by_weights) )
624  {
625    $where_clauses[]='i.id IN ('
626      . implode(',', array_keys($by_weights)) . ')';
627  }
628  if ( !empty($search_results['qs']['matching_cats']) )
629  {
630    $where_clauses[]='category_id IN ('.
631      implode(',',array_keys($search_results['qs']['matching_cats'])).')';
632  }
633  $where_clauses = array( '('.implode("\n    OR ",$where_clauses).')' );
634  if (!empty($images_where))
635  {
636    $where_clauses[]='('.$images_where.')';
637  }
638  $where_clauses[] = get_sql_condition_FandF(
639      array
640        (
641          'forbidden_categories' => 'category_id',
642          'visible_categories' => 'category_id',
643          'visible_images' => 'i.id'
644        ),
645      null,true
646    );
647
648  $query = '
649SELECT DISTINCT(id)
650  FROM '.IMAGES_TABLE.' i
651    INNER JOIN '.IMAGE_CATEGORY_TABLE.' AS ic ON id = ic.image_id
652  WHERE '.implode("\n AND ", $where_clauses)."\n".
653  $conf['order_by'];
654
655  $allowed_images = array_from_query( $query, 'id');
656
657  if ( $super_order_by or empty($by_weights) )
658  {
659    $search_results['items'] = $allowed_images;
660    return $search_results;
661  }
662
663  $allowed_images = array_flip( $allowed_images );
664  $divisor = 5.0 * count($allowed_images);
665  foreach ($allowed_images as $id=>$rank )
666  {
667    $weight = isset($by_weights[$id]) ? $by_weights[$id] : 1;
668    $weight -= $rank/$divisor;
669    $allowed_images[$id] = $weight;
670  }
671  arsort($allowed_images, SORT_NUMERIC);
672  $search_results['items'] = array_keys($allowed_images);
673  return $search_results;
674}
675
676/**
677 * returns an array of 'items' corresponding to the search id
678 *
679 * @param int search id
680 * @param string images_where optional aditional restriction on images table
681 * @return array
682 */
683function get_search_results($search_id, $super_order_by, $images_where='')
684{
685  $search = get_search_array($search_id);
686  if ( !isset($search['q']) )
687  {
688    $result['items'] = get_regular_search_results($search, $images_where);
689    return $result;
690  }
691  else
692  {
693    return get_quick_search_results($search['q'], $super_order_by, $images_where);
694  }
695}
696?>
Note: See TracBrowser for help on using the repository browser.