Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
'OCA\\DAV\\CalDAV\\Import\\ImportService' => $baseDir . '/../lib/CalDAV/Import/ImportService.php',
'OCA\\DAV\\CalDAV\\Import\\TextImporter' => $baseDir . '/../lib/CalDAV/Import/TextImporter.php',
'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => $baseDir . '/../lib/CalDAV/Import/XmlImporter.php',
'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => $baseDir . '/../lib/CalDAV/Integration/ExternalCalendar.php',
'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => $baseDir . '/../lib/CalDAV/Integration/ICalendarProvider.php',
'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => $baseDir . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php',
Expand Down
3 changes: 3 additions & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
'OCA\\DAV\\CalDAV\\Import\\ImportService' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportService.php',
'OCA\\DAV\\CalDAV\\Import\\TextImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/TextImporter.php',
'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/XmlImporter.php',
'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ExternalCalendar.php',
'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ICalendarProvider.php',
'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => __DIR__ . '/..' . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php',
Expand Down
11 changes: 3 additions & 8 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -982,9 +982,9 @@ public function restoreCalendar(int $id): void {
* @param int $calendarType
* @return array
*/
public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array {
public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, array $fields = []):array {
$query = $this->db->getQueryBuilder();
$query->select(['id','uid', 'etag', 'uri', 'calendardata'])
$query->select($fields ?: ['id', 'uid', 'etag', 'uri', 'calendardata'])
->from('calendarobjects')
->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
Expand All @@ -993,12 +993,7 @@ public function getLimitedCalendarObjects(int $calendarId, int $calendarType = s

$result = [];
while (($row = $stmt->fetch()) !== false) {
$result[$row['uid']] = [
'id' => $row['id'],
'etag' => $row['etag'],
'uri' => $row['uri'],
'calendardata' => $row['calendardata'],
];
$result[$row['uid']] = $row;
}
$stmt->closeCursor();

Expand Down
186 changes: 186 additions & 0 deletions apps/dav/lib/CalDAV/Import/ImportService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Import;

use Generator;
use InvalidArgumentException;
use OCA\DAV\CalDAV\CalDavBackend;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;

/**
* Calendar Import Service
*/
class ImportService {

public function __construct(
private CalDavBackend $backend,
) {
}

/**
* Generates object stream from a text formatted source (ical)
*
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
*/
public function importText($source): Generator {
if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
$importer = new TextImporter($source);
$structure = $importer->structure();
$sObjectPrefix = $importer::OBJECT_PREFIX;
$sObjectSuffix = $importer::OBJECT_SUFFIX;
// calendar properties
foreach ($structure['VCALENDAR'] as $entry) {
if (!str_ends_with($entry, "\n") || !str_ends_with($entry, "\r\n")) {
$sObjectPrefix .= PHP_EOL;
}
}
// calendar time zones
$timezones = [];
foreach ($structure['VTIMEZONE'] as $tid => $collection) {
$instance = $collection[0];
$sObjectContents = $importer->extract((int)$instance[2], (int)$instance[3]);
$vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix);
$timezones[$tid] = clone $vObject->VTIMEZONE;
}
// calendar components
// for each component type, construct a full calendar object with all components
// that match the same UID and appropriate time zones that are used in the components
foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) {
foreach ($structure[$type] as $cid => $instances) {
/** @var array<int,VCalendar> $instances */
// extract all instances of component and unserialize to object
$sObjectContents = '';
foreach ($instances as $instance) {
$sObjectContents .= $importer->extract($instance[2], $instance[3]);
}
/** @var VCalendar $vObject */
$vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix);
// add time zones to object
foreach ($this->findTimeZones($vObject) as $zone) {
if (isset($timezones[$zone])) {
$vObject->add(clone $timezones[$zone]);
}
}
yield $vObject;
}
}
}

/**
* Generates object stream from a xml formatted source (xcal)
*
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
*/
public function importXml($source): Generator {
if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
$importer = new XmlImporter($source);
$structure = $importer->structure();
$sObjectPrefix = $importer::OBJECT_PREFIX;
$sObjectSuffix = $importer::OBJECT_SUFFIX;
// calendar time zones
$timezones = [];
foreach ($structure['VTIMEZONE'] as $tid => $collection) {
$instance = $collection[0];
$sObjectContents = $importer->extract((int)$instance[2], (int)$instance[3]);
$vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix);
$timezones[$tid] = clone $vObject->VTIMEZONE;
}
// calendar components
// for each component type, construct a full calendar object with all components
// that match the same UID and appropriate time zones that are used in the components
foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) {
foreach ($structure[$type] as $cid => $instances) {
/** @var array<int,VCalendar> $instances */
// extract all instances of component and unserialize to object
$sObjectContents = '';
foreach ($instances as $instance) {
$sObjectContents .= $importer->extract($instance[2], $instance[3]);
}
/** @var VCalendar $vObject */
$vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix);
// add time zones to object
foreach ($this->findTimeZones($vObject) as $zone) {
if (isset($timezones[$zone])) {
$vObject->add(clone $timezones[$zone]);
}
}
yield $vObject;
}
}
}

/**
* Generates object stream from a json formatted source (jcal)
*
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
*/
public function importJson($source): Generator {
if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
/** @var VCALENDAR $importer */
$importer = Reader::readJson($source);
// calendar time zones
$timezones = [];
foreach ($importer->VTIMEZONE as $timezone) {
$tzid = $timezone->TZID?->getValue();
if ($tzid !== null) {
$timezones[$tzid] = clone $timezone;
}
}
// calendar components
foreach ($importer->getBaseComponents() as $base) {
$vObject = new VCalendar;
$vObject->VERSION = clone $importer->VERSION;
$vObject->PRODID = clone $importer->PRODID;
// extract all instances of component
foreach ($importer->getByUID($base->UID->getValue()) as $instance) {
$vObject->add(clone $instance);
}
// add time zones to object
foreach ($this->findTimeZones($vObject) as $zone) {
if (isset($timezones[$zone])) {
$vObject->add(clone $timezones[$zone]);
}
}
yield $vObject;
}
}

/**
* Searches through all component properties looking for defined timezones
*
* @return array<string>
*/
private function findTimeZones(VCalendar $vObject): array {
$timezones = [];
foreach ($vObject->getComponents() as $vComponent) {
if ($vComponent->name !== 'VTIMEZONE') {
foreach (['DTSTART', 'DTEND', 'DUE', 'RDATE', 'EXDATE'] as $property) {
if (isset($vComponent->$property?->parameters['TZID'])) {
$tid = $vComponent->$property->parameters['TZID']->getValue();
$timezones[$tid] = true;
}
}
}
}
return array_keys($timezones);
}

}
156 changes: 156 additions & 0 deletions apps/dav/lib/CalDAV/Import/TextImporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Import;

use Exception;

class TextImporter {

public const OBJECT_PREFIX = 'BEGIN:VCALENDAR' . PHP_EOL;
public const OBJECT_SUFFIX = PHP_EOL . 'END:VCALENDAR';
private const COMPONENT_TYPES = ['VEVENT', 'VTODO', 'VJOURNAL', 'VTIMEZONE'];

private bool $analyzed = false;
private array $structure = ['VCALENDAR' => [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []];

/**
* @param resource $source
*/
public function __construct(
private $source,
) {
// Ensure that source is a stream resource
if (!is_resource($source) || get_resource_type($source) !== 'stream') {
throw new Exception('Source must be a stream resource');
}
}

/**
* Analyzes the source data and creates a structure of components
*/
private function analyze() {
$componentStart = null;
$componentEnd = null;
$componentId = null;
$componentType = null;
$tagName = null;
$tagValue = null;

// iterate through the source data line by line
fseek($this->source, 0);
while (!feof($this->source)) {
$data = fgets($this->source);
// skip empty lines
if ($data === false || empty(trim($data))) {
continue;
}
// lines with whitespace at the beginning are continuations of the previous line
if (ctype_space($data[0]) === false) {
// detect the line TAG
// detect the first occurrence of ':' or ';'
$colonPos = strpos($data, ':');
$semicolonPos = strpos($data, ';');
if ($colonPos !== false && $semicolonPos !== false) {
$splitPosition = min($colonPos, $semicolonPos);
} elseif ($colonPos !== false) {
$splitPosition = $colonPos;
} elseif ($semicolonPos !== false) {
$splitPosition = $semicolonPos;
} else {
continue;
}
$tagName = strtoupper(trim(substr($data, 0, $splitPosition)));
$tagValue = trim(substr($data, $splitPosition + 1));
$tagContinuation = false;
} else {
$tagContinuation = true;
$tagValue .= trim($data);
}

if ($tagContinuation === false) {
// check line for component start, remember the position and determine the type
if ($tagName === 'BEGIN' && in_array($tagValue, self::COMPONENT_TYPES, true)) {
$componentStart = ftell($this->source) - strlen($data);
$componentType = $tagValue;
}
// check line for component end, remember the position
if ($tagName === 'END' && $componentType === $tagValue) {
$componentEnd = ftell($this->source);
}
// check line for component id
if ($componentStart !== null && ($tagName === 'UID' || $tagName === 'TZID')) {
$componentId = $tagValue;
}
} else {
// check line for component id
if ($componentStart !== null && ($tagName === 'UID' || $tagName === 'TZID')) {
$componentId = $tagValue;
}
}
// any line(s) not inside a component are VCALENDAR properties
if ($componentStart === null) {
if ($tagName !== 'BEGIN' && $tagName !== 'END' && $tagValue === 'VCALENDAR') {
$components['VCALENDAR'][] = $data;
}
}
// if component start and end are found, add the component to the structure
if ($componentStart !== null && $componentEnd !== null) {
if ($componentId !== null) {
$this->structure[$componentType][$componentId][] = [
$componentType,
$componentId,
$componentStart,
$componentEnd
];
} else {
$this->structure[$componentType][] = [
$componentType,
$componentId,
$componentStart,
$componentEnd
];
}
$componentId = null;
$componentType = null;
$componentStart = null;
$componentEnd = null;
}
}
}

/**
* Returns the analyzed structure of the source data
* the analyzed structure is a collection of components organized by type,
* each entry is a collection of instances
* [
* 'VEVENT' => [
* '7456f141-b478-4cb9-8efc-1427ba0d6839' => [
* ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 0, 100 ],
* ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 100, 200 ]
* ]
* ]
* ]
*/
public function structure(): array {
if (!$this->analyzed) {
$this->analyze();
}
return $this->structure;
}

/**
* Extracts a string chuck from the source data
*
* @param int $start starting byte position
* @param int $end ending byte position
*/
public function extract(int $start, int $end): string {
fseek($this->source, $start);
return fread($this->source, $end - $start);
}
}
Loading
Loading