egroupware_official/timesheet/src/Events.php

517 lines
14 KiB
PHP

<?php
/**
* TimeSheet - Events object
*
* @link http://www.egroupware.org
* @author Ralf Becker <rb@egroupware.org>
* @package timesheet
* @copyright (c) 2022 by Ralf Becker <rb@egroupware.org>
* @license https://opensource.org/licenses/gpl-license.php GPL v2+ - GNU General Public License Version 2 or higher
*/
namespace EGroupware\Timesheet;
use EGroupware\Api;
/**
* Events object of the TimeSheet
*
* Uses eTemplate's Api\Storage as storage object (Table: egw_timesheet).
*/
class Events extends Api\Storage\Base
{
const APP = 'timesheet';
const TABLE = 'egw_timesheet_events';
/**
* @var string[] name of timestamps to convert (not set automatic by setup_table/parent::__construct()!)
*/
public $timestamps = [
'tse_timestamp', 'tse_time', 'tse_modified',
];
/**
* Bitfields for egw_timesheet_events.tse_type
*/
const OVERALL = 16;
const START = 1;
const STOP = 2;
const PAUSE = 4;
/**
* @var Events
*/
private static $instance;
protected int $user;
function __construct()
{
parent::__construct(self::APP,self::TABLE, null, '', true, 'object');
if (!isset(self::$instance))
{
self::$instance = $this;
}
$this->user = $GLOBALS['egw_info']['user']['account_id'];
}
function __destruct()
{
if (self::$instance === $this)
{
self::$instance = null;
}
}
protected static function getInstance()
{
if (!isset(self::$instance))
{
self::$instance = new self();
}
return self::$instance;
}
/**
* Record/persist timer events from UI
*
* @param array $state
* @return int tse_id of created event
* @throws Api\Exception
* @throws Api\Exception\WrongParameter
*/
public function ajax_event(array $state)
{
list($timer, $action) = explode('-', $state['action']);
if (!in_array($timer, ['overall', 'specific']) || !in_array($action, ['start', 'stop', 'pause']))
{
throw new Api\Exception\WrongParameter("Invalid action '$state[action]'!");
}
if (empty($state['ts']))
{
throw new Api\Exception\WrongParameter("Invalid timestamp ('ts') '$state[ts]'!");
}
$type = ($timer === 'overall' ? self::OVERALL : 0) |
($action === 'start' ? self::START : ($action === 'stop' ? self::STOP : self::PAUSE));
$app = $id = $ts_id = null;
if ($timer === 'specific' && !empty($state['specific']['app_id']))
{
list($app, $id) = explode('::', $state['specific']['app_id'], 2);
if ($app === self::APP)
{
$ts_id = $id;
}
}
$this->init();
$this->save([
'tse_timestamp' => new Api\DateTime(),
'tse_time' => new Api\DateTime($state['ts']),
'account_id' => $this->user,
'tse_modifier' => $this->user,
'tse_type' => $type,
'tse_app' => $app,
'tse_app_id' => $id,
'ts_id' => $ts_id,
]);
// create timesheet for stopped working time
if ($timer === 'overall' && $action === 'stop')
{
try {
$minutes = $this->storeWorkingTime();
Api\Json\Response::get()->message(lang('Working time of %1 hours stored',
sprintf('%d:%02d', intdiv($minutes, 60), $minutes % 60)), 'success');
}
catch(\Exception $e) {
Api\Json\Response::get()->message(lang('Error storing working time').': '.$e->getMessage(), 'error');
}
}
// return (new) tse_id
Api\Json\Response::get()->data($this->data['tse_id']);
}
/**
* Set app::id on an already started timer
*
* @param int $tse_id id of the running timer-event
* @param string $app_id "app::id" string
* @return void
* @throws Api\Db\Exception\InvalidSql
*/
public function ajax_updateAppId(int $tse_id, string $app_id)
{
if ($tse_id > 0 && !empty($app_id))
{
list($app, $id) = explode('::', $app_id);
$this->db->update(self::TABLE, [
'tse_app' => $app,
'tse_app_id' => $id,
], [
'tse_id' => $tse_id,
'account_id' => $this->user,
'ts_id IS NULL',
], __LINE__, __FILE__, self::APP);
}
}
/**
* Set tse_time on an already started timer
*
* @param int $tse_id id of the running timer-event
* @param string $time
* @return void
* @throws Api\Db\Exception\InvalidSql
*/
public function ajax_updateTime(int $tse_id, string $time)
{
if ($tse_id > 0 && !empty($time) && ($event = $this->read($tse_id)))
{
$time = new Api\DateTime($time);
$this->db->update(self::TABLE, [
'tse_time' => $time,
], [
'tse_id' => $tse_id,
'account_id' => $this->user,
], __LINE__, __FILE__, self::APP);
// if a stop event is overwritten, we need to adjust the timesheet duration and quantity
if (!empty($event['ts_id']) && ($diff = round(($time->getTimestamp() - $event['tse_time']->getTimestamp()) / 60)))
{
$bo = new \timesheet_bo();
if ($bo->read($event['ts_id']))
{
$bo->data['ts_duration'] += $diff;
$bo->data['ts_quantity'] += $diff / 60;
// set title, in case date(s) changed
$events = self::get(['ts_id' => $event['ts_id']]);
$bo->data['ts_title'] = self::workingTimeTitle($events);
$bo->save();
}
}
}
}
/**
* Generate working time title
*
* @param array $events
* @param Api\DateTime|null &$start on return start-time
* @return string
*/
protected static function workingTimeTitle(array $events, Api\DateTime &$start=null)
{
$start = array_shift($events)['tse_time'];
$timespan = Api\DateTime::to($start, true);
$last = array_pop($events);
if ($timespan !== ($end = Api\DateTime::to($last['tse_time'], true)))
{
$timespan .= ' - '.$end;
}
return lang('Working time from %1', $timespan);
}
/**
* Store pending overall timer events as a working time timesheet
*
* @return int minutes
*/
public function storeWorkingTime()
{
if (!($events = self::getPending(true, $time, $paused)) || !$time)
{
throw new Api\Exception\AssertionFailed("No pending overall events!");
}
$ids = array_keys($events);
$bo = new \timesheet_bo();
// check if we already have a timesheet for the current period
if (($period_ts = $bo->periodeWorkingTimesheet(reset($events)['tse_time'])))
{
$events = array_merge(self::get(['ts_id' => $period_ts['ts_id']], $period_total, $period_paused), $events);
$time += $period_total;
$paused += $period_paused;
}
$title = self::workingTimeTitle($events, $start);
$bo->init($period_ts);
$bo->save([
'ts_title' => $title,
'cat_id' => self::workingTimeCat(),
'ts_start' => $start,
'start_time' => Api\DateTime::server2user($start, 'H:s'),
'end_time' => '',
'ts_duration' => $minutes = round($time / 60),
'ts_quantity' => $minutes / 60.0,
'ts_paused' => round($paused / 60),
'ts_owner' => $this->user,
]);
self::addToTimesheet($bo->data['ts_id'], $ids);
return $minutes;
}
/**
* Name of session variable to not ask again if user denied starting working time
*/
const DONT_ASK_AGAIN_WORKING_TIME = 'dont-ask-again-working-time';
/**
* Get state of timer
*
* @return array[]|void
*/
public static function timerState()
{
try {
$state = [
'overall' => [
'offset' => 0,
'start' => null,
'paused' => false,
'last' => null,
'dont_ask' => Api\Cache::getSession(__CLASS__, self::DONT_ASK_AGAIN_WORKING_TIME),
],
'specific' => [
'offset' => 0,
'start' => null,
'paused' => false,
'last' => null,
],
];
foreach(self::getInstance()->search('', false, 'tse_id', '', '', false, 'AND', false, [
'ts_id' => null,
'account_id' => self::getInstance()->user,
]) as $row)
{
if ($row['tse_type'] & self::OVERALL)
{
self::evaluate($state['overall'], $row);
}
else
{
self::evaluate($state['specific'], $row);
// if event is associated with an app:id, set it again
if ($row['tse_app'] && $row['tse_app_id'])
{
$state['specific']['app_id'] = $row['tse_app'].'::'.$row['tse_app_id'];
}
}
}
// format start-times in UTZ as JavaScript Date() understands
foreach($state as &$timer)
{
foreach(['start', 'started', 'last'] as $name)
{
if (isset($timer[$name]))
{
$timer[$name] = (new Api\DateTime($timer[$name], new \DateTimeZone('UTC')))->format(Api\DateTime::ET2);
}
}
}
// send timer configuration to client-side
$config = Api\Config::read(self::APP);
$state['disable'] = $config['disable_timer'] ?? [];
return $state;
}
catch (\Exception $e) {
_egw_log_exception($e);
}
}
/**
* Remember for 18h or forever to not ask again to start working time
*
* @param ?bool $never true: never ask again, set preference, otherwise remember in session for 18h
* @return void
*/
static function ajax_dontAskAgainWorkingTime(bool $never=null)
{
if ($never)
{
$prefs = new Api\Preferences($GLOBALS['egw_info']['user']['account_id']);
$prefs->read_repository();
$prefs->user['timesheet']['workingtime_session'] = 'no';
$prefs->save_repository();
}
else
{
Api\Cache::setSession(__CLASS__, self::DONT_ASK_AGAIN_WORKING_TIME, true, 18*3600);
}
}
/**
* Evaluate events
*
* Returned time and offset/duration are always rounded to full minutes, independent of their actual unit!
* This is done, as we only show full minutes in the UI, and we want to avoid seconds in timestamps
* leading to unexpected results in the minutes display.
*
* @param array& $timer array with keys 'start', 'offset' and 'paused'
* @param array $row
* @return int? time in ms for stop or pause events, null for start
*/
protected static function evaluate(array &$timer, array $row)
{
// paused timer is started or stopped
if ($timer['paused'] && !($row['tse_type'] & self::PAUSE))
{
$timer['was_paused'] = 60000 * round(($row['tse_time']->getTimestamp() - $timer['pause_started']->getTimestamp())/60);
}
else
{
unset($timer['was_paused']);
}
if ($row['tse_type'] & self::START)
{
$timer['start'] = $timer['started'] = $row['tse_time'];
$timer['started_id'] = $row['tse_id'];
$timer['paused'] = false;
}
elseif ($timer['start'])
{
$timer['offset'] += $time = 60000 * round(($row['tse_time']->getTimestamp() - $timer['start']->getTimestamp())/60);
$timer['start'] = null;
$timer['paused'] = ($row['tse_type'] & self::PAUSE) === self::PAUSE;
}
else // stop of paused timer
{
$timer['paused'] = ($row['tse_type'] & self::PAUSE) === self::PAUSE;
}
if ($timer['paused'])
{
$timer['pause_started'] = $row['tse_time'];
}
$timer['last'] = $row['tse_time'];
$timer['id'] = $row['tse_id'];
return $time ?? null;
}
/**
* Get events of given ts_id or a filter
*
* Not stopped events-sequences are NOT returned (stopped sequences end with a stop event).
*
* @param int|array $filter
* @param ?int &$total=null on return time in seconds
* @param ?int &$paused on return paused time in seconds
* @return array[] tse_id => array pairs plus extra key sum (time-sum in seconds)
*/
public static function get($filter, ?int &$total=null, ?int &$paused=null)
{
if (!is_array($filter))
{
$filter = ['ts_id' => $filter];
}
$timer = $init_timer = [
'start' => null,
'offset' => 0,
'paused' => false,
];
$total = $open = $paused = 0;
$events = [];
foreach(self::getInstance()->search('', false, 'tse_id', '', '',
false, 'AND', false, $filter) as $row)
{
$time = self::evaluate($timer, $row);
++$open;
if ($row['tse_type'] & self::STOP)
{
$row['total'] = $total += $timer['offset'] / 1000;
$timer = $init_timer;
$open = 0;
}
elseif ($row['tse_type'] & self::PAUSE)
{
$row['total'] = $total + $timer['offset'] / 1000;
}
$row['time'] = $time / 1000;
$row['paused'] = !empty($timer['was_paused']) ? $paused += $timer['was_paused']/1000 : null;
$events[$row['tse_id']] = $row;
}
// remove open / unstopped timer events
if ($open)
{
$events = array_slice($events, 0, -$open, true);
}
return $events;
}
/**
* Get pending (overall) events of (current) user
*
* Not stopped events-sequences are NOT returned (stopped sequences end with a stop event).
*
* @param bool $overall
* @param ?int &$time=null on return total time in seconds
* @param ?int &$paused=null on return total paused time in seconds
* @return array[] tse_id => array pairs
*/
public static function getPending($overall=false, ?int &$time=null, ?int &$paused=null)
{
return self::get([
'ts_id' => null,
'account_id' => self::getInstance()->user,
($overall ? '' : 'NOT ').'(tse_type & '.self::OVERALL.')',
], $time, $paused);
}
/**
* Add given events to timesheet / set ts_id parameter
*
* @param int $ts_id
* @param int[] $events array of tse_id's
* @return Api\ADORecordSet|false|int
* @throws Api\Db\Exception\InvalidSql
*/
public static function addToTimesheet(int $ts_id, array $events)
{
return self::getInstance()->db->update(self::TABLE, ['ts_id' => $ts_id], ['tse_id' => $events], __LINE__, __FILE__, self::APP);
}
/**
* Register site config validation hooks
*/
public static function config_validate()
{
$GLOBALS['egw_info']['server']['found_validation_hook'] = [
'final_validation' => self::class.'::final_validation',
];
}
/**
* Final validation called after storing the config
*
* @param array $config
* @param Api\Config $c
*/
public static function final_validation($config, Api\Config $c)
{
// check if category for 'working time' is configured, otherwise create and store it
if ($config['working_time_cat'] === '')
{
$c->config_data['working_time_cat'] = self::workingTimeCat();
}
}
/**
* Get working time category, create it if not yet configured
*
* @return int
*/
public static function workingTimeCat()
{
$config = Api\Config::read(self::APP);
if (empty($config['working_time_cat']) || !Api\Categories::read($config['working_time_cat']))
{
$cats = new Api\Categories(Api\Categories::GLOBAL_ACCOUNT, Api\Categories::GLOBAL_APPNAME);
Api\Config::save_value('working_time_cat', $config['working_time_cat'] = $cats->add([
'name' => lang('Working time'),
//'data' => ['color' => '#ffb6c1'],
'description' => lang('Created by TimeSheet configuration'),
]), self::APP);
}
return $config['working_time_cat'];
}
}