diff options
Diffstat (limited to 'apps/files_reminders/lib')
20 files changed, 1314 insertions, 0 deletions
diff --git a/apps/files_reminders/lib/AppInfo/Application.php b/apps/files_reminders/lib/AppInfo/Application.php new file mode 100644 index 00000000000..2776e9db0b1 --- /dev/null +++ b/apps/files_reminders/lib/AppInfo/Application.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\AppInfo; + +use OCA\DAV\Events\SabrePluginAddEvent; +use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\FilesReminders\Listener\LoadAdditionalScriptsListener; +use OCA\FilesReminders\Listener\NodeDeletedListener; +use OCA\FilesReminders\Listener\SabrePluginAddListener; +use OCA\FilesReminders\Listener\UserDeletedListener; +use OCA\FilesReminders\Notification\Notifier; +use OCA\FilesReminders\SetupChecks\NeedNotificationsApp; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Files\Events\Node\NodeDeletedEvent; +use OCP\User\Events\UserDeletedEvent; + +class Application extends App implements IBootstrap { + public const APP_ID = 'files_reminders'; + + public function __construct() { + parent::__construct(static::APP_ID); + } + + public function boot(IBootContext $context): void { + } + + public function register(IRegistrationContext $context): void { + $context->registerNotifierService(Notifier::class); + + $context->registerEventListener(SabrePluginAddEvent::class, SabrePluginAddListener::class); + + $context->registerEventListener(NodeDeletedEvent::class, NodeDeletedListener::class); + $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); + + $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalScriptsListener::class); + + $context->registerSetupCheck(NeedNotificationsApp::class); + } +} diff --git a/apps/files_reminders/lib/BackgroundJob/CleanUpReminders.php b/apps/files_reminders/lib/BackgroundJob/CleanUpReminders.php new file mode 100644 index 00000000000..35b72b190e8 --- /dev/null +++ b/apps/files_reminders/lib/BackgroundJob/CleanUpReminders.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\BackgroundJob; + +use OCA\FilesReminders\Service\ReminderService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; + +class CleanUpReminders extends TimedJob { + public function __construct( + ITimeFactory $time, + private ReminderService $reminderService, + ) { + parent::__construct($time); + + $this->setInterval(24 * 60 * 60); // 1 day + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function run($argument) { + $this->reminderService->cleanUp(500); + } +} diff --git a/apps/files_reminders/lib/BackgroundJob/ScheduledNotifications.php b/apps/files_reminders/lib/BackgroundJob/ScheduledNotifications.php new file mode 100644 index 00000000000..ab8c762d674 --- /dev/null +++ b/apps/files_reminders/lib/BackgroundJob/ScheduledNotifications.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\BackgroundJob; + +use OCA\FilesReminders\Db\ReminderMapper; +use OCA\FilesReminders\Service\ReminderService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; + +class ScheduledNotifications extends TimedJob { + public function __construct( + ITimeFactory $time, + protected ReminderMapper $reminderMapper, + protected ReminderService $reminderService, + protected LoggerInterface $logger, + ) { + parent::__construct($time); + + $this->setInterval(60); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function run($argument) { + $reminders = $this->reminderMapper->findOverdue(); + foreach ($reminders as $reminder) { + try { + $this->reminderService->send($reminder); + } catch (DoesNotExistException $e) { + $this->logger->debug('Could not send notification for reminder with id ' . $reminder->getId()); + } + } + } +} diff --git a/apps/files_reminders/lib/Command/ListCommand.php b/apps/files_reminders/lib/Command/ListCommand.php new file mode 100644 index 00000000000..118d00c45d3 --- /dev/null +++ b/apps/files_reminders/lib/Command/ListCommand.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Command; + +use DateTimeInterface; +use OC\Core\Command\Base; +use OCA\FilesReminders\Model\RichReminder; +use OCA\FilesReminders\Service\ReminderService; +use OCP\IUserManager; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class ListCommand extends Base { + public function __construct( + private ReminderService $reminderService, + private IUserManager $userManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('files:reminders') + ->setDescription('List file reminders') + ->addArgument( + 'user', + InputArgument::OPTIONAL, + 'list reminders for user', + ) + ->addOption( + 'output', + null, + InputOption::VALUE_OPTIONAL, + 'Output format (plain, json or json_pretty, default is plain)', + $this->defaultOutputFormat, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + + $uid = $input->getArgument('user'); + if ($uid !== null) { + /** @var string $uid */ + $user = $this->userManager->get($uid); + if ($user === null) { + $io->error("Unknown user <$uid>"); + return 1; + } + } + + $reminders = $this->reminderService->getAll($user ?? null); + + $outputOption = $input->getOption('output'); + switch ($outputOption) { + case static::OUTPUT_FORMAT_JSON: + case static::OUTPUT_FORMAT_JSON_PRETTY: + $this->writeArrayInOutputFormat( + $input, + $io, + array_map( + fn (RichReminder $reminder) => $reminder->jsonSerialize(), + $reminders, + ), + '', + ); + return 0; + default: + if (empty($reminders)) { + $io->text('No reminders'); + return 0; + } + + $io->table( + ['User Id', 'File Id', 'Path', 'Due Date', 'Updated At', 'Created At', 'Notified'], + array_map( + fn (RichReminder $reminder) => [ + $reminder->getUserId(), + $reminder->getFileId(), + $reminder->getNode()->getPath(), + $reminder->getDueDate()->format(DateTimeInterface::ATOM), // ISO 8601 + $reminder->getUpdatedAt()->format(DateTimeInterface::ATOM), // ISO 8601 + $reminder->getCreatedAt()->format(DateTimeInterface::ATOM), // ISO 8601 + $reminder->getNotified() ? 'true' : 'false', + ], + $reminders, + ), + ); + return 0; + } + } +} diff --git a/apps/files_reminders/lib/Controller/ApiController.php b/apps/files_reminders/lib/Controller/ApiController.php new file mode 100644 index 00000000000..c95a74a04f4 --- /dev/null +++ b/apps/files_reminders/lib/Controller/ApiController.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Controller; + +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use Exception; +use OCA\FilesReminders\Exception\NodeNotFoundException; +use OCA\FilesReminders\Exception\ReminderNotFoundException; +use OCA\FilesReminders\Service\ReminderService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +class ApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + protected ReminderService $reminderService, + protected IUserSession $userSession, + protected LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * Get a reminder + * + * @param int $fileId ID of the file + * @return DataResponse<Http::STATUS_OK, array{dueDate: ?string}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, list<empty>, array{}> + * + * 200: Reminder returned + * 401: Account not found + */ + #[NoAdminRequired] + public function get(int $fileId): DataResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + try { + $reminder = $this->reminderService->getDueForUser($user, $fileId); + if ($reminder === null) { + return new DataResponse(['dueDate' => null], Http::STATUS_OK); + } + return new DataResponse([ + 'dueDate' => $reminder->getDueDate()->format(DateTimeInterface::ATOM), // ISO 8601 + ], Http::STATUS_OK); + } catch (NodeNotFoundException $e) { + return new DataResponse(['dueDate' => null], Http::STATUS_OK); + } + } + + /** + * Set a reminder + * + * @param int $fileId ID of the file + * @param string $dueDate ISO 8601 formatted date time string + * + * @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Reminder updated + * 201: Reminder created successfully + * 400: Creating reminder is not possible + * 401: Account not found + * 404: File not found + */ + #[NoAdminRequired] + public function set(int $fileId, string $dueDate): DataResponse { + try { + $dueDate = (new DateTime($dueDate))->setTimezone(new DateTimeZone('UTC')); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + try { + $created = $this->reminderService->createOrUpdate($user, $fileId, $dueDate); + if ($created) { + return new DataResponse([], Http::STATUS_CREATED); + } + return new DataResponse([], Http::STATUS_OK); + } catch (NodeNotFoundException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + } + + /** + * Remove a reminder + * + * @param int $fileId ID of the file + * + * @return DataResponse<Http::STATUS_OK|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Reminder deleted successfully + * 401: Account not found + * 404: Reminder not found + */ + #[NoAdminRequired] + public function remove(int $fileId): DataResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->reminderService->remove($user, $fileId); + return new DataResponse([], Http::STATUS_OK); + } catch (NodeNotFoundException|ReminderNotFoundException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + } +} diff --git a/apps/files_reminders/lib/Dav/PropFindPlugin.php b/apps/files_reminders/lib/Dav/PropFindPlugin.php new file mode 100644 index 00000000000..014e636eb2d --- /dev/null +++ b/apps/files_reminders/lib/Dav/PropFindPlugin.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Dav; + +use DateTimeInterface; +use OCA\DAV\Connector\Sabre\Directory; +use OCA\DAV\Connector\Sabre\Node; +use OCA\FilesReminders\Service\ReminderService; +use OCP\Files\Folder; +use OCP\IUser; +use OCP\IUserSession; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; + +class PropFindPlugin extends ServerPlugin { + + public const REMINDER_DUE_DATE_PROPERTY = '{http://nextcloud.org/ns}reminder-due-date'; + + public function __construct( + private ReminderService $reminderService, + private IUserSession $userSession, + ) { + } + + public function initialize(Server $server): void { + $server->on('propFind', [$this, 'propFind']); + } + + public function propFind(PropFind $propFind, INode $node) { + if (!in_array(static::REMINDER_DUE_DATE_PROPERTY, $propFind->getRequestedProperties())) { + return; + } + + if (!($node instanceof Node)) { + return; + } + + if ( + $node instanceof Directory + && $propFind->getDepth() > 0 + && $propFind->getStatus(static::REMINDER_DUE_DATE_PROPERTY) !== null + ) { + $folder = $node->getNode(); + $this->cacheFolder($folder); + } + + $propFind->handle( + static::REMINDER_DUE_DATE_PROPERTY, + function () use ($node) { + $user = $this->userSession->getUser(); + if (!($user instanceof IUser)) { + return ''; + } + + $fileId = $node->getId(); + $reminder = $this->reminderService->getDueForUser($user, $fileId, false); + if ($reminder === null) { + return ''; + } + + return $reminder->getDueDate()->format(DateTimeInterface::ATOM); // ISO 8601 + }, + ); + } + + private function cacheFolder(Folder $folder): void { + $user = $this->userSession->getUser(); + if (!($user instanceof IUser)) { + return; + } + $this->reminderService->cacheFolder($user, $folder); + } +} diff --git a/apps/files_reminders/lib/Db/Reminder.php b/apps/files_reminders/lib/Db/Reminder.php new file mode 100644 index 00000000000..1a8ba15063e --- /dev/null +++ b/apps/files_reminders/lib/Db/Reminder.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Db; + +use DateTime; +use OCP\AppFramework\Db\Entity; + +/** + * @method void setUserId(string $userId) + * @method string getUserId() + * + * @method void setFileId(int $fileId) + * @method int getFileId() + * + * @method void setDueDate(DateTime $dueDate) + * @method DateTime getDueDate() + * + * @method void setUpdatedAt(DateTime $updatedAt) + * @method DateTime getUpdatedAt() + * + * @method void setCreatedAt(DateTime $createdAt) + * @method DateTime getCreatedAt() + * + * @method void setNotified(bool $notified) + * @method bool getNotified() + */ +class Reminder extends Entity { + protected $userId; + protected $fileId; + protected $dueDate; + protected $updatedAt; + protected $createdAt; + protected $notified = false; + + public function __construct() { + $this->addType('userId', 'string'); + $this->addType('fileId', 'integer'); + $this->addType('dueDate', 'datetime'); + $this->addType('updatedAt', 'datetime'); + $this->addType('createdAt', 'datetime'); + $this->addType('notified', 'boolean'); + } +} diff --git a/apps/files_reminders/lib/Db/ReminderMapper.php b/apps/files_reminders/lib/Db/ReminderMapper.php new file mode 100644 index 00000000000..63cba437d07 --- /dev/null +++ b/apps/files_reminders/lib/Db/ReminderMapper.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Db; + +use DateTime; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\IDBConnection; +use OCP\IUser; + +/** + * @template-extends QBMapper<Reminder> + */ +class ReminderMapper extends QBMapper { + public const TABLE_NAME = 'files_reminders'; + + public function __construct(IDBConnection $db) { + parent::__construct( + $db, + static::TABLE_NAME, + Reminder::class, + ); + } + + public function markNotified(Reminder $reminder): Reminder { + $reminderUpdate = new Reminder(); + $reminderUpdate->setId($reminder->getId()); + $reminderUpdate->setNotified(true); + return $this->update($reminderUpdate); + } + + /** + * @throws DoesNotExistException + */ + public function findDueForUser(IUser $user, int $fileId): Reminder { + $qb = $this->db->getQueryBuilder(); + + $qb->select('id', 'user_id', 'file_id', 'due_date', 'updated_at', 'created_at', 'notified') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($user->getUID(), IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('notified', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + + return $this->findEntity($qb); + } + + /** + * @return Reminder[] + */ + public function findAll() { + $qb = $this->db->getQueryBuilder(); + + $qb->select('id', 'user_id', 'file_id', 'due_date', 'updated_at', 'created_at', 'notified') + ->from($this->getTableName()) + ->orderBy('due_date', 'ASC'); + + return $this->findEntities($qb); + } + + /** + * @return Reminder[] + */ + public function findAllForUser(IUser $user) { + $qb = $this->db->getQueryBuilder(); + + $qb->select('id', 'user_id', 'file_id', 'due_date', 'updated_at', 'created_at', 'notified') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($user->getUID(), IQueryBuilder::PARAM_STR))) + ->orderBy('due_date', 'ASC'); + + return $this->findEntities($qb); + } + + /** + * @return Reminder[] + */ + public function findAllForNode(Node $node) { + try { + $nodeId = $node->getId(); + } catch (NotFoundException $e) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + + $qb->select('id', 'user_id', 'file_id', 'due_date', 'updated_at', 'created_at', 'notified') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))) + ->orderBy('due_date', 'ASC'); + + return $this->findEntities($qb); + } + + /** + * @return Reminder[] + */ + public function findOverdue() { + $qb = $this->db->getQueryBuilder(); + + $qb->select('id', 'user_id', 'file_id', 'due_date', 'updated_at', 'created_at', 'notified') + ->from($this->getTableName()) + ->where($qb->expr()->lt('due_date', $qb->createFunction('NOW()'))) + ->andWhere($qb->expr()->eq('notified', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->orderBy('due_date', 'ASC'); + + return $this->findEntities($qb); + } + + /** + * @return Reminder[] + */ + public function findNotified(DateTime $buffer, ?int $limit = null) { + $qb = $this->db->getQueryBuilder(); + + $qb->select('id', 'user_id', 'file_id', 'due_date', 'updated_at', 'created_at', 'notified') + ->from($this->getTableName()) + ->where($qb->expr()->eq('notified', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->lt('due_date', $qb->createNamedParameter($buffer, IQueryBuilder::PARAM_DATETIME_MUTABLE))) + ->orderBy('due_date', 'ASC') + ->setMaxResults($limit); + + return $this->findEntities($qb); + } + + /** + * @return Reminder[] + */ + public function findAllInFolder(IUser $user, Folder $folder) { + $qb = $this->db->getQueryBuilder(); + + $qb->select('r.id', 'r.user_id', 'r.file_id', 'r.due_date', 'r.updated_at', 'r.created_at', 'r.notified') + ->from($this->getTableName(), 'r') + ->innerJoin('r', 'filecache', 'f', $qb->expr()->eq('r.file_id', 'f.fileid')) + ->where($qb->expr()->eq('r.user_id', $qb->createNamedParameter($user->getUID(), IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('f.parent', $qb->createNamedParameter($folder->getId(), IQueryBuilder::PARAM_INT))) + ->orderBy('r.due_date', 'ASC'); + + return $this->findEntities($qb); + } +} diff --git a/apps/files_reminders/lib/Exception/NodeNotFoundException.php b/apps/files_reminders/lib/Exception/NodeNotFoundException.php new file mode 100644 index 00000000000..65e1b28fe1e --- /dev/null +++ b/apps/files_reminders/lib/Exception/NodeNotFoundException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Exception; + +use Exception; + +class NodeNotFoundException extends Exception { +} diff --git a/apps/files_reminders/lib/Exception/ReminderNotFoundException.php b/apps/files_reminders/lib/Exception/ReminderNotFoundException.php new file mode 100644 index 00000000000..fd7031a834f --- /dev/null +++ b/apps/files_reminders/lib/Exception/ReminderNotFoundException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Exception; + +use Exception; + +class ReminderNotFoundException extends Exception { +} diff --git a/apps/files_reminders/lib/Exception/UserNotFoundException.php b/apps/files_reminders/lib/Exception/UserNotFoundException.php new file mode 100644 index 00000000000..d1ddf9148cb --- /dev/null +++ b/apps/files_reminders/lib/Exception/UserNotFoundException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Exception; + +use Exception; + +class UserNotFoundException extends Exception { +} diff --git a/apps/files_reminders/lib/Listener/LoadAdditionalScriptsListener.php b/apps/files_reminders/lib/Listener/LoadAdditionalScriptsListener.php new file mode 100644 index 00000000000..765bf1e3ce2 --- /dev/null +++ b/apps/files_reminders/lib/Listener/LoadAdditionalScriptsListener.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Listener; + +use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\FilesReminders\AppInfo\Application; +use OCP\App\IAppManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener<LoadAdditionalScriptsEvent> */ +class LoadAdditionalScriptsListener implements IEventListener { + public function __construct( + private IAppManager $appManager, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof LoadAdditionalScriptsEvent)) { + return; + } + + if (!$this->appManager->isEnabledForUser(Application::APP_ID) + || !$this->appManager->isEnabledForUser('notifications') + ) { + return; + } + + Util::addInitScript(Application::APP_ID, 'init'); + } +} diff --git a/apps/files_reminders/lib/Listener/NodeDeletedListener.php b/apps/files_reminders/lib/Listener/NodeDeletedListener.php new file mode 100644 index 00000000000..06a4733e6cd --- /dev/null +++ b/apps/files_reminders/lib/Listener/NodeDeletedListener.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Listener; + +use OCA\FilesReminders\Service\ReminderService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\NodeDeletedEvent; + +/** @template-implements IEventListener<NodeDeletedEvent> */ +class NodeDeletedListener implements IEventListener { + public function __construct( + private ReminderService $reminderService, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof NodeDeletedEvent)) { + return; + } + + $node = $event->getNode(); + $this->reminderService->removeAllForNode($node); + } +} diff --git a/apps/files_reminders/lib/Listener/SabrePluginAddListener.php b/apps/files_reminders/lib/Listener/SabrePluginAddListener.php new file mode 100644 index 00000000000..b2c4501f9af --- /dev/null +++ b/apps/files_reminders/lib/Listener/SabrePluginAddListener.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Listener; + +use OCA\DAV\Events\SabrePluginAddEvent; +use OCA\FilesReminders\Dav\PropFindPlugin; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Container\ContainerInterface; + +/** @template-implements IEventListener<SabrePluginAddEvent> */ +class SabrePluginAddListener implements IEventListener { + public function __construct( + private ContainerInterface $container, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof SabrePluginAddEvent)) { + return; + } + + $server = $event->getServer(); + $plugin = $this->container->get(PropFindPlugin::class); + $server->addPlugin($plugin); + } +} diff --git a/apps/files_reminders/lib/Listener/UserDeletedListener.php b/apps/files_reminders/lib/Listener/UserDeletedListener.php new file mode 100644 index 00000000000..366a5e60420 --- /dev/null +++ b/apps/files_reminders/lib/Listener/UserDeletedListener.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Listener; + +use OCA\FilesReminders\Service\ReminderService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\User\Events\UserDeletedEvent; + +/** @template-implements IEventListener<UserDeletedEvent> */ +class UserDeletedListener implements IEventListener { + public function __construct( + private ReminderService $reminderService, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof UserDeletedEvent)) { + return; + } + + $user = $event->getUser(); + $this->reminderService->removeAllForUser($user); + } +} diff --git a/apps/files_reminders/lib/Migration/Version10000Date20230725162149.php b/apps/files_reminders/lib/Migration/Version10000Date20230725162149.php new file mode 100644 index 00000000000..74614c6515e --- /dev/null +++ b/apps/files_reminders/lib/Migration/Version10000Date20230725162149.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Migration; + +use Closure; +use OCA\FilesReminders\Db\ReminderMapper; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version10000Date20230725162149 extends SimpleMigrationStep { + /** + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable(ReminderMapper::TABLE_NAME)) { + return null; + } + + $table = $schema->createTable(ReminderMapper::TABLE_NAME); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('file_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('due_date', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->addColumn('updated_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->addColumn('notified', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id', 'file_id', 'due_date'], 'reminders_uniq_idx'); + + return $schema; + } +} diff --git a/apps/files_reminders/lib/Model/RichReminder.php b/apps/files_reminders/lib/Model/RichReminder.php new file mode 100644 index 00000000000..4f221252717 --- /dev/null +++ b/apps/files_reminders/lib/Model/RichReminder.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Model; + +use DateTimeInterface; +use JsonSerializable; +use OCA\FilesReminders\Db\Reminder; +use OCA\FilesReminders\Exception\NodeNotFoundException; +use OCP\Files\IRootFolder; +use OCP\Files\Node; + +class RichReminder extends Reminder implements JsonSerializable { + public function __construct( + private Reminder $reminder, + private IRootFolder $root, + ) { + parent::__construct(); + } + + /** + * @throws NodeNotFoundException + */ + public function getNode(): Node { + $node = $this->root->getUserFolder($this->getUserId())->getFirstNodeById($this->getFileId()); + if (!$node) { + throw new NodeNotFoundException(); + } + return $node; + } + + protected function getter(string $name): mixed { + return $this->reminder->getter($name); + } + + public function __call(string $methodName, array $args) { + return $this->reminder->__call($methodName, $args); + } + + public function jsonSerialize(): array { + return [ + 'userId' => $this->getUserId(), + 'fileId' => $this->getFileId(), + 'path' => $this->getNode()->getPath(), + 'dueDate' => $this->getDueDate()->format(DateTimeInterface::ATOM), // ISO 8601 + 'updatedAt' => $this->getUpdatedAt()->format(DateTimeInterface::ATOM), // ISO 8601 + 'createdAt' => $this->getCreatedAt()->format(DateTimeInterface::ATOM), // ISO 8601 + 'notified' => $this->getNotified(), + ]; + } +} diff --git a/apps/files_reminders/lib/Notification/Notifier.php b/apps/files_reminders/lib/Notification/Notifier.php new file mode 100644 index 00000000000..337ef04c814 --- /dev/null +++ b/apps/files_reminders/lib/Notification/Notifier.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Notification; + +use OCA\FilesReminders\AppInfo\Application; +use OCP\Files\FileInfo; +use OCP\Files\IRootFolder; +use OCP\IURLGenerator; +use OCP\L10N\IFactory; +use OCP\Notification\AlreadyProcessedException; +use OCP\Notification\IAction; +use OCP\Notification\INotification; +use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; + +class Notifier implements INotifier { + public function __construct( + protected IFactory $l10nFactory, + protected IURLGenerator $urlGenerator, + protected IRootFolder $root, + ) { + } + + public function getID(): string { + return Application::APP_ID; + } + + public function getName(): string { + return $this->l10nFactory->get(Application::APP_ID)->t('File reminders'); + } + + /** + * @throws UnknownNotificationException + */ + public function prepare(INotification $notification, string $languageCode): INotification { + $l = $this->l10nFactory->get(Application::APP_ID, $languageCode); + + if ($notification->getApp() !== Application::APP_ID) { + throw new UnknownNotificationException(); + } + + switch ($notification->getSubject()) { + case 'reminder-due': + $params = $notification->getSubjectParameters(); + $fileId = $params['fileId']; + + $node = $this->root->getUserFolder($notification->getUser())->getFirstNodeById($fileId); + if ($node === null) { + throw new AlreadyProcessedException(); + } + + $path = rtrim($node->getPath(), '/'); + if (strpos($path, '/' . $notification->getUser() . '/files/') === 0) { + // Remove /user/files/... + $fullPath = $path; + [,,, $path] = explode('/', $fullPath, 4); + } + + $link = $this->urlGenerator->linkToRouteAbsolute( + 'files.viewcontroller.showFile', + ['fileid' => $node->getId()], + ); + + // TRANSLATORS The name placeholder is for a file or folder name + $subject = $l->t('Reminder for {name}'); + $notification + ->setRichSubject( + $subject, + [ + 'name' => [ + 'type' => 'highlight', + 'id' => (string)$node->getId(), + 'name' => $node->getName(), + ], + ], + ) + ->setLink($link); + + $label = match ($node->getType()) { + FileInfo::TYPE_FILE => $l->t('View file'), + FileInfo::TYPE_FOLDER => $l->t('View folder'), + }; + + $this->addActionButton($notification, $label); + break; + default: + throw new UnknownNotificationException(); + } + + return $notification; + } + + protected function addActionButton(INotification $notification, string $label): void { + $action = $notification->createAction(); + + $action->setLabel($label) + ->setParsedLabel($label) + ->setLink($notification->getLink(), IAction::TYPE_WEB) + ->setPrimary(true); + + $notification->addParsedAction($action); + } +} diff --git a/apps/files_reminders/lib/Service/ReminderService.php b/apps/files_reminders/lib/Service/ReminderService.php new file mode 100644 index 00000000000..6ee39562076 --- /dev/null +++ b/apps/files_reminders/lib/Service/ReminderService.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\Service; + +use DateTime; +use DateTimeZone; +use OCA\FilesReminders\AppInfo\Application; +use OCA\FilesReminders\Db\Reminder; +use OCA\FilesReminders\Db\ReminderMapper; +use OCA\FilesReminders\Exception\NodeNotFoundException; +use OCA\FilesReminders\Exception\ReminderNotFoundException; +use OCA\FilesReminders\Exception\UserNotFoundException; +use OCA\FilesReminders\Model\RichReminder; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; +use Throwable; + +class ReminderService { + + private ICache $cache; + + public function __construct( + protected IUserManager $userManager, + protected IURLGenerator $urlGenerator, + protected INotificationManager $notificationManager, + protected ReminderMapper $reminderMapper, + protected IRootFolder $root, + protected LoggerInterface $logger, + protected ICacheFactory $cacheFactory, + ) { + $this->cache = $this->cacheFactory->createInMemory(); + } + + public function cacheFolder(IUser $user, Folder $folder): void { + $reminders = $this->reminderMapper->findAllInFolder($user, $folder); + $reminderMap = []; + foreach ($reminders as $reminder) { + $reminderMap[$reminder->getFileId()] = $reminder; + } + + $nodes = $folder->getDirectoryListing(); + foreach ($nodes as $node) { + $reminder = $reminderMap[$node->getId()] ?? false; + $this->cache->set("{$user->getUID()}-{$node->getId()}", $reminder); + } + } + + /** + * @throws NodeNotFoundException + */ + public function getDueForUser(IUser $user, int $fileId, bool $checkNode = true): ?RichReminder { + if ($checkNode) { + $this->checkNode($user, $fileId); + } + /** @var null|false|Reminder $cachedReminder */ + $cachedReminder = $this->cache->get("{$user->getUID()}-$fileId"); + if ($cachedReminder === false) { + return null; + } + if ($cachedReminder instanceof Reminder) { + return new RichReminder($cachedReminder, $this->root); + } + + try { + $reminder = $this->reminderMapper->findDueForUser($user, $fileId); + $this->cache->set("{$user->getUID()}-$fileId", $reminder); + return new RichReminder($reminder, $this->root); + } catch (DoesNotExistException $e) { + $this->cache->set("{$user->getUID()}-$fileId", false); + return null; + } + } + + /** + * @return RichReminder[] + */ + public function getAll(?IUser $user = null) { + $reminders = ($user !== null) + ? $this->reminderMapper->findAllForUser($user) + : $this->reminderMapper->findAll(); + return array_map( + fn (Reminder $reminder) => new RichReminder($reminder, $this->root), + $reminders, + ); + } + + /** + * @return bool true if created, false if updated + * + * @throws NodeNotFoundException + */ + public function createOrUpdate(IUser $user, int $fileId, DateTime $dueDate): bool { + $now = new DateTime('now', new DateTimeZone('UTC')); + $this->checkNode($user, $fileId); + $reminder = $this->getDueForUser($user, $fileId); + if ($reminder === null) { + $reminder = new Reminder(); + $reminder->setUserId($user->getUID()); + $reminder->setFileId($fileId); + $reminder->setDueDate($dueDate); + $reminder->setUpdatedAt($now); + $reminder->setCreatedAt($now); + $this->reminderMapper->insert($reminder); + $this->cache->set("{$user->getUID()}-$fileId", $reminder); + return true; + } + $reminder->setDueDate($dueDate); + $reminder->setUpdatedAt($now); + $this->reminderMapper->update($reminder); + $this->cache->set("{$user->getUID()}-$fileId", $reminder); + return false; + } + + /** + * @throws NodeNotFoundException + * @throws ReminderNotFoundException + */ + public function remove(IUser $user, int $fileId): void { + $this->checkNode($user, $fileId); + $reminder = $this->getDueForUser($user, $fileId); + if ($reminder === null) { + throw new ReminderNotFoundException(); + } + $this->deleteReminder($reminder); + } + + public function removeAllForNode(Node $node): void { + $reminders = $this->reminderMapper->findAllForNode($node); + foreach ($reminders as $reminder) { + $this->deleteReminder($reminder); + } + } + + public function removeAllForUser(IUser $user): void { + $reminders = $this->reminderMapper->findAllForUser($user); + foreach ($reminders as $reminder) { + $this->deleteReminder($reminder); + } + } + + /** + * @throws DoesNotExistException + * @throws UserNotFoundException + */ + public function send(Reminder $reminder): void { + if ($reminder->getNotified()) { + return; + } + + $user = $this->userManager->get($reminder->getUserId()); + if ($user === null) { + throw new UserNotFoundException(); + } + + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp(Application::APP_ID) + ->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('files', 'folder.svg'))) + ->setUser($user->getUID()) + ->setObject('reminder', (string)$reminder->getId()) + ->setSubject('reminder-due', [ + 'fileId' => $reminder->getFileId(), + ]) + ->setDateTime($reminder->getDueDate()); + + try { + $this->notificationManager->notify($notification); + $this->reminderMapper->markNotified($reminder); + $this->cache->set("{$user->getUID()}-{$reminder->getFileId()}", $reminder); + } catch (Throwable $th) { + $this->logger->error($th->getMessage(), $th->getTrace()); + } + } + + public function cleanUp(?int $limit = null): void { + $buffer = (new DateTime()) + ->setTimezone(new DateTimeZone('UTC')) + ->modify('-1 day'); + $reminders = $this->reminderMapper->findNotified($buffer, $limit); + foreach ($reminders as $reminder) { + $this->deleteReminder($reminder); + } + } + + private function deleteReminder(Reminder $reminder): void { + $this->reminderMapper->delete($reminder); + $this->cache->set("{$reminder->getUserId()}-{$reminder->getFileId()}", false); + } + + + /** + * @throws NodeNotFoundException + */ + private function checkNode(IUser $user, int $fileId): void { + $userFolder = $this->root->getUserFolder($user->getUID()); + $node = $userFolder->getFirstNodeById($fileId); + if ($node === null) { + throw new NodeNotFoundException(); + } + } +} diff --git a/apps/files_reminders/lib/SetupChecks/NeedNotificationsApp.php b/apps/files_reminders/lib/SetupChecks/NeedNotificationsApp.php new file mode 100644 index 00000000000..e5890567181 --- /dev/null +++ b/apps/files_reminders/lib/SetupChecks/NeedNotificationsApp.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\FilesReminders\SetupChecks; + +use OCP\App\IAppManager; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class NeedNotificationsApp implements ISetupCheck { + public function __construct( + private IAppManager $appManager, + private IL10N $l10n, + ) { + } + + public function getName(): string { + return $this->l10n->t('Files reminder'); + } + + public function getCategory(): string { + return 'system'; + } + + public function run(): SetupResult { + if ($this->appManager->isEnabledForAnyone('notifications')) { + return SetupResult::success($this->l10n->t('The "files_reminders" app can work properly.')); + } else { + return SetupResult::warning($this->l10n->t('The "files_reminders" app needs the notification app to work properly. You should either enable notifications or disable files_reminder.')); + } + } +} |