* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package api * @subpackage link * @version $Id$ */ /** * eGW wide index over all applications (superindex) * * This index allows a fulltext search over all applications (or of cause also a single app). * Whenever an applications stores an entry it calls: * * boolean egw_index::save($app,$id,$owner,array $fields,array $cat_id=null), * * which calls, as the application do when is deletes an entry (!), * * boolean egw_index::delete($app,$id) * * and then splits all fields into keywords and add these to the index by * * boolean private egw_index::add($app,$id,$keyword). * * Applications can then use the index to search for a given keyword (and optional application): * * array egw_index::search($keyword,$app=null) or * * foreach(new egw_index($keyword,$app=null) as $app_id => $title) * * To also allow to search by a category or keyword part of it, the index also tracks the categories * of the entries. Applications can choose to only use it for category storage, or cat do it redundant in * there own table too. To retrieve the categories of one or multiple entries: * * array egw_index::cats($app,$ids) * * Applications can use a sql (sub-)query to get the id's of there app matching a certain keyword and * include that in there own queries: * * string egw_index::sql_ids_by_keyword($app,$keyword) * * Please note: the index knows nothing about ACL, so it's the task of the application to ensure ACL rights. */ class egw_index implements IteratorAggregate { const INDEX_TABLE = 'egw_index'; const KEYWORD_TABLE = 'egw_index_keywords'; const INDEX_CAT_TABLE = 'egw_cat2entry'; const CAT_TABLE = 'egw_categories'; const SEPARATORS = "/[ ,;.:\"'!\/?=()+*><|\n\r-]+/"; const MIN_KEYWORD_LEN = 4; /** * Private reference to the global db object * * @var egw_db */ private static $db; /** * Search parameters of the constructor * * @var array */ private $search_params; /** * Constructor for the search iterator * * @param string $keyword * @param string $app=null * @param string $order='app' ordered by column: 'owner', 'id', 'app' (default) * @param string $sort='ASC' sorting 'ASC' (default) or 'DESC' * @param int $start=null if not null return limited resultset starting with row $start * @param int $num_rows=0 number of rows for a limited resultset, defaul maxmatches from the user prefs */ function __construct($keyword,$app=null,$order='title',$sort='ASC',$start=null,$num_rows=0) { $this->search_params = func_get_args(); } /** * Return the result of egw_index::search() as ArrayIterator * * @return ArrayIterator */ function getIterator() { return new ArrayIterator(call_user_func_array(array(__CLASS__,'search'),$this->search_params)); } /** * Search for keywords * * @param string $keyword * @param string $app=null * @param string $order='app' ordered by column: 'keyword', 'id', 'app' (default) * @param string $sort='ASC' sorting 'ASC' (default) or 'DESC' * @param int $start=null if not null return limited resultset starting with row $start * @param int $num_rows=null number of rows for a limited resultset, defaul maxmatches from the user prefs * @return array with "$app:$id" or $id => $title pairs */ static function &search($keyword,$app=null,$order='title',$sort='ASC',$start=null,$num_rows=null) { if (!in_array(strtoupper($sort),array('ASC','DESC'))) $sort = 'ASC'; if (substr($order,0,3) != 'si_') $order = 'si_'.$order; if (!in_array($order,array('si_app','si_id','si_owner'))) $order = 'si_app'; $rs = self::$db->union(array( array( 'table' => self::INDEX_TABLE, 'cols' => 'si_app,si_app_id,si_owner', 'where' => array('keyword' => $keyword)+ ($app ? array('ce_app' => $app) : array()), ), array( 'table' => self::INDEX_CAT_TABLE, 'cols' => 'ce_app,ce_app_id,ce_owner', 'where' => array('cat_id IN (SELECT cat_id FROM '.self::CAT_TABLE.' WHERE cat_title '. self::$db->capabilities['case_insensitive_like'].' '.self::$db->quote('%'.$keyword.'%').')')+ ($app ? array('ce_app' => $app) : array()), ), ),__LINE__,__FILE__,$order.' '.$sort,$start,$num_rows); // agregate the ids by app $app_ids = $titles = $rows = array(); foreach($rs as $row) { $app_ids[$row['si_app']] = $row['si_app_id']; $rows[] = $row; } unset($rs); // query the titles app-wise foreach($app_ids as $id_app => $ids) { $titles[$id_app] = bolink::titles($id_app,$ids); } $matches = array(); foreach($rows as $row) { $key = $app ? $row['si_app_id'] : $row['si_app'].':'.$row['si_app_id']; $title = $titles[$row['si_app']][$row['si_app_id']]; if (is_null($title)) // entry does not exist { self::delete($row['si_app'],$row['si_app_id']); error_log(__METHOD__.": not existing entry (is_null(title($row[si_app],$row[si_app_id])) deleted from index!"); continue; } elseif($title === false) { $title = lang('Not readable %1 entry of user %2',lang($row['si_app']),$GLOBALS['egw']->common->grab_owner_name($row['si_owner'])); } $matches[$key] = $title; } return $matches; //return iterator_to_array(new egw_index($keyword,$app,$order,$sort,$start,$num_rows),true); } /** * Stores the keywords for an entry in the index * * @param string $app * @param string|int $id * @param string $owner eGW account_id of the owner of the entry, used to create a "private entry of ..." title * @param array $fields * @param array|int|string $cat_ids=null optional cat_id(s) either comma-separated or as array * @param array $ignore_fields=array() keys of fields NOT to index * @return int|boolean false on error, othwerwise number off added keywords */ static function save($app,$id,$owner,array $fields,$cat_ids=null,array $ignore_fields=array()) { if (!$app || !$id) { return false; } // collect the keywords of all fields $keywords = array(); foreach($fields as $field) { if ($ignore_fields && in_array($field, $ignore_fields)) continue; foreach(preg_split(self::SEPARATORS, $field) as $keyword) { if (!in_array($keyword,$keywords) && strlen($keyword) >= self::MIN_KEYWORD_LEN && !is_numeric($keyword)) { $keywords[] = $keyword; } } } // delete evtl. existing current keywords self::delete($app,$id); // add the keywords foreach($keywords as $key => &$keyword) { if (!self::add($app,$id,$keyword,$owner)) // add can reject keywords { unset($keywords[$key]); } } // delete the existing cats self::delete_cats($app,$id); // add the cats if ($cat_ids) { self::add_cats($app,$id,$cat_ids,$owner); } return count($keywords); } /** * Delete the keywords for an entry or an entire application * * @param string $app * @param string|int $id=null */ static function delete($app,$id=null) { if (!$app) { return false; } $where = array('si_app' => $app); if ($id) { $where['si_app_id'] = $id; } return !!self::$db->delete(self::INDEX_TABLE,$where,__LINE__,__FILE__); } /** * Returns the cats of an entry or multiple entries * * @param string $app * @param string|int|array $ids * @return array with cats or single id or id => array with cats pairs */ static function cats($app,$ids) { if (!$app || !$ids) { return array(); } $cats = array(); foreach(self::$db->select(self::INDEX_CAT_TABLE,'cat_id,ce_app_id',array( 'ce_app' => $app, 'ce_app_id' => $ids, ),__LINE__,__FILE__) as $row) { $cats[$row['ce_app_id']][] = $row['cat_id']; } return is_array($ids) ? $cats : $cats[(int)$ids]; } /** * Get the SQL to fetch (eg. as subquery) the id's of a given app matching a keyword * * @param string $keyword * @param string $app * @return string */ static function sql_ids_by_keyword($keyword,$app) { return '(SELECT si_id FROM '.self::INDEX_TABLE.' WHERE si_app='.self::$db->quote($app). ' AND si_keyword = '.self::$db->quote($keyword).') UNION '. '(SELECT ce_id FROM '.self::INDEX_CAT_TABLE.' WHERE si_app='.self::$db->quote($app). ' AND cat_id IN (SELECT cat_id FROM '.self::CAT_TABLE.' WHERE cat_title '.self::$db->capabilities['case_insensitive_like'].' '. self::$db->quote('%'.$keyword.'%').'))'; } /** * Stores one keyword for an entry in the index * * @todo reject keywords which are common words ... * @param string $app * @param string|int $id * @param string $keyword * @param int $owner=null * @return boolean true if keyword added, false if it was rejected in future */ static private function add($app,$id,$keyword,$owner=null) { // todo: reject keywords which are common words, not sure how to do that for all languages // mayby we can come up with some own little statistic analysis: // all keywords more common then N % of the entries get deleted and moved to a separate table ... if (!($si = self::$db->select(self::KEYWORD_TABLE, '*', array('si_keyword' => $keyword))->fetch())) { self::$db->insert(self::KEYWORD_TABLE, array( 'si_keyword' => $keyword, ), false, __LINE__, __FILE__); $si_id = self::$db->get_last_insert_id(self::KEYWORD_TABLE, 'si_id'); } elseif ($si['si_ignore']) { return false; } else { $si_id = $si['si_id']; } self::$db->insert(self::INDEX_TABLE,array( 'si_app' => $app, 'si_app_id' => $id, 'si_id' => $si_id, 'si_owner' => $owner, ),false,__LINE__,__FILE__); return true; } /** * Stores the cat_id(s) for an entry * * @param string $app * @param string|int $id * @param array|int|string $cat_ids=null optional cat_id(s) either comma-separated or as array * @param int $owner=null * @return boolean true on success, false on error */ static private function add_cats($app,$id,$cat_ids,$owner=null) { if (!$app) { return false; } if (!$cat_ids) { return true; // nothing to do } foreach(is_array($cat_ids) ? $cat_ids : explode(',',$cat_ids) as $cat_id) { self::$db->insert(self::INDEX_CAT_TABLE,array( 'cat_id' => $cat_id, 'ce_app' => $app, 'ce_app_id' => $id, 'ce_owner' => $owner, ),false,__LINE__,__FILE__); } return true; } /** * Delete the cat for an entry or an entire application * * @param string $app * @param string|int $id=null */ static private function delete_cats($app,$id=null) { if (!$app) { return false; } $where = array('ce_app' => $app); if ($id) { $where['ce_app_id'] = $id; } return !!self::$db->delete(self::INDEX_CAT_TABLE,$where,__LINE__,__FILE__); } /** * Init our static vars */ static function _init_static() { if (!is_object($GLOBALS['egw_setup'])) { self::$db = $GLOBALS['egw']->db; } else { self::$db = $GLOBALS['egw_setup']->db; } } } egw_index::_init_static();