feat: create example event when a user logs in for the first time

Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
pull/53311/head
Richard Steinmetz 2025-06-03 13:45:43 +07:00
parent 10852e01be
commit 4a6909ffef
No known key found for this signature in database
GPG Key ID: 27137D9E7D273FB2
23 changed files with 971 additions and 41 deletions

@ -278,6 +278,7 @@ return array(
'OCA\\DAV\\Events\\SubscriptionCreatedEvent' => $baseDir . '/../lib/Events/SubscriptionCreatedEvent.php',
'OCA\\DAV\\Events\\SubscriptionDeletedEvent' => $baseDir . '/../lib/Events/SubscriptionDeletedEvent.php',
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => $baseDir . '/../lib/Events/SubscriptionUpdatedEvent.php',
'OCA\\DAV\\Exception\\ExampleEventException' => $baseDir . '/../lib/Exception/ExampleEventException.php',
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => $baseDir . '/../lib/Exception/ServerMaintenanceMode.php',
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => $baseDir . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
'OCA\\DAV\\Files\\BrowserErrorPagePlugin' => $baseDir . '/../lib/Files/BrowserErrorPagePlugin.php',
@ -349,6 +350,7 @@ return array(
'OCA\\DAV\\Migration\\Version1029Date20231004091403' => $baseDir . '/../lib/Migration/Version1029Date20231004091403.php',
'OCA\\DAV\\Migration\\Version1030Date20240205103243' => $baseDir . '/../lib/Migration/Version1030Date20240205103243.php',
'OCA\\DAV\\Migration\\Version1031Date20240610134258' => $baseDir . '/../lib/Migration/Version1031Date20240610134258.php',
'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php',
'OCA\\DAV\\Paginate\\PaginatePlugin' => $baseDir . '/../lib/Paginate/PaginatePlugin.php',
@ -365,6 +367,7 @@ return array(
'OCA\\DAV\\ServerFactory' => $baseDir . '/../lib/ServerFactory.php',
'OCA\\DAV\\Service\\AbsenceService' => $baseDir . '/../lib/Service/AbsenceService.php',
'OCA\\DAV\\Service\\DefaultContactService' => $baseDir . '/../lib/Service/DefaultContactService.php',
'OCA\\DAV\\Service\\ExampleEventService' => $baseDir . '/../lib/Service/ExampleEventService.php',
'OCA\\DAV\\Settings\\Admin\\SystemAddressBookSettings' => $baseDir . '/../lib/Settings/Admin/SystemAddressBookSettings.php',
'OCA\\DAV\\Settings\\AvailabilitySettings' => $baseDir . '/../lib/Settings/AvailabilitySettings.php',
'OCA\\DAV\\Settings\\CalDAVSettings' => $baseDir . '/../lib/Settings/CalDAVSettings.php',

@ -293,6 +293,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Events\\SubscriptionCreatedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionCreatedEvent.php',
'OCA\\DAV\\Events\\SubscriptionDeletedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionDeletedEvent.php',
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionUpdatedEvent.php',
'OCA\\DAV\\Exception\\ExampleEventException' => __DIR__ . '/..' . '/../lib/Exception/ExampleEventException.php',
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => __DIR__ . '/..' . '/../lib/Exception/ServerMaintenanceMode.php',
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => __DIR__ . '/..' . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
'OCA\\DAV\\Files\\BrowserErrorPagePlugin' => __DIR__ . '/..' . '/../lib/Files/BrowserErrorPagePlugin.php',
@ -364,6 +365,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Migration\\Version1029Date20231004091403' => __DIR__ . '/..' . '/../lib/Migration/Version1029Date20231004091403.php',
'OCA\\DAV\\Migration\\Version1030Date20240205103243' => __DIR__ . '/..' . '/../lib/Migration/Version1030Date20240205103243.php',
'OCA\\DAV\\Migration\\Version1031Date20240610134258' => __DIR__ . '/..' . '/../lib/Migration/Version1031Date20240610134258.php',
'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php',
'OCA\\DAV\\Paginate\\PaginatePlugin' => __DIR__ . '/..' . '/../lib/Paginate/PaginatePlugin.php',
@ -380,6 +382,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\ServerFactory' => __DIR__ . '/..' . '/../lib/ServerFactory.php',
'OCA\\DAV\\Service\\AbsenceService' => __DIR__ . '/..' . '/../lib/Service/AbsenceService.php',
'OCA\\DAV\\Service\\DefaultContactService' => __DIR__ . '/..' . '/../lib/Service/DefaultContactService.php',
'OCA\\DAV\\Service\\ExampleEventService' => __DIR__ . '/..' . '/../lib/Service/ExampleEventService.php',
'OCA\\DAV\\Settings\\Admin\\SystemAddressBookSettings' => __DIR__ . '/..' . '/../lib/Settings/Admin/SystemAddressBookSettings.php',
'OCA\\DAV\\Settings\\AvailabilitySettings' => __DIR__ . '/..' . '/../lib/Settings/AvailabilitySettings.php',
'OCA\\DAV\\Settings\\CalDAVSettings' => __DIR__ . '/..' . '/../lib/Settings/CalDAVSettings.php',

@ -10,9 +10,13 @@ declare(strict_types=1);
namespace OCA\DAV\Controller;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\Service\ExampleEventService;
use OCP\App\IAppManager;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
@ -23,12 +27,14 @@ use Psr\Log\LoggerInterface;
class ExampleContentController extends ApiController {
private IAppData $appData;
public function __construct(
IRequest $request,
IAppManager $appManager,
private IConfig $config,
private IAppDataFactory $appDataFactory,
private IAppManager $appManager,
private LoggerInterface $logger,
private ExampleEventService $exampleEventService,
) {
parent::__construct(Application::APP_ID, $request);
$this->appData = $this->appDataFactory->get('dav');
@ -83,4 +89,37 @@ class ExampleContentController extends ApiController {
return $folder->fileExists('defaultContact.vcf');
}
#[FrontpageRoute(verb: 'POST', url: '/api/exampleEvent/enable')]
public function setCreateExampleEvent(bool $enable): JSONResponse {
$this->exampleEventService->setCreateExampleEvent($enable);
return new JsonResponse([]);
}
#[FrontpageRoute(verb: 'GET', url: '/api/exampleEvent/event')]
#[NoCSRFRequired]
public function downloadExampleEvent(): DataDownloadResponse {
$exampleEvent = $this->exampleEventService->getExampleEvent();
return new DataDownloadResponse(
$exampleEvent->getIcs(),
'example_event.ics',
'text/calendar',
);
}
#[FrontpageRoute(verb: 'POST', url: '/api/exampleEvent/event')]
public function uploadExampleEvent(string $ics): JSONResponse {
if (!$this->exampleEventService->shouldCreateExampleEvent()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$this->exampleEventService->saveCustomExampleEvent($ics);
return new JsonResponse([]);
}
#[FrontpageRoute(verb: 'DELETE', url: '/api/exampleEvent/event')]
public function deleteExampleEvent(): JSONResponse {
$this->exampleEventService->deleteCustomExampleEvent();
return new JsonResponse([]);
}
}

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Exception;
class ExampleEventException extends \Exception {
}

@ -13,13 +13,13 @@ use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CardDAV\CardDavBackend;
use OCA\DAV\CardDAV\SyncService;
use OCA\DAV\Service\DefaultContactService;
use OCA\DAV\Service\ExampleEventService;
use OCP\Accounts\UserUpdatedEvent;
use OCP\Defaults;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Server;
use OCP\User\Events\BeforeUserDeletedEvent;
use OCP\User\Events\BeforeUserIdUnassignedEvent;
use OCP\User\Events\UserChangedEvent;
@ -47,6 +47,8 @@ class UserEventsListener implements IEventListener {
private CardDavBackend $cardDav,
private Defaults $themingDefaults,
private DefaultContactService $defaultContactService,
private ExampleEventService $exampleEventService,
private LoggerInterface $logger,
) {
}
@ -137,17 +139,31 @@ class UserEventsListener implements IEventListener {
public function firstLogin(IUser $user): void {
$principal = 'principals/users/' . $user->getUID();
$calendarId = null;
if ($this->calDav->getCalendarsForUserCount($principal) === 0) {
try {
$this->calDav->createCalendar($principal, CalDavBackend::PERSONAL_CALENDAR_URI, [
$calendarId = $this->calDav->createCalendar($principal, CalDavBackend::PERSONAL_CALENDAR_URI, [
'{DAV:}displayname' => CalDavBackend::PERSONAL_CALENDAR_NAME,
'{http://apple.com/ns/ical/}calendar-color' => $this->themingDefaults->getColorPrimary(),
'components' => 'VEVENT'
]);
} catch (\Exception $e) {
Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]);
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}
if ($calendarId !== null) {
try {
$this->exampleEventService->createExampleEvent($calendarId);
} catch (\Exception $e) {
$this->logger->error('Failed to create example event: ' . $e->getMessage(), [
'exception' => $e,
'userId' => $user->getUID(),
'calendarId' => $calendarId,
]);
}
}
$addressBookId = null;
if ($this->cardDav->getAddressBooksForUserCount($principal) === 0) {
try {
@ -155,7 +171,7 @@ class UserEventsListener implements IEventListener {
'{DAV:}displayname' => CardDavBackend::PERSONAL_ADDRESSBOOK_NAME,
]);
} catch (\Exception $e) {
Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]);
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}
if ($addressBookId) {

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Model;
use Sabre\VObject\Component\VCalendar;
/**
* Simple DTO to store a parsed example event and its UID.
*/
final class ExampleEvent {
public function __construct(
private readonly VCalendar $vCalendar,
private readonly string $uid,
) {
}
public function getUid(): string {
return $this->uid;
}
public function getIcs(): string {
return $this->vCalendar->serialize();
}
}

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Service;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\Exception\ExampleEventException;
use OCA\DAV\Model\ExampleEvent;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\Security\ISecureRandom;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
class ExampleEventService {
private const FOLDER_NAME = 'example_event';
private const FILE_NAME = 'example_event.ics';
private const ENABLE_CONFIG_KEY = 'create_example_event';
public function __construct(
private readonly CalDavBackend $calDavBackend,
private readonly ISecureRandom $random,
private readonly ITimeFactory $time,
private readonly IAppData $appData,
private readonly IAppConfig $appConfig,
private readonly IL10N $l10n,
) {
}
public function createExampleEvent(int $calendarId): void {
if (!$this->shouldCreateExampleEvent()) {
return;
}
$exampleEvent = $this->getExampleEvent();
$uid = $exampleEvent->getUid();
$this->calDavBackend->createCalendarObject(
$calendarId,
"$uid.ics",
$exampleEvent->getIcs(),
);
}
private function getStartDate(): \DateTimeInterface {
return $this->time->now()
->add(new \DateInterval('P7D'))
->setTime(10, 00);
}
private function getEndDate(): \DateTimeInterface {
return $this->time->now()
->add(new \DateInterval('P7D'))
->setTime(11, 00);
}
private function getDefaultEvent(string $uid): VCalendar {
$defaultDescription = $this->l10n->t(<<<EOF
Welcome to Nextcloud Calendar!
This is a sample event - explore the flexibility of planning with Nextcloud Calendar by making any edits you want!
With Nextcloud Calendar, you can:
- Create, edit, and manage events effortlessly.
- Create multiple calendars and share them with teammates, friends, or family.
- Check availability and display your busy times to others.
- Seamlessly integrate with apps and devices via CalDAV.
- Customize your experience: schedule recurring events, adjust notifications and other settings.
EOF);
$vCalendar = new VCalendar();
$props = [
'UID' => $uid,
'DTSTAMP' => $this->time->now(),
'SUMMARY' => $this->l10n->t('Example event - open me!'),
'DTSTART' => $this->getStartDate(),
'DTEND' => $this->getEndDate(),
'DESCRIPTION' => $defaultDescription,
];
$vCalendar->add('VEVENT', $props);
return $vCalendar;
}
/**
* @return string|null The ics of the custom example event or null if no custom event was uploaded.
* @throws ExampleEventException If reading the custom ics file fails.
*/
private function getCustomExampleEvent(): ?string {
try {
$folder = $this->appData->getFolder(self::FOLDER_NAME);
$icsFile = $folder->getFile(self::FILE_NAME);
} catch (NotFoundException $e) {
return null;
}
try {
return $icsFile->getContent();
} catch (NotFoundException|NotPermittedException $e) {
throw new ExampleEventException(
'Failed to read custom example event',
0,
$e,
);
}
}
/**
* Get the configured example event or the default one.
*
* @throws ExampleEventException If loading the custom example event fails.
*/
public function getExampleEvent(): ExampleEvent {
$uid = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC);
$customIcs = $this->getCustomExampleEvent();
if ($customIcs === null) {
return new ExampleEvent($this->getDefaultEvent($uid), $uid);
}
[$vCalendar, $vEvent] = $this->parseEvent($customIcs);
$vEvent->UID = $uid;
$vEvent->DTSTART = $this->getStartDate();
$vEvent->DTEND = $this->getEndDate();
$vEvent->remove('ORGANIZER');
$vEvent->remove('ATTENDEE');
return new ExampleEvent($vCalendar, $uid);
}
/**
* @psalm-return list{VCalendar, VEvent} The VCALENDAR document and its VEVENT child component
* @throws ExampleEventException If parsing the event fails or if it is invalid.
*/
private function parseEvent(string $ics): array {
try {
$vCalendar = \Sabre\VObject\Reader::read($ics);
if (!($vCalendar instanceof VCalendar)) {
throw new ExampleEventException('Custom event does not contain a VCALENDAR component');
}
/** @var VEvent|null $vEvent */
$vEvent = $vCalendar->getBaseComponent('VEVENT');
if ($vEvent === null) {
throw new ExampleEventException('Custom event does not contain a VEVENT component');
}
} catch (\Exception $e) {
throw new ExampleEventException('Failed to parse custom event: ' . $e->getMessage(), 0, $e);
}
return [$vCalendar, $vEvent];
}
public function saveCustomExampleEvent(string $ics): void {
// Parse and validate the event before attempting to save it to prevent run time errors
$this->parseEvent($ics);
try {
$folder = $this->appData->getFolder(self::FOLDER_NAME);
} catch (NotFoundException $e) {
$folder = $this->appData->newFolder(self::FOLDER_NAME);
}
try {
$existingFile = $folder->getFile(self::FILE_NAME);
$existingFile->putContent($ics);
} catch (NotFoundException $e) {
$folder->newFile(self::FILE_NAME, $ics);
}
}
public function deleteCustomExampleEvent(): void {
try {
$folder = $this->appData->getFolder(self::FOLDER_NAME);
$file = $folder->getFile(self::FILE_NAME);
} catch (NotFoundException $e) {
return;
}
$file->delete();
}
public function hasCustomExampleEvent(): bool {
try {
return $this->getCustomExampleEvent() !== null;
} catch (ExampleEventException $e) {
return false;
}
}
public function setCreateExampleEvent(bool $enable): void {
$this->appConfig->setValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, $enable);
}
public function shouldCreateExampleEvent(): bool {
return $this->appConfig->getValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, true);
}
}

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace OCA\DAV\Settings;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\Service\ExampleEventService;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
@ -16,21 +17,40 @@ use OCP\IConfig;
use OCP\Settings\ISettings;
class ExampleContentSettings implements ISettings {
public function __construct(
private IConfig $config,
private IInitialState $initialState,
private IAppManager $appManager,
private readonly IConfig $config,
private readonly IInitialState $initialState,
private readonly IAppManager $appManager,
private readonly ExampleEventService $exampleEventService,
) {
}
public function getForm(): TemplateResponse {
$enableDefaultContact = $this->config->getAppValue(Application::APP_ID, 'enableDefaultContact', 'no');
$this->initialState->provideInitialState('enableDefaultContact', $enableDefaultContact);
$calendarEnabled = $this->appManager->isEnabledForUser('calendar');
$contactsEnabled = $this->appManager->isEnabledForUser('contacts');
$this->initialState->provideInitialState('calendarEnabled', $calendarEnabled);
$this->initialState->provideInitialState('contactsEnabled', $contactsEnabled);
if ($calendarEnabled) {
$enableDefaultEvent = $this->exampleEventService->shouldCreateExampleEvent();
$this->initialState->provideInitialState('create_example_event', $enableDefaultEvent);
$this->initialState->provideInitialState(
'has_custom_example_event',
$this->exampleEventService->hasCustomExampleEvent(),
);
}
if ($contactsEnabled) {
$enableDefaultContact = $this->config->getAppValue(Application::APP_ID, 'enableDefaultContact', 'no');
$this->initialState->provideInitialState('enableDefaultContact', $enableDefaultContact);
}
return new TemplateResponse(Application::APP_ID, 'settings-example-content');
}
public function getSection(): ?string {
if (!$this->appManager->isEnabledForUser('contacts')) {
if (!$this->appManager->isEnabledForUser('contacts')
&& !$this->appManager->isEnabledForUser('calendar')) {
return null;
}
@ -40,5 +60,4 @@ class ExampleContentSettings implements ISettings {
public function getPriority(): int {
return 10;
}
}

@ -4,20 +4,16 @@
-->
<template>
<NcSettingsSection id="exmaple-content"
:name="$t('dav', 'Example Content')"
class="example-content-setting"
:description="$t('dav', 'Set example content to be created on new user first login.')">
<div class="example-content-setting__contacts">
<input id="enable-default-contact"
v-model="enableDefaultContact"
type="checkbox"
class="checkbox"
@change="updateEnableDefaultContact">
<label for="enable-default-contact"> {{ $t('dav',"Default contact is added to the user's own address book on user's first login.") }} </label>
<div v-if="enableDefaultContact" class="example-content-setting__contacts__buttons">
<div class="example-contact-settings">
<div class="example-content-setting__form">
<NcCheckboxRadioSwitch :checked="enableDefaultContact"
type="switch"
@update:model-value="updateEnableDefaultContact">
{{ $t('dav',"Default contact is added to the user's own address book on user's first login.") }}
</NcCheckboxRadioSwitch>
<div v-if="enableDefaultContact" class="example-contact-settings__form__buttons">
<NcButton type="primary"
class="example-content-setting__contacts__buttons__button"
class="example-contact-settings__form__buttons__button"
@click="toggleModal">
<template #icon>
<IconUpload :size="20" />
@ -25,7 +21,7 @@
{{ $t('dav', 'Import contact') }}
</NcButton>
<NcButton type="secondary"
class="example-content-setting__contacts__buttons__button"
class="example-contact-settings__form__buttons__button"
@click="resetContact">
<template #icon>
<IconRestore :size="20" />
@ -48,18 +44,19 @@
accept=".vcf"
class="hidden-visually"
@change="processFile">
</NcSettingsSection>
</div>
</template>
<script>
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { NcDialog, NcButton, NcSettingsSection } from '@nextcloud/vue'
import { NcDialog, NcButton, NcCheckboxRadioSwitch } from '@nextcloud/vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
import IconUpload from 'vue-material-design-icons/Upload.vue'
import IconRestore from 'vue-material-design-icons/Restore.vue'
import IconCancel from '@mdi/svg/svg/cancel.svg?raw'
import IconCheck from '@mdi/svg/svg/check.svg?raw'
import logger from '../service/logger.js'
const enableDefaultContact = loadState('dav', 'enableDefaultContact') === 'yes'
@ -68,7 +65,7 @@ export default {
components: {
NcDialog,
NcButton,
NcSettingsSection,
NcCheckboxRadioSwitch,
IconUpload,
IconRestore,
},
@ -95,9 +92,10 @@ export default {
methods: {
updateEnableDefaultContact() {
axios.put(generateUrl('apps/dav/api/defaultcontact/config'), {
allow: this.enableDefaultContact ? 'yes' : 'no',
}).catch(() => {
allow: this.enableDefaultContact ? 'no' : 'yes',
}).then(() => {
this.enableDefaultContact = !this.enableDefaultContact
}).catch(() => {
showError(this.$t('dav', 'Error while saving settings'))
})
},
@ -114,7 +112,7 @@ export default {
showSuccess(this.$t('dav', 'Contact reset successfully'))
})
.catch((error) => {
console.error('Error importing contact:', error)
logger.error('Error importing contact:', { error })
showError(this.$t('dav', 'Error while resetting contact'))
})
.finally(() => {
@ -133,7 +131,7 @@ export default {
await axios.put(generateUrl('/apps/dav/api/defaultcontact/contact'), { contactData: reader.result })
showSuccess(this.$t('dav', 'Contact imported successfully'))
} catch (error) {
console.error('Error importing contact:', error)
logger.error('Error importing contact:', { error })
showError(this.$t('dav', 'Error while importing contact'))
} finally {
this.loading = false
@ -146,11 +144,14 @@ export default {
}
</script>
<style lang="scss" scoped>
.example-content-setting{
&__contacts{
.example-contact-settings {
margin-block-start: 2rem;
&__form{
&__buttons{
margin-top: 1rem;
display: flex;
&__button{
margin-inline-end: 5px;
}

@ -0,0 +1,214 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="example-event-settings">
<NcCheckboxRadioSwitch :checked="createExampleEvent"
:disabled="savingConfig"
type="switch"
@update:model-value="updateCreateExampleEvent">
{{ t('dav', "Add example event to user's calendar when they first log in") }}
</NcCheckboxRadioSwitch>
<div v-if="createExampleEvent"
class="example-event-settings__buttons">
<NcButton type="tertiary"
:href="exampleEventDownloadUrl">
<template #icon>
<IconCalendarBlank :size="20" />
</template>
<span class="example-event-settings__buttons__download-link">
example_event.ics
<IconDownload :size="20" />
</span>
</NcButton>
<NcButton type="secondary"
@click="showImportModal = true">
<template #icon>
<IconUpload :size="20" />
</template>
{{ t('dav', 'Import calendar event') }}
</NcButton>
<NcButton v-if="hasCustomEvent"
type="tertiary"
:disabled="deleting"
@click="deleteCustomEvent">
<template #icon>
<IconRestore :size="20" />
</template>
{{ t('dav', 'Restore default event') }}
</NcButton>
</div>
<NcDialog :open.sync="showImportModal"
:name="t('dav', 'Import calendar event')">
<div class="import-event-modal">
<p>
{{ t('dav', 'Uploading a new event will overwrite the existing one.') }}
</p>
<input ref="event-file"
:disabled="uploading"
type="file"
accept=".ics,text/calendar"
class="import-event-modal__file-picker"
@change="selectFile" />
<div class="import-event-modal__buttons">
<NcButton :disabled="uploading || !selectedFile"
type="primary"
@click="uploadCustomEvent()">
<template #icon>
<IconUpload :size="20" />
</template>
{{ t('dav', 'Upload event') }}
</NcButton>
</div>
</div>
</NcDialog>
</div>
</template>
<script>
import { NcButton, NcCheckboxRadioSwitch, NcDialog } from '@nextcloud/vue'
import { loadState } from '@nextcloud/initial-state'
import IconDownload from 'vue-material-design-icons/Download.vue'
import IconCalendarBlank from 'vue-material-design-icons/CalendarBlank.vue'
import IconUpload from 'vue-material-design-icons/Upload.vue'
import IconRestore from 'vue-material-design-icons/Restore.vue'
import * as ExampleEventService from '../service/ExampleEventService.js'
import { showError, showSuccess } from '@nextcloud/dialogs'
import logger from '../service/logger.js'
import { generateUrl } from '@nextcloud/router'
export default {
name: 'ExampleEventSettings',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcDialog,
IconDownload,
IconCalendarBlank,
IconUpload,
IconRestore,
},
data() {
return {
createExampleEvent: loadState('dav', 'create_example_event', false),
hasCustomEvent: loadState('dav', 'has_custom_example_event', false),
showImportModal: false,
uploading: false,
deleting: false,
savingConfig: false,
selectedFile: undefined,
}
},
computed: {
exampleEventDownloadUrl() {
return generateUrl('/apps/dav/api/exampleEvent/event')
},
},
methods: {
selectFile() {
this.selectedFile = this.$refs['event-file']?.files[0]
},
async updateCreateExampleEvent() {
this.savingConfig = true
const enable = !this.createExampleEvent
try {
await ExampleEventService.setCreateExampleEvent(enable)
} catch (error) {
showError(t('dav', 'Failed to save example event creation setting'))
logger.error('Failed to save example event creation setting', {
error,
enable,
})
} finally {
this.savingConfig = false
}
this.createExampleEvent = enable
},
uploadCustomEvent() {
if (!this.selectedFile) {
return
}
this.uploading = true
const reader = new FileReader()
reader.addEventListener('load', async () => {
const ics = reader.result
try {
await ExampleEventService.uploadExampleEvent(ics)
} catch (error) {
showError(t('dav', 'Failed to upload the example event'))
logger.error('Failed to upload example ICS', {
error,
ics,
})
return
} finally {
this.uploading = false
}
showSuccess(t('dav', 'Custom example event was saved successfully'))
this.showImportModal = false
this.hasCustomEvent = true
})
reader.readAsText(this.selectedFile)
},
async deleteCustomEvent() {
this.deleting = true
try {
await ExampleEventService.deleteExampleEvent()
} catch (error) {
showError(t('dav', 'Failed to delete the custom example event'))
logger.error('Failed to delete the custom example event', {
error,
})
return
} finally {
this.deleting = false
}
showSuccess(t('dav', 'Custom example event was deleted successfully'))
this.hasCustomEvent = false
},
},
}
</script>
<style lang="scss" scoped>
.example-event-settings {
margin-block: 2rem;
&__buttons {
display: flex;
gap: calc(var(--default-grid-baseline) * 2);
margin-top: calc(var(--default-grid-baseline) * 2);
&__download-link {
display: flex;
text-decoration: underline;
}
}
}
.import-event-modal {
display: flex;
flex-direction: column;
gap: calc(var(--default-grid-baseline) * 2);
padding: calc(var(--default-grid-baseline) * 2);
&__file-picker {
width: 100%;
}
&__buttons {
display: flex;
justify-content: flex-end;
}
}
</style>

@ -0,0 +1,43 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
/**
* Configure the creation of example events on a user's first login.
*
* @param {boolean} enable Whether to enable or disable the feature.
* @return {Promise<void>}
*/
export async function setCreateExampleEvent(enable) {
const url = generateUrl('/apps/dav/api/exampleEvent/enable')
await axios.post(url, {
enable,
})
}
/**
* Upload a custom example event.
*
* @param {string} ics The ICS data of the event.
* @return {Promise<void>}
*/
export async function uploadExampleEvent(ics) {
const url = generateUrl('/apps/dav/api/exampleEvent/event')
await axios.post(url, {
ics,
})
}
/**
* Delete a previously uploaded custom example event.
*
* @return {Promise<void>}
*/
export async function deleteExampleEvent() {
const url = generateUrl('/apps/dav/api/exampleEvent/event')
await axios.delete(url)
}

@ -4,10 +4,15 @@
*/
import Vue from 'vue'
import { translate } from '@nextcloud/l10n'
import ExampleContactSettings from './views/ExampleContactSettings.vue'
import ExampleContentSettingsSection from './views/ExampleContentSettingsSection.vue'
Vue.prototype.$t = translate
Vue.mixin({
methods: {
t: translate,
$t: translate,
}
})
const View = Vue.extend(ExampleContactSettings);
const View = Vue.extend(ExampleContentSettingsSection);
(new View({})).$mount('#settings-example-content')

@ -0,0 +1,38 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSettingsSection id="example-content"
:name="$t('dav', 'Example content')"
class="example-content-setting"
:description="$t('dav', 'Example content serves to showcase the features of Nextcloud. Default content is shipped with Nextcloud, and can be replaced by custom content.')">
<ExampleContactSettings v-if="hasContactsApp" />
<ExampleEventSettings v-if="hasCalendarApp" />
</NcSettingsSection>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { NcSettingsSection } from '@nextcloud/vue'
import ExampleEventSettings from '../components/ExampleEventSettings.vue'
import ExampleContactSettings from '../components/ExampleContactSettings.vue'
export default {
name: 'ExampleContentSettingsSection',
components: {
NcSettingsSection,
ExampleContactSettings,
ExampleEventSettings,
},
computed: {
hasContactsApp() {
return loadState('dav', 'contactsEnabled')
},
hasCalendarApp() {
return loadState('dav', 'calendarEnabled')
},
}
}
</script>

@ -15,10 +15,12 @@ use OCA\DAV\CardDAV\CardDavBackend;
use OCA\DAV\CardDAV\SyncService;
use OCA\DAV\Listener\UserEventsListener;
use OCA\DAV\Service\DefaultContactService;
use OCA\DAV\Service\ExampleEventService;
use OCP\Defaults;
use OCP\IUser;
use OCP\IUserManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class UserEventsListenerTest extends TestCase {
@ -27,19 +29,24 @@ class UserEventsListenerTest extends TestCase {
private CalDavBackend&MockObject $calDavBackend;
private CardDavBackend&MockObject $cardDavBackend;
private Defaults&MockObject $defaults;
private DefaultContactService&MockObject $defaultContactService;
private ExampleEventService&MockObject $exampleEventService;
private LoggerInterface&MockObject $logger;
private UserEventsListener $userEventsListener;
protected function setUp(): void {
parent::setUp();
$this->userManager = $this->createMock(IUserManager::class);
$this->syncService = $this->createMock(SyncService::class);
$this->calDavBackend = $this->createMock(CalDavBackend::class);
$this->cardDavBackend = $this->createMock(CardDavBackend::class);
$this->defaults = $this->createMock(Defaults::class);
$this->defaultContactService = $this->createMock(DefaultContactService::class);
$this->exampleEventService = $this->createMock(ExampleEventService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->userEventsListener = new UserEventsListener(
$this->userManager,
$this->syncService,
@ -47,6 +54,8 @@ class UserEventsListenerTest extends TestCase {
$this->cardDavBackend,
$this->defaults,
$this->defaultContactService,
$this->exampleEventService,
$this->logger,
);
}
@ -63,7 +72,13 @@ class UserEventsListenerTest extends TestCase {
'{DAV:}displayname' => 'Personal',
'{http://apple.com/ns/ical/}calendar-color' => '#745bca',
'components' => 'VEVENT'
]);
])
->willReturn(1000);
$this->calDavBackend->expects(self::never())
->method('getCalendarsForUser');
$this->exampleEventService->expects(self::once())
->method('createExampleEvent')
->with(1000);
$this->cardDavBackend->expects($this->once())->method('getAddressBooksForUserCount')->willReturn(0);
$this->cardDavBackend->expects($this->once())->method('createAddressBook')->with(
@ -79,6 +94,10 @@ class UserEventsListenerTest extends TestCase {
$this->calDavBackend->expects($this->once())->method('getCalendarsForUserCount')->willReturn(1);
$this->calDavBackend->expects($this->never())->method('createCalendar');
$this->calDavBackend->expects(self::never())
->method('createCalendar');
$this->exampleEventService->expects(self::never())
->method('createExampleEvent');
$this->cardDavBackend->expects($this->once())->method('getAddressBooksForUserCount')->willReturn(1);
$this->cardDavBackend->expects($this->never())->method('createAddressBook');

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\Service;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\Service\ExampleEventService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\Security\ISecureRandom;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class ExampleEventServiceTest extends TestCase {
private ExampleEventService $service;
private CalDavBackend&MockObject $calDavBackend;
private ISecureRandom&MockObject $random;
private ITimeFactory&MockObject $time;
private IAppData&MockObject $appData;
private IAppConfig&MockObject $appConfig;
private IL10N&MockObject $l10n;
protected function setUp(): void {
parent::setUp();
$this->calDavBackend = $this->createMock(CalDavBackend::class);
$this->random = $this->createMock(ISecureRandom::class);
$this->time = $this->createMock(ITimeFactory::class);
$this->appData = $this->createMock(IAppData::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->l10n = $this->createMock(IL10N::class);
$this->l10n->method('t')
->willReturnArgument(0);
$this->service = new ExampleEventService(
$this->calDavBackend,
$this->random,
$this->time,
$this->appData,
$this->appConfig,
$this->l10n,
);
}
public static function provideCustomEventData(): array {
return [
[file_get_contents(__DIR__ . '/../test_fixtures/example-event.ics')],
[file_get_contents(__DIR__ . '/../test_fixtures/example-event-with-attendees.ics')],
];
}
/** @dataProvider provideCustomEventData */
public function testCreateExampleEventWithCustomEvent($customEventIcs): void {
$this->appConfig->expects(self::once())
->method('getValueBool')
->with('dav', 'create_example_event', true)
->willReturn(true);
$exampleEventFolder = $this->createMock(ISimpleFolder::class);
$this->appData->expects(self::once())
->method('getFolder')
->with('example_event')
->willReturn($exampleEventFolder);
$exampleEventFile = $this->createMock(ISimpleFile::class);
$exampleEventFolder->expects(self::once())
->method('getFile')
->with('example_event.ics')
->willReturn($exampleEventFile);
$exampleEventFile->expects(self::once())
->method('getContent')
->willReturn($customEventIcs);
$this->random->expects(self::once())
->method('generate')
->with(32, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')
->willReturn('RANDOM-UID');
$now = new \DateTimeImmutable('2025-01-21T00:00:00Z');
$this->time->expects(self::exactly(2))
->method('now')
->willReturn($now);
$expectedIcs = file_get_contents(__DIR__ . '/../test_fixtures/example-event-expected.ics');
$this->calDavBackend->expects(self::once())
->method('createCalendarObject')
->with(1000, 'RANDOM-UID.ics', $expectedIcs);
$this->service->createExampleEvent(1000);
}
public function testCreateExampleEventWithDefaultEvent(): void {
$this->appConfig->expects(self::once())
->method('getValueBool')
->with('dav', 'create_example_event', true)
->willReturn(true);
$this->appData->expects(self::once())
->method('getFolder')
->with('example_event')
->willThrowException(new NotFoundException());
$this->random->expects(self::once())
->method('generate')
->with(32, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')
->willReturn('RANDOM-UID');
$now = new \DateTimeImmutable('2025-01-21T00:00:00Z');
$this->time->expects(self::exactly(3))
->method('now')
->willReturn($now);
$expectedIcs = file_get_contents(__DIR__ . '/../test_fixtures/example-event-default-expected.ics');
$this->calDavBackend->expects(self::once())
->method('createCalendarObject')
->with(1000, 'RANDOM-UID.ics', $expectedIcs);
$this->service->createExampleEvent(1000);
}
public function testCreateExampleWhenDisabled(): void {
$this->appConfig->expects(self::once())
->method('getValueBool')
->with('dav', 'create_example_event', true)
->willReturn(false);
$this->calDavBackend->expects(self::never())
->method('createCalendarObject');
$this->service->createExampleEvent(1000);
}
/** @dataProvider provideCustomEventData */
public function testGetExampleEventWithCustomEvent($customEventIcs): void {
$exampleEventFolder = $this->createMock(ISimpleFolder::class);
$this->appData->expects(self::once())
->method('getFolder')
->with('example_event')
->willReturn($exampleEventFolder);
$exampleEventFile = $this->createMock(ISimpleFile::class);
$exampleEventFolder->expects(self::once())
->method('getFile')
->with('example_event.ics')
->willReturn($exampleEventFile);
$exampleEventFile->expects(self::once())
->method('getContent')
->willReturn($customEventIcs);
$this->random->expects(self::once())
->method('generate')
->with(32, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')
->willReturn('RANDOM-UID');
$now = new \DateTimeImmutable('2025-01-21T00:00:00Z');
$this->time->expects(self::exactly(2))
->method('now')
->willReturn($now);
$expectedIcs = file_get_contents(__DIR__ . '/../test_fixtures/example-event-expected.ics');
$actualIcs = $this->service->getExampleEvent()->getIcs();
$this->assertEquals($expectedIcs, $actualIcs);
}
public function testGetExampleEventWithDefault(): void {
$this->appData->expects(self::once())
->method('getFolder')
->with('example_event')
->willThrowException(new NotFoundException());
$this->random->expects(self::once())
->method('generate')
->with(32, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')
->willReturn('RANDOM-UID');
$now = new \DateTimeImmutable('2025-01-21T00:00:00Z');
$this->time->expects(self::exactly(3))
->method('now')
->willReturn($now);
$expectedIcs = file_get_contents(__DIR__ . '/../test_fixtures/example-event-default-expected.ics');
$actualIcs = $this->service->getExampleEvent()->getIcs();
$this->assertEquals($expectedIcs, $actualIcs);
}
}

@ -0,0 +1,20 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Sabre//Sabre VObject 4.5.6//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:RANDOM-UID
DTSTAMP:20250121T000000Z
SUMMARY:Example event - open me!
DTSTART:20250128T100000Z
DTEND:20250128T110000Z
DESCRIPTION:Welcome to Nextcloud Calendar!\n\nThis is a sample event - expl
ore the flexibility of planning with Nextcloud Calendar by making any edit
s you want!\n\nWith Nextcloud Calendar\, you can:\n- Create\, edit\, and m
anage events effortlessly.\n- Create multiple calendars and share them wit
h teammates\, friends\, or family.\n- Check availability and display your
busy times to others.\n- Seamlessly integrate with apps and devices via Ca
lDAV.\n- Customize your experience: schedule recurring events\, adjust not
ifications and other settings.
END:VEVENT
END:VCALENDAR

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later

@ -0,0 +1,18 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//IDN nextcloud.com//Calendar app 5.2.0-dev.1//EN
BEGIN:VEVENT
CREATED:20250128T091147Z
DTSTAMP:20250128T091507Z
LAST-MODIFIED:20250128T091507Z
SEQUENCE:2
STATUS:CONFIRMED
SUMMARY:Welcome!
DESCRIPTION:Welcome!!!
LOCATION:Test
UID:RANDOM-UID
DTSTART:20250128T100000Z
DTEND:20250128T110000Z
END:VEVENT
END:VCALENDAR

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later

@ -0,0 +1,21 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//IDN nextcloud.com//Calendar app 5.2.0-dev.1//EN
BEGIN:VEVENT
CREATED:20250128T091147Z
DTSTAMP:20250128T091507Z
LAST-MODIFIED:20250128T091507Z
SEQUENCE:2
UID:3b4df6a8-84df-43d5-baf9-377b43390b70
DTSTART;VALUE=DATE:20250130
DTEND;VALUE=DATE:20250131
STATUS:CONFIRMED
SUMMARY:Welcome!
DESCRIPTION:Welcome!!!
LOCATION:Test
ATTENDEE;CN=user a;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICI
PANT;RSVP=TRUE;LANGUAGE=en;SCHEDULE-STATUS=1.1:mailto:usera@imap.localhost
ORGANIZER;CN=Admin Account:mailto:admin@imap.localhost
END:VEVENT
END:VCALENDAR

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later

@ -0,0 +1,18 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//IDN nextcloud.com//Calendar app 5.2.0-dev.1//EN
BEGIN:VEVENT
CREATED:20250128T091147Z
DTSTAMP:20250128T091507Z
LAST-MODIFIED:20250128T091507Z
SEQUENCE:2
UID:3b4df6a8-84df-43d5-baf9-377b43390b70
STATUS:CONFIRMED
SUMMARY:Welcome!
DESCRIPTION:Welcome!!!
LOCATION:Test
DTSTART:20250204T100000Z
DTEND:20250204T110000Z
END:VEVENT
END:VCALENDAR

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later