<?php
/**
 * TimeSheet - business object
 *
 * @link http://www.egroupware.org
 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de>
 * @package timesheet
 * @copyright (c) 2005-16 by Ralf Becker <RalfBecker-AT-outdoor-training.de>
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
 * @version $Id$
 */

use EGroupware\Api;
use EGroupware\Api\Link;
use EGroupware\Api\Acl;
use EGroupware\Timesheet\Events;

if (!defined('TIMESHEET_APP'))
{
	define('TIMESHEET_APP','timesheet');
}


/**
 * Business object of the TimeSheet
 *
 * Uses eTemplate's Api\Storage as storage object (Table: egw_timesheet).
 */
class timesheet_bo extends Api\Storage
{
	/**
	 * Flag for timesheets deleted, but preserved
	 */
	const DELETED_STATUS = -1;

	/**
	 * Pseudo status/filter for all billable status: status not flagged as "invoiced" or "not_to_invoice"
	 */
	const BILLABLE = '**billable**';

	/**
	 * Timesheets Api\Config data
	 *
	 * @var array
	 */
	var $config_data = array();
	/**
	 * Should we show a quantity sum, makes only sense if we sum up identical units (can be used to sum up negative (over-)time)
	 *
	 * @var boolean
	 */
	var $quantity_sum=false;
	/**
	 * current user
	 *
	 * @var int
	 */
	var $user;
	/**
	 * Start of today in user-time
	 *
	 * @var int
	 */
	var $today;
	/**
	 * Filter for search limiting the date-range
	 *
	 * @var array
	 */
	var $date_filters = array(	// Start: year,month,day,week, End: year,month,day,week
		'Today'       => array(0,0,0,0,  0,0,1,0),
		'Yesterday'   => array(0,0,-1,0, 0,0,0,0),
		'This week'   => array(0,0,0,0,  0,0,0,1),
		'Last week'   => array(0,0,0,-1, 0,0,0,0),
		'This month'  => array(0,0,0,0,  0,1,0,0),
		'Last month'  => array(0,-1,0,0, 0,0,0,0),
		'2 month ago' => array(0,-2,0,0, 0,-1,0,0),
		'This year'   => array(0,0,0,0,  1,0,0,0),
		'Last year'   => array(-1,0,0,0, 0,0,0,0),
		'2 years ago' => array(-2,0,0,0, -1,0,0,0),
		'3 years ago' => array(-3,0,0,0, -2,0,0,0),
	);
	/**
	 * Grants: $GLOBALS['egw']->acl->get_grants(TIMESHEET_APP);
	 *
	 * @var array
	 */
	var $grants;
	/**
	 * Sums of the last search in keys duration and price
	 *
	 * @var array
	 */
	var $summary;
	/**
	 * Array with boolean values in keys 'day', 'week' or 'month', for the sums to return in the search
	 *
	 * @var array
	 */
	var $show_sums;
	/**
	 * Array with custom fileds
	 *
	 * @var array
	 */
	var $customfields=array();
	/**
	 * Array with status label
	 *
	 * @var array
	 */
	var $status_labels = array();
	/**
	 * Array with status label configuration
	 *
	 * @var array
	 */
	var $status_labels_config = array();
	/**
	 * Instance of the timesheet_tracking object
	 *
	 * @var timesheet_tracking
	 */
	var $tracking;
	/**
	 * Translates field / acl-names to labels
	 *
	 * @var array
	 */
	var $field2label = array(
		'ts_project'     => 'Project',
		'ts_title'     	 => 'Title',
		'cat_id'         => 'Category',
		'ts_description' => 'Description',
		'ts_start'       => 'Start',
		'ts_duration'    => 'Duration',
		'ts_paused'      => 'Paused',
		'ts_quantity'    => 'Quantity',
		'ts_unitprice'   => 'Unitprice',
		'ts_owner'       => 'Owner',
		'ts_modifier'    => 'Modifier',
		'ts_status'      => 'Status',
		'pm_id'		     => 'Projectid',
		// pseudo fields used in edit
		//'link_to'        => 'Attachments & Links',
		'customfields'   => 'Custom fields',
	);
	/**
	 * Name of the timesheet table storing custom fields
	 */
	const EXTRA_TABLE = 'egw_timesheet_extra';
	const TABLE = 'egw_timesheet';

	/**
	* Columns to search when user does a text search
	*/
	var $columns_to_search = array('egw_timesheet.ts_id', 'ts_project', 'ts_title', 'ts_description', 'ts_duration', 'ts_quantity', 'ts_unitprice');

	/**
	 * all cols in data which are not (direct)in the db, for data_merge
	 *
	 * @var array
	 */
	var $non_db_cols = array('pm_id');

	function __construct()
	{
		parent::__construct(TIMESHEET_APP,self::TABLE,self::EXTRA_TABLE,'','ts_extra_name','ts_extra_value','ts_id');

		$this->convert_all_timestamps();

		$this->config_data = Api\Config::read(TIMESHEET_APP);
		$this->quantity_sum = $this->config_data['quantity_sum'] == 'true';

		// Load & process statuses
		if($this->config_data['status_labels']) $this->load_statuses();

		$this->today = mktime(0,0,0,date('m',$this->now),date('d',$this->now),date('Y',$this->now));

		// save us in $GLOBALS['timesheet_bo'] for ExecMethod used in hooks
		if (!isset($GLOBALS['timesheet_bo']) && !is_object($GLOBALS['timesheet_bo']))
		{
			$GLOBALS['timesheet_bo'] =& $this;
		}
		$this->user = (int)$GLOBALS['egw_info']['user']['account_id'];
		$this->grants = $GLOBALS['egw']->acl->get_grants(TIMESHEET_APP);
	}

	/**
	 * Load status labels
	 */
	protected function load_statuses()
	{
		$this->status_labels =&  $this->config_data['status_labels'];
		if (!is_array($this->status_labels)) $this->status_labels= array($this->status_labels);

		$check_have = [
			'not to invoice' => false,
			'invoiced' => false,
		];
		foreach ($this->status_labels as $status_id => $label)
		{
			if (empty($label)) continue;
			if (!is_array($label))
			{
				$label= [
					'name' => $label,
					'parent' => '',
				];
			}
			$label['id'] = $status_id;
			foreach ($check_have as $name => &$have)
			{
				if (!empty($label[str_replace(' ', '_', $name)])) $have = true;
			}
			$this->status_labels_config[$status_id] = $label;
		}
		// create required not-to-invoice and invoiced stati
		foreach(array_keys(array_filter($check_have, static fn($have) => !$have)) as $name)
		{
			++$status_id;
			$this->status_labels_config[$status_id] = $this->status_labels[$status_id] =
				$this->config_data['status_labels'][$status_id] = [
					'name' => $name,
					'parent' => '',
					'not_to_invoice' => $name == 'not to invoice',
					'invoiced' => $name == 'invoiced',
				];
			Api\Config::save_value('status_labels', $this->status_labels, 'timesheet');
		}

		// Organize into tree structure
		$map = array(
			'' => array('substatus' => array())
		);
		foreach($this->status_labels_config as $id => &$status)
		{
			$status['substatus'] = array();
			$map[$id] = &$status;
		}
		foreach($this->status_labels_config as &$status)
		{
			$map[$status['parent']]['substatus'][] = &$status;
		}
		$tree = $map['']['substatus'];

		// Make nice selectbox labels
		$this->status_labels = array();
		$this->make_status_labels($tree, $this->status_labels);

		// Sort Api\Config based on tree
		$sorted = array();
		foreach($this->status_labels as $status_id => $label)
		{
			$sorted[$status_id] = $this->status_labels_config[$status_id];
			//$sorted[$status_id]['name'] = $label;
			unset($sorted[$status_id]['substatus']);
		}
		$this->status_labels_config = $sorted;
	}

	/**
	 * Get id's of status with the given flag set
	 *
	 * @param string $flag "not_to_invoice" or "invoiced"
	 * @return int[] of id's
	 */
	public function getStatus(string $flag) : array
	{
		return array_keys(array_filter($this->status_labels_config, static fn($status) => !empty($status[$flag])));
	}

	/**
	 * Return evtl. existing sub-statuses of given status
	 *
	 * @param int $status
	 * @return array|int with sub-statuses incl. $status or just $status
	 */
	function get_sub_status($status)
	{
		if (!isset($this->status_labels_config)) $this->load_statuses();
		$stati = array($status);
		foreach($this->status_labels_config as $stat)
		{
			if ($stat['parent'] && in_array($stat['parent'], $stati))
			{
				$stati[] = $stat['id'];
			}
		}
		//error_log(__METHOD__."($status) returning ".array2string(count($stati) == 1 ? $status : $stati));
		return count($stati) == 1 ? $status : $stati;
	}

	/**
	 * Make nice labels with leading spaces depending on depth
	 *
	 * @param array $statuses List of statuses to process, with sub-statuses in a 'substatus' array
	 * @param array $labels Array of labels, pass array() and labels will be built in it
	 * @param int $depth Current depth
	 *
	 * @return void labels are built in labels parameter
	 */
	protected function make_status_labels($statuses, &$labels, $depth=0)
	{
		foreach($statuses as $status)
		{
			$labels[$status['id']] = str_pad('',$depth*12, "&nbsp;",STR_PAD_LEFT).trim(str_replace('&nbsp;','',$status['name']));
			if(count($status['substatus']) > 0)
			{
				$this->make_status_labels($status['substatus'], $labels, $depth+1);
			}
		}
	}

	/**
	 * Get status labels with admin statuses (optionally) filtered out
	 *
	 * @param boolean $admin
	 *
	 * @return Array
	 */
	protected function get_status_labels($admin = null)
	{
		if(is_null($admin))
		{
			$admin = isset($GLOBALS['egw_info']['user']['apps']['admin']);
		}
		$labels = array();
		foreach($this->status_labels as $status_id => $label)
		{
			if($admin || !$admin && !$this->status_labels_config[$status_id]['admin'])
			{
				$labels[$status_id] = $label;
			}
		}
		return $labels;
	}

	/**
	 * get list of specified grants as uid => Username pairs
	 *
	 * @param int $required =Acl::READ
	 * @param boolean $hide_deactive =null default only Acl::EDIT hides deactivates users
	 * @return array with uid => Username pairs
	 */
	function grant_list($required=Acl::READ, $hide_deactive=null)
	{
		if (!isset($hide_deactive)) $hide_deactive = $required == Acl::EDIT;

		$result = array();
		foreach($this->grants as $uid => $grant)
		{
			if ($grant & $required && (!$hide_deactive || Api\Accounts::getInstance()->is_active($uid)))
			{
				$result[$uid] = Api\Accounts::username($uid);
			}
		}
		natcasesort($result);

		return $result;
	}

	/**
	 * checks if the user has enough rights for a certain operation
	 *
	 * Rights are given via status Api\Config admin/noadmin
	 *
	 * @param array|int $data =null use $this->data or $this->data['ts_id'] (to fetch the data)
	 * @param int $user =null for which user to check, default current user
	 * @return boolean true if the rights are ok, false if no rights
	 */
	function check_statusForEditRights($data=null,$user=null)
	{
		if (is_null($data) || (int)$data == $this->data['ts_id'])
		{
			$data =& $this->data;
		}
		if (!is_array($data))
		{
			$save_data = $this->data;
			$data = $this->read($data,true);
			$this->data = $save_data;

			if (!$data) return null; 	// entry not found
		}
		if (!$user) $user = $this->user;
		if (!isset($GLOBALS['egw_info']['user']['apps']['admin']) && $data['ts_status'])
		{
			if ($this->status_labels_config[$data['ts_status']]['admin'])
			{
				return false;
			}
		}
		return true;
	}

	/**
	 * checks if the user has enough rights for a certain operation
	 *
	 * Rights are given via owner grants or role based Acl
	 *
	 * @param int $required Acl::READ, EGW_ACL_WRITE, Acl::ADD, Acl::DELETE, EGW_ACL_BUDGET, EGW_ACL_EDIT_BUDGET
	 * @param array|int $data =null project or project-id to use, default the project in $this->data
	 * @param int $user =null for which user to check, default current user
	 * @return boolean true if the rights are ok, null if not found, false if no rights
	 */
	function check_acl($required,$data=null,$user=null)
	{
		if (is_null($data) || (int)$data == $this->data['ts_id'])
		{
			$data =& $this->data;
		}
		if (!$data)
		{
			return null;    // entry not found
		}
		if (!is_array($data))
		{
			$save_data = $this->data;
			$data = $this->read($data,true);
			$this->data = $save_data;

			if (!$data) return null; 	// entry not found
		}
		if (!$user) $user = $this->user;
		if ($user == $this->user)
		{
			$grants = $this->grants;
		}
		else
		{
			$grants = $GLOBALS['egw']->acl->get_grants(TIMESHEET_APP,true,$user);
		}
		$ret = $data && !!($grants[$data['ts_owner']] & $required);

		// check explicit read-rights of entry
		if (!$ret && $required == Acl::READ && !empty($data['ts_readable']))
		{
			static $memberships = null;
			if (!isset($memberships[$user]))
			{
				$memberships[$user] = Api\Accounts::getInstance()->memberships($user, true);
				$memberships[$user][] = $user;
			}
			$ret = (bool)array_intersect($memberships[$user], explode(',',$data['ts_readable']));
		}

		if(($required & Acl::DELETE) && $this->config_data['history'] == 'history' &&
			$data['ts_status'] == self::DELETED_STATUS)
		{
			$ret = !!($GLOBALS['egw_info']['user']['apps']['admin']);
		}
		//error_log(__METHOD__."($required,$data[ts_id],$user) returning ".array2string($ret));
		return $ret;
	}

	/**
	 * return SQL implementing filtering by date
	 *
	 * @param string $name
	 * @param int &$start
	 * @param int &$end
	 * @return string
	 */
	function date_filter($name,&$start,&$end)
	{
		return Api\DateTime::sql_filter($name, $start, $end, 'ts_start', $this->date_filters);
	}

	/**
	 * search the timesheet
	 *
	 * reimplemented to limit result to users we have grants from
	 * Use $filter['ts_owner'] === false for no ACL check.
	 *
	 * @param array|string $criteria array of key and data cols, OR a SQL query (content for WHERE), fully quoted (!)
	 * @param boolean|string $only_keys =true True returns only keys, False returns all cols. comma seperated list of keys to return
	 * @param string $order_by ='' fieldnames + {ASC|DESC} separated by colons ',', can also contain a GROUP BY (if it contains ORDER BY)
	 * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num"
	 * @param string $wildcard ='' appended befor and after each criteria
	 * @param boolean $empty =false False=empty criteria are ignored in query, True=empty have to be empty in row
	 * @param string $op ='AND' defaults to 'AND', can be set to 'OR' too, then criteria's are OR'ed together
	 * @param mixed $start =false if != false, return only maxmatch rows begining with start, or array($start,$num)
	 * @param array $filter =null if set (!=null) col-data pairs, to be and-ed (!) into the query without wildcards
	 * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or
	 *	"LEFT JOIN table2 ON (x=y)", Note: there's no quoting done on $join!
	 * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false
	 * @param boolean $only_summary =false If true only return the sums as array with keys duration and price, default false
	 * @return array of matching rows (the row is an array of the cols) or False
	 */
	function &search($criteria,$only_keys=True,$order_by='',$extra_cols='',$wildcard='',$empty=False,$op='AND',$start=false,$filter=null,$join='',$need_full_no_count=false,$only_summary=false)
	{
		//error_log(__METHOD__."(".print_r($criteria,true).",'$only_keys','$order_by',".print_r($extra_cols,true).",'$wildcard','$empty','$op','$start',".print_r($filter,true).",'$join')");
		//echo "<p>".__METHOD__."(".print_r($criteria,true).",'$only_keys','$order_by',".print_r($extra_cols,true).",'$wildcard','$empty','$op','$start',".print_r($filter,true).",'$join')</p>\n";
		// postgres can't round from double precission, only from numeric ;-)
		$total_sql = $this->db->Type != 'pgsql' ? "round(ts_quantity*ts_unitprice,2)" : "round(cast(ts_quantity*ts_unitprice AS numeric),2)";

		if (!is_array($extra_cols))
		{
			$extra_cols = $extra_cols ? explode(',',$extra_cols) : array();
		}
		if ($only_keys === false || $this->show_sums && strpos($order_by,'ts_start') !== false)
		{
			$extra_cols[] = $total_sql.' AS ts_total';
		}
		// $filter['ts_owner'] === false --> no ACL checks
		if (isset($filter['ts_owner']) && $filter['ts_owner'] === false)
		{
			$filter['ts_owner'] = '';
		}
		// empty --> all the user has access too
		elseif (empty($filter['ts_owner']))
		{
			$filter['ts_owner'] = array_keys($this->grants);
		}
		else
		{
			if(!is_array($filter['ts_owner']))
			{
				$filter['ts_owner'] = array($filter['ts_owner']);
			}

			foreach($filter['ts_owner'] as $key => $owner)
			{
				if(!isset($this->grants[$owner]))
				{
					unset($filter['ts_owner'][$key]);
				}
			}
		}
		if(isset($filter['ts_status']) && $filter['ts_status'] && $filter['ts_status'] != self::DELETED_STATUS)
		{
			if ($filter['ts_status'] == self::BILLABLE)
			{
				$filter[] = '(ts_status IS NULL OR ts_status IN ('.
					implode(',',array_keys(array_filter($this->status_labels_config,
						static fn($status) => !($status['invoiced'] || $status['not_to_invoice'])))).'))';
				unset($filter['ts_status']); // no status set
			}
			elseif ($filter['ts_status'] !== 'all')
			{
				$filter['ts_status'] = $this->get_sub_status($filter['ts_status']);
			}
			else
			{
				unset($filter['ts_status']);
			}
		}
		else
		{
			$filter[] = '(ts_status ' . ($filter['ts_status'] == self::DELETED_STATUS ? '=' : '!= ') . self::DELETED_STATUS .
				($filter['ts_status'] == self::DELETED_STATUS ? '' : ' OR ts_status IS NULL') . ')';
		}
		if(is_array($filter['ts_owner']) && !count($filter['ts_owner']))
		{
			$this->total = 0;
			$this->summary = array();
			return array();
		}
		if($only_summary == false && $criteria && $this->show_sums)
		{
			// if we have a criteria AND intend to show the sums we first query the affected ids,
			// then we throw away criteria and filter, and replace the filter with the list of ids
			$ids = parent::search($criteria, 'egw_timesheet.ts_id as id', '', '', $wildcard, $empty, $op, false, $filter, $join);
			//_debug_array($ids);
			if(empty($ids))
			{
				$this->summary = array('duration' => 0, 'paused' => 0, 'price' => null, 'quantity' => 0);
				return array();
			}
			unset($criteria);
			foreach ($ids as $v)
			{
				$id_filter[] = $v['id'];
			}
			$filter = array('ts_id'=>$id_filter);
		}
		// if we only want to return the summary (sum of duration and sum of price) we have to take care that the customfield table
		// is not joined, as the join causes a multiplication of the sum per customfield found
		// joining of the cutomfield table is triggered by criteria being set with either a string or an array
		$cols = ['SUM(ts_duration) AS duration',
				 'SUM(COALESCE(ts_paused,0)) AS paused',
				 "SUM($total_sql) AS price",
				 'MAX(ts_modified) AS max_modified'];
		if($this->quantity_sum)
		{
			$cols[] = 'SUM(ts_quantity) AS quantity';
		}
		$this->summary = parent::search(
			$only_summary ? null : $criteria,
			$cols,
			'', '', $wildcard, $empty, $op, false,
			$only_summary && is_array($criteria) ? ($filter ? array_merge($criteria, (array)$filter) : $criteria) : $filter,
			$only_summary ? '' : $join
		);
		$this->summary = $this->summary[0];
		$this->summary['max_modified'] = Api\DateTime::server2user($this->summary['max_modified']);

		if ($only_summary) return $this->summary;

		if ($this->show_sums && strpos($order_by,'ts_start') !== false && 	// sums only make sense if ordered by ts_start
			$this->db->capabilities['union'] && ($from_unixtime_ts_start = $this->db->from_unixtime('ts_start')))
		{
			$sum_sql = array(
				'year'  => $this->db->date_format($from_unixtime_ts_start,'%Y'),
				'month' => $this->db->date_format($from_unixtime_ts_start,'%Y%m'),
				'week'  => $this->db->date_format($from_unixtime_ts_start,$GLOBALS['egw_info']['user']['preferences']['calendar']['weekdaystarts'] == 'Sunday' ? '%X%V' : '%x%v'),
				'day'   => $this->db->date_format($from_unixtime_ts_start,'%Y-%m-%d'),
			);
			foreach($this->show_sums as $type)
			{
				$extra_cols[] = $sum_sql[$type].' AS ts_'.$type;
				$extra_cols[] = '0 AS is_sum_'.$type;
				$sum_extra_cols[] = str_replace('ts_start','MIN(ts_start)',$sum_sql[$type]);	// as we dont group by ts_start
				$sum_extra_cols[$type] = '0 AS is_sum_'.$type;
			}
			// regular entries
			parent::search($criteria,$only_keys,$order_by,$extra_cols,$wildcard,$empty,$op,'UNION',$filter,$join,$need_full_no_count);

			$sort = substr($order_by,8);
			$union_order = array();
			$sum_ts_id = array('year' => -3,'month' => -2,'week' => -1,'day' => 0);
			foreach($this->show_sums as $type)
			{
				$union_order[] = 'ts_'.$type . ' ' . $sort;
				$union_order[] = 'is_sum_'.$type;
				$sum_extra_cols[$type][0] = '1';
				// the $type sum
				parent::search($criteria,array(
					(string)$sum_ts_id[$type],"''","''","''",'MIN(ts_start)','SUM(ts_duration) AS ts_duration',
					($this->quantity_sum ? "SUM(ts_quantity) AS ts_quantity" : '0'),
					'0','NULL','0','0','0','0','0','0',"SUM(COALESCE(ts_paused,0)) AS ts_paused",'NULL',"SUM($total_sql) AS ts_total"
				),'GROUP BY '.$sum_sql[$type],$sum_extra_cols,$wildcard,$empty,$op,'UNION',$filter,$join,$need_full_no_count);
				$sum_extra_cols[$type][0] = '0';
			}
			$union_order[] = 'ts_start '.$sort;
			return parent::search('','',implode(',',$union_order),'','',false,'',$start);
		}
		return parent::search($criteria,$only_keys,$order_by,$extra_cols,$wildcard,$empty,$op,$start,$filter,$join,$need_full_no_count);
	}

	/**
	 * read a timesheet entry
	 *
	 * @param int $ts_id
	 * @param boolean $ignore_acl =false should the Acl be checked
	 * @param string $join ='' *UNSUPPORTED*
	 * @return array|boolean array with timesheet entry, null if timesheet not found or false if no rights
	 */
	function read($ts_id,$ignore_acl=false,$join='')
	{
		if (!is_bool($ignore_acl)) throw new TypeError();
		//error_log(__METHOD__."($ts_id,$ignore_acl) ".function_backtrace());
		if (!(int)$ts_id || (int)$ts_id != $this->data['ts_id'] && !parent::read($ts_id))
		{
			return null;	// entry not found
		}
		if (!$ignore_acl && !($ret = $this->check_acl(Acl::READ)))
		{
			return false;	// no read rights
		}
		return $this->data;
	}

	/**
	 * saves a timesheet entry
	 *
	 * reimplemented to notify the link-class
	 *
	 * @param array $keys if given $keys are copied to data before saveing => allows a save as
	 * @param boolean $touch_modified =true should modification date+user be set, default yes
	 * @param boolean $ignore_acl =false should the Acl be checked, returns true if no edit-rigts
	 * @return int 0 on success and errno != 0 else
	 */
	function save($keys=null,$touch_modified=true,$ignore_acl=false)
	{
		if ($keys) $this->data_merge($keys);

		if (!$ignore_acl && $this->data['ts_id'] && !$this->check_acl(Acl::EDIT))
		{
			return true;
		}
		if ($touch_modified)
		{
			$this->data['ts_modifier'] = $GLOBALS['egw_info']['user']['account_id'];
			$this->data['ts_modified'] = $this->now;
			$this->user = $this->data['ts_modifier'];
		}

		// check if we have a real modification of an existing record
		if ($this->data['ts_id'])
		{
			$new =& $this->data;
			unset($this->data);
			$this->read($new['ts_id']);
			$old =& $this->data;
			$this->data =& $new;
			$changed = array();
			if (isset($old)) foreach($old as $name => $value)
			{
				if (isset($new[$name]) && $new[$name] != $value) $changed[] = $name;
			}
		}
		if (!$this->data['ts_created'])
		{
			$this->data['ts_created'] = Api\DateTime::to('now','ts');
		}
		if (isset($old) && !$changed)
		{
			return false;
		}
		// Update ts_project to match project
		if ($this->pm_integration == 'full' && (
				!$old && $this->data['pm_id'] != $this->data['old_pm_id'] || $old && $old['pm_id'] != $new['pm_id']
		))
		{
			$this->data['ts_project'] = $this->data['pm_id'] ? Link::title('projectmanager', $this->data['pm_id']) : '';
			if($this->data['ts_title'] == Link::title('projectmanager', $old['pm_id']))
			{
				$this->data['ts_title'] = $this->data['ts_project'];
			}
		}

		$type = !isset($old) ? 'add' :
			($new['ts_status'] == self::DELETED_STATUS ? 'delete' : 'update');

		// Check for restore of deleted contact, restore held links
		if($old && $old['ts_status'] == self::DELETED_STATUS && $new['ts_status'] != self::DELETED_STATUS)
		{
			Link::restore(TIMESHEET_APP, $new['ts_id']);
			$type = 'add';
		}

		if (!($err = parent::save()))
		{
			if (!is_object($this->tracking))
			{
				$this->tracking = new timesheet_tracking($this);

				$this->tracking->html_content_allow = true;
			}
			if ($this->tracking->track($this->data,$old,$this->user) === false)
			{
				return implode(', ',$this->tracking->errors);
			}
			// notify the link-class about the update, as other apps may be subscribt to it
			Link::notify_update(TIMESHEET_APP, $this->data['ts_id'], $this->data, $type);
		}

		return $err;
	}

	/**
	 * deletes a timesheet entry identified by $keys or the loaded one, reimplemented to notify the link class (unlink)
	 *
	 * @param array $keys if given array with col => value pairs to characterise the rows to delete
	 * @param boolean $ignore_acl =false should the Acl be checked, returns false if no delete-rigts
	 * @return int affected rows, should be 1 if ok, 0 if an error
	 */
	function delete($keys=null,$ignore_acl=false)
	{
		if (!is_array($keys) && (int) $keys)
		{
			$keys = array('ts_id' => (int) $keys);
		}
		$ts_id = is_null($keys) ? $this->data['ts_id'] : $keys['ts_id'];

		if (!$ignore_acl && !$this->check_acl(Acl::DELETE,$ts_id) || !($old = $this->read($ts_id)))
		{
			return false;
		}

		// check if we only mark timesheets as deleted, or really delete them
		if ($old['ts_owner'] && $this->config_data['history'] != '' && $old['ts_status'] != self::DELETED_STATUS)
		{
			$delete = $old;
			$delete['ts_status'] = self::DELETED_STATUS;
			$ret = !($this->save($delete));
			Link::unlink(0,TIMESHEET_APP,$ts_id,'','','',true);
		}
		elseif (($ret = parent::delete($keys)) && $ts_id)
		{
			(new Events())->delete($ts_id);
			// delete all links to timesheet entry $ts_id
			Link::unlink(0,TIMESHEET_APP,$ts_id);
		}
		return $ret;
	}

	/**
	 * delete / move all timesheets of a given user
	 *
	 * @param array $data
	 * @param int $data['account_id'] owner to change
	 * @param int $data['new_owner']  new owner or 0 for delete
	 */
	function deleteaccount($data)
	{
		$account_id = $data['account_id'];
		$new_owner =  $data['new_owner'];

		if (!$new_owner)
		{
			Link::unlink(0, TIMESHEET_APP, '', $account_id);
			(new Events())->delete(['ts_id' => parent::delete(['ts_owner' => $account_id], true)]);
			parent::delete(array('ts_owner' => $account_id));
		}
		else
		{
			$this->db->update($this->table_name, array(
				'ts_owner' => $new_owner,
			), array(
				'ts_owner' => $account_id,
			), __LINE__, __FILE__, TIMESHEET_APP);
		}
	}

	/**
	 * set a status for timesheet entry identified by $keys
	 *
	 * @param array $keys =null if given array with col => value pairs to characterise single timesheet or null for $this->data
	 * @param int $status =0
	 * @return int affected rows, should be 1 if ok, 0 if an error
	 */
	function set_status($keys=null, $status=0)
	{
		$ret = true;
		if (!is_array($keys) && (int) $keys)
		{
			$keys = array('ts_id' => (int) $keys);
		}
		$ts_id = is_null($keys) ? $this->data['ts_id'] : $keys['ts_id'];

		if (!$this->check_acl(Acl::EDIT,$ts_id) || !$this->read($ts_id,true))
		{
			return false;
		}

		$this->data['ts_status'] = $status;
		if ($this->save($ts_id)!=0) $ret = false;

		return $ret;
	}

	/**
	 * Get the time-, price-, quantity-sum and max. modification date for the given timesheet entries
	 *
	 * @param array $ids array of timesheet id's
	 * @return array with values for keys "duration", "price", "max_modified" and "quantity"
	 */
	function sum($ids, $ignore_acl = false)
	{
		if(!$ids)
		{
			return array('duration' => 0, 'paused' => 0, 'quantity' => 0, 'price' => 0, 'max_modified' => null);
		}
		$filter = [];
		if($ignore_acl)
		{
			$filter['ts_owner'] = false;
		}
		return $this->search(array('ts_id' => $ids), true, '', '', '', false, 'AND', false, $filter, '', false, true);
	}

	/**
	 * get title for a timesheet entry identified by $entry
	 *
	 * Is called as hook to participate in the linking
	 *
	 * @param int|array $entry int ts_id or array with timesheet entry
	 * @return string/boolean string with title, null if timesheet not found, false if no perms to view it
	 */
	function link_title( $entry )
	{
		if (!is_array($entry))
		{
			// need to preserve the $this->data
			$backup =& $this->data;
			unset($this->data);
			$entry = $this->read( $entry,true,false);
			// restore the data again
			$this->data =& $backup;
			// check access now (and not in read to support readable attribute)
			if (!($e = $this->check_acl(Acl::READ, $entry)))
			{
				$entry = $e;
			}
		}
		if (!$entry)
		{
			return $entry;
		}
		$format = $GLOBALS['egw_info']['user']['preferences']['common']['dateformat'];
		// how to display link titles: date+time or date+duration
		switch ($GLOBALS['egw_info']['user']['preferences']['timesheet']['link_title'] ?? 'date+time')
		{
			case 'date+time':
				if (date('H:i',$entry['ts_start']) != '00:00')	// dont show 00:00 time, as it means date only
				{
					$format .= ' '.($GLOBALS['egw_info']['user']['preferences']['common']['timeformat'] == 12 ? 'h:i a' : 'H:i');
				}
				break;
			case 'date+duration':
				$duration = ' ('.sprintf('%d:%02d', floor($entry['ts_duration']/60), $entry['ts_duration']%60).')';
				break;
		}
		return date($format,$entry['ts_start']).($duration??'').': '.$entry['ts_title'];
	}

	/**
	 * get title for multiple timesheet entries identified by $ids
	 *
	 * Is called as hook to participate in the linking
	 *
	 * @param array $ids array with ts_id's
	 * @return array with titles, see link_title
	 */
	function link_titles( array $ids )
	{
		$titles = array();
		if (($entries = $this->search(array('ts_id' => $ids),'ts_id,ts_title,ts_start,ts_duration')))
		{
			foreach($entries as $entry)
			{
				$titles[$entry['ts_id']] = $this->link_title($entry);
			}
		}
		// we check all not returned entries explicitly, as they might have entry-specific read-rights
		foreach($ids as $id)
		{
			if (!isset($titles[$id]))
			{
				$titles[$id] = $this->link_title($id);
			}
		}
		return $titles;
	}

	/**
	 * query timesheet for entries matching $pattern
	 *
	 * Is called as hook to participate in the linking
	 *
	 * @param string $pattern pattern to search
	 * @param array $options Array of options for the search
	 * @return array with ts_id - title pairs of the matching entries
	 */
	function link_query( $pattern, Array &$options = array() )
	{
		$limit = false;
		$need_count = false;
		if (!empty($options['start']) || !empty($options['num_rows']))
		{
			$limit = array($options['start'], $options['num_rows']);
			$need_count = true;
		}
		$result = array();
		foreach((array) $this->search($pattern,false,'ts_start DESC','','%',false,'OR', $limit, null, '', $need_count) as $ts )
		{
			if ($ts) $result[$ts['ts_id']] = $this->link_title($ts);
		}
		$options['total'] = $need_count ? $this->total : count($result);
		return $result;
	}

	/**
	 * Check access to the file store
	 *
	 * @param int|array $id id of entry or entry array
	 * @param int $check Acl::READ for read and Acl::EDIT for write or delete access
	 * @param string $rel_path =null currently not used in InfoLog
	 * @param int $user =null for which user to check, default current user
	 * @return boolean true if access is granted or false otherwise
	 */
	function file_access($id,$check,$rel_path=null,$user=null)
	{
		unset($rel_path);	// not used, but required by function signature

		return $this->check_acl($check,$id,$user);
	}

	/**
	 * updates the project titles in the timesheet application (called whenever a project name is changed in the project manager)
	 *
	 * Todo: implement via notification
	 *
	 * @param string $oldtitle => the origin title of the project
	 * @param string $newtitle => the new title of the project
	 * @return boolean true for success, false for invalid parameters
	 */
	 function update_ts_project($oldtitle='', $newtitle='')
	 {
		if(strlen($oldtitle) > 0 && strlen($newtitle) > 0)
		{
			$this->db->update('egw_timesheet',array(
				'ts_project' => $newtitle,
				'ts_title' => $newtitle,
			),array(
				'ts_project' => $oldtitle,
			),__LINE__,__FILE__,TIMESHEET_APP);

			return true;
		}
		return false;
	 }

	/**
	 * returns array with relation link_id and ts_id (necessary for project-selection)
	 *
	 * @param int $pm_id ID of selected project
	 * @return array containing link_id and ts_id
	 */
	function get_ts_links($pm_id=0)
	{
		if($pm_id && isset($GLOBALS['egw_info']['user']['apps']['projectmanager']))
		{
			$pm_ids = ExecMethod('projectmanager.projectmanager_bo.children',$pm_id);
			$pm_ids[] = $pm_id;
			$links = Link\Storage::get_links('projectmanager',$pm_ids,'timesheet');	// Link\Storage::get_links not egw_links::get_links!
			if ($links)
			{
				$links = array_unique(call_user_func_array('array_merge',$links));
			}
			return $links;
		}
		return array();
	}

	/**
	 * receives notifications from the link-class: new, deleted links to timesheets, or updated content of linked entries
	 *
	 * Function makes sure timesheets linked or unlinked to projects via projectmanager behave like ones
	 * linked via timesheets project-selector, thought timesheet only stores project-title, not the id!
	 *
	 * @param array $data array with keys type, id, target_app, target_id, link_id, data
	 */
	function notify($data)
	{
		//error_log(__METHOD__.'('.array2string($data).')');
		$backup =& $this->data;	// backup internal data in case class got re-used by ExecMethod
		unset($this->data);

		if ($data['target_app'] == 'projectmanager' && $this->read($data['id']))
		{
			$old_title = isset($data['data']) ? $data['data'][Link::OLD_LINK_TITLE] : null;
			switch($data['type'])
			{
				case 'link':
				case 'update':
					if (empty($this->data['ts_project']) ||	// timesheet has not yet project set --> set just linked one
						isset($old_title) && $this->data['ts_project'] === $old_title)
					{
						$pm_id = $data['target_id'];
						$update['ts_project'] = Link::title('projectmanager', $pm_id);
						if (isset($old_title) && $this->data['ts_title'] === $old_title)
						{
							$update['ts_title'] = $update['ts_project'];
						}
					}
					break;

				case 'unlink':	// if current project got unlinked --> unset it
					if ($this->data['ts_project'] == projectmanager_bo::link_title($data['target_id']))
					{
						$pm_id = 0;
						$update['ts_project'] = null;

					}
					break;
			}
			if (isset($update))
			{
				$this->update($update);
				// do NOT notify about title-change, as this will lead to an infinit loop!
				// Link::notify_update(TIMESHEET_APP, $this->data['ts_id'],$this->data);
				//error_log(__METHOD__."() setting pm_id=$pm_id --> ".array2string($update));
			}
		}
		if ($backup) $this->data = $backup;
	}


	/**
	 * changes the data from the db-format to your work-format
	 *
	 * Reimplemented to store just ts_project in db, but have pm_id and ts_project in memory,
	 * with ts_project only set, if it contains a custom project name.
	 *
	 * @param array $data =null if given works on that array and returns result, else works on internal data-array
	 * @return array
	 */
	function db2data($data=null)
	{
		if (($intern = !is_array($data)))
		{
			$data =& $this->data;
		}
		// get pm_id from links and ts_project: either project matching ts_project or first found project
		if (!isset($data['pm_id']) && !empty($data['ts_id']))
		{
			$first_pm_id = null;
			foreach(Link::get_links('timesheet', $data['ts_id'], 'projectmanager') as $pm_id)
			{
				if (!isset($first_pm_id)) $first_pm_id = $pm_id;
				if ($data['ts_project'] == Link::title('projectmanager', $pm_id))
				{
					$data['pm_id'] = $pm_id;
					$data['ts_project_blur'] = $data['ts_project'];
					$data['ts_project'] = '';
					break;
				}
			}
			if (!isset($data['pm_id']) && isset($first_pm_id)) $data['pm_id'] = $first_pm_id;
		}
		elseif (!empty($data['ts_id']) && !empty($data['pm_id']) && Link::title('projectmanager', $data['pm_id']) == $data['ts_project'])
		{
			$data['ts_project_blur'] = $data['ts_project'];
			$data['ts_project'] = '';
		}
		return parent::db2data($intern ? null : $data);	// important to use null, if $intern!
	}

	/**
	 * changes the data from your work-format to the db-format
	 *
	 * Reimplemented to store just ts_project in db, but have pm_id and ts_project in memory,
	 * with ts_project only set, if it contains a custom project name.
	 *
	 * @param array $data =null if given works on that array and returns result, else works on internal data-array
	 * @return array
	 */
	function data2db($data=null)
	{
		if (($intern = !is_array($data)))
		{
			$data =& $this->data;
		}
		// always store ts_project to be able to search for it, even if no custom project is set
		if (empty($data['ts_project']) && !is_null($data['ts_project']))
		{
			$data['ts_project'] = $data['pm_id'] ? Link::title('projectmanager', $data['pm_id']) : '';
		}
		return parent::data2db($intern ? null : $data);	// important to use null, if $intern!
	}

	/**
	 * Find working time timesheet for given time and confibured period
	 *
	 * @param DateTime $time time to define the periode
	 * @param string &$periode on return "day", "week" or "month"
	 * @param Api\DateTime|null &$start on return start-time of periode
	 * @param Api\DateTime|null &$end on return end-time of periode
	 * @return array|null ts or null, if there is none yet
	 * @throws Api\Exception
	 */
	function periodeWorkingTimesheet(DateTime $time, string &$periode=null, Api\DateTime &$start=null, Api\DateTime &$end=null)
	{
		$start = new Api\DateTime($time, Api\DateTime::$user_timezone);
		$start->setTime(0, 0, 0);
		$end = new Api\DateTime($start);
		switch($periode = $this->config_data['working_time_period'] ?? 'day')
		{
			case 'day':
				$end->setDate($start->format('Y'), $start->format('m'), 1+$start->format('d'));
				break;

			case 'week':
				$start->setWeekstart();
				$end = new Api\DateTime($start);
				$end->setDate($start->format('Y'), $start->format('m'), 7+$start->format('d'));
				break;

			case 'month':
				$start->setDate($start->format('Y'), $start->format('m'), 1);
				$end->setDate($start->format('Y'), 1+$start->format('m'), 1);
				break;
		}
		foreach($this->search('', false, 'ts_id DESC', '', '', false, 'AND', [0, 1], [
			'cat_id' => Events::workingTimeCat(),
			'ts_owner' => $this->user,
			'ts_start BETWEEN '.$this->db->quote($start, 'int').' AND '.$this->db->quote($end, 'int'),
		]) ?? [] as $row)
		{
			return $row;
		}
		return null;
	}

	/**
	 * Get a ctag (collection tag) for timesheet
	 *
	 * Currently implemented as maximum modification date (1 second granularity!)
	 *
	 * We have to include deleted entries, as otherwise the ctag will not change if an entry gets deleted!
	 * (Only works if tracking of deleted entries / history is switched on!)
	 *
	 * @param int|array $user =null
	 * @return string
	 */
	public function getctag($user=null)
	{
		$filter = array('ts_status' => 'all');	// --> use all entries incl. deleted
		// show timesheets of a single user?
		if ($user) $filter['ts_owner'] = $user;

		$result = $this->search(array(),'ts_modified','ts_modified DESC','','',false,'AND',array(0,1),$filter);

		if (!$result || !isset($result[0]['ts_modified']))
		{
			$ctag = 'empty';	// ctag for empty addressbook
		}
		else
		{
			// need to convert modified time back to server-time (was converted to user-time by search)
			// as we use it direct in server-queries eg. CardDAV sync-report and to be consistent with CalDAV
			$ctag = Api\DateTime::user2server($result[0]['ts_modified']);
		}
		//error_log(__METHOD__.'('.array2string($owner).') returning '.array2string($ctag));
		return $ctag;
	}


}