mirror of
https://github.com/nextcloud/server.git
synced 2026-03-30 06:14:09 -04:00
Merge pull request #58907 from nextcloud/backport/57112/stable33
[stable33] feat: improve calendar migrator
This commit is contained in:
commit
7f6b708b09
3 changed files with 1059 additions and 488 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue