| Current Path : /var/www/html/libraries/noboss/src/Util/ |
| Current File : /var/www/html/libraries/noboss/src/Util/NbIcsParserUtil.php |
<?php
/**
* @package No Boss Extensions
* @subpackage No Boss Library
* @author No Boss Technology <contact@nobosstechnology.com>
* @copyright Copyright (C) 2026 No Boss Technology. All rights reserved.
* @license GNU Lesser General Public License version 3 or later; see <https://www.gnu.org/licenses/lgpl-3.0.en.html>
*/
namespace Noboss\Library\Util;
use Joomla\CMS\Factory;
// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects
/**
* Parser leve e proprio de arquivos iCalendar (.ics / RFC 5545).
*
* Converte o conteudo bruto de um feed ICS em um array normalizado de eventos cujos campos
* espelham o que o EventModel::prepareData() do com_nobosscalendar espera
* (event_title, initial_date, final_date, hours, is_recurrent, recurrence_type, recurrent_days, ...).
*
* Cada VEVENT vira UM evento normalizado (preservando o UID), e a recorrencia (RRULE) eh mapeada
* para o modelo de recorrencia do No Boss Calendar:
* - WEEKLY/DAILY com INTERVAL 1 -> recurrence_type 'week-days' (recurrent_days + recurrent_days_ignore)
* - demais casos (INTERVAL>1, MONTHLY, YEARLY) -> recurrence_type 'specific-dates' (lista de datas expandida)
*/
abstract class NbIcsParserUtil
{
/**
* Horizonte (em anos) usado para expandir recorrencias sem COUNT/UNTIL.
*/
const DEFAULT_HORIZON_YEARS = 2;
/**
* Numero maximo de ocorrencias geradas por evento (trava de seguranca contra RRULE abusivo).
*/
const MAX_OCCURRENCES = 750;
/**
* De-para de dias da semana ICS (BYDAY) -> indice usado pelo No Boss Calendar (Domingo = 0 ... Sabado = 6).
*/
const WEEKDAY_MAP = [
'SU' => 0,
'MO' => 1,
'TU' => 2,
'WE' => 3,
'TH' => 4,
'FR' => 5,
'SA' => 6,
];
/**
* Faz o parse de uma string ICS para um array normalizado de eventos.
*
* @param string $ics Conteudo bruto do arquivo .ics
* @param string|null $timezone Timezone destino (default: offset do Joomla)
* @param array $options 'horizon_end' (string|\DateTime) e 'max_occurrences' (int)
*
* @return array Lista de eventos normalizados
*/
public static function parse($ics, $timezone = null, array $options = [])
{
if (!\is_string($ics) || $ics === '') {
return [];
}
$timezone = !empty($timezone) ? $timezone : self::getJoomlaTimezone();
try {
$tz = new \DateTimeZone($timezone);
} catch (\Exception $e) {
$tz = new \DateTimeZone('UTC');
}
$maxOccurrences = isset($options['max_occurrences']) ? (int) $options['max_occurrences'] : self::MAX_OCCURRENCES;
// Horizonte default para recorrencias ilimitadas
$horizonEnd = self::resolveHorizonEnd($options['horizon_end'] ?? null, $tz);
$lines = self::unfoldLines($ics);
$events = [];
$current = null;
$depthSkip = 0;
foreach ($lines as $line) {
$upper = \strtoupper(\trim($line));
// Ignora blocos aninhados que nao sejam o VEVENT (ex.: VALARM dentro do evento)
if ($depthSkip > 0) {
if (\strpos($upper, 'BEGIN:') === 0) {
$depthSkip++;
} elseif (\strpos($upper, 'END:') === 0) {
$depthSkip--;
}
continue;
}
if ($upper === 'BEGIN:VEVENT') {
$current = self::newRawEvent();
continue;
}
if ($upper === 'END:VEVENT') {
if ($current !== null) {
$event = self::buildEvent($current, $tz, $horizonEnd, $maxOccurrences);
if ($event !== null) {
$events[] = $event;
}
}
$current = null;
continue;
}
// Fora de um VEVENT nao ha nada a coletar
if ($current === null) {
continue;
}
// Inicio de um bloco aninhado (ex.: VALARM): pula tudo ate o END correspondente
if (\strpos($upper, 'BEGIN:') === 0) {
$depthSkip = 1;
continue;
}
$parsed = self::parseContentLine($line);
if ($parsed === null) {
continue;
}
self::collectProperty($current, $parsed);
}
return $events;
}
/**
* Extrai o nome do calendario a partir das propriedades de VCALENDAR.
*
* Le propriedades no nivel VCALENDAR (antes do primeiro BEGIN:VEVENT, fora de blocos aninhados).
* Prioridade: X-WR-CALNAME -> NAME (RFC 7986) -> CALNAME.
*
* @param string $ics Conteudo bruto do arquivo .ics
*
* @return string|null Nome do calendario ou null se ausente
*/
public static function extractCalendarName($ics)
{
if (!\is_string($ics) || $ics === '') {
return null;
}
$lines = self::unfoldLines($ics);
$depthSkip = 0;
$names = [
'X-WR-CALNAME' => null,
'NAME' => null,
'CALNAME' => null,
];
foreach ($lines as $line) {
$upper = \strtoupper(\trim($line));
if ($depthSkip > 0) {
if (\strpos($upper, 'BEGIN:') === 0) {
$depthSkip++;
} elseif (\strpos($upper, 'END:') === 0) {
$depthSkip--;
}
continue;
}
if ($upper === 'BEGIN:VEVENT') {
break;
}
if (\strpos($upper, 'BEGIN:') === 0) {
if ($upper === 'BEGIN:VCALENDAR') {
continue;
}
$depthSkip = 1;
continue;
}
if (\strpos($upper, 'END:') === 0) {
continue;
}
$parsed = self::parseContentLine($line);
if ($parsed === null) {
continue;
}
$name = $parsed['name'];
if (!\array_key_exists($name, $names) || $names[$name] !== null) {
continue;
}
$value = self::unescapeText($parsed['value']);
if ($value !== '') {
$names[$name] = $value;
}
}
foreach (['X-WR-CALNAME', 'NAME', 'CALNAME'] as $key) {
if ($names[$key] !== null) {
return $names[$key];
}
}
return null;
}
/**
* Estrutura bruta acumulada para um VEVENT em parsing.
*
* @return array
*/
protected static function newRawEvent()
{
return [
'uid' => '',
'summary' => '',
'description' => '',
'description_html' => '',
'location' => '',
'dtstart' => null,
'dtend' => null,
'duration' => '',
'rrule' => '',
'exdate' => [],
'attachments' => [],
];
}
/**
* Acumula uma propriedade do VEVENT na estrutura bruta.
*
* @param array $event Estrutura bruta (por referencia)
* @param array $parsed ['name' => , 'params' => [], 'value' => ]
*
* @return void
*/
protected static function collectProperty(array &$event, array $parsed)
{
$name = $parsed['name'];
$params = $parsed['params'];
$value = $parsed['value'];
switch ($name) {
case 'UID':
$event['uid'] = \trim($value);
break;
case 'SUMMARY':
$event['summary'] = self::unescapeText($value);
break;
case 'DESCRIPTION':
$event['description'] = self::unescapeText($value);
break;
case 'X-ALT-DESC':
$event['description_html'] = self::parseAltDescription($value, $params);
break;
case 'LOCATION':
$event['location'] = self::unescapeText($value);
break;
case 'DTSTART':
$event['dtstart'] = ['value' => $value, 'params' => $params];
break;
case 'DTEND':
$event['dtend'] = ['value' => $value, 'params' => $params];
break;
case 'DURATION':
$event['duration'] = \trim($value);
break;
case 'RRULE':
$event['rrule'] = \trim($value);
break;
case 'EXDATE':
// EXDATE pode trazer varias datas separadas por virgula
foreach (\explode(',', $value) as $part) {
$part = \trim($part);
if ($part !== '') {
$event['exdate'][] = ['value' => $part, 'params' => $params];
}
}
break;
case 'ATTACH':
$valueType = isset($params['VALUE']) ? \strtoupper($params['VALUE']) : 'URI';
if ($valueType === 'BINARY' || isset($params['ENCODING'])) {
break;
}
$url = self::unescapeText($value);
if (!self::isValidAttachmentUrl($url)) {
break;
}
$event['attachments'][] = [
'value' => $url,
'params' => $params,
];
break;
}
}
/**
* Constroi o evento normalizado a partir da estrutura bruta.
*
* @param array $raw Estrutura bruta do VEVENT
* @param \DateTimeZone $tz Timezone destino
* @param \DateTime $horizonEnd Limite superior para expansao de recorrencias
* @param int $maxOccurrences Trava de seguranca
*
* @return array|null Evento normalizado ou null se invalido
*/
protected static function buildEvent(array $raw, \DateTimeZone $tz, \DateTime $horizonEnd, $maxOccurrences)
{
// Sem data inicial nao ha evento valido
if (empty($raw['dtstart'])) {
return null;
}
$start = self::parseDateValue($raw['dtstart']['value'], $raw['dtstart']['params'], $tz);
if ($start === null) {
return null;
}
$end = self::resolveEnd($raw, $start, $tz);
$event = [
'uid' => $raw['uid'],
'event_title' => $raw['summary'],
'description' => $raw['description'],
'description_html' => $raw['description_html'],
'location' => $raw['location'],
'all_day' => $start['all_day'],
'initial_date' => $start['date'],
'final_date' => $end['date'],
'hours' => [],
'is_recurrent' => '0',
'recurrence_type' => '',
'recurrent_days' => [],
'recurrent_days_ignore' => [],
'specific_dates' => [],
'attachments' => $raw['attachments'],
];
// Horarios (apenas para eventos com hora definida)
if (!$start['all_day'] && $start['time'] !== null) {
$event['hours'][] = [
'initial' => $start['time'],
'final' => $end['time'] !== null ? $end['time'] : '',
];
}
// Datas a ignorar (EXDATE) normalizadas no timezone destino
$exdates = [];
foreach ($raw['exdate'] as $ex) {
$parsedEx = self::parseDateValue($ex['value'], $ex['params'], $tz);
if ($parsedEx !== null) {
$exdates[$parsedEx['date']] = true;
}
}
// Sem recorrencia: evento simples
if (empty($raw['rrule'])) {
$event['recurrent_days_ignore'] = \array_keys($exdates);
return $event;
}
$rrule = self::parseRRule($raw['rrule'], $tz);
return self::applyRecurrence($event, $start, $rrule, $exdates, $horizonEnd, $maxOccurrences);
}
/**
* Aplica a recorrencia (RRULE) ao evento, mapeando para o modelo do No Boss Calendar.
*
* @param array $event Evento normalizado (base)
* @param array $start Data inicial parseada
* @param array $rrule Regra parseada
* @param array $exdates Mapa de datas a excluir ('Y-m-d' => true)
* @param \DateTime $horizonEnd Limite superior
* @param int $maxOccurrences Trava de seguranca
*
* @return array
*/
protected static function applyRecurrence(array $event, array $start, array $rrule, array $exdates, \DateTime $horizonEnd, $maxOccurrences)
{
$freq = $rrule['freq'];
$interval = $rrule['interval'];
$byday = $rrule['byday'];
$dtStart = clone $start['dt'];
$dtStart->setTime(0, 0, 0);
// Mapeamento direto para 'week-days': DAILY/WEEKLY com INTERVAL 1 e BYDAY simples (sem ordinais)
$simpleByDay = self::byDayHasOrdinal($byday) === false;
if (\in_array($freq, ['WEEKLY', 'DAILY'], true) && $interval === 1 && $simpleByDay) {
$event['is_recurrent'] = '1';
$event['recurrence_type'] = 'week-days';
// Dias da semana
$days = self::resolveWeekDays($freq, $byday, $dtStart);
$event['recurrent_days'] = $days;
// Data inicial = DTSTART; data final = UNTIL/COUNT/horizonte
$event['initial_date'] = $dtStart->format('Y-m-d');
$event['final_date'] = self::resolveWeekDaysFinalDate($rrule, $dtStart, $byday, $freq, $horizonEnd, $maxOccurrences);
// EXDATE -> dias a ignorar
$event['recurrent_days_ignore'] = \array_keys($exdates);
return $event;
}
// Demais casos: expande as ocorrencias em datas especificas
$dates = self::expandOccurrences($freq, $interval, $byday, $rrule, $dtStart, $horizonEnd, $maxOccurrences);
// Remove as datas excluidas (EXDATE)
$dates = \array_values(\array_filter($dates, static function ($date) use ($exdates) {
return !isset($exdates[$date]);
}));
// Sem ocorrencias validas: degrada para evento simples (mantem initial/final originais)
if (empty($dates)) {
return $event;
}
$event['is_recurrent'] = '1';
$event['recurrence_type'] = 'specific-dates';
$event['specific_dates'] = $dates;
$event['initial_date'] = $dates[0];
$event['final_date'] = $dates[\count($dates) - 1];
return $event;
}
/**
* Resolve os dias da semana (recurrent_days) para o tipo 'week-days'.
*
* @param string $freq FREQ (DAILY|WEEKLY)
* @param array $byday Lista de tokens BYDAY ja parseados
* @param \DateTime $dtStart Data inicial
*
* @return array Mapa indiceDia => 1
*/
protected static function resolveWeekDays($freq, array $byday, \DateTime $dtStart)
{
$days = [];
if (!empty($byday)) {
foreach ($byday as $token) {
if (isset(self::WEEKDAY_MAP[$token['day']])) {
$days[self::WEEKDAY_MAP[$token['day']]] = 1;
}
}
} elseif ($freq === 'DAILY') {
// DAILY sem BYDAY = todos os dias da semana
for ($i = 0; $i < 7; $i++) {
$days[$i] = 1;
}
} else {
// WEEKLY sem BYDAY = dia da semana do DTSTART
$days[(int) $dtStart->format('w')] = 1;
}
\ksort($days);
return $days;
}
/**
* Determina a data final para recorrencia 'week-days'.
*
* @param array $rrule Regra parseada
* @param \DateTime $dtStart Data inicial
* @param array $byday Tokens BYDAY
* @param string $freq FREQ
* @param \DateTime $horizonEnd Limite superior
* @param int $maxOccurrences Trava de seguranca
*
* @return string Data final 'Y-m-d'
*/
protected static function resolveWeekDaysFinalDate(array $rrule, \DateTime $dtStart, array $byday, $freq, \DateTime $horizonEnd, $maxOccurrences)
{
// UNTIL definido: usa diretamente
if ($rrule['until'] instanceof \DateTime) {
$until = clone $rrule['until'];
// Nao ultrapassa o horizonte de seguranca
return ($until < $horizonEnd ? $until : $horizonEnd)->format('Y-m-d');
}
// COUNT definido: expande para descobrir a data da ultima ocorrencia
if ($rrule['count'] !== null) {
$dates = self::expandOccurrences($freq, 1, $byday, $rrule, $dtStart, $horizonEnd, $maxOccurrences);
if (!empty($dates)) {
return $dates[\count($dates) - 1];
}
}
// Sem limite: usa o horizonte default
return $horizonEnd->format('Y-m-d');
}
/**
* Expande as ocorrencias de uma RRULE em uma lista de datas 'Y-m-d'.
*
* @param string $freq FREQ
* @param int $interval INTERVAL
* @param array $byday Tokens BYDAY
* @param array $rrule Regra completa (count/until)
* @param \DateTime $dtStart Data inicial
* @param \DateTime $horizonEnd Limite superior
* @param int $maxOccurrences Trava de seguranca
*
* @return array Lista ordenada e unica de datas 'Y-m-d'
*/
protected static function expandOccurrences($freq, $interval, array $byday, array $rrule, \DateTime $dtStart, \DateTime $horizonEnd, $maxOccurrences)
{
$interval = \max(1, (int) $interval);
$count = $rrule['count'];
$until = $rrule['until'] instanceof \DateTime ? clone $rrule['until'] : null;
// Limite efetivo = menor entre UNTIL e o horizonte
$limit = clone $horizonEnd;
if ($until !== null && $until < $limit) {
$limit = $until;
}
$limit->setTime(0, 0, 0);
$startDate = clone $dtStart;
$startDate->setTime(0, 0, 0);
$generated = 0;
$dates = [];
switch ($freq) {
case 'DAILY':
$cursor = clone $startDate;
while ($cursor <= $limit && $generated < $maxOccurrences) {
if (empty($byday) || self::weekdayMatchesByDay($cursor, $byday)) {
$generated++;
if ($count !== null && $generated > $count) {
break 2;
}
$dates[$cursor->format('Y-m-d')] = true;
}
$cursor->modify('+' . $interval . ' days');
}
break;
case 'WEEKLY':
// Inicia na segunda-feira (WKST padrao) da semana do DTSTART
$weekStart = clone $startDate;
$iso = (int) $weekStart->format('N'); // 1 (Seg) .. 7 (Dom)
$weekStart->modify('-' . ($iso - 1) . ' days');
// Sem BYDAY: usa o dia da semana do DTSTART
$targets = !empty($byday) ? $byday : [['ordinal' => 0, 'day' => self::isoToIcsDay($iso)]];
while ($weekStart <= $limit && $generated < $maxOccurrences) {
foreach ($targets as $token) {
$offset = self::icsDayToIsoOffset($token['day']);
if ($offset === null) {
continue;
}
$candidate = clone $weekStart;
$candidate->modify('+' . $offset . ' days');
if ($candidate < $startDate || $candidate > $limit) {
continue;
}
$generated++;
if ($count !== null && $generated > $count) {
break 3;
}
$dates[$candidate->format('Y-m-d')] = true;
}
$weekStart->modify('+' . ($interval * 7) . ' days');
}
break;
case 'MONTHLY':
$monthCursor = clone $startDate;
$monthCursor->modify('first day of this month');
$monthCursor->setTime(0, 0, 0);
while ($monthCursor <= $limit && $generated < $maxOccurrences) {
$candidates = self::candidatesForMonth($monthCursor, $byday, $startDate);
foreach ($candidates as $candidate) {
if ($candidate < $startDate || $candidate > $limit) {
continue;
}
$generated++;
if ($count !== null && $generated > $count) {
break 3;
}
$dates[$candidate->format('Y-m-d')] = true;
}
$monthCursor->modify('first day of +' . $interval . ' month');
}
break;
case 'YEARLY':
$yearCursor = clone $startDate;
while ($yearCursor <= $limit && $generated < $maxOccurrences) {
$candidates = self::candidatesForYear($yearCursor, $byday, $startDate);
foreach ($candidates as $candidate) {
if ($candidate < $startDate || $candidate > $limit) {
continue;
}
$generated++;
if ($count !== null && $generated > $count) {
break 3;
}
$dates[$candidate->format('Y-m-d')] = true;
}
$yearCursor->modify('+' . $interval . ' year');
}
break;
}
$dates = \array_keys($dates);
\sort($dates);
return $dates;
}
/**
* Gera as datas candidatas dentro de um mes (MONTHLY).
*
* @param \DateTime $monthStart Primeiro dia do mes
* @param array $byday Tokens BYDAY
* @param \DateTime $dtStart Data inicial (para day-of-month quando sem BYDAY)
*
* @return \DateTime[]
*/
protected static function candidatesForMonth(\DateTime $monthStart, array $byday, \DateTime $dtStart)
{
$year = (int) $monthStart->format('Y');
$month = (int) $monthStart->format('n');
if (empty($byday)) {
// Mesmo dia do mes do DTSTART (ignora meses sem esse dia, ex.: 31 em fevereiro)
$day = (int) $dtStart->format('j');
if ($day > (int) $monthStart->format('t')) {
return [];
}
return [self::makeDate($year, $month, $day)];
}
return self::weekdayCandidates($byday, $year, $month);
}
/**
* Gera as datas candidatas dentro de um ano (YEARLY).
*
* @param \DateTime $yearStart Referencia do ano
* @param array $byday Tokens BYDAY
* @param \DateTime $dtStart Data inicial
*
* @return \DateTime[]
*/
protected static function candidatesForYear(\DateTime $yearStart, array $byday, \DateTime $dtStart)
{
$year = (int) $yearStart->format('Y');
$month = (int) $dtStart->format('n');
if (empty($byday)) {
$day = (int) $dtStart->format('j');
$maxDay = (int) self::makeDate($year, $month, 1)->format('t');
if ($day > $maxDay) {
return [];
}
return [self::makeDate($year, $month, $day)];
}
return self::weekdayCandidates($byday, $year, $month);
}
/**
* Calcula as datas de um mes/ano a partir de tokens BYDAY (com ou sem ordinal).
*
* @param array $byday Tokens BYDAY
* @param int $year Ano
* @param int $month Mes (1-12)
*
* @return \DateTime[]
*/
protected static function weekdayCandidates(array $byday, $year, $month)
{
$result = [];
$daysInMonth = (int) self::makeDate($year, $month, 1)->format('t');
foreach ($byday as $token) {
if (!isset(self::WEEKDAY_MAP[$token['day']])) {
continue;
}
$targetW = self::WEEKDAY_MAP[$token['day']]; // 0 (Dom) .. 6 (Sab)
$matches = [];
for ($d = 1; $d <= $daysInMonth; $d++) {
$date = self::makeDate($year, $month, $d);
if ((int) $date->format('w') === $targetW) {
$matches[] = $date;
}
}
$ordinal = (int) $token['ordinal'];
if ($ordinal === 0) {
// Todas as ocorrencias do dia da semana no mes
foreach ($matches as $m) {
$result[$m->format('Y-m-d')] = $m;
}
} elseif ($ordinal > 0 && isset($matches[$ordinal - 1])) {
$m = $matches[$ordinal - 1];
$result[$m->format('Y-m-d')] = $m;
} elseif ($ordinal < 0) {
$index = \count($matches) + $ordinal;
if (isset($matches[$index])) {
$m = $matches[$index];
$result[$m->format('Y-m-d')] = $m;
}
}
}
\ksort($result);
return \array_values($result);
}
/**
* Resolve a data/hora final do evento a partir de DTEND ou DURATION.
*
* @param array $raw Estrutura bruta
* @param array $start Data inicial parseada
* @param \DateTimeZone $tz Timezone destino
*
* @return array ['date' => 'Y-m-d', 'time' => 'HH:MM'|null, 'dt' => \DateTime, 'all_day' => bool]
*/
protected static function resolveEnd(array $raw, array $start, \DateTimeZone $tz)
{
// DTEND explicito
if (!empty($raw['dtend'])) {
$end = self::parseDateValue($raw['dtend']['value'], $raw['dtend']['params'], $tz);
if ($end !== null) {
// Eventos all-day: DTEND eh exclusivo (dia seguinte ao ultimo) -> subtrai 1 dia
if ($end['all_day']) {
$dt = clone $end['dt'];
$dt->modify('-1 day');
// Garante coerencia: nunca antes da data inicial
if ($dt < $start['dt']) {
$dt = clone $start['dt'];
}
return [
'date' => $dt->format('Y-m-d'),
'time' => null,
'dt' => $dt,
'all_day' => true,
];
}
return $end;
}
}
// DURATION (calcula fim a partir do inicio)
if (!empty($raw['duration'])) {
$dt = self::applyDuration($start['dt'], $raw['duration']);
if ($dt !== null) {
if ($start['all_day']) {
// Duracao em eventos all-day: fim eh exclusivo -> subtrai 1 dia
$dt->modify('-1 day');
if ($dt < $start['dt']) {
$dt = clone $start['dt'];
}
return [
'date' => $dt->format('Y-m-d'),
'time' => null,
'dt' => $dt,
'all_day' => true,
];
}
return [
'date' => $dt->format('Y-m-d'),
'time' => $dt->format('H:i'),
'dt' => $dt,
'all_day' => false,
];
}
}
// Sem DTEND/DURATION: fim igual ao inicio
return [
'date' => $start['date'],
'time' => $start['time'],
'dt' => clone $start['dt'],
'all_day' => $start['all_day'],
];
}
/**
* Aplica uma DURATION ISO-8601 (ex.: PT1H30M, P2D) a uma data.
*
* @param \DateTime $dt Data base
* @param string $duration String de duracao
*
* @return \DateTime|null
*/
protected static function applyDuration(\DateTime $dt, $duration)
{
$negative = \strpos($duration, '-') === 0;
$duration = \ltrim($duration, '+-');
try {
$interval = new \DateInterval($duration);
} catch (\Exception $e) {
return null;
}
$result = clone $dt;
if ($negative) {
$interval->invert = 1;
}
$result->add($interval);
return $result;
}
/**
* Faz o parse de um valor de data/hora ICS aplicando timezone.
*
* @param string $value Valor (ex.: 20260615, 20260615T140000Z, 20260615T140000)
* @param array $params Parametros da propriedade (VALUE, TZID)
* @param \DateTimeZone $tz Timezone destino
*
* @return array|null ['all_day' => bool, 'date' => 'Y-m-d', 'time' => 'HH:MM'|null, 'dt' => \DateTime]
*/
protected static function parseDateValue($value, array $params, \DateTimeZone $tz)
{
$value = \trim($value);
if ($value === '') {
return null;
}
$isAllDay = (isset($params['VALUE']) && \strtoupper($params['VALUE']) === 'DATE')
|| \preg_match('/^\d{8}$/', $value) === 1;
// Data sem horario (all-day)
if ($isAllDay) {
if (\preg_match('/^(\d{4})(\d{2})(\d{2})/', $value, $m) !== 1) {
return null;
}
try {
$dt = new \DateTime($m[1] . '-' . $m[2] . '-' . $m[3] . ' 00:00:00', $tz);
} catch (\Exception $e) {
return null;
}
return [
'all_day' => true,
'date' => $dt->format('Y-m-d'),
'time' => null,
'dt' => $dt,
];
}
// Data com horario
if (\preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z)?$/', $value, $m) !== 1) {
return null;
}
$dateStr = $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . (isset($m[6]) && $m[6] !== '' ? $m[6] : '00');
$isUtc = isset($m[7]) && $m[7] === 'Z';
// Timezone de origem: Z (UTC), TZID informado, ou flutuante (assume destino)
if ($isUtc) {
$sourceTz = new \DateTimeZone('UTC');
} elseif (!empty($params['TZID'])) {
try {
$sourceTz = new \DateTimeZone($params['TZID']);
} catch (\Exception $e) {
$sourceTz = $tz;
}
} else {
$sourceTz = $tz;
}
try {
$dt = new \DateTime($dateStr, $sourceTz);
} catch (\Exception $e) {
return null;
}
// Converte para o timezone destino
$dt->setTimezone($tz);
return [
'all_day' => false,
'date' => $dt->format('Y-m-d'),
'time' => $dt->format('H:i'),
'dt' => $dt,
];
}
/**
* Faz o parse de uma RRULE.
*
* @param string $rrule Valor bruto da RRULE
* @param \DateTimeZone $tz Timezone destino (para UNTIL)
*
* @return array ['freq' =>, 'interval' =>, 'byday' => [['ordinal' =>, 'day' =>]], 'count' =>, 'until' =>]
*/
protected static function parseRRule($rrule, \DateTimeZone $tz)
{
$result = [
'freq' => '',
'interval' => 1,
'byday' => [],
'count' => null,
'until' => null,
];
foreach (\explode(';', $rrule) as $part) {
$part = \trim($part);
if ($part === '' || \strpos($part, '=') === false) {
continue;
}
[$key, $val] = \explode('=', $part, 2);
$key = \strtoupper(\trim($key));
$val = \trim($val);
switch ($key) {
case 'FREQ':
$result['freq'] = \strtoupper($val);
break;
case 'INTERVAL':
$result['interval'] = \max(1, (int) $val);
break;
case 'COUNT':
$result['count'] = \max(0, (int) $val);
break;
case 'UNTIL':
$parsed = self::parseDateValue($val, [], $tz);
if ($parsed !== null) {
$until = clone $parsed['dt'];
$until->setTime(0, 0, 0);
$result['until'] = $until;
}
break;
case 'BYDAY':
foreach (\explode(',', $val) as $token) {
$token = \strtoupper(\trim($token));
if (\preg_match('/^([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/', $token, $m) === 1) {
$result['byday'][] = [
'ordinal' => $m[1] !== '' ? (int) $m[1] : 0,
'day' => $m[2],
];
}
}
break;
}
}
return $result;
}
/**
* Verifica se algum token BYDAY possui ordinal (ex.: 2MO, -1FR).
*
* @param array $byday Tokens BYDAY
*
* @return bool
*/
protected static function byDayHasOrdinal(array $byday)
{
foreach ($byday as $token) {
if ((int) $token['ordinal'] !== 0) {
return true;
}
}
return false;
}
/**
* Verifica se o dia da semana de uma data esta entre os tokens BYDAY.
*
* @param \DateTime $date Data
* @param array $byday Tokens BYDAY
*
* @return bool
*/
protected static function weekdayMatchesByDay(\DateTime $date, array $byday)
{
$w = (int) $date->format('w');
foreach ($byday as $token) {
if (isset(self::WEEKDAY_MAP[$token['day']]) && self::WEEKDAY_MAP[$token['day']] === $w) {
return true;
}
}
return false;
}
/**
* Converte um indice ISO (1=Seg..7=Dom) para a sigla ICS (MO..SU).
*
* @param int $iso
*
* @return string
*/
protected static function isoToIcsDay($iso)
{
$map = [1 => 'MO', 2 => 'TU', 3 => 'WE', 4 => 'TH', 5 => 'FR', 6 => 'SA', 7 => 'SU'];
return $map[$iso] ?? 'MO';
}
/**
* Retorna o offset (em dias) de uma sigla ICS a partir da segunda-feira (WKST padrao).
*
* @param string $day Sigla ICS (MO..SU)
*
* @return int|null
*/
protected static function icsDayToIsoOffset($day)
{
$map = ['MO' => 0, 'TU' => 1, 'WE' => 2, 'TH' => 3, 'FR' => 4, 'SA' => 5, 'SU' => 6];
return $map[$day] ?? null;
}
/**
* Cria um \DateTime a partir de ano/mes/dia (meia-noite, timezone default do PHP).
*
* @param int $year
* @param int $month
* @param int $day
*
* @return \DateTime
*/
protected static function makeDate($year, $month, $day)
{
$dt = new \DateTime();
$dt->setDate((int) $year, (int) $month, (int) $day);
$dt->setTime(0, 0, 0);
return $dt;
}
/**
* Resolve o horizonte superior para expansao de recorrencias.
*
* @param mixed $value String, \DateTime ou null
* @param \DateTimeZone $tz Timezone destino
*
* @return \DateTime
*/
protected static function resolveHorizonEnd($value, \DateTimeZone $tz)
{
if ($value instanceof \DateTime) {
$dt = clone $value;
$dt->setTime(0, 0, 0);
return $dt;
}
if (\is_string($value) && $value !== '') {
try {
$dt = new \DateTime($value, $tz);
$dt->setTime(0, 0, 0);
return $dt;
} catch (\Exception $e) {
// cai no default abaixo
}
}
$dt = new \DateTime('now', $tz);
$dt->modify('+' . self::DEFAULT_HORIZON_YEARS . ' year');
$dt->setTime(0, 0, 0);
return $dt;
}
/**
* Faz o unfolding das linhas do ICS (RFC 5545): linhas iniciadas por espaco/tab sao continuacoes.
*
* @param string $ics Conteudo bruto
*
* @return array Linhas logicas (ja desdobradas)
*/
protected static function unfoldLines($ics)
{
// Remove BOM eventual
$ics = \preg_replace('/^\xEF\xBB\xBF/', '', $ics);
// Normaliza quebras de linha
$ics = \str_replace(["\r\n", "\r"], "\n", $ics);
$rawLines = \explode("\n", $ics);
$lines = [];
foreach ($rawLines as $line) {
// Continuacao: comeca com espaco ou tab -> concatena a linha anterior
if ($line !== '' && ($line[0] === ' ' || $line[0] === "\t")) {
if (!empty($lines)) {
$lines[\count($lines) - 1] .= \substr($line, 1);
}
continue;
}
$lines[] = $line;
}
return $lines;
}
/**
* Faz o parse de uma linha de conteudo ICS em nome, parametros e valor.
*
* @param string $line Linha logica
*
* @return array|null ['name' =>, 'params' => [], 'value' =>]
*/
protected static function parseContentLine($line)
{
if (\trim($line) === '') {
return null;
}
// Localiza o primeiro ':' fora de aspas duplas (separa name+params do value)
$colonPos = self::findValueColon($line);
if ($colonPos === null) {
return null;
}
$namePart = \substr($line, 0, $colonPos);
$value = \substr($line, $colonPos + 1);
// Separa nome e parametros (separados por ';')
$segments = self::splitParams($namePart);
$name = \strtoupper(\trim(\array_shift($segments)));
$params = [];
foreach ($segments as $segment) {
if (\strpos($segment, '=') === false) {
continue;
}
[$pKey, $pVal] = \explode('=', $segment, 2);
$pKey = \strtoupper(\trim($pKey));
$pVal = \trim($pVal);
// Remove aspas dos valores de parametro
if (\strlen($pVal) >= 2 && $pVal[0] === '"' && \substr($pVal, -1) === '"') {
$pVal = \substr($pVal, 1, -1);
}
$params[$pKey] = $pVal;
}
return [
'name' => $name,
'params' => $params,
'value' => $value,
];
}
/**
* Encontra a posicao do ':' que separa a parte de nome/params do valor (ignora ':' dentro de aspas).
*
* @param string $line Linha logica
*
* @return int|null
*/
protected static function findValueColon($line)
{
$inQuotes = false;
$len = \strlen($line);
for ($i = 0; $i < $len; $i++) {
$char = $line[$i];
if ($char === '"') {
$inQuotes = !$inQuotes;
} elseif ($char === ':' && !$inQuotes) {
return $i;
}
}
return null;
}
/**
* Divide a parte de nome/parametros por ';' ignorando separadores dentro de aspas.
*
* @param string $namePart
*
* @return array
*/
protected static function splitParams($namePart)
{
$segments = [];
$buffer = '';
$inQuotes = false;
$len = \strlen($namePart);
for ($i = 0; $i < $len; $i++) {
$char = $namePart[$i];
if ($char === '"') {
$inQuotes = !$inQuotes;
$buffer .= $char;
} elseif ($char === ';' && !$inQuotes) {
$segments[] = $buffer;
$buffer = '';
} else {
$buffer .= $char;
}
}
$segments[] = $buffer;
return $segments;
}
/**
* Extrai HTML alternativo de X-ALT-DESC (FMTTYPE=text/html).
*
* @param string $value Valor bruto da propriedade.
* @param array $params Parametros da propriedade (FMTTYPE, ENCODING, etc.).
*
* @return string
*/
protected static function parseAltDescription($value, array $params)
{
$fmtType = isset($params['FMTTYPE']) ? \strtolower(\trim((string) $params['FMTTYPE'])) : '';
if ($fmtType !== 'text/html') {
return '';
}
$encoding = isset($params['ENCODING']) ? \strtoupper(\trim((string) $params['ENCODING'])) : '';
$raw = \trim((string) $value);
if ($raw === '') {
return '';
}
if ($encoding === 'BASE64') {
$decoded = \base64_decode($raw, true);
if ($decoded === false) {
return '';
}
$raw = $decoded;
}
return self::unescapeText($raw);
}
/**
* Decodifica os escapes de texto do RFC 5545 (\n, \,, \;, \\).
*
* @param string $value
*
* @return string
*/
protected static function unescapeText($value)
{
$replacements = [
'\\n' => "\n",
'\\N' => "\n",
'\\,' => ',',
'\\;' => ';',
'\\\\' => '\\',
];
return \strtr(\trim($value), $replacements);
}
/**
* Converte anexos brutos do VEVENT (ATTACH) para o JSON usado na coluna attachments.
*
* @param array $rawAttachments Lista [['value' => url, 'params' => []], ...]
*
* @return array Objetos com fileUrl, title, mimeType e iconLink (paridade Google)
*/
public static function formatAttachmentsForStorage(array $rawAttachments)
{
$formatted = [];
foreach ($rawAttachments as $item) {
$url = isset($item['value']) ? \trim((string) $item['value']) : '';
$params = isset($item['params']) && \is_array($item['params']) ? $item['params'] : [];
if (!self::isValidAttachmentUrl($url)) {
continue;
}
$mimeType = isset($params['FMTTYPE']) ? \trim((string) $params['FMTTYPE']) : '';
$title = self::resolveAttachmentTitle($params, $url);
$attachment = [
'fileUrl' => $url,
'title' => $title,
'iconLink' => self::buildAttachmentIconLink($mimeType),
];
if ($mimeType !== '') {
$attachment['mimeType'] = $mimeType;
}
$formatted[] = $attachment;
}
return $formatted;
}
/**
* Valida URL de anexo ICS (somente http/https).
*
* @param string $url
*
* @return bool
*/
protected static function isValidAttachmentUrl($url)
{
if ($url === '' || \filter_var($url, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = \parse_url($url, PHP_URL_SCHEME);
return \in_array(\strtolower((string) $scheme), ['http', 'https'], true);
}
/**
* Resolve titulo do anexo a partir de FILENAME ou basename da URL.
*
* @param array $params Parametros do ATTACH
* @param string $url URL do anexo
*
* @return string
*/
protected static function resolveAttachmentTitle(array $params, $url)
{
if (!empty($params['FILENAME'])) {
return \trim((string) $params['FILENAME']);
}
$path = (string) \parse_url($url, PHP_URL_PATH);
$base = \basename($path);
if ($base !== '' && $base !== '/') {
return \urldecode($base);
}
return 'Anexo';
}
/**
* Gera iconLink (data URI SVG) conforme mimeType do anexo.
*
* @param string $mimeType
*
* @return string
*/
protected static function buildAttachmentIconLink($mimeType = '')
{
$mimeType = \strtolower(\trim((string) $mimeType));
if (\strpos($mimeType, 'pdf') !== false) {
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="2" fill="#dc3545"/><text x="8" y="11" text-anchor="middle" fill="#fff" font-size="6" font-family="sans-serif">PDF</text></svg>';
} elseif (\strpos($mimeType, 'image/') === 0) {
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="2" fill="#0d6efd"/><circle cx="5.5" cy="5.5" r="1.5" fill="#fff"/><path d="M2 12l3.5-3.5 2 2L11 5.5 14 12H2z" fill="#fff"/></svg>';
} else {
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="2" fill="#6c757d"/><path d="M5 4h4l2 2v6H5V4z" fill="#fff"/><path d="M9 4v2h2" fill="none" stroke="#6c757d" stroke-width="1"/></svg>';
}
return 'data:image/svg+xml;base64,' . \base64_encode($svg);
}
/**
* Obtem o timezone configurado no Joomla (com fallback).
*
* @return string
*/
protected static function getJoomlaTimezone()
{
try {
$config = Factory::getApplication()->getConfig();
return $config->get('offset', 'America/Sao_Paulo');
} catch (\Throwable $e) {
return 'America/Sao_Paulo';
}
}
}