1<?php
2/**
3 * Tracker - Universal tracker (bugs, feature requests, ...) with voting and bounties
4 *
5 * @link http://www.egroupware.org
6 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de>
7 * @package tracker
8 * @copyright (c) 2006-16 by Ralf Becker <RalfBecker-AT-outdoor-training.de>
9 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
10 * @version $Id$
11 */
12
13use EGroupware\Api;
14use EGroupware\Api\Link;
15use EGroupware\Api\Acl;
16use EGroupware\Api\Vfs;
17
18/**
19 * Some constants for the check_rights function
20 */
21define('TRACKER_ADMIN',1);
22define('TRACKER_TECHNICIAN',2);
23define('TRACKER_USER',4);		// non-anonymous user with tracker-rights
24define('TRACKER_EVERYBODY',8);	// everyone incl. anonymous user
25define('TRACKER_ITEM_CREATOR',16);
26define('TRACKER_ITEM_ASSIGNEE',32);
27define('TRACKER_ITEM_NEW',64);
28define('TRACKER_ITEM_GROUP',128);
29
30/**
31 * Business Object of the tracker
32 */
33class tracker_bo extends tracker_so
34{
35	/**
36	 * Timestamps which need to be converted to user-time and back
37	 *
38	 * @var array
39	 */
40	var $timestamps = array('tr_created','tr_modified','tr_closed','reply_created');
41	/**
42	 * Current user
43	 *
44	 * @var int;
45	 */
46	var $user;
47
48	/**
49	 * Existing trackers (stored as app-global cats with cat_data='tracker')
50	 *
51	 * @var array
52	 */
53	var $trackers;
54	/**
55	 * Existing priorities
56	 *
57	 * @var array
58	 */
59	static $stock_priorities = array(
60		1 => '1 - lowest',
61		2 => '2',
62		3 => '3',
63		4 => '4',
64		5 => '5 - medium',
65		6 => '6',
66		7 => '7',
67		8 => '8',
68		9 => '9 - highest',
69	);
70	/**
71	 * Priorities by tracker or key=0 for all trackers
72	 *
73	 * Not set trackers use the key=0 or if that's not set the stock priorities
74	 *
75	 * @var array
76	 */
77	protected $priorities;
78
79	/**
80	 * Stati used by all trackers
81	 *
82	 * @var array
83	 */
84	static $stati = array(
85		self::STATUS_OPEN    => 'Open(status)',
86		self::STATUS_CLOSED  => 'Closed',
87		self::STATUS_DELETED => 'Deleted',
88		self::STATUS_PENDING => 'Pending',
89	);
90	/**
91	 * Resolutions used by all trackers historically
92	 *
93	 * Kept around for history display, but no longer used
94	 * @var array
95	 */
96	static $resolutions = array(
97		'n' => 'None',
98		'a' => 'Accepted',
99		'd' => 'Duplicate',
100		'f' => 'Fixed',
101		'i' => 'Invalid',
102		'I' => 'Info only',
103		'l' => 'Later',
104		'o' => 'Out of date',
105		'p' => 'Postponed',
106		'O' => 'Outsourced',
107		'r' => 'Rejected',
108		'R' => 'Remind',
109		'w' => 'Wont fix',
110		'W' => 'Works for me',
111	);
112	/**
113	 * Technicians by tracker or key=0 for all trackers
114	 *
115	 * @var array
116	 */
117	var $technicians;
118	/**
119	 * Admins by tracker or key=0 for all trackers
120	 *
121	 * @var array
122	 */
123	var $admins;
124	/**
125	 * Users by tracker or key=0 for all trackers
126	 *
127	 * @var array
128	 */
129	var $users;
130	/**
131	 * ACL for the fields of the tracker
132	 *
133	 * field-name is the key with values or'ed together from the TRACKER_ constants
134	 *
135	 * @var array
136	 */
137	var $field_acl;
138	/**
139	 * Restricions settings (tracker specific, keys: group, creator)
140	 *
141	 * @var array
142	 */
143	var $restrictions;
144	/**
145	 * Enabled the Acl queue access?
146	 *
147	 * @var boolean
148	 */
149	var $enabled_queue_acl_access = false;
150	/**
151	 * Mailhandler settings (tracker unspecific)
152	 *  Keys:
153	 *   interval
154	 *   address
155	 *   server
156	 *   servertype
157	 *   serverport
158	 *   folder
159	 *   username
160	 *   password
161	 *   delete_from_server (true/false)
162	 *   default_tracker (<empty>=reject new tickets|TrackerID)
163	 *   unrecognized_mails (ignore/delete/forward/default)
164	 *   unrec_reply (0=Creator/1=Nobody)
165	 *   unrec_mail (<empty>=ignore|UID)
166	 *   forward_to
167	 *   auto_reply (0=Never/1=New/2=Always)
168	 *   reply_unknown (1=Yes/0=No)
169	 *   reply_text (text message)
170	 *   bounces (ignore/delete/forward)
171	 *   autoreplies (ignore/delete/forward/process)
172	 *
173	 * @var array
174	 */
175	var $mailhandling = array();
176	/**
177	 * Supported server types for mail handling as an array of arrays with spec => descr
178	 *
179	 * @var array
180	 */
181	var $mailservertypes = array(
182		0 => array('imap/notls', 'Standard IMAP'),
183		1 => array('imap/tls', 'IMAP, TLS secured'),
184		2 => array('imap/ssl', 'IMAP, SSL secured'),
185		3 => array('pop3', 'POP3'),
186	);
187
188	/**
189	 * how to handle mailheaderinfo, provided as an array of arrays with spec => descr
190	 *
191	 * @var array
192	 */
193	var $mailheaderhandling = array(
194		0 => array('noinfo', 'no, no additional Mailheader to description and comments'),
195		1 => array('infotodesc', 'yes, add Mailheader to description'),
196		2 => array('infotocomment', 'yes, add Mailheader to comments'),
197		3 => array('infotoboth', 'yes, add Mailheader to both (description and comments)'),
198	);
199
200	/**
201	 * Translates field / acl-names to labels
202	 *
203	 * @var array
204	 */
205	var $field2label = array(
206		'tr_summary'     => 'Summary',
207		'tr_tracker'     => 'Tracker',
208		'cat_id'         => 'Category',
209		'tr_version'     => 'Version',
210		'tr_status'      => 'Status',
211		'tr_description' => 'Description',
212		'tr_assigned'    => 'Assigned to',
213		'tr_private'     => 'Private',
214//		'tr_budget'      => 'Budget',
215		'tr_resolution'  => 'Resolution',
216		'tr_completion'  => 'Completed',
217		'tr_priority'    => 'Priority',
218		'tr_startdate'   => 'Start date',
219		'tr_duedate'     => 'Due date',
220		'tr_closed'      => 'Closed',
221		'tr_creator'     => 'Created by',
222		'tr_created'     => 'Created on',
223		'tr_group'		 => 'Group',
224		'tr_cc'			 => 'CC',
225		// pseudo fields used in edit
226		'link_to'        => 'Attachments & Links',
227		'canned_response' => 'Canned response',
228		'reply_message'  => 'Add comment',
229		'edit_own_reply' => 'Edit own comments',
230		'edit_reply'     => 'Edit others comments',
231		'add'            => 'Add',
232		'vote'           => 'Vote for it!',
233		'no_notifications'	=> 'No notifications',
234		'bounty'         => 'Set bounty',
235		'num_replies'    => 'Number of replies',
236		'customfields'   => 'Custom fields',
237	);
238	/**
239	 * Translate field-name to 2-char history status
240	 *
241	 * @var array
242	 */
243	var $field2history = array(
244		'tr_summary'     => 'Su',
245		'tr_tracker'     => 'Tr',
246		'cat_id'         => 'Ca',
247		'tr_version'     => 'Ve',
248		'tr_status'      => 'St',
249		'tr_description' => 'De',
250		'tr_creator'     => 'Cr',
251		'tr_assigned'    => 'As',
252		'tr_private'     => 'pr',
253//		'tr_budget'      => 'Bu',
254		'tr_completion'  => 'Co',
255		'tr_priority'    => 'Pr',
256		'tr_startdate'   => 'tr_startdate',
257		'tr_duedate'     => 'tr_duedate',
258		'tr_closed'      => 'Cl',
259		'tr_resolution'  => 'Re',
260		'tr_cc'			 => 'Cc',
261		'tr_group'		 => 'Gr',
262		// no need to track number of replies, as replies are versioned
263		//'num_replies'    => 'Nr',
264/* the following bounty-stati are only for reference
265		'bounty-set'     => 'bo',
266		'bounty-deleted' => 'xb',
267		'bounty-confirmed'=> 'Bo',
268*/
269		// all custom fields together
270		'customfields'	=> '#c',
271	);
272	/**
273	 * Allow to assign tracker items to groups:  0=no; 1=yes, display groups+users; 2=yes, display users+groups
274	 *
275	 * @var int
276	 */
277	var $allow_assign_groups=1;
278	/**
279	 * Allow to vote on tracker items
280	 *
281	 * @var boolean
282	 */
283	var $allow_voting=true;
284	/**
285	 * How many days to mark a not responded item overdue
286	 *
287	 * @var int
288	 */
289	var $overdue_days=14;
290	/**
291	 * How many days to mark a pending item closed
292	 *
293	 * @var int
294	 */
295	var $pending_close_days=7;
296	/**
297	 * Permit html editing on details and comments
298	 */
299	var $htmledit = false;
300	var $all_cats;
301	var $historylog;
302
303	/**
304	 * config var for color code
305	 * @var type
306	 */
307	var $enabled_color_code_for = '';
308
309	/**
310	 * Instance of the tracker_tracking object
311	 *
312	 * @var tracker_tracking
313	 */
314	var $tracking;
315	/**
316	 * Names of all config vars
317	 *
318	 * @var array
319	 */
320	var $config_names = array(
321		'technicians','admins','users','notification','projects','priorities','default_priority','restrictions', 'user_category_preference',	// tracker specific
322		'field_acl','allow_assign_groups','allow_voting','overdue_days','pending_close_days','htmledit','create_new_as_private','allow_assign_users','allow_infolog','allow_restricted_comments','mailhandling','enabled_color_code_for',	// tracker unspecific
323		'allow_bounties','currency','enabled_queue_acl_access','exclude_app_on_timesheetcreation','show_dates', 'comment_reopens'
324	);
325	/**
326	 * Notification settings (tracker specific, keys: sender, link, copy, lang)
327	 *
328	 * @var array
329	 */
330	var $notification;
331	/**
332	 * Allow bounties to be set on tracker items
333	 *
334	 * @var string
335	 */
336	var $allow_bounties = false;
337	/**
338	 * Currency used by the bounties
339	 *
340	 * @var string
341	 */
342	var $currency = 'Euro';
343	/**
344	 * Filters to manage advanced logical statis
345	 */
346	var $filters = array(
347		'closed'				=> '&#9830; Closed',
348		'not-closed'				=> '&#9830; Not closed',
349		'own-not-closed'			=> '&#9830; Own not closed',
350		'ownorassigned-not-closed'		=> '&#9830; Own or assigned not closed',
351		'without-reply-not-closed' 		=> '&#9830; Without reply not closed',
352		'own-without-reply-not-closed' 		=> '&#9830; Own without reply not closed',
353		'without-30-days-reply-not-closed'	=> '&#9830; Without 30 days reply not closed',
354	);
355
356	/**
357	 * Filter for search limiting the date-range
358	 *
359	 * @var array
360	 */
361	var $date_filters = array(      // Start: year,month,day,week, End: year,month,day,week
362		'Overdue'     => false,
363		'Today'       => array(0,0,0,0,  0,0,1,0),
364		'Yesterday'   => array(0,0,-1,0, 0,0,0,0),
365		'This week'   => array(0,0,0,0,  0,0,0,1),
366		'Last week'   => array(0,0,0,-1, 0,0,0,0),
367		'This month'  => array(0,0,0,0,  0,1,0,0),
368		'Last month'  => array(0,-1,0,0, 0,0,0,0),
369		'Last 3 months' => array(0,-3,0,0, 0,0,0,0),
370		'This quarter'=> array(0,0,0,0,  0,0,0,0),	// Just a marker, needs special handling
371		'Last quarter'=> array(0,-4,0,0, 0,-4,0,0),	// Just a marker
372		'This year'   => array(0,0,0,0,  1,0,0,0),
373		'Last year'   => array(-1,0,0,0, 0,0,0,0),
374		'2 years ago' => array(-2,0,0,0, -1,0,0,0),
375		'3 years ago' => array(-3,0,0,0, -2,0,0,0),
376	);
377
378
379
380	/**
381	 * Constructor
382	 *
383	 * @return tracker_bo
384	 */
385	function __construct()
386	{
387		parent::__construct();
388
389		$this->user = $GLOBALS['egw_info']['user']['account_id'];
390		$this->today = mktime(0,0,0,date('m',$this->now),date('d',$this->now),date('Y',$this->now));
391
392		// read the tracker-configuration
393		$this->load_config();
394
395		$this->trackers = $this->get_tracker_labels();
396	}
397
398	/**
399	 * initializes data with the content of key
400	 *
401	 * Reimplemented to set some defaults
402	 *
403	 * @param array $keys = array() array with keys in form internalName => value
404	 * @return array internal data after init
405	 */
406	function init($keys=array())
407	{
408		parent::init();
409		if (isset($keys['tr_tracker'])&&!empty($keys['tr_tracker'])) $this->data['tr_tracker']=$keys['tr_tracker'];
410		if (is_array($this->trackers)&&(!isset($this->data['tr_tracker'])||empty($this->data['tr_tracker'])))	// init is called from Api\Storage\Base::__construct(), where $this->trackers is NOT set
411		{
412			$this->data['tr_tracker'] = key($this->trackers);	// Need some tracker so creator rights are correct
413		}
414		$this->data['tr_creator'] = $GLOBALS['egw_info']['user']['account_id'];
415		$this->data['tr_private'] = $this->create_new_as_private;
416		$this->data['tr_group'] = $GLOBALS['egw_info']['user']['account_primary_group'];
417		// set default resolution
418		$this->get_tracker_labels('resolution', $this->data['tr_tracker'], $this->data['tr_resolution']);
419
420		// set default priority
421		$default_priority = null;
422		$this->get_tracker_priorities($this->data['tr_tracker'],$this->data['cat_id'], true, $default_priority);
423		$this->data['tr_priority'] = $default_priority;
424
425		// Set default category
426		if(!$this->data['cat_id'])
427		{
428			$default_category = null;
429			$this->get_tracker_labels('cat', $this->data['tr_tracker'], $default_category);
430			$this->data['cat_id'] = $default_category;
431		}
432
433		$this->data_merge($keys);
434
435		return $this->data;
436	}
437
438	/**
439	 * Changes the data from the db-format to your work-format
440	 *
441	 * @param array $data if given works on that array and returns result, else works on internal data-array
442	 * @return array with changed data
443	 */
444	function db2data($data=null)
445	{
446		if (($intern = !is_array($data)))
447 		{
448 			$data =& $this->data;
449 		}
450		if (is_array($data['replies']))
451		{
452			foreach($data['replies'] as &$reply)
453			{
454				$reply['reply_servertime'] = $reply['reply_created'];
455				$reply['reply_created'] = Api\DateTime::server2user($reply['reply_created'],$this->timestamp_type);
456			}
457		}
458		// check if item is overdue
459		if ($this->overdue_days > 0)
460		{
461			$modified = $data['tr_modified'] ? $data['tr_modified'] : $data['tr_created'];
462			$limit = $this->now - $this->overdue_days * 24*60*60;
463			$data['overdue'] = !in_array($data['tr_status'],$this->get_tracker_stati(null,true)) && 	// only open items can be overdue
464				(!$data['tr_modified'] || $data['tr_modifier'] == $data['tr_creator']) && $modified < $limit;
465
466		}
467
468		// Consider due date independent of overdue days
469		$data['overdue'] |= ($data['tr_duedate'] && $this->now > $data['tr_duedate'] && !in_array($data['tr_status'], $this->get_tracker_stati(null,true)));
470
471		// Keep a copy of the timestamps in server time, so notifications can change them for each user
472		foreach($this->timestamps as $field)
473		{
474			$data[$field . '_servertime'] = $data[$field];
475		}
476
477		// will run all regular timestamps ($this->timestamps) trough Api\DateTime::server2user()
478		return parent::db2data($intern ? null : $data);	// important to use null, if $intern!
479	}
480
481	/**
482	 * Changes the data from your work-format to the db-format
483	 *
484	 * @param array $data if given works on that array and returns result, else works on internal data-array
485	 * @return array with changed data
486	 */
487	function data2db($data=null)
488	{
489		if (($intern = !is_array($data)))
490		{
491			$data = &$this->data;
492		}
493		if (substr($data['tr_completion'],-1) == '%') $data['tr_completion'] = (int) round(substr($data['tr_completion'],0,-1));
494
495		// will run all regular timestamps ($this->timestamps) through Api\DateTime::user2server()
496		return parent::data2db($intern ? null : $data);	// important to use null, if $intern!
497	}
498
499	/**
500	 * Read a tracker item
501	 *
502	 * Reimplemented to store the old status
503	 *
504	 * @param array $keys array with keys in form internalName => value, may be a scalar value if only one key
505	 * @param string|array $extra_cols string or array of strings to be added to the SELECT, eg. "count(*) as num"
506	 * @param string $join sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or
507	 * @param int $user = null for which user to check, default current user
508	 * @return array|boolean data if row could be retrived else False
509	*/
510	function read($keys,$extra_cols='',$join='',$user=null)
511	{
512		if (($ret = parent::read($keys, $extra_cols, $join)))
513		{
514			// read_extras need to know if $user is admin and/or technician of queue of ticket
515			$ret = $this->read_extra($this->is_admin($this->data['tr_tracker'], $user),
516				$this->is_technician($this->data['tr_tracker'], $user), $user);
517
518			$this->data['old_status'] = $this->data['tr_status'];
519
520			if ($this->deny_private()) $ret = $this->data = false;
521		}
522		return $ret;
523	}
524
525	/**
526	 * saves the content of data to the db
527	 *
528	 * @param array $keys if given $keys are copied to data before saveing => allows a save as
529	 * @param array $autoreply when called from the mailhandler, contains data for the autoreply
530	 * (only for forwarding to tracling)
531	 * @return int 0 on success and errno != 0 else
532	 */
533	function save($keys=null, $autoreply=null)
534	{
535		if ($keys) $this->data_merge($keys);
536
537		if (!$this->data['tr_id'])	// new entry
538		{
539			$this->data['tr_created'] = (isset($this->data['tr_created'])&&!empty($this->data['tr_created'])?$this->data['tr_created']:$this->now);
540			$this->data['tr_creator'] = $this->data['tr_creator'] ? $this->data['tr_creator'] : $this->user;
541			$this->data['tr_version'] = $this->data['tr_version'] ? $this->data['tr_version'] : $GLOBALS['egw_info']['user']['preferences']['tracker']['default_version'];
542			$this->data['tr_status'] = $this->data['tr_status'] ? $this->data['tr_status'] : self::STATUS_OPEN;
543
544			if (!$this->data['tr_resolution'])	// if no resolution set, ask labels for resolution default
545			{
546				$this->get_tracker_labels('resolution', $this->data['tr_tracker'], $this->data['tr_resolution']);
547			}
548			$this->data['tr_seen'] = serialize(array($this->user));
549
550			if (!$this->data['tr_group'])
551			{
552				$this->data['tr_group'] = $GLOBALS['egw']->accounts->data['account_primary_group'];
553			}
554
555			if ($this->data['cat_id'] && !$this->data['tr_assigned'])
556			{
557				$this->autoassign();
558			}
559		}
560		else
561		{
562			// check if we have a real modification
563			// read the old record
564			$new =& $this->data;
565			unset($this->data);
566			$this->read($new['tr_id']);
567			$old =& $this->data;
568			unset($this->data);
569			$this->data =& $new;
570
571			if (!is_object($this->tracking)) $this->tracking = new tracker_tracking($this);
572			$changed = $this->tracking->changed_fields($new, $old);
573			// Avoid saving the entry when the same entry has been opened and modified by someone else
574			if (is_array($changed) && $new['tr_modified'] != $old['tr_modified']) return 'tr_modified';
575			//error_log(__METHOD__.__LINE__.' ReplyMessage:'.$this->data['reply_message'].' Mode:'.$this->data['tr_edit_mode'].' Config:'.$this->htmledit);
576			$testReply = $this->data['reply_message'];
577			if ($this->htmledit && isset($this->data['reply_message']) && !empty($this->data['reply_message']))
578			{
579				$testReply = trim(Api\Mail\Html::convertHTMLToText(Api\Html::purify($this->data['reply_message']), false, true, true));
580			}
581			//error_log(__METHOD__.__LINE__.' TestReplyMessage:'.$testReply);
582			if (!$changed && !((isset($this->data['reply_message']) && !empty($this->data['reply_message']) && !empty($testReply)) ||
583				(isset($this->data['canned_response']) && !empty($this->data['canned_response']))))
584			{
585				//error_log(__METHOD__.__LINE__."  no change --> no save needed");
586				return false;
587			}
588			// Check for modifying field without access
589			$readonlys = $this->readonlys_from_acl();
590			foreach($changed as $field)
591			{
592				if ($readonlys[$field])
593				{
594					//error_log(__METHOD__.__LINE__.' Field:'.$field.'->'.array2string($readonlys).function_backtrace());
595					return $field;
596				}
597			}
598
599			// Auto-assign if category changed & noone assigned
600			if ($this->data['cat_id'] && $this->data['cat_id'] != $old['cat_id'] && !$this->data['tr_assigned'])
601			{
602				$this->autoassign();
603			}
604
605			// Changes mark the ticket unseen for everbody but the current
606			// user if the ticket wasn't closed at the same time
607			if (!in_array($this->data['tr_status'],$this->get_tracker_stati(null, true)))
608			{
609				$seen = array();
610				$this->data['tr_seen'] = unserialize($this->data['tr_seen']);
611
612				// This only matters if no other changes have been made
613				if($this->data['reply_visible'] && empty($changed))
614				{
615					// Keep those that can't see the comment
616					$seen = array_intersect($this->data['tr_seen'], array_keys(array_diff(
617						$this->get_staff($this->data['tracker_id'], 2, 'users'),
618						$this->get_staff($this->data['tracker_id'], 2, 'technicians')
619					)));
620				}
621				$seen[] = $this->user;
622				$this->data['tr_seen'] = serialize($seen);
623			}
624			$this->data['tr_modified'] = $this->now;
625			$this->data['tr_modifier'] = $this->user;
626			$changed[] = 'tr_modified';
627
628			// set close-date if status is closed and not yet set
629			if (in_array($this->data['tr_status'],array_keys($this->get_tracker_stati(null, true))) &&
630				is_null($this->data['tr_closed']))
631			{
632				$this->data['tr_closed'] = $this->now;
633				$changed[] = 'tr_closed';
634			}
635			// unset closed date, if item is re-opend
636			if (!in_array($this->data['tr_status'],array_keys($this->get_tracker_stati(null, true))) &&
637				!is_null($this->data['tr_closed']))
638			{
639				$this->data['tr_closed'] = null;
640				$changed[] = 'tr_closed';
641			}
642			if (($this->data['reply_message'] && !empty($testReply)) || $this->data['canned_response'])
643			{
644				if ($this->data['canned_response'])
645				{
646					$this->data['reply_message'] = $this->get_canned_response($this->data['canned_response']).
647						($this->data['reply_message'] ? "\n\n".$this->data['reply_message'] : '');
648				}
649				$this->data['reply_created'] = (isset($this->data['reply_created'])&&!empty($this->data['reply_created'])?$this->data['reply_created']:$this->now);
650				$this->data['reply_creator'] = $this->user;
651
652				// replies set status pending back to open
653				if (($this->data['old_status'] == self::STATUS_PENDING && $this->data['old_status'] == $this->data['tr_status']) ||
654					(($this->comment_reopens || !property_exists($this, 'comment_reopens')) && $this->is_closed_status($this->data['old_status']) && $this->data['old_status'] == $this->data['tr_status']))
655				{
656					$this->data['tr_status'] = self::STATUS_OPEN;
657				}
658			}
659			else
660			{
661				if (isset($this->data['reply_message'])) unset($this->data['reply_message']);
662				if (isset($this->data['canned_response'])) unset($this->data['canned_response']);
663			}
664
665			// Reset escalation flags on variable fields (comment, modified, etc.)
666			$esc = new tracker_escalations();
667			$esc->reset($this->data, $changed);
668		}
669		if (!($err = parent::save()))
670		{
671			// try to resolve inline images which are not already resolved by mail_integration,
672			// like images from mailhandling or comments.
673			$replaced = false;
674			foreach((array)$this->data['link_to']['to_id'] as $link)
675			{
676				if (is_array($link) && !empty($link['id']['cid']))
677				{
678					$link_callback = function($cid) use($link) {
679						if ($link['id']['cid'] == $cid)
680						{
681							return Api\Egw::link(Api\Vfs::download_url(Api\Link::vfs_path('tracker', $this->data['tr_id'], Api\Vfs::basename($link['id']['name']))));
682						}
683						else
684						{
685							return "cid:".$cid;
686						}
687					};
688					foreach(array('src','url','background') as $type)
689					{
690						$this->data['tr_description'] = mail_ui::resolve_inline_image_byType($this->data['tr_description'], null, null, null, $type, $link_callback);
691						$this->data['reply_message'] = mail_ui::resolve_inline_image_byType($this->data['reply_message'], null, null, null, $type, $link_callback);
692					}
693					$replaced = true;
694				}
695			}
696			if ($replaced)
697			{
698				$this->update (array(
699				'tr_description' => $this->data['tr_description'],
700				'reply_message' => $this->data['reply_message']
701				));
702			}
703
704			// create (and remove) links in custom fields
705			Api\Storage\Customfields::update_links('tracker',$this->data,$old,'tr_id');
706
707			// so other apps can update eg. their titles and the cached title gets unset
708			Link::notify_update('tracker',$this->data['tr_id'],$this->data);
709
710			if (!is_object($this->tracking))
711			{
712				$this->tracking = new tracker_tracking($this);
713			}
714			if($this->prefs['notify_own_modification'])
715			{
716				$this->tracking->notify_current_user = true;
717			}
718			$this->tracking->html_content_allow = true;
719			$notification_copy = $this->notification[$this->data['tr_tracker']]['copy'] ?: $this->notification[0]['copy'];
720			$no_notification = $autoreply['reply_text'] && !$notification_copy ? !($old) : $this->data['no_notifications'];
721			if (!$this->tracking->track($this->data,$old,$this->user,null,null,$no_notification))
722			{
723				return $err == 0 && empty($this->tracking->errors) || !is_array($this->tracking->errors)?
724					0:implode(', ',$this->tracking->errors);
725			}
726			if ($autoreply)
727			{
728				$this->tracking->autoreply($this->data,$autoreply,$old);
729			}
730		}
731		return $err;
732	}
733
734	/**
735	 * Get a list of all groups
736	 *
737	 * @param boolean $primary = false, when not ACL to change the group, return primary group only on new tickets
738	 * @return array with gid => group-name pairs
739	 */
740	function &get_groups($primary=false)
741	{
742		static $groups = null;
743		static $primary_group = null;
744
745		if($primary)
746		{
747			if (isset($primary_group))
748			{
749				return $primary_group;
750			}
751		}
752		else
753		{
754			if(isset($groups))
755			{
756				return $groups;
757			}
758		}
759
760		$group_list = $GLOBALS['egw']->accounts->search(array('type' => 'groups', 'order' => 'account_lid', 'sort' => 'ASC'));
761		foreach($group_list as $gid)
762		{
763			$groups[$gid['account_id']] = $gid['account_lid'];
764		}
765		$primary_group[$GLOBALS['egw']->accounts->data['account_primary_group']] = $groups[$GLOBALS['egw']->accounts->data['account_primary_group']];
766
767		return ($primary ? $primary_group : $groups);
768	}
769
770	/**
771	 * Get the staff (technicians or admins) of a tracker
772	 *
773	 * @param int $tracker tracker-id or 0, 0 = staff of all trackers!
774	 * @param int $return_groups = 2 0=users, 1=groups+users, 2=users+groups
775	 * @param string $what = 'technicians' technicians=technicians (incl. admins), admins=only admins, users=only users
776	 * @return array with uid => user-name pairs
777	 */
778	function &get_staff($tracker,$return_groups=2,$what='technicians')
779	{
780		static $staff_cache = null;
781
782		//echo "botracker::get_staff($tracker,$return_groups,$what)".function_backtrace()."<br>";
783		//error_log(__METHOD__.__LINE__.array2string($tracker));
784		// some caching
785		$r = 0;
786		$rv = array();
787		foreach ((array)$tracker as $track)
788		{
789			if (!empty($tracker) && isset($staff_cache[$track]) && isset($staff_cache[$track][(int)$return_groups]) &&
790				isset($staff_cache[$track][(int)$return_groups][$what]))
791			{
792				$r++;
793				//echo "from cache"; _debug_array($staff_cache[$tracker][$return_groups][$what]);
794				$rv = $rv+$staff_cache[$track][(int)$return_groups][$what];
795			}
796		}
797		if (!empty($rv) && $r==count((array)$tracker)) return $rv;
798
799		$staff = array();
800		if (is_array($tracker))
801		{
802			$_tracker = $tracker;
803			array_unshift($_tracker,0);
804		}
805		else
806		{
807			$_tracker = array(0,$tracker);
808		}
809
810		switch($what)
811		{
812			case 'users':
813			case 'usersANDtechnicians':
814				if (is_null($this->users) || $this->users==='NULL') $this->users = array();
815				foreach($tracker ? $_tracker : array_keys($this->users) as $t)
816				{
817					if (is_array($this->users[$t])) $staff = array_merge($staff,$this->users[$t]);
818				}
819				if ($what == 'users') break;
820			case 'technicians':
821				if (is_null($this->technicians) || $this->technicians==='NULL') $this->technicians = array();
822				foreach($tracker ? $_tracker : array_keys($this->technicians) as $t)
823				{
824					if (is_array($this->technicians[$t])) $staff = array_merge($staff,$this->technicians[$t]);
825				}
826				// fall through, as technicians include admins
827			case 'admins':
828				if (is_null($this->admins) || $this->admins==='NULL') $this->admins = array();
829				foreach($tracker ? $_tracker : array_keys($this->admins) as $t)
830				{
831					if (is_array($this->admins[$t])) $staff = array_merge($staff,$this->admins[$t]);
832				}
833				break;
834		}
835
836		// split users and groups and resolve the groups into there users
837		$users = $groups = array();
838		foreach(array_unique($staff) as $uid)
839		{
840			if ($GLOBALS['egw']->accounts->get_type($uid) == 'g')
841			{
842				if ($return_groups) $groups[(string)$uid] = Api\Accounts::username($uid);
843				foreach((array)$GLOBALS['egw']->accounts->members($uid,true) as $u)
844				{
845					if (!isset($users[$u])) $users[$u] = Api\Accounts::username($u);
846				}
847			}
848			else // users
849			{
850				if (!isset($users[$uid])) $users[$uid] = Api\Accounts::username($uid);
851			}
852		}
853		// sort alphabetic
854		natcasesort($users);
855		natcasesort($groups);
856
857		// groups or users first
858		$staff_sorted = $this->allow_assign_groups == 1 ? $groups : $users;
859
860		if ($this->allow_assign_groups)	// do we need a second one
861		{
862			foreach($this->allow_assign_groups == 1 ? $users : $groups as $uid => $label)
863			{
864				$staff_sorted[$uid] = $label;
865			}
866		}
867		//_debug_array($staff);
868		if (!is_array($tracker)) $staff_cache[$tracker][(int)$return_groups][$what] = $staff_sorted;
869
870		return $staff_sorted;
871	}
872
873	/**
874	 * Check if a user (default current user) is an admin for the given tracker
875	 *
876	 * @param int $tracker ID of tracker
877	 * @param int $user = null ID of user, default current user $this->user
878	 * @param boolean $checkGivenUser = false flag to force the check If the given User is admin, no matter if $this->user=0
879	 * @return boolean
880	 */
881	function is_admin($tracker,$user=null,$checkGivenUser=false)
882	{
883		if (is_null($user)) $user = $this->user;
884
885		$admins =& $this->get_staff($tracker,0,'admins');
886		// evaluate $checkGivenUser flag to force the check If the given User is admin, no matter if $this->user=0
887		// this is used and needed to control (email)notification on close-pending
888		if ($checkGivenUser)
889		{
890			return isset($admins[$user]);
891		}
892		return $this->user===0 || isset($admins[$user]); // this->user is set to 0 by close_pending
893	}
894
895	/**
896	 * Check if a user (default current user) is an technichan for the given tracker
897	 *
898	 * @param int $tracker ID of tracker
899	 * @param int $user=null ID of user, default current user $this->user
900	 * @return boolean
901	 */
902	function is_technician($tracker,$user=null,$checkgroups=false)
903	{
904		if (is_null($user)) $user = $this->user;
905
906		$technicians =& $this->get_staff($tracker,$checkgroups ? 2 : 0,'technicians');
907
908		return isset($technicians[$user]);
909	}
910
911	/**
912	 * Check if a user (default current user) is an user for the given tracker
913	 *
914	 * If queue ACL access is NOT enabled, we return is_tracker_user() (user is non-anonymous and has tracker run-rights)
915	 *
916	 * @param int $tracker ID of tracker
917	 * @param int $user = null ID of user, default current user $this->user
918	 * @return boolean
919	 */
920	function is_user($tracker,$user=null)
921	{
922		if (is_null($user)) $user = $this->user;
923
924		$users =& $this->get_staff($tracker,0,'users');
925
926		return isset($users[$user]);
927	}
928
929	/**
930	 * Check if a user (default current user) is staff member for the given tracker
931	 *
932	 * @param int $tracker ID of tracker
933	 * @param int $user = null ID of user, default current user $this->user
934	 * @return boolean
935	 */
936	function is_staff($tracker,$user=null)
937	{
938		if (is_null($user)) $user = $this->user;
939
940		return ($this->is_technician($tracker,$user) || $this->is_admin($tracker,$user));
941	}
942
943	/**
944	 * Check if a user (default current user) is anonymous
945	 *
946	 * @param int $user = null ID of user, default current user $this->user
947	 * @return boolean
948	 */
949	function is_anonymous($user=null)
950	{
951		static $cache = array();	// some caching to not read Acl multiple times from the database ($user != $this->user)
952
953		if (!$user) $user = $this->user;
954
955		$anonymous =& $cache[$user];
956
957		if (!isset($anonymous))
958		{
959			if ($user == $this->user)
960			{
961				$anonymous = $GLOBALS['egw']->acl->check('anonymous',1,'phpgwapi');
962			}
963			else
964			{
965				$rights = $GLOBALS['egw']->acl->get_all_location_rights($user,'phpgwapi',$use_memberships=false);
966				$anonymous = (boolean)$rights['anonymous'];
967			}
968		}
969		return $anonymous;
970	}
971
972	/**
973	 * Check if a user (default current user) is a non-anoymous user with run-rights for tracker
974	 *
975	 * @param int $user = null ID of user, default current user $this->user
976	 * @return boolean
977	 */
978	function is_tracker_user($user=null)
979	{
980		static $cache = array();	// some caching to not read Acl multiple times from the database ($user != $this->user)
981
982		if (is_null($user)) $user = $this->user;
983
984		$is_user =& $cache[$user];
985
986		if (!isset($is_user))
987		{
988			if ($this->is_anonymous($user))
989			{
990				$reason = 'anonymous';
991				$is_user = false;
992			}
993			elseif ($user == $this->user)
994			{
995				$is_user = isset($GLOBALS['egw_info']['user']['apps']['tracker']);
996				$reason = 'egw_info[user][apps][tracker] is '.(!$is_user ? 'NOT ' : '').'set';
997			}
998			else
999			{
1000				$rights = $GLOBALS['egw']->acl->get_all_location_rights($user,'tracker',$use_memberships=true);
1001				$is_user = (boolean)$rights['run'];
1002				$reason = 'has '.(!$is_user ? 'NO' : '').'run rights';
1003			}
1004		}
1005		//error_log(__METHOD__."($user) this->user=$this->user returning ($reason) ".array2string($is_user));
1006		return $is_user;
1007	}
1008
1009	/**
1010	 * Check if given or current ticket is private and user is not creator, assignee or admin
1011	 *
1012	 * @param array $data = null array with ticket or null for $this->data
1013	 * @param int $user = null account_id or null for current user
1014	 * @return boolean true = deny access to private ticket, false grant access (ticket not private or access allowed)
1015	 */
1016	function deny_private(array $data=null,$user=null)
1017	{
1018		if (!$user) $user = $this->user;
1019		if (!$data) $data = $this->data;
1020		$memberships = $GLOBALS['egw']->accounts->memberships($user, true);
1021		$memberships[] = $user;
1022
1023		return $data['tr_private'] && !($user == $data['tr_creator'] || $this->is_admin($data['tr_tracker'],$user) ||
1024			$data['tr_assigned'] && array_intersect($memberships, $data['tr_assigned']));
1025	}
1026
1027	/**
1028	 * Check what rights the current user has on a given or the current tracker item ($this->data) or a given tracker
1029	 *
1030	 * @param int $needed or'ed together: TRACKER_ADMIN|TRACKER_TECHNICIAN|TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE
1031	 * @param int $check_only_tracker = null should only the given tracker be checked and NO $this->data specific checks be performed, default no
1032	 * @param int|array $data = null array with tracker item, integer tr_id or default null for loaded tracker item ($this->tracker)
1033	 * @param int $user = null for which user to check, default current user
1034	 * @param string $name = null something to put in error_log
1035	 * @return boolean true if user has the $needed rights, false otherwise
1036	 */
1037	function check_rights($needed,$check_only_tracker=null,$data=null,$user=null,$name=null)
1038	{
1039		if (!$user) $user = $this->user;
1040
1041		if (!$data)
1042		{
1043			$data = $this->data;
1044		}
1045		elseif(!is_array($data))
1046		{
1047			$backup = $this->data;
1048			if (!($data = $this->read(array('tr_id' => $data))))
1049			{
1050				$access = false;
1051				$line = __LINE__;
1052			}
1053			$this->data = $backup;
1054		}
1055		$tracker = $check_only_tracker ? $check_only_tracker : $data['tr_tracker'];
1056
1057		if (isset($access))
1058		{
1059			// nothing to do, already set
1060		}
1061		elseif (!$needed)
1062		{
1063			$access = false;
1064			$line = __LINE__;
1065		}
1066		// private tickets are only visible to creator, assignee and admins
1067		elseif(!$check_only_tracker && $this->deny_private($data,$user))
1068		{
1069			$access = false;
1070			$line = __LINE__.' (private)';
1071		}
1072		elseif ($needed & TRACKER_EVERYBODY)
1073		{
1074			$access = true;
1075			$line = __LINE__;
1076		}
1077		// item creator
1078		elseif (!$check_only_tracker && $needed & TRACKER_ITEM_CREATOR && $user == $data['tr_creator'])
1079		{
1080			$access = true;
1081			$line = __LINE__;
1082		}
1083		// item group
1084		elseif (!$check_only_tracker && $needed & TRACKER_ITEM_GROUP &&
1085			($memberships = $GLOBALS['egw']->accounts->memberships($user,true)) && in_array($data['tr_group'],$memberships))
1086		{
1087			$access = true;
1088			$line = __LINE__;
1089		}
1090		// tracker user
1091		elseif ($needed & TRACKER_USER && $this->is_tracker_user($user))
1092		{
1093			$access = true;
1094			$line = __LINE__;
1095		}
1096		// tracker admins and technicians
1097		elseif ($tracker)
1098		{
1099			if ($needed & TRACKER_ADMIN && $this->is_admin($tracker,$user))
1100			{
1101				$access = true;
1102				$line = __LINE__;
1103			}
1104			elseif ($needed & TRACKER_TECHNICIAN && $this->is_technician($tracker,$user))
1105			{
1106				$access = true;
1107				$line = __LINE__;
1108			}
1109		}
1110		if (isset($access))
1111		{
1112			// nothing to do, already set
1113		}
1114		// new items: everyone is the owner of new items
1115		elseif (!$check_only_tracker && !$data['tr_id'])
1116		{
1117			$access = !!($needed & (TRACKER_ITEM_CREATOR|TRACKER_ITEM_NEW));
1118			$line = __LINE__;
1119		}
1120		// assignee
1121		elseif (!$check_only_tracker && ($needed & TRACKER_ITEM_ASSIGNEE) && $data['tr_assigned'])
1122		{
1123			foreach((array)$data['tr_assigned'] as $assignee)
1124			{
1125				if ($user == $assignee)
1126				{
1127					$access = true;
1128					$line = __LINE__;
1129					break;
1130				}
1131				// group assinged
1132				if ($this->allow_assign_groups && $assignee < 0)
1133				{
1134					if (($members = $GLOBALS['egw']->accounts->members($assignee,true)) && in_array($user,$members))
1135					{
1136						$access = true;
1137						$line = __LINE__;
1138						break;
1139					}
1140				}
1141			}
1142		}
1143		if (!isset($access))
1144		{
1145			$access = false;
1146			$line = __LINE__;
1147		}
1148		//error_log(__METHOD__."($needed, $check_only_tracker, tr_id=$data[tr_id], user=$user) '$name' returning in $line ".array2string($access).(!$needed ? ': '.function_backtrace() : ''));
1149		unset($name);
1150		return $access;
1151	}
1152
1153	/**
1154	 * Check access to the file store
1155	 *
1156	 * We need to map Tracker ACL to read or write access of the filestore:
1157	 * - read access: a non-anonymous Tracker user, plus beeing able to read the tracker item
1158	 * - write access: user is allowed to upload files or link with other entries
1159	 *
1160	 * @param int|array $id id of entry or entry array
1161	 * @param int $check Acl::READ for read and Acl::EDIT for write or delete access
1162	 * @param string $rel_path = null currently not used in Tracker
1163	 * @param int $user = null for which user to check, default current user
1164	 * @return boolean true if access is granted or false otherwise
1165	 */
1166	function file_access($id,$check,$rel_path=null,$user=null)
1167	{
1168		unset($rel_path);	// unused, but required by function signature
1169		static $cache = array();	// as tracker does NOT cache read items, we run a cache here to not query items multiple times
1170
1171		if (!$user) $user = $this->user;
1172
1173		if (!is_array($id))
1174		{
1175			$access =& $cache[$user][(int)$id][$check];
1176		}
1177		if (!isset($access))
1178		{
1179			$needed = $check == Acl::READ ? TRACKER_USER : $this->field_acl['link_to'];
1180			$name = 'file_access '.($check == Acl::READ ? 'read' : 'write');
1181
1182			$access = $this->check_rights($needed,null,$id,$user,$name);
1183		}
1184		//error_log(__METHOD__."($id,$check,'$rel_path',$user) returning ".array2string($access));
1185		return $access;
1186	}
1187
1188	/**
1189	 * Check if users is allowed to vote and has not already voted
1190	 *
1191	 * @param int $tr_id = null tracker-id, default current tracker-item ($this->data)
1192	 * @return int|boolean true for no rights, timestamp voted or null
1193	 */
1194	function check_vote($tr_id=null)
1195	{
1196		if (is_null($tr_id)) $tr_id = $this->data['tr_id'];
1197
1198		if (!$tr_id || !$this->check_rights($this->field_acl['vote'],null,null,null,'vote')) return true;
1199
1200		if ($this->is_anonymous())
1201		{
1202			$ip = $_SERVER['REMOTE_ADDR'].(isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? ':'.$_SERVER['HTTP_X_FORWARDED_FOR'] : '');
1203		}
1204		if (($time = parent::check_vote($tr_id,$this->user,$ip)))
1205		{
1206			$time += $this->tz_offset_s;
1207		}
1208		return $time;
1209	}
1210
1211	/**
1212	 * Cast vote for given tracker-item
1213	 *
1214	 * @param int $tr_id = null tracker-id, default current tracker-item ($this->data)
1215	 * @return boolean true = vote casted, false=already voted before
1216	 */
1217	function cast_vote($tr_id=null)
1218	{
1219		if (is_null($tr_id)) $tr_id = $this->data['tr_id'];
1220
1221		if ($this->check_vote($tr_id)) return false;
1222
1223		$ip = $_SERVER['REMOTE_ADDR'].(isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? ':'.$_SERVER['HTTP_X_FORWARDED_FOR'] : '');
1224
1225		return parent::cast_vote($tr_id,$this->user,$ip);
1226	}
1227
1228	/**
1229	 * Get tracker specific labels: tracker, version, categorie
1230	 *
1231	 * The labels are saved as categories and can be tracker specific (sub-cat of the tracker) or for all trackers.
1232	 * The "cat_data" column stores if a tracker-cat is a "tracker", "version", "cat" or empty.
1233	 * Labels need to be either tracker specific or global and NOT in denyglobal.
1234	 *
1235	 * @param string $type = 'tracker' 'tracker', 'version', 'cat', 'resolution'
1236	 * @param int $tracker = null tracker to use or null to use $this->data['tr_tracker']
1237	 * @param int &$default = null on return default, if it is set
1238	 */
1239	function get_tracker_labels($type='tracker', $tracker=null, &$default=null)
1240	{
1241		if (is_null($this->all_cats))
1242		{
1243			if (!isset($GLOBALS['egw']->categories))
1244			{
1245				$GLOBALS['egw']->categories = new Api\Categories($this->user,'tracker');
1246			}
1247			if (isset($GLOBALS['egw']->categories) && $GLOBALS['egw']->categories->app_name == 'tracker')
1248			{
1249				$cats = $GLOBALS['egw']->categories;
1250			}
1251			else
1252			{
1253				$cats = new Api\Categories($this->user,'tracker');
1254			}
1255			$this->all_cats = $cats->return_array('all',0,false);
1256			if (!is_array($this->all_cats)) $this->all_cats = array();
1257			//_debug_array($this->all_cats);
1258		}
1259		if (!$tracker) $tracker = $this->data['tr_tracker'];
1260
1261		$labels = array();
1262		$default = $none_id = null;
1263		foreach($this->all_cats as $cat)
1264		{
1265			$cat_data =& $cat['data'];
1266			$cat_type = isset($cat_data['type']) ? $cat_data['type'] : 'cat';
1267			if ($cat_type == $type &&	// cats need to be either tracker specific or global and tracker NOT in denyglobal
1268				(!$cat['parent'] && !($tracker && in_array($tracker, (array)$cat_data['denyglobal'])) ||
1269				$cat['main'] == $tracker && $cat['id'] != $tracker))
1270			{
1271				$labels[$cat['id']] = $cat['name'];
1272				// set default with precedence to tracker specific one
1273				if (is_array($cat_data) && isset($cat_data['isdefault']) && $cat_data['isdefault'] && (!isset($default) || $cat['main'] == $tracker))
1274				{
1275					$default = $cat['id'];
1276				}
1277				if ($cat['name'] == 'None' && (!isset($none_id) || $cat['main'] == $tracker))
1278				{
1279					$none_id = $cat['id'];
1280				}
1281			}
1282		}
1283		// if no default specified, fall back to id of cat with name "None"
1284		if (!isset($default) && isset($none_id))
1285		{
1286			$default = $none_id;
1287		}
1288
1289		if ($type == 'tracker' && !$GLOBALS['egw_info']['user']['apps']['admin'] && $this->enabled_queue_acl_access)
1290		{
1291			foreach (array_keys($labels) as $tracker_id)
1292			{
1293				if (!$this->is_user($tracker_id,$this->user) && !$this->is_technician($tracker_id,$this->user) && !$this->is_admin($tracker_id,$this->user))
1294				{
1295					unset($labels[$tracker_id]);
1296				}
1297			}
1298		}
1299
1300		$user_cat_default = $GLOBALS['egw_info']['user']['preferences']['tracker'][$tracker.'_cat_default'];
1301		if ($type == 'cat' && $user_cat_default && $labels[$user_cat_default])
1302		{
1303			$default = $user_cat_default;
1304		}
1305
1306		natcasesort($labels);
1307
1308		//echo "botracker::get_tracker_labels('$type',$tracker)"; _debug_array($labels);
1309		return $labels;
1310	}
1311
1312	/**
1313	 * Get tracker specific stati
1314	 *
1315	 * There's a bunch of pre-defined stati, plus statis stored as labels, which can be per tracker
1316	 *
1317	 * @param int $tracker = null tracker to use of null to use $this->data['tr_tracker']
1318	 * @param boolean $closed True to get 'closed' stati, false to get open stati, null for all
1319	 */
1320	function get_tracker_stati($tracker=null, $closed = null)
1321	{
1322		$stati = self::$stati + $this->get_tracker_labels('stati',$tracker);
1323		if($closed === null) return $stati;
1324
1325		$filtered = (!$closed ? array(self::STATUS_OPEN    => 'Open(status)') : array(self::STATUS_CLOSED  => 'Closed'));
1326
1327		foreach($stati as $id => $name)
1328		{
1329			if($id > 0 && $data = $GLOBALS['egw']->categories->id2name($id,'data'))
1330			{
1331				if($closed == $data['closed']) $filtered[$id] = $name;
1332			}
1333		}
1334		return $filtered;
1335	}
1336
1337	/**
1338	 * Check if the given status is a closed status
1339	 *
1340	 * @param int $status ID of a status
1341	 * @param int [$tracker] Optional tracker queue ID.  If not provided, current tracker
1342	 *	will be used
1343	 */
1344	public function is_closed_status($status, $tracker = null)
1345	{
1346		$stati = $this->get_tracker_stati($tracker, true);
1347		return (array_key_exists($status, $stati));
1348	}
1349	/**
1350	 * Get tracker and category specific priorities
1351	 *
1352	 * Currently priorities are a fixed list with numeric values from 1 to 9 as keys and customizable labels
1353	 *
1354	 * @param int $tracker = null tracker to use or null to use tracker unspecific priorities
1355	 * @param int $cat_id = null category to use or null to use categorie unspecific priorities
1356	 * @param boolean $remove_empty = true should empty labels be displayed, default no
1357	 * @param int& $default =null on return default, if it is set
1358	 * @return array
1359	 */
1360	function get_tracker_priorities($tracker=null,$cat_id=null,$remove_empty=true, &$default = null)
1361	{
1362		if(!$tracker)
1363		{
1364			$tracker = 0;
1365		}
1366		if (isset($this->priorities[$tracker.'-'.$cat_id]))
1367		{
1368			$prios = $this->priorities[$tracker.'-'.$cat_id];
1369		}
1370		elseif (isset($this->priorities[$tracker]))
1371		{
1372			$prios = $this->priorities[$tracker];
1373		}
1374		elseif (isset($this->priorities['0-'.$cat_id]))
1375		{
1376			$prios = $this->priorities['0-'.$cat_id];
1377		}
1378		elseif(isset($this->priorities[0]))
1379		{
1380			$prios = $this->priorities[0];
1381		}
1382		else
1383		{
1384			$prios = self::$stock_priorities;
1385		}
1386		$default = $prios['default'] ? $prios['default'] : 5;
1387		unset($prios['default']);
1388
1389		if ($remove_empty)
1390		{
1391			foreach($prios as $key => $val)
1392			{
1393				if ($val === '') unset($prios[$key]);
1394			}
1395		}
1396		//echo "<p>".__METHOD__."(tracker=$tracker,$remove_empty) prios=".array2string($prios)."</p>\n";
1397		return $prios;
1398	}
1399
1400	/**
1401	 * Set the default category for the given tracker
1402	 *
1403	 * @param int $tracker
1404	 * @param int $category
1405	 * @param string $type
1406	 */
1407	public function set_default_category($tracker = null, $category = false, $type = 'cat')
1408	{
1409		foreach($this->all_cats as $cat)
1410		{
1411			$cat_data =& $cat['data'];
1412			$cat_type = isset($cat_data['type']) ? $cat_data['type'] : 'cat';
1413			if ($cat_type == $type &&	// cats need to be tracker specific
1414				($cat['main'] == $tracker && $cat['id'] != $tracker ||
1415				!$tracker && !$cat['parent'] // or global
1416			))
1417			{
1418				if($cat['id'] == $category)
1419				{
1420					$cat_data['isdefault'] = true;
1421				}
1422				else
1423				{
1424					unset($cat_data['isdefault']);
1425				}
1426				$GLOBALS['egw']->categories->edit($cat);
1427			}
1428		}
1429		// Need to reset before they're available
1430		$this->all_cats = null;
1431		$this->load_config();
1432		$this->init();
1433	}
1434
1435	/**
1436	 * Check if the given tracker uses category specific priorities and eg. need to reload of user changes the cat
1437	 *
1438	 * @param int $tracker
1439	 * @return boolean
1440	 */
1441	function tracker_has_cat_specific_priorities($tracker)
1442	{
1443		if (!$this->priorities) return false;
1444
1445		$prefix = (int)$tracker.'-';
1446		$len = strlen($prefix);
1447		foreach(array_keys($this->priorities) as $key)
1448		{
1449			if (substr($key,0,$len) == $prefix || substr($key,0,2) == '0-') return true;
1450		}
1451		return false;
1452	}
1453
1454	/**
1455	 * Reload the labels (tracker, cats, versions, projects)
1456	 *
1457	 */
1458	function reload_labels()
1459	{
1460		unset($this->all_cats);
1461		$this->trackers = $this->get_tracker_labels();
1462	}
1463
1464	/**
1465	 * Get the canned response via it's id
1466	 *
1467	 * Canned responses are now saved in the the data array, as the description is limited to 255 chars, which is to small.
1468	 *
1469	 * @param int $id
1470	 * @return string|boolean string with the response or false if id not found
1471	 */
1472	function get_canned_response($id)
1473	{
1474		foreach($this->all_cats as $cat)
1475		{
1476			if ($cat['data']['type'] == 'response' && $cat['id'] == $id)
1477			{
1478				return $cat['data']['response'] ? $cat['data']['response'] : $cat['description'];
1479			}
1480		}
1481		return false;
1482	}
1483
1484	/**
1485	 * Try to autoassign to a new tracker item
1486	 *
1487	 * @return int|boolean account_id or false
1488	 */
1489	function autoassign()
1490	{
1491		foreach($this->all_cats as $cat)
1492		{
1493			if ($cat['id'] == $this->data['cat_id'])
1494			{
1495				$user = $cat['data']['autoassign'];
1496
1497				if ($user && $this->is_technician($this->data['tr_tracker'],$user,true))
1498				{
1499					return $this->data['tr_assigned'] = $user;
1500				}
1501			}
1502		}
1503		return false;
1504	}
1505
1506	/**
1507	 * get title for an tracker item identified by $entry
1508	 *
1509	 * Is called as hook to participate in the linking
1510	 *
1511	 * @param int|array $entry int ts_id or array with tracker item
1512	 * @return string|boolean string with title, null if tracker item not found, false if no perms to view it
1513	 */
1514	function link_title( $entry )
1515	{
1516		if (!is_array($entry))
1517		{
1518			$entry = $this->read( $entry );
1519		}
1520		if (!$entry)
1521		{
1522			return $entry;
1523		}
1524		return $this->trackers[$entry['tr_tracker']].' #'.$entry['tr_id'].': '.$entry['tr_summary'];
1525	}
1526
1527	/**
1528	 * get titles for multiple tracker items
1529	 *
1530	 * Is called as hook to participate in the linking
1531	 *
1532	 * @param array $ids array with tracker id's
1533	 * @return array with titles, see link_title
1534	 */
1535	function link_titles( $ids )
1536	{
1537		$titles = array();
1538		if (($tickets = $this->search(array('tr_id' => $ids),'tr_id,tr_tracker,tr_summary')))
1539		{
1540			foreach($tickets as $ticket)
1541			{
1542				$titles[$ticket['tr_id']] = $this->link_title($ticket);
1543			}
1544		}
1545		// we assume all not returned tickets are not readable by the user, as we notify Link about each deleted ticket
1546		foreach($ids as $id)
1547		{
1548			if (!isset($titles[$id])) $titles[$id] = false;
1549		}
1550		return $titles;
1551	}
1552
1553	/**
1554	 * query tracker for entries matching $pattern, we search only open entries
1555	 *
1556	 * Is called as hook to participate in the linking
1557	 *
1558	 * @param string $pattern pattern to search
1559	 * @param array $options Array of options for the search
1560	 * @return array with ts_id - title pairs of the matching entries
1561	 */
1562	function link_query( $pattern, Array &$options = array() )
1563	{
1564		$limit = false;
1565		$result = array();
1566		if($options['start'] || $options['num_rows']) {
1567			$limit = array($options['start'], $options['num_rows']);
1568		}
1569		$filter[]=array('tr_status != '. self::STATUS_DELETED);
1570		$filter['tr_tracker']=array_keys($this->trackers);
1571		foreach((array) $this->search($pattern,false,'tr_modified DESC','','%',false,'OR',$limit,$filter) as $item )
1572		{
1573			if ($item) $result[$item['tr_id']] = $this->link_title($item);
1574		}
1575		$options['total'] = $this->total;
1576		return $result;
1577	}
1578
1579	/**
1580	 * query rows for the nextmatch widget
1581	 *
1582	 * @param array $query with keys 'start', 'search', 'order', 'sort', 'col_filter'
1583	 *	For other keys like 'filter', 'cat_id' you have to reimplement this method in a derived class.
1584	 * @param array &$rows returned rows/competitions
1585	 * @param array &$readonlys eg. to disable buttons based on Acl, not use here, maybe in a derived class
1586	 * @param string $join = '' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or
1587	 *	"LEFT JOIN table2 ON (x=y)", Note: there's no quoting done on $join!
1588	 * @param boolean $need_full_no_count = false If true an unlimited query is run to determine the total number of rows, default false
1589	 * @return int total number of rows
1590	 */
1591	function get_rows(&$query,&$rows,&$readonlys,$join=true,$need_full_no_count=false,$only_keys=false,$extra_cols=array())
1592	{
1593		if($query['filter'])
1594		{
1595			$query['col_filter'][] = $this->date_filter($query['filter'],$query['startdate'],$query['enddate'],$query['order']);
1596		}
1597		return parent::get_rows($query,$rows,$readonlys,$join,$need_full_no_count,$only_keys,$extra_cols);
1598	}
1599
1600	/**
1601	 * Add a new tracker-queue
1602	 *
1603	 * @param string $name
1604	 * @param string $color
1605	 * @return int|boolean integer tracker-id on success or false otherwise
1606	 */
1607	function add_tracker($name, $color = '')
1608	{
1609		$cats = new Api\Categories(Api\Categories::GLOBAL_ACCOUNT,'tracker');	// global cat!
1610		if ($name && ($id = $cats->add(array(
1611			'name'   => $name,
1612			'descr'  => 'tracker',
1613			'data'   => serialize(array('type' => 'tracker', 'color' => $color)),
1614			'access' => 'public',
1615		))))
1616		{
1617			$this->trackers[$id] = $name;
1618
1619
1620			return $id;
1621		}
1622		return false;
1623	}
1624
1625	/**
1626	 * Change color for a tracker-queue
1627	 *
1628	 * @param type $tracker
1629	 * @param type $color
1630	 * @return boolean
1631	 */
1632	function change_color_tracker($tracker, $color)
1633	{
1634		$cats = new Api\Categories(Api\Categories::GLOBAL_ACCOUNT,'tracker');
1635		if ($tracker > 0 && ($data = $cats->read($tracker)))
1636		{
1637			$data['data']['color'] = $color;
1638			$cats->edit($data);
1639			return true;
1640		}
1641		return false;
1642	}
1643
1644	/**
1645	 * Rename a tracker-queue
1646	 *
1647	 * @param int $tracker
1648	 * @param string $name
1649	 * @return boolean true on success or false otherwise
1650	 */
1651	function rename_tracker($tracker,$name)
1652	{
1653		$cats = new Api\Categories(Api\Categories::GLOBAL_ACCOUNT,'tracker');
1654		if ($tracker > 0 && !empty($name) && ($data = $cats->read($tracker)))
1655		{
1656			if ($data['name'] != $name)
1657			{
1658				$data['name'] = $this->trackers[$tracker] = $name;
1659				$cats->edit($data);
1660			}
1661			return true;
1662		}
1663		return false;
1664	}
1665
1666	/**
1667	 * Delete a tracker include all items, categories, staff, ...
1668	 *
1669	 * @param int $tracker
1670	 * @return boolean true on success, false otherwise
1671	 */
1672	function delete_tracker($tracker)
1673	{
1674		if (!$tracker) return false;
1675
1676		if (!is_object($this->historylog))
1677		{
1678			$this->historylog = new Api\Storage\History('tracker');
1679		}
1680		$ids = $this->query_list($this->table_name.'.tr_id','',array('tr_tracker' => $tracker));
1681		if ($ids) $this->historylog->delete($ids);
1682
1683		$GLOBALS['egw']->categories->delete($tracker,true);
1684
1685		$this->reload_labels();
1686		unset($this->admins[$tracker]);
1687		unset($this->technicians[$tracker]);
1688		unset($this->users[$tracker]);
1689		$this->mailhandling[$tracker]['interval'] = 0; // Cancel async job
1690		$this->delete(array('tr_tracker' => $tracker));
1691		$this->save_config();
1692
1693		return true;
1694	}
1695
1696	/**
1697	 * Save the tracker configuration stored in various class-vars
1698	 */
1699	function save_config()
1700	{
1701		foreach($this->config_names as $name)
1702		{
1703			#echo "<p>calling Api\Config::save_value('$name','{$this->$name}','tracker')</p>\n";
1704			Api\Config::save_value($name,$this->$name,'tracker');
1705		}
1706		self::set_async_job($this->pending_close_days > 0);
1707
1708		$mailhandler = new tracker_mailhandler();
1709		foreach((array)$this->mailhandling as $queue_id => $handling) {
1710			$mailhandler->set_async_job($queue_id, $handling['interval']);
1711		}
1712	}
1713
1714	/**
1715	 * Load the tracker config into various class-vars
1716	 *
1717	 */
1718	function load_config()
1719	{
1720		$migrate_config = false;	// update old config-values, can be removed soon
1721		foreach((array)Api\Config::read('tracker') as $name => $value)
1722		{
1723			if (substr($name,0,13) == 'notification_')	// update old config-values, can be removed soon
1724			{
1725				$this->notification[0][substr($name,13)] = $value;
1726				Api\Config::save_value($name,null,'tracker');
1727				$migrate_config = true;
1728				continue;
1729			}
1730			$this->$name = $value;
1731		}
1732		if ($migrate_config)	// update old config-values, can be removed soon
1733		{
1734			foreach($this->notification as $name => $value)
1735			{
1736				Api\Config::save_value($name,$value,'tracker');
1737			}
1738		}
1739
1740		if (is_array($this->notification) && !$this->notification[0]['lang'])
1741		{
1742			$this->notification[0]['lang'] = $GLOBALS['egw']->preferences->default_prefs('common', 'lang');
1743		}
1744		foreach(array(
1745			'tr_summary'     => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1746			'tr_tracker'     => TRACKER_ITEM_NEW|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1747			'cat_id'         => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1748			'tr_version'     => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1749			'tr_status'      => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1750			'tr_description' => TRACKER_ITEM_NEW,
1751			'tr_creator'     => TRACKER_ADMIN,
1752			'tr_assigned'    => TRACKER_ITEM_CREATOR|TRACKER_ADMIN,
1753			'tr_private'     => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1754			'tr_budget'      => TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1755			'tr_resolution'  => TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1756			'tr_completion'  => TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1757			'tr_priority'    => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1758			'tr_startdate'   => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1759			'tr_duedate'     => TRACKER_ITEM_CREATOR|TRACKER_ADMIN,
1760			'tr_cc'			 => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1761			'tr_group'		 => TRACKER_TECHNICIAN|TRACKER_ADMIN,
1762			'customfields'   => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1763			// set automatic by botracker::save()
1764			'tr_id'          => 0,
1765			'tr_created'     => 0,
1766			'tr_modifier'    => 0,
1767			'tr_modified'    => 0,
1768			'tr_closed'      => 0,
1769			// pseudo fields used in edit
1770			'link_to'        => TRACKER_ITEM_CREATOR|TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1771			'canned_response' => TRACKER_ITEM_ASSIGNEE|TRACKER_ADMIN,
1772			'reply_message'  => TRACKER_USER,
1773			'edit_own_reply' => 0,
1774			'edit_reply'     => TRACKER_ADMIN|TRACKER_TECHNICIAN,
1775			'add'            => TRACKER_USER,
1776			'vote'           => TRACKER_EVERYBODY,	// TRACKER_USER for NO anon user
1777			'bounty'         => TRACKER_EVERYBODY,
1778			'no_notifications'	=> TRACKER_ITEM_ASSIGNEE|TRACKER_TECHNICIAN|TRACKER_ADMIN,
1779		) as $name => $value)
1780		{
1781			if (!isset($this->field_acl[$name])) $this->field_acl[$name] = $value;
1782		}
1783
1784		// Add date filters if using start/due dates
1785		if($this->show_dates)
1786		{
1787			$this->date_filters = array(
1788				'started'  => false,
1789				'upcoming' => false
1790			) + $this->date_filters;
1791		}
1792	}
1793
1794	/**
1795	 * Check if exist and if not start or stop an async job to close pending items
1796	 *
1797	 * @param boolean $start = true true=start, false=stop
1798	 */
1799	static function set_async_job($start=true)
1800	{
1801		//echo '<p>'.__METHOD__.'('.($start?'true':'false').")</p>\n";
1802
1803		$async = new Api\Asyncservice();
1804
1805		if ($start === !$async->read('tracker-close-pending'))
1806		{
1807			if ($start)
1808			{
1809				$async->set_timer(array('hour' => '*'),'tracker-close-pending','tracker.tracker_bo.close_pending',null);
1810			}
1811			else
1812			{
1813				$async->cancel_timer('tracker-close-pending');
1814			}
1815		}
1816	}
1817
1818	/**
1819	 * Close pending tracker items, which are not answered withing $this->pending_close_days days
1820	 */
1821	function close_pending()
1822	{
1823		$this->user = 0;	// we dont want to run under the id of the current or the user created the async job
1824
1825		if (($ids = $this->query_list('tr_id','tr_id',array(
1826			'tr_status' => self::STATUS_PENDING,
1827			'tr_modified < '.(time()-$this->pending_close_days*24*60*60),
1828		))))
1829		{
1830			if (($default_lang = $GLOBALS['egw']->preferences->default_prefs('common','lang')) &&	// load the system default language
1831				Api\Translation::$userlang != $default_lang)
1832			{
1833				$save_lang = $GLOBALS['egw_info']['user']['preferences']['common']['lang'];
1834				$GLOBALS['egw_info']['user']['preferences']['common']['lang'] = $default_lang;
1835				Api\Translation::init();
1836			}
1837			Api\Translation::add_app('tracker');
1838
1839			foreach($ids as $tr_id)
1840			{
1841				if ($this->read($tr_id))
1842				{
1843					$this->data['tr_status'] = self::STATUS_CLOSED;
1844					$this->data['reply_message'] = lang('This Tracker item was closed automatically by the system. It was previously set to a Pending status, and the original submitter did not respond within %1 days.',$this->pending_close_days);
1845					$this->save();
1846				}
1847			}
1848			if ($save_lang)
1849			{
1850				$GLOBALS['egw_info']['user']['preferences']['common']['lang'] = $save_lang;
1851				Api\Translation::init();
1852			}
1853		}
1854	}
1855
1856	/**
1857	 * Read bounties specified by the given keys
1858	 *
1859	 * Reimplement to convert to user-time
1860	 *
1861	 * @param array|int $keys array with key(s) or integer bounty-id
1862	 * @return array with bounties
1863	 */
1864	function read_bounties($keys)
1865	{
1866		if (!$this->allow_bounties) return array();
1867
1868		if (($bounties = parent::read_bounties($keys)))
1869		{
1870			foreach($bounties as $n => $bounty)
1871			{
1872				foreach(array('bounty_created','bounty_confirmed') as $name)
1873				{
1874					if ($bounty[$name]) $bounties[$n][$name] += $this->tz_offset_s;
1875				}
1876			}
1877		}
1878		return $bounties;
1879	}
1880
1881	/**
1882	 * Save or update a bounty
1883	 *
1884	 * @param array &$data
1885	 * @return int|boolean integer bounty_id or false on error
1886	 */
1887	function save_bounty(&$data)
1888	{
1889		if (!$this->allow_bounties) return false;
1890
1891		if (($new = !$data['bounty_id']))	// new bounty
1892		{
1893			if (!$data['bounty_amount'] || !$data['bounty_name'] || !$data['bounty_email']) return false;
1894
1895			$data['bounty_creator'] = $this->user;
1896			$data['bounty_created'] = $this->now;
1897			if (!$data['tr_id']) $data['tr_id'] = $this->data['tr_id'];
1898		}
1899		else
1900		{
1901			if (!$this->is_admin($this->data['tr_tracker']) ||
1902				!($bounties = $this->read_bounties(array('bounty_id' => $data['bounty_id']))))
1903			{
1904				return false;
1905			}
1906			$old = $bounties[0];
1907
1908			$data['bounty_confirmer'] = $this->user;
1909			$data['bounty_confirmed'] = $this->now;
1910		}
1911		// convert to server-time
1912		foreach(array('bounty_created','bounty_confirmed') as $name)
1913		{
1914			if ($data[$name]) $data[$name] -= $this->tz_offset_s;
1915		}
1916		if (($data['bounty_id'] = parent::save_bounty($data)))
1917		{
1918			$this->_bounty2history($data,$old);
1919		}
1920		// convert back to user-time
1921		foreach(array('bounty_created','bounty_confirmed') as $name)
1922		{
1923			if ($data[$name]) $data[$name] += $this->tz_offset_s;
1924		}
1925		return $data['bounty_id'];
1926	}
1927
1928	/**
1929	 * Delete a bounty, the bounty must not be confirmed and you must be an tracker-admin!
1930	 *
1931	 * @param int $id
1932	 * @return boolean true on success or false otherwise
1933	 */
1934	function delete_bounty($id)
1935	{
1936		//echo "<p>botracker::delete_bounty($id)</p>\n";
1937		if (!($bounties = $this->read_bounties(array('bounty_id' => $id))) ||
1938			$bounties[0]['bounty_confirmed'] || !$this->is_admin($this->data['tr_tracker']))
1939		{
1940			return false;
1941		}
1942		if (parent::delete_bounty($id))
1943		{
1944			$this->_bounty2history(null,$bounties[0]);
1945
1946			return true;
1947		}
1948		return false;
1949	}
1950
1951	/**
1952	 * Historylog a bounty
1953	 *
1954	 * @internal
1955	 * @param array $new new value
1956	 * @param array $old = null old value
1957	 */
1958	function _bounty2history($new,$old=null)
1959	{
1960		if (!is_object($this->historylog))
1961		{
1962			$this->historylog = new Api\Storage\History('tracker');
1963		}
1964		if (is_null($new) && $old)
1965		{
1966			$status = 'xb';	// bounty deleted
1967		}
1968		elseif ($new['bounty_confirmed'])
1969		{
1970			$status = 'Bo';	// bounty confirmed
1971		}
1972		else
1973		{
1974			$status = 'bo';	// bounty set
1975		}
1976		$this->historylog->add($status,$this->data['tr_id'],$this->_serialize_bounty($new),$this->_serialize_bounty($old));
1977	}
1978
1979	/**
1980	 * Serialize the bounty for the historylog
1981	 *
1982	 * @internal
1983	 * @param array $bounty
1984	 * @return string
1985	 */
1986	function _serialize_bounty($bounty)
1987	{
1988		return !is_array($bounty) ? $bounty : '#'.$bounty['bounty_id'].', '.$bounty['bounty_name'].' <'.$bounty['bounty_email'].
1989			'> ('.$GLOBALS['egw']->accounts->id2name($bounty['bounty_creator']).') '.
1990			$bounty['bounty_amount'].' '.$this->currency.($bounty['bounty_confirmed'] ? ' Ok' : '');
1991	}
1992
1993	/**
1994	 * Provide response data of get_ticketId to client-side
1995	 * JSON response to client with data = (int)ticket_id
1996	 * or 0 if there was no ticket registered for the given subject
1997	 *
1998	 * @param type $_subject
1999	 */
2000	function ajax_getTicketId($_subject='')
2001	{
2002		$response  = Api\Json\Response::get();
2003		$response->data($this->get_ticketId($_subject));
2004	}
2005
2006	/**
2007	 * Try to extract a ticket number from a subject line
2008	 *
2009	 * @param string the subjectline from the incoming message, may be modified when we find some id, but not matching available trackers
2010	 * @return int ticket ID, or 0 of no ticket ID was recognized
2011	 */
2012	function get_ticketId(&$subj='')
2013	{
2014		if (empty($subj))
2015		{
2016			return 0; // Don't bother...
2017		}
2018
2019		// The subject line is expected to be in the format:
2020		// [Re: |Fwd: |etc ]<Tracker name> #<id>: <Summary>
2021		// allow colon or dash to separate Id from summary, as our notifications use a dash (' - ') and not a colon (': ')
2022		$tr_data = null;
2023		if (!preg_match_all("/(.*)( #[0-9]+:? ?-? )(.*)$/",$subj, $tr_data) && !$tr_data[2])
2024		{
2025			return 0; //
2026		}
2027		if (strpos($tr_data[1][0],'#') !== false) // there is more than one part of the subject, that could be a tracker ID
2028		{
2029			// try once more, and modify the tr_data as we go for comparsion with tracker subject
2030			$buff = $tr_data;
2031			unset($tr_data);
2032			preg_match_all("/(.*)( #[0-9]+:? ?-? )(.*)$/",$buff[1][0], $tr_data);
2033			$tr_data[0][0] = $buff[0][0];
2034			$tr_data[3][0] = $tr_data[3][0].$buff[2][0].$buff[3][0];
2035		}
2036		$tr_id = null;
2037		$tracker_id = preg_match_all("/[0-9]+/",$tr_data[2][0], $tr_id) ? $tr_id[0][0] : null;
2038		if (!is_numeric($tracker_id)) return 0; // nothing found that looks like an ID
2039		//error_log(__METHOD__.array2string(array(0=>$tracker_id,1=>$subj)));
2040		$trackerData = $this->search(array('tr_id' => $tracker_id),'tr_summary');
2041		if (is_numeric($tracker_id) && empty($trackerData)) // we have a numeric ID, but we could not find it in our database, is it external?
2042		{
2043			// we modify the subject as external tracker ids mess up our recognition of tracker ids
2044			if ($tracker_id > 0) $subj = $tr_data[1][0].str_replace('#','ID:',$tr_data[2][0]).$tr_data[3][0];
2045			return 0;
2046		}
2047		// Use strncmp() here, since a Fwd might add a sqr bracket.
2048		if (strncmp(trim($trackerData[0]['tr_summary']), trim($tr_data[3][0]), strlen(trim($trackerData[0]['tr_summary']))) &&
2049				// Some mail apps might truncate long subjects, 72 seems to be the smallest
2050				// Those are OK if what remains matches
2051				strlen($tr_data[3][0]) <= 70 &&
2052				strpos(trim($tr_data[3][0]), trim($trackerData[0]['tr_summary'])) !== 0
2053		)
2054		{
2055			//_debug_array($trackerData);
2056			return 0; // Summary doesn't match. Should this be ok?
2057		}
2058		return $tracker_id;
2059	}
2060
2061	/**
2062	 * prepares the content of an email to be imported as tracker
2063	 *
2064	 * @author Klaus Leithoff <kl@stylite.de>
2065	 * @param array $_addresses array of addresses
2066	 *	- array (email,name)
2067	 * @param string $_subject
2068	 * @param string $_message
2069	 * @param array $_attachments
2070	 * @param string $_ticket_id ticket id
2071	 * @param int $_queue optional param to pass queue
2072	 * @return array $content array for tracker_ui
2073	 */
2074	function prepare_import_mail($_addresses, $_subject, $_message, $_attachments, $_ticket_id, $_queue = 0)
2075	{
2076		foreach((array)$_addresses as $address)
2077		{
2078			if (is_array($address) && isset($address['email']))
2079			{
2080				$emails[] =$address['email'];
2081			}
2082			else
2083			{
2084				$parsedAddresses = Api\Mail::parseAddressList($address);
2085				foreach($parsedAddresses as $i => $adr)
2086				{
2087					$emails[] = $adr->mailbox.'@'.$adr->host;
2088				}
2089			}
2090		}
2091
2092		$ticketId = $_ticket_id? $_ticket_id: $this->get_ticketId($_subject);
2093		//_debug_array('TickedId found:'.$ticketId);
2094		// we have to check if we know this ticket before proceeding
2095		if ($ticketId == 0)
2096		{
2097			$trackerentry = array(
2098				'tr_id' => 0,
2099				'tr_cc' => implode(', ',$emails),
2100				'tr_summary' => $_subject,
2101				'tr_description' => $_message,
2102				'referer' => false,
2103				'popup' => true,
2104				'link_to' => array(
2105					'to_app' => 'tracker',
2106					'to_id' => 0,
2107				),
2108			);
2109			// find the addressbookentry to link with
2110			$addressbook = new Api\Contacts();
2111			$contacts = array();
2112			$filter = array();
2113			foreach ($emails as $mailadr)
2114			{
2115				// for LDAP, AD or UCS, check if the email belongs to an account first
2116				if ($GLOBALS['egw']->accounts->name2id($mailadr, 'account_email'))
2117				{
2118					$filter['owner'] = 0;
2119				}
2120				else
2121				{
2122					unset($filter['owner']);
2123				}
2124				$contacts = array_merge($contacts,(array)$addressbook->search(
2125					array(
2126						'email' => $mailadr,
2127						'email_home' => $mailadr
2128					),'contact_id,contact_email,contact_email_home,egw_addressbook.account_id as account_id','','','',false,'OR',false,$filter,'',false));
2129			}
2130			if (!$contacts || !is_array($contacts) || !is_array($contacts[0]))
2131			{
2132				$trackerentry['msg'] = lang('Attention: No Contact with address %1 found.',implode(', ',$emails));
2133				$trackerentry['tr_creator'] = $this->user;	// use current user as creator instead
2134			}
2135			else
2136			{
2137				// create as "ordinary" links and try to find/set the creator according to the sender (if it is a valid user to the all queues (tracker=0))
2138				foreach ($contacts as $contact)
2139				{
2140					Link::link('tracker',$trackerentry['link_to']['to_id'],'addressbook',(isset($contact['contact_id'])?$contact['contact_id']:$contact['id']));
2141					//error_log(__METHOD__.__LINE__.'linking ->'.array2string($trackerentry['link_to']['to_id']).' Status:'.$gg.': for'.(isset($contact['contact_id'])?$contact['contact_id']:$contact['id']));
2142					$staff = $this->get_staff($tracker=0,0,'usersANDtechnicians');
2143					if (empty($trackerentry['tr_creator'])&& $contact['account_id']>0)
2144					{
2145						$buff = explode(',',strtolower($trackerentry['tr_cc'])) ;
2146						unset($trackerentry['tr_cc']);
2147						foreach (array('email','email_home') as $k => $n)
2148						{
2149							if (!empty($contact[$n]) && !empty($buff))
2150							{
2151								$break = false;
2152								$cnt = count($buff);
2153								$i = 0;
2154								while ( $break == false )
2155								{
2156									$key = array_search(strtolower($contact[$n]),$buff);
2157									//_debug_array('found:'.$n.'->'.$key);
2158									if ($key !== false && isset($staff[$contact['account_id']]))
2159									{
2160										unset($buff[$key]);
2161										if (empty($trackerentry['tr_creator'])) $trackerentry['tr_creator'] = $contact['account_id'];
2162									}
2163									$i++;
2164									if ($key==false || $i>=$cnt) $break=true;
2165								}
2166							}
2167						}
2168						$trackerentry['tr_cc'] = implode(',',$buff);
2169					}
2170				}
2171				if (empty($trackerentry['tr_creator']))
2172				{
2173					$trackerentry['msg'] = lang('Attention: No Contact with address %1 found.',implode(', ',$emails));
2174					$trackerentry['tr_creator']=$this->user;
2175				}
2176			}
2177		}
2178		else
2179		{
2180			// find the addressbookentry to idetify the reply creator
2181			$addressbook = new Api\Contacts();
2182			$contacts = array();
2183			$filter = array();
2184			foreach ($emails as $mailadr)
2185			{
2186				$contacts = array_merge($contacts,(array)$addressbook->search(
2187					array(
2188						'email' => $mailadr,
2189						'email_home' => $mailadr
2190					),'contact_id,contact_email,contact_email_home,egw_addressbook.account_id as account_id','','','',false,'OR',false,$filter,'',false));
2191			}
2192			$found= false;
2193			if (!$contacts || !is_array($contacts) || !is_array($contacts[0]))
2194			{
2195				$msg['reply_creator'] = $this->user;      // use current user as creator instead
2196			}
2197			else
2198			{
2199				$msg['reply_creator'] = $this->user;
2200				// try to find/set the creator according to the sender (if it is a valid user to the all queues (tracker=0))
2201				//error_log(__METHOD__.__LINE__.' Number of Contacts Found:'.count($contacts));
2202				foreach ($contacts as $contact)
2203				{
2204					if (empty($contact['account_id'])) continue;
2205					//error_log(__METHOD__.__LINE__.' Contact Found:'.array2string($contact));
2206					$staff = $this->get_staff($tracker=0,0,'usersANDtechnicians');
2207					//error_log(__METHOD__.__LINE__.array2string($staff));
2208					if ($found==false && $contact['account_id']>0)
2209					{
2210						foreach (array('email','email_home') as $k => $n)
2211						{
2212							if (!empty($contact[$n]))
2213							{
2214								// we found someone as staff, so we set it as current user
2215								if (isset($staff[$contact['account_id']]))
2216								{
2217									//error_log(__METHOD__.__LINE__.' ->'.$n.':'.array2string($contact));
2218									$msg['reply_creator'] = $contact['account_id'];
2219									$found = true;
2220								}
2221							}
2222						}
2223					}
2224				}
2225			}
2226			if($found===false) $msg['msg'] = lang('Attention: No Contact with address %1 found.',implode(', ',$emails));
2227			$this->read($ticketId);
2228			//echo "<p>data[tr_edit_mode]={$this->data['tr_edit_mode']}, this->htmledit=".array2string($this->htmledit)."</p>\n";
2229			// Ascii Replies are converted to html, if htmledit is disabled (default), we allways convert, as this detection is weak
2230			if (is_array($this->data['replies']))
2231			{
2232				foreach ($this->data['replies'] as &$reply)
2233				{
2234					if (!$this->htmledit || stripos($reply['reply_message'], '<br') === false && stripos($reply['reply_message'], '<p>') === false)
2235					{
2236						$reply['reply_message'] = nl2br(Api\Html::htmlspecialchars($reply['reply_message']));
2237					}
2238				}
2239			}
2240			$trackerentry = $this->data;
2241			$trackerentry['reply_message'] = $_message;
2242			$trackerentry['popup'] = true;
2243			$trackerentry['link_to'] = array(
2244				'to_app' => 'tracker',
2245				'to_id' => $ticketId
2246			);
2247			if (isset($msg['msg'])) $trackerentry['msg'] = $msg['msg'];
2248			if (isset($msg['reply_creator'])) $trackerentry['reply_creator'] = $msg['reply_creator'];
2249		}
2250		$queue = $_queue; // all; we use this, as we do not have a queue, when preparing a new ticket
2251		if (isset($trackerentry['tr_tracker']) && !empty($trackerentry['tr_tracker'])) $queue = $trackerentry['tr_tracker'];
2252		// since we only add replies for existing tickets, we do not mess with tr_cc in that case
2253		if ($ticketId==0 && (!isset($this->mailhandling[$queue]['auto_cc']) || empty($this->mailhandling[$queue]['auto_cc']))) unset($trackerentry['tr_cc']);
2254		if (is_array($_attachments))
2255		{
2256			foreach ($_attachments as $attachment)
2257			{
2258				if($ticketId)
2259				{
2260					// Put it where it will be tied to the comment
2261					$attachment['name'] = 'comments/.new/'.$attachment['name'];
2262				}
2263				if($ticketId && $attachment['egw_data'])
2264				{
2265					Link::link('tracker',$trackerentry['link_to']['to_id'],array(array('app' => Link::DATA_APPNAME,'id'=>$attachment)));
2266				}
2267				else if($attachment['egw_data'])
2268				{
2269					Link::link('tracker',$trackerentry['link_to']['to_id'],Link::DATA_APPNAME,$attachment);
2270				}
2271				else if(is_readable($attachment['tmp_name']) ||
2272					(Vfs::is_readable($attachment['tmp_name']) && parse_url($attachment['tmp_name'], PHP_URL_SCHEME) === 'vfs'))
2273				{
2274					Link::link('tracker',$trackerentry['link_to']['to_id'],'file',$attachment);
2275				}
2276			}
2277		}
2278		return $trackerentry;
2279	}
2280
2281	/**
2282	 * return SQL implementing filtering by date
2283	 *
2284	 * If the currently sorted column is a date, we filter by that date, otherwise
2285	 * we sort on tr_created
2286	 *
2287	 * @param string $name
2288	 * @param int &$start
2289	 * @param int &$end
2290	 * @param string &$column
2291	 * @return string
2292	 */
2293	function date_filter($name,&$start,&$end, $column = 'tr_created')
2294	{
2295		if(!$column ||
2296			// Just these columns
2297			!in_array($column, array('tr_created','tr_startdate','tr_duedate','tr_closed'))
2298			// Any date column
2299			//!in_array($column, tracker_egw_record::$types['date-time']))
2300		)
2301		{
2302			$column = 'tr_created';
2303		}
2304		switch(strtolower($name))
2305		{
2306			case 'overdue':
2307				$limit = $this->now - $this->overdue_days * 24*60*60;
2308
2309				return "(tr_duedate IS NOT NULL and tr_duedate < {$this->now}
2310OR tr_duedate IS NULL AND
2311	CASE
2312		WHEN tr_modified IS NULL
2313		THEN
2314			tr_created < $limit
2315		ELSE
2316			tr_modified < $limit
2317	END
2318) ";
2319
2320			case 'started':
2321				return "(tr_startdate IS NULL OR tr_startdate < {$this->now} )" ;
2322
2323			case 'upcoming':
2324				return "(tr_startdate IS NOT NULL and tr_startdate > {$this->now} )";
2325		}
2326		return Api\DateTime::sql_filter($name, $start, $end, $column, $this->date_filters);
2327	}
2328
2329	/**
2330	 * set fields readonly, depending on the rights the current user has on the actual tracker item
2331	 *
2332	 * @return array
2333	 */
2334	function readonlys_from_acl()
2335	{
2336		//echo "<p>uitracker::get_readonlys() is_admin(tracker={$this->data['tr_tracker']})=".$this->is_admin($this->data['tr_tracker']).", id={$this->data['tr_id']}, creator={$this->data['tr_creator']}, assigned={$this->data['tr_assigned']}, user=$this->user</p>\n";
2337		$readonlys = array();
2338		foreach((array)$this->field_acl as $name => $rigths)
2339		{
2340			$readonlys[$name] = !$rigths || !$this->check_rights($rigths, null, null, null, $name);
2341		}
2342		if ($this->customfields && $readonlys['customfields'])
2343		{
2344			foreach(array_keys($this->customfields) as $name)
2345			{
2346				$readonlys['#'.$name] = $readonlys['customfields'];
2347			}
2348		}
2349		return $readonlys;
2350	}
2351
2352	/**
2353	 * Get a list of users with open tickets, either created or assigned.
2354	 *
2355	 * Limits the amount of checking to do for notifications by only getting users with
2356	 * tickets where the start date, due date or created + limit is within 4 days
2357	 *
2358	 * @return array of user IDs
2359	 */
2360	public function users_with_open_entries()
2361	{
2362
2363		$users = array();
2364
2365		$config_limit = $this->now - $this->overdue_days * 24*60*60;
2366		$four_days = 4 * 24*60*60;
2367
2368		$where = array(
2369			'tr_status' => array_keys($this->get_tracker_stati(null, false)),
2370			"(tr_duedate IS NOT NULL and ABS(tr_duedate - {$this->now}) < {$four_days}
2371OR tr_startdate IS NOT NULL AND ABS(tr_startdate - {$this->now}) < $four_days
2372OR tr_duedate IS NULL AND
2373    CASE
2374        WHEN tr_modified IS NULL
2375        THEN
2376            ABS(tr_created - $config_limit) < $four_days
2377        ELSE
2378            ABS(tr_modified - $config_limit) < $four_days
2379    END
2380                        ) "
2381		);
2382
2383		// Creator
2384		foreach($this->db->select(self::TRACKER_TABLE, array('DISTINCT tr_creator'),$where,__LINE__,__FILE__) as $user)
2385		{
2386			$users[] = $user['tr_creator'];
2387		}
2388
2389		// Assigned
2390		foreach($this->db->select(
2391			self::ASSIGNEE_TABLE, array('DISTINCT tr_assigned'),$where,__LINE__,__FILE__,
2392			false, '',false,-1,
2393			'JOIN '.self::TRACKER_TABLE.' ON '.self::TRACKER_TABLE.'.tr_id = '.self::ASSIGNEE_TABLE.'.tr_id'
2394		) as $user)
2395		{
2396			$user = $user['tr_assigned'];
2397			if($user < 0) $user = $GLOBALS['egw']->accounts->members($user,true);
2398			$users[] = $user;
2399		}
2400
2401		return array_unique($users);
2402	}
2403
2404
2405	/**
2406	 * Deal with files from Add comment tab
2407	 *
2408	 * @param Arra $content
2409	 */
2410	protected function comment_files($tr_id, $reply_id, &$content = array())
2411	{
2412		$path = "/apps/tracker/{$tr_id}/comments/";
2413
2414		// Get files.  Established tickets (should) let files go to VFS, we'll move them.
2415		$files = Api\Vfs::find(
2416				"{$path}.new/",
2417				array('type' => 'f', 'maxdepth' => 1)
2418			);
2419		if(count($files) && !$content['reply_message'])
2420		{
2421			// No comment makes no sense, add something then get that reply ID
2422			$content['reply_message'] = lang('File(s) added');
2423			$this->save();
2424			return $this->comment_files($tr_id, $this->data['replies'][0]['reply_id'], $this->data);
2425		}
2426		$comment = Api\Accounts::username($GLOBALS['egw_info']['user']['account_id']) . ' ' .
2427			Api\DateTime::to();
2428		foreach($files as $key => $file)
2429		{
2430			$file_name = is_array($file) && $file['name'] ? $file['name'] : Api\Vfs::basename($file);
2431			$file_path = is_array($file) ? ($file['tmp_name'] ? $file['tmp_name'] : $file['path']) : $file;
2432			$target = "$path{$reply_id}/{$file_name}";
2433
2434			// Move to final destination
2435			Api\Vfs::rename($file_path, $target);
2436
2437			// Comment with user and date
2438			$result = Api\Vfs::proppatch($target, array(array('name' => 'comment', 'val' => $comment)));
2439		}
2440		$this->remove_comment_dir($tr_id);
2441	}
2442
2443	/**
2444	 * Empty and remove the 'Add comment' temporary directory
2445	 *
2446	 * @param int $tr_id
2447	 */
2448	protected function remove_comment_dir($tr_id)
2449	{
2450		$path = "/apps/tracker/{$tr_id}/comments/.new";
2451		if(!Api\Vfs::is_dir($path))
2452		{
2453			return;
2454		}
2455		$files = array_diff(Api\Vfs::scandir($path), array('.','..'));
2456		foreach ($files as $file) {
2457		  Api\Vfs::unlink("$path/$file");
2458		}
2459		Api\Vfs::rmdir($path);
2460	}
2461}
2462