Introduce ZimbraConnector in ChillZimbraBundle

- Added `ZimbraConnector` to handle synchronization with Zimbra calendars.
- Implemented creation of Zimbra invite components using `CreateZimbraComponent` and `CreateEvent`.
- Added `DateConverter` for PHP to Zimbra date conversions.
- Configured `SoapClientBuilder` for API interactions with Zimbra.
- Updated translations for Zimbra event messaging.
- Enhanced `CalendarRange` entity with `hasLocation` method.
This commit is contained in:
2025-11-25 17:01:25 +01:00
parent b2a6a2170a
commit 3c110b2f1b
11 changed files with 418 additions and 5 deletions

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ZimbraBundle\Calendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\MainBundle\Entity\User;
use Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector\CreateEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
final readonly class ZimbraConnector implements RemoteCalendarConnectorInterface
{
private const LOG_PREFIX = '[ZimbraConnector] ';
public function __construct(
private CreateEvent $createEvent,
private LoggerInterface $logger,
) {}
public function countEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate): int
{
return 0;
}
public function getMakeReadyResponse(string $returnPath): Response
{
throw new \BadMethodCallException('Zimbra connector is always ready');
}
public function isReady(): bool
{
return true;
}
public function listEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array
{
return [];
}
public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void
{
// TODO: Implement removeCalendar() method.
}
public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void
{
// TODO: Implement removeCalendarRange() method.
}
public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void
{
if (!$calendar->hasRemoteId()) {
$calItemId = ($this->createEvent)($calendar);
$this->logger->info(self::LOG_PREFIX.'Calendar synced with Zimbra', ['calendar_id' => $calendar->getId(), 'action' => $action, 'calItemId' => $calItemId]);
$calendar->setRemoteId($calItemId);
return;
}
throw new \RuntimeException('Update of calendar: to be implemented');
}
public function syncCalendarRange(CalendarRange $calendarRange): void
{
if (!$calendarRange->hasRemoteId()) {
$calItemId = ($this->createEvent)($calendarRange);
$this->logger->info(self::LOG_PREFIX.'Calendar range synced with Zimbra', ['calendar_range_id' => $calendarRange->getId(), 'calItemId' => $calItemId]);
$calendarRange->setRemoteId($calItemId);
return;
}
throw new \RuntimeException('Update of calendar: to be implemented');
}
public function syncInvite(Invite $invite): void
{
// TODO: Implement syncInvite() method.
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\ZimbraBundle\Exception\CalendarWithoutMainUserException;
use Symfony\Contracts\Translation\TranslatorInterface;
use Zimbra\Mail\Struct\InvitationInfo;
use Zimbra\Mail\Struct\MimePartInfo;
use Zimbra\Mail\Struct\Msg;
final readonly class CreateEvent
{
public function __construct(
private SoapClientBuilder $soapClientBuilder,
private CreateZimbraComponent $createEvent,
private TranslatorInterface $translator,
) {}
public function __invoke(Calendar|CalendarRange $calendar): string
{
if ($calendar instanceof Calendar) {
$organizerEmail = $calendar->getMainUser()->getEmail();
$organizerLang = $calendar->getMainUser()->getLocale();
} else {
$organizerEmail = $calendar->getUser()->getEmail();
$organizerLang = $calendar->getUser()->getLocale();
}
if (null === $organizerEmail) {
throw new CalendarWithoutMainUserException();
}
$api = $this->soapClientBuilder->getApiForAccount($organizerEmail);
$comp = $this->createEvent->createZimbraInviteComponentFromCalendar($calendar);
$inv = new InvitationInfo();
$inv->setInviteComponent($comp);
$mp = new MimePartInfo();
$mp->addMimePart(new MimePartInfo('text/plain', $this->translator->trans('zimbra.event_created_by_chill', locale: $organizerLang)));
$msg = new Msg();
$msg->setSubject($this->translator->trans('zimbra.event_created_trough_soap', locale: $organizerLang))
->setFolderId('10')
->setInvite($inv)
->setMimePart($mp);
$response = $api->createAppointment($msg, echo: true);
return $response->getCalItemId();
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Templating\Entity\AddressRender;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Zimbra\Common\Enum\FreeBusyStatus;
use Zimbra\Common\Enum\InviteClass;
use Zimbra\Common\Enum\InviteStatus;
use Zimbra\Common\Enum\Transparency;
use Zimbra\Mail\Struct\InviteComponent;
/**
* Class responsible for creating Zimbra invite components based on calendar data.
*/
final readonly class CreateZimbraComponent
{
public function __construct(
private PersonRenderInterface $personRender,
private AddressRender $addressRender,
private DateConverter $dateConverter,
private TranslatorInterface $translator,
) {}
/**
* Creates a Zimbra invite component from the provided calendar object.
*
* The method initializes a new InviteComponent object, sets its properties
* including name, free/busy status, status, classification, transparency,
* all-day and draft status, as well as start and end times. If the calendar
* contains a location, it also sets the location for the invite component.
*
* @param Calendar|CalendarRange $calendar a calendar object containing event data
*
* @return InviteComponent the configured Zimbra invite component
*/
public function createZimbraInviteComponentFromCalendar(Calendar|CalendarRange $calendar): InviteComponent
{
if ($calendar instanceof Calendar) {
$subject = '[Chill] '.
implode(
', ',
$calendar->getPersons()->map(fn (Person $p) => $this->personRender->renderString($p, []))->toArray()
);
} else {
$subject = $this->translator->trans('remote_calendar.calendar_range_title');
}
$comp = new InviteComponent();
$comp->setName($subject)
->setFreeBusy(FreeBusyStatus::BUSY)
->setStatus(InviteStatus::CONFIRMED)
->setCalClass(InviteClass::PUB)
->setTransparency(Transparency::OPAQUE)
->setIsAllDay(false)
->setIsDraft(false)
->setDtStart($this->dateConverter->phpToZimbraDateTime($calendar->getStartDate()))
->setDtEnd($this->dateConverter->phpToZimbraDateTime($calendar->getEndDate()));
if ($calendar->hasLocation()) {
$comp
->setLocation($this->createLocationString($calendar->getLocation()));
}
return $comp;
}
private function createLocationString(Location $location): string
{
$str = '';
if ('' !== ((string) $location->getName())) {
$str .= $location->getName();
$str .= ', ';
}
if ($location->hasAddress()) {
if ('' !== $str) {
$str .= ', ';
}
$str .= $this->addressRender->renderString($location->getAddress(), []);
}
return $str;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector;
use Zimbra\Mail\Struct\DtTimeInfo;
/**
* Class DateConverter.
*
* Provides methods for converting PHP DateTime objects
* into specific date-time formats or representations.
*/
final readonly class DateConverter
{
public const FORMAT_DATE_TIME = 'Ymd\THis';
/**
* Converts a PHP DateTimeInterface object into a Zimbra-specific DtTimeInfo object.
*
* @param \DateTimeInterface $date the date to be converted
*
* @return DtTimeInfo the converted DtTimeInfo object
*/
public function phpToZimbraDateTime(\DateTimeInterface $date): DtTimeInfo
{
return new DtTimeInfo($date->format(self::FORMAT_DATE_TIME), $date->getTimezone()->getName());
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Zimbra\Common\Enum\AccountBy;
use Zimbra\Common\Soap\ClientFactory;
use Zimbra\Common\Struct\Header\AccountInfo;
use Zimbra\Mail\MailApi;
final readonly class SoapClientBuilder
{
private string $username;
private string $password;
private string $url;
public function __construct(private ParameterBagInterface $parameterBag, private HttpClientInterface $client)
{
$dsn = $this->parameterBag->get('chill_calendar.remote_calendar_dsn');
$url = parse_url($dsn);
$this->username = urldecode($url['user']);
$this->password = urldecode($url['pass']);
if ('zimbra+http' === $url['scheme']) {
$scheme = 'http://';
$port = $url['port'] ?? 80;
} elseif ('zimbra+https' === $url['scheme']) {
$scheme = 'https://';
$port = $url['port'] ?? 443;
} else {
throw new \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException('Unsupported remote calendar scheme: '.$url['scheme']);
}
$this->url = $scheme.$url['host'].':'.$port;
}
private function buildApi(): MailApi
{
$baseClient = $this->client->withOptions([
'base_uri' => $location = $this->url.'/service/soap',
'verify_host' => false,
'verify_peer' => false,
]);
$psr18Client = new Psr18Client($baseClient);
$api = new MailApi();
$client = ClientFactory::create($location, $psr18Client);
$api->setClient($client);
return $api;
}
public function getApiForAccount(string $accountName): MailApi
{
$api = $this->buildApi();
$response = $api->authByAccountName($this->username, $this->password);
$token = $response->getAuthToken();
$apiBy = $this->buildApi();
$apiBy->setAuthToken($token);
$apiBy->setTargetAccount(new AccountInfo(AccountBy::NAME, $accountName));
return $apiBy;
}
}

View File

@@ -1,10 +1,16 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ZimbraBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class ChillZimbraBundle extends Bundle
{
}
class ChillZimbraBundle extends Bundle {}

View File

@@ -1,5 +1,14 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ZimbraBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
@@ -14,5 +23,4 @@ class ChillZimbraExtension extends Extension
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ZimbraBundle\Exception;
class CalendarWithoutMainUserException extends \RuntimeException {}

View File

@@ -6,3 +6,6 @@ services:
Chill\ZimbraBundle\Command\:
resource: '../Command'
tags: ['console.command']
Chill\ZimbraBundle\Calendar\:
resource: '../Calendar'

View File

@@ -0,0 +1,3 @@
zimbra:
event_created_by_chill: Événement créé par Chill
event_created_trough_soap: Événement créé via l'API SOAP

View File

@@ -107,6 +107,11 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
public function hasLocation(): bool
{
return null !== $this->location;
}
public function setLocation(?Location $location): self
{
$this->location = $location;