* @author Bettina Gille * @author Ralf Becker * Copyright (C) 2000, 2001 Joseph Engo, Bettina Gille * Copyright (C) 2002, 2003 Bettina Gille * Reworked 11/2005 by RalfBecker-AT-outdoor-training.de * Reworked 12/2008 by RalfBecker-AT-outdoor-training.de to operate only on a catergory cache, no longer the db direct * @license http://opensource.org/licenses/lgpl-license.php LGPL - GNU Lesser General Public License * @package api * @subpackage categories * @access public * @version $Id$ */ namespace EGroupware\Api; /** * class to manage categories in eGroupWare * * Categories are read now once from the database into a static cache variable (by the static init_cache method). * The egw object fills that cache ones per session, stores it in a private var, from which it restores it for each * request of that session. * * $cat['data'] array: * ------------------ * $cat['data'] array is stored serialized in the database to allow applications to simply add all * sorts of values there (without the hassel of a DB schema change). * Before 14.1 data was PHP serialize(d), if it was an array, now it get json_encode(d), * we use json_php_unserialize(, true) to automatic cope with that (true allows strings too)! * Static methods categories::read($cat_id) and categories::id2name now returns data already as array * and add() or edit() methods automatically json-encode $cat['data'], if it's an array. * return*() methods return $cat['data'] now as array by default! * * @ToDo The cache now contains a backlink from the parent to it's children. Use that link to simplyfy return_all_children * and other functions needing to know if a cat has children. Be aware a user might not see all children, as they can * belong to other users. */ class Categories { /** * Account id this class is instanciated for (self::GLOBAL_ACCOUNT for global cats) * * @var int */ public $account_id; /** * Application this class is instancated for (self::GLOBAL_APPNAME for application global cats) * * @var string */ public $app_name; /** * @var Db */ private $db; /** * Total number of records of return_(sorted_)array (returning only a limited number of rows) * * @var int */ public $total_records; /** * Grants from other users for account_id and app_name (init by return array) * * @var array */ public $grants; /** * Name of the categories table */ const TABLE = 'egw_categories'; /** * @deprecated use Categories::TABLE * @var string */ public $table = self::TABLE; /** * Cache holding all categories, set via init_cache() method * * @var array cat_id => array of data */ protected static $cache; const CACHE_APP = 'phpgwapi'; const CACHE_NAME = 'cat_cache'; /** * Appname for global categories */ const GLOBAL_APPNAME = 'phpgw'; /** * account_id for global categories */ const GLOBAL_ACCOUNT = 0; /** * Owners for global accounts * * Usually the users group memberships and self::GLOBAL_ACCOUNT * * @var array */ private $global_owners = array(self::GLOBAL_ACCOUNT); /** * string to postfix global cats * * @var string */ static public $global_marker; /** * constructor for Categories class * * @param int|string $accountid ='' account id or lid, default to current user * @param string $app_name ='' app name defaults to current app */ function __construct($accountid='',$app_name = '') { if (!$app_name) $app_name = $GLOBALS['egw_info']['flags']['currentapp']; if ($accountid === self::GLOBAL_ACCOUNT || $accountid < 0 && $GLOBALS['egw']->accounts->exists($accountid) == 2) { $this->account_id = (int) $accountid; } else { $this->account_id = (int) get_account_id($accountid); $this->global_owners = $this->account_id ? $GLOBALS['egw']->accounts->memberships($this->account_id, true) : array(); $this->global_owners[] = self::GLOBAL_ACCOUNT; } $this->app_name = $app_name; $this->db = $GLOBALS['egw']->db; if (is_null(self::$cache)) // should not be necessary, as cache is load and restored by egw object { self::init_cache(); } if (is_null(self::$global_marker)) { // as et2 adds options with .text(), it can't be entities, but php knows no string literals with utf-8 } } /** * returns array with id's of all children from $cat_id and $cat_id itself! * * @param int|array $cat_id (array of) integer cat-id to search for * @return array of cat-id's */ function return_all_children($cat_id) { $all_children = array_map('intval', (array)$cat_id); $children = $this->return_array('subs',0,False,'','','',True,$cat_id,-1,'id'); if (is_array($children) && count($children)) { $all_children = array_merge($all_children,$this->return_all_children($children)); } return $all_children; } /** * return an array populated with categories * * @param string $type ='all' 'subs','mains','appandmains','appandsubs','noglobal','noglobalapp', defaults to 'all' * @param int $start =0 see $limit * @param boolean|int $limit if true limited query to maxmatches rows (starting with $start) * @param string $query ='' query-pattern * @param string $sort ='ASC' sort order, defaults to 'ASC' * @param string $order ='' order by, default cat_main, cat_level, cat_name ASC * @param boolean|string $globals includes the global egroupware categories or not, * 'all_no_acl' to return global and all non-private user categories independent of ACL * @param array|int $parent_id =null return only subcats of $parent_id(s) * @param int $lastmod = -1 if > 0 return only cats modified since then * @param string $column ='' if column-name given only that column is returned, not the full array with all cat-data * @param array $filter =null array with column-name (without cat_-prefix) => value pairs (! negates the value) * @return array of cat-arrays or $column values */ function return_array($type='all', $start=0, $limit=true, $query='', $sort='ASC',$order='',$globals=false, $parent_id=null, $lastmod=-1, $column='', $filter=null) { //error_log(__METHOD__."($type,$start,$limit,$query,$sort,$order,globals=$globals,parent=".array2string($parent_id).",$lastmod,$column,filter=".array2string($filter).") account_id=$this->account_id, appname=$this->app_name: ".function_backtrace()); $cats = array(); foreach(self::$cache as $cat) { if ($filter) foreach($filter as $col => $val) { if (!is_array($val) && $val[0] === '!') { // also match against trimmed database entry on name and description fields if (($col == 'name' || $col == 'description') && is_string($cat[$col])) { if ($cat[$col] == substr($val,1) || trim($cat[$col]) == substr($val,1)) continue 2; } else { if ($cat[$col] == substr($val,1)) continue 2; } } elseif (is_array($val)) { // also match against trimmed database entry on name and description fields if (($col == 'name' || $col == 'description') && is_string($cat[$col])) { if (!in_array($cat[$col],$val) && !in_array(trim($cat[$col]),$val)) continue 2; } else { if (!in_array($cat[$col],$val)) continue 2; } } else { // also match against trimmed database entry on name and description fields if (($col == 'name' || $col == 'description') && is_string($cat[$col])) { if ($cat[$col] != $val && trim($cat[$col]) != $val) continue 2; } else { if ($cat[$col] != $val) continue 2; } } } // check if certain parent required if ($parent_id && !in_array($cat['parent'],(array)$parent_id)) continue; // return global categories just if $globals is set if (!$globals && $cat['appname'] == self::GLOBAL_APPNAME) { continue; } // check for read permission if(!$this->check_perms(Acl::READ, $cat, $globals === 'all_no_acl')) { continue; } // check if we have the correct type switch ($type) { case 'subs': if (!$cat['parent']) continue 2; // 2 for switch AND foreach! break; case 'mains': if ($cat['parent']) continue 2; break; case 'appandmains': if ($cat['appname'] != $this->app_name || $cat['parent']) continue 2; break; case 'appandsubs': if ($cat['appname'] != $this->app_name || !$cat['parent']) continue 2; break; case 'noglobal': if ($cat['appname'] == $this->app_name) continue 2; break; case 'noglobalapp': if ($cat['appname'] != $this->app_name || $cat['owner'] == (int)$this->account_id) continue 2; break; } // check name and description for $query if ($query && stristr($cat['name'],$query) === false && stristr($cat['description'],$query) === false) continue; // check if last modified since if ($lastmod > 0 && $cat['last_mod'] <= $lastmod) continue; $cat['data'] = $cat['data'] ? json_php_unserialize($cat['data'], true) : array(); $cats[] = $cat; } if (!($this->total_records = count($cats))) { //error_log(__METHOD__."($type,$start,$limit,$query,$sort,$order,$globals,parent=$parent_id,$lastmod,$column) account_id=$this->account_id, appname=$this->app_name = FALSE"); return array(); } if (!$sort) $sort = 'ASC'; // order the entries if necessary (cache is already ordered in or default order: cat_main, cat_level, cat_name ASC) if ($this->total_records > 1 && !empty($order) && preg_match('/^[a-zA-Z_(), ]+$/',$order) && preg_match('/^(ASC|DESC|asc|desc)$/',$sort)) { if (strstr($order,'cat_data') !== false) $order = 'cat_data'; // sitemgr orders by round(cat_data)! if (substr($order,0,4) == 'cat_') $order = substr($order,4); $sign = strtoupper($sort) == 'DESC' ? -1 : 1; usort($cats, function($a, $b) use ($order, $sign) { if (in_array($order, array('name','description','appname','app_name'))) { return $sign * strcasecmp($a[$order], $b[$order]); } return $sign*(int)$a[$order] - $sign*(int)$b[$order]; }); } // limit the number of returned rows if ($limit) { if (!is_int($limit)) $limit = (int)$GLOBALS['egw_info']['user']['preferences']['common']['maxmatchs']; $cats = array_slice($cats,(int)$start,$limit); } // return only a certain column (why not return is as value?) if ($column) { foreach($cats as $k => $cat) { $cats[$k] = $cat[$column]; } } //error_log(__METHOD__."($type,$start,$limit,$query,$sort,$order,$globals,parent=".array2string($parent_id).",$lastmod,$column,filter=".array2string($filter).",$unserialize_data) account_id=$this->account_id, appname=$this->app_name = ".array2string($cats)); reset($cats); // some old code (eg. sitemgr) relies on the array-pointer! return $cats; } /** * return a sorted array populated with categories (main sorting criteria is hierachy!) * * @param int $start =0 see $limit * @param boolean|int $limit if true limited query to maxmatches rows (starting with $start) * @param string $query ='' query-pattern * @param string $sort ='ASC' sort order, either defaults to 'ASC' * @param string $order ='cat_name' order by * @param boolean|string $globals includes the global egroupware categories or not, * 'all_no_acl' to return global and all non-private user categories independent of ACL * @param array|int $parent_id =0 return only subcats of $parent_id(s) * @param boolean $unserialize_data =true return $cat['data'] as array (not serialized array) * @return array with cats */ function return_sorted_array($start=0,$limit=True,$query='',$sort='ASC',$order='cat_name',$globals=False, $parent_id=0,$unserialize_data=true,$filter=null) { if (!$sort) $sort = 'ASC'; if (!$order) $order = 'cat_name'; //error_log(__METHOD__."($start,$limit,$query,$sort,$order,globals=$globals,parent=$parent_id,$unserialize_data) account_id=$this->account_id, appname=$this->app_name: ".function_backtrace()); $parents = $cats = array(); // Cast parent_id to array, but only if there is one if($parent_id !== false && $parent_id !== null) $parent_id = (array)$parent_id; if (!($cats = $this->return_array('all',0,false,$query,$sort,$order,$globals,$parent_id,-1,'',$filter,$unserialize_data))) { $cats = array(); } foreach($cats as $cat) { $parents[] = $cat['id']; } if($parent_id || !$cats) // Avoid wiping search results { // Go find the children while (count($parents)) { if (!($subs = $this->return_array('all',0,false,$query,$sort,$order,$globals,$parents,-1,'',$filter,$unserialize_data))) { break; } $parents = $children = array(); foreach($subs as $cat) { $parents[] = $cat['id']; $children[$cat['parent']][] = $cat; } // sort the cats into the mains if (count($children)) { $cats2 = $cats; $cats = array(); foreach($cats2 as $cat) { $cats[] = $cat; if (isset($children[$cat['id']])) { foreach($children[$cat['id']] as $child) { $cats[] = $child; } } } } } } $this->total_records = count($cats); // limit the number of returned rows if ($limit) { if (!is_int($limit)) $limit = (int)$GLOBALS['egw_info']['user']['preferences']['common']['maxmatchs']; $cats = array_slice($cats,(int)$start,$limit); } reset($cats); // some old code (eg. sitemgr) relies on the array-pointer! return $cats; } /** * Read a category * * We use a shared cache together with id2name * * Data array get automatically unserialized, if it was serialized! * * @param int $id id of category * @return array|boolean array with cat-data or false if cat not found */ static function read($id) { if (is_null(self::$cache)) self::init_cache(); if (!isset(self::$cache[$id])) return false; $cat = self::$cache[$id]; $cat['data'] = $cat['data'] ? ((($arr=json_php_unserialize($cat['data'], true)) !== false || $cat['data'] === 'b:0;') ? $arr : $cat['data']) : array(); return $cat; } /** * Add a category * * Owner and appname are set from the values used to instanciate the class! * * @param array $values cat-data * @return int new cat-id */ function add($values) { if ((int)$values['parent'] > 0) { $values['level'] = $this->id2name($values['parent'],'level')+1; $values['main'] = $this->id2name($values['parent'],'main'); } else { $values['level'] = 0; } $this->db->insert(self::TABLE,$cat=array( 'cat_parent' => $values['parent'], 'cat_owner' => isset($values['owner']) ? $values['owner'] : $this->account_id, 'cat_access' => isset($values['access']) ? $values['access'] : 'public', 'cat_appname' => $this->app_name, 'cat_name' => $values['name'], 'cat_description' => isset($values['description']) ? $values['description'] : $values['descr'], // support old name different from returned one 'cat_data' => is_array($values['data']) ? json_encode($values['data']) : $values['data'], 'cat_main' => $values['main'], 'cat_level' => $values['level'], 'last_mod' => time(), ),(int)$values['id'] > 0 ? array('cat_id' => $values['id']) : array(),__LINE__,__FILE__); $cat['cat_id'] = $id = (int)$values['id'] > 0 ? (int)$values['id'] : $this->db->get_last_insert_id(self::TABLE,'cat_id'); if (!(int)$values['parent'] && $id != $values['main']) { $this->db->update(self::TABLE,array('cat_main' => $id),array('cat_id' => $id),__LINE__,__FILE__); $cat['cat_main'] = $id; } // update cache accordingly self::invalidate_cache(Db::strip_array_keys($cat, 'cat_')); return $id; } /** * Checks category permissions for a given list of commaseparated category ids * and truncates it by the ones the user does not have the requested permission on * * @param int $needed necessary ACL right: Acl::{READ|EDIT|DELETE} * @param string $cat_list commaseparated list of category ids * @return string truncated commaseparated list of category ids */ function check_list($needed, $cat_list) { if (empty($cat_list)) return $cat_list; if (is_array($cat_list)) { $cat_list = implode(',',$cat_list); //error_log(__METHOD__.__LINE__.' string expected, array found for cat_list. Converted to:'.$cat_list); } $cat_arr = explode(',',$cat_list); if (!empty($cat_arr) && is_array($cat_arr) && count($cat_arr) > 0) { foreach($cat_arr as $id=>$cat_id) { if (!$this->check_perms($needed, $cat_id, false, $needed == Acl::READ)) // allow reading all global cats { unset($cat_arr[$id]); } } $cat_list = implode(',',$cat_arr); } return $cat_list; } /** * Checks if the current user has the necessary ACL rights * * If the access of a category is set to private, one needs a private grant for the application * * @param int $needed necessary ACL right: Acl::{READ|EDIT|DELETE} * @param mixed $category category as array or the category_id * @param boolean $no_acl_check =false if true, grants are NOT checked, gives access to all non-private categories of all users * @param boolean $allow_global_read if true, global cats are allowed (independent of app) for reading * @return boolean true permission granted, false for permission denied, null for category does not exist */ public function check_perms($needed, $category, $no_acl_check=false, $allow_global_read=false) { if (!is_array($category) && !($category = self::read($category))) { return null; } // The user for the global cats has id self::GLOBAL_ACCOUNT, this one has full access to all global cats if ($this->account_id == self::GLOBAL_ACCOUNT && ($category['appname'] == self::GLOBAL_APPNAME || $category['appname'] == $this->app_name && self::is_global($category))) { //echo "

".__METHOD__."($needed,$category[name]) access because class instanciated for GLOBAL ACCOUNT

\n"; return true; } // Read access to global categories if ($needed == Acl::READ && (($is_global=array_intersect(explode(',',$category['owner']),$this->global_owners)) || $no_acl_check && $category['access'] == 'public') && // no_acl_check only means public cats ($category['appname'] == self::GLOBAL_APPNAME || $category['appname'] == $this->app_name || $is_global && $allow_global_read)) { //echo "

".__METHOD__."($needed,$category[name]) access because global via memberships

\n"; return true; } // Full access to own categories if ($category['appname'] == $this->app_name && $category['owner'] == $this->account_id) { return true; } // if $no_acl_check is set, allow access to all public (non-private) categories if ($no_acl_check && $category['access'] == 'public' && $this->account_id != self::GLOBAL_ACCOUNT && $category['appname'] == $this->app_name) { return true; } // Load the application grants if ($category['appname'] == $this->app_name && is_null($this->grants)) { $this->grants = $GLOBALS['egw']->acl->get_grants($this->app_name,true); } // Check for ACL granted access, the self::GLOBAL_ACCOUNT user must not get access by ACL to keep old behaviour $acl_grant = $this->account_id != self::GLOBAL_ACCOUNT && $category['appname'] == $this->app_name; $owner_grant = false; foreach(explode(',',$category['owner']) as $owner) { $owner_grant = $owner_grant || (is_array($this->grants) && !empty($this->grants[$owner]) && ($this->grants[$owner] & $needed) && ($category['access'] === 'public' || ($this->grants[$owner] & Acl::PRIVAT))); } return $acl_grant && $owner_grant; } /** * delete a category * * @param int $cat_id category id * @param boolean $drop_subs =false if true delete sub-cats too * @param boolean $modify_subs =false if true make the subs owned by the parent of $cat_id */ function delete($cat_id, $drop_subs = False, $modify_subs = False) { //error_log(__METHOD__."(".array2string($cat_id).', drop_subs='.array2string($drop_subs).', modify_subs='.array2string($modify_subs).') '.function_backtrace()); if ($modify_subs) { $new_parent = $this->id2name($cat_id,'parent'); foreach ((array) $this->return_sorted_array('',False,'','','',False, $cat_id) as $cat) { if ($cat['level'] == 1) { $this->db->update(self::TABLE,array( 'cat_level' => 0, 'cat_parent' => 0, 'cat_main' => $cat['id'], ),array( 'cat_id' => $cat['id'], 'cat_appname' => $this->app_name, ),__LINE__,__FILE__); $new_main = $cat['id']; } else { $update = array('cat_level' => $cat['level']-1); if ($new_main) $update['cat_main'] = $new_main; if ($cat['parent'] == $cat_id) $update['cat_parent'] = $new_parent; $this->db->update(self::TABLE,$update,array( 'cat_id' => $cat['id'], 'cat_appname' => $this->app_name, ),__LINE__,__FILE__); } } } if ($drop_subs) { $where['cat_id'] = $this->return_all_children($cat_id); } else { $where['cat_id'] = $cat_id; } $where['cat_appname'] = $this->app_name; $GLOBALS['hook_values'] = array( 'cat_id' => $cat_id, 'cat_name' => self::id2name($cat_id), 'drop_subs' => $drop_subs, 'modify_subs' => $modify_subs, 'location' => 'delete_category' ); if($this->is_global($cat_id, true)) // true = application global (otherwise eg. global addressbook categories call all apps) { Hooks::process($GLOBALS['hook_values'],False,True); // called for every app now, not only enabled ones) } else { Hooks::single($GLOBALS['hook_values'], self::id2name($cat_id,'appname')); } $this->db->delete(self::TABLE,$where,__LINE__,__FILE__); // update cache accordingly self::invalidate_cache($modify_subs ? null : $where['cat_id']); } /** * adapt_level_in_subtree of a category * * Owner and appname are set from the values used to instanciate the class! * * @param array $values array with cat-data (it need to be complete, as everything get's written) * @return void */ function adapt_level_in_subtree($values) { foreach ((array) $this->return_sorted_array('',False,'','','',False, $values['id']) as $cat) { if ($cat['parent'] == $values['id']) { $this->db->update(self::TABLE,array( 'cat_level' => $values['level']+1, 'last_mod' => time(), ),array( 'cat_id' => $cat['id'], 'cat_appname' => $this->app_name, ),__LINE__,__FILE__); $cat['level'] = $values['level'] + 1; self::invalidate_cache($cat['id']); $this->adapt_level_in_subtree($cat); } else { continue; } } } /** * check_consistency4update - for edit * * @param array $values array with cat-data (it need to be complete, as everything get's written) * @return mixed string/boolean errorstring if consitency check failed / true if the consistency check did not fail */ function check_consistency4update($values) { // check if we try to move an element down its own subtree, which will fail foreach ($this->return_sorted_array('',False,'','','',False, $values['id']) as $cat) { if ($cat['id'] == $values['parent']) return lang('Cannot set a category as parent, which is part of this categorys subtree!'); } // check if we try to be our own parent if ($values['parent']==$values['id']) return lang('Cannot set this cat as its own parent!'); // deny to be our own parent // check if parent still exists if ((int)$values['parent']>0 && !$this->read($values['parent'])) return lang('Chosen parent category no longer exists'); return true; } /** * edit / update a category * * Owner and appname are set from the values used to instanciate the class! * * @param array $values array with cat-data (it need to be complete, as everything get's written) * @return int cat-id or false if it failed */ function edit($values) { if (isset($values['old_parent']) && (int)$values['old_parent'] != (int)$values['parent']) { $ret = $this->check_consistency4update($values); if ($ret !== true) throw new Exception\WrongUserinput($ret); // everything seems in order -> proceed $values['level'] = ($values['parent'] ? $this->id2name($values['parent'],'level')+1:0); $this->adapt_level_in_subtree($values); return $this->add($values); } else { //echo "old parent not set
"; if ($values['parent'] > 0) { $ret = $this->check_consistency4update($values); if ($ret !== true) throw new Exception\WrongUserinput($ret); // everything seems in order -> proceed $values['main'] = $this->id2name($values['parent'],'main'); $values['level'] = $this->id2name($values['parent'],'level') + 1; } else { //echo "new parent not set
"; $values['main'] = $values['id']; $values['level'] = 0; } // adapt the level info in each child $this->adapt_level_in_subtree($values); } $this->db->update(self::TABLE,$cat=array( 'cat_name' => $values['name'], 'cat_description' => isset($values['description']) ? $values['description'] : $values['descr'], // support old name different from the one read 'cat_data' => is_array($values['data']) ? json_encode($values['data']) : $values['data'], 'cat_parent' => $values['parent'], 'cat_access' => $values['access'], 'cat_owner' => isset($values['owner']) ? $values['owner'] : $this->account_id, 'cat_main' => $values['main'], 'cat_level' => $values['level'], 'last_mod' => time(), ),array( 'cat_id' => $values['id'], 'cat_appname' => $this->app_name, ),__LINE__,__FILE__); $cat['cat_id'] = $values['id']; $cat['cat_appname'] = $this->app_name; // update cache accordingly self::invalidate_cache(Db::strip_array_keys($cat, 'cat_')); return (int)$values['id']; } /** * return category id for a given name * * Cat's with the given name are returned in this order: * - personal cats first * - then application global categories * - global categories * - cat's of other user * * @param string $cat_name cat-name * @param boolean|string $strip =false if true, strips 'X-' ($strip) from category names added by some SyncML clients * @return int cat-id or 0 if not found */ function name2id($cat_name, $strip=false) { static $cache = array(); // a litle bit of caching if (isset($cache[$cat_name])) return $cache[$cat_name]; if ($strip === true) { $strip = 'X-'; } $cats = array($cat_name); if ($strip && strncmp($strip, $cat_name, strlen($strip)) == 0) { $stripped_cat_name = substr($cat_name, strlen($strip)); if (isset($cache[$stripped_cat_name])) { $cache[$cat_name] = $cache[$stripped_cat_name]; return $cache[$stripped_cat_name]; } $cats[] = $stripped_cat_name; } if (!($cats = $this->return_array('all',0,false,'','','',true,null,-1,'',array( 'name' => $cats, 'appname' => array($this->app_name, self::GLOBAL_APPNAME), )))) { return 0; // cat not found, dont cache it, as it might be created in this request } if (count($cats) > 1) { // if more the one cat matches we weight them by: exact name match; own, global, other users cat; appplication cats foreach($cats as $k => $cat) { $cats[$k]['weight'] = 100 * ($cat['name'] == $cat_name) + 10 * ($cat['owner'] == $this->account_id ? 3 : ($cat['owner'] <= self::GLOBAL_ACCOUNT ? 2 : 1)) + ($cat['appname'] != self::GLOBAL_APPNAME); } // sort heighest weight to the top usort($cats, function($a, $b) { return $b['weight'] - $a['weight']; }); } return $cache[$cat['cat_name']] = (int) $cats[0]['id']; } /** * Check if category is global (owner <= 0 || appname == 'phpgw') * * @param int|array $cat * @param boolean $application_global =false true check for application global categories only (appname == 'phpgw') * @return boolean */ static function is_global($cat,$application_global=false) { if (!is_array($cat) && !($cat = self::read($cat))) return null; // cat not found $global_owner = false; foreach(explode(',',$cat['owner']) as $owner) { $global_owner = $global_owner || $owner <= self::GLOBAL_ACCOUNT; } return $global_owner && !$application_global || $cat['appname'] == self::GLOBAL_APPNAME; } /** * return category information for a given id * * We use a shared cache together with read * $item == 'data' is returned as array (not serialized array)! * * @param int $cat_id =0 cat-id * @param string $item ='name' requested information, 'path' = / delimited path of category names (incl. parents) * @return string information or '--' if not found or !$cat_id */ static function id2name($cat_id=0, $item='name') { if(!$cat_id) return '--'; if (!$item) $item = 'parent'; if (is_null(self::$cache)) self::init_cache(); $cat = self::$cache[$cat_id] ?? null; if ($item == 'path') { if ($cat['parent']) { return self::id2name($cat['parent'],'path').' / '.$cat['name']; } $item = 'name'; } if ($item == 'data') { return !empty($cat['data']) ? json_php_unserialize($cat['data'], true) : array(); } elseif ($cat[$item]) { return $cat[$item]; } elseif ($item == 'name') { return '--'; } return null; } /** * check if a category id and/or name exists, if id AND name are given the check is for a category with same name and different id (!) * * @param string $type subs or mains * @param string $cat_name ='' category name * @param int $cat_id =0 category id * @param int $parent =0 category id of parent * @return int/boolean cat_id or false if cat not exists */ function exists($type,$cat_name = '',$cat_id = 0,$parent = 0) { if ($cat_name) { $filter['name'] = $cat_name; if ($cat_id) $filter['id'] = '!'.(int)$cat_id; } elseif ($cat_id) { $filter['id'] = $cat_id; } if (!($cats = $this->return_array($type,0,false,'','','',true,$parent,-1,'id',$filter))) { $ret = false; } else { $ret = $cats[0]; } //error_log(__METHOD__."($type,$cat_name,$cat_id,$parent) = ".$ret); return $ret; } /** * Change the owner of all cats owned by $owner to $to OR deletes them if !$to * * @param int $owner owner or the cats to delete or change * @param int $to =0 new owner or 0 to delete the cats * @param string $app ='' if given only cats matching $app are modifed/deleted */ function change_owner($owner,$to=0,$app='') { $where = array('cat_owner' => $owner); if ($app) $where['cat_appname'] = $app; if ((int)$to) { $this->db->update(self::TABLE,array('cat_owner' => $to),$where,__LINE__,__FILE__); } else { $this->db->delete(self::TABLE,$where,__LINE__,__FILE__); } // update cache accordingly self::invalidate_cache(); } /** * Initialise or restore the categories cache * * We use the default ordering of return_array to avoid doing it again there */ public static function init_cache() { self::$cache = Cache::getInstance(self::CACHE_APP, self::CACHE_NAME); if (is_null(self::$cache)) { // check if we are already updated to global owner == 0, if not do it now if (!$GLOBALS['egw']->db->select(self::TABLE,'COUNT(*)',array('cat_owner'=>'0'),__LINE__,__FILE__)->fetchColumn()) { $GLOBALS['egw']->db->update(self::TABLE,array('cat_owner'=>'0'),"(cat_owner='-1' OR cat_appname='phpgw')",__LINE__,__FILE__); $GLOBALS['egw']->db->insert(self::TABLE,array( 'cat_main' => 0, 'cat_parent' => 0, 'cat_level' => 0, 'cat_owner' => 0, 'cat_appname' => '*update*', 'cat_name' => 'global=0', 'cat_description' => 'global=0', 'last_mod' => time(), ),false,__LINE__,__FILE__); } self::$cache = array(); // read all cats (cant use $this->db!) foreach($GLOBALS['egw']->db->select(self::TABLE,'*',false,__LINE__,__FILE__, false,'ORDER BY cat_main, cat_level, cat_name ASC') as $cat) { $cat = Db::strip_array_keys($cat,'cat_'); if ($cat['appname'] == '*update*') continue; // --> ignore update marker $cat['app_name'] = $cat['appname']; // backlink children to their parent if ($cat['parent']) { self::$cache[$cat['parent']]['children'][] = $cat['id']; } if (isset(self::$cache[$cat['id']])) { $cat['children'] = self::$cache[$cat['id']]['children']; unset(self::$cache[$cat['id']]); // otherwise the order gets messed up! } self::$cache[$cat['id']] = $cat; } Cache::setInstance(self::CACHE_APP, self::CACHE_NAME, self::$cache); } //error_log(__METHOD__."() cache initialised: ".function_backtrace()); } /** * Invalidate the cache * * Currently we dont care for $cat_id, as changing cats happens very infrequently and * also changes child categories (!) * * @param int|array $cat concerned id(s) or array with cat-data or null for all cats */ public static function invalidate_cache($cat=null) { //error_log(__METHOD__."(".array2string($cat).') '.function_backtrace()); // allways invalidate instance-global cache, as updating our own cache is not perfect and does not help other sessions Cache::unsetInstance(self::CACHE_APP, self::CACHE_NAME); // if cat given update our own cache, to work around failed sitemgr install via setup (cant read just added categories) if ($cat) { if (!is_array($cat) || isset($cat[0])) { foreach((array)$cat as $cat_id) { unset(self::$cache[$cat_id]); } } elseif($cat['id']) { self::$cache[$cat['id']] = $cat; } } else { self::init_cache(); } } /** * Have the framework include the CSS file * * We calculate the cachebuster timestamp from the last modified category in * the application. * * @param string appname * * @return Returns the URL, but you do not need to do anything with it. */ public static function css($appname) { $cats = new Categories('',$appname); $last_mod = $cats->return_array('all',0,1,'','DESC','last_mod', $appname == self::GLOBAL_APPNAME); $time = count($last_mod) ? $last_mod[0]['last_mod'] : time(); $path = '/api/categories.php?app='.$appname.'&'.$time; Framework::includeCSS($path); return $path; } /** * Get the color of a category * * For multiple cats, the first with a color is used * * @param int|string $_cats multiple comma-separated cat_id's * @return string */ static function cats2color($_cats) { static $cat2color = array(); // ACL check $cats = $GLOBALS['egw']->categories->check_list(Acl::READ, $_cats); if (!$cats) return null; if (isset($cat2color[$cats])) { return $cat2color[$cats]; } foreach(explode(',', $cats) as $cat) { if (isset($cat2color[$cat])) { return $cat2color[$cat]; } $data = self::id2name($cat, 'data'); if (is_array($data) && ($color = $data['color'])) { return $cat2color[$cats] = $cat2color[$cat] = $color; } } return null; } /** * Delete categories belonging to a given account, when account got deleted * * @param int $account_id * @param int $new_owner =null for users data can be transfered to new owner * @return int number of deleted or modified categories */ public static function delete_account($account_id, $new_owner=null) { if (is_null(self::$cache)) self::init_cache(); $deleted = 0; $cat = null; foreach(self::$cache as $cat_id => $data) { if ($data['owner'] && ($owners = explode(',', $data['owner'])) && ($owner_key = array_search($account_id, $owners)) !== false) { // delete category if account_id is single owner and no new owner or owner is a group if (count($owners) == 1 && (!$new_owner || $account_id < 0)) { if (!isset($cat)) { $cat = new Categories($new_owner, $data['appname']); } $cat->delete($cat_id, false, true); } else { unset($owners[$owner_key]); if ($new_owner && $account_id > 0) $owners[] = $new_owner; $data['owner'] = implode(',', $owners); // app_name have to match cat to update! if (!isset($cat) || $cat->app_name != $data['appname']) { $cat = new Categories($new_owner, $data['appname']); } $cat->add($data); } ++$deleted; } } return $deleted; } /** * Delete categories with not (longer) existing owners * * @return int number of deleted categories */ public static function delete_orphans() { if (is_null(self::$cache)) self::init_cache(); $checked = array(); $deleted = 0; foreach(self::$cache as $data) { foreach(explode(',', $data['owner']) as $owner) { if ($owner && !in_array($owner, $checked)) { if (!$GLOBALS['egw']->accounts->exists($owner)) { $deleted += self::delete_account($owner); } $checked[] = $owner; } } } return $deleted; } }