From 4f29fe65826814497d821f774c3530ec476650ce Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 6 Jun 2026 10:53:13 +0200 Subject: [PATCH 1/3] fix(ContextChat): Upgrade context chat integration to use OCP classes Signed-off-by: Marcel Klehr --- lib/AppInfo/Application.php | 20 ++- lib/BackgroundJobs/ContextChatIndexJob.php | 8 +- lib/ContextChat/ContextChatProvider.php | 29 +++- lib/Controller/WebViewController.php | 6 +- tests/ContextChatIndexJobTest.php | 169 +++++++++++++++++++++ tests/ContextChatProviderTest.php | 158 +++++++++++++++++++ tests/stub.phpstub | 21 +-- 7 files changed, 378 insertions(+), 33 deletions(-) create mode 100644 tests/ContextChatIndexJobTest.php create mode 100644 tests/ContextChatProviderTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 32136d50c4..833522ee11 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -29,13 +29,14 @@ use OCA\Bookmarks\Reference\BookmarkReferenceProvider; use OCA\Bookmarks\Search\Provider; use OCA\Bookmarks\Service\TreeCacheManager; -use OCA\ContextChat\Event\ContentProviderRegisterEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\Collaboration\Reference\RenderReferenceEvent; +use OCP\ContextChat\Events\ContentProviderRegisterEvent; +use OCP\ContextChat\IContentManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\Group\Events\BeforeGroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; @@ -104,15 +105,18 @@ public function register(IRegistrationContext $context): void { */ public function boot(IBootContext $context): void { // Register with ContextChat - if (class_exists(ContentProviderRegisterEvent::class)) { - $this->getContainer()->get(ContextChatProvider::class)->register(); - $eventDispatcher = $this->getContainer()->get(IEventDispatcher::class); - $eventDispatcher->addServiceListener(InsertEvent::class, ContextChatProvider::class); - $eventDispatcher->addServiceListener(ManipulateEvent::class, ContextChatProvider::class); - $eventDispatcher->addServiceListener(BeforeDeleteEvent::class, ContextChatProvider::class); + $serverContainer = $context->getServerContainer(); + if ($serverContainer->has(IContentManager::class)) { + $contentManager = $serverContainer->get(IContentManager::class); + if ($contentManager->isContextChatAvailable()) { + $eventDispatcher = $this->getContainer()->get(IEventDispatcher::class); + $eventDispatcher->addServiceListener(InsertEvent::class, ContextChatProvider::class); + $eventDispatcher->addServiceListener(ManipulateEvent::class, ContextChatProvider::class); + $eventDispatcher->addServiceListener(BeforeDeleteEvent::class, ContextChatProvider::class); + } } // Register with Nextcloud Flow - $container = $context->getServerContainer(); + $container = $serverContainer; CreateBookmark::register($container->get(IEventDispatcher::class)); } } diff --git a/lib/BackgroundJobs/ContextChatIndexJob.php b/lib/BackgroundJobs/ContextChatIndexJob.php index 4ab6eab32d..64194d3c87 100644 --- a/lib/BackgroundJobs/ContextChatIndexJob.php +++ b/lib/BackgroundJobs/ContextChatIndexJob.php @@ -12,10 +12,10 @@ use OCA\Bookmarks\ContextChat\ContextChatProvider; use OCA\Bookmarks\Service\BookmarkService; use OCA\Bookmarks\Service\UserSettingsService; -use OCA\ContextChat\Public\ContentItem; -use OCA\ContextChat\Public\ContentManager; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\QueuedJob; +use OCP\ContextChat\ContentItem; +use OCP\ContextChat\IContentManager; use OCP\IUserManager; class ContextChatIndexJob extends QueuedJob { @@ -23,7 +23,7 @@ class ContextChatIndexJob extends QueuedJob { public function __construct( ITimeFactory $timeFactory, private BookmarkService $bookmarkService, - private ?ContentManager $contentManager, + private ?IContentManager $contentManager, private IUserManager $userManager, private ContextChatProvider $provider, private UserSettingsService $userSettings, @@ -32,7 +32,7 @@ public function __construct( } protected function run($argument) { - if ($this->contentManager === null) { + if ($this->contentManager === null || !$this->contentManager->isContextChatAvailable()) { return; } if (!isset($argument['user'])) { diff --git a/lib/ContextChat/ContextChatProvider.php b/lib/ContextChat/ContextChatProvider.php index cd2ffbc9b1..a0a7b5dcff 100644 --- a/lib/ContextChat/ContextChatProvider.php +++ b/lib/ContextChat/ContextChatProvider.php @@ -17,11 +17,11 @@ use OCA\Bookmarks\Events\ManipulateEvent; use OCA\Bookmarks\Service\BookmarkService; use OCA\Bookmarks\Service\UserSettingsService; -use OCA\ContextChat\Event\ContentProviderRegisterEvent; -use OCA\ContextChat\Public\ContentItem; -use OCA\ContextChat\Public\ContentManager; -use OCA\ContextChat\Public\IContentProvider; use OCP\BackgroundJob\IJobList; +use OCP\ContextChat\ContentItem; +use OCP\ContextChat\Events\ContentProviderRegisterEvent; +use OCP\ContextChat\IContentManager; +use OCP\ContextChat\IContentProvider; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IUser; @@ -35,18 +35,18 @@ class ContextChatProvider implements IContentProvider, IEventListener { public function __construct( private BookmarkService $bookmarkService, private IUserManager $userManager, - private ?ContentManager $contentManager, + private ?IContentManager $contentManager, private IJobList $jobList, private UserSettingsService $userSettings, ) { } public function handle(Event $event): void { - if ($this->contentManager === null) { + if (!$this->isContextChatAvailable()) { return; } if ($event instanceof ContentProviderRegisterEvent) { - $this->register(); + $this->register($event); return; } if (!$event instanceof ChangeEvent) { @@ -84,10 +84,23 @@ public function handle(Event $event): void { } } - public function register(): void { + public function register(?ContentProviderRegisterEvent $event = null): void { + if ($event !== null) { + $event->registerContentProvider($this->getAppId(), $this->getId(), self::class); + return; + } + + if (!$this->isContextChatAvailable()) { + return; + } + $this->contentManager->registerContentProvider($this->getAppId(), $this->getId(), self::class); } + private function isContextChatAvailable(): bool { + return $this->contentManager !== null && $this->contentManager->isContextChatAvailable(); + } + /** * The ID of the provider * diff --git a/lib/Controller/WebViewController.php b/lib/Controller/WebViewController.php index a3cea7facd..059b9e4fea 100644 --- a/lib/Controller/WebViewController.php +++ b/lib/Controller/WebViewController.php @@ -17,7 +17,6 @@ use OCA\Bookmarks\Service\SettingsService; use OCA\Bookmarks\Service\UserSettingsService; use OCA\Viewer\Event\LoadViewer; -use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -31,6 +30,7 @@ use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\StreamResponse; use OCP\AppFramework\Http\Template\PublicTemplateResponse; +use OCP\ContextChat\IContentManager; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; @@ -57,7 +57,7 @@ public function __construct( private BookmarkMapper $bookmarkMapper, private UserSettingsService $userSettingsService, private SettingsService $settings, - private IAppManager $appManager, + private ?IContentManager $contentManager, private IConfig $config, private IEventDispatcher $eventDispatcher, private LoggerInterface $logger, @@ -108,7 +108,7 @@ public function index(): AugmentedTemplateResponse { } $this->initialState->provideInitialState($this->appName, 'tags', $this->tagsController->fullTags(true)->getData()); - $this->initialState->provideInitialState($this->appName, 'contextChatInstalled', $this->appManager->isEnabledForUser('context_chat')); + $this->initialState->provideInitialState($this->appName, 'contextChatInstalled', $this->contentManager?->isContextChatAvailable() ?? false); $this->initialState->provideInitialState($this->appName, 'appStoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true)); $settings = $this->userSettingsService->toArray(); diff --git a/tests/ContextChatIndexJobTest.php b/tests/ContextChatIndexJobTest.php new file mode 100644 index 0000000000..33df20ac93 --- /dev/null +++ b/tests/ContextChatIndexJobTest.php @@ -0,0 +1,169 @@ +timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->method('getTime')->willReturn(1710000000); + $this->bookmarkService = $this->createMock(BookmarkService::class); + $this->contentManager = $this->createMock(IContentManager::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->provider = $this->createMock(ContextChatProvider::class); + $this->userSettings = $this->createMock(UserSettingsService::class); + $this->jobList = $this->createMock(IJobList::class); + + $this->job = new ContextChatIndexJob( + $this->timeFactory, + $this->bookmarkService, + $this->contentManager, + $this->userManager, + $this->provider, + $this->userSettings, + ); + $this->job->setArgument(['user' => 'alice']); + } + + public function testDoesNothingWhenContextChatUnavailable(): void { + $this->contentManager->expects(self::once()) + ->method('isContextChatAvailable') + ->willReturn(false); + $this->userManager->expects(self::never())->method('get'); + $this->contentManager->expects(self::never())->method('submitContent'); + $this->jobList->expects(self::once())->method('remove'); + $this->jobList->expects(self::once())->method('setLastRun'); + $this->jobList->expects(self::once())->method('setExecutionTime'); + + $this->job->start($this->jobList); + } + + public function testIndexesBookmarksForEnabledUser(): void { + $user = $this->createConfiguredMock(IUser::class, ['getUID' => 'alice']); + $bookmarks = [ + $this->createBookmark(1, 'alice', 'One', 'First content'), + $this->createBookmark(2, 'alice', 'Two', 'Second content'), + ]; + + $this->contentManager->expects(self::once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $this->userManager->expects(self::once()) + ->method('get') + ->with('alice') + ->willReturn($user); + $this->userSettings->expects(self::once()) + ->method('setUserId') + ->with('alice'); + $this->userSettings->expects(self::once()) + ->method('get') + ->with('contextchat.enabled') + ->willReturn('true'); + $this->bookmarkService->expects(self::once()) + ->method('getIterator') + ->with('alice') + ->willReturn((function () use ($bookmarks) { + foreach ($bookmarks as $bookmark) { + yield $bookmark; + } + })()); + $this->provider->expects(self::exactly(2)) + ->method('getId') + ->willReturn('bookmarks'); + $this->contentManager->expects(self::once()) + ->method('submitContent') + ->with( + Application::APP_ID, + self::callback(function (array $items): bool { + self::assertCount(2, $items); + self::assertContainsOnlyInstancesOf(ContentItem::class, $items); + self::assertSame('1', $items[0]->itemId); + self::assertSame('2', $items[1]->itemId); + self::assertSame(['alice'], $items[0]->users); + return true; + }) + ); + $this->jobList->expects(self::once())->method('remove'); + $this->jobList->expects(self::once())->method('setLastRun'); + $this->jobList->expects(self::once())->method('setExecutionTime'); + + $this->job->start($this->jobList); + } + + public function testDoesNotIndexWhenUserDisabledContextChat(): void { + $user = $this->createConfiguredMock(IUser::class, ['getUID' => 'alice']); + + $this->contentManager->expects(self::once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $this->userManager->expects(self::once()) + ->method('get') + ->with('alice') + ->willReturn($user); + $this->userSettings->expects(self::once()) + ->method('setUserId') + ->with('alice'); + $this->userSettings->expects(self::once()) + ->method('get') + ->with('contextchat.enabled') + ->willReturn('false'); + $this->bookmarkService->expects(self::never())->method('getIterator'); + $this->contentManager->expects(self::never())->method('submitContent'); + $this->jobList->expects(self::once())->method('remove'); + $this->jobList->expects(self::once())->method('setLastRun'); + $this->jobList->expects(self::once())->method('setExecutionTime'); + + $this->job->start($this->jobList); + } + + private function createBookmark(int $id, string $userId, string $title, string $textContent): Bookmark { + return Bookmark::fromArray([ + 'id' => $id, + 'userId' => $userId, + 'url' => 'https://example.com/' . $id, + 'title' => $title, + 'description' => '', + 'lastmodified' => 1710000000 + $id, + 'added' => 1710000000, + 'clickcount' => 0, + 'lastPreview' => 0, + 'available' => true, + 'archivedFile' => 0, + 'textContent' => $textContent, + 'htmlContent' => '', + 'urlHash' => 'hash-' . $id, + ]); + } +} + diff --git a/tests/ContextChatProviderTest.php b/tests/ContextChatProviderTest.php new file mode 100644 index 0000000000..50d9805dd7 --- /dev/null +++ b/tests/ContextChatProviderTest.php @@ -0,0 +1,158 @@ +bookmarkService = $this->createMock(BookmarkService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->contentManager = $this->createMock(IContentManager::class); + $this->jobList = $this->createMock(IJobList::class); + $this->userSettings = $this->createMock(UserSettingsService::class); + + $this->provider = new ContextChatProvider( + $this->bookmarkService, + $this->userManager, + $this->contentManager, + $this->jobList, + $this->userSettings, + ); + } + + public function testRegistersWithOcpContextChatEvent(): void { + $event = $this->createMock(ContentProviderRegisterEvent::class); + $event->expects(self::once()) + ->method('registerContentProvider') + ->with('bookmarks', 'bookmarks', ContextChatProvider::class); + + $this->provider->handle($event); + } + + public function testDoesNotSubmitWhenContextChatUnavailable(): void { + $this->contentManager->expects(self::once()) + ->method('isContextChatAvailable') + ->willReturn(false); + $this->contentManager->expects(self::never()) + ->method('submitContent'); + $this->userSettings->expects(self::never()) + ->method('get'); + + $this->provider->handle(new InsertEvent(TreeMapper::TYPE_BOOKMARK, 42)); + } + + public function testSubmitsBookmarkContentOnInsert(): void { + $bookmark = Bookmark::fromArray([ + 'id' => 42, + 'userId' => 'alice', + 'url' => 'https://example.com', + 'title' => 'Example', + 'description' => 'Desc', + 'lastmodified' => 1710000000, + 'added' => 1710000000, + 'clickcount' => 0, + 'lastPreview' => 0, + 'available' => true, + 'archivedFile' => 0, + 'textContent' => 'Indexed body', + 'htmlContent' => '', + 'urlHash' => 'hash', + ]); + + $this->contentManager->expects(self::once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $this->userSettings->expects(self::once()) + ->method('get') + ->with('contextchat.enabled') + ->willReturn('true'); + $this->bookmarkService->expects(self::once()) + ->method('findById') + ->with(42) + ->willReturn($bookmark); + $this->contentManager->expects(self::once()) + ->method('submitContent') + ->with( + 'bookmarks', + self::callback(function (array $items): bool { + self::assertCount(1, $items); + self::assertContainsOnlyInstancesOf(ContentItem::class, $items); + self::assertSame('42', $items[0]->itemId); + self::assertSame('bookmarks', $items[0]->providerId); + self::assertSame('Example', $items[0]->title); + self::assertSame('Indexed body', $items[0]->content); + self::assertSame(['alice'], $items[0]->users); + return true; + }) + ); + + $this->provider->handle(new InsertEvent(TreeMapper::TYPE_BOOKMARK, 42)); + } + + public function testDeletesBookmarkContentOnDelete(): void { + $this->contentManager->expects(self::once()) + ->method('isContextChatAvailable') + ->willReturn(true); + $this->userSettings->expects(self::once()) + ->method('get') + ->with('contextchat.enabled') + ->willReturn('true'); + $this->contentManager->expects(self::once()) + ->method('deleteContent') + ->with('bookmarks', 'bookmarks', ['7']); + + $this->provider->handle(new BeforeDeleteEvent(TreeMapper::TYPE_BOOKMARK, 7)); + } + + public function testTriggerInitialImportQueuesAJobForEachUser(): void { + $userA = $this->createConfiguredMock(IUser::class, ['getUID' => 'alice']); + $userB = $this->createConfiguredMock(IUser::class, ['getUID' => 'bob']); + + $this->jobList->expects(self::exactly(2)) + ->method('add') + ->withConsecutive( + [ContextChatIndexJob::class, ['user' => 'alice']], + [ContextChatIndexJob::class, ['user' => 'bob']], + ); + $this->userManager->expects(self::once()) + ->method('callForAllUsers') + ->willReturnCallback(function (callable $callback) use ($userA, $userB): void { + $callback($userA); + $callback($userB); + }); + + $this->provider->triggerInitialImport(); + } +} + diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 4d9db0c338..9630a82f95 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -140,17 +140,17 @@ namespace Doctrine\DBAL { } } -namespace OCA\ContextChat\Public { +namespace OCP\ContextChat { /** * This interface defines methods to implement a content provider - * @since 1.1.0 + * @since 32.0.0 */ interface IContentProvider { /** * The ID of the provider * * @return string - * @since 1.1.0 + * @since 32.0.0 */ public function getId(): string; @@ -158,7 +158,7 @@ interface IContentProvider { * The ID of the app making the provider avaialble * * @return string - * @since 1.1.0 + * @since 32.0.0 */ public function getAppId(): string; @@ -167,7 +167,7 @@ interface IContentProvider { * * @param string $id * @return string - * @since 1.1.0 + * @since 32.0.0 */ public function getItemUrl(string $id): string; @@ -175,13 +175,14 @@ interface IContentProvider { * Starts the initial import of content items into content chat * * @return void - * @since 1.1.0 + * @since 32.0.0 */ public function triggerInitialImport(): void; } -class ContentManager { - public function registerContentProvider(string, $appId, string $providerId, string $providerClass); +interface IContentManager { + public function isContextChatAvailable(): bool; + public function registerContentProvider(string $appId, string $providerId, string $providerClass): void; public function submitContent(string $appId, array $items); public function deleteContent(string $appId, string $providerId, array $itemIds); } @@ -199,8 +200,8 @@ class ContentItem { } } -namespace OCA\ContextChat\Event { +namespace OCP\ContextChat\Events { class ContentProviderRegisterEvent extends \OCP\EventDispatcher\Event { public function registerContentProvider(string $appId, string $provider, string $class); } -} \ No newline at end of file +} From e8446adcf11ae667721193b2ec2bceae10ed8fb9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 6 Jun 2026 12:34:53 +0200 Subject: [PATCH 2/3] fix: Run cs:fix Signed-off-by: Marcel Klehr --- tests/ContextChatIndexJobTest.php | 2 +- tests/ContextChatProviderTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ContextChatIndexJobTest.php b/tests/ContextChatIndexJobTest.php index 33df20ac93..d9fca79609 100644 --- a/tests/ContextChatIndexJobTest.php +++ b/tests/ContextChatIndexJobTest.php @@ -1,4 +1,5 @@ provider->triggerInitialImport(); } } - From 154d2f223a80eb0e9ecc70fb7a95365342db027d Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Sat, 6 Jun 2026 12:53:44 +0200 Subject: [PATCH 3/3] fix:(tests) Signed-off-by: Marcel Klehr --- lib/ContextChat/ContextChatProvider.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ContextChat/ContextChatProvider.php b/lib/ContextChat/ContextChatProvider.php index a0a7b5dcff..d0f6997bf3 100644 --- a/lib/ContextChat/ContextChatProvider.php +++ b/lib/ContextChat/ContextChatProvider.php @@ -42,13 +42,13 @@ public function __construct( } public function handle(Event $event): void { - if (!$this->isContextChatAvailable()) { - return; - } if ($event instanceof ContentProviderRegisterEvent) { $this->register($event); return; } + if (!$this->isContextChatAvailable()) { + return; + } if (!$event instanceof ChangeEvent) { return; }