Merge pull request #58907 from nextcloud/backport/57112/stable33

[stable33] feat: improve calendar migrator
This commit is contained in:
Sebastian Krupinski 2026-03-16 08:48:41 -04:00 committed by GitHub
commit 7f6b708b09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1059 additions and 488 deletions

View file

@ -325,7 +325,7 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIs
public function export(?CalendarExportOptions $options = null): Generator {
foreach (
$this->backend->exportCalendar(
$this->calendarInfo['id'],
(int)$this->calendarInfo['id'],
$this->backend::CALENDAR_TYPE_CALENDAR,
$options
) as $event

View file

@ -11,452 +11,57 @@ namespace OCA\DAV\UserMigration;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin;
use OCA\DAV\CalDAV\Plugin as CalDAVPlugin;
use OCA\DAV\Connector\Sabre\CachingTree;
use OCA\DAV\Connector\Sabre\Server as SabreDavServer;
use OCA\DAV\RootCollection;
use OCP\Calendar\ICalendar;
use OCA\DAV\CalDAV\CalendarImpl;
use OCA\DAV\CalDAV\Export\ExportService;
use OCA\DAV\CalDAV\Import\ImportService;
use OCP\App\IAppManager;
use OCP\Calendar\CalendarExportOptions;
use OCP\Calendar\CalendarImportOptions;
use OCP\Calendar\IManager as ICalendarManager;
use OCP\Defaults;
use OCP\IL10N;
use OCP\ITempManager;
use OCP\IUser;
use OCP\UserMigration\IExportDestination;
use OCP\UserMigration\IImportSource;
use OCP\UserMigration\IMigrator;
use OCP\UserMigration\ISizeEstimationMigrator;
use OCP\UserMigration\TMigratorBasicVersionHandling;
use Sabre\VObject\Component as VObjectComponent;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VTimeZone;
use Sabre\VObject\Property\ICalendar\DateTime;
use Sabre\VObject\Reader as VObjectReader;
use Sabre\VObject\UUIDUtil;
use Symfony\Component\Console\Output\NullOutput;
use Sabre\DAV\Xml\Property\Href;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function substr;
class CalendarMigrator implements IMigrator, ISizeEstimationMigrator {
use TMigratorBasicVersionHandling;
private SabreDavServer $sabreDavServer;
private const PATH_ROOT = Application::APP_ID . '/calendars/';
private const PATH_VERSION = self::PATH_ROOT . 'version.json';
private const PATH_CALENDARS = self::PATH_ROOT . 'calendars.json';
private const PATH_SUBSCRIPTIONS = self::PATH_ROOT . 'subscriptions.json';
private const USERS_URI_ROOT = 'principals/users/';
private const FILENAME_EXT = '.ics';
private const MIGRATED_URI_PREFIX = 'migrated-';
private const EXPORT_ROOT = Application::APP_ID . '/calendars/';
private const DAV_PROPERTY_URI = 'uri';
private const DAV_PROPERTY_DISPLAYNAME = '{DAV:}displayname';
private const DAV_PROPERTY_CALENDAR_COLOR = '{http://apple.com/ns/ical/}calendar-color';
private const DAV_PROPERTY_CALENDAR_TIMEZONE = '{urn:ietf:params:xml:ns:caldav}calendar-timezone';
private const DAV_PROPERTY_SUBSCRIBED_SOURCE = 'source';
private const DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos';
private const DAV_PROPERTY_SUBSCRIBED_STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms';
private const DAV_PROPERTY_SUBSCRIBED_STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments';
public function __construct(
private CalDavBackend $calDavBackend,
private ICalendarManager $calendarManager,
private ICSExportPlugin $icsExportPlugin,
private Defaults $defaults,
private IL10N $l10n,
private readonly IAppManager $appManager,
private readonly CalDavBackend $calDavBackend,
private readonly ICalendarManager $calendarManager,
private readonly Defaults $defaults,
private readonly IL10N $l10n,
private readonly ExportService $exportService,
private readonly ImportService $importService,
private readonly ITempManager $tempManager,
) {
$root = new RootCollection();
$this->sabreDavServer = new SabreDavServer(new CachingTree($root));
$this->sabreDavServer->addPlugin(new CalDAVPlugin());
}
private function getPrincipalUri(IUser $user): string {
return CalendarMigrator::USERS_URI_ROOT . $user->getUID();
}
/**
* @return array{name: string, vCalendar: VCalendar}
*
* @throws CalendarMigratorException
* @throws InvalidCalendarException
*/
private function getCalendarExportData(IUser $user, ICalendar $calendar, OutputInterface $output): array {
$userId = $user->getUID();
$uri = $calendar->getUri();
$path = CalDAVPlugin::CALENDAR_ROOT . "/$userId/$uri";
/**
* @see \Sabre\CalDAV\ICSExportPlugin::httpGet() implementation reference
*/
$properties = $this->sabreDavServer->getProperties($path, [
'{DAV:}resourcetype',
'{DAV:}displayname',
'{http://sabredav.org/ns}sync-token',
'{DAV:}sync-token',
'{http://apple.com/ns/ical/}calendar-color',
]);
// Filter out invalid (e.g. deleted) calendars
if (!isset($properties['{DAV:}resourcetype']) || !$properties['{DAV:}resourcetype']->is('{' . CalDAVPlugin::NS_CALDAV . '}calendar')) {
throw new InvalidCalendarException();
}
/**
* @see \Sabre\CalDAV\ICSExportPlugin::generateResponse() implementation reference
*/
$calDataProp = '{' . CalDAVPlugin::NS_CALDAV . '}calendar-data';
$calendarNode = $this->sabreDavServer->tree->getNodeForPath($path);
$nodes = $this->sabreDavServer->getPropertiesIteratorForPath($path, [$calDataProp], 1);
$blobs = [];
foreach ($nodes as $node) {
if (isset($node[200][$calDataProp])) {
$blobs[$node['href']] = $node[200][$calDataProp];
}
}
$mergedCalendar = $this->icsExportPlugin->mergeObjects(
$properties,
$blobs,
);
$problems = $mergedCalendar->validate();
if (!empty($problems)) {
$output->writeln('Skipping calendar "' . $properties['{DAV:}displayname'] . '" containing invalid calendar data');
throw new InvalidCalendarException();
}
return [
'name' => $calendarNode->getName(),
'vCalendar' => $mergedCalendar,
];
}
/**
* @return array<int, array{name: string, vCalendar: VCalendar}>
*
* @throws CalendarMigratorException
*/
private function getCalendarExports(IUser $user, OutputInterface $output): array {
$principalUri = $this->getPrincipalUri($user);
return array_values(array_filter(array_map(
function (ICalendar $calendar) use ($user, $output) {
try {
return $this->getCalendarExportData($user, $calendar, $output);
} catch (InvalidCalendarException $e) {
// Allow this exception as invalid (e.g. deleted) calendars are not to be exported
return null;
}
},
$this->calendarManager->getCalendarsForPrincipal($principalUri),
)));
}
/**
* @throws InvalidCalendarException
*/
private function getUniqueCalendarUri(IUser $user, string $initialCalendarUri): string {
$principalUri = $this->getPrincipalUri($user);
$initialCalendarUri = substr($initialCalendarUri, 0, strlen(CalendarMigrator::MIGRATED_URI_PREFIX)) === CalendarMigrator::MIGRATED_URI_PREFIX
? $initialCalendarUri
: CalendarMigrator::MIGRATED_URI_PREFIX . $initialCalendarUri;
if ($initialCalendarUri === '') {
throw new InvalidCalendarException();
}
$existingCalendarUris = array_map(
fn (ICalendar $calendar) => $calendar->getUri(),
$this->calendarManager->getCalendarsForPrincipal($principalUri),
);
$calendarUri = $initialCalendarUri;
$acc = 1;
while (in_array($calendarUri, $existingCalendarUris, true)) {
$calendarUri = $initialCalendarUri . "-$acc";
++$acc;
}
return $calendarUri;
}
/**
* {@inheritDoc}
*/
public function getEstimatedExportSize(IUser $user): int|float {
$calendarExports = $this->getCalendarExports($user, new NullOutput());
$calendarCount = count($calendarExports);
// 150B for top-level properties
$size = ($calendarCount * 150) / 1024;
$componentCount = array_sum(array_map(
function (array $data): int {
/** @var VCalendar $vCalendar */
$vCalendar = $data['vCalendar'];
return count($vCalendar->getComponents());
},
$calendarExports,
));
// 450B for each component (events, todos, alarms, etc.)
$size += ($componentCount * 450) / 1024;
return ceil($size);
}
/**
* {@inheritDoc}
*/
public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
$output->writeln('Exporting calendars into ' . CalendarMigrator::EXPORT_ROOT . '…');
$calendarExports = $this->getCalendarExports($user, $output);
if (empty($calendarExports)) {
$output->writeln('No calendars to export…');
}
try {
/**
* @var string $name
* @var VCalendar $vCalendar
*/
foreach ($calendarExports as ['name' => $name, 'vCalendar' => $vCalendar]) {
// Set filename to sanitized calendar name
$filename = preg_replace('/[^a-z0-9-_]/iu', '', $name) . CalendarMigrator::FILENAME_EXT;
$exportPath = CalendarMigrator::EXPORT_ROOT . $filename;
$exportDestination->addFileContents($exportPath, $vCalendar->serialize());
}
} catch (Throwable $e) {
throw new CalendarMigratorException('Could not export calendars', 0, $e);
}
}
/**
* @return array<string, VTimeZone>
*/
private function getCalendarTimezones(VCalendar $vCalendar): array {
/** @var VTimeZone[] $calendarTimezones */
$calendarTimezones = array_filter(
$vCalendar->getComponents(),
fn ($component) => $component->name === 'VTIMEZONE',
);
/** @var array<string, VTimeZone> $calendarTimezoneMap */
$calendarTimezoneMap = [];
foreach ($calendarTimezones as $vTimeZone) {
$calendarTimezoneMap[$vTimeZone->getTimeZone()->getName()] = $vTimeZone;
}
return $calendarTimezoneMap;
}
/**
* @return VTimeZone[]
*/
private function getTimezonesForComponent(VCalendar $vCalendar, VObjectComponent $component): array {
$componentTimezoneIds = [];
foreach ($component->children() as $child) {
if ($child instanceof DateTime && isset($child->parameters['TZID'])) {
$timezoneId = $child->parameters['TZID']->getValue();
if (!in_array($timezoneId, $componentTimezoneIds, true)) {
$componentTimezoneIds[] = $timezoneId;
}
}
}
$calendarTimezoneMap = $this->getCalendarTimezones($vCalendar);
return array_values(array_filter(array_map(
fn (string $timezoneId) => $calendarTimezoneMap[$timezoneId],
$componentTimezoneIds,
)));
}
private function sanitizeComponent(VObjectComponent $component): VObjectComponent {
// Operate on the component clone to prevent mutation of the original
$component = clone $component;
// Remove RSVP parameters to prevent automatically sending invitation emails to attendees on import
foreach ($component->children() as $child) {
if (
$child->name === 'ATTENDEE'
&& isset($child->parameters['RSVP'])
) {
unset($child->parameters['RSVP']);
}
}
return $component;
}
/**
* @return VObjectComponent[]
*/
private function getRequiredImportComponents(VCalendar $vCalendar, VObjectComponent $component): array {
$component = $this->sanitizeComponent($component);
/** @var array<int, VTimeZone> $timezoneComponents */
$timezoneComponents = $this->getTimezonesForComponent($vCalendar, $component);
return [
...$timezoneComponents,
$component,
];
}
private function initCalendarObject(): VCalendar {
$vCalendarObject = new VCalendar();
$vCalendarObject->PRODID = '-//IDN nextcloud.com//Migrated calendar//EN';
return $vCalendarObject;
}
/**
* @throws InvalidCalendarException
*/
private function importCalendarObject(int $calendarId, VCalendar $vCalendarObject, string $filename, OutputInterface $output): void {
try {
$this->calDavBackend->createCalendarObject(
$calendarId,
UUIDUtil::getUUID() . CalendarMigrator::FILENAME_EXT,
$vCalendarObject->serialize(),
CalDavBackend::CALENDAR_TYPE_CALENDAR,
);
} catch (Throwable $e) {
$output->writeln("Error creating calendar object, rolling back creation of \"$filename\" calendar…");
$this->calDavBackend->deleteCalendar($calendarId, true);
throw new InvalidCalendarException();
}
}
/**
* @throws InvalidCalendarException
*/
private function importCalendar(IUser $user, string $filename, string $initialCalendarUri, VCalendar $vCalendar, OutputInterface $output): void {
$principalUri = $this->getPrincipalUri($user);
$calendarUri = $this->getUniqueCalendarUri($user, $initialCalendarUri);
$calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => isset($vCalendar->{'X-WR-CALNAME'}) ? $vCalendar->{'X-WR-CALNAME'}->getValue() : $this->l10n->t('Migrated calendar (%1$s)', [$filename]),
'{http://apple.com/ns/ical/}calendar-color' => isset($vCalendar->{'X-APPLE-CALENDAR-COLOR'}) ? $vCalendar->{'X-APPLE-CALENDAR-COLOR'}->getValue() : $this->defaults->getColorPrimary(),
'components' => implode(
',',
array_reduce(
$vCalendar->getComponents(),
function (array $componentNames, VObjectComponent $component) {
/** @var array<int, string> $componentNames */
return !in_array($component->name, $componentNames, true)
? [...$componentNames, $component->name]
: $componentNames;
},
[],
)
),
]);
/** @var VObjectComponent[] $calendarComponents */
$calendarComponents = array_values(array_filter(
$vCalendar->getComponents(),
// VTIMEZONE components are handled separately and added to the calendar object only if depended on by the component
fn (VObjectComponent $component) => $component->name !== 'VTIMEZONE',
));
/** @var array<string, VObjectComponent[]> $groupedCalendarComponents */
$groupedCalendarComponents = [];
/** @var VObjectComponent[] $ungroupedCalendarComponents */
$ungroupedCalendarComponents = [];
foreach ($calendarComponents as $component) {
if (isset($component->UID)) {
$uid = $component->UID->getValue();
// Components with the same UID (e.g. recurring events) are grouped together into a single calendar object
if (isset($groupedCalendarComponents[$uid])) {
$groupedCalendarComponents[$uid][] = $component;
} else {
$groupedCalendarComponents[$uid] = [$component];
}
} else {
$ungroupedCalendarComponents[] = $component;
}
}
foreach ($groupedCalendarComponents as $uid => $components) {
// Construct and import a calendar object containing all components of a group
$vCalendarObject = $this->initCalendarObject();
foreach ($components as $component) {
foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
$vCalendarObject->add($component);
}
}
$this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output);
}
foreach ($ungroupedCalendarComponents as $component) {
// Construct and import a calendar object for a single component
$vCalendarObject = $this->initCalendarObject();
foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
$vCalendarObject->add($component);
}
$this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output);
}
}
/**
* {@inheritDoc}
*
* @throws CalendarMigratorException
*/
public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void {
if ($importSource->getMigratorVersion($this->getId()) === null) {
$output->writeln('No version for ' . static::class . ', skipping import…');
return;
}
$output->writeln('Importing calendars from ' . CalendarMigrator::EXPORT_ROOT . '…');
$calendarImports = $importSource->getFolderListing(CalendarMigrator::EXPORT_ROOT);
if (empty($calendarImports)) {
$output->writeln('No calendars to import…');
}
foreach ($calendarImports as $filename) {
$importPath = CalendarMigrator::EXPORT_ROOT . $filename;
try {
/** @var VCalendar $vCalendar */
$vCalendar = VObjectReader::read(
$importSource->getFileAsStream($importPath),
VObjectReader::OPTION_FORGIVING,
);
} catch (Throwable $e) {
$output->writeln("Failed to read file \"$importPath\", skipping…");
continue;
}
$problems = $vCalendar->validate();
if (!empty($problems)) {
$output->writeln("Invalid calendar data contained in \"$importPath\", skipping…");
continue;
}
$splitFilename = explode('.', $filename, 2);
if (count($splitFilename) !== 2) {
$output->writeln("Invalid filename \"$filename\", expected filename of the format \"<calendar_name>" . CalendarMigrator::FILENAME_EXT . '", skipping…');
continue;
}
[$initialCalendarUri, $ext] = $splitFilename;
try {
$this->importCalendar(
$user,
$filename,
$initialCalendarUri,
$vCalendar,
$output,
);
} catch (InvalidCalendarException $e) {
// Allow this exception to skip a failed import
} finally {
$vCalendar->destroy();
}
}
$this->version = 2;
}
/**
@ -479,4 +84,448 @@ class CalendarMigrator implements IMigrator, ISizeEstimationMigrator {
public function getDescription(): string {
return $this->l10n->t('Calendars including events, details and attendees');
}
/**
* {@inheritDoc}
*/
public function getEstimatedExportSize(IUser $user): int|float {
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri);
$calendarCount = 0;
$totalSize = 0;
foreach ($calendars as $calendar) {
if (!$calendar instanceof CalendarImpl) {
continue;
}
if ($calendar->isShared()) {
continue;
}
$calendarCount++;
// Note: 'uid' is required because getLimitedCalendarObjects uses it as the array key
$objects = $this->calDavBackend->getLimitedCalendarObjects((int)$calendar->getKey(), CalDavBackend::CALENDAR_TYPE_CALENDAR, ['uid', 'size']);
foreach ($objects as $object) {
$totalSize += (int)($object['size'] ?? 0);
}
}
// 150B for meta file per calendar + total calendar data size
$size = ($calendarCount * 150 + $totalSize) / 1024;
return ceil($size);
}
/**
* {@inheritDoc}
*/
#[\Override]
public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
$output->writeln('Exporting calendaring data…');
$this->exportVersion($exportDestination, $output);
$this->exportCalendars($user, $exportDestination, $output);
$this->exportSubscriptions($user, $exportDestination, $output);
}
/**
* @throws CalendarMigratorException
*/
private function exportVersion(IExportDestination $exportDestination, OutputInterface $output): void {
try {
$versionData = [
'appVersion' => $this->appManager->getAppVersion(Application::APP_ID),
];
$exportDestination->addFileContents(self::PATH_VERSION, json_encode($versionData, JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
throw new CalendarMigratorException('Could not export version information', 0, $e);
}
}
/**
* @throws CalendarMigratorException
*/
public function exportCalendars(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
$output->writeln('Exporting calendars to ' . self::PATH_CALENDARS . '…');
try {
$calendarExports = $this->calendarManager->getCalendarsForPrincipal(self::USERS_URI_ROOT . $user->getUID());
$exportData = [];
/** @var CalendarImpl $calendar */
foreach ($calendarExports as $calendar) {
$output->writeln('Exporting calendar "' . $calendar->getUri() . '"');
if (!$calendar instanceof CalendarImpl) {
$output->writeln('Skipping unsupported calendar type for "' . $calendar->getUri() . '"');
continue;
}
if ($calendar->isShared()) {
$output->writeln('Skipping shared calendar "' . $calendar->getUri() . '"');
continue;
}
// construct archive path for calendar data
$filename = preg_replace('/[^a-z0-9-_]/iu', '', $calendar->getUri());
$exportDataPath = self::PATH_ROOT . $filename . '.data';
// add calendar metadata to the collection
$exportData[] = [
'format' => 'ical',
'uri' => $calendar->getUri(),
'label' => $calendar->getDisplayName(),
'color' => $calendar->getDisplayColor(),
'timezone' => $calendar->getSchedulingTimezone(),
];
// export calendar data to a temporary file
$options = new CalendarExportOptions();
$options->setFormat('ical');
$tempPath = $this->tempManager->getTemporaryFile();
$tempFile = fopen($tempPath, 'w+');
foreach ($this->exportService->export($calendar, $options) as $chunk) {
fwrite($tempFile, $chunk);
}
// add the temporary file to the export archive
rewind($tempFile);
$exportDestination->addFileAsStream($exportDataPath, $tempFile);
fclose($tempFile);
}
// write all calendar metadata
$exportDestination->addFileContents(self::PATH_CALENDARS, json_encode($exportData, JSON_THROW_ON_ERROR));
$output->writeln('Exported ' . count($exportData) . ' calendar(s)…');
} catch (Throwable $e) {
throw new CalendarMigratorException('Could not export calendars', 0, $e);
}
}
/**
* @throws CalendarMigratorException
*/
private function exportSubscriptions(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
$output->writeln('Exporting calendar subscriptions to ' . self::PATH_SUBSCRIPTIONS . '…');
try {
$subscriptions = $this->calDavBackend->getSubscriptionsForUser(self::USERS_URI_ROOT . $user->getUID());
$exportData = [];
foreach ($subscriptions as $subscription) {
$exportData[] = [
'uri' => $subscription[self::DAV_PROPERTY_URI],
'displayname' => $subscription[self::DAV_PROPERTY_DISPLAYNAME] ?? null,
'color' => $subscription[self::DAV_PROPERTY_CALENDAR_COLOR] ?? null,
'source' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_SOURCE] ?? null,
'striptodos' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS] ?? null,
'stripalarms' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_ALARMS] ?? null,
'stripattachments' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_ATTACHMENTS] ?? null,
];
}
$exportDestination->addFileContents(self::PATH_SUBSCRIPTIONS, json_encode($exportData, JSON_THROW_ON_ERROR));
$output->writeln('Exported ' . count($exportData) . ' calendar subscription(s)…');
} catch (Throwable $e) {
throw new CalendarMigratorException('Could not export calendar subscriptions', 0, $e);
}
}
/**
* {@inheritDoc}
*
* @throws CalendarMigratorException
*/
public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void {
$output->writeln('Importing calendaring data…');
if ($importSource->getMigratorVersion($this->getId()) === null) {
$output->writeln('No version for ' . static::class . ', skipping import…');
return;
}
$this->importCalendars($user, $importSource, $output);
$this->importSubscriptions($user, $importSource, $output);
}
/**
* @throws CalendarMigratorException
*/
public function importCalendars(IUser $user, IImportSource $importSource, OutputInterface $output): void {
$output->writeln('Importing calendars from ' . self::PATH_ROOT . '…');
$migratorVersion = $importSource->getMigratorVersion($this->getId());
match ($migratorVersion) {
1 => $this->importCalendarsV1($user, $importSource, $output),
2 => $this->importCalendarsV2($user, $importSource, $output),
default => throw new CalendarMigratorException('Unsupported migrator version ' . $migratorVersion . ' for ' . static::class),
};
}
/**
* @throws CalendarMigratorException
*/
public function importCalendarsV2(IUser $user, IImportSource $importSource, OutputInterface $output): void {
$output->writeln('Importing calendars from ' . self::PATH_CALENDARS . '…');
if ($importSource->pathExists(self::PATH_CALENDARS) === false) {
$output->writeln('No calendars to import…');
return;
}
$importData = $importSource->getFileContents(self::PATH_CALENDARS);
if (empty($importData)) {
$output->writeln('No calendars to import…');
return;
}
try {
/** @var array<int, array<string, mixed>> $calendarsData */
$calendarsData = json_decode($importData, true, 512, JSON_THROW_ON_ERROR);
if (empty($calendarsData)) {
$output->writeln('No calendars to import…');
return;
}
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$importCount = 0;
foreach ($calendarsData as $calendarMeta) {
$migratedCalendarUri = self::MIGRATED_URI_PREFIX . $calendarMeta['uri'];
$filename = preg_replace('/[^a-z0-9-_]/iu', '', $calendarMeta['uri']);
$importDataPath = self::PATH_ROOT . $filename . '.data';
try {
// check if a calendar with this URI already exists
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$migratedCalendarUri]);
if (empty($calendars)) {
$output->writeln("Creating calendar \"$migratedCalendarUri\"");
// create the calendar
$this->calDavBackend->createCalendar($principalUri, $migratedCalendarUri, [
self::DAV_PROPERTY_DISPLAYNAME => $calendarMeta['label'] ?? $this->l10n->t('Migrated calendar (%1$s)', [$calendarMeta['uri']]),
self::DAV_PROPERTY_CALENDAR_COLOR => $calendarMeta['color'] ?? $this->defaults->getColorPrimary(),
self::DAV_PROPERTY_CALENDAR_TIMEZONE => $calendarMeta['timezone'] ?? null,
]);
// retrieve the created calendar
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$migratedCalendarUri]);
if (empty($calendars) || !($calendars[0] instanceof CalendarImpl)) {
$output->writeln("Failed to retrieve created calendar \"$migratedCalendarUri\", skipping import…");
continue;
}
} else {
$output->writeln("Using existing calendar \"$migratedCalendarUri\"");
}
$calendar = $calendars[0];
// copy import stream to temporary file as the source stream is not rewindable
$importStream = $importSource->getFileAsStream($importDataPath);
$tempPath = $this->tempManager->getTemporaryFile();
$tempFile = fopen($tempPath, 'w+');
stream_copy_to_stream($importStream, $tempFile);
rewind($tempFile);
// import calendar data
try {
$options = new CalendarImportOptions();
$options->setFormat($calendarMeta['format'] ?? 'ical');
$options->setErrors(0);
$options->setValidate(1);
$options->setSupersede(true);
$outcome = $this->importService->import(
$tempFile,
$calendar,
$options
);
} finally {
fclose($tempFile);
}
$this->importSummary($calendarMeta['label'] ?? $calendarMeta['uri'], $outcome, $output);
$importCount++;
} catch (Throwable $e) {
$output->writeln('Failed to import calendar "' . ($calendarMeta['uri'] ?? 'unknown') . '", skipping…');
continue;
}
}
$output->writeln('Imported ' . $importCount . ' calendar(s)…');
} catch (Throwable $e) {
throw new CalendarMigratorException('Could not import calendars', 0, $e);
}
}
/**
* @throws CalendarMigratorException
*/
public function importCalendarsV1(IUser $user, IImportSource $importSource, OutputInterface $output): void {
$files = $importSource->getFolderListing(self::PATH_ROOT);
if (empty($files)) {
$output->writeln('No calendars to import…');
}
$principalUri = self::USERS_URI_ROOT . $user->getUID();
foreach ($files as $filename) {
// Only process .ics files
if (!str_ends_with($filename, '.ics')) {
continue;
}
// construct archive path
$importDataPath = self::PATH_ROOT . $filename;
try {
$calendarUri = substr($filename, 0, -4);
$migratedCalendarUri = self::MIGRATED_URI_PREFIX . $calendarUri;
// copy import stream to temporary file as the source stream is not rewindable
$importStream = $importSource->getFileAsStream($importDataPath);
$tempPath = $this->tempManager->getTemporaryFile();
$tempFile = fopen($tempPath, 'w+');
stream_copy_to_stream($importStream, $tempFile);
rewind($tempFile);
// check if a calendar with this URI already exists
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$migratedCalendarUri]);
if (empty($calendars)) {
$output->writeln("Creating calendar \"$migratedCalendarUri\"");
// extract calendar properties from the ICS header without full parsing
$calendarName = null;
$calendarColor = null;
$headerLines = 0;
while (($line = fgets($tempFile)) !== false && $headerLines < 50) {
$headerLines++;
$line = trim($line);
if (str_starts_with($line, 'X-WR-CALNAME:')) {
$calendarName = substr($line, 13);
} elseif (str_starts_with($line, 'X-APPLE-CALENDAR-COLOR:')) {
$calendarColor = substr($line, 23);
}
// stop parsing header once we hit the first component
if (str_starts_with($line, 'BEGIN:VEVENT')
|| str_starts_with($line, 'BEGIN:VTODO')
|| str_starts_with($line, 'BEGIN:VJOURNAL')) {
break;
}
}
rewind($tempFile);
// create the calendar
$this->calDavBackend->createCalendar($principalUri, $migratedCalendarUri, [
self::DAV_PROPERTY_DISPLAYNAME => $calendarName ?? $this->l10n->t('Migrated calendar (%1$s)', [$calendarUri]),
self::DAV_PROPERTY_CALENDAR_COLOR => $calendarColor ?? $this->defaults->getColorPrimary(),
]);
// retrieve the created calendar
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$migratedCalendarUri]);
if (empty($calendars) || !($calendars[0] instanceof CalendarImpl)) {
$output->writeln("Failed to retrieve created calendar \"$migratedCalendarUri\", skipping import…");
fclose($tempFile);
continue;
}
} else {
$output->writeln("Using existing calendar \"$migratedCalendarUri\"");
}
$calendar = $calendars[0];
// import calendar data
$options = new CalendarImportOptions();
$options->setFormat('ical');
$options->setErrors(0);
$options->setValidate(1);
$options->setSupersede(true);
try {
$outcome = $this->importService->import(
$tempFile,
$calendar,
$options
);
} finally {
fclose($tempFile);
}
$this->importSummary($calendarName ?? $calendarUri, $outcome, $output);
} catch (Throwable $e) {
$output->writeln("Failed to import calendar \"$filename\", skipping…");
continue;
}
}
}
/**
* @throws CalendarMigratorException
*/
public function importSubscriptions(IUser $user, IImportSource $importSource, OutputInterface $output): void {
$output->writeln('Importing calendar subscriptions from ' . self::PATH_SUBSCRIPTIONS . '…');
if ($importSource->pathExists(self::PATH_SUBSCRIPTIONS) === false) {
$output->writeln('No calendar subscriptions to import…');
return;
}
$importData = $importSource->getFileContents(self::PATH_SUBSCRIPTIONS);
if (empty($importData)) {
$output->writeln('No calendar subscriptions to import…');
return;
}
try {
$subscriptions = json_decode($importData, true, 512, JSON_THROW_ON_ERROR);
if (empty($subscriptions)) {
$output->writeln('No calendar subscriptions to import…');
return;
}
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$importCount = 0;
foreach ($subscriptions as $subscription) {
$output->writeln('Importing calendar subscription "' . ($subscription['displayname'] ?? $subscription['source'] ?? 'unknown') . '"');
if (empty($subscription['source'])) {
$output->writeln('Skipping subscription without source URL');
continue;
}
$this->calDavBackend->createSubscription(
$principalUri,
$subscription['uri'] ? self::MIGRATED_URI_PREFIX . $subscription['uri'] : self::MIGRATED_URI_PREFIX . bin2hex(random_bytes(16)),
[
'{http://calendarserver.org/ns/}source' => new Href($subscription['source']),
self::DAV_PROPERTY_DISPLAYNAME => $subscription['displayname'] ?? null,
self::DAV_PROPERTY_CALENDAR_COLOR => $subscription['color'] ?? null,
self::DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS => $subscription['striptodos'] ?? null,
self::DAV_PROPERTY_SUBSCRIBED_STRIP_ALARMS => $subscription['stripalarms'] ?? null,
self::DAV_PROPERTY_SUBSCRIBED_STRIP_ATTACHMENTS => $subscription['stripattachments'] ?? null,
]
);
$importCount++;
}
$output->writeln('Imported ' . $importCount . ' subscription(s)…');
} catch (Throwable $e) {
throw new CalendarMigratorException('Could not import calendar subscriptions', 0, $e);
}
}
private function importSummary(string $label, array $outcome, OutputInterface $output): void {
$created = 0;
$updated = 0;
$skipped = 0;
$errors = 0;
foreach ($outcome as $result) {
match ($result['outcome'] ?? null) {
'created' => $created++,
'updated' => $updated++,
'exists' => $skipped++,
'error' => $errors++,
default => null,
};
}
$output->writeln(" \"$label\": $created created, $updated updated, $skipped skipped, $errors errors");
}
}

View file

@ -10,107 +10,629 @@ declare(strict_types=1);
namespace OCA\DAV\Tests\integration\UserMigration;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarImpl;
use OCA\DAV\UserMigration\CalendarMigrator;
use OCP\AppFramework\App;
use OCP\Calendar\IManager as ICalendarManager;
use OCP\IUser;
use OCP\IUserManager;
use Sabre\VObject\Component as VObjectComponent;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Property as VObjectProperty;
use Sabre\VObject\Reader as VObjectReader;
use OCP\UserMigration\IExportDestination;
use OCP\UserMigration\IImportSource;
use Sabre\VObject\UUIDUtil;
use Symfony\Component\Console\Output\OutputInterface;
use Test\TestCase;
use function scandir;
#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
class CalendarMigratorTest extends TestCase {
private IUserManager $userManager;
private ICalendarManager $calendarManager;
private CalDavBackend $calDavBackend;
private CalendarMigrator $migrator;
private OutputInterface $output;
private const ASSETS_DIR = __DIR__ . '/assets/calendars/';
private const USERS_URI_ROOT = 'principals/users/';
protected function setUp(): void {
parent::setUp();
$app = new App(Application::APP_ID);
$container = $app->getContainer();
$this->userManager = $container->get(IUserManager::class);
$this->calendarManager = $container->get(ICalendarManager::class);
$this->calDavBackend = $container->get(CalDavBackend::class);
$this->migrator = $container->get(CalendarMigrator::class);
$this->output = $this->createMock(OutputInterface::class);
}
public static function dataAssets(): array {
return array_map(
function (string $filename) {
/** @var VCalendar $vCalendar */
$vCalendar = VObjectReader::read(
fopen(self::ASSETS_DIR . $filename, 'r'),
VObjectReader::OPTION_FORGIVING,
);
[$initialCalendarUri, $ext] = explode('.', $filename, 2);
return [UUIDUtil::getUUID(), $filename, $initialCalendarUri, $vCalendar];
},
array_diff(
scandir(self::ASSETS_DIR),
// Exclude current and parent directories
['.', '..'],
),
);
private function createTestUser(): IUser {
$userId = UUIDUtil::getUUID();
return $this->userManager->createUser($userId, 'topsecretpassword');
}
private function getProperties(VCalendar $vCalendar): array {
return array_map(
fn (VObjectProperty $property) => $property->serialize(),
array_values(array_filter(
$vCalendar->children(),
fn ($child) => $child instanceof VObjectProperty,
)),
);
private function deleteUser(IUser $user): void {
$user->delete();
}
private function getComponents(VCalendar $vCalendar): array {
return array_map(
// Elements of the serialized blob are sorted
fn (VObjectComponent $component) => $component->serialize(),
$vCalendar->getComponents(),
);
private function getCalendarsForUser(IUser $user): array {
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri);
return array_filter($calendars, fn ($c) => $c instanceof CalendarImpl && !$c->isShared());
}
private function getSanitizedComponents(VCalendar $vCalendar): array {
return array_map(
// Elements of the serialized blob are sorted
fn (VObjectComponent $component) => $this->invokePrivate($this->migrator, 'sanitizeComponent', [$component])->serialize(),
$vCalendar->getComponents(),
);
public function testImportV1(): void {
$user = $this->createTestUser();
try {
// Get all asset files
$files = scandir(self::ASSETS_DIR);
$this->assertNotFalse($files, 'Failed to scan assets directory');
$files = array_values(array_diff($files, ['.', '..']));
$this->assertNotEmpty($files, 'No asset files found');
// Load all ICS content
$icsContents = [];
foreach ($files as $filename) {
$icsContents[$filename] = file_get_contents(self::ASSETS_DIR . $filename);
}
// Setup import source mock
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(1);
$importSource->method('getFolderListing')
->with('dav/calendars/')
->willReturn($files);
$importSource->method('getFileAsStream')
->willReturnCallback(function (string $path) use ($icsContents) {
foreach ($icsContents as $filename => $content) {
if ($path === 'dav/calendars/' . $filename) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $content);
rewind($stream);
return $stream;
}
}
throw new \Exception("Unexpected path: $path");
});
// Import all calendars
$this->migrator->import($user, $importSource, $this->output);
// Verify all calendars were created
$calendars = $this->getCalendarsForUser($user);
$this->assertCount(count($files), $calendars, 'Expected all calendars to be created');
// Verify each calendar has the migrated prefix and has objects
foreach ($files as $filename) {
$expectedUri = 'migrated-' . substr($filename, 0, -4);
$found = false;
foreach ($calendars as $calendar) {
if ($calendar->getUri() === $expectedUri) {
$found = true;
// Verify calendar has objects
$objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey());
$this->assertNotEmpty($objects, "Expected calendar $expectedUri to have objects");
break;
}
}
$this->assertTrue($found, "Calendar with URI $expectedUri was not found");
}
} finally {
$this->deleteUser($user);
}
}
#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataAssets')]
public function testImportExportAsset(string $userId, string $filename, string $initialCalendarUri, VCalendar $importCalendar): void {
$user = $this->userManager->createUser($userId, 'topsecretpassword');
public function testImportV2(): void {
$user = $this->createTestUser();
$problems = $importCalendar->validate();
$this->assertEmpty($problems);
try {
// Get all asset files
$files = scandir(self::ASSETS_DIR);
$this->assertNotFalse($files, 'Failed to scan assets directory');
$files = array_values(array_diff($files, ['.', '..']));
$this->assertNotEmpty($files, 'No asset files found');
$this->invokePrivate($this->migrator, 'importCalendar', [$user, $filename, $initialCalendarUri, $importCalendar, $this->output]);
// Load all ICS content and build calendars metadata
$calendarsMetadata = [];
$icsContents = [];
foreach ($files as $filename) {
$icsContent = file_get_contents(self::ASSETS_DIR . $filename);
$calendarUri = substr($filename, 0, -4);
$icsContents[$calendarUri] = $icsContent;
$calendarExports = $this->invokePrivate($this->migrator, 'getCalendarExports', [$user, $this->output]);
$this->assertCount(1, $calendarExports);
$calendarsMetadata[] = [
'format' => 'ical',
'uri' => $calendarUri,
'label' => $calendarUri,
'color' => '#0082c9',
'timezone' => null,
];
}
/** @var VCalendar $exportCalendar */
['vCalendar' => $exportCalendar] = reset($calendarExports);
// Setup import source mock for V2 format (calendars.json + .data files)
$importSource = $this->createMock(IImportSource::class);
$calendarsJson = json_encode($calendarsMetadata);
$this->assertEqualsCanonicalizing(
$this->getProperties($importCalendar),
$this->getProperties($exportCalendar),
);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$this->assertEqualsCanonicalizing(
// Components are expected to be sanitized on import
$this->getSanitizedComponents($importCalendar),
$this->getComponents($exportCalendar),
);
$importSource->method('pathExists')
->willReturnCallback(fn (string $path) => $path === 'dav/calendars/calendars.json');
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) use ($calendarsJson) {
if ($path === 'dav/calendars/calendars.json') {
return $calendarsJson;
}
throw new \Exception("Unexpected path: $path");
});
$importSource->method('getFileAsStream')
->willReturnCallback(function (string $path) use ($icsContents) {
foreach ($icsContents as $calendarUri => $icsContent) {
if ($path === 'dav/calendars/' . $calendarUri . '.data') {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $icsContent);
rewind($stream);
return $stream;
}
}
throw new \Exception("Unexpected path: $path");
});
// Import all calendars
$this->migrator->import($user, $importSource, $this->output);
// Verify all calendars were created
$calendars = $this->getCalendarsForUser($user);
$this->assertCount(count($files), $calendars, 'Expected all calendars to be created');
// Verify each calendar has the correct properties and objects
foreach ($calendarsMetadata as $metadata) {
$expectedUri = 'migrated-' . $metadata['uri'];
$found = false;
foreach ($calendars as $calendar) {
if ($calendar->getUri() === $expectedUri) {
$found = true;
// Verify calendar display name
$this->assertEquals($metadata['label'], $calendar->getDisplayName());
// Verify calendar has objects
$objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey());
$this->assertNotEmpty($objects, "Expected calendar $expectedUri to have objects");
break;
}
}
$this->assertTrue($found, "Calendar with URI $expectedUri was not found");
}
} finally {
$this->deleteUser($user);
}
}
public function testExport(): void {
$user = $this->createTestUser();
try {
// Create a calendar to export
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendarUri = 'test-export-calendar';
$calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => 'Test Export Calendar',
'{http://apple.com/ns/ical/}calendar-color' => '#ff0000',
]);
// Add an event to the calendar
$icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics');
$this->calDavBackend->createCalendarObject($calendarId, 'test-event.ics', $icsContent);
// Setup export destination mock
$exportDestination = $this->createMock(IExportDestination::class);
$exportedCalendarsJson = null;
$exportedData = null;
$exportDestination->method('addFileContents')
->willReturnCallback(function (string $path, string $content) use (&$exportedCalendarsJson) {
if ($path === 'dav/calendars/calendars.json') {
$exportedCalendarsJson = json_decode($content, true);
}
});
$exportDestination->method('addFileAsStream')
->willReturnCallback(function (string $path, $stream) use (&$exportedData) {
if (str_ends_with($path, '.data')) {
$exportedData = stream_get_contents($stream);
}
});
// Export the calendar
$this->migrator->export($user, $exportDestination, $this->output);
// Verify calendars.json was exported
$this->assertNotNull($exportedCalendarsJson, 'Expected calendars.json to be exported');
$this->assertIsArray($exportedCalendarsJson);
$this->assertCount(1, $exportedCalendarsJson);
$exportedMeta = $exportedCalendarsJson[0];
$this->assertEquals('ical', $exportedMeta['format']);
$this->assertEquals($calendarUri, $exportedMeta['uri']);
$this->assertEquals('Test Export Calendar', $exportedMeta['label']);
$this->assertEquals('#ff0000', $exportedMeta['color']);
// Verify data was exported
$this->assertNotNull($exportedData, 'Expected data to be exported');
$this->assertIsString($exportedData);
/** @var string $exportedData */
$this->assertStringContainsString('BEGIN:VCALENDAR', $exportedData);
$this->assertStringContainsString('BEGIN:VEVENT', $exportedData);
} finally {
$this->deleteUser($user);
}
}
public function testExportImportRoundTrip(): void {
$user = $this->createTestUser();
try {
// Create a calendar with some events
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendarUri = 'roundtrip-calendar';
$calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => 'Round Trip Calendar',
'{http://apple.com/ns/ical/}calendar-color' => '#00ff00',
]);
// Add events to the calendar
$icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics');
$this->calDavBackend->createCalendarObject($calendarId, 'event1.ics', $icsContent);
// Capture exported data
$exportedFiles = [];
$exportDestination = $this->createMock(IExportDestination::class);
$exportDestination->method('addFileContents')
->willReturnCallback(function (string $path, string $content) use (&$exportedFiles) {
$exportedFiles[$path] = $content;
});
$exportDestination->method('addFileAsStream')
->willReturnCallback(function (string $path, $stream) use (&$exportedFiles) {
$exportedFiles[$path] = stream_get_contents($stream);
});
// Export
$this->migrator->export($user, $exportDestination, $this->output);
// Delete the original calendar
$this->calDavBackend->deleteCalendar($calendarId, true);
// Verify calendar is gone
$calendars = $this->getCalendarsForUser($user);
$this->assertEmpty($calendars, 'Calendar should be deleted');
// Setup import source from exported data
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(function (string $path) use ($exportedFiles) {
return isset($exportedFiles[$path]);
});
$importSource->method('getFolderListing')
->with('dav/calendars/')
->willReturn(array_map(fn ($p) => basename($p), array_keys($exportedFiles)));
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) use ($exportedFiles) {
if (isset($exportedFiles[$path])) {
return $exportedFiles[$path];
}
throw new \Exception("File not found: $path");
});
$importSource->method('getFileAsStream')
->willReturnCallback(function (string $path) use ($exportedFiles) {
if (isset($exportedFiles[$path])) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $exportedFiles[$path]);
rewind($stream);
return $stream;
}
throw new \Exception("File not found: $path");
});
// Import
$this->migrator->import($user, $importSource, $this->output);
// Verify calendar was recreated with migrated prefix
$calendars = $this->getCalendarsForUser($user);
$this->assertCount(1, $calendars, 'Expected one calendar after import');
$calendar = reset($calendars);
$this->assertEquals('migrated-' . $calendarUri, $calendar->getUri());
$this->assertEquals('Round Trip Calendar', $calendar->getDisplayName());
// Verify events were imported
$objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey());
$this->assertCount(1, $objects, 'Expected one event after import');
} finally {
$this->deleteUser($user);
}
}
public function testGetEstimatedExportSize(): void {
$user = $this->createTestUser();
try {
// Initially should be 0 or minimal
$initialSize = $this->migrator->getEstimatedExportSize($user);
$this->assertEquals(0, $initialSize);
// Create a calendar with events
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$calendarUri = 'size-test-calendar';
$calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => 'Size Test Calendar',
]);
// Add an event
$icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics');
$this->calDavBackend->createCalendarObject($calendarId, 'event.ics', $icsContent);
// Size should now be > 0
$sizeWithData = $this->migrator->getEstimatedExportSize($user);
$this->assertGreaterThan(0, $sizeWithData);
} finally {
$this->deleteUser($user);
}
}
public function testImportExistingCalendarSkipped(): void {
$user = $this->createTestUser();
try {
$principalUri = self::USERS_URI_ROOT . $user->getUID();
// Pre-create a calendar with the migrated prefix
$calendarUri = 'migrated-existing-calendar';
$this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => 'Existing Calendar',
]);
// Setup import for V2
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(function (string $path) {
if ($path === 'dav/calendars/calendars.json') {
return true;
}
if ($path === 'dav/calendars/subscriptions.json') {
return false;
}
return false;
});
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) {
if ($path === 'dav/calendars/calendars.json') {
return json_encode([[
'format' => 'ical',
'uri' => 'existing-calendar',
'label' => 'Existing Calendar',
'color' => '#0082c9',
'timezone' => null,
]]);
}
throw new \Exception("Unexpected path: $path");
});
// Import should use existing calendar
$this->migrator->import($user, $importSource, $this->output);
// Should still have just one calendar
$calendars = $this->getCalendarsForUser($user);
$this->assertCount(1, $calendars);
} finally {
$this->deleteUser($user);
}
}
public function testExportSubscriptions(): void {
$user = $this->createTestUser();
try {
// Create a subscription to export
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$this->calDavBackend->createSubscription(
$principalUri,
'test-subscription',
[
'{http://calendarserver.org/ns/}source' => new \Sabre\DAV\Xml\Property\Href('https://example.com/calendar.ics'),
'{DAV:}displayname' => 'Test Subscription',
'{http://apple.com/ns/ical/}calendar-color' => '#ff0000',
'{http://calendarserver.org/ns/}subscribed-strip-todos' => '1',
]
);
// Setup export destination mock
$exportDestination = $this->createMock(IExportDestination::class);
$exportedSubscriptionsJson = null;
$exportDestination->method('addFileContents')
->willReturnCallback(function (string $path, string $content) use (&$exportedSubscriptionsJson) {
if ($path === 'dav/calendars/subscriptions.json') {
$exportedSubscriptionsJson = json_decode($content, true);
}
});
$exportDestination->method('addFileAsStream');
// Export
$this->migrator->export($user, $exportDestination, $this->output);
// Verify exported subscription data
$this->assertNotNull($exportedSubscriptionsJson, 'Subscriptions JSON should be exported');
$this->assertCount(1, $exportedSubscriptionsJson, 'Expected one subscription in export');
$exportedSubscription = $exportedSubscriptionsJson[0];
$this->assertEquals('test-subscription', $exportedSubscription['uri']);
$this->assertEquals('Test Subscription', $exportedSubscription['displayname']);
$this->assertEquals('#ff0000', $exportedSubscription['color']);
$this->assertEquals('https://example.com/calendar.ics', $exportedSubscription['source']);
$this->assertEquals('1', $exportedSubscription['striptodos']);
} finally {
$this->deleteUser($user);
}
}
public function testImportSubscriptions(): void {
$user = $this->createTestUser();
try {
// Setup import source mock
$importSource = $this->createMock(IImportSource::class);
$subscriptionsJson = json_encode([[
'uri' => 'imported-subscription',
'displayname' => 'Imported Subscription',
'color' => '#00ff00',
'source' => 'https://example.com/imported.ics',
'striptodos' => null,
'stripalarms' => '1',
'stripattachments' => null,
]]);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(function (string $path) {
if ($path === 'dav/calendars/subscriptions.json') {
return true;
}
if ($path === 'dav/calendars/calendars.json') {
return false;
}
return false;
});
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) use ($subscriptionsJson) {
if ($path === 'dav/calendars/subscriptions.json') {
return $subscriptionsJson;
}
if ($path === 'dav/calendars/calendars.json') {
// Return empty calendars array
return json_encode([]);
}
throw new \Exception("Unexpected path: $path");
});
// Import
$this->migrator->import($user, $importSource, $this->output);
// Verify subscription was created
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
$this->assertCount(1, $subscriptions);
$subscription = $subscriptions[0];
$this->assertEquals('migrated-imported-subscription', $subscription['uri']);
$this->assertEquals('Imported Subscription', $subscription['{DAV:}displayname']);
$this->assertEquals('#00ff00', $subscription['{http://apple.com/ns/ical/}calendar-color']);
$this->assertEquals('1', $subscription['{http://calendarserver.org/ns/}subscribed-strip-alarms']);
} finally {
$this->deleteUser($user);
}
}
public function testExportImportSubscriptionsRoundTrip(): void {
$user = $this->createTestUser();
try {
// Create subscriptions to export
$principalUri = self::USERS_URI_ROOT . $user->getUID();
$this->calDavBackend->createSubscription(
$principalUri,
'roundtrip-subscription',
[
'{http://calendarserver.org/ns/}source' => new \Sabre\DAV\Xml\Property\Href('https://example.com/roundtrip.ics'),
'{DAV:}displayname' => 'Round Trip Subscription',
'{http://apple.com/ns/ical/}calendar-color' => '#0000ff',
]
);
// Capture exported data
$exportedFiles = [];
$exportDestination = $this->createMock(IExportDestination::class);
$exportDestination->method('addFileContents')
->willReturnCallback(function (string $path, string $content) use (&$exportedFiles) {
$exportedFiles[$path] = $content;
});
$exportDestination->method('addFileAsStream');
// Export
$this->migrator->export($user, $exportDestination, $this->output);
// Delete the original subscription
$subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
foreach ($subscriptions as $subscription) {
$this->calDavBackend->deleteSubscription($subscription['id']);
}
// Verify subscription is gone
$subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
$this->assertEmpty($subscriptions, 'Subscription should be deleted');
// Setup import source from exported data
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')
->with('calendar')
->willReturn(2);
$importSource->method('pathExists')
->willReturnCallback(function (string $path) use ($exportedFiles) {
return isset($exportedFiles[$path]);
});
$importSource->method('getFileContents')
->willReturnCallback(function (string $path) use ($exportedFiles) {
if (isset($exportedFiles[$path])) {
return $exportedFiles[$path];
}
// Return empty for missing files
if ($path === 'dav/calendars/calendars.json') {
return json_encode([]);
}
throw new \Exception("File not found: $path");
});
// Import
$this->migrator->import($user, $importSource, $this->output);
// Verify subscription was recreated with migrated prefix
$subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
$this->assertCount(1, $subscriptions, 'Expected one subscription after import');
$subscription = $subscriptions[0];
$this->assertEquals('migrated-roundtrip-subscription', $subscription['uri']);
$this->assertEquals('Round Trip Subscription', $subscription['{DAV:}displayname']);
$this->assertEquals('#0000ff', $subscription['{http://apple.com/ns/ical/}calendar-color']);
} finally {
$this->deleteUser($user);
}
}
}