* @copyright 2001-2014 by RalfBecker@outdoor-training.de * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package api * @subpackage link * @version $Id$ */ /** * Generalized linking between entries of EGroupware apps * * Please note: this class can NOT and does not need to be initialised, all methods are static * * To participate in the linking an applications has to implement the following hooks: * * /** * * Hook called by link-class to include app in the appregistry of the linkage * * * * @param array|string $location location and other parameters (not used) * * @return array with method-names * *% * function search_link($location) * { * return array( * 'query' => 'app.class.link_query', // method to search app for a pattern: array link_query(string $pattern, array $options) * 'title' => 'app.class.link_title', // method to return title of an entry of app: string/false/null link_title(int/string $id) * 'titles' => 'app.class.link_titles', // method to return multiple titles: array link_title(array $ids) * 'view' => array( // get parameters to view an entry of app * 'menuaction' => 'app.class.method', * ), * 'types' => array( // Optional list of sub-types to filter (eg organisations), app to handle different queries * 'type_key' => array( * 'name' => 'Human Reference', * 'icon' => 'app/icon' // Optional icon to use for that sub-type * ) * ), * 'view_id' => 'app_id', // name of get parameter of the id * 'view_popup' => '400x300', // size of popup (XxY), if view is in popup * 'view_list' => 'app.class.method' // deprecated use 'list' instead * 'list' => array( // Method to be called to display a list of links, method should check $_GET['search'] to filter * 'menuaction' => 'app.class.method', * ), * 'list_popup' => '400x300' * 'add' => array( // get parameter to add an empty entry to app * 'menuaction' => 'app.class.method', * ), * 'add_app' => 'link_app', // name of get parameter to add links to other app * 'add_id' => 'link_id', // --------------------- " ------------------- id * 'add_popup' => '400x300', // size of popup (XxY), if add is in popup * 'notify' => 'app.class.method', // method to be called if an other applications liks or unlinks with app: notify(array $data) * 'file_access' => 'app.class.method', // method to be called to check file access rights of a given user, see links_stream_wrapper class * // boolean file_access(string $id,int $check,string $rel_path=null,int $user=null) * 'file_access_user' => false, // true if file_access method supports 4th parameter $user, if app is NOT supporting it * // egw_link::file_access() returns false for $user != current user! * 'find_extra' => array('name_preg' => '/^(?!.picture.jpg)$/') // extra options to egw_vfs::find, to eg. remove some files from the list of attachments * 'edit' => array( * 'menuaction' => 'app.class.method', * ), * 'edit_id' => 'app_id', * 'edit_popup' => '400x300', * 'name' => 'Some name', // Name to use instead of app-name * 'icon' => 'app/icon', // Optional icon to use instead of app-icon * 'entry' => 'Contact', // Optional name for single entry of app, eg. "contact" used instead of appname * 'entries' => 'Contacts', // Optional name for multiple entries of app, eg. "contacts" used instead of appname * 'mime' => array( // Optional register mime-types application can open * 'text/something' => array( * 'mime_url' => $attr, // either mime_url or mime_data is required for server-side processing! * 'mime_data' => $attr, // md5-hash returned from egw_link::set_data() to retrive content (only server-side) * 'menuaction' => 'app.class.method', // method to call * 'mime_popup' => '400x300', // optional size of popup * 'mime_target' => '_self', // optional target, default _blank * // other get-parameters to set in url * ), * // further mime types supported ... * ), * 'fetch' => 'app.class.method', // method to return entry data for a given id. the method called should support id, and expected mime-type * // basically you should return something like array(id, title, mimetype, body, linked-files) * 'additional' => array( // allow one app to register sub-types, * 'app-sub' => array( // different from 'types' approach above * // every value defined above * ) * ) * } * All entries are optional, thought you only get conected functionality, if you implement them ... * * The BO-layer implementes some extra features on top of the so-layer: * 1) It handles links to not already existing entries. This is used by the eTemplate link-widget, which allows to * setup links even for new / not already existing entries, before they get saved. * In that case you have to set the first id to 0 for the link-static function and pass the array returned in that id * (not the return-value) after saveing your new entry again to the link static function. * 2) Attaching files: they are saved in the vfs and not the link-table (!). * Attached files are stored under $vfs_basedir='/infolog' in the vfs! * 3) It manages the link-registry, in which apps can register themselfs by implementing some hooks * 4) It notifies apps, who registered for that service, about changes in the links their entries * * Modification times in links (and deleted timestamp) are always in server-time! * (We dont convert them here, as most apps ignore them anyway) */ class egw_link extends solink { /** * appname used for returned attached files (!= 'filemanager'!) */ const VFS_APPNAME = 'file'; // pseudo-appname for own file-attachments in vfs, this is NOT the vfs-app /** * Appname used of files stored via egw_link::set_data() */ const DATA_APPNAME = 'egw-data'; /** * appname used for linking existing files to VFS */ const VFS_LINK = 'link'; /** * Baseurl for the attachments in the vfs */ const VFS_BASEURL = 'vfs://default/apps'; /** * Turns on debug-messages */ const DEBUG = false; /** * other apps can participate in the linking by implementing a 'search_link' hook, which * has to return an array in the format of an app_register entry below * * @var array */ static $app_register = array( 'home-accounts' => array( // user need run-rights for home 'app' => 'home', 'name' => 'Accounts', 'icon' => 'addressbook/accounts', 'query' => 'accounts::link_query', 'title' => 'common::grab_owner_name', 'view' => array('menuaction'=>'addressbook.addressbook_ui.view'), 'view_id' => 'account_id' ), 'home' => array( // handling of text or pdf files by browser in a popup window 'mime' => array( 'application/pdf' => array( 'mime_popup' => '640x480', 'mime_target' => '_blank', ), '/^text\\/(plain|html|diff)/' => array( // text/(mimetypes which can be opened as recognised popups) 'mime_popup' => '640x480', 'mime_target' => '_blank', ), '/^image\\//' => array( // image 'mime_popup' => '640x480', 'mime_target' => '_blank', ), ), ), ); /** * Caches link titles for a better performance * * @var array */ private static $title_cache = array(); /** * Cache file access permissions * * @var array */ private static $file_access_cache = array(); /** * Private constructor to forbid instanciated use * */ private function __construct() { } /** * initialize our static vars */ static function init_static( ) { // FireFox 36 can not display pdf with it's internal viewer in an iframe used by mobile theme/template for popups // same is true for all mobile devices if (html::$user_agent == 'firefox' && $GLOBALS['egw_info']['user']['preferences']['common']['theme'] == 'mobile' || html::$ua_mobile) { unset(self::$app_register['home']['mime']['application/pdf']); } // other apps can participate in the linking by implementing a search_link hook, which // has to return an array in the format of an app_register entry // for performance reasons, we do it only once / cache it in the session if (!($search_link_hooks = $GLOBALS['egw']->session->appsession('search_link_hooks','phpgwapi'))) { $search_link_hooks = $GLOBALS['egw']->hooks->process('search_link',array(), (bool)$GLOBALS['egw_info']['flags']['async-service']); $GLOBALS['egw']->session->appsession('search_link_hooks','phpgwapi',$search_link_hooks); } if (is_array($search_link_hooks)) { foreach($search_link_hooks as $app => $data) { // allow apps to register additional types if (isset($data['additional'])) { foreach($data['additional'] as $name => $values) { $values['app'] = $app; // store name of registring app, to be able to check access self::$app_register[$name] = $values; } unset($data['additional']); } // support deprecated view_list attribute instead of new index attribute if (isset($data['view_list']) && !isset($data['list'])) { $data['list'] = array('menuaction' => $data['view_list']); } elseif(isset($data['list']) && !isset($data['view_list'])) { $data['view_list'] = $data['list']['menuaction']; } if (is_array($data)) { self::$app_register[$app] = $data; } } } // disable ability to link to accounts for non-admins, if account-selection is disabled if ($GLOBALS['egw_info']['user']['preferences']['common']['account_selection'] == 'none' && !isset($GLOBALS['egw_info']['user']['apps']['admin'])) { unset(self::$app_register['home-accounts']); } if (!(self::$title_cache = $GLOBALS['egw']->session->appsession('link_title_cache','phpgwapi'))) { self::$title_cache = array(); } if (!(self::$file_access_cache = $GLOBALS['egw']->session->appsession('link_file_access_cache','phpgwapi'))) { self::$file_access_cache = array(); } // register self::save_session_cache to run on shutdown egw::on_shutdown(array(__CLASS__, 'save_session_cache')); //error_log(__METHOD__.'() items in title-cache: '.count(self::$title_cache).' file-access-cache: '.count(self::$file_access_cache)); } /** * Get clientside relevant attributes from app registry in json format * * Only transfering relevant information cuts approx. half of the size. * Also only transfering information relevant to apps user has access too. * Important eg. for mime-registry, to not use calendar for opening iCal files, if user has no calendar! * As app can store additonal types, we have to check the registring app $data['app'] too! * * @return string json encoded object with app: object pairs with attributes "(view|add|edit)(|_id|_popup)" */ public static function json_registry() { $to_json = array(); foreach(self::$app_register as $app => $data) { if (isset($GLOBALS['egw_info']['user']['apps'][$app]) || isset($data['app']) && isset($GLOBALS['egw_info']['user']['apps'][$data['app']])) { $to_json[$app] = array_intersect_key($data, array_flip(array( 'view','view_id','view_popup', 'add','add_app','add_id','add_popup', 'edit','edit_id','edit_popup', 'list','list_popup', 'name','icon','query', 'mime','entry','entries', ))); } } return json_encode($to_json); } /** * Called by egw::shutdown to store the title-cache in session and run notifications * * Would probably better called shutdown as well. */ static function save_session_cache() { if (isset($GLOBALS['egw']->session)) // eg. cron-jobs use it too, without any session { //error_log(__METHOD__.'() items in title-cache: '.count(self::$title_cache).' file-access-cache: '.count(self::$file_access_cache)); $GLOBALS['egw']->session->appsession('link_title_cache','phpgwapi',self::$title_cache); $GLOBALS['egw']->session->appsession('link_file_access_cache','phpgwapi',self::$file_access_cache); } } /** * creats a link between $app1,$id1 and $app2,$id2 - $id1 does NOT need to exist yet * * Does NOT check if link already exists. * File-attachments return a negative link-id !!! * * @param string $app1 app of $id1 * @param string|array &$id1 id of item to linkto or 0 if item not yet created or array with links * of not created item or $file-array if $app1 == self::VFS_APPNAME (see below). * If $id==0 it will be set on return to an array with the links for the new item. * @param string|array $app2 app of 2.linkend or array with links ($id2 not used) * @param string $id2 ='' id of 2. item of $file-array if $app2 == self::VFS_APPNAME or self::DATA_APPNAME * $file array with informations about the file in format of the etemplate file-type * $file['name'] name of the file (no directory) * $file['type'] mime-type of the file * $file['tmp_name'] name of the uploaded file (incl. directory) for self::VFS_APPNAME or * $file['egw_data'] id of egw_link::set_data() call for self::DATA_APPNAME * @param string $remark ='' Remark to be saved with the link (defaults to '') * @param int $owner =0 Owner of the link (defaults to user) * @param int $lastmod =0 timestamp of last modification (defaults to now=time()) * @param int $no_notify =0 &1 dont notify $app1, &2 dont notify $app2 * @return int/boolean False (for db or param-error) or on success link_id (Please not the return-value of $id1) */ static function link( $app1,&$id1,$app2,$id2='',$remark='',$owner=0,$lastmod=0,$no_notify=0 ) { if (self::DEBUG) { echo "

egw_link::link('$app1',$id1,'".print_r($app2,true)."',".print_r($id2,true).",'$remark',$owner,$lastmod)

\n"; } if (!$app1 || !$app2 || $app1 == $app2 && $id1 == $id2) { return False; } if (is_array($app2) && !$id2) { reset($app2); $link_id = True; while ($link_id && list(,$link) = each($app2)) { if (!is_array($link)) // check for unlink-marker { //echo "link='$link' is no array
\n"; continue; } if (is_array($id1) || !$id1) // create link only in $id1 array { self::link($app1, $id1, $link['app'], $link['id'], $link['remark'],$link['owner'],$link['lastmod']); continue; } switch ($link['app']) { case self::DATA_APPNAME: if (!($link['id']['tmp_name'] = self::get_data($link['id']['egw_data'], true))) { $link_id = false; break; } // fall through case self::VFS_APPNAME: $link_id = self::attach_file($app1,$id1,$link['id'],$link['remark']); break; case self::VFS_LINK: $link_id = self::link_file($app1,$id1, $link['id'],$link['remark']); break; default: $link_id = solink::link($app1,$id1,$link['app'],$link['id'], $link['remark'],$link['owner'],$link['lastmod']); // notify both sides if (!($no_notify&2)) self::notify('link',$link['app'],$link['id'],$app1,$id1,$link_id); if (!($no_notify&1)) self::notify('link',$app1,$id1,$link['app'],$link['id'],$link_id); break; } } return $link_id; } if (is_array($id1) || !$id1) // create link only in $id1 array { if (!is_array($id1)) { $id1 = array( ); } $link_id = self::temp_link_id($app2,$id2); $id1[$link_id] = array( 'app' => $app2, 'id' => $id2, 'remark' => $remark, 'owner' => $owner, 'link_id' => $link_id, 'lastmod' => time() ); if (self::DEBUG) { _debug_array($id1); } return $link_id; } if ($app1 == self::VFS_LINK) { return self::link_file($app2,$id2,$id1,$remark); } elseif ($app2 == self::VFS_LINK) { return self::link_file($app1,$id1,$id2,$remark); } if ($app1 == self::VFS_APPNAME) { return self::attach_file($app2,$id2,$id1,$remark); } elseif ($app2 == self::VFS_APPNAME) { return self::attach_file($app1,$id1,$id2,$remark); } $link_id = solink::link($app1,$id1,$app2,$id2,$remark,$owner); if (!($no_notify&2)) self::notify('link',$app2,$id2,$app1,$id1,$link_id); if (!($no_notify&1)) self::notify('link',$app1,$id1,$app2,$id2,$link_id); return $link_id; } /** * generate temporary link_id used as array-key * * @param string $app app-name * @param mixed $id * @return string */ static function temp_link_id($app,$id) { return $app.':'.(!in_array($app, array(self::VFS_APPNAME,self::VFS_LINK, self::DATA_APPNAME)) ? $id : $id['name']); } /** * returns array of links to $app,$id (reimplemented to deal with not yet created items) * * @param string $app appname * @param string|array $id id(s) in $app * @param string $only_app ='' if set return only links from $only_app (eg. only addressbook-entries) or NOT from if $only_app[0]=='!' * @param string $order ='link_lastmod DESC' defaults to newest links first * @param boolean $cache_titles =false should all titles be queryed and cached (allows to query each link app only once!) * This option also removes links not viewable by current user from the result! * @param boolean $deleted =false Include links that have been flagged as deleted, waiting for purge of linked record. * @param int $limit =null number of entries to return, only affects links, attachments are allways reported! * @return array id => links pairs if $id is an array or just the links (only_app: ids) or empty array if no matching links found */ static function get_links($app, $id, $only_app='', $order='link_lastmod DESC',$cache_titles=false, $deleted=false, $limit=null) { if (self::DEBUG) echo "

egw_link::get_links(app='$app',id='$id',only_app='$only_app',order='$order',deleted='$deleted')

\n"; if (is_array($id) || !$id) { $ids = array(); if (is_array($id)) { if (($not_only = $only_app[0] == '!')) { $only_app = substr(1,$only_app); } foreach (array_reverse($id) as $link) { if (is_array($link) // check for unlink-marker && !($only_app && $not_only == ($link['app'] == $only_app))) { $ids[$link['link_id']] = $only_app ? $link['id'] : $link; } } } return $ids; } $ids = solink::get_links($app, $id, $only_app, $order, $deleted, $limit); if (empty($only_app) || $only_app == self::VFS_APPNAME || ($only_app[0] == '!' && $only_app != '!'.self::VFS_APPNAME)) { if (($vfs_ids = self::list_attached($app,$id))) { $ids += $vfs_ids; } } //echo "ids=
"; print_r($ids); echo "
\n"; if ($cache_titles) { // agregate links by app $app_ids = array(); foreach($ids as $link) { $app_ids[$link['app']][] = $link['id']; } foreach($app_ids as $appname => $a_ids) { self::titles($appname,array_unique($a_ids)); } // remove links, current user has no access, from result foreach($ids as $key => $link) { if (!self::title($link['app'],$link['id'])) { unset($ids[$key]); } } reset($ids); } return $ids; } /** * Query the links of multiple entries of one application * * @ToDo also query the attachments in a single query, eg. via a directory listing of /apps/$app * @param string $app * @param array $ids * @param boolean $cache_titles =true should all titles be queryed and cached (allows to query each link app only once!) * @param string $only_app if set return only links from $only_app (eg. only addressbook-entries) or NOT from if $only_app[0]=='!' * @param string $order ='link_lastmod DESC' defaults to newest links first * @param boolean $deleted =false Include links that have been flagged as deleted, waiting for purge of linked record. * @return array of $id => array($links) pairs */ static function get_links_multiple($app,array $ids,$cache_titles=true,$only_app='',$order='link_lastmod DESC', $deleted=false ) { if (self::DEBUG) echo "

".__METHOD__."('$app',".print_r($ids,true).",$cache_titles,'$only_app','$order')

\n"; if (!$ids) { return array(); // no ids are linked to nothing } $links = solink::get_links($app,$ids,$only_app,$order,$deleted); if (empty($only_app) || $only_app == self::VFS_APPNAME || ($only_app[0] == '!' && $only_app != '!'.self::VFS_APPNAME)) { // todo do that in a single query, eg. directory listing, too foreach($ids as $id) { if (!isset($links[$id])) { $links[$id] = array(); } if (($vfs_ids = self::list_attached($app,$id))) { $links[$id] += $vfs_ids; } } } if ($cache_titles) { // agregate links by app $app_ids = array(); foreach($links as &$targets) { foreach($targets as $link) { if (is_array($link)) $app_ids[$link['app']][] = $link['id']; } } foreach($app_ids as $app => $a_ids) { self::titles($app,array_unique($a_ids)); } } return $links; } /** * Read one link specified by it's link_id or by the two end-points * * If $id is an array (links not yet created) only link_ids are allowed. * * @param int|string $app_link_id > 0 link_id of link or app-name of link * @param string|array $id ='' id if $app_link_id is an appname or array with links, if 1. entry not yet created * @param string $app2 ='' second app * @param string $id2 ='' id in $app2 * @return array with link-data or False */ static function get_link($app_link_id,$id='',$app2='',$id2='') { if (self::DEBUG) { echo '

'.__METHOD__."($app_link_id,$id,$app2,$id2)

\n"; echo function_backtrace(); } if (is_array($id)) { if (strpos($app_link_id,':') === false) $app_link_id = self::temp_link_id($app2,$id2); // create link_id of temporary link, if not given if (isset($id[$app_link_id]) && is_array($id[$app_link_id])) // check for unlinked-marker { return $id[$app_link_id]; } return False; } if ((int)$app_link_id < 0 || $app_link_id == self::VFS_APPNAME || $app2 == self::VFS_APPNAME) { if ((int)$app_link_id < 0) // vfs link_id ? { return self::fileinfo2link(-$app_link_id); } if ($app_link_id == self::VFS_APPNAME) { return self::info_attached($app2,$id2,$id); } return self::info_attached($app_link_id,$id,$id2); } return solink::get_link($app_link_id,$id,$app2,$id2); } /** * Remove link with $link_id or all links matching given $app,$id * * Note: if $link_id != '' and $id is an array: unlink removes links from that array only * unlink has to be called with &$id to see the result (depricated) or unlink2 has to be used !!! * * @param $link_id link-id to remove if > 0 * @param string $app ='' appname of first endpoint * @param string|array $id ='' id in $app or array with links, if 1. entry not yet created * @param int $owner =0 account_id to delete all links of a given owner, or 0 * @param string $app2 ='' app of second endpoint * @param string $id2 ='' id in $app2 * @param boolean $hold_for_purge Don't really delete the link, just mark it as deleted and wait for final delete * @return the number of links deleted */ static function unlink($link_id,$app='',$id='',$owner=0,$app2='',$id2='',$hold_for_purge=false) { return self::unlink2($link_id,$app,$id,$owner,$app2,$id2,$hold_for_purge); } /** * Remove link with $link_id or all links matching given $app,$id * * @param $link_id link-id to remove if > 0 * @param string $app ='' appname of first endpoint * @param string|array &$id='' id in $app or array with links, if 1. entry not yet created * @param int $owner =0 account_id to delete all links of a given owner, or 0 * @param string $app2 ='' app of second endpoint, or !file (other !app are not yet supported!) * @param string $id2 ='' id in $app2 * @param boolean $hold_for_purge Don't really delete the link, just mark it as deleted and wait for final delete * @return the number of links deleted */ static function unlink2($link_id,$app,&$id,$owner=0,$app2='',$id2='',$hold_for_purge=false) { if (self::DEBUG) { echo "

egw_link::unlink('$link_id','$app',".array2string($id).",'$owner','$app2','$id2', $hold_for_purge)

\n"; } if ($link_id < 0) // vfs-link? { return self::delete_attached(-$link_id); } elseif ($app == self::VFS_APPNAME) { return self::delete_attached($app2,$id2,$id); } elseif ($app2 == self::VFS_APPNAME) { return self::delete_attached($app,$id,$id2); } if (!is_array($id)) { if (!$link_id && !$app2 && !$id2 && $app2 != '!'.self::VFS_APPNAME) { self::delete_attached($app,$id); // deleting all attachments self::delete_cache($app,$id); } // Log in history if($link_id && (!$app || !$app2)) { // Need to load it first $link = self::get_link($link_id); $app = $link['link_app1']; $id = $link['link_id1']; $app2 = $link['link_app2']; $id2 = $link['link_id2']; } if ($app && $app2) { historylog::static_add($app,$id,$GLOBALS['egw_info']['user']['account_id'],'~link~','',$app2.':'.$id2); historylog::static_add($app2,$id2,$GLOBALS['egw_info']['user']['account_id'],'~link~','',$app.':'.$id); } $deleted =& solink::unlink($link_id,$app,$id,$owner,$app2 != '!'.self::VFS_APPNAME ? $app2 : '',$id2,$hold_for_purge); // only notify on real links, not the one cached for writing or fileattachments self::notify_unlink($deleted); return count($deleted); } if (!$link_id) $link_id = self::temp_link_id($app2,$id2); // create link_id of temporary link, if not given if (isset($id[$link_id])) { $id[$link_id] = False; // set the unlink marker if (self::DEBUG) { _debug_array($id); } return True; } return False; } /** * get list/array of link-aware apps the user has rights to use * * @param string $must_support capability the apps need to support, eg. 'add', default ''=list all apps * @return array with app => title pairs */ static function app_list($must_support='') { $apps = array(); foreach(self::$app_register as $type => $reg) { if ($must_support && !isset($reg[$must_support])) continue; list($app) = explode('-', $type); if ($GLOBALS['egw_info']['user']['apps'][$app]) { $apps[$type] = lang(self::get_registry($type, 'name')); } } return $apps; } /** * Searches for a $pattern in the entries of $app * * @param string $app app to search * @param string $pattern pattern to search * @param string $type Search only a certain sub-type of records (optional) * @return array with $id => $title pairs of matching entries of app */ static function query($app,$pattern, &$options = array()) { if ($app == '' || !is_array($reg = self::$app_register[$app]) || !isset($reg['query'])) { return array(); } $method = $reg['query']; if (self::DEBUG) { echo "

egw_link::query('$app','$pattern') => '$method'

\n"; echo "Options: "; _debug_array($options); } // See etemplate's nextmatch widget, following was copied from there // allow static callbacks if(strpos($method,'::') !== false) { // workaround for php < 5.3: do NOT call it static, but allow application code to specify static callbacks if (version_compare(PHP_VERSION,'5.3','<')) list($class,$method) = explode('::',$method); } else { list($app,$class,$method) = explode('.',$method); } if ($class) { if (!$app && !is_object($GLOBALS[$class])) { $GLOBALS[$class] = new $class(); } if (is_object($GLOBALS[$class])) // use existing instance (put there by a previous CreateObject) { $obj = $GLOBALS[$class]; } else { $obj = CreateObject($app.'.'.$class); } } if(is_callable($method)) // php5.3+ call { $result = call_user_func($method,$pattern,$options); } elseif(is_object($obj) && method_exists($obj,$method)) { $result = $obj->$method($pattern,$options); } else { // if there is no object or no method, give a more explaining error message throw new egw_exception_assertion_failed("Object has no method '$reg[query]'!"); } if (!isset($options['total'])) { $options['total'] = count($result); } if (is_array($result) && (isset($options['start']) || (isset($options['num_rows']) && count($result) > $options['num_rows']))) { $result = array_slice($result, $options['start'], (isset($options['num_rows']) ? $options['num_rows'] : count($result)), true); } return $result; } /** * returns the title (short description) of entry $id and $app * * @param string $app appname * @param string $id id in $app * @param array $link =null link-data for file-attachments * @return string/boolean string with title, null if $id does not exist in $app or false if no perms to view it */ static function title($app,$id,$link=null) { if (!$id) return ''; $title =& self::get_cache($app,$id); if (isset($title) && !empty($title) && !is_array($id)) { if (self::DEBUG) echo '

'.__METHOD__."('$app','$id')='$title' (from cache)

\n"; return $title; } if ($app == self::VFS_APPNAME) { if (is_array($id) && $link) { $link = $id; $title = egw_vfs::decodePath($link['name']); } else { $title = $id; } /* disabling mime-type and size in link-title of attachments, as it clutters the UI and users dont need it most of the time. These details can allways be views in filemanager. if (is_array($link)) { $title .= ': '.$link['type'] . ' '.egw_vfs::hsize($link['size']); }*/ if (self::DEBUG) echo '

'.__METHOD__."('$app','$id')='$title' (file)

\n"; return $title; } if ($app == '' || !is_array($reg = self::$app_register[$app]) || !isset($reg['title'])) { if (self::DEBUG) echo "

".__METHOD__."('$app','$id') something is wrong!!!

\n"; return false; //array(); // not sure why it should return an array on failure, as the description states boolean/string } $method = $reg['title']; if (true) $title = ExecMethod($method,$id); if ($id && is_null($title)) // $app,$id has been deleted ==> unlink all links to it { static $unlinking = array(); // check if we are already trying to unlink the entry, to avoid an infinit recursion if (!isset($unlinking[$app]) || !isset($unlinking[$app][$id])) { $unlinking[$app][$id] = true; self::unlink(0,$app,$id); unset($unlinking[$app][$id]); } if (self::DEBUG) echo '

'.__METHOD__."('$app','$id') unlinked, as $method returned null

\n"; return False; } if (self::DEBUG) echo '

'.__METHOD__."('$app','$id')='$title' (from $method)

\n"; return $title; } /** * Maximum number of titles to query from an application at once (to NOT trash mysql) */ const MAX_TITLES_QUERY = 100; /** * Query the titles off multiple id's of one app * * Apps can implement that hook, if they have a quicker (eg. less DB queries) method to query the title of multiple entries. * If it's not implemented, we call the regular title method multiple times. * * @param string $app * @param array $ids */ static function titles($app,array $ids) { if (self::DEBUG) { echo "

".__METHOD__."($app,".implode(',',$ids).")

\n"; } $titles = $ids_to_query = array(); foreach($ids as $id) { $title =& self::get_cache($app,$id); if (!isset($title)) { if (isset(self::$app_register[$app]['titles'])) { $ids_to_query[] = $id; // titles method --> collect links to query at once } else { $title = self::title($app,$id); // no titles method --> fallback to query each link separate } } $titles[$id] = $title; } if ($ids_to_query) { for ($n = 0; ($ids = array_slice($ids_to_query,$n*self::MAX_TITLES_QUERY,self::MAX_TITLES_QUERY)); ++$n) { foreach(ExecMethod(self::$app_register[$app]['titles'],$ids) as $id => $t) { $title =& self::get_cache($app,$id); $titles[$id] = $title = $t; } } } return $titles; } /** * Add new entry to $app, evtl. already linked to $to_app, $to_id * * @param string $app appname of entry to create * @param string $to_app ='' appname to link the new entry to * @param string $to_id =''id in $to_app * @return array/boolean with name-value pairs for link to add-methode of $app or false if add not supported */ static function add($app,$to_app='',$to_id='') { //echo "

egw_link::add('$app','$to_app','$to_id') app_register[$app] ="; _debug_array($app_register[$app]); if ($app == '' || !is_array($reg = self::$app_register[$app]) || !isset($reg['add'])) { return false; } $params = $reg['add']; if ($reg['add_app'] && $to_app && $reg['add_id'] && $to_id) { $params[$reg['add_app']] = $to_app; $params[$reg['add_id']] = $to_id; } return $params; } /** * Edit entry $id of $app * * @param string $app appname of entry * @param string $id id in $app * @param string &$popup=null on return popup size eg. '600x400' or null * @return array|boolean with name-value pairs for link to edit-methode of $app or false if edit not supported */ static function edit($app,$id,&$popup=null) { //echo "

egw_link::add('$app','$to_app','$to_id') app_register[$app] ="; _debug_array($app_register[$app]); if (empty($app) || empty($id) || !is_array($reg = self::$app_register[$app]) || !isset($reg['edit'])) { if ($reg && isset($reg['view'])) { $popup = $reg['view_popup']; return self::view($app,$id); // fallback to view } return false; } $params = $reg['edit']; $params[$reg['edit_id']] = $id; $popup = $reg['edit_popup']; return $params; } /** * view entry $id of $app * * @param string $app appname * @param string $id id in $app * @param array $link =null link-data for file-attachments * @return array with name-value pairs for link to view-methode of $app to view $id */ static function view($app,$id,$link=null) { if ($app == self::VFS_APPNAME && !empty($id) && is_array($link)) { //return egw_vfs::download_url(self::vfs_path($link['app2'],$link['id2'],$link['id'],true)); return self::mime_open(self::vfs_path($link['app2'],$link['id2'],$link['id'],true), $link['type']); } if ($app == '' || !is_array($reg = self::$app_register[$app]) || !isset($reg['view']) || !isset($reg['view_id'])) { return array(); } $view = $reg['view']; $names = explode(':',$reg['view_id']); if (count($names) > 1) { $id = explode(':',$id); while (list($n,$name) = each($names)) { $view[$name] = $id[$n]; } } else { $view[$reg['view_id']] = $id; } return $view; } /** * Get mime-type information from app-registry * * Only return information from apps the user has access too (incl. registered sub-types of that apps). * * We prefer full matches over wildcards like "text/*" written as regexp "/^text\\//". * * @param string $type * @return array with values for keys 'menuaction', 'mime_id' (path) or 'mime_url' and options 'mime_popup' and other values to pass one */ static function get_mime_info($type) { foreach(self::$app_register as $app => $registry) { if (isset($registry['mime']) && (isset($GLOBALS['egw_info']['user']['apps'][$app]) || isset($registry['app']) && isset($GLOBALS['egw_info']['user']['apps'][$registry['app']]))) { foreach($registry['mime'] as $mime => $data) { if ($mime == $type) return $data; if ($mime[0] == '/' && preg_match($mime.'i', $type)) { $wildcard_mime = $data; } } } } return isset($wildcard_mime) ? $wildcard_mime : null; } /** * Get handler (link-data) for given path and mime-type * * @param string $path vfs path * @param string $type =null default to egw_vfs::mime_content_type($path) * @param string &$popup=null on return popup size or null * @return string|array string with EGw relative link, array with get-parameters for '/index.php' or null (directory and not filemanager access) */ static function mime_open($path, $type=null, &$popup=null) { if (is_null($type)) $type = egw_vfs::mime_content_type($path); if (($data = self::get_mime_info($type))) { if (isset($data['mime_url'])) { $data[$data['mime_url']] = egw_vfs::PREFIX.$path; unset($data['mime_url']); } elseif (isset($data['mime_id'])) { $data[$data['mime_id']] = $path; unset($data['mime_id']); } elseif(isset($data['mime_popup'])) { $popup = $data['mime_popup']; } else { throw new egw_exception_assertion_failed("Missing 'mime_id' or 'mime_url' for mime-type '$type'!"); } unset($data['mime_popup']); } else { $data = egw_vfs::download_url($path); } return $data; } /** * Check if $app uses a popup for $action * * @param string $app app-name * @param string $action ='view' name of the action, atm. 'view' or 'add' * @param array $link =null link-data for file-attachments * @return boolean|string false if no popup is used or $app is not registered, otherwise string with the prefered popup size (eg. '640x400) */ static function is_popup($app, $action='view', $link=null) { $popup = self::get_registry($app,$action.'_popup'); // for files/attachments check mime-registry if ($app == self::VFS_APPNAME && is_array($link) && !empty($link['type'])) { $path = self::vfs_path($link['app2'], $link['id2'], $link['id'], true); $p = null; if (self::mime_open($path, $link['type'], $p)) { $popup = $p; } } //error_log(__METHOD__."('$app', '$action', ".array2string($link).') returning '.array2string($popup)); return $popup; } /** * Check if $app is in the registry and has an entry for $name * * @param string $app app-name * @param string $name name / key in the registry, eg. 'view' * @return boolean|string false if $app is not registered, otherwise string with the value for $name */ static function get_registry($app,$name) { $reg = self::$app_register[$app]; if (!isset($reg)) return false; if (!isset($reg[$name])) // some defaults { switch($name) { case 'name': $reg[$name] = $app; break; case 'icon': if (isset($GLOBALS['egw_info']['apps'][$app]['icon'])) { $reg[$name] = ($GLOBALS['egw_info']['apps'][$app]['icon_app'] ? $GLOBALS['egw_info']['apps'][$app]['icon_app'] : $app). '/'.$GLOBALS['egw_info']['apps'][$app]['icon']; } else { $reg[$name] = $app.'/navbar'; } break; } } return isset($reg) ? $reg[$name] : false; } /** * path to the attached files of $app/$ip or the directory for $app if no $id,$file given * * All link-files are based in the vfs-subdir '/apps/'.$app * * @param string $app appname * @param string $id ='' id in $app * @param string $file ='' filename * @param boolean $just_the_path =false return url or just the vfs path * @return string/array path or array with path and relatives, depending on $relatives */ static function vfs_path($app,$id='',$file='',$just_the_path=false) { $path = self::VFS_BASEURL; if ($app) { $path .= '/'.$app; if ($id) { $path .= '/'.$id; if ($file) { $path .= '/'.$file; } } } if ($just_the_path) { $path = parse_url($path,PHP_URL_PATH); } else { $path = egw_vfs::resolve_url($path); } //error_log(__METHOD__."($app,$id,$file,$just_the_path)=$path"); return $path; } /** * Put a file to the corrosponding place in the VFS and set the attributes * * Does NO is_uploaded_file check, calling application is responsible for doing that for uploaded files! * * @param string $app appname to linke the file to * @param string $id id in $app * @param array $file informations about the file in format of the etemplate file-type * $file['name'] name of the file (no directory) * $file['type'] mine-type of the file * $file['tmp_name'] name of the uploaded file (incl. directory) or resource of opened file * @param string $comment ='' comment to add to the link * @return int negative id of egw_sqlfs table as negative link-id's are for vfs attachments */ static function attach_file($app,$id,$file,$comment='') { $entry_dir = self::vfs_path($app,$id); if (self::DEBUG) { echo "

attach_file: app='$app', id='$id', tmp_name='$file[tmp_name]', name='$file[name]', size='$file[size]', type='$file[type]', path='$file[path]', ip='$file[ip]', comment='$comment', entry_dir='$entry_dir'

\n"; } if (file_exists($entry_dir) || ($Ok = mkdir($entry_dir,0,true))) { $Ok = egw_vfs::copy_uploaded($file, $p=self::vfs_path($app,$id,'',true), $comment, false); // no is_uploaded_file() check! if (!$Ok) error_log(__METHOD__."('$app', '$id', ".array2string($file).", '$comment') called egw_vfs::copy_uploaded('$file[tmp_name]', '$p', '$comment', false)=".array2string($Ok)); } else { error_log(__METHOD__."($app,$id,".array2string($file).",$comment) Can't mkdir $entry_dir!"); } return $Ok ? -$Ok['ino'] : false; } /** * Links the entry to an existing file in the VFS * * @param string $app appname to link the file to * @param string $id id in $app * @param string $file VFS path to link to * @param string $comment ='' comment to add to the link */ static function link_file($app,$id,$file)//,$comment='') { // Don't try to link into app dir if there is no id if(!$id) return; $app_path = self::vfs_path($app,$id); $ok = true; if (egw_vfs::file_exists($app_path) || ($ok = egw_vfs::mkdir($app_path,0,true))) { if (!egw_vfs::stat($file)) { error_log(__METHOD__. ' (Link target ' . egw_vfs::decodePath($file) . ' not found!'); return false; } } $link = egw_vfs::concat($app_path,egw_vfs::basename($file)); return egw_vfs::symlink($file,$link); } /** * deletes a single or all attached files of an entry (for all there's no acl check, as the entry probably not exists any more!) * * @param int|string $app > 0: file_id of an attchemnt or $app/$id entry which linked to * @param string $id ='' id in app * @param string $fname ='' filename * @return boolean|array false on error ($app or $id not found), array with path as key and boolean result of delete */ static function delete_attached($app,$id='',$fname='') { if ((int)$app > 0) // is file_id { $url = egw_vfs::resolve_url(sqlfs_stream_wrapper::id2path($app)); } else { if (empty($app) || empty($id)) { return False; // dont delete more than all attachments of an entry } $url = self::vfs_path($app,$id,$fname); if (!$fname || !$id) // we delete the whole entry (or all entries), which probably not exist anymore { $current_is_root = egw_vfs::$is_root; egw_vfs::$is_root = true; } } if (self::DEBUG) { echo '

'.__METHOD__."('$app','$id','$fname') url=$url

\n"; } // Log in history - Need to load it first if((int)$app > 0) { $link = self::get_link(-$app); if($link['app2'] && $link['id2']) { historylog::static_add($link['app2'],$link['id2'],$GLOBALS['egw_info']['user']['account_id'],'~file~','', basename($url)); } } if (($Ok = !file_exists($url) || egw_vfs::remove($url,true)) && ((int)$app > 0 || $fname)) { // try removing the dir, in case it's empty @egw_vfs::rmdir(egw_vfs::dirname($url)); } if (!is_null($current_is_root)) { egw_vfs::$is_root = $current_is_root; } return $Ok; } /** * converts the infos vfs has about a file into a link * * @param string $app appname * @param string $id id in app * @param string $filename filename * @return array 'kind' of link-array */ static function info_attached($app,$id,$filename) { $path = self::vfs_path($app,$id,$filename,true); if (!($stat = egw_vfs::stat($path,STREAM_URL_STAT_QUIET))) { return false; } return self::fileinfo2link($stat,$path); } /** * converts a fileinfo (row in the vfs-db-table) in a link * * @param array|int $fileinfo a row from the vfs-db-table (eg. returned by the vfs ls static function) or a file_id of that table * @return array a 'kind' of link-array */ static function fileinfo2link($fileinfo,$url=null) { if (!is_array($fileinfo)) { $url = sqlfs_stream_wrapper::id2path($fileinfo); if (!($fileinfo = egw_vfs::url_stat($url,STREAM_URL_STAT_QUIET))) { return false; } } list(,,$app,$id) = explode('/',$url[0] == '/' ? $url : parse_url($url,PHP_URL_PATH)); // /apps/$app/$id return array( 'app' => self::VFS_APPNAME, 'id' => $fileinfo['name'], 'app2' => $app, 'id2' => $id, 'remark' => '', // only list_attached currently sets the remark 'owner' => $fileinfo['uid'], 'link_id' => -$fileinfo['ino'], 'lastmod' => $fileinfo['mtime'], 'size' => $fileinfo['size'], 'type' => $fileinfo['mime'], ); } /** * lists all attachments to $app/$id * * @param string $app appname * @param string $id id in app * @return array with link_id => 'kind' of link-array pairs */ static function list_attached($app,$id) { $path = self::vfs_path($app,$id); //error_log(__METHOD__."($app,$id) url=$url"); if (!($extra = self::get_registry($app,'find_extra'))) $extra = array(); // always use regular links stream wrapper here: extended one is unnecessary (slow) for just listing attachments if (substr($path,0,13) == 'stylite.links') $path = substr($path,8); $attached = array(); if (($url2stats = egw_vfs::find($path,array('need_mime'=>true,'type'=>'F','url'=>true)+$extra,true))) { $props = egw_vfs::propfind(array_keys($url2stats)); // get the comments foreach($url2stats as $url => &$fileinfo) { $link = self::fileinfo2link($fileinfo,$url); if ($props && isset($props[$url])) { foreach($props[$url] as $prop) { if ($prop['ns'] == egw_vfs::DEFAULT_PROP_NAMESPACE && $prop['name'] == 'comment') { $link['remark'] = $prop['val']; break; } } } $attached[$link['link_id']] = $link; } } return $attached; } /** * reverse static function of htmlspecialchars() * * @param string $str string to decode * @return string decoded string */ static private function decode_htmlspecialchars($str) { return str_replace(array('&','"','<','>'),array('&','"','<','>'),$str); } /** * Key for old link title in $data param to egw_link::notify */ const OLD_LINK_TITLE = 'old_link_title'; /** * notify other apps about changed content in $app,$id * * To give other apps the possebility to update a title, you can also specify * a changed old link-title in $data[egw_link::OLD_LINK_TITLE]. * * @param string $app name of app in which the updated happend * @param string $id id in $app of the updated entry * @param array $data =null updated data of changed entry, as the read-method of the BO-layer would supply it */ static function notify_update($app,$id,$data=null) { self::delete_cache($app,$id); //error_log(__METHOD__."('$app', $id, $data)"); foreach(self::get_links($app,$id,'!'.self::VFS_APPNAME) as $link_id => $link) { self::notify('update',$link['app'],$link['id'],$app,$id,$link_id,$data); } if($data[egw_link::OLD_LINK_TITLE] && egw_json_response::isJSONResponse()) { // Update client side with new title egw_json_response::get()->apply('egw.link_title_callback',array(array($app => array($id => self::title($app, $id))))); } } /** * Stores notifications to run after regular processing is done * * @var array */ private static $notifies = array(); /** * notify an application about a new or deleted links to own entries or updates in the content of the linked entry * * Please note: not all apps supply update notifications * * @internal * @param string $type 'link' for new links, 'unlink' for unlinked entries, 'update' of content in linked entries * @param string $notify_app app to notify * @param string $notify_id id in $notify_app * @param string $target_app name of app whos entry changed, linked or deleted * @param string $target_id id in $target_app * @param array $data =null data of entry in app2 (optional) */ static private function notify($type,$notify_app,$notify_id,$target_app,$target_id,$link_id,$data=null) { //error_log(__METHOD__."('$type', '$notify_app', $notify_id, '$target_app', $target_id, $link_id, $data)"); if ($link_id && isset(self::$app_register[$notify_app]) && isset(self::$app_register[$notify_app]['notify'])) { if (!self::$notifies) { egw::on_shutdown(array(__CLASS__, 'run_notifies')); } self::$notifies[] = array( 'method' => self::$app_register[$notify_app]['notify'], 'type' => $type, 'id' => $notify_id, 'target_app' => $target_app, 'target_id' => $target_id, 'link_id' => $link_id, 'data' => $data, ); } } /** * Run notifications called by egw::on_shutdown(), after regular processing is finished */ static public function run_notifies() { //error_log(__METHOD__."() count(self::\$notifies)=".count(self::$notifies)); while(self::$notifies) { $args = array_shift(self::$notifies); $method = $args['method']; unset($args['method']); //error_log(__METHOD__."() calling $method(".array2string($args).')'); ExecMethod($method, $args); } } /** * notifies about unlinked links * * @internal * @param array &$links unlinked links from the database */ static private function notify_unlink(&$links) { foreach($links as $link) { // we notify both sides of the link, as the unlink command NOT clearly knows which side initiated the unlink self::notify('unlink',$link['link_app1'],$link['link_id1'],$link['link_app2'],$link['link_id2'],$link['link_id']); self::notify('unlink',$link['link_app2'],$link['link_id2'],$link['link_app1'],$link['link_id1'],$link['link_id']); } } /** * Get a reference to the cached value for $app/$id for $type * * @param string $app * @param string|int $id * @param string $type ='title' 'title' or 'file_access' * @return int|string can be null, if cache not yet set */ private static function &get_cache($app,$id,$type = 'title') { switch($type) { case 'title': return self::$title_cache[$app.':'.$id]; case 'file_access': return self::$file_access_cache[$app.':'.$id]; default: throw new egw_exception_wrong_parameter("Unknown type '$type'!"); } } /** * Set title and optional file_access cache for $app,$id * * Allows applications to set values for title and file access, eg. in their search method, * to not be called again. This offloads the need to cache from the app to the link class. * If there's no caching, items get read multiple times from the database! * * @param string $app * @param int|string $id * @param string $title title string or null * @param int $file_access =null EGW_ACL_READ, EGW_ACL_EDIT or both or'ed together */ public static function set_cache($app,$id,$title,$file_access=null) { //error_log(__METHOD__."($app,$id,$title,$file_access)"); if (!is_null($title)) { self::$title_cache[$app.':'.$id] = $title; } if (!is_null($file_access)) { self::$file_access_cache[$app.':'.$id] = $file_access; } } /** * Delete the diverse caches for $app/$id * * @param string $app app-name or null to delete the whole cache * @param int|string $id id or null to delete only file_access cache of given app (keeps title cache, if app implements file_access!) */ private static function delete_cache($app,$id) { unset(self::$title_cache[$app.':'.$id]); unset(self::$file_access_cache[$app.':'.$id]); } /** * Store function call and parameters in session and return id to retrieve it result * * @param string $mime_type * @param string $method * @param array $params * @param boolean $ignore_mime =false true: return id, even if nothing registered for given mime-type * @return string|null md5 hash of stored data of server-side supported mime-type or null otherwise */ public static function set_data($mime_type, $method, array $params, $ignore_mime=false) { if (!$ignore_mime && (!($info = self::get_mime_info($mime_type)) || empty($info['mime_data']))) { return null; } array_unshift($params, $method); $id = md5(serialize($params)); //error_log(__METHOD__."('$mime_type', '$method', ...) params=".array2string($params)." --> json=".array2string(serialize($params)).' --> id='.array2string($id)); egw_cache::setSession(__CLASS__, $id, $params); return $id; } /** * Call stored function with parameters and return result * * @param string $id * @param boolean $return_resource =false false: return string, true: return resource * @return mixed null if id is not found or invalid * @throws egw_exception_wrong_parameter */ public static function get_data($id, $return_resource=false) { $data = egw_cache::getSession(__CLASS__, $id); if (!isset($data) || empty($data[0])) { throw new egw_exception_wrong_parameter(__METHOD__."('$id')"); } $ret = call_user_func_array('ExecMethod2', $data); if (is_resource($ret)) fseek($ret, 0); if ($return_resource != is_resource($ret)) { if ($return_resource && ($fp = fopen('php://temp', 'w'))) { fwrite($fp, $ret); fseek($fp, 0); $ret = $fp; } if (!$return_resource) { $fp = $ret; $ret = ''; while(!feof($fp)) { $ret .= fread($fp, 8192); } fclose($fp); } } //error_log(__METHOD__."('$id') returning ".gettype($ret).'='.array2string($ret)); return $ret; } /** * Check the file access perms for $app/id and given user $user * * If $user given and != current user AND app does not set file_access_user=true, * allways return false, as there's no way to check access for an other user! * * @ToDo $rel_path is not yet implemented, as no app use it currently * @param string $app * @param string|int $id id of entry * @param int $required =EGW_ACL_READ EGW_ACL_{READ|EDIT} * @param string $rel_path =null * @param int $user =null default null = current user * @return boolean true if access granted, false otherwise */ static function file_access($app,$id,$required=EGW_ACL_READ,$rel_path=null,$user=null) { // are we called for an other user if ($user && $user != $GLOBALS['egw_info']['user']['account_id']) { // check if app supports file_access WITH 4th $user parameter --> return false if not if (!self::get_registry($app,'file_access_user') || !($method = self::get_registry($app,'file_access'))) { $ret = false; $err = "(no file_access_user)"; } else { $ret = ExecMethod2($method,$id,$required,$rel_path,$user); $err = "(from $method)"; } //error_log(__METHOD__."('$app',$id,$required,'$rel_path',$user) returning $err ".array2string($ret)); return $ret; } $cache =& self::get_cache($app,$id,'file_access'); if (!isset($cache) || $required == EGW_ACL_EDIT && !($cache & $required)) { if(($method = self::get_registry($app,'file_access'))) { $cache |= ExecMethod2($method,$id,$required,$rel_path) ? $required|EGW_ACL_READ : 0; } else { $cache |= self::title($app,$id) ? EGW_ACL_READ|EGW_ACL_EDIT : 0; } //error_log(__METHOD__."($app,$id,$required,$rel_path) got $cache --> ".($cache & $required ? 'true' : 'false')); } //else error_log(__METHOD__."($app,$id,$required,$rel_path) using cached value $cache --> ".($cache & $required ? 'true' : 'false')); return !!($cache & $required); } } egw_link::init_static();