* @copyright 2001-2008 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)
* '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',
* ),
* 'view_id' => 'app_id', // name of get parameter of the id
* 'view_popup' => '400x300', // size of popup (XxY), if view is in popup
* '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, see links_stream_wrapper class
* ); // boolean file_access(string $id,int $check,string $rel_path)
* }
* 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
*/
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
/**
* 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(
'felamimail' => array(
'add' => array(
'menuaction' => 'felamimail.uicompose.compose',
),
'add_popup' => '700x750',
),
);
/**
* Caches link titles for a better performance
*
* @var array
*/
private static $title_cache = array();
/**
* Private constructor to forbid instanciated use
*
*/
private function __construct()
{
}
/**
* initialize our static vars
*/
static function init_static( )
{
// 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');
$GLOBALS['egw']->session->appsession('search_link_hooks','phpgwapi',$search_link_hooks);
}
if (is_array($search_link_hooks))
{
foreach($search_link_hooks as $app => $data)
{
if (is_array($data))
{
self::$app_register[$app] = $data;
}
}
}
if (!(self::$title_cache = $GLOBALS['egw']->session->appsession('link_title_cache','phpgwapi')))
{
self::$title_cache = array();
}
}
/**
* Called by egw::egw_final to store the title-cache in the session
*
*/
static function save_session_cache()
{
$GLOBALS['egw']->session->appsession('link_title_cache','phpgwapi',self::$title_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 (see below)
* $file array with 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)
* $file['path'] path of the file on the client computer
* $file['ip'] of the client (path and ip in $file are only needed if u want a symlink (if possible))
* @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($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 (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 arrayegw_link::get_links(app='$app',id='$id',only_app='$only_app',order='$order')
\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); 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"; 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 * @return array of $id => array($links) pairs */ static function get_links_multiple($app,array $ids,$cache_titles=true,$only_app='',$order='link_lastmod DESC' ) { 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); 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 $src_id => &$targets) { foreach($targets as $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 string $app2='' app of second endpoint * @param string $id2='' id in $app2 * @return the number of links deleted */ static function unlink($link_id,$app='',$id='',$owner='',$app2='',$id2='') { return self::unlink2($link_id,$app,$id,$owner,$app2,$id2); } /** * 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 string $app2='' app of second endpoint, or !file (other !app are not yet supported!) * @param string $id2='' id in $app2 * @return the number of links deleted */ static function unlink2($link_id,$app,&$id,$owner='',$app2='',$id2='') { if (self::DEBUG) { echo "egw_link::unlink('$link_id','$app','$id','$owner','$app2','$id2')
\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 unset(self::$title_cache[$app.':'.$id]); } $deleted =& solink::unlink($link_id,$app,$id,$owner,$app2 != '!'.self::VFS_APPNAME ? $app2 : '',$id2); // 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 $app => $reg) { if ($must_support && !isset($reg[$must_support])) continue; if ($GLOBALS['egw_info']['user']['apps'][$app]) { $apps[$app] = $GLOBALS['egw_info']['apps'][$app]['title']; } } return $apps; } /** * Searches for a $pattern in the entries of $app * * @param string $app app to search * @param string $pattern pattern to search * @return array with $id => $title pairs of matching entries of app */ static function query($app,$pattern) { 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"; } return ExecMethod($method,$pattern); } /** * 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 ''; if (isset(self::$title_cache[$app.':'.$id])) { if (self::DEBUG) echo ''.__METHOD__."('$app','$id')='".self::$title_cache[$app.':'.$id]."' (from cache)
\n"; return self::$title_cache[$app.':'.$id]; } if ($app == self::VFS_APPNAME) { if (is_array($id) && $link) { $link = $id; $id = $link['name']; } if (is_array($link)) { $extra = ': '.$link['type'] . ' '.egw_vfs::hsize($link['size']); } return self::$title_cache[$app.':'.$id] = $id.$extra; } if ($app == '' || !is_array($reg = self::$app_register[$app]) || !isset($reg['title'])) { if (self::DEBUG) echo "".__METHOD__."('$app','$id') something is wrong!!!
\n"; return array(); } $method = $reg['title']; $title = ExecMethod($method,$id); if ($id && is_null($title)) // $app,$id has been deleted ==> unlink all links to it { self::unlink(0,$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 self::$title_cache[$app.':'.$id] = $title; } /** * 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) { if (!isset(self::$title_cache[$app.':'.$id])) { if (isset(self::$app_register[$app]['titles'])) { $ids_to_query[] = $id; // titles method --> collect links to query at once } else { self::title($app,$id); // no titles method --> fallback to query each link separate } } $titles[$id] = self::$title_cache[$app.':'.$id]; } if ($ids_to_query) { foreach(ExecMethod(self::$app_register[$app]['titles'],$ids_to_query) as $id => $title) { $titles[$id] = self::$title_cache[$app.':'.$id] = $title; } } 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; } /** * 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(parse_url(self::vfs_path($link['app2'],$link['id2'],$link['id']),PHP_URL_PATH)); } 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; } /** * 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' * @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') { return self::get_registry($app,$action.'_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]; 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 '/infolog'. For other apps * separate subdirs with name app are created. * * @param string $app appname * @param string $id='' id in $app * @param string $file='' filename * @return string/array path or array with path and relatives, depending on $relatives */ static function vfs_path($app,$id='',$file='') { $path = links_stream_wrapper::BASEURL; if ($app) { $path .= '/'.$app; if ($id) { $path .= '/'.$id; if ($file) { $path .= '/'.$file; } } } //error_log(__METHOD__."($app,$id,$file)=$path"); return $path; } /** * Put a file to the corrosponding place in the VFS and set the attributes * * @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) * $file['path'] path of the file on the client computer * $file['ip'] of the client (path and ip are only needed if u want a symlink (if possible)) * @param string $comment='' comment to add to the link * @todo remark/comment from the vfs * @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='') { 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'
\n"; } $entry_dir = self::vfs_path($app,$id); if (file_exists($entry_dir) || ($Ok = mkdir($entry_dir,0,true))) { $Ok = copy($file['tmp_name'],$fname = $entry_dir.'/'.$file['name']) && ($stat = links_stream_wrapper::url_stat($fname,0)); } else { error_log(__METHOD__."($app,$id,$file,$comment) Can't mkdir $entry_dir!"); } // todo: set comment return $Ok ? -$stat['ino'] : false; } /** * 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 = links_stream_wrapper::PREFIX.links_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 "egw_link::delete_attached('$app','$id','$fname') url=$url
\n"; } if (($Ok = egw_vfs::remove($url,true)) && ((int)$app > 0 || $fname)) { // try removing the dir, in case it's empty @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) { $url = self::vfs_path($app,$id,$filename); if (!($stat = links_stream_wrapper::url_stat($url,STREAM_URL_STAT_QUIET))) { return false; } return self::fileinfo2link($stat,$url); } /** * 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 * @todo remark/comment from the vfs * @return array a 'kind' of link-array */ static function fileinfo2link($fileinfo,$url=null) { if (!is_array($fileinfo)) { $url = links_stream_wrapper::id2path($fileinfo); if (!($fileinfo = links_stream_wrapper::url_stat($url,STREAM_URL_STAT_QUIET))) { return false; } } list(,,,,$app,$id) = explode('/',$url); // links://apps/$app/$id return array( 'app' => self::VFS_APPNAME, 'id' => $fileinfo['name'], 'app2' => $app, 'id2' => $id, '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) { $url = self::vfs_path($app,$id); //error_log(__METHOD__."($app,$id) url=$url"); $attached = array(); foreach(egw_vfs::find($url,array('url'=>true,'need_mime'=>true,'type'=>'f'),true) as $url => $fileinfo) { $link = self::fileinfo2link($fileinfo,$url); $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); } /** * notify other apps about changed content in $app,$id * * @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) { 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); } unset(self::$title_cache[$app.':'.$id]); } /** * 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) { if ($link_id && isset(self::$app_register[$notify_app]) && isset(self::$app_register[$notify_app]['notify'])) { ExecMethod(self::$app_register[$notify_app]['notify'],array( 'type' => $type, 'id' => $notify_id, 'target_app' => $target_app, 'target_id' => $target_id, 'link_id' => $link_id, 'data' => $data, )); } } /** * 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']); } } } egw_link::init_static();