OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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');
}
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}
}

View 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');
}
}
}

View 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']);
}
}

View 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');
}
}

View 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);
}
}

View 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',
]);
}
}

View 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');
}
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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]{ '&#8212;'[ 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: '&laquo;',
next: '&raquo;',
prevYear: '&nbsp;&lt;&lt;&nbsp;',
nextYear: '&nbsp;&gt;&gt;&nbsp;',
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
};
})();

View 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),
];
}
}

View 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'],
]);
}
}

View 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),
];
}
}

View 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';
}
}

View 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

View 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>
&nbsp;
@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>
&nbsp;
<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')

View 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>

View 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

View 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

View 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

View 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>

View 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

View 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>

View 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

View 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

View 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.');
}
}

View 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;
}
}

View 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)
);
}
}

View 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)
);
}
}

View 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.');
}
}

View 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.');
}
}

View 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);
}
}

View 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.');
}
}
}

View 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;
}
}

View 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.');
}
}