OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
64
app/Domain/Calendar/Controllers/AddEvent.php
Normal file
64
app/Domain/Calendar/Controllers/AddEvent.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* newClient Class - Add a new client
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Support\FromFormat;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AddEvent extends Controller
|
||||
{
|
||||
private Calendar $calendarService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(Calendar $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
#[RequiresPermission(CalendarPermissions::CREATE)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$values = [
|
||||
'description' => '',
|
||||
'dateFrom' => '',
|
||||
'dateTo' => '',
|
||||
'allDay' => '',
|
||||
];
|
||||
|
||||
$this->tpl->assign('values', $values);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.addEvent');
|
||||
}
|
||||
|
||||
#[RequiresPermission(CalendarPermissions::CREATE)]
|
||||
public function post(array $params): Response
|
||||
{
|
||||
|
||||
// Time comes in as 24:00 time from html5 element. Make it user date format
|
||||
$params['timeFrom'] = format(value: $params['timeFrom'], fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
|
||||
$params['timeTo'] = format(value: $params['timeTo'], fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
|
||||
$result = $this->calendarService->addEvent($params);
|
||||
|
||||
if (is_numeric($result) === true) {
|
||||
$this->tpl->setNotification('notification.event_created_successfully', 'success');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/calendar/editEvent/'.$result);
|
||||
} else {
|
||||
$this->tpl->setNotification('notification.please_enter_title', 'error');
|
||||
$this->tpl->assign('values', $params);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.addEvent');
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/Domain/Calendar/Controllers/CalendarSettings.php
Normal file
61
app/Domain/Calendar/Controllers/CalendarSettings.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* CalendarSettings Controller - Displays calendar settings modal.
|
||||
*
|
||||
* Plugins can register additional settings sections via the filter:
|
||||
* 'leantime.domain.calendar.controllers.calendarsettings.get.calendarSettings.sections'
|
||||
*
|
||||
* Plugins can mark calendars as plugin-managed (hides edit/delete UI) via the filter:
|
||||
* 'leantime.domain.calendar.controllers.calendarsettings.get.calendarSettings.externalCalendars'
|
||||
*
|
||||
* Section array structure:
|
||||
* - id: string (unique identifier)
|
||||
* - icon: string (FontAwesome class or SVG markup)
|
||||
* - iconType: string ('fontawesome'|'svg', default 'fontawesome')
|
||||
* - title: string (section heading)
|
||||
* - description: string (optional description text)
|
||||
* - content: string (raw HTML content - plugin must sanitize)
|
||||
* - actions: array (optional action buttons with url, label, class, icon, type keys)
|
||||
*/
|
||||
class CalendarSettings extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initialize the controller with dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the Calendar Settings modal.
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
// Get external calendars for the current user
|
||||
$externalCalendars = $this->calendarService->getMyExternalCalendars((int) session('userdata.id'));
|
||||
|
||||
// Plugins can augment calendar data (e.g., add 'managedByPlugin' => true to hide edit/delete)
|
||||
$externalCalendars = self::dispatchFilter('calendarSettings.externalCalendars', $externalCalendars);
|
||||
|
||||
// Plugins can add their settings sections via this filter
|
||||
$pluginSections = self::dispatchFilter('calendarSettings.sections', []);
|
||||
|
||||
$this->tpl->assign('externalCalendars', $externalCalendars);
|
||||
$this->tpl->assign('pluginSections', $pluginSections);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.calendarSettings');
|
||||
}
|
||||
}
|
||||
72
app/Domain/Calendar/Controllers/ConnectCalendar.php
Normal file
72
app/Domain/Calendar/Controllers/ConnectCalendar.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* ConnectCalendar Controller - Displays extensible modal for connecting external calendars.
|
||||
*
|
||||
* Plugins can register additional calendar providers via the filter:
|
||||
* 'leantime.domain.calendar.controllers.connectcalendar.get.connectOptions.providers'
|
||||
*
|
||||
* Provider array structure:
|
||||
* - id: string (unique identifier)
|
||||
* - icon: string (FontAwesome class or SVG markup)
|
||||
* - iconType: string ('fontawesome'|'svg', default 'fontawesome')
|
||||
* - title: string (display name)
|
||||
* - description: string (short description)
|
||||
* - actionUrl: string (URL to connect)
|
||||
* - actionLabel: string (button text)
|
||||
* - actionType: string ('link'|'modal', default 'link')
|
||||
*/
|
||||
class ConnectCalendar extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initialize the controller with dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the Connect Calendar modal with available providers.
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::CREATE)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$providers = self::dispatchFilter('connectOptions.providers', []);
|
||||
|
||||
$this->tpl->assign('providers', $providers);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.connectCalendar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle iCal calendar import submission.
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::CREATE)]
|
||||
public function post(array $params): Response
|
||||
{
|
||||
if (isset($params['name']) || isset($params['url'])) {
|
||||
$values = [
|
||||
'url' => $params['url'] ?? '',
|
||||
'name' => $params['name'] ?? 'My Calendar',
|
||||
'colorClass' => $params['colorClass'] ?? '#082236',
|
||||
];
|
||||
|
||||
$this->calendarService->addExternalCalendarUrl($values);
|
||||
$this->tpl->setNotification('notification.gcal_imported_successfully', 'success', 'externalcalendar_created');
|
||||
}
|
||||
|
||||
return FrontcontrollerCore::redirect(BASE_URL.'/calendar/showMyCalendar');
|
||||
}
|
||||
}
|
||||
61
app/Domain/Calendar/Controllers/DelEvent.php
Normal file
61
app/Domain/Calendar/Controllers/DelEvent.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* delClient Class - Deleting clients
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DelEvent extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieves delete calendar event page data
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::DELETE)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayPartial('calendar.delEvent');
|
||||
}
|
||||
|
||||
/**
|
||||
* sets, creates, and updates edit calendar event page data
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::DELETE)]
|
||||
public function post(array $params): Response
|
||||
{
|
||||
|
||||
if (isset($_GET['id']) === false) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/calendar/showMyCalendar/');
|
||||
}
|
||||
|
||||
$id = (int) $_GET['id'];
|
||||
$result = $this->calendarService->delEvent($id);
|
||||
|
||||
if (is_numeric($result) === true) {
|
||||
$this->tpl->setNotification('notification.event_removed_successfully', 'success');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/calendar/showMyCalendar/');
|
||||
} else {
|
||||
$this->tpl->setNotification('notification.could_not_delete_event', 'error');
|
||||
|
||||
return $this->tpl->displayPartial('calendar.delEvent');
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/Domain/Calendar/Controllers/DelExternalCalendar.php
Normal file
62
app/Domain/Calendar/Controllers/DelExternalCalendar.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* delClient Class - Deleting clients
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DelExternalCalendar extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieves delete calendar event page data
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::DELETE)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
return $this->tpl->displayPartial('calendar.delExternalCal');
|
||||
}
|
||||
|
||||
/**
|
||||
* sets, creates, and updates edit calendar event page data
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::DELETE)]
|
||||
public function post(array $params): Response
|
||||
{
|
||||
|
||||
if (isset($_GET['id']) === false) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/calendar/showMyCalendar/');
|
||||
}
|
||||
|
||||
$id = (int) $_GET['id'];
|
||||
|
||||
$result = $this->calendarService->deleteGCal($id);
|
||||
|
||||
if ($result === true) {
|
||||
$this->tpl->setNotification('notification.calendar_removed_successfully', 'success');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/calendar/showMyCalendar/');
|
||||
} else {
|
||||
$this->tpl->setNotification('notification.could_not_delete_calendar', 'error');
|
||||
|
||||
return $this->tpl->displayPartial('calendar.delEvent');
|
||||
}
|
||||
}
|
||||
}
|
||||
64
app/Domain/Calendar/Controllers/EditEvent.php
Normal file
64
app/Domain/Calendar/Controllers/EditEvent.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* editEvent Class - Add a new client
|
||||
*/
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Support\FromFormat;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EditEvent extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* init - initialize private variables
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieves edit calendar event page data
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::EDIT)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$values = $this->calendarService->getEvent($params['id']);
|
||||
|
||||
$this->tpl->assign('values', $values);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.editEvent');
|
||||
}
|
||||
|
||||
/**
|
||||
* sets, creates, and updates edit calendar event page data
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::EDIT)]
|
||||
public function post(array $params): Response
|
||||
{
|
||||
$params['id'] = $_GET['id'] ?? null;
|
||||
|
||||
// Time comes in as 24:00 time from html5 element. Make it user date format
|
||||
$params['timeFrom'] = format(value: $params['timeFrom'], fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
|
||||
$params['timeTo'] = format(value: $params['timeTo'], fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
|
||||
|
||||
$result = $this->calendarService->editEvent($params);
|
||||
|
||||
if ($result === true) {
|
||||
$this->tpl->setNotification('notification.event_edited_successfully', 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification('notification.please_enter_title', 'error');
|
||||
}
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/calendar/editEvent/'.$params['id']);
|
||||
}
|
||||
}
|
||||
74
app/Domain/Calendar/Controllers/EditExternal.php
Normal file
74
app/Domain/Calendar/Controllers/EditExternal.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EditExternal extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the edit external calendar form.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::EDIT)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->display('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
$calendar = $this->calendarService->getExternalCalendar((int) $params['id'], session('userdata.id'));
|
||||
|
||||
$this->tpl->assign('values', $calendar);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.editExternalCalendar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles external calendar update.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::EDIT)]
|
||||
public function post(array $params): Response
|
||||
{
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->display('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
$id = (int) $params['id'];
|
||||
$calendar = $this->calendarService->getExternalCalendar($id, session('userdata.id'));
|
||||
$values = $calendar;
|
||||
|
||||
if (isset($_POST['save'])) {
|
||||
$values = [
|
||||
'id' => $calendar['id'],
|
||||
'url' => $_POST['url'],
|
||||
'name' => $_POST['name'],
|
||||
'colorClass' => $_POST['colorClass'],
|
||||
];
|
||||
|
||||
$this->calendarService->editExternalCalendar($values, $id);
|
||||
$this->tpl->setNotification('notification.external_calendar_edited', 'success', 'externalCalendar_edited');
|
||||
}
|
||||
|
||||
$this->tpl->assign('values', $values);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.editExternalCalendar');
|
||||
}
|
||||
}
|
||||
83
app/Domain/Calendar/Controllers/Export.php
Normal file
83
app/Domain/Calendar/Controllers/Export.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Export extends Controller
|
||||
{
|
||||
private SettingService $settingService;
|
||||
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(
|
||||
SettingService $settingService,
|
||||
CalendarService $calendarService
|
||||
): void {
|
||||
$this->settingService = $settingService;
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the calendar export page.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
if (isset($_GET['remove'])) {
|
||||
$this->settingService->deleteSetting('usersettings.'.session('userdata.id').'.icalSecret');
|
||||
$this->tpl->setNotification('notifications.ical_removed_success', 'success');
|
||||
}
|
||||
|
||||
$this->assignUrl();
|
||||
|
||||
return $this->tpl->displayPartial('calendar.export');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles iCal URL generation.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function post(array $params): Response
|
||||
{
|
||||
if (isset($_POST['generateUrl'])) {
|
||||
try {
|
||||
$this->calendarService->generateIcalHash();
|
||||
$this->tpl->setNotification('notifications.ical_success', 'success');
|
||||
} catch (\Exception $e) {
|
||||
$this->tpl->setNotification('There was a problem generating the ical hash', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$this->assignUrl();
|
||||
|
||||
return $this->tpl->displayPartial('calendar.export');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the iCal URL to the template.
|
||||
*/
|
||||
private function assignUrl(): void
|
||||
{
|
||||
$icalUrl = '';
|
||||
try {
|
||||
$icalUrl = $this->calendarService->getICalUrl();
|
||||
} catch (\Exception $e) {
|
||||
$this->tpl->setNotification('Could not find ical URL', 'error');
|
||||
}
|
||||
|
||||
$this->tpl->assign('url', $icalUrl);
|
||||
}
|
||||
}
|
||||
40
app/Domain/Calendar/Controllers/ExternalCal.php
Normal file
40
app/Domain/Calendar/Controllers/ExternalCal.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ExternalCal extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an external calendar's iCal content, with session-based caching.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$content = $this->calendarService->getCachedExternalCalendarContent(
|
||||
(int) ($params['id'] ?? 0),
|
||||
(int) session('userdata.id')
|
||||
);
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => 'text/calendar; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
app/Domain/Calendar/Controllers/Ical.php
Normal file
43
app/Domain/Calendar/Controllers/Ical.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Ical extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the iCal feed for a given calendar hash.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
try {
|
||||
$calendar = $this->calendarService->getIcalByRequestToken(
|
||||
$_GET['id'] ?? '',
|
||||
$params['act'] ?? ''
|
||||
);
|
||||
|
||||
return new Response($calendar->get(), 200, [
|
||||
'Content-Type' => 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="leantime-calendar.ics"',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/errors/404');
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/Domain/Calendar/Controllers/ImportGCal.php
Normal file
63
app/Domain/Calendar/Controllers/ImportGCal.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ImportGCal extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the import calendar form.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::CREATE)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$this->tpl->assign('values', [
|
||||
'url' => '',
|
||||
'name' => '',
|
||||
'colorClass' => '',
|
||||
]);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.importGCal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles external calendar URL import.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::CREATE)]
|
||||
public function post(array $params): Response
|
||||
{
|
||||
$values = [
|
||||
'url' => $_POST['url'] ?? '',
|
||||
'name' => $_POST['name'] ?? 'My Calendar',
|
||||
'colorClass' => $_POST['colorClass'] ?? '#082236',
|
||||
];
|
||||
|
||||
if (isset($_POST['name']) || isset($_POST['url'])) {
|
||||
$this->calendarService->addExternalCalendarUrl($values);
|
||||
$this->tpl->setNotification('notification.gcal_imported_successfully', 'success', 'externalcalendar_created');
|
||||
}
|
||||
|
||||
$this->tpl->assign('values', $values);
|
||||
|
||||
return $this->tpl->displayPartial('calendar.importGCal');
|
||||
}
|
||||
}
|
||||
35
app/Domain/Calendar/Controllers/ShowAllGCals.php
Normal file
35
app/Domain/Calendar/Controllers/ShowAllGCals.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShowAllGCals extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays all external calendars.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$this->tpl->assign('allCalendars', $this->calendarService->getMyExternalCalendars(session('userdata.id')));
|
||||
|
||||
return $this->tpl->display('calendar.showAllGCals');
|
||||
}
|
||||
}
|
||||
41
app/Domain/Calendar/Controllers/ShowMyCalendar.php
Normal file
41
app/Domain/Calendar/Controllers/ShowMyCalendar.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShowMyCalendar extends Controller
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the user's calendar view.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$this->tpl->assign('calendar', $this->calendarService->getCalendar(session('userdata.id')));
|
||||
|
||||
session(['lastPage' => BASE_URL.'/calendar/showMyCalendar/']);
|
||||
|
||||
$externalCalendars = $this->calendarService->getMyExternalCalendars(session('userdata.id'));
|
||||
$externalCalendars = self::dispatch_filter('showMyCalendar.externalCalendars', $externalCalendars);
|
||||
$this->tpl->assign('externalCalendars', $externalCalendars);
|
||||
|
||||
return $this->tpl->display('calendar.showMyCalendar');
|
||||
}
|
||||
}
|
||||
489
app/Domain/Calendar/Js/calendarController.js
Normal file
489
app/Domain/Calendar/Js/calendarController.js
Normal file
@@ -0,0 +1,489 @@
|
||||
leantime.calendarController = (function () {
|
||||
|
||||
var closeModal = false;
|
||||
|
||||
// Latest todo-draggable initializer, refreshed on each initWidgetCalendar() call. The single
|
||||
// global htmx.onLoad handler (registered once) calls THIS, so reloading the calendar widget
|
||||
// rewires drag/drop to the current calendar instance instead of a stale closure.
|
||||
var latestTodoDraggableInit = null;
|
||||
|
||||
//Functions
|
||||
var initCalendar = function (userEvents) {
|
||||
|
||||
var date = new Date();
|
||||
var d = date.getDate();
|
||||
var m = date.getMonth();
|
||||
var y = date.getFullYear();
|
||||
|
||||
var heightWindow = jQuery("body").height() - 260;
|
||||
|
||||
var calendar = jQuery('#calendar').fullCalendar({
|
||||
timeZone: leantime.i18n.__("usersettings.timezone"),
|
||||
height: heightWindow,
|
||||
header: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'month,agendaWeek,agendaDay,listDay'
|
||||
},
|
||||
titleFormat: {
|
||||
month: 'MMMM yyyy',
|
||||
week: "MMM d[ yyyy]{ '—'[ MMM] d yyyy}",
|
||||
day: 'dddd, MMM d, yyyy'
|
||||
},
|
||||
columnFormat: {
|
||||
month: leantime.i18n.__("language.columnFormatMonth"),
|
||||
week: leantime.i18n.__("language.columnFormatWeek"),
|
||||
day: leantime.i18n.__("language.columnFormatday")
|
||||
},
|
||||
timeFormat: { // for event elements
|
||||
'': leantime.dateHelper.getFormatFromSettings("timeformat", "luxon")
|
||||
},
|
||||
// locale
|
||||
isRTL: leantime.i18n.__("language.isRTL") == "false" ? 0 : 1,
|
||||
firstDay: leantime.i18n.__("language.firstDayOfWeek"),
|
||||
monthNames: leantime.i18n.__("language.monthNames").split(","),
|
||||
monthNamesShort: leantime.i18n.__("language.monthNamesShort").split(","),
|
||||
dayNames: leantime.i18n.__("language.dayNames").split(","),
|
||||
dayNamesShort: leantime.i18n.__("language.dayNamesShort").split(","),
|
||||
buttonText: {
|
||||
prev: '«',
|
||||
next: '»',
|
||||
prevYear: ' << ',
|
||||
nextYear: ' >> ',
|
||||
today: leantime.i18n.__("buttons.today"),
|
||||
month: leantime.i18n.__("buttons.month"),
|
||||
week: leantime.i18n.__("buttons.week"),
|
||||
day: leantime.i18n.__("buttons.day")
|
||||
},
|
||||
select: function (start, end, allDay) {
|
||||
var title = prompt(leantime.i18n.__("label.event_title"));
|
||||
if (title) {
|
||||
calendar.fullCalendar(
|
||||
'renderEvent',
|
||||
{
|
||||
title: title,
|
||||
start: start,
|
||||
end: end,
|
||||
allDay: allDay
|
||||
},
|
||||
true // make the event "stick"
|
||||
);
|
||||
}
|
||||
calendar.fullCalendar('unselect');
|
||||
},
|
||||
events: userEvents,
|
||||
eventColor: '#0866c6'
|
||||
});
|
||||
};
|
||||
|
||||
var initEventDatepickers = function () {
|
||||
|
||||
jQuery(document).ready(function () {
|
||||
|
||||
Date.prototype.addDays = function (days) {
|
||||
this.setDate(this.getDate() + days);
|
||||
return this;
|
||||
};
|
||||
jQuery.datepicker.setDefaults(
|
||||
{ beforeShow: function (i) {
|
||||
if (jQuery(i).attr('readonly')) {
|
||||
return false; } } }
|
||||
);
|
||||
|
||||
var dateFormat = leantime.dateHelper.getFormatFromSettings("dateformat", "jquery");
|
||||
|
||||
from = jQuery("#event_date_from")
|
||||
.datepicker(
|
||||
{
|
||||
numberOfMonths: 1,
|
||||
dateFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"),
|
||||
dayNames: leantime.i18n.__("language.dayNames").split(","),
|
||||
dayNamesMin: leantime.i18n.__("language.dayNamesMin").split(","),
|
||||
dayNamesShort: leantime.i18n.__("language.dayNamesShort").split(","),
|
||||
monthNames: leantime.i18n.__("language.monthNames").split(","),
|
||||
currentText: leantime.i18n.__("language.currentText"),
|
||||
closeText: leantime.i18n.__("language.closeText"),
|
||||
buttonText: leantime.i18n.__("language.buttonText"),
|
||||
isRTL: leantime.i18n.__("language.isRTL") === "true" ? 1 : 0,
|
||||
nextText: leantime.i18n.__("language.nextText"),
|
||||
prevText: leantime.i18n.__("language.prevText"),
|
||||
weekHeader: leantime.i18n.__("language.weekHeader"),
|
||||
firstDay: leantime.i18n.__("language.firstDayOfWeek"),
|
||||
}
|
||||
)
|
||||
.on(
|
||||
"change",
|
||||
function (date) {
|
||||
to.datepicker("option", "minDate", getDate(this));
|
||||
|
||||
if (jQuery("#event_date_to").val() == '') {
|
||||
jQuery("#event_date_to").val(jQuery("#event_date_from").val());
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
to = jQuery("#event_date_to").datepicker(
|
||||
{
|
||||
numberOfMonths: 1,
|
||||
dateFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"),
|
||||
dayNames: leantime.i18n.__("language.dayNames").split(","),
|
||||
dayNamesMin: leantime.i18n.__("language.dayNamesMin").split(","),
|
||||
dayNamesShort: leantime.i18n.__("language.dayNamesShort").split(","),
|
||||
monthNames: leantime.i18n.__("language.monthNames").split(","),
|
||||
currentText: leantime.i18n.__("language.currentText"),
|
||||
closeText: leantime.i18n.__("language.closeText"),
|
||||
buttonText: leantime.i18n.__("language.buttonText"),
|
||||
isRTL: leantime.i18n.__("language.isRTL") === "true" ? 1 : 0,
|
||||
nextText: leantime.i18n.__("language.nextText"),
|
||||
prevText: leantime.i18n.__("language.prevText"),
|
||||
weekHeader: leantime.i18n.__("language.weekHeader"),
|
||||
firstDay: leantime.i18n.__("language.firstDayOfWeek"),
|
||||
}
|
||||
)
|
||||
.on(
|
||||
"change",
|
||||
function () {
|
||||
from.datepicker("option", "maxDate", getDate(this));
|
||||
}
|
||||
);
|
||||
|
||||
function getDate( element )
|
||||
{
|
||||
var date;
|
||||
try {
|
||||
date = jQuery.datepicker.parseDate(dateFormat, element.value);
|
||||
} catch ( error ) {
|
||||
date = null;
|
||||
console.log(error);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
|
||||
var initExportModal = function () {
|
||||
|
||||
var exportModalConfig = {
|
||||
sizes: {
|
||||
minW: 400,
|
||||
minH: 350
|
||||
},
|
||||
resizable: true,
|
||||
autoSizable: true,
|
||||
callbacks: {
|
||||
afterShowCont: function () {
|
||||
|
||||
jQuery(".formModal").nyroModal(exportModalConfig);
|
||||
},
|
||||
beforeClose: function () {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
titleFromIframe: true
|
||||
};
|
||||
jQuery(".exportModal").nyroModal(exportModalConfig);
|
||||
|
||||
}
|
||||
|
||||
var initWidgetCalendar = function (element, initialView) {
|
||||
|
||||
let calendarEl = document.querySelector(element);
|
||||
let userDateFormat = leantime.dateHelper.getFormatFromSettings("dateformat", "luxon");
|
||||
let userTimeFormat = leantime.dateHelper.getFormatFromSettings("timeformat", "luxon");
|
||||
|
||||
|
||||
const calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
timeZone: leantime.i18n.__("usersettings.timezone"),
|
||||
height: 'calc(100% - 65px)',
|
||||
stickyHeaderDates: true,
|
||||
initialView: initialView,
|
||||
eventStartEditable: true,
|
||||
dayHeaderFormat: userDateFormat,
|
||||
eventTimeFormat: userTimeFormat,
|
||||
slotLabelFormat: userTimeFormat,
|
||||
firstDay: leantime.i18n.__("language.firstDayOfWeek"),
|
||||
views: {
|
||||
multiMonthOneMonth: {
|
||||
type: 'multiMonth',
|
||||
duration: {months: 1},
|
||||
multiMonthTitleFormat: {month: 'long', year: 'numeric'},
|
||||
dayHeaderFormat: {weekday: 'short'},
|
||||
},
|
||||
timeGridDay: {
|
||||
dayHeaders: false
|
||||
},
|
||||
listWeek: {
|
||||
listDayFormat: {weekday: 'long'},
|
||||
listDaySideFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "luxon"),
|
||||
}
|
||||
},
|
||||
droppable: true,
|
||||
eventSources: eventSources,
|
||||
|
||||
editable: true,
|
||||
headerToolbar: false,
|
||||
nowIndicator: true,
|
||||
bootstrapFontAwesome: {
|
||||
close: 'fa-times',
|
||||
prev: 'fa-chevron-left',
|
||||
next: 'fa-chevron-right',
|
||||
prevYear: 'fa-angle-double-left',
|
||||
nextYear: 'fa-angle-double-right'
|
||||
},
|
||||
eventDrop: function (event) {
|
||||
if (event.event.extendedProps.enitityType == "ticket") {
|
||||
leantime.rpc('Tickets.Tickets.patchTicket', {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
values: {
|
||||
editFrom: luxon.DateTime.fromJSDate(event.event.start).toFormat(userDateFormat),
|
||||
timeFrom: luxon.DateTime.fromJSDate(event.event.start).toFormat(userTimeFormat),
|
||||
editTo: luxon.DateTime.fromJSDate(event.event.end).toFormat(userDateFormat),
|
||||
timeTo: luxon.DateTime.fromJSDate(event.event.end).toFormat(userTimeFormat),
|
||||
}
|
||||
}).catch(function (error) {
|
||||
jQuery.growl({ message: (error && error.message) ? error.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
|
||||
event.revert();
|
||||
console.error('Could not update ticket dates', error);
|
||||
});
|
||||
} else if (event.event.extendedProps.enitityType == "event") {
|
||||
leantime.rpc('Calendar.Calendar.patch', {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
params: {
|
||||
dateFrom: event.event.startStr,
|
||||
dateTo: event.event.endStr
|
||||
}
|
||||
}).then(function (success) {
|
||||
// Denied/failed update resolves to false — undo the visual move.
|
||||
if (! success) { event.revert(); }
|
||||
}).catch(function (error) {
|
||||
console.error('Could not update event dates', error);
|
||||
event.revert();
|
||||
})
|
||||
}
|
||||
},
|
||||
eventResize: function (event) {
|
||||
if (event.event.extendedProps.enitityType == "ticket") {
|
||||
leantime.rpc('Tickets.Tickets.patchTicket', {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
values: {
|
||||
editFrom: luxon.DateTime.fromJSDate(event.event.start).toFormat(userDateFormat),
|
||||
timeFrom: luxon.DateTime.fromJSDate(event.event.start).toFormat(userTimeFormat),
|
||||
editTo: luxon.DateTime.fromJSDate(event.event.end).toFormat(userDateFormat),
|
||||
timeTo: luxon.DateTime.fromJSDate(event.event.end).toFormat(userTimeFormat),
|
||||
}
|
||||
}).catch(function (error) {
|
||||
jQuery.growl({ message: (error && error.message) ? error.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
|
||||
event.revert();
|
||||
console.error('Could not update ticket dates', error);
|
||||
});
|
||||
} else if (event.event.extendedProps.enitityType == "event") {
|
||||
leantime.rpc('Calendar.Calendar.patch', {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
params: {
|
||||
dateFrom: event.event.startStr,
|
||||
dateTo: event.event.endStr
|
||||
}
|
||||
}).then(function (success) {
|
||||
// Denied/failed update resolves to false — undo the visual move.
|
||||
if (! success) { event.revert(); }
|
||||
}).catch(function (error) {
|
||||
console.error('Could not update event dates', error);
|
||||
event.revert();
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
eventReceive: function (event) {
|
||||
|
||||
leantime.rpc('Tickets.Tickets.patchTicket', {
|
||||
id: event.event.id,
|
||||
values: {
|
||||
editFrom: luxon.DateTime.fromJSDate(event.event.start).toFormat(userDateFormat),
|
||||
timeFrom: luxon.DateTime.fromJSDate(event.event.start).toFormat(userTimeFormat),
|
||||
editTo: luxon.DateTime.fromJSDate(event.event.end).toFormat(userDateFormat),
|
||||
timeTo: luxon.DateTime.fromJSDate(event.event.end).toFormat(userTimeFormat),
|
||||
}
|
||||
}).catch(function (error) {
|
||||
jQuery.growl({ message: (error && error.message) ? error.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
|
||||
event.revert();
|
||||
console.error('Could not update ticket dates', error);
|
||||
});
|
||||
|
||||
},
|
||||
eventDragStart: function (event) {
|
||||
|
||||
},
|
||||
eventDidMount: function (info) {
|
||||
|
||||
if (info.isDraggable === false) {
|
||||
jQuery(info.el).addClass("locked");
|
||||
}
|
||||
|
||||
if (info.event.extendedProps.location != null
|
||||
&& info.event.extendedProps.location != ""
|
||||
&& info.event.extendedProps.location.indexOf("http") == 0
|
||||
) {
|
||||
//jQuery(info.el).prepend("<div class='pull-right'><a href='"+info.event.extendedProps.location+"'>Join Call</a></div>")
|
||||
jQuery(info.el).attr("href", info.event.extendedProps.location);
|
||||
jQuery(info.el).attr("target", "_blank");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
jQuery(document).ready(function () {
|
||||
//let tickets = jQuery("#yourToDoContainer")[0];
|
||||
|
||||
// Set up draggable for each ticket box
|
||||
// setupDraggableTickets();
|
||||
//
|
||||
// Initialize the ThirdPartyDraggable for the todo container
|
||||
|
||||
if(jQuery("#yourToDoContainer").length > 0) {
|
||||
initializeThirdPartyDraggable(jQuery("#yourToDoContainer")[0]);
|
||||
}
|
||||
|
||||
initButtons();
|
||||
|
||||
calendar.scrollToTime(Date.now());
|
||||
|
||||
|
||||
});
|
||||
|
||||
// function setupDraggableTickets() {
|
||||
// jQuery("#yourToDoContainer").find(".ticketBox").each(function () {
|
||||
// setupTicketDraggable(jQuery(this));
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// function setupTicketDraggable(ticketElement) {
|
||||
// var currentTicket = ticketElement;
|
||||
// currentTicket.data('event', {
|
||||
// id: currentTicket.attr("data-val"),
|
||||
// title: currentTicket.find(".titleContainer strong").text(),
|
||||
// color: 'var(--accent2)',
|
||||
// enitityType: "ticket",
|
||||
// url: '#/tickets/showTicket/' + currentTicket.attr("data-val"),
|
||||
// });
|
||||
//
|
||||
// currentTicket.draggable({
|
||||
//
|
||||
// zIndex: 999999,
|
||||
// revert: true, // will cause the event to go back to its
|
||||
// revertDuration: 0, // original position after the drag
|
||||
// helper: "clone",
|
||||
// appendTo: '.maincontent',
|
||||
// cursor: "grab",
|
||||
// cursorAt: {bottom: 5, right: 5},
|
||||
// distance: 10, // Minimum distance before drag starts
|
||||
// delay: 150, // Small delay to allow for sortable to initialize first
|
||||
// });
|
||||
// }
|
||||
|
||||
function initializeThirdPartyDraggable(element) {
|
||||
|
||||
var tickets = element;
|
||||
if (tickets) {
|
||||
new FullCalendar.ThirdPartyDraggable(tickets, {
|
||||
itemSelector: '.draggable-todo',
|
||||
mirrorClass: 'dragging-mirror',
|
||||
eventDragMinDistance: 10,
|
||||
mirrorSelector: function (el) {
|
||||
return el.closest('.ticketBox');
|
||||
},
|
||||
eventData: function (eventEl) {
|
||||
|
||||
let ticketEventData = jQuery(eventEl).data("event");
|
||||
|
||||
return {
|
||||
id: ticketEventData.id,
|
||||
title: ticketEventData.title,
|
||||
color: ticketEventData.color,
|
||||
enitityType: "ticket",
|
||||
duration: '01:00',
|
||||
url: ticketEventData.url,
|
||||
};
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
calendar.scrollToTime(Date.now());
|
||||
};
|
||||
|
||||
// Point the shared reference at THIS init's closure (bound to the current calendar instance).
|
||||
latestTodoDraggableInit = initializeThirdPartyDraggable;
|
||||
|
||||
function initButtons() {
|
||||
|
||||
calendar.setOption('locale', leantime.i18n.__("language.code"));
|
||||
calendar.render();
|
||||
|
||||
calendar.scrollToTime(Date.now());
|
||||
|
||||
jQuery('.minCalendar .fc-prev-button').click(function () {
|
||||
calendar.prev();
|
||||
calendar.getCurrentData()
|
||||
});
|
||||
jQuery('.minCalendar .fc-next-button').click(function () {
|
||||
calendar.next();
|
||||
});
|
||||
jQuery('.minCalendar .fc-today-button').click(function () {
|
||||
calendar.today();
|
||||
});
|
||||
jQuery(".minCalendar .calendarViewSelect").on("click", function (e) {
|
||||
|
||||
var newView = jQuery(this).data("value");
|
||||
calendar.changeView(newView);
|
||||
|
||||
// Show the day selector only in day view
|
||||
if (newView === 'timeGridDay') {
|
||||
jQuery('.day-selector').show();
|
||||
} else {
|
||||
jQuery('.day-selector').hide();
|
||||
}
|
||||
|
||||
leantime.rpc('Api.Api.setSubmenuState', {
|
||||
submenu: "dashboardCalendarView",
|
||||
state: newView
|
||||
}).catch(function (e) { console.error('Could not update submenu state', e); });
|
||||
|
||||
});
|
||||
|
||||
// Initialize day selector buttons (only active in day view)
|
||||
jQuery('.day-button').on('click', function() {
|
||||
var date = jQuery(this).data('date');
|
||||
calendar.gotoDate(date);
|
||||
|
||||
// Update active state
|
||||
jQuery('.day-button').removeClass('active');
|
||||
jQuery(this).addClass('active');
|
||||
});
|
||||
}
|
||||
|
||||
// Register once. This runs inside an init function that HTMX re-invokes on every
|
||||
// swap, so without the guard each load stacked another global onLoad handler
|
||||
// (handler leak → compounding churn on the dashboard).
|
||||
if (!window.leantime._calendarTodoOnLoadRegistered) {
|
||||
window.leantime._calendarTodoOnLoadRegistered = true;
|
||||
htmx.onLoad(function (content) {
|
||||
// Find any todo containers that were loaded via HTMX. Call the LATEST initializer so
|
||||
// drag/drop binds to the current calendar instance, not the one from first init.
|
||||
if (content.id == "yourToDoContainer" && latestTodoDraggableInit) {
|
||||
latestTodoDraggableInit(content);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Make public what you want to have public, everything else is private
|
||||
return {
|
||||
initCalendar:initCalendar,
|
||||
initEventDatepickers:initEventDatepickers,
|
||||
initExportModal:initExportModal,
|
||||
initWidgetCalendar:initWidgetCalendar
|
||||
};
|
||||
})();
|
||||
63
app/Domain/Calendar/Permissions/CalendarPermissions.php
Normal file
63
app/Domain/Calendar/Permissions/CalendarPermissions.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
|
||||
|
||||
/**
|
||||
* The Calendar permission vocabulary.
|
||||
*
|
||||
* Calendar is a PERSONAL feature: events and external-calendar subscriptions belong to a user
|
||||
* (zp_calendar.userId / zp_gcallinks.userId). Two orthogonal axes:
|
||||
* - CAPABILITY (can you use the calendar at all) — the project-scoped standard verbs below,
|
||||
* resolved against the current project's role (mirroring the legacy editor+ authOrRedirect on
|
||||
* the controllers, which used the effective/project role). They auto-grant through the matrix
|
||||
* with NO DefaultRolePermissions edit: view→readonly+, create/edit/delete→editor+.
|
||||
* - OWNERSHIP (whose data) — enforced IN-BODY in the service (row.userId === currentUserId), with
|
||||
* a cross-user override for `manage`.
|
||||
*
|
||||
* `manage` is GLOBAL-scoped (admin+ only — it auto-grants through scope:any and is deliberately NOT
|
||||
* given to managers): it preserves the legacy admin-only cross-user override (patch's
|
||||
* userIsAllowedToUpdate used Auth::userIsAtLeast(admin)).
|
||||
*
|
||||
* Maintainer-approved loosening: VIEW moves from the legacy editor+ page gate to readonly+ — seeing
|
||||
* your OWN calendar is benign (ownership still fences whose events you see).
|
||||
*
|
||||
* The iCal feed methods (getIcalByHash / getIcalByRequestToken) are NOT part of this vocabulary:
|
||||
* they are served by the public, hash-authenticated /calendar/ical route with no session, so they
|
||||
* are de-@api'd rather than permission-gated.
|
||||
*/
|
||||
final class CalendarPermissions implements ProvidesPermissions
|
||||
{
|
||||
/** View your own calendar (events, external subscriptions, feed url). Readonly+. */
|
||||
public const VIEW = 'calendar.view';
|
||||
|
||||
/** Add events / connect external calendars to your own calendar. Editor+. */
|
||||
public const CREATE = 'calendar.create';
|
||||
|
||||
/** Edit your own events / external calendars. Editor+ (cross-user requires MANAGE). */
|
||||
public const EDIT = 'calendar.edit';
|
||||
|
||||
/** Delete your own events / external calendars. Editor+. */
|
||||
public const DELETE = 'calendar.delete';
|
||||
|
||||
/** Cross-user calendar override (act on another user's events). Admin+ (global). */
|
||||
public const MANAGE = 'calendar.manage';
|
||||
|
||||
public function domain(): string
|
||||
{
|
||||
return 'calendar';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
new Permission(self::VIEW, 'View your calendar', true),
|
||||
new Permission(self::CREATE, 'Add calendar events', true),
|
||||
new Permission(self::EDIT, 'Edit calendar events', true),
|
||||
new Permission(self::DELETE, 'Delete calendar events', true),
|
||||
new Permission(self::MANAGE, 'Manage any user\'s calendar', false),
|
||||
];
|
||||
}
|
||||
}
|
||||
449
app/Domain/Calendar/Repositories/Calendar.php
Normal file
449
app/Domain/Calendar/Repositories/Calendar.php
Normal file
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Repositories;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
use Leantime\Core\Db\Repository as RepositoryCore;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Support\EntityRelationshipEnum;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
use Leantime\Domain\Tickets\Services\Tickets;
|
||||
use Leantime\Domain\Users\Repositories\Users;
|
||||
|
||||
class Calendar extends RepositoryCore
|
||||
{
|
||||
public array $classColorMap = [
|
||||
'label-warning' => 'var(--yellow)',
|
||||
'label-purple' => 'var(--purple)',
|
||||
'label-pink' => 'var(--pink)',
|
||||
'label-darker-blue' => 'var(--darker-blue)',
|
||||
'label-info' => 'var(--dark-blue)',
|
||||
'label-blue' => 'var(--blue)',
|
||||
'label-dark-blue' => 'var(--dark-blue)',
|
||||
'label-success' => 'var(--green)',
|
||||
'label-brown' => 'var(--brown)',
|
||||
'label-danger' => 'var(--dark-red)',
|
||||
'label-important' => 'var(--red)',
|
||||
'label-green' => 'var(--green)',
|
||||
'label-default' => 'var(--grey)',
|
||||
'label-dark-green' => 'var(--dark-green)',
|
||||
'label-red' => 'var(--red)',
|
||||
'label-dark-red' => 'var(--dark-red)',
|
||||
'label-grey' => 'var(--grey)',
|
||||
];
|
||||
|
||||
protected string $entity = 'calendar';
|
||||
|
||||
private ConnectionInterface $db;
|
||||
|
||||
/**
|
||||
* Class constructor.
|
||||
*
|
||||
* @param DbCore $dbCore The DbCore object.
|
||||
* @param LanguageCore $language The LanguageCore object.
|
||||
* @param Environment $config The Environment object.
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
DbCore $dbCore,
|
||||
private LanguageCore $language,
|
||||
private Environment $config
|
||||
) {
|
||||
$this->db = $dbCore->getConnection();
|
||||
}
|
||||
|
||||
public function getAllDates(?CarbonImmutable $dateFrom, ?CarbonImmutable $dateTo): false|array
|
||||
{
|
||||
$query = $this->db->table('zp_calendar')
|
||||
->where('userId', session('userdata.id'))
|
||||
->whereNotNull('dateFrom');
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
$query->where('dateFrom', '>=', $dateFrom->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
if (! empty($dateTo)) {
|
||||
$query->where('dateTo', '<=', $dateTo->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
$results = $query->orderBy('dateFrom')->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves calendar events based on optional filters.
|
||||
*
|
||||
* @param int|null $userId The user ID to filter the results by.
|
||||
* @param CarbonImmutable|null $dateFrom The minimum date and time of the events.
|
||||
* @param CarbonImmutable|null $dateTo The maximum date and time of the events.
|
||||
* @return false|array Returns an array of calendar events if successful, otherwise false.
|
||||
*/
|
||||
public function getAll(?int $userId, ?CarbonImmutable $dateFrom, ?CarbonImmutable $dateTo): false|array
|
||||
{
|
||||
$query = $this->db->table('zp_calendar')
|
||||
->whereNotNull('dateFrom');
|
||||
|
||||
if (! empty($userId)) {
|
||||
$query->where('userId', '>=', $userId);
|
||||
}
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
$query->where('dateFrom', '>=', $dateFrom->formatDateTimeForDb());
|
||||
}
|
||||
|
||||
if (! empty($dateTo)) {
|
||||
$query->where('dateTo', '<=', $dateTo->formatDateTimeForDb());
|
||||
}
|
||||
|
||||
$results = $query->orderBy('dateFrom')->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function getCalendar(int $userId): array
|
||||
{
|
||||
$ticketService = app()->make(Tickets::class);
|
||||
$dbTickets = $ticketService->getOpenUserTicketsThisWeekAndLater($userId, '', true);
|
||||
|
||||
$tickets = [];
|
||||
if (isset($dbTickets['thisWeek']['tickets'])) {
|
||||
$tickets = array_merge($tickets, $dbTickets['thisWeek']['tickets']);
|
||||
}
|
||||
|
||||
if (isset($dbTickets['later']['tickets'])) {
|
||||
$tickets = array_merge($tickets, $dbTickets['later']['tickets']);
|
||||
}
|
||||
|
||||
if (isset($dbTickets['overdue']['tickets'])) {
|
||||
$tickets = array_merge($tickets, $dbTickets['overdue']['tickets']);
|
||||
}
|
||||
|
||||
$results = $this->db->table('zp_calendar')
|
||||
->where('userId', $userId)
|
||||
->whereNotNull('dateFrom')
|
||||
->get();
|
||||
|
||||
$values = array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
|
||||
$newValues = [];
|
||||
foreach ($values as $value) {
|
||||
$allDay = filter_var($value['allDay'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
|
||||
$newValues[] = [
|
||||
'title' => $value['description'],
|
||||
'allDay' => $allDay,
|
||||
'description' => '',
|
||||
'dateFrom' => $value['dateFrom'],
|
||||
'dateTo' => $value['dateTo'],
|
||||
'id' => $value['id'],
|
||||
'projectId' => '',
|
||||
'eventType' => 'calendar',
|
||||
'dateContext' => 'plan',
|
||||
'backgroundColor' => 'var(--accent1)',
|
||||
'borderColor' => 'var(--accent1)',
|
||||
'url' => BASE_URL.'/calendar/showMyCalendar/#/calendar/editEvent/'.$value['id'],
|
||||
];
|
||||
}
|
||||
|
||||
if (count($tickets)) {
|
||||
$statusLabelsArray = [];
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
if (! isset($statusLabelsArray[$ticket['projectId']])) {
|
||||
$statusLabelsArray[$ticket['projectId']] = $ticketService->getStatusLabels(
|
||||
$ticket['projectId']
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($statusLabelsArray[$ticket['projectId']][$ticket['status']])) {
|
||||
$statusName = $statusLabelsArray[$ticket['projectId']][$ticket['status']]['name'];
|
||||
$statusColor = $this->classColorMap[$statusLabelsArray[$ticket['projectId']][$ticket['status']]['class']];
|
||||
} else {
|
||||
$statusName = '';
|
||||
$statusColor = 'var(--grey)';
|
||||
}
|
||||
|
||||
$backgroundColor = 'var(--accent2)';
|
||||
|
||||
if (dtHelper()->isValidDateString($ticket['dateToFinish'])) {
|
||||
$context = '❕ '.$this->language->__('label.due_todo');
|
||||
|
||||
// Detect if the due date has no specific time set (stored as end-of-day 23:59:59).
|
||||
// If so, treat it as an all-day event to avoid timezone boundary issues
|
||||
// that cause the event to appear on two days in the calendar.
|
||||
$dueDate = dtHelper()->parseDbDateTime($ticket['dateToFinish']);
|
||||
$isEndOfDay = $dueDate->format('H:i:s') === '23:59:59';
|
||||
$allDay = $isEndOfDay;
|
||||
|
||||
$newValues[] = $this->mapEventData(
|
||||
title: $context.$ticket['headline'].' ('.$statusName.')',
|
||||
description: $ticket['description'],
|
||||
allDay: $allDay,
|
||||
id: $ticket['id'],
|
||||
projectId: $ticket['projectId'],
|
||||
eventType: 'ticket',
|
||||
dateContext: 'due',
|
||||
backgroundColor: $backgroundColor,
|
||||
borderColor: $statusColor,
|
||||
dateFrom: $ticket['dateToFinish'],
|
||||
dateTo: $ticket['dateToFinish']
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
dtHelper()->isValidDateString($ticket['editFrom'])
|
||||
&& dtHelper()->isValidDateString($ticket['editTo'])
|
||||
) {
|
||||
// Set ticket to all-day ticket when no time is set
|
||||
$dateFrom = dtHelper()->parseDbDateTime($ticket['editFrom']);
|
||||
$dateTo = dtHelper()->parseDbDateTime($ticket['editTo']);
|
||||
|
||||
$allDay = false;
|
||||
if ($dateFrom->diffInDays($dateTo) >= 1) {
|
||||
$allDay = true;
|
||||
}
|
||||
|
||||
$context = $this->language->__('label.planned_edit');
|
||||
|
||||
$newValues[] = $this->mapEventData(
|
||||
title: $context.$ticket['headline'].' ('.$statusName.')',
|
||||
description: $ticket['description'],
|
||||
allDay: $allDay,
|
||||
id: $ticket['id'],
|
||||
projectId: $ticket['projectId'],
|
||||
eventType: 'ticket',
|
||||
dateContext: 'edit',
|
||||
backgroundColor: $backgroundColor,
|
||||
borderColor: $statusColor,
|
||||
dateFrom: $ticket['editFrom'],
|
||||
dateTo: $ticket['editTo']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $newValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event array for fullcalendar.io frontend.
|
||||
*/
|
||||
private function mapEventData(
|
||||
string $title,
|
||||
?string $description,
|
||||
bool $allDay,
|
||||
int $id,
|
||||
int $projectId,
|
||||
string $eventType,
|
||||
string $dateContext,
|
||||
string $backgroundColor,
|
||||
string $borderColor,
|
||||
string $dateFrom,
|
||||
string $dateTo
|
||||
): array {
|
||||
return [
|
||||
'title' => $title,
|
||||
'allDay' => $allDay,
|
||||
'description' => $description,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'id' => $id,
|
||||
'projectId' => $projectId,
|
||||
'eventType' => $eventType,
|
||||
'dateContext' => $dateContext,
|
||||
'backgroundColor' => $backgroundColor,
|
||||
'borderColor' => $borderColor,
|
||||
'url' => BASE_URL.'/dashboard/home/#/tickets/showTicket/'.$id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function getCalendarBySecretHash(string $userHash, string $calHash): false|array
|
||||
{
|
||||
// get user
|
||||
$userRepo = app()->make(Users::class);
|
||||
$user = $userRepo->getUserBySha($userHash);
|
||||
|
||||
if (! isset($user['id'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if setting exists
|
||||
$settingService = app()->make(Setting::class);
|
||||
$hash = $settingService->getSetting('usersettings.'.$user['id'].'.icalSecret');
|
||||
|
||||
session([
|
||||
'usersettings.timezone' => $settingService->getSetting('usersettings.'.$user['id'].'.timezone') ?: $this->config->defaultTimezone,
|
||||
]);
|
||||
date_default_timezone_set(session('usersettings.timezone'));
|
||||
|
||||
if ($hash !== false && $calHash == $hash) {
|
||||
return $this->getCalendar($user['id']);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function getTicketWishDates(): false|array
|
||||
{
|
||||
$results = $this->db->table('zp_tickets')
|
||||
->select('id', 'headline', 'dateToFinish')
|
||||
->where(function ($query) {
|
||||
$query->where('userId', session('userdata.id'))
|
||||
->orWhere('editorId', (string) session('userdata.id'))
|
||||
->orWhereExists(function ($subquery) {
|
||||
$subquery->selectRaw('1')
|
||||
->from('zp_entity_relationship')
|
||||
->whereColumn('zp_entity_relationship.entityA', 'zp_tickets.id')
|
||||
->where('zp_entity_relationship.entityAType', 'Ticket')
|
||||
->where('zp_entity_relationship.entityBType', 'User')
|
||||
->where('zp_entity_relationship.relationship', EntityRelationshipEnum::Collaborator->value)
|
||||
->where('zp_entity_relationship.entityB', session('userdata.id'));
|
||||
});
|
||||
})
|
||||
->where('dateToFinish', '<>', '000-00-00 00:00:00')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
public function getTicketEditDates(): false|array
|
||||
{
|
||||
$results = $this->db->table('zp_tickets')
|
||||
->select('id', 'headline', 'editFrom', 'editTo')
|
||||
->where(function ($query) {
|
||||
$query->where('userId', session('userdata.id'))
|
||||
->orWhere('editorId', (string) session('userdata.id'))
|
||||
->orWhereExists(function ($subquery) {
|
||||
$subquery->selectRaw('1')
|
||||
->from('zp_entity_relationship')
|
||||
->whereColumn('zp_entity_relationship.entityA', 'zp_tickets.id')
|
||||
->where('zp_entity_relationship.entityAType', 'Ticket')
|
||||
->where('zp_entity_relationship.entityBType', 'User')
|
||||
->where('zp_entity_relationship.relationship', EntityRelationshipEnum::Collaborator->value)
|
||||
->where('zp_entity_relationship.entityB', session('userdata.id'));
|
||||
});
|
||||
})
|
||||
->where('editFrom', '<>', '000-00-00 00:00:00')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
public function addEvent(array $values): false|string
|
||||
{
|
||||
$id = $this->db->table('zp_calendar')->insertGetId([
|
||||
'userId' => session('userdata.id'),
|
||||
'dateFrom' => $values['dateFrom'],
|
||||
'dateTo' => $values['dateTo'],
|
||||
'description' => $values['description'],
|
||||
'allDay' => $values['allDay'],
|
||||
]);
|
||||
|
||||
return $id ? (string) $id : false;
|
||||
}
|
||||
|
||||
public function getEvent(int $id): mixed
|
||||
{
|
||||
$result = $this->db->table('zp_calendar')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : false;
|
||||
}
|
||||
|
||||
public function editEvent(array $values, int $id): void
|
||||
{
|
||||
$this->db->table('zp_calendar')
|
||||
->where('id', $id)
|
||||
->where('userId', session('userdata.id'))
|
||||
->update([
|
||||
'dateFrom' => $values['dateFrom'],
|
||||
'dateTo' => $values['dateTo'],
|
||||
'description' => $values['description'],
|
||||
'allDay' => $values['allDay'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function delPersonalEvent(int $id): int|false
|
||||
{
|
||||
return $this->db->table('zp_calendar')
|
||||
->where('id', $id)
|
||||
->where('userId', session('userdata.id'))
|
||||
->delete();
|
||||
}
|
||||
|
||||
public function getMyExternalCalendars(int $userId): false|array
|
||||
{
|
||||
$results = $this->db->table('zp_gcallinks')
|
||||
->select('id', 'url', 'name', 'colorClass')
|
||||
->where('userId', $userId)
|
||||
->get();
|
||||
|
||||
return array_map(fn ($item) => (array) $item, $results->toArray());
|
||||
}
|
||||
|
||||
public function getExternalCalendar(int $calendarId, int $userId): false|array
|
||||
{
|
||||
$result = $this->db->table('zp_gcallinks')
|
||||
->select('id', 'url', 'name', 'colorClass')
|
||||
->where('userId', $userId)
|
||||
->where('id', $calendarId)
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : false;
|
||||
}
|
||||
|
||||
public function getGCal(int $id): mixed
|
||||
{
|
||||
$result = $this->db->table('zp_gcallinks')
|
||||
->select('id', 'url', 'name', 'colorClass')
|
||||
->where('userId', session('userdata.id'))
|
||||
->where('id', $id)
|
||||
->limit(1)
|
||||
->first();
|
||||
|
||||
return $result ? (array) $result : false;
|
||||
}
|
||||
|
||||
public function editGUrl(array $values, int $id): void
|
||||
{
|
||||
$this->db->table('zp_gcallinks')
|
||||
->where('userId', session('userdata.id'))
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'url' => $values['url'],
|
||||
'name' => $values['name'],
|
||||
'colorClass' => $values['colorClass'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteGCal(int $id): bool
|
||||
{
|
||||
return $this->db->table('zp_gcallinks')
|
||||
->where('id', $id)
|
||||
->where('userId', session('userdata.id'))
|
||||
->delete() > 0;
|
||||
}
|
||||
|
||||
public function addGUrl(array $values): void
|
||||
{
|
||||
$this->db->table('zp_gcallinks')->insert([
|
||||
'userId' => session('userdata.id'),
|
||||
'name' => $values['name'],
|
||||
'url' => $values['url'],
|
||||
'colorClass' => $values['colorClass'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
945
app/Domain/Calendar/Services/Calendar.php
Normal file
945
app/Domain/Calendar/Services/Calendar.php
Normal file
@@ -0,0 +1,945 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Leantime\Core\Exceptions\MissingParameterException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Support\OutboundUrlGuard;
|
||||
use Leantime\Domain\Calendar\Permissions\CalendarPermissions;
|
||||
use Leantime\Domain\Calendar\Repositories\Calendar as CalendarRepository;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
use Leantime\Domain\Tickets\Services\Tickets;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Spatie\IcalendarGenerator\Components\Calendar as IcalCalendar;
|
||||
use Spatie\IcalendarGenerator\Components\Event as IcalEvent;
|
||||
use Spatie\IcalendarGenerator\Enums\Display;
|
||||
|
||||
/**
|
||||
* Calendar service: personal events, external-calendar subscriptions, and the public iCal feed.
|
||||
*
|
||||
* Authorization: the @api methods carry a dispatch #[RequiresPermission(calendar.*)] capability gate
|
||||
* (project-scoped, session project — readonly+ to view, editor+ to create/edit/delete). OWNERSHIP is
|
||||
* separate and user-based: reads that aren't already userId-scoped by the repository self-authorize
|
||||
* in-body (row.userId === currentUserId() OR can(MANAGE)); the userId PARAMS on the external-calendar
|
||||
* reads are ignored in favor of the session user (closing the RPC param-spoof). The iCal feed methods
|
||||
* are NOT @api — they are served by the public, hash-authenticated /calendar/ical route.
|
||||
*/
|
||||
class Calendar extends BaseService
|
||||
{
|
||||
private CalendarRepository $calendarRepo;
|
||||
|
||||
private LanguageCore $language;
|
||||
|
||||
private Setting $settingsRepo;
|
||||
|
||||
private Environment $config;
|
||||
|
||||
public function __construct(
|
||||
CalendarRepository $calendarRepo,
|
||||
LanguageCore $language,
|
||||
Setting $settingsRepo,
|
||||
Environment $config,
|
||||
) {
|
||||
$this->calendarRepo = $calendarRepo;
|
||||
$this->language = $language;
|
||||
$this->settingsRepo = $settingsRepo;
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a Google Calendar.
|
||||
*
|
||||
* @param int $id The ID of the Google Calendar to delete.
|
||||
* @return bool Returns true if the Google Calendar was successfully deleted, false otherwise.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::DELETE)]
|
||||
public function deleteGCal(int $id): bool
|
||||
{
|
||||
return $this->calendarRepo->deleteGCal($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches calendar event.
|
||||
*
|
||||
* @param int $id Id of the event to update (only events; tickets are updated via the ticket API).
|
||||
* @param array $params Key/value array of columns to update.
|
||||
* @return bool true on success, false on failure
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::EDIT)]
|
||||
public function patch(int $id, array $params): bool
|
||||
{
|
||||
// The event's owner can always change it; a cross-user override needs calendar.manage (admin+).
|
||||
if ($this->userIsAllowedToUpdate($id)) {
|
||||
return $this->calendarRepo->patch($id, $params);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current user may change the given event. The event's owner always may; a
|
||||
* cross-user override requires calendar.manage (admin+ — replaces the legacy
|
||||
* Auth::userIsAtLeast(admin) check, preserving the same admin-only override).
|
||||
*
|
||||
* @param int $eventId Id of event to be checked
|
||||
* @return bool true when allowed, false otherwise
|
||||
*/
|
||||
private function userIsAllowedToUpdate($eventId): bool
|
||||
{
|
||||
if ($this->can(CalendarPermissions::MANAGE)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$event = $this->calendarRepo->getEvent($eventId);
|
||||
|
||||
return $event && (int) ($event['userId'] ?? 0) === $this->currentUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new event to the user's calendar
|
||||
*
|
||||
*
|
||||
* @params array $values array of event values
|
||||
*
|
||||
* @return int|false returns the id on success, false on failure
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::CREATE)]
|
||||
public function addEvent(array $values): int|false
|
||||
{
|
||||
$values['allDay'] = $values['allDay'] ?? false;
|
||||
|
||||
if (isset($values['dateFrom'])) {
|
||||
try {
|
||||
$timeFrom = $values['timeFrom'] ?? null;
|
||||
$values['dateFrom'] = dtHelper()->parseUserDateTime(
|
||||
$values['dateFrom'],
|
||||
$timeFrom
|
||||
)->formatDateTimeForDb();
|
||||
} catch (\Exception $e) {
|
||||
// Silent exception handling
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($values['dateTo'])) {
|
||||
try {
|
||||
$timeTo = $values['timeTo'] ?? null;
|
||||
$values['dateTo'] = dtHelper()->parseUserDateTime(
|
||||
$values['dateTo'],
|
||||
$timeTo
|
||||
)->formatDateTimeForDb();
|
||||
} catch (\Exception $e) {
|
||||
// Silent exception handling
|
||||
}
|
||||
}
|
||||
|
||||
if ($values['description'] !== '') {
|
||||
$result = $this->calendarRepo->addEvent($values);
|
||||
|
||||
// Trigger event for plugins
|
||||
EventDispatcher::dispatch_event('afterCalendarSave', ['eventId' => $result, 'values' => $values]);
|
||||
|
||||
return $result;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single event, fail-closed to its owner. The repository fetches by bare id, so
|
||||
* without this check any user could read any event by id over RPC; calendar.manage (admin+)
|
||||
* is the cross-user override. Soft-denies (returns false) for a foreign event.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function getEvent(int $eventId): mixed
|
||||
{
|
||||
$event = $this->calendarRepo->getEvent($eventId);
|
||||
|
||||
if ($event === false || $event === null) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
if ((int) ($event['userId'] ?? 0) !== $this->currentUserId() && ! $this->can(CalendarPermissions::MANAGE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* edits an event on the user's calendar
|
||||
* Important: Time needs to come in as user formatted time value.
|
||||
*
|
||||
*
|
||||
* @params array $values array of event values
|
||||
*
|
||||
* @return bool returns true on success, false on failure
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::EDIT)]
|
||||
public function editEvent(array $values): bool
|
||||
{
|
||||
if (isset($values['id']) === true) {
|
||||
$id = $values['id'];
|
||||
|
||||
$row = $this->calendarRepo->getEvent($id);
|
||||
|
||||
if ($row === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($values['allDay']) === true) {
|
||||
$allDay = 'true';
|
||||
} else {
|
||||
$allDay = 'false';
|
||||
}
|
||||
|
||||
$values['allDay'] = $allDay;
|
||||
|
||||
if (isset($values['dateFrom'])) {
|
||||
try {
|
||||
$timeFrom = $values['timeFrom'] ?? null;
|
||||
$values['dateFrom'] = dtHelper()->parseUserDateTime(
|
||||
$values['dateFrom'],
|
||||
$timeFrom
|
||||
)->formatDateTimeForDb();
|
||||
} catch (\Exception $e) {
|
||||
// Silent exception handling
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($values['dateTo'])) {
|
||||
try {
|
||||
$timeTo = $values['timeTo'] ?? null;
|
||||
$values['dateTo'] = dtHelper()->parseUserDateTime(
|
||||
$values['dateTo'],
|
||||
$timeTo
|
||||
)->formatDateTimeForDb();
|
||||
} catch (\Exception $e) {
|
||||
// Silent exception handling
|
||||
}
|
||||
}
|
||||
|
||||
if ($values['description'] !== '') {
|
||||
$this->calendarRepo->editEvent($values, $id);
|
||||
|
||||
// Trigger event for plugins
|
||||
EventDispatcher::dispatch_event('afterCalendarSave', ['eventId' => $id, 'values' => $values]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes an event on the user's calendar
|
||||
*
|
||||
*
|
||||
*
|
||||
* @return int|false returns the id on success, false on failure
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::DELETE)]
|
||||
public function delEvent(int $id): int|false
|
||||
{
|
||||
// Trigger event for plugins
|
||||
EventDispatcher::dispatch_event('afterCalendarDelete', ['eventId' => $id]);
|
||||
|
||||
return $this->calendarRepo->delPersonalEvent($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one external-calendar subscription, scoped to the SESSION user. The $userId argument
|
||||
* is retained for signature/RPC compatibility but IGNORED for authorization — otherwise an RPC
|
||||
* caller could read another user's subscription by passing a foreign id.
|
||||
*
|
||||
* @return array|false
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function getExternalCalendar(int $id, int $userId): bool|array
|
||||
{
|
||||
return $this->calendarRepo->getExternalCalendar($id, $this->currentUserId() ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw iCal content for an external calendar, using a
|
||||
* session-based cache with a 30 minute time to live.
|
||||
*
|
||||
* Looks up the cached content for the given calendar in the session and
|
||||
* returns it when still fresh. Otherwise it resolves the external
|
||||
* calendar's URL, fetches its content (with SSRF protection via
|
||||
* {@see loadIcalUrl()}), stores it in the session cache and returns it.
|
||||
* Any fetch failure resolves to an empty string, matching the previous
|
||||
* controller behaviour.
|
||||
*
|
||||
* @param int $calId The external calendar id.
|
||||
* @param int $userId Retained for signature compatibility; the underlying read is pinned to
|
||||
* the session user via getExternalCalendar().
|
||||
* @return string The iCal content, or an empty string when unavailable.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function getCachedExternalCalendarContent(int $calId, int $userId): string
|
||||
{
|
||||
$cacheTime = 60 * 30; // 30min
|
||||
|
||||
if (! session()->exists('calendarCache')) {
|
||||
session(['calendarCache' => []]);
|
||||
}
|
||||
|
||||
$isCacheFresh = session()->exists('calendarCache.'.$calId)
|
||||
&& session()->exists('calendarCache.'.$calId.'.lastUpdate')
|
||||
&& session('calendarCache.'.$calId.'.lastUpdate') > time() - $cacheTime;
|
||||
|
||||
if ($isCacheFresh) {
|
||||
return (string) session('calendarCache.'.$calId.'.content');
|
||||
}
|
||||
|
||||
$cal = $this->getExternalCalendar($calId, $userId);
|
||||
|
||||
if (! isset($cal['url'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
// loadIcalUrl includes SSRF protection.
|
||||
$content = $this->loadIcalUrl($cal['url']);
|
||||
session(['calendarCache.'.$calId.'.lastUpdate' => time()]);
|
||||
session(['calendarCache.'.$calId.'.content' => $content]);
|
||||
|
||||
return $content;
|
||||
} catch (\Exception $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an external-calendar subscription. The repository scopes the update to the session
|
||||
* user (WHERE userId = session), so a foreign id is a no-op.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::EDIT)]
|
||||
public function editExternalCalendar(array $values, int $id): void
|
||||
{
|
||||
$this->calendarRepo->editGUrl($values, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all external calendars for a given user.
|
||||
*
|
||||
* @param int $userId Retained for signature/RPC compatibility but IGNORED — the list is
|
||||
* always scoped to the SESSION user, closing the cross-user param spoof.
|
||||
* @return array|false The external calendars or false if none found
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function getMyExternalCalendars(int $userId): array|false
|
||||
{
|
||||
return $this->calendarRepo->getMyExternalCalendars($this->currentUserId() ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new external calendar URL.
|
||||
*
|
||||
* @param array $values The calendar values (url, name, colorClass)
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::CREATE)]
|
||||
public function addExternalCalendarUrl(array $values): void
|
||||
{
|
||||
$this->calendarRepo->addGUrl($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves iCal calendar by user hash and calendar hash.
|
||||
*
|
||||
* @param string $userHash The hash of the user.
|
||||
* @param string $calHash The hash of the calendar.
|
||||
* @return IcalCalendar The iCal calendar generated from the calendar events.
|
||||
*
|
||||
* @throws MissingParameterException If either user hash or calendar hash is empty.
|
||||
*
|
||||
* Not @api: served by the PUBLIC, hash-authenticated /calendar/ical route (no session). The
|
||||
* userHash+calHash secrets ARE the credential; exposing it over JSON-RPC would let a caller
|
||||
* brute-force feeds. The Ical controller calls it internally.
|
||||
*/
|
||||
public function getIcalByHash(string $userHash, string $calHash): IcalCalendar
|
||||
{
|
||||
|
||||
if (empty($userHash) || empty($calHash)) {
|
||||
throw new MissingParameterException('userHash and calendar hash are required');
|
||||
}
|
||||
|
||||
$calendarEvents = $this->calendarRepo->getCalendarBySecretHash($userHash, $calHash);
|
||||
|
||||
if (! $calendarEvents) {
|
||||
throw new \Exception('Calendar could not be retrieved');
|
||||
}
|
||||
|
||||
$eventObjects = [];
|
||||
// Create array of event objects for ical generator
|
||||
foreach ($calendarEvents as $event) {
|
||||
|
||||
try {
|
||||
|
||||
$description = str_replace("\r\n", '\\n', strip_tags($event['description']));
|
||||
|
||||
$currentEvent = IcalEvent::create()
|
||||
->image(BASE_URL.'/dist/images/favicon.png', 'image/png', Display::badge())
|
||||
->startsAt(dtHelper()->parseDbDateTime($event['dateFrom'])->setToUserTimezone())
|
||||
->endsAt(dtHelper()->parseDbDateTime($event['dateTo'])->setToUserTimezone())
|
||||
->name($event['title'])
|
||||
->description($description)
|
||||
->uniqueIdentifier($event['id'])
|
||||
->url($event['url'] ?? '');
|
||||
|
||||
if ($event['allDay'] === true) {
|
||||
$currentEvent->fullDay();
|
||||
}
|
||||
|
||||
if ($event['eventType'] == 'ticket' && $event['dateContext'] == 'due') {
|
||||
$currentEvent->alertMinutesBefore(30, $this->language->__('text.ical.todo_is_due'));
|
||||
}
|
||||
|
||||
if ($event['eventType'] == 'ticket' && $event['dateContext'] == 'edit') {
|
||||
$currentEvent->alertMinutesBefore(5, $this->language->__('text.ical.todo_start_alert'));
|
||||
}
|
||||
|
||||
$eventObjects[] = $currentEvent;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Do not include event in ical
|
||||
Log::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
$icalCalendar = IcalCalendar::create($this->language->__('text.ical_title'))->event($eventObjects);
|
||||
|
||||
return $icalCalendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: builds the calendar feed (personal events + ticket due/edit events) for a GIVEN
|
||||
* user id. Deliberately NOT @api — it trusts the $userId param. The web caller passes the
|
||||
* session id; RPC callers must use {@see getMyCalendar()}, which pins to the session user.
|
||||
*
|
||||
* @param int $userId The user whose calendar to build
|
||||
* @param null|string|CarbonImmutable $from Optional start of the window
|
||||
* @param null|string|CarbonImmutable $until Optional end of the window
|
||||
* @return array<int, array<string, mixed>> FullCalendar-shaped event arrays
|
||||
*/
|
||||
public function getCalendar(int $userId, null|string|CarbonImmutable $from = null, null|string|CarbonImmutable $until = null): array
|
||||
{
|
||||
// Convert date parameters to Carbon instances if they're strings
|
||||
if (is_string($from)) {
|
||||
$from = CarbonImmutable::parse($from);
|
||||
}
|
||||
if (is_string($until)) {
|
||||
$until = CarbonImmutable::parse($until);
|
||||
}
|
||||
|
||||
// Get tickets and filter by date range
|
||||
$ticketService = app()->make(Tickets::class);
|
||||
$dbTickets = $ticketService->getOpenUserTicketsThisWeekAndLater($userId, '', true);
|
||||
|
||||
$tickets = [];
|
||||
if (isset($dbTickets['thisWeek']['tickets'])) {
|
||||
$tickets = array_merge($tickets, $dbTickets['thisWeek']['tickets']);
|
||||
}
|
||||
|
||||
if (isset($dbTickets['later']['tickets'])) {
|
||||
$tickets = array_merge($tickets, $dbTickets['later']['tickets']);
|
||||
}
|
||||
|
||||
if (isset($dbTickets['overdue']['tickets'])) {
|
||||
$tickets = array_merge($tickets, $dbTickets['overdue']['tickets']);
|
||||
}
|
||||
|
||||
$dbUserEvents = $this->calendarRepo->getAll($userId, $from, $until);
|
||||
|
||||
$newValues = [];
|
||||
foreach ($dbUserEvents as $value) {
|
||||
$allDay = filter_var($value['allDay'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
|
||||
// Filter events by date range if specified. Use dtHelper()
|
||||
// rather than CarbonImmutable::parse directly so the
|
||||
// existing isValidDateString() guard catches MySQL zero-date
|
||||
// sentinel (`0000-00-00 00:00:00`), epoch sentinel
|
||||
// (`1969-12-31 00:00:00`), and empty values BEFORE parsing,
|
||||
// and so the parse itself respects Leantime's DB timezone.
|
||||
// Without this, one bad row took down the entire getCalendar
|
||||
// response with a -32000 server error.
|
||||
if ($from || $until) {
|
||||
try {
|
||||
$eventStart = dtHelper()->parseDbDateTime($value['dateFrom'] ?? '');
|
||||
$eventEnd = dtHelper()->parseDbDateTime($value['dateTo'] ?? '');
|
||||
} catch (\Exception $e) {
|
||||
// Invalid stored date — skip this event rather than
|
||||
// killing the feed. SQL audit + cleanup of zero-date
|
||||
// rows is a separate operations task.
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($from && $eventEnd < $from) {
|
||||
continue;
|
||||
}
|
||||
if ($until && $eventStart > $until) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$newValues[] = [
|
||||
'title' => $value['description'],
|
||||
'allDay' => $allDay,
|
||||
'description' => '',
|
||||
'dateFrom' => $value['dateFrom'],
|
||||
'dateTo' => $value['dateTo'],
|
||||
'id' => $value['id'],
|
||||
'projectId' => '',
|
||||
'eventType' => 'calendar',
|
||||
'dateContext' => 'plan',
|
||||
'backgroundColor' => 'var(--accent1)',
|
||||
'borderColor' => 'var(--accent1)',
|
||||
'url' => BASE_URL.'/calendar/showMyCalendar/#/calendar/editEvent/'.$value['id'],
|
||||
];
|
||||
}
|
||||
|
||||
if (count($tickets)) {
|
||||
$statusLabelsArray = [];
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
if (! isset($statusLabelsArray[$ticket['projectId']])) {
|
||||
$statusLabelsArray[$ticket['projectId']] = $ticketService->getStatusLabels(
|
||||
$ticket['projectId']
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($statusLabelsArray[$ticket['projectId']][$ticket['status']])) {
|
||||
$statusName = $statusLabelsArray[$ticket['projectId']][$ticket['status']]['name'];
|
||||
$statusColor = $this->calendarRepo->classColorMap[$statusLabelsArray[$ticket['projectId']][$ticket['status']]['class']];
|
||||
} else {
|
||||
$statusName = '';
|
||||
$statusColor = 'var(--grey)';
|
||||
}
|
||||
|
||||
$backgroundColor = 'var(--accent2)';
|
||||
|
||||
if (dtHelper()->isValidDateString($ticket['dateToFinish'])) {
|
||||
|
||||
$context = '❕ '.$this->language->__('label.due_todo');
|
||||
|
||||
$dueDate = dtHelper()->parseDbDateTime($ticket['dateToFinish']);
|
||||
if ($from || $until) {
|
||||
|
||||
if ($from && $dueDate < $from) {
|
||||
continue;
|
||||
}
|
||||
if ($until && $dueDate > $until) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if the due date has no specific time set (stored as end-of-day 23:59:59).
|
||||
// If so, treat it as an all-day event to avoid timezone boundary issues
|
||||
// that cause the event to appear on two days in the calendar.
|
||||
$isEndOfDay = $dueDate->format('H:i:s') === '23:59:59';
|
||||
$allDay = $isEndOfDay;
|
||||
|
||||
$newValues[] = $this->mapEventData(
|
||||
title: $context.$ticket['headline'].' ('.$statusName.')',
|
||||
description: $ticket['description'],
|
||||
allDay: $allDay,
|
||||
id: $ticket['id'],
|
||||
projectId: $ticket['projectId'],
|
||||
eventType: 'ticket',
|
||||
dateContext: 'due',
|
||||
backgroundColor: $backgroundColor,
|
||||
borderColor: $statusColor,
|
||||
dateFrom: $ticket['dateToFinish'],
|
||||
dateTo: $ticket['dateToFinish']
|
||||
);
|
||||
}
|
||||
|
||||
if (dtHelper()->isValidDateString($ticket['editFrom'])) {
|
||||
|
||||
// Set ticket to all-day ticket when no time is set.
|
||||
// Guard editTo the same way editFrom is guarded: a ticket can
|
||||
// have a planned start but no (or a sentinel) end date, and
|
||||
// parseDbDateTime() throws on empty/zero-date values — which
|
||||
// previously took down the whole calendar feed with a 500.
|
||||
$dateFrom = dtHelper()->parseDbDateTime($ticket['editFrom']);
|
||||
$hasValidEditTo = dtHelper()->isValidDateString($ticket['editTo'] ?? '');
|
||||
$dateTo = $hasValidEditTo
|
||||
? dtHelper()->parseDbDateTime($ticket['editTo'])
|
||||
: $dateFrom;
|
||||
|
||||
if ($from || $until) {
|
||||
|
||||
if ($from && $dateFrom < $from) {
|
||||
continue;
|
||||
}
|
||||
if ($until && $dateTo > $until) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$allDay = false;
|
||||
if ($dateFrom->diffInDays($dateTo) >= 1) {
|
||||
$allDay = true;
|
||||
}
|
||||
|
||||
$context = $this->language->__('label.planned_edit');
|
||||
|
||||
$newValues[] = $this->mapEventData(
|
||||
title: $context.$ticket['headline'].' ('.$statusName.')',
|
||||
description: $ticket['description'],
|
||||
allDay: $allDay,
|
||||
id: $ticket['id'],
|
||||
projectId: $ticket['projectId'],
|
||||
eventType: 'ticket',
|
||||
dateContext: 'edit',
|
||||
backgroundColor: $backgroundColor,
|
||||
borderColor: $statusColor,
|
||||
dateFrom: $ticket['editFrom'],
|
||||
dateTo: $hasValidEditTo ? $ticket['editTo'] : $ticket['editFrom']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $newValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-scoped calendar feed (personal events + ticket due/edit events) for the
|
||||
* authenticated user. Mobile's calendar tab calls this; the optional from/until window lets
|
||||
* it fetch just the visible month.
|
||||
*
|
||||
* getCalendar() itself is internal-only — it trusts an arbitrary $userId — so this wrapper is
|
||||
* the API entry point and pins the read to the session user (no spoofable param).
|
||||
*
|
||||
* @param null|string|CarbonImmutable $from Optional ISO start of the window
|
||||
* @param null|string|CarbonImmutable $until Optional ISO end of the window
|
||||
* @return array<int, array<string, mixed>> FullCalendar-shaped event arrays
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function getMyCalendar(null|string|CarbonImmutable $from = null, null|string|CarbonImmutable $until = null): array
|
||||
{
|
||||
$userId = $this->currentUserId() ?? 0;
|
||||
if ($userId === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getCalendar($userId, $from, $until);
|
||||
}
|
||||
|
||||
/**
|
||||
* The authenticated user's personal iCal subscription URL (hash-authenticated feed). Already
|
||||
* session-scoped — it takes no userId. Mobile surfaces this so the user can subscribe their
|
||||
* device calendar.
|
||||
*
|
||||
* @return string The full iCal feed URL
|
||||
*
|
||||
* @throws MissingParameterException When the user has no iCal feed configured (maps to RPC -32602)
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function getICalUrl(): string
|
||||
{
|
||||
$userId = -1;
|
||||
if (! empty(session('userdata.id'))) {
|
||||
$userId = session('userdata.id');
|
||||
}
|
||||
|
||||
$userHash = hash('sha1', $userId.$this->config->sessionPassword);
|
||||
$icalHash = $this->settingsRepo->getSetting('usersettings.'.$userId.'.icalSecret');
|
||||
|
||||
if (empty($icalHash)) {
|
||||
throw new MissingParameterException('User has no iCal feed configured');
|
||||
}
|
||||
|
||||
return BASE_URL.'/calendar/ical/'.$icalHash.'_'.$userHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an iCal request token into the iCal calendar.
|
||||
*
|
||||
* The token follows the same `{icalHash}_{userHash}` format produced by
|
||||
* {@see getICalUrl()}. It may arrive either as the request id (the raw
|
||||
* `{icalHash}_{userHash}` string) or embedded as the third dot-separated
|
||||
* segment of the frontcontroller `act` value
|
||||
* (`calendar.ical.{icalHash}_{userHash}`).
|
||||
*
|
||||
* @param string $token The raw id token, may be empty.
|
||||
* @param string $act The frontcontroller act string, may be empty.
|
||||
* @return IcalCalendar The iCal calendar for the resolved hashes.
|
||||
*
|
||||
* @throws MissingParameterException If the token does not contain both hashes.
|
||||
* @throws \Exception If the calendar could not be retrieved.
|
||||
*
|
||||
* Not @api: the entry point for the PUBLIC, hash-authenticated /calendar/ical route (no
|
||||
* session) — the Ical controller calls it directly. Not exposed via JSON-RPC (delegates to
|
||||
* getIcalByHash, where the hashes are the credential).
|
||||
*/
|
||||
public function getIcalByRequestToken(string $token, string $act = ''): IcalCalendar
|
||||
{
|
||||
$actParts = explode('.', $act);
|
||||
|
||||
if (count($actParts) === 3) {
|
||||
$rawToken = $actParts[2];
|
||||
} else {
|
||||
$rawToken = $token;
|
||||
}
|
||||
|
||||
$idParts = explode('_', $rawToken);
|
||||
|
||||
if (count($idParts) !== 2) {
|
||||
throw new MissingParameterException('iCal token must contain both an iCal hash and a user hash');
|
||||
}
|
||||
|
||||
// idParts[0] = iCal hash (calHash), idParts[1] = user hash.
|
||||
return $this->getIcalByHash($idParts[1], $idParts[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* External-calendar events (from the user's subscribed iCal feeds) for the authenticated user.
|
||||
* Already session-scoped — it keys off session('userdata.id') with no spoofable param. Mobile
|
||||
* merges these into its calendar view.
|
||||
*
|
||||
* @param null|string|CarbonImmutable $from Optional ISO start of the window
|
||||
* @param null|string|CarbonImmutable $until Optional ISO end of the window
|
||||
* @return array<int, array<string, mixed>> Array of external calendar events
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(CalendarPermissions::VIEW)]
|
||||
public function getExternalCalendarEvents(null|string|CarbonImmutable $from = null, null|string|CarbonImmutable $until = null): array
|
||||
{
|
||||
$cacheKey = 'calendar.external.'.session('userdata.id');
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && ! empty($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Get all external calendars for the user
|
||||
$externalCalendars = $this->calendarRepo->getMyExternalCalendars(session('userdata.id'));
|
||||
|
||||
if (empty($externalCalendars)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allEvents = [];
|
||||
|
||||
// Convert date parameters to Carbon instances if they're strings
|
||||
try {
|
||||
if (is_string($from)) {
|
||||
$from = CarbonImmutable::parse($from);
|
||||
}
|
||||
if (is_string($until)) {
|
||||
$until = CarbonImmutable::parse($until);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error converting date parameters to Carbon instances: '.$e->getMessage());
|
||||
Log::error($e);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($externalCalendars as $calendar) {
|
||||
try {
|
||||
// Load the iCal data using existing functionality
|
||||
$icalContent = $this->loadIcalUrl($calendar['url']);
|
||||
|
||||
// Parse the iCal data into events
|
||||
$parser = new \ICal\ICal;
|
||||
$parser->initString($icalContent);
|
||||
|
||||
$events = $parser->events();
|
||||
|
||||
// Filter events by date range if specified
|
||||
if ($from || $until) {
|
||||
$events = array_filter($events, function ($event) use ($from, $until) {
|
||||
$eventStart = CarbonImmutable::parse($event->dtstart);
|
||||
$eventEnd = isset($event->dtend) ? CarbonImmutable::parse($event->dtend) : $eventStart;
|
||||
|
||||
if ($from && $eventEnd < $from) {
|
||||
return false;
|
||||
}
|
||||
if ($until && $eventStart > $until) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Transform each event into our standard format
|
||||
foreach ($events as $event) {
|
||||
|
||||
if (Str::endsWith($event->dtstart, 'Z')) {
|
||||
$dtstart = dtHelper()->parseDbDateTime($event->dtstart)->formatDateTimeForDb();
|
||||
} else {
|
||||
$dtstart = dtHelper()->parseUserDateTime($event->dtstart)->formatDateTimeForDb();
|
||||
}
|
||||
|
||||
if (Str::endsWith($event->dtend, 'Z')) {
|
||||
$dtend = dtHelper()->parseDbDateTime($event->dtend)->formatDateTimeForDb();
|
||||
} else {
|
||||
$dtend = dtHelper()->parseUserDateTime($event->dtend)->formatDateTimeForDb();
|
||||
}
|
||||
|
||||
$allEvents[] = [
|
||||
'title' => $event->summary,
|
||||
'description' => $event->description ?? '',
|
||||
'dateFrom' => $dtstart,
|
||||
'dateTo' => $dtend,
|
||||
'allDay' => isset($event->dtstart_array[3]) ? false : true,
|
||||
'id' => $event->uid,
|
||||
'projectId' => '',
|
||||
'eventType' => 'external',
|
||||
'dateContext' => 'plan',
|
||||
'backgroundColor' => $calendar['colorClass'],
|
||||
'borderColor' => $calendar['colorClass'],
|
||||
'url' => $event->url ?? '',
|
||||
'source' => $calendar['name'],
|
||||
];
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log error but continue with other calendars
|
||||
Log::error("Error fetching calendar {$calendar['name']}: ".$e->getMessage());
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Cache::put('calendar.external.'.session('userdata.id'), $allEvents, 240);
|
||||
|
||||
return $allEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an iCal URL and return its contents.
|
||||
*
|
||||
* Validates the URL against SSRF attacks before making the request.
|
||||
*
|
||||
* @param string $url The URL of the iCal feed.
|
||||
* @return string The iCal content.
|
||||
*
|
||||
* @throws \Exception If the URL is unsafe or there is an error loading the URL.
|
||||
*/
|
||||
public function loadIcalUrl(string $url): string
|
||||
{
|
||||
if (str_contains($url, 'webcal://')) {
|
||||
$url = str_replace('webcal://', 'https://', $url);
|
||||
}
|
||||
|
||||
if (! OutboundUrlGuard::isAllowedUrl($url)) {
|
||||
throw new \Exception('Refused to fetch iCal feed: URL failed SSRF safety check');
|
||||
}
|
||||
|
||||
$client = new \GuzzleHttp\Client;
|
||||
|
||||
try {
|
||||
$response = $client->get($url, [
|
||||
'allow_redirects' => OutboundUrlGuard::redirectOptions(),
|
||||
'headers' => [
|
||||
'Accept' => 'text/calendar',
|
||||
'User-Agent' => 'Leantime Calendar Integration v'.$this->config->appVersion,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() == 200) {
|
||||
return (string) $response->getBody();
|
||||
}
|
||||
|
||||
throw new \Exception('Failed to load iCal feed: HTTP '.$response->getStatusCode());
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Error loading iCal feed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function generateIcalHash()
|
||||
{
|
||||
|
||||
if (empty(session('userdata.id'))) {
|
||||
throw new \Exception('Session id is not set.');
|
||||
}
|
||||
|
||||
$uuid = Uuid::uuid4();
|
||||
$icalHash = $uuid->toString();
|
||||
|
||||
$this->settingsRepo->saveSetting('usersettings.'.session('userdata.id').'.icalSecret', $icalHash);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event array for fullcalendar.io frontend.
|
||||
*/
|
||||
private function mapEventData(
|
||||
string $title,
|
||||
?string $description,
|
||||
bool $allDay,
|
||||
?int $id,
|
||||
?int $projectId,
|
||||
string $eventType,
|
||||
string $dateContext,
|
||||
?string $backgroundColor,
|
||||
?string $borderColor,
|
||||
?string $dateFrom,
|
||||
?string $dateTo
|
||||
): array {
|
||||
// Ticket records in MySQL can legitimately have NULL for any of
|
||||
// the user-facing optional fields here (description, dates, colours)
|
||||
// AND for the foreign-key-style id columns (orphaned tickets, soft-
|
||||
// deleted projects, etc.). PHP 8's strict-type declarations rejected
|
||||
// those with a hard 500. Widening the genuinely-nullable params to
|
||||
// accept null and coercing to safe defaults (empty string / 0)
|
||||
// in the output preserves the payload shape for calendar
|
||||
// consumers — both mobile and web expect strings + numeric ids
|
||||
// — without rewriting every call site or pre-filtering at the
|
||||
// query layer.
|
||||
return [
|
||||
'title' => $title,
|
||||
'allDay' => $allDay,
|
||||
'description' => $description ?? '',
|
||||
'dateFrom' => $dateFrom ?? '',
|
||||
'dateTo' => $dateTo ?? '',
|
||||
'id' => $id ?? 0,
|
||||
'projectId' => $projectId ?? 0,
|
||||
'eventType' => $eventType,
|
||||
'dateContext' => $dateContext,
|
||||
'backgroundColor' => $backgroundColor ?? '',
|
||||
'borderColor' => $borderColor ?? '',
|
||||
'url' => BASE_URL.'/dashboard/home/#/tickets/showTicket/'.($id ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
313
app/Domain/Calendar/Support/CalendarEventFormatter.php
Normal file
313
app/Domain/Calendar/Support/CalendarEventFormatter.php
Normal file
@@ -0,0 +1,313 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Support;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Support\AbstractEntityFormatter;
|
||||
|
||||
/**
|
||||
* Calendar event formatter for AI consumption.
|
||||
*
|
||||
* Formats calendar events into structured markdown suitable for AI prompts,
|
||||
* embeddings, and other LLM operations. Handles all event types: calendar,
|
||||
* ticket, and external events.
|
||||
*/
|
||||
class CalendarEventFormatter extends AbstractEntityFormatter
|
||||
{
|
||||
/**
|
||||
* Fields to exclude from calendar event formatting.
|
||||
*/
|
||||
protected array $excludedFields = [
|
||||
'backgroundColor',
|
||||
'borderColor',
|
||||
'url',
|
||||
'className',
|
||||
];
|
||||
|
||||
/**
|
||||
* Priority order for displaying calendar event fields.
|
||||
*/
|
||||
protected array $fieldPriority = [
|
||||
'id',
|
||||
'title',
|
||||
'description',
|
||||
'eventType',
|
||||
'dateContext',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'allDay',
|
||||
'projectId',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected array $event
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the entity type.
|
||||
*/
|
||||
public function getEntityType(): string
|
||||
{
|
||||
return 'calendar_event';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity ID.
|
||||
*/
|
||||
public function getEntityId(): mixed
|
||||
{
|
||||
return $this->event['id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare calendar event data for formatting.
|
||||
*/
|
||||
protected function prepareEntityData(array $context = []): array
|
||||
{
|
||||
$data = [
|
||||
'id' => $this->event['id'] ?? null,
|
||||
'title' => $this->sanitizeValue($this->event['title'] ?? ''),
|
||||
'description' => $this->sanitizeValue($this->event['description'] ?? ''),
|
||||
'eventType' => $this->sanitizeValue($this->event['eventType'] ?? ''),
|
||||
'dateContext' => $this->sanitizeValue($this->event['dateContext'] ?? ''),
|
||||
'startDate' => $this->formatDate($this->event['dateFrom'] ?? ''),
|
||||
'endDate' => $this->formatDate($this->event['dateTo'] ?? ''),
|
||||
'allDay' => $this->formatAllDay($this->event['allDay'] ?? false),
|
||||
'duration' => $this->calculateDuration(),
|
||||
'projectId' => $this->event['projectId'] ?? null,
|
||||
'projectName' => $this->sanitizeValue($this->event['projectName'] ?? ''),
|
||||
'eventTypeFormatted' => $this->formatEventType(),
|
||||
'dateContextFormatted' => $this->formatDateContext(),
|
||||
];
|
||||
|
||||
// Add ticket-specific information if this is a ticket event
|
||||
if (($this->event['eventType'] ?? '') === 'ticket') {
|
||||
$data['ticketDetails'] = $this->formatTicketDetails();
|
||||
}
|
||||
|
||||
// Add external calendar information if this is an external event
|
||||
if (($this->event['eventType'] ?? '') === 'external') {
|
||||
$data['externalCalendarInfo'] = $this->formatExternalCalendarInfo();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the header section.
|
||||
*/
|
||||
protected function formatHeader(array $data): string
|
||||
{
|
||||
$eventTypeEmoji = match ($data['eventType']) {
|
||||
'calendar' => '📅',
|
||||
'ticket' => '🎫',
|
||||
'external' => '🔗',
|
||||
default => '📝'
|
||||
};
|
||||
|
||||
$allDayIndicator = $data['allDay'] === 'Yes' ? ' (All Day)' : '';
|
||||
$projectInfo = ! empty($data['projectName']) ? " - {$data['projectName']}" : '';
|
||||
|
||||
return "## {$eventTypeEmoji} {$data['title']}{$allDayIndicator}{$projectInfo}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the body with custom calendar event formatting.
|
||||
*/
|
||||
protected function formatBody(array $data, array $context = []): string
|
||||
{
|
||||
$filteredData = $this->filterFields($data, $context);
|
||||
|
||||
// Remove the duplicated header fields from body
|
||||
unset($filteredData['id'], $filteredData['title'], $filteredData['projectName']);
|
||||
|
||||
$sortedData = $this->sortFields($filteredData);
|
||||
|
||||
return "\n".Str::toMarkdown($sortedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a compact summary.
|
||||
*/
|
||||
protected function formatSummary(array $data): string
|
||||
{
|
||||
$eventTypeEmoji = match ($data['eventType']) {
|
||||
'calendar' => '📅',
|
||||
'ticket' => '🎫',
|
||||
'external' => '🔗',
|
||||
default => '📝'
|
||||
};
|
||||
|
||||
$timeInfo = $this->formatTimeRange($data['startDate'], $data['endDate'], $data['allDay'] === 'Yes');
|
||||
|
||||
return "{$eventTypeEmoji} {$data['title']} ({$timeInfo})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the all-day flag.
|
||||
*
|
||||
* @param mixed $allDay
|
||||
*/
|
||||
protected function formatAllDay($allDay): string
|
||||
{
|
||||
if (is_bool($allDay)) {
|
||||
return $allDay ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
if (is_string($allDay)) {
|
||||
return strtolower($allDay) === 'true' ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
return 'No';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and format event duration.
|
||||
*/
|
||||
protected function calculateDuration(): string
|
||||
{
|
||||
$dateFrom = $this->event['dateFrom'] ?? '';
|
||||
$dateTo = $this->event['dateTo'] ?? '';
|
||||
|
||||
if (empty($dateFrom) || empty($dateTo)) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
$start = \DateTime::createFromFormat('Y-m-d H:i:s', $dateFrom);
|
||||
$end = \DateTime::createFromFormat('Y-m-d H:i:s', $dateTo);
|
||||
|
||||
if (! $start || ! $end) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
$diff = $start->diff($end);
|
||||
|
||||
if ($diff->days > 0) {
|
||||
return $diff->days.' day(s)';
|
||||
} elseif ($diff->h > 0) {
|
||||
$minutes = $diff->i > 0 ? " {$diff->i}min" : '';
|
||||
|
||||
return $diff->h.'h'.$minutes;
|
||||
} elseif ($diff->i > 0) {
|
||||
return $diff->i.' minutes';
|
||||
} else {
|
||||
return 'Less than 1 minute';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the event type with description.
|
||||
*/
|
||||
protected function formatEventType(): string
|
||||
{
|
||||
return match ($this->event['eventType'] ?? '') {
|
||||
'calendar' => '📅 Calendar Event',
|
||||
'ticket' => '🎫 Task/Ticket Event',
|
||||
'external' => '🔗 External Calendar Import',
|
||||
default => '📝 Other Event'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the date context with description.
|
||||
*/
|
||||
protected function formatDateContext(): string
|
||||
{
|
||||
return match ($this->event['dateContext'] ?? '') {
|
||||
'plan' => '📋 Planned Event',
|
||||
'due' => '⏰ Due Date',
|
||||
'edit' => '⚙️ Scheduled Work Time',
|
||||
default => '📅 General Event'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ticket-specific details.
|
||||
*/
|
||||
protected function formatTicketDetails(): array
|
||||
{
|
||||
$details = [];
|
||||
|
||||
if (isset($this->event['ticketId'])) {
|
||||
$details['ticketId'] = $this->event['ticketId'];
|
||||
}
|
||||
|
||||
if (isset($this->event['status'])) {
|
||||
$details['status'] = $this->sanitizeValue($this->event['status']);
|
||||
}
|
||||
|
||||
if (isset($this->event['priority'])) {
|
||||
$details['priority'] = $this->formatPriority($this->event['priority']);
|
||||
}
|
||||
|
||||
if (isset($this->event['assignedTo'])) {
|
||||
$details['assignedTo'] = $this->sanitizeValue($this->event['assignedTo']);
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format external calendar information.
|
||||
*/
|
||||
protected function formatExternalCalendarInfo(): array
|
||||
{
|
||||
$details = [];
|
||||
|
||||
if (isset($this->event['calendarId'])) {
|
||||
$details['calendarId'] = $this->sanitizeValue($this->event['calendarId']);
|
||||
}
|
||||
|
||||
if (isset($this->event['externalId'])) {
|
||||
$details['externalId'] = $this->sanitizeValue($this->event['externalId']);
|
||||
}
|
||||
|
||||
if (isset($this->event['lastSync'])) {
|
||||
$details['lastSync'] = $this->formatDate($this->event['lastSync']);
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time range for summaries.
|
||||
*/
|
||||
protected function formatTimeRange(string $startDate, string $endDate, bool $allDay): string
|
||||
{
|
||||
if ($allDay) {
|
||||
try {
|
||||
$start = \DateTime::createFromFormat(\DateTime::ATOM, $startDate);
|
||||
if ($start) {
|
||||
return $start->format('M j, Y');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Fall through to default
|
||||
}
|
||||
|
||||
return 'All Day';
|
||||
}
|
||||
|
||||
try {
|
||||
$start = \DateTime::createFromFormat(\DateTime::ATOM, $startDate);
|
||||
$end = \DateTime::createFromFormat(\DateTime::ATOM, $endDate);
|
||||
|
||||
if ($start && $end) {
|
||||
if ($start->format('Y-m-d') === $end->format('Y-m-d')) {
|
||||
// Same day
|
||||
return $start->format('M j, Y g:i A').' - '.$end->format('g:i A');
|
||||
} else {
|
||||
// Multi-day
|
||||
return $start->format('M j, Y g:i A').' - '.$end->format('M j, Y g:i A');
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Fall through to default
|
||||
}
|
||||
|
||||
return 'Time not available';
|
||||
}
|
||||
}
|
||||
76
app/Domain/Calendar/Templates/addEvent.blade.php
Normal file
76
app/Domain/Calendar/Templates/addEvent.blade.php
Normal file
@@ -0,0 +1,76 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$values = $values ?? [];
|
||||
@endphp
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h4 class="widgettitle title-light">{!! __('subtitles.event') !!}</h4>
|
||||
|
||||
<form action="{{ BASE_URL }}/calendar/addEvent/" method="post" class='formModal'>
|
||||
@csrf
|
||||
|
||||
@dispatchEvent('afterFormOpen')
|
||||
|
||||
<label for="description">{!! __('label.title') !!}</label>
|
||||
<x-global::forms.text-input id="description" name="description" value="{{ $tpl->escape($values['description']) }}" /><br />
|
||||
|
||||
<div class="par">
|
||||
<label for="dateFrom">{!! __('label.start_date') !!}</label>
|
||||
<input type="text" id="event_date_from" name="dateFrom" value="" autocomplete="off" /><br/>
|
||||
</div>
|
||||
<div class="par">
|
||||
<label for="">{!! __('label.start_time') !!}</label>
|
||||
<div class="input-append bootstrap-timepicker">
|
||||
<input type="time" id="event_time_from" name="timeFrom" value="" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="par">
|
||||
<label for="dateTo">{!! __('label.end_date') !!}</label>
|
||||
<input type="text" id="event_date_to" name="dateTo" value="" autocomplete="off" /><br/>
|
||||
</div>
|
||||
<div class="par">
|
||||
<label for="">{!! __('label.end_time') !!} </label>
|
||||
<div class="input-append bootstrap-timepicker">
|
||||
<input type="time" id="event_time_to" name="timeTo" value="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="allDay">{!! __('label.all_day') !!}</label>
|
||||
<input type="checkbox" id="allDay" name="allDay"
|
||||
@if (isset($values['allDay']) && $values['allDay'] === true)
|
||||
checked="checked"
|
||||
@endif
|
||||
/><br /><br />
|
||||
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
|
||||
<p class="stdformbutton">
|
||||
<input type="hidden" value="1" name="save" />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="saveEvent" id="saveEvent" />
|
||||
</p>
|
||||
|
||||
@dispatchEvent('beforeFormClose')
|
||||
|
||||
</form>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
|
||||
@dispatchEvent('scripts.afterOpen')
|
||||
|
||||
jQuery(document).ready(function() {
|
||||
leantime.calendarController.initEventDatepickers();
|
||||
});
|
||||
|
||||
@dispatchEvent('scripts.beforeClose')
|
||||
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
||||
@endsection
|
||||
92
app/Domain/Calendar/Templates/calendarSettings.blade.php
Normal file
92
app/Domain/Calendar/Templates/calendarSettings.blade.php
Normal file
@@ -0,0 +1,92 @@
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h4 class="widgettitle title-light"><i class="fa fa-cog"></i> {{ __('label.calendar_settings') }}</h4>
|
||||
|
||||
<br />
|
||||
|
||||
{{-- Connected Calendars Section --}}
|
||||
<h5 class="subtitle">{{ __('label.connected_calendars') }}</h5>
|
||||
|
||||
@if(count($externalCalendars) > 0)
|
||||
<ul class="simpleList" style="margin-bottom: 20px;">
|
||||
@foreach($externalCalendars as $calendar)
|
||||
<li style="padding: 10px; background: var(--secondary-background); border-radius: var(--box-radius-small); margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>
|
||||
<span class="indicatorCircle" style="background-color: {{ e($calendar['colorClass']) }};"></span>
|
||||
<strong>{{ $calendar['name'] }}</strong>
|
||||
@if(!empty($calendar['subtitle']))
|
||||
<br><small class="text-muted">{!! $calendar['subtitle'] !!}</small>
|
||||
@endif
|
||||
</span>
|
||||
<span style="white-space: nowrap;">
|
||||
@if(!empty($calendar['actions']))
|
||||
{{-- Plugin-provided per-calendar actions --}}
|
||||
@foreach($calendar['actions'] as $action)
|
||||
<a href="{{ $action['url'] }}"
|
||||
class="{{ ($action['type'] ?? 'link') === 'modal' ? 'formModal' : '' }}"
|
||||
data-tippy-content="{{ $action['tooltip'] ?? '' }}">
|
||||
<i class="{{ e($action['icon']) }}"></i>
|
||||
</a>
|
||||
|
||||
@endforeach
|
||||
@elseif(empty($calendar['managedByPlugin']))
|
||||
{{-- Default iCal calendar actions --}}
|
||||
<a href="#/calendar/editExternal/{{ $calendar['id'] }}" class="formModal" data-tippy-content="{{ __('label.edit') }}">
|
||||
<i class="fa fa-pen"></i>
|
||||
</a>
|
||||
|
||||
<a href="#/calendar/delExternalCalendar/{{ $calendar['id'] }}" class="delete" data-tippy-content="{{ __('label.delete') }}">
|
||||
<i class="fa fa-trash"></i>
|
||||
</a>
|
||||
@endif
|
||||
</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
<p class="text-muted small" style="font-style: italic;">
|
||||
{{ __('label.no_connected_calendars') }}
|
||||
</p>
|
||||
<br />
|
||||
@endif
|
||||
|
||||
{{-- Plugin-injected sections (Google Calendar, etc.) --}}
|
||||
{{-- Note: Plugin content via {!! !!} is trusted. Plugins are responsible for sanitizing their output. --}}
|
||||
@foreach($pluginSections as $section)
|
||||
<hr style="margin: 20px 0;" />
|
||||
|
||||
<h5 class="subtitle">
|
||||
@if(isset($section['icon']))
|
||||
@if(($section['iconType'] ?? 'fontawesome') === 'svg')
|
||||
<span style="display: inline-block; width: 20px; height: 20px; vertical-align: middle; margin-right: 8px;">{!! $section['icon'] !!}</span>
|
||||
@else
|
||||
<i class="{{ e($section['icon']) }}" style="margin-right: 8px;"></i>
|
||||
@endif
|
||||
@endif
|
||||
{{ $section['title'] }}
|
||||
</h5>
|
||||
|
||||
@if(isset($section['description']))
|
||||
<p class="text-muted small">{{ $section['description'] }}</p>
|
||||
@endif
|
||||
|
||||
@if(isset($section['content']))
|
||||
{!! $section['content'] !!}
|
||||
@endif
|
||||
|
||||
@if(isset($section['actions']))
|
||||
<div style="margin-top: 10px;">
|
||||
@foreach($section['actions'] as $action)
|
||||
<a href="{{ $action['url'] }}"
|
||||
class="btn {{ $action['class'] ?? 'btn-default' }} {{ ($action['type'] ?? 'link') === 'modal' ? 'formModal' : '' }}">
|
||||
@if(isset($action['icon']))
|
||||
<i class="{{ e($action['icon']) }}"></i>
|
||||
@endif
|
||||
{{ $action['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@dispatchEvent('afterCalendarSettings')
|
||||
68
app/Domain/Calendar/Templates/connectCalendar.blade.php
Normal file
68
app/Domain/Calendar/Templates/connectCalendar.blade.php
Normal file
@@ -0,0 +1,68 @@
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h4 class="widgettitle title-light"><i class="fa-regular fa-calendar-plus"></i> {{ __('label.connect_calendar_title') }}</h4>
|
||||
<p class="subtitle">{{ __('label.connect_calendar_description') }}</p>
|
||||
|
||||
<br />
|
||||
|
||||
{{-- iCal URL Import --}}
|
||||
<x-global::accordion id="connectCalendar-ical" class="noBackground">
|
||||
<x-slot name="title">
|
||||
<i class="fa fa-calendar-alt" style="margin-right: 5px;"></i> {{ __('label.ical_url_title') }}
|
||||
</x-slot>
|
||||
<x-slot name="content" style="padding-top: 10px;">
|
||||
<p class="text-muted small">{{ __('label.ical_url_description') }}</p>
|
||||
|
||||
<form action="{{ BASE_URL }}/calendar/connectCalendar" method="post" class="formModal">
|
||||
@csrf
|
||||
<label for="ical_name">{{ __('label.calendar_name') }}:</label>
|
||||
<x-global::forms.text-input id="ical_name" name="name" autocomplete="off" placeholder="{{ __('label.calendar_name') }}" /><br />
|
||||
|
||||
<label for="ical_url">{{ __('label.ical_url') }}:</label>
|
||||
<x-global::forms.text-input id="ical_url" name="url" autocomplete="off" style="width:100%;" placeholder="https://example.com/calendar.ics" /><br />
|
||||
|
||||
<label for="ical_color">{{ __('label.color') }}:</label>
|
||||
<input type="text" id="ical_color" name="colorClass" autocomplete="off" value="#082236" class="simpleColorPicker"/>
|
||||
|
||||
<br /><br />
|
||||
<x-global::forms.button tag="input" inputType="submit" name="save" contentRole="primary" :labelText="__('label.import_ical_button')" />
|
||||
</form>
|
||||
</x-slot>
|
||||
</x-global::accordion>
|
||||
|
||||
{{-- Plugin-injected providers (CalDAV, Google Calendar, etc.) --}}
|
||||
{{-- Note: SVG icons via {!! !!} are trusted plugin content. Plugins are responsible for sanitizing their output. --}}
|
||||
@foreach($providers as $provider)
|
||||
@if($provider['id'] !== 'ical')
|
||||
<x-global::accordion id="connectCalendar-{{ e($provider['id']) }}" class="noBackground">
|
||||
<x-slot name="title">
|
||||
@if(($provider['iconType'] ?? 'fontawesome') === 'svg')
|
||||
<span style="display: inline-block; width: 18px; height: 18px; vertical-align: middle; margin-right: 5px;">{!! $provider['icon'] !!}</span>
|
||||
@else
|
||||
<i class="{{ e($provider['icon']) }}" style="margin-right: 5px;"></i>
|
||||
@endif
|
||||
{{ $provider['title'] }}
|
||||
</x-slot>
|
||||
<x-slot name="content" style="padding-top: 10px;">
|
||||
<p class="text-muted small">{{ $provider['description'] }}</p>
|
||||
<x-global::forms.button tag="a" link="{{ $provider['actionUrl'] }}"
|
||||
contentRole="primary" class="{{ ($provider['actionType'] ?? 'link') === 'modal' ? 'formModal' : '' }}">
|
||||
{{ $provider['actionLabel'] }}
|
||||
</x-global::forms.button>
|
||||
</x-slot>
|
||||
</x-global::accordion>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@dispatchEvent('afterProviders')
|
||||
|
||||
<br />
|
||||
<p class="text-muted small">
|
||||
{{ __('label.more_integrations_hint') }}
|
||||
</p>
|
||||
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function() {
|
||||
leantime.ticketsController.initSimpleColorPicker();
|
||||
});
|
||||
</script>
|
||||
20
app/Domain/Calendar/Templates/delEvent.blade.php
Normal file
20
app/Domain/Calendar/Templates/delEvent.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$id = (int) $_GET['id'];
|
||||
@endphp
|
||||
|
||||
<h4 class="widgettitle title-light">{!! __('subtitles.delete') !!}</h4>
|
||||
|
||||
<form method="post" class="formModal" action="{{ BASE_URL }}/calendar/delEvent/{{ $id }}">
|
||||
@dispatchEvent('afterFormOpen')
|
||||
<p>{!! __('text.confirm_event_deletion') !!}</p><br />
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
<x-global::forms.button inputType="submit" contentRole="primary" id="saveAndClose" value="closeModal">{!! __('buttons.yes_delete') !!}</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/calendar/showMyCalendar">{!! __('buttons.back') !!}</x-global::forms.button>
|
||||
@dispatchEvent('beforeFormClose')
|
||||
</form>
|
||||
|
||||
@endsection
|
||||
20
app/Domain/Calendar/Templates/delExternalCal.blade.php
Normal file
20
app/Domain/Calendar/Templates/delExternalCal.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$id = (int) $_GET['id'];
|
||||
@endphp
|
||||
|
||||
<h4 class="widgettitle title-light">{!! __('subtitles.delete') !!}</h4>
|
||||
|
||||
<form method="post" class="formModal" action="{{ BASE_URL }}/calendar/delExternalCalendar/{{ $id }}">
|
||||
@dispatchEvent('afterFormOpen')
|
||||
<p>{!! __('text.confirm_calendar_deletion') !!}</p><br />
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
<x-global::forms.button inputType="submit" contentRole="primary" id="saveAndClose" value="closeModal">{!! __('buttons.yes_delete') !!}</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/calendar/showMyCalendar">{!! __('buttons.back') !!}</x-global::forms.button>
|
||||
@dispatchEvent('beforeFormClose')
|
||||
</form>
|
||||
|
||||
@endsection
|
||||
71
app/Domain/Calendar/Templates/editEvent.blade.php
Normal file
71
app/Domain/Calendar/Templates/editEvent.blade.php
Normal file
@@ -0,0 +1,71 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$values = $values ?? [];
|
||||
@endphp
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h4 class="widgettitle title-light">{!! __('subtitles.event') !!}</h4>
|
||||
|
||||
<form action="{{ BASE_URL }}/calendar/editEvent/{{ $values['id'] }}" method="post" class="formModal">
|
||||
|
||||
@dispatchEvent('afterFormOpen')
|
||||
|
||||
<label for="description">{!! __('label.title') !!}</label>
|
||||
<x-global::forms.text-input id="description" name="description" value="{{ $tpl->escape($values['description']) }}" /><br />
|
||||
|
||||
<label for="dateFrom">{!! __('label.start_date') !!}</label>
|
||||
<input type="text" id="event_date_from" autocomplete="off" name="dateFrom" value="{{ format($values['dateFrom'])->date() }}" />
|
||||
|
||||
<div class="par">
|
||||
<label> {!! __('label.start_time') !!}</label>
|
||||
<div class="input-append bootstrap-timepicker">
|
||||
<input type="time" id="event_time_from" name="timeFrom" value="{{ format($values['dateFrom'])->time24() }}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="dateTo">{!! __('label.end_date') !!}</label>
|
||||
<input type="text" id="event_date_to" autocomplete="off" name="dateTo" value="{{ format($values['dateTo'])->date() }}" />
|
||||
|
||||
<div class="par">
|
||||
<label for="">{!! __('label.end_time') !!} </label>
|
||||
<div class="input-append bootstrap-timepicker">
|
||||
<input type="time" id="event_time_to" name="timeTo" value="{{ format($values['dateTo'])->time24() }}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="allDay">{!! __('label.all_day') !!}</label>
|
||||
<input type="checkbox" id="allDay" name="allDay"
|
||||
@if ($values['allDay'] === 'true')
|
||||
checked="checked"
|
||||
@endif
|
||||
/>
|
||||
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
|
||||
<div class="clear"></div>
|
||||
<br />
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/calendar/delEvent/{{ (int) $_GET['id'] }}" class="formModal delete right" state="danger" variant="outline"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</x-global::forms.button>
|
||||
<input type="hidden" value="1" name="save" />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="saveEvent" id="save" />
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
@dispatchEvent('beforeFormClose')
|
||||
|
||||
</form>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function() {
|
||||
leantime.calendarController.initEventDatepickers();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
||||
@endsection
|
||||
32
app/Domain/Calendar/Templates/editExternalCalendar.blade.php
Normal file
32
app/Domain/Calendar/Templates/editExternalCalendar.blade.php
Normal file
@@ -0,0 +1,32 @@
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h4 class="widgettitle title-light"><i class="fa-regular fa-calendar-plus"></i> {{ __('label.edit_ical') }}</h4>
|
||||
{!! __('label.import_ical_content') !!}
|
||||
|
||||
|
||||
<form action="{{ BASE_URL }}/calendar/editExternal/{{ $values['id'] }}" method="post" class="formModal">
|
||||
|
||||
<input type="hidden" name="save" value="1" />
|
||||
<label for="name">{{ $tpl->__('label.calendar_name') }}:</label>
|
||||
<x-global::forms.text-input id="name" name="name" autocomplete="off" value="{{ $values['name'] }}" /><br />
|
||||
|
||||
<label for="url">{{ $tpl->__('label.ical_url') }}:</label>
|
||||
<x-global::forms.text-input id="url" name="url" autocomplete="off" style="width:300px;" value="{{ $values['url'] }}" /><br />
|
||||
|
||||
<label for="color">{{ $tpl->__('label.color') }}:</label>
|
||||
<input type="text" name="colorClass" autocomplete="off" value="{{ $values['colorClass'] }}" class="simpleColorPicker"/>
|
||||
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
<br /><br />
|
||||
<x-global::forms.button tag="input" inputType="submit" name="save" id="save" :labelText="__('buttons.save')" />
|
||||
|
||||
|
||||
|
||||
|
||||
@dispatchEvent('beforeFormClose')
|
||||
|
||||
</form>
|
||||
|
||||
<script>
|
||||
leantime.ticketsController.initSimpleColorPicker();
|
||||
</script>
|
||||
47
app/Domain/Calendar/Templates/export.blade.php
Normal file
47
app/Domain/Calendar/Templates/export.blade.php
Normal file
@@ -0,0 +1,47 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$url = $url ?? null;
|
||||
@endphp
|
||||
|
||||
<h4 class="widgettitle title-light"><i class="fa fa-file-export"></i> {!! __('label.ical_export') !!}</h4>
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<form class="formModal" method="post" action="{{ BASE_URL }}/calendar/export">
|
||||
|
||||
@dispatchEvent('afterFormOpen')
|
||||
|
||||
{!! __('text.ical_export_description') !!}
|
||||
<br />
|
||||
|
||||
@if ($url)
|
||||
{!! __('text.you_ical_url') !!}
|
||||
<br /><x-global::forms.text-input value='{{ $url }}' style='width:100%;' />
|
||||
@else
|
||||
{!! __('text.no_url') !!}
|
||||
@endif
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="hidden" value="1" name="generateUrl" />
|
||||
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
|
||||
<br /><x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.generate_ical_url')" />
|
||||
|
||||
</div>
|
||||
<div class="col-md-6 align-right">
|
||||
@if ($url)
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/calendar/export?remove=1" class="delete formModal" state="danger" variant="outline"><i class="fa fa-trash"></i> {!! __('links.remove_access') !!}</x-global::forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@dispatchEvent('beforeFormClose')
|
||||
|
||||
</form>
|
||||
|
||||
@endsection
|
||||
31
app/Domain/Calendar/Templates/importGCal.blade.php
Normal file
31
app/Domain/Calendar/Templates/importGCal.blade.php
Normal file
@@ -0,0 +1,31 @@
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h4 class="widgettitle title-light"><i class="fa-regular fa-calendar-plus"></i> {{ __('label.import_ical') }}</h4>
|
||||
{!! __('label.import_ical_content') !!}
|
||||
|
||||
|
||||
<form action="{{ BASE_URL }}/calendar/importGCal" method="post" class="formModal">
|
||||
|
||||
<label for="name">{{ $tpl->__('label.calendar_name') }}:</label>
|
||||
<x-global::forms.text-input id="name" name="name" autocomplete="off" value="{{ $values['name'] }}" /><br />
|
||||
|
||||
<label for="url">{{ $tpl->__('label.ical_url') }}:</label>
|
||||
<x-global::forms.text-input id="url" name="url" autocomplete="off" style="width:300px;" value="{{ $values['url'] }}" /><br />
|
||||
|
||||
<label for="color">{{ $tpl->__('label.color') }}:</label>
|
||||
<input type="text" name="colorClass" autocomplete="off" value="{{ $values['colorClass'] }}" class="simpleColorPicker"/>
|
||||
|
||||
@dispatchEvent('beforeSubmitButton')
|
||||
<br /><br />
|
||||
<x-global::forms.button tag="input" inputType="submit" name="save" id="save" :labelText="__('buttons.save')" />
|
||||
|
||||
|
||||
|
||||
|
||||
@dispatchEvent('beforeFormClose')
|
||||
|
||||
</form>
|
||||
|
||||
<script>
|
||||
leantime.ticketsController.initSimpleColorPicker();
|
||||
</script>
|
||||
93
app/Domain/Calendar/Templates/showAllGCals.blade.php
Normal file
93
app/Domain/Calendar/Templates/showAllGCals.blade.php
Normal file
@@ -0,0 +1,93 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@dispatchEvent('beforePageHeaderOpen')
|
||||
<div class="pageheader">
|
||||
@dispatchEvent('afterPageHeaderOpen')
|
||||
<form action="{{ BASE_URL }}/index.php?act=tickets.showAll" method="post" class="searchbar">
|
||||
<x-global::forms.text-input name="term" placeholder="To search type and hit enter..." />
|
||||
</form>
|
||||
|
||||
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
|
||||
<div class="pagetitle">
|
||||
<h5>{!! __('OVERVIEW') !!}</h5>
|
||||
<h1>{!! __('ALL_GCCALS') !!}</h1>
|
||||
</div>
|
||||
@dispatchEvent('beforePageHeaderClose')
|
||||
</div><!--pageheader-->
|
||||
@dispatchEvent('afterPageHeaderClose')
|
||||
|
||||
<div class="maincontent">
|
||||
<div class="maincontentinner">
|
||||
<form action="">
|
||||
|
||||
@dispatchEvent('afterFormOpen')
|
||||
|
||||
<table cellpadding="0" cellspacing="0" border="0" class="allTickets table table-bordered"
|
||||
id="allTickets">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>{!! __('NAME') !!}</th>
|
||||
<th>{!! __('URL') !!}</th>
|
||||
<th>{!! __('COLOR') !!}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
@foreach ($allCalendars as $row)
|
||||
<tr>
|
||||
<td>{!! $tpl->displayLink('calendar.editGCal', $row['id'], ['id' => $row['id']]) !!}</td>
|
||||
<td>{!! $tpl->displayLink('calendar.editGCal', $row['name'], ['id' => $row['id']]) !!}</td>
|
||||
<td>{{ $row['url'] }}</td>
|
||||
<td><span style="color: {{ $row['colorClass'] }}; padding:2px;">{{ $row['colorClass'] }}</span></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@dispatchEvent('beforeFormClose')
|
||||
|
||||
</form>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
|
||||
@dispatchEvent('scripts.afterOpen')
|
||||
|
||||
$(document).ready(function()
|
||||
{
|
||||
$("#allTickets").tablesorter({
|
||||
sortList:[[0,0]],
|
||||
widgets: ['zebra']
|
||||
}).tablesorterPager({container: $("#pager")});
|
||||
|
||||
//assign the sortStart event
|
||||
$("#allTickets").bind("sortStart",function() {
|
||||
|
||||
$('#loader').show();
|
||||
|
||||
|
||||
}).bind("sortEnd",function() {
|
||||
|
||||
$('#loader').hide();
|
||||
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
@dispatchEvent('scripts.beforeClose')
|
||||
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
||||
<link rel='stylesheet' type='text/css' href='includes/libs/fullCalendar/fullcalendar.css' />
|
||||
|
||||
@endsection
|
||||
355
app/Domain/Calendar/Templates/showMyCalendar.blade.php
Normal file
355
app/Domain/Calendar/Templates/showMyCalendar.blade.php
Normal file
@@ -0,0 +1,355 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
if (! session()->exists('usersettings.submenuToggle.myCalendarView')) {
|
||||
session(['usersettings.submenuToggle.myCalendarView' => 'dayGridMonth']);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@dispatchEvent('beforePageHeaderOpen')
|
||||
<div class="pageheader">
|
||||
@dispatchEvent('afterPageHeaderOpen')
|
||||
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
|
||||
<div class="pagetitle">
|
||||
<h5>{!! __('headline.calendar') !!}</h5>
|
||||
<h1>{!! __('headline.my_calendar') !!}</h1>
|
||||
</div>
|
||||
@dispatchEvent('beforePageHeaderClose')
|
||||
</div><!--pageheader-->
|
||||
@dispatchEvent('afterPageHeaderClose')
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<div class="maincontent">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<div class="maincontentinner">
|
||||
<h5 class="subtitle tw-pb-m">Calendars</h5>
|
||||
|
||||
<ul class="simpleList">
|
||||
<li><span class="indicatorCircle" style="background:var(--accent1)"></span>Events</li>
|
||||
<li><span class="indicatorCircle" style="background:var(--accent2)"></span>Projects & Tasks</li>
|
||||
|
||||
@foreach ($externalCalendars as $calendars)
|
||||
<li>
|
||||
@if (empty($calendars['managedByPlugin']))
|
||||
<div class="inlineDropDownContainer" style="float:right;">
|
||||
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown editHeadline" data-toggle="dropdown">
|
||||
<i class="fa fa-ellipsis-h" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a href="#/calendar/editExternal/{{ $calendars['id'] }}"><i class="fa-solid fa-pen-to-square"></i> {!! __('links.edit_calendar') !!}</a>
|
||||
</li>
|
||||
<li><a href="#/calendar/delExternalCalendar/{{ $calendars['id'] }}" class="delete"><i class="fa fa-trash"></i> {!! __('links.delete_external_calendar') !!}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
<span class="indicatorCircle" style="background:{{ $calendars['colorClass'] }}"></span>{{ $calendars['name'] }}
|
||||
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
</ul>
|
||||
<hr />
|
||||
<a href="#/calendar/connectCalendar" class="formModal" style="display:block; margin-bottom:8px; margin-left:-5px;"><i class="fa-regular fa-calendar-plus" style="width:16px;"></i> {!! __('label.connect_calendar') !!}</a>
|
||||
<a href="#/calendar/calendarSettings" class="formModal" style="margin-left:-5px;"><i class="fa fa-cog" style="width:16px;"></i> {!! __('label.calendar_settings') !!}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="maincontentinner">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<x-global::forms.button tag="a" link="#/calendar/addEvent" contentRole="primary" class="formModal"><i class='fa fa-plus'></i> {!! __('buttons.add_event') !!}</x-global::forms.button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="fc-center center" id="calendarTitle" style="padding-top:5px;">
|
||||
<h2>..</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<x-global::forms.button tag="a" link="#/calendar/export" contentRole="default" class="right">Export</x-global::forms.button>
|
||||
<button class="fc-next-button btn btn-default right" type="button" style="margin-right:5px;">
|
||||
<span class="fc-icon fc-icon-chevron-right"></span>
|
||||
</button>
|
||||
<button class="fc-prev-button btn btn-default right" type="button" style="margin-right:5px;">
|
||||
<span class="fc-icon fc-icon-chevron-left"></span>
|
||||
</button>
|
||||
|
||||
<button class="fc-today-button btn btn-default right" style="margin-right:5px;">today</button>
|
||||
|
||||
|
||||
<select id="my-select" style="margin-right:5px;" class="right">
|
||||
<option class="fc-timeGridDay-button fc-button fc-state-default fc-corner-right" value="timeGridDay" {{ session('usersettings.submenuToggle.myCalendarView') == 'timeGridDay' ? 'selected' : '' }}>Day</option>
|
||||
<option class="fc-timeGridWeek-button fc-button fc-state-default fc-corner-right" value="timeGridWeek" {{ session('usersettings.submenuToggle.myCalendarView') == 'timeGridWeek' ? 'selected' : '' }}>Week</option>
|
||||
<option class="fc-dayGridMonth-button fc-button fc-state-default fc-corner-right" value="dayGridMonth" {{ session('usersettings.submenuToggle.myCalendarView') == 'dayGridMonth' ? 'selected' : '' }}>Month</option>
|
||||
<option class="fc-multiMonthYear-button fc-button fc-state-default fc-corner-right" value="multiMonthYear" {{ session('usersettings.submenuToggle.myCalendarView') == 'multiMonthYear' ? 'selected' : '' }}>Year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script type='text/javascript'>
|
||||
|
||||
@dispatchEvent('scripts.afterOpen')
|
||||
|
||||
|
||||
jQuery(document).ready(function() {
|
||||
|
||||
//leantime.calendarController.initCalendar(events);
|
||||
leantime.calendarController.initExportModal();
|
||||
|
||||
});
|
||||
var eventSources = [];
|
||||
|
||||
var events = {events: [
|
||||
@foreach ($calendar as $calendarEvent)
|
||||
{
|
||||
title: {!! json_encode($calendarEvent['title']) !!},
|
||||
|
||||
start: new Date({{ format($calendarEvent['dateFrom'])->jsTimestamp() }}),
|
||||
@if (isset($calendarEvent['dateTo']))
|
||||
end: new Date({{ format($calendarEvent['dateTo'])->jsTimestamp() }}),
|
||||
@endif
|
||||
@if (isset($calendarEvent['allDay']) && $calendarEvent['allDay'] === true)
|
||||
allDay: true,
|
||||
@else
|
||||
allDay: false,
|
||||
@endif
|
||||
enitityId: {{ $calendarEvent['id'] }},
|
||||
@if (isset($calendarEvent['eventType']) && $calendarEvent['eventType'] == 'calendar')
|
||||
url: '{{ CURRENT_URL }}#/calendar/editEvent/{{ $calendarEvent['id'] }}',
|
||||
backgroundColor: '{{ $calendarEvent['backgroundColor'] ?? 'var(--accent2)' }}',
|
||||
borderColor: '{{ $calendarEvent['borderColor'] ?? 'var(--accent2)' }}',
|
||||
enitityType: "event",
|
||||
dateContext: '{{ $calendarEvent['dateContext'] ?? 'plan' }}',
|
||||
@else
|
||||
url: '{{ CURRENT_URL }}#/tickets/showTicket/{{ $calendarEvent['id'] }}?projectId={{ $calendarEvent['projectId'] }}',
|
||||
backgroundColor: '{{ $calendarEvent['backgroundColor'] ?? 'var(--accent2)' }}',
|
||||
borderColor: '{{ $calendarEvent['borderColor'] ?? 'var(--accent2)' }}',
|
||||
enitityType: "ticket",
|
||||
dateContext: '{{ $calendarEvent['dateContext'] ?? 'edit' }}',
|
||||
@endif
|
||||
},
|
||||
@endforeach
|
||||
]};
|
||||
|
||||
eventSources.push(events);
|
||||
|
||||
@foreach ($externalCalendars as $externalCalendar)
|
||||
@if (empty($externalCalendar['managedByPlugin']))
|
||||
eventSources.push(
|
||||
{
|
||||
url: '{{ BASE_URL }}/calendar/externalCal/{{ $externalCalendar['id'] }}',
|
||||
format: 'ics',
|
||||
color: '{{ $externalCalendar['colorClass'] }}',
|
||||
editable: false,
|
||||
}
|
||||
);
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const heightWindow = jQuery("body").height() - 210;
|
||||
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
|
||||
const calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
timeZone: leantime.i18n.__("usersettings.timezone"),
|
||||
height: 'calc(100% - 40px)',
|
||||
stickyHeaderDates: true,
|
||||
initialView: '{{ session('usersettings.submenuToggle.myCalendarView') }}',
|
||||
eventSources:eventSources,
|
||||
editable: true,
|
||||
headerToolbar: false,
|
||||
dayHeaderFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "luxon"),
|
||||
eventTimeFormat: leantime.dateHelper.getFormatFromSettings("timeformat", "luxon"),
|
||||
slotLabelFormat: leantime.dateHelper.getFormatFromSettings("timeformat", "luxon"),
|
||||
firstDay: leantime.i18n.__("language.firstDayOfWeek"),
|
||||
views: {
|
||||
timeGridDay: {
|
||||
|
||||
},
|
||||
timeGridWeek: {
|
||||
|
||||
},
|
||||
dayGridMonth: {
|
||||
dayHeaderFormat: { weekday: 'short' },
|
||||
},
|
||||
multiMonthYear: {
|
||||
showNonCurrentDates: true,
|
||||
multiMonthTitleFormat: { month: 'long', year: 'numeric' },
|
||||
dayHeaderFormat: { weekday: 'short' },
|
||||
},
|
||||
multiMonthOneMonth: {
|
||||
type: 'multiMonth',
|
||||
duration: {months: 1},
|
||||
multiMonthTitleFormat: {month: 'long', year: 'numeric'},
|
||||
dayHeaderFormat: {weekday: 'short'},
|
||||
},
|
||||
listWeek: {
|
||||
listDayFormat: {weekday: 'long'},
|
||||
listDaySideFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "luxon"),
|
||||
}
|
||||
},
|
||||
nowIndicator: true,
|
||||
bootstrapFontAwesome: {
|
||||
close: 'fa-times',
|
||||
prev: 'fa-chevron-left',
|
||||
next: 'fa-chevron-right',
|
||||
prevYear: 'fa-angle-double-left',
|
||||
nextYear: 'fa-angle-double-right'
|
||||
},
|
||||
eventDrop: function (event) {
|
||||
|
||||
if(event.event.extendedProps.enitityType == "ticket") {
|
||||
|
||||
let dataVal = {};
|
||||
|
||||
if(event.event.extendedProps.dateContext == "due") {
|
||||
|
||||
dataVal = {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
dateToFinish: event.event.startStr
|
||||
}
|
||||
|
||||
}else{
|
||||
dataVal = {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
editFrom: event.event.startStr,
|
||||
editTo: event.event.endStr
|
||||
}
|
||||
}
|
||||
|
||||
leantime.rpc('Tickets.Tickets.patchTicket', { id: dataVal.id, values: dataVal })
|
||||
.catch(function (error) {
|
||||
jQuery.growl({ message: (error && error.message) ? error.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
|
||||
event.revert();
|
||||
console.error('Could not update ticket dates', error);
|
||||
});
|
||||
|
||||
}else if(event.event.extendedProps.enitityType == "event") {
|
||||
|
||||
leantime.rpc('Calendar.Calendar.patch', {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
params: {
|
||||
dateFrom: event.event.startStr,
|
||||
dateTo: event.event.endStr
|
||||
}
|
||||
}).then(function (success) {
|
||||
// Denied/failed update resolves to false — undo the visual move.
|
||||
if (! success) { event.revert(); }
|
||||
}).catch(function (error) {
|
||||
console.error('Could not update event dates', error);
|
||||
event.revert();
|
||||
})
|
||||
}
|
||||
},
|
||||
eventResize: function (event) {
|
||||
|
||||
if(event.event.extendedProps.enitityType == "ticket") {
|
||||
|
||||
let dataVal = {};
|
||||
|
||||
if(event.event.extendedProps.dateContext == "due") {
|
||||
|
||||
dataVal = {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
dateToFinish: event.event.startStr
|
||||
}
|
||||
|
||||
}else{
|
||||
dataVal = {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
editFrom: event.event.startStr,
|
||||
editTo: event.event.endStr
|
||||
}
|
||||
}
|
||||
|
||||
leantime.rpc('Tickets.Tickets.patchTicket', { id: dataVal.id, values: dataVal })
|
||||
.catch(function (error) {
|
||||
jQuery.growl({ message: (error && error.message) ? error.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
|
||||
event.revert();
|
||||
console.error('Could not update ticket dates', error);
|
||||
});
|
||||
|
||||
|
||||
}else if(event.event.extendedProps.enitityType == "event") {
|
||||
|
||||
leantime.rpc('Calendar.Calendar.patch', {
|
||||
id: event.event.extendedProps.enitityId,
|
||||
params: {
|
||||
dateFrom: event.event.startStr,
|
||||
dateTo: event.event.endStr
|
||||
}
|
||||
}).then(function (success) {
|
||||
// Denied/failed update resolves to false — undo the visual move.
|
||||
if (! success) { event.revert(); }
|
||||
}).catch(function (error) {
|
||||
console.error('Could not update event dates', error);
|
||||
event.revert();
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
eventMouseEnter: function() {
|
||||
},
|
||||
|
||||
}
|
||||
);
|
||||
calendar.setOption('locale', leantime.i18n.__("language.code"));
|
||||
calendar.render();
|
||||
calendar.scrollToTime( Date.now() );
|
||||
jQuery("#calendarTitle h2").text(calendar.getCurrentData().viewTitle);
|
||||
|
||||
jQuery('.fc-prev-button').click(function() {
|
||||
calendar.prev();
|
||||
calendar.getCurrentData()
|
||||
jQuery("#calendarTitle h2").text(calendar.getCurrentData().viewTitle);
|
||||
});
|
||||
jQuery('.fc-next-button').click(function() {
|
||||
calendar.next();
|
||||
jQuery("#calendarTitle h2").text(calendar.getCurrentData().viewTitle);
|
||||
});
|
||||
jQuery('.fc-today-button').click(function() {
|
||||
calendar.today();
|
||||
jQuery("#calendarTitle h2").text(calendar.getCurrentData().viewTitle);
|
||||
});
|
||||
jQuery("#my-select").on("change", function(e){
|
||||
|
||||
calendar.changeView(jQuery("#my-select option:selected").val());
|
||||
|
||||
leantime.rpc('Api.Api.setSubmenuState', {
|
||||
submenu: "myCalendarView",
|
||||
state: jQuery("#my-select option:selected").val()
|
||||
}).catch(function (e) { console.error('Could not update submenu state', e); });
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@dispatchEvent('scripts.beforeClose')
|
||||
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
||||
<style type="text/css">
|
||||
.maincontent .maincontentinner {
|
||||
height:calc(100vh - 165px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@endsection
|
||||
59
app/Domain/Calendar/Tools/AddCalendarEventTool.php
Normal file
59
app/Domain/Calendar/Tools/AddCalendarEventTool.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
|
||||
/**
|
||||
* Add a new calendar event.
|
||||
*/
|
||||
class AddCalendarEventTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->string('eventTitle')->description('Title of the event.')
|
||||
->required()
|
||||
->string('dateFrom')->description('Start date in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required()
|
||||
->string('dateTo')->description('End date in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required()
|
||||
->boolean('allDay')->description('Whether this is an all-day event or not.');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'addEvent';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Adds a new calendar event.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$result = $this->calendarService->addEvent([
|
||||
'description' => $arguments['eventTitle'],
|
||||
'dateFrom' => $arguments['dateFrom'],
|
||||
'dateTo' => $arguments['dateTo'],
|
||||
'allDay' => $arguments['allDay'] ?? false,
|
||||
]);
|
||||
|
||||
if ($result) {
|
||||
return ToolResult::text("Event added successfully with ID: {$result}");
|
||||
}
|
||||
|
||||
return ToolResult::error('Failed to add event.');
|
||||
}
|
||||
}
|
||||
289
app/Domain/Calendar/Tools/BreakdownTaskTool.php
Normal file
289
app/Domain/Calendar/Tools/BreakdownTaskTool.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
use Leantime\Domain\Tickets\Services\Tickets;
|
||||
|
||||
/**
|
||||
* Break down a task into multiple subtasks and schedule them.
|
||||
*/
|
||||
class BreakdownTaskTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
private Tickets $ticketService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('taskId')->description('ID of the task to break down.')
|
||||
->required()
|
||||
->raw('subtasks', ['type' => 'array', 'description' => 'Array of subtask definitions. Each should have title, description, and duration (in minutes).'])->required()
|
||||
->string('startDate')->description('Start date to begin scheduling from in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required()
|
||||
->string('endDate')->description('End date to finish scheduling by in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'breakdownTask';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Breaks a task down into subtasks and schedules them.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$taskId = (int) ($arguments['taskId'] ?? 0);
|
||||
$subtasks = ($arguments['subtasks'] ?? []);
|
||||
$startDate = $arguments['startDate'];
|
||||
$endDate = ($arguments['endDate'] ?? null);
|
||||
|
||||
$task = $this->ticketService->getTicket($taskId);
|
||||
|
||||
if (! $task) {
|
||||
return ToolResult::error("Task with ID {$taskId} not found.");
|
||||
}
|
||||
|
||||
// Create subtasks
|
||||
$subtaskResults = [];
|
||||
$subtaskIds = [];
|
||||
$subtaskDurations = [];
|
||||
|
||||
foreach ($subtasks as $subtaskDef) {
|
||||
$params = [
|
||||
'headline' => $subtaskDef['title'],
|
||||
'description' => $subtaskDef['description'] ?? '',
|
||||
'projectId' => $task->projectId,
|
||||
'editorId' => session('userdata.id'),
|
||||
'userId' => session('userdata.id'),
|
||||
'type' => 'subtask',
|
||||
'dependingTicketId' => $taskId,
|
||||
'status' => 3,
|
||||
];
|
||||
|
||||
$result = $this->ticketService->quickAddTicket($params);
|
||||
|
||||
if ($result) {
|
||||
$subtaskResults[] = [
|
||||
'headline' => $subtaskDef['title'],
|
||||
'status' => 'success',
|
||||
'id' => $result,
|
||||
];
|
||||
$subtaskIds[] = $result;
|
||||
$subtaskDurations[$result] = $subtaskDef['duration'] ?? 30;
|
||||
} else {
|
||||
$subtaskResults[] = [
|
||||
'headline' => $subtaskDef['title'],
|
||||
'status' => 'error',
|
||||
'message' => 'Failed to create subtask',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$createResult = Str::toMarkdown($subtaskResults);
|
||||
|
||||
// Schedule subtasks if we have IDs and a date range
|
||||
if (! empty($subtaskIds) && ! empty($startDate)) {
|
||||
$currentDate = dtHelper()->parseUserDateTime($startDate);
|
||||
$lastDate = $endDate ? dtHelper()->parseUserDateTime($endDate) : $currentDate->addDays(7);
|
||||
|
||||
$scheduledResults = [];
|
||||
|
||||
while ($currentDate <= $lastDate && ! empty($subtaskIds)) {
|
||||
$workStart = $currentDate->setTime(9, 0);
|
||||
$workEnd = $currentDate->setTime(18, 0);
|
||||
|
||||
$dayFrom = $currentDate->startOfDay();
|
||||
$dayTo = $currentDate->endOfDay();
|
||||
$existingEvents = $this->calendarService->getCalendar(session('userdata.id'), $dayFrom, $dayTo);
|
||||
$existingTasks = $this->ticketService->getScheduledTasks($dayFrom, $dayTo, session('userdata.id'));
|
||||
|
||||
$availableSlots = $this->findAvailableTimeSlots($workStart, $workEnd, $existingEvents, $existingTasks);
|
||||
|
||||
$tasksToSchedule = [];
|
||||
foreach ($subtaskIds as $id) {
|
||||
$tasksToSchedule[] = [
|
||||
'id' => $id,
|
||||
'duration' => $subtaskDurations[$id] ?? 30,
|
||||
];
|
||||
}
|
||||
|
||||
$scheduledTasks = $this->scheduleTasksInSlots($availableSlots, $tasksToSchedule);
|
||||
|
||||
foreach ($scheduledTasks as $scheduledTask) {
|
||||
$editFrom = is_object($scheduledTask['dateFrom']) ? $scheduledTask['dateFrom']->toIso8601String() : (string) $scheduledTask['dateFrom'];
|
||||
$editTo = is_object($scheduledTask['dateTo']) ? $scheduledTask['dateTo']->toIso8601String() : (string) $scheduledTask['dateTo'];
|
||||
|
||||
$this->ticketService->patch($scheduledTask['id'], [
|
||||
'editFrom' => $editFrom,
|
||||
'editTo' => $editTo,
|
||||
]);
|
||||
|
||||
$index = array_search($scheduledTask['id'], $subtaskIds);
|
||||
if ($index !== false) {
|
||||
unset($subtaskIds[$index]);
|
||||
$subtaskIds = array_values($subtaskIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($scheduledTasks)) {
|
||||
$dateStr = $currentDate->format('Y-m-d');
|
||||
$scheduledResults[] = 'Scheduled '.count($scheduledTasks)." subtask(s) on {$dateStr}";
|
||||
}
|
||||
|
||||
$currentDate = $currentDate->addDay();
|
||||
}
|
||||
|
||||
$schedulingSummary = implode("\n", $scheduledResults);
|
||||
|
||||
return ToolResult::text("Task breakdown and scheduling completed.\n\nSubtask Creation:\n{$createResult}\n\nScheduling:\n{$schedulingSummary}");
|
||||
}
|
||||
|
||||
return ToolResult::text("Task breakdown initiated.\n\n{$createResult}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find available time slots in a day.
|
||||
*/
|
||||
private function findAvailableTimeSlots($workStart, $workEnd, array $existingEvents, array $existingTasks): array
|
||||
{
|
||||
$busyTimes = [];
|
||||
|
||||
foreach ($existingEvents as $event) {
|
||||
$busyTimes[] = [
|
||||
'start' => dtHelper()->parseDbDateTime($event['dateFrom']),
|
||||
'end' => dtHelper()->parseDbDateTime($event['dateTo']),
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($existingTasks['totalTasks'])) {
|
||||
foreach ($existingTasks['totalTasks'] as $task) {
|
||||
if (! empty($task['editFrom']) && ! empty($task['editTo'])) {
|
||||
$busyTimes[] = [
|
||||
'start' => dtHelper()->parseDbDateTime($task['editFrom']),
|
||||
'end' => dtHelper()->parseDbDateTime($task['editTo']),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($busyTimes, function ($a, $b) {
|
||||
return $a['start']->getTimestamp() - $b['start']->getTimestamp();
|
||||
});
|
||||
|
||||
$mergedBusyTimes = [];
|
||||
foreach ($busyTimes as $busy) {
|
||||
if (empty($mergedBusyTimes)) {
|
||||
$mergedBusyTimes[] = $busy;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastBusy = &$mergedBusyTimes[count($mergedBusyTimes) - 1];
|
||||
|
||||
if ($busy['start'] <= $lastBusy['end']) {
|
||||
if ($busy['end'] > $lastBusy['end']) {
|
||||
$lastBusy['end'] = $busy['end'];
|
||||
}
|
||||
} else {
|
||||
$mergedBusyTimes[] = $busy;
|
||||
}
|
||||
}
|
||||
|
||||
$availableSlots = [];
|
||||
$currentTime = clone $workStart;
|
||||
|
||||
foreach ($mergedBusyTimes as $busy) {
|
||||
if ($busy['end'] <= $workStart || $busy['start'] >= $workEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$busyStart = max($busy['start'], $workStart);
|
||||
$busyEnd = min($busy['end'], $workEnd);
|
||||
|
||||
if ($currentTime < $busyStart) {
|
||||
$availableSlots[] = [
|
||||
'start' => clone $currentTime,
|
||||
'end' => clone $busyStart,
|
||||
];
|
||||
}
|
||||
|
||||
$currentTime = clone $busyEnd;
|
||||
}
|
||||
|
||||
if ($currentTime < $workEnd) {
|
||||
$availableSlots[] = [
|
||||
'start' => clone $currentTime,
|
||||
'end' => clone $workEnd,
|
||||
];
|
||||
}
|
||||
|
||||
return $availableSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule tasks in available time slots.
|
||||
*/
|
||||
private function scheduleTasksInSlots(array $availableSlots, array $tasks): array
|
||||
{
|
||||
usort($tasks, function ($a, $b) {
|
||||
$priorityA = $a['priority'] ?? 3;
|
||||
$priorityB = $b['priority'] ?? 3;
|
||||
|
||||
if ($priorityA !== $priorityB) {
|
||||
return $priorityB - $priorityA;
|
||||
}
|
||||
|
||||
$durationA = $a['duration'] ?? 30;
|
||||
$durationB = $b['duration'] ?? 30;
|
||||
|
||||
return $durationB - $durationA;
|
||||
});
|
||||
|
||||
$scheduledTasks = [];
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$durationMinutes = $task['duration'] ?? 30;
|
||||
$durationSeconds = $durationMinutes * 60;
|
||||
$taskId = $task['id'];
|
||||
|
||||
foreach ($availableSlots as $key => $slot) {
|
||||
$slotDuration = $slot['end']->getTimestamp() - $slot['start']->getTimestamp();
|
||||
|
||||
if ($slotDuration >= $durationSeconds) {
|
||||
$taskStart = clone $slot['start'];
|
||||
$taskEnd = clone $taskStart;
|
||||
$taskEnd = $taskEnd->modify("+{$durationMinutes} minutes");
|
||||
|
||||
$scheduledTasks[] = [
|
||||
'id' => $taskId,
|
||||
'dateFrom' => $taskStart,
|
||||
'dateTo' => $taskEnd,
|
||||
];
|
||||
|
||||
$availableSlots[$key]['start'] = $taskEnd;
|
||||
|
||||
if ($availableSlots[$key]['end']->getTimestamp() - $availableSlots[$key]['start']->getTimestamp() < 900) {
|
||||
unset($availableSlots[$key]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $scheduledTasks;
|
||||
}
|
||||
}
|
||||
116
app/Domain/Calendar/Tools/BulkAddCalendarEventsTool.php
Normal file
116
app/Domain/Calendar/Tools/BulkAddCalendarEventsTool.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
|
||||
/**
|
||||
* Add multiple calendar events in a single operation.
|
||||
*/
|
||||
class BulkAddCalendarEventsTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->raw('events', ['type' => 'array', 'description' => 'Array of event data. Each element should contain eventTitle, dateFrom, dateTo, and optional allDay.'])->required();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'bulkAddEvents';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Adds multiple calendar events in a single operation.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$events = ($arguments['events'] ?? []);
|
||||
$results = [];
|
||||
$successCount = 0;
|
||||
$failureCount = 0;
|
||||
|
||||
$validationErrors = [];
|
||||
|
||||
foreach ($events as $index => $eventData) {
|
||||
if (! isset($eventData['eventTitle']) || ! isset($eventData['dateFrom']) || ! isset($eventData['dateTo'])) {
|
||||
$validationErrors[] = "Event #{$index} is missing required fields (eventTitle, dateFrom, dateTo)";
|
||||
Log::error("Event #{$index} is missing required fields (eventTitle, dateFrom, dateTo)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$dateFrom = ($eventData['dateFrom'] instanceof CarbonImmutable) ? $eventData['dateFrom'] : dtHelper()->parseUserDateTime($eventData['dateFrom']);
|
||||
$dateTo = ($eventData['dateTo'] instanceof CarbonImmutable) ? $eventData['dateTo'] : dtHelper()->parseUserDateTime($eventData['dateTo']);
|
||||
|
||||
$durationSeconds = $dateTo->getTimestamp() - $dateFrom->getTimestamp();
|
||||
if ($durationSeconds < 900) {
|
||||
$validationErrors[] = "Event #{$index} is shorter than the minimum 15 minute duration";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$validationErrors[] = "Event #{$index} has invalid date format";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($validationErrors)) {
|
||||
return ToolResult::error("Validation failed:\n- ".implode("\n- ", $validationErrors));
|
||||
}
|
||||
|
||||
foreach ($events as $eventData) {
|
||||
try {
|
||||
$result = $this->calendarService->addEvent([
|
||||
'description' => $eventData['eventTitle'],
|
||||
'dateFrom' => $eventData['dateFrom'],
|
||||
'dateTo' => $eventData['dateTo'],
|
||||
'allDay' => $eventData['allDay'] ?? false,
|
||||
'userId' => session('userdata.id'),
|
||||
]);
|
||||
|
||||
if ($result) {
|
||||
$successCount++;
|
||||
$results[] = [
|
||||
'title' => $eventData['eventTitle'],
|
||||
'status' => 'success',
|
||||
'id' => $result,
|
||||
];
|
||||
} else {
|
||||
$failureCount++;
|
||||
$results[] = [
|
||||
'title' => $eventData['eventTitle'],
|
||||
'status' => 'error',
|
||||
'message' => 'Failed to create event',
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$failureCount++;
|
||||
$results[] = [
|
||||
'title' => $eventData['eventTitle'] ?? 'Unknown',
|
||||
'status' => 'error',
|
||||
'message' => 'Failed to create event',
|
||||
];
|
||||
Log::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult::text(
|
||||
"Bulk event creation completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
|
||||
Str::toMarkdown($results)
|
||||
);
|
||||
}
|
||||
}
|
||||
108
app/Domain/Calendar/Tools/BulkEditCalendarEventsTool.php
Normal file
108
app/Domain/Calendar/Tools/BulkEditCalendarEventsTool.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
|
||||
/**
|
||||
* Update multiple calendar events in a single operation.
|
||||
*/
|
||||
class BulkEditCalendarEventsTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->raw('updates', ['type' => 'array', 'description' => 'Array of updates. Each element must have id and the fields to update.'])->required();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'bulkEditEvents';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Updates multiple calendar events in a single operation.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$updates = ($arguments['updates'] ?? []);
|
||||
$results = [];
|
||||
$successCount = 0;
|
||||
$failureCount = 0;
|
||||
|
||||
foreach ($updates as $update) {
|
||||
if (! isset($update['id'])) {
|
||||
$failureCount++;
|
||||
$results[] = ['status' => 'error', 'message' => 'Missing event ID'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($update['dateFrom']) && isset($update['dateTo'])) {
|
||||
$dateFrom = dtHelper()->parseUserDateTime($update['dateFrom']);
|
||||
$dateTo = dtHelper()->parseUserDateTime($update['dateTo']);
|
||||
|
||||
if ($dateFrom->format('Y-m-d') !== $dateTo->format('Y-m-d')) {
|
||||
$failureCount++;
|
||||
$results[] = [
|
||||
'id' => $update['id'],
|
||||
'status' => 'error',
|
||||
'message' => 'Event must start and end on the same day',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$duration = $dateTo->getTimestamp() - $dateFrom->getTimestamp();
|
||||
if ($duration < 900) {
|
||||
$failureCount++;
|
||||
$results[] = [
|
||||
'id' => $update['id'],
|
||||
'status' => 'error',
|
||||
'message' => 'Event must be at least 15 minutes long',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$eventData = [
|
||||
'id' => $update['id'],
|
||||
'description' => $update['eventTitle'] ?? null,
|
||||
'dateFrom' => $update['dateFrom'] ?? null,
|
||||
'dateTo' => $update['dateTo'] ?? null,
|
||||
'allDay' => $update['allDay'] ?? null,
|
||||
];
|
||||
|
||||
$eventData = array_filter($eventData, function ($value) {
|
||||
return $value !== null;
|
||||
});
|
||||
|
||||
if ($this->calendarService->editEvent($eventData)) {
|
||||
$successCount++;
|
||||
$results[] = ['id' => $update['id'], 'status' => 'success'];
|
||||
} else {
|
||||
$failureCount++;
|
||||
$results[] = ['id' => $update['id'], 'status' => 'error', 'message' => 'Failed to update event'];
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult::text(
|
||||
"Bulk event update completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
|
||||
Str::toMarkdown($results)
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/Domain/Calendar/Tools/DeleteCalendarEventTool.php
Normal file
50
app/Domain/Calendar/Tools/DeleteCalendarEventTool.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
|
||||
/**
|
||||
* Delete a calendar event.
|
||||
*/
|
||||
class DeleteCalendarEventTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('Event ID to delete.')
|
||||
->required();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'deleteEvent';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Deletes a calendar event.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
$result = $this->calendarService->delEvent($id);
|
||||
|
||||
if ($result) {
|
||||
return ToolResult::text('Event deleted successfully.');
|
||||
}
|
||||
|
||||
return ToolResult::error('Failed to delete event.');
|
||||
}
|
||||
}
|
||||
62
app/Domain/Calendar/Tools/EditCalendarEventTool.php
Normal file
62
app/Domain/Calendar/Tools/EditCalendarEventTool.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
|
||||
/**
|
||||
* Edit an existing calendar event.
|
||||
*/
|
||||
class EditCalendarEventTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('Event ID to edit.')
|
||||
->required()
|
||||
->string('eventTitle')->description('Title of the event.')
|
||||
->required()
|
||||
->string('dateFrom')->description('Start date in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required()
|
||||
->string('dateTo')->description('End date in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required()
|
||||
->boolean('allDay')->description('Whether this is an all-day event.');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'editEvent';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Edits an existing calendar event.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$result = $this->calendarService->editEvent([
|
||||
'id' => (int) ($arguments['id'] ?? 0),
|
||||
'description' => $arguments['eventTitle'],
|
||||
'dateFrom' => $arguments['dateFrom'],
|
||||
'dateTo' => $arguments['dateTo'],
|
||||
'allDay' => $arguments['allDay'] ?? false,
|
||||
]);
|
||||
|
||||
if ($result) {
|
||||
return ToolResult::text('Event updated successfully.');
|
||||
}
|
||||
|
||||
return ToolResult::error('Failed to update event.');
|
||||
}
|
||||
}
|
||||
87
app/Domain/Calendar/Tools/GetCalendarTool.php
Normal file
87
app/Domain/Calendar/Tools/GetCalendarTool.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
use Leantime\Domain\Calendar\Support\CalendarEventFormatter;
|
||||
|
||||
/**
|
||||
* Get all calendar events for the current user.
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class GetCalendarTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->string('from')->description('Starting date of the date range to look for. In user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required()
|
||||
->string('until')->description('End date of the date range to look for. In user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'getCalendar';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Gets all calendar events for the current user including tasks with due dates and scheduled work times.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$from = $arguments['from'];
|
||||
$until = $arguments['until'];
|
||||
|
||||
$events = $this->calendarService->getCalendar(session('userdata.id'), $from, $until);
|
||||
|
||||
$maxEvents = 100;
|
||||
$numEvents = 0;
|
||||
|
||||
$response = "## Calendar Events (tasks, and events)\n";
|
||||
foreach ($events as $event) {
|
||||
if ($numEvents < $maxEvents) {
|
||||
$enhancedEvent = $event;
|
||||
$enhancedEvent['dateFrom'] = dtHelper()->parseDbDateTime($event['dateFrom'])->setToUserTimezone()->toIso8601String();
|
||||
$enhancedEvent['dateTo'] = dtHelper()->parseDbDateTime($event['dateTo'])->setToUserTimezone()->toIso8601String();
|
||||
|
||||
$formatter = new CalendarEventFormatter($enhancedEvent);
|
||||
$response .= $formatter->format()."\n\n";
|
||||
$numEvents++;
|
||||
}
|
||||
}
|
||||
|
||||
$externalEvents = $this->calendarService->getExternalCalendarEvents($from, $until);
|
||||
$response .= "\n## External Calendar Events (ICAL imports)\n";
|
||||
foreach ($externalEvents as $event) {
|
||||
if ($numEvents < $maxEvents) {
|
||||
$enhancedEvent = $event;
|
||||
$enhancedEvent['dateFrom'] = dtHelper()->parseDbDateTime($event['dateFrom'])->setToUserTimezone()->toIso8601String();
|
||||
$enhancedEvent['dateTo'] = dtHelper()->parseDbDateTime($event['dateTo'])->setToUserTimezone()->toIso8601String();
|
||||
|
||||
$formatter = new CalendarEventFormatter($enhancedEvent);
|
||||
$response .= $formatter->format()."\n\n";
|
||||
$numEvents++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($numEvents === 0) {
|
||||
return ToolResult::text('No calendar events found for the specified date range.');
|
||||
}
|
||||
|
||||
return ToolResult::text($response);
|
||||
}
|
||||
}
|
||||
49
app/Domain/Calendar/Tools/GetICalUrlTool.php
Normal file
49
app/Domain/Calendar/Tools/GetICalUrlTool.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
|
||||
/**
|
||||
* Get the iCal URL for the user calendar.
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class GetICalUrlTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'getICalUrl';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Gets the iCal URL for the user calendar.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
try {
|
||||
$url = $this->calendarService->getICalUrl();
|
||||
|
||||
return ToolResult::text($url);
|
||||
} catch (\Exception $e) {
|
||||
return ToolResult::error('No iCal URL available. Generate one first using generateIcalHash.');
|
||||
}
|
||||
}
|
||||
}
|
||||
288
app/Domain/Calendar/Tools/ScheduleDayTool.php
Normal file
288
app/Domain/Calendar/Tools/ScheduleDayTool.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Calendar\Services\Calendar;
|
||||
use Leantime\Domain\Tickets\Services\Tickets;
|
||||
|
||||
/**
|
||||
* Create a structured day plan with appropriate events and breaks.
|
||||
*/
|
||||
class ScheduleDayTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Calendar $calendarService,
|
||||
private Tickets $ticketService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->string('date')->description('Date to schedule in user timezone in ISO8601 format (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required()
|
||||
->raw('events', ['type' => 'array', 'description' => 'Array of events to schedule. Each should have title, duration (in minutes), and optional priority (1-20).'])->required()
|
||||
->string('workingHoursStart')->description('Start of working hours in 24-hour format (HH:MM), if none available use 09:00.')
|
||||
->string('workingHoursEnd')->description('End of working hours in 24-hour format (HH:MM), if none available use 18:00.')
|
||||
->raw('taskIds', ['type' => 'array', 'description' => 'Array of task IDs to schedule instead of creating events. If provided, these tasks will be scheduled rather than creating new events.']);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'scheduleDay';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Creates a structured day plan with events and breaks.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$date = $arguments['date'];
|
||||
$events = ($arguments['events'] ?? []);
|
||||
$workingHoursStart = ($arguments['workingHoursStart'] ?? '');
|
||||
$workingHoursEnd = ($arguments['workingHoursEnd'] ?? '');
|
||||
$taskIds = ($arguments['taskIds'] ?? []);
|
||||
|
||||
$dateObj = dtHelper()->parseUserDateTime($date);
|
||||
$dateFrom = $dateObj->startOfDay();
|
||||
$dateTo = $dateObj->endOfDay();
|
||||
|
||||
$existingEvents = $this->calendarService->getCalendar(session('userdata.id'), $dateFrom, $dateTo);
|
||||
$existingTasks = $this->ticketService->getScheduledTasks($dateFrom, $dateTo, session('userdata.id'));
|
||||
|
||||
if ($workingHoursStart !== '') {
|
||||
$timeparts = explode(':', $workingHoursStart);
|
||||
$workStart = $dateObj->setTime((int) $timeparts[0], (int) ($timeparts[1] ?? 0));
|
||||
} else {
|
||||
$workStart = $dateObj->setTime(9, 0);
|
||||
}
|
||||
|
||||
if ($workingHoursEnd !== '') {
|
||||
$timeparts = explode(':', $workingHoursEnd);
|
||||
$workEnd = $dateObj->setTime((int) $timeparts[0], (int) ($timeparts[1] ?? 0));
|
||||
} else {
|
||||
$workEnd = $dateObj->setTime(18, 0);
|
||||
}
|
||||
|
||||
$availableSlots = $this->findAvailableTimeSlots($workStart, $workEnd, $existingEvents, $existingTasks);
|
||||
|
||||
if (! empty($taskIds)) {
|
||||
$tasksToSchedule = [];
|
||||
|
||||
foreach ($taskIds as $taskId) {
|
||||
$task = $this->ticketService->getTicket($taskId);
|
||||
if ($task) {
|
||||
$tasksToSchedule[] = [
|
||||
'id' => $taskId,
|
||||
'title' => $task->headline ?? 'Untitled Task',
|
||||
'duration' => $events[array_search($taskId, array_column($events, 'taskId'))]['duration'] ?? 30,
|
||||
'priority' => $events[array_search($taskId, array_column($events, 'taskId'))]['priority'] ?? 3,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$scheduledTasks = $this->scheduleItemsInSlots($availableSlots, $tasksToSchedule);
|
||||
|
||||
$successCount = 0;
|
||||
$failureCount = 0;
|
||||
$results = [];
|
||||
|
||||
foreach ($scheduledTasks as $task) {
|
||||
$editFrom = $task['dateFrom'] instanceof CarbonImmutable ? $task['dateFrom']->toIso8601String() : (string) $task['dateFrom'];
|
||||
$editTo = $task['dateTo'] instanceof CarbonImmutable ? $task['dateTo']->toIso8601String() : (string) $task['dateTo'];
|
||||
|
||||
if ($this->ticketService->patch($task['id'], ['editFrom' => $editFrom, 'editTo' => $editTo])) {
|
||||
$successCount++;
|
||||
$results[] = ['taskId' => $task['id'], 'status' => 'success'];
|
||||
} else {
|
||||
$failureCount++;
|
||||
$results[] = ['taskId' => $task['id'], 'status' => 'error', 'message' => 'Failed to schedule task'];
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult::text("Day scheduling completed for {$date}.\n\nTask scheduling: Success: {$successCount}, Failed: {$failureCount}");
|
||||
}
|
||||
|
||||
$scheduledEvents = $this->scheduleItemsInSlots($availableSlots, $events);
|
||||
|
||||
$successCount = 0;
|
||||
$failureCount = 0;
|
||||
|
||||
foreach ($scheduledEvents as $eventData) {
|
||||
$eventDateFrom = $eventData['dateFrom'] instanceof CarbonImmutable ? $eventData['dateFrom']->toIso8601String() : (string) $eventData['dateFrom'];
|
||||
$eventDateTo = $eventData['dateTo'] instanceof CarbonImmutable ? $eventData['dateTo']->toIso8601String() : (string) $eventData['dateTo'];
|
||||
|
||||
$result = $this->calendarService->addEvent([
|
||||
'description' => $eventData['title'] ?? $eventData['eventTitle'] ?? 'Untitled',
|
||||
'dateFrom' => $eventDateFrom,
|
||||
'dateTo' => $eventDateTo,
|
||||
'allDay' => false,
|
||||
'userId' => session('userdata.id'),
|
||||
]);
|
||||
|
||||
if ($result) {
|
||||
$successCount++;
|
||||
} else {
|
||||
$failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult::text("Day scheduling completed for {$date}.\n\nEvent creation: Success: {$successCount}, Failed: {$failureCount}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find available time slots in a day.
|
||||
*
|
||||
* @param CarbonImmutable $workStart Start of working hours
|
||||
* @param CarbonImmutable $workEnd End of working hours
|
||||
* @param array $existingEvents Existing calendar events
|
||||
* @param array $existingTasks Existing scheduled tasks
|
||||
* @return array Available time slots as [start, end] pairs
|
||||
*/
|
||||
private function findAvailableTimeSlots(CarbonImmutable $workStart, CarbonImmutable $workEnd, array $existingEvents, array $existingTasks): array
|
||||
{
|
||||
$busyTimes = [];
|
||||
|
||||
foreach ($existingEvents as $event) {
|
||||
$busyTimes[] = [
|
||||
'start' => dtHelper()->parseDbDateTime($event['dateFrom']),
|
||||
'end' => dtHelper()->parseDbDateTime($event['dateTo']),
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($existingTasks['totalTasks'])) {
|
||||
foreach ($existingTasks['totalTasks'] as $task) {
|
||||
if (! empty($task['editFrom']) && ! empty($task['editTo'])) {
|
||||
$busyTimes[] = [
|
||||
'start' => dtHelper()->parseDbDateTime($task['editFrom']),
|
||||
'end' => dtHelper()->parseDbDateTime($task['editTo']),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($busyTimes, function ($a, $b) {
|
||||
return $a['start']->getTimestamp() - $b['start']->getTimestamp();
|
||||
});
|
||||
|
||||
$mergedBusyTimes = [];
|
||||
foreach ($busyTimes as $busy) {
|
||||
if (empty($mergedBusyTimes)) {
|
||||
$mergedBusyTimes[] = $busy;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastBusy = &$mergedBusyTimes[count($mergedBusyTimes) - 1];
|
||||
|
||||
if ($busy['start'] <= $lastBusy['end']) {
|
||||
if ($busy['end'] > $lastBusy['end']) {
|
||||
$lastBusy['end'] = $busy['end'];
|
||||
}
|
||||
} else {
|
||||
$mergedBusyTimes[] = $busy;
|
||||
}
|
||||
}
|
||||
|
||||
$availableSlots = [];
|
||||
$currentTime = clone $workStart;
|
||||
|
||||
foreach ($mergedBusyTimes as $busy) {
|
||||
if ($busy['end'] <= $workStart || $busy['start'] >= $workEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$busyStart = max($busy['start'], $workStart);
|
||||
$busyEnd = min($busy['end'], $workEnd);
|
||||
|
||||
if ($currentTime < $busyStart) {
|
||||
$availableSlots[] = [
|
||||
'start' => clone $currentTime,
|
||||
'end' => clone $busyStart,
|
||||
];
|
||||
}
|
||||
|
||||
$currentTime = clone $busyEnd;
|
||||
}
|
||||
|
||||
if ($currentTime < $workEnd) {
|
||||
$availableSlots[] = [
|
||||
'start' => clone $currentTime,
|
||||
'end' => clone $workEnd,
|
||||
];
|
||||
}
|
||||
|
||||
return $availableSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule items (events or tasks) in available time slots.
|
||||
*
|
||||
* @param array $availableSlots Available time slots
|
||||
* @param array $items Items to schedule
|
||||
* @return array Scheduled items with dateFrom and dateTo
|
||||
*/
|
||||
private function scheduleItemsInSlots(array $availableSlots, array $items): array
|
||||
{
|
||||
usort($items, function ($a, $b) {
|
||||
$priorityA = $a['priority'] ?? 3;
|
||||
$priorityB = $b['priority'] ?? 3;
|
||||
|
||||
if ($priorityA !== $priorityB) {
|
||||
return $priorityB - $priorityA;
|
||||
}
|
||||
|
||||
$durationA = $a['duration'] ?? 30;
|
||||
$durationB = $b['duration'] ?? 30;
|
||||
|
||||
return $durationB - $durationA;
|
||||
});
|
||||
|
||||
$scheduledItems = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$title = $item['title'] ?? $item['eventTitle'] ?? 'Untitled';
|
||||
$durationMinutes = $item['duration'] ?? 30;
|
||||
$durationSeconds = $durationMinutes * 60;
|
||||
|
||||
foreach ($availableSlots as $key => $slot) {
|
||||
$slotDuration = $slot['end']->getTimestamp() - $slot['start']->getTimestamp();
|
||||
|
||||
if ($slotDuration >= $durationSeconds) {
|
||||
$itemStart = $slot['start'];
|
||||
$itemEnd = $itemStart->modify("+{$durationMinutes} minutes");
|
||||
|
||||
$scheduledItem = [
|
||||
'title' => $title,
|
||||
'dateFrom' => $itemStart,
|
||||
'dateTo' => $itemEnd,
|
||||
];
|
||||
|
||||
if (isset($item['id'])) {
|
||||
$scheduledItem['id'] = $item['id'];
|
||||
}
|
||||
|
||||
$scheduledItems[] = $scheduledItem;
|
||||
|
||||
$availableSlots[$key]['start'] = $itemEnd;
|
||||
|
||||
if ($availableSlots[$key]['end']->getTimestamp() - $availableSlots[$key]['start']->getTimestamp() < 900) {
|
||||
unset($availableSlots[$key]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $scheduledItems;
|
||||
}
|
||||
}
|
||||
55
app/Domain/Calendar/Tools/ScheduleTaskOnCalendarTool.php
Normal file
55
app/Domain/Calendar/Tools/ScheduleTaskOnCalendarTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Calendar\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Tickets\Services\Tickets;
|
||||
|
||||
/**
|
||||
* Schedule a task on the calendar by setting editFrom and editTo fields.
|
||||
*/
|
||||
class ScheduleTaskOnCalendarTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Tickets $ticketService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('ID of the task to schedule.')
|
||||
->required()
|
||||
->string('editFrom')->description('Date time string of when the task should start in user timezone in ISO8601 format (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required()
|
||||
->string('editTo')->description('Date time string of when the task should end in user timezone in ISO8601 format (example: 2024-04-30T15:00:00-04:00).')
|
||||
->required();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'scheduleTaskOnCalendar';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Schedules a task by setting the editFrom and editTo fields.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
$editFrom = $arguments['editFrom'];
|
||||
$editTo = $arguments['editTo'];
|
||||
|
||||
if ($this->ticketService->patch($id, ['editFrom' => $editFrom, 'editTo' => $editTo])) {
|
||||
return ToolResult::text('Task scheduled successfully.');
|
||||
}
|
||||
|
||||
return ToolResult::error('Failed to schedule task.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user