diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index f676ac0bb..973a7f934 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -9,12 +9,15 @@ namespace OCA\Bookmarks\AppInfo; use OCA\Bookmarks\Activity\ActivityPublisher; +use OCA\Bookmarks\ContextChat\ContextChatProvider; use OCA\Bookmarks\Dashboard\Frequent; use OCA\Bookmarks\Dashboard\Recent; use OCA\Bookmarks\Events\BeforeDeleteEvent; use OCA\Bookmarks\Events\BeforeSoftDeleteEvent; use OCA\Bookmarks\Events\BeforeSoftUndeleteEvent; use OCA\Bookmarks\Events\CreateEvent; +use OCA\Bookmarks\Events\InsertEvent; +use OCA\Bookmarks\Events\ManipulateEvent; use OCA\Bookmarks\Events\MoveEvent; use OCA\Bookmarks\Events\UpdateEvent; use OCA\Bookmarks\Flow\CreateBookmark; @@ -24,6 +27,7 @@ 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; @@ -97,6 +101,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerMiddleware(ExceptionMiddleware::class); + + $context->registerEventListener(ContentProviderRegisterEvent::class, ContextChatProvider::class); } /** @@ -105,6 +111,15 @@ public function register(IRegistrationContext $context): void { * @throws \Throwable */ 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); + } + // Register with Nextcloud Flow $container = $context->getServerContainer(); CreateBookmark::register($container->get(IEventDispatcher::class)); } diff --git a/lib/BackgroundJobs/ContextChatIndexJob.php b/lib/BackgroundJobs/ContextChatIndexJob.php new file mode 100644 index 000000000..4ab6eab32 --- /dev/null +++ b/lib/BackgroundJobs/ContextChatIndexJob.php @@ -0,0 +1,70 @@ +contentManager === null) { + return; + } + if (!isset($argument['user'])) { + return; + } + $user = $this->userManager->get($argument['user']); + if ($user === null) { + return; + } + $this->userSettings->setUserId($user->getUID()); + if ($this->userSettings->get('contextchat.enabled') !== 'true') { + return; + } + $items = []; + foreach ($this->bookmarkService->getIterator($user->getUID()) as $bookmark) { + $items[] = new ContentItem( + (string)$bookmark->getId(), + $this->provider->getId(), + $bookmark->getTitle(), + $bookmark->getTextContent(), + 'Website', + new \DateTime('@' . $bookmark->getLastmodified()), + [$user->getUID()] + ); + if (count($items) < 25) { + continue; + } + $this->contentManager->submitContent(Application::APP_ID, $items); + $items = []; + } + if (count($items) > 0) { + $this->contentManager->submitContent(Application::APP_ID, $items); + } + } +} diff --git a/lib/ContextChat/ContextChatProvider.php b/lib/ContextChat/ContextChatProvider.php new file mode 100644 index 000000000..cd2ffbc9b --- /dev/null +++ b/lib/ContextChat/ContextChatProvider.php @@ -0,0 +1,129 @@ + + */ +class ContextChatProvider implements IContentProvider, IEventListener { + + public function __construct( + private BookmarkService $bookmarkService, + private IUserManager $userManager, + private ?ContentManager $contentManager, + private IJobList $jobList, + private UserSettingsService $userSettings, + ) { + } + + public function handle(Event $event): void { + if ($this->contentManager === null) { + return; + } + if ($event instanceof ContentProviderRegisterEvent) { + $this->register(); + return; + } + if (!$event instanceof ChangeEvent) { + return; + } + if ($this->userSettings->get('contextchat.enabled') !== 'true') { + return; + } + if ($event instanceof InsertEvent || $event instanceof ManipulateEvent) { + if ($event->getType() !== TreeMapper::TYPE_BOOKMARK) { + return; + } + $bookmark = $this->bookmarkService->findById($event->getId()); + if ($bookmark === null) { + return; + } + $item = new ContentItem( + (string)$event->getId(), + $this->getId(), + $bookmark->getTitle(), + $bookmark->getTextContent(), + 'Website', + new \DateTime('@' . $bookmark->getLastmodified()), + [$bookmark->getUserId()] + ); + $this->contentManager->submitContent($this->getAppId(), [$item]); + return; + } + if ($event instanceof BeforeDeleteEvent) { + if ($event->getType() !== TreeMapper::TYPE_BOOKMARK) { + return; + } + $this->contentManager->deleteContent($this->getAppId(), $this->getId(), [(string)$event->getId()]); + return; + } + } + + public function register(): void { + $this->contentManager->registerContentProvider($this->getAppId(), $this->getId(), self::class); + } + + /** + * The ID of the provider + * + * @return string + */ + public function getId(): string { + return 'bookmarks'; + } + + /** + * The ID of the app making the provider available + * + * @return string + */ + public function getAppId(): string { + return Application::APP_ID; + } + + /** + * The absolute URL to the content item + * + * @param string $id + * @return string + */ + public function getItemUrl(string $id): string { + return $this->bookmarkService->findById(intval($id))?->getUrl() ?? ''; + } + + /** + * Starts the initial import of content items into content chat + * + * @return void + */ + public function triggerInitialImport(): void { + $this->userManager->callForAllUsers(function (IUser $user) { + $this->jobList->add(ContextChatIndexJob::class, ['user' => $user->getUID()]); + }); + } +} diff --git a/lib/Controller/WebViewController.php b/lib/Controller/WebViewController.php index fdc70b36c..e693a1199 100644 --- a/lib/Controller/WebViewController.php +++ b/lib/Controller/WebViewController.php @@ -15,6 +15,7 @@ use OCA\Bookmarks\Db\PublicFolderMapper; use OCA\Bookmarks\Service\SettingsService; use OCA\Bookmarks\Service\UserSettingsService; +use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -23,6 +24,7 @@ use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\StreamResponse; use OCP\AppFramework\Http\Template\PublicTemplateResponse; +use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; use OCP\IURLGenerator; @@ -49,6 +51,8 @@ public function __construct( private \OCA\Bookmarks\Controller\InternalTagsController $tagsController, private UserSettingsService $userSettingsService, private SettingsService $settings, + private IAppManager $appManager, + private IConfig $config, ) { parent::__construct($appName, $request); $this->userId = $userId; @@ -78,6 +82,8 @@ public function index(): AugmentedTemplateResponse { $this->initialState->provideInitialState($this->appName, 'allClicksCount', $this->bookmarkController->countAllClicks()->getData()['item']); $this->initialState->provideInitialState($this->appName, 'withClicksCount', $this->bookmarkController->countWithClicks()->getData()['item']); $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, 'appStoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true)); $settings = $this->userSettingsService->toArray(); $settings['shareapi_allow_links'] = $this->settings->getLinkSharingAllowed(); diff --git a/lib/Db/BookmarkMapper.php b/lib/Db/BookmarkMapper.php index 9c7c370b1..8646ab51b 100644 --- a/lib/Db/BookmarkMapper.php +++ b/lib/Db/BookmarkMapper.php @@ -9,6 +9,8 @@ namespace OCA\Bookmarks\Db; use OCA\Bookmarks\Events\BeforeDeleteEvent; +use OCA\Bookmarks\Events\InsertEvent; +use OCA\Bookmarks\Events\ManipulateEvent; use OCA\Bookmarks\Exception\AlreadyExistsError; use OCA\Bookmarks\Exception\UrlParseError; use OCA\Bookmarks\Exception\UserLimitExceededError; @@ -257,6 +259,21 @@ protected function findEntitiesWithRawQuery(string $query, array $params, array return $entities; } + /** + * @throws \OCP\DB\Exception + */ + protected function getIteratorWithRawQuery(string $query, array $params, array $types): \Generator { + $cursor = $this->db->executeQuery($query, $params, $types); + + $entities = []; + + while ($row = $cursor->fetch()) { + yield $this->mapRowToEntity($row); + } + + $cursor->closeCursor(); + } + /** * Common table expression that lists all items in a given folder, recursively * @param int $folderId @@ -773,7 +790,9 @@ public function update(Entity $entity): Bookmark { $entity->setUrl($this->urlNormalizer->normalize($entity->getUrl())); } $entity->setLastmodified(time()); - return parent::update($entity); + parent::update($entity); + $this->eventDispatcher->dispatchTyped(new ManipulateEvent('bookmark', $entity->getId())); + return $entity; } /** @@ -806,6 +825,7 @@ public function insert(Entity $entity): Bookmark { $this->findByUrl($entity->getUserId(), $entity->getUrl()); } catch (DoesNotExistException $e) { parent::insert($entity); + $this->eventDispatcher->dispatchTyped(new InsertEvent('bookmark', $entity->getId())); return $entity; } catch (MultipleObjectsReturnedException $e) { // noop @@ -904,4 +924,41 @@ private function _findSharersFor(string $userId) :array { return $share->getOwner(); }, $this->shareMapper->findByUser($userId)); } + + public function getIterator(string $userId, QueryParameters $queryParams): \Generator { + $rootFolder = $this->folderMapper->findRootFolder($userId); + // gives us all bookmarks in this folder, recursively + [$cte, $params, $paramTypes] = $this->_generateCTE($rootFolder->getId(), $queryParams->getSoftDeletedFolders()); + + $qb = $this->db->getQueryBuilder(); + $bookmark_cols = array_map(static function ($c) { + return 'b.' . $c; + }, Bookmark::$columns); + + $qb->select($bookmark_cols); + $qb->groupBy($bookmark_cols); + + $qb->automaticTablePrefix(false); + + $qb + ->from('*PREFIX*bookmarks', 'b') + ->innerJoin('b', 'folder_tree', 'tree', 'tree.item_id = b.id AND tree.type = ' . $qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK) + . ($queryParams->getSoftDeleted() ? ' AND tree.soft_deleted_at is NOT NULL' : ' AND tree.soft_deleted_at is NULL')); + + $this->_filterUrl($qb, $queryParams); + $this->_filterArchived($qb, $queryParams); + $this->_filterUnavailable($qb, $queryParams); + $this->_filterDuplicated($qb, $queryParams); + $this->_filterFolder($qb, $queryParams); + $this->_filterTags($qb, $queryParams); + $this->_filterUntagged($qb, $queryParams); + $this->_filterSearch($qb, $queryParams); + $this->_sortAndPaginate($qb, $queryParams); + + $finalQuery = $cte . ' ' . $qb->getSQL(); + + $params = array_merge($params, $qb->getParameters()); + $paramTypes = array_merge($paramTypes, $qb->getParameterTypes()); + return $this->getIteratorWithRawQuery($finalQuery, $params, $paramTypes); + } } diff --git a/lib/Events/InsertEvent.php b/lib/Events/InsertEvent.php new file mode 100644 index 000000000..3fe08170f --- /dev/null +++ b/lib/Events/InsertEvent.php @@ -0,0 +1,16 @@ +bookmarkMapper = $bookmarkMapper; - $this->treeMapper = $treeMapper; - $this->linkExplorer = $linkExplorer; - $this->folderMapper = $folderMapper; - $this->tagMapper = $tagMapper; - $this->bookmarkPreviewer = $bookmarkPreviewer; - $this->faviconPreviewer = $faviconPreviewer; - $this->folders = $folders; - $this->eventDispatcher = $eventDispatcher; - $this->hashManager = $hashManager; - $this->urlNormalizer = $urlNormalizer; - $this->crawler = $crawler; - $this->jobList = $jobList; + public function __construct( + private BookmarkMapper $bookmarkMapper, + private FolderMapper $folderMapper, + private TagMapper $tagMapper, + private TreeMapper $treeMapper, + private LinkExplorer $linkExplorer, + private BookmarkPreviewer $bookmarkPreviewer, + private FaviconPreviewer $faviconPreviewer, + private FolderService $folders, + private IEventDispatcher $eventDispatcher, + private \OCA\Bookmarks\Service\TreeCacheManager $hashManager, + private UrlNormalizer $urlNormalizer, + private IJobList $jobList, + ) { } /** @@ -544,4 +477,12 @@ public function deleteAll(string $userId): void { $this->hashManager->setInvalidationEnabled(true); $this->hashManager->invalidateFolder($rootFolder->getId()); } + + /** + * @param string $userId + * @return \Generator + */ + public function getIterator(string $userId): \Generator { + return $this->bookmarkMapper->getIterator($userId, new QueryParameters()); + } } diff --git a/lib/Service/UserSettingsService.php b/lib/Service/UserSettingsService.php index 85ea9de53..d872743e7 100644 --- a/lib/Service/UserSettingsService.php +++ b/lib/Service/UserSettingsService.php @@ -8,18 +8,21 @@ namespace OCA\Bookmarks\Service; +use OCA\Bookmarks\BackgroundJobs\ContextChatIndexJob; +use OCP\BackgroundJob\IJobList; use OCP\IConfig; use OCP\IL10N; class UserSettingsService { - public const KEYS = ['hasSeenWhatsnew', 'viewMode', 'archive.enabled', 'archive.filePath', 'backup.enabled', 'backup.filePath', 'sorting']; + public const KEYS = ['hasSeenWhatsnew', 'viewMode', 'archive.enabled', 'archive.filePath', 'backup.enabled', 'backup.filePath', 'sorting', 'contextchat.enabled']; public function __construct( private ?string $userId, private string $appName, private IConfig $config, private IL10N $l, + private IJobList $jobList, ) { } @@ -46,7 +49,7 @@ public function get(string $key): string { return $this->config->getAppValue('bookmarks', 'performance.maxBookmarksperAccount', '0'); } if ($key === 'archive.enabled') { - $default = (string)true; + $default = 'true'; } if ($key === 'privacy.enableScraping') { return $this->config->getAppValue($this->appName, 'privacy.enableScraping', 'false'); @@ -55,11 +58,17 @@ public function get(string $key): string { $default = $this->l->t('Bookmarks'); } if ($key === 'backup.enabled') { - $default = (string)false; + $default = 'false'; } if ($key === 'backup.filePath') { $default = $this->l->t('Bookmarks Backups'); } + if ($key === 'contextchat.enabled') { + $default = 'false'; + if ($this->get('archive.enabled') !== 'true') { + return 'false'; + } + } return $this->config->getUserValue( $this->userId, $this->appName, @@ -84,6 +93,9 @@ public function set(string $key, string $value): void { if ($key === 'sorting' && !in_array($value, ['title', 'added', 'clickcount', 'lastmodified', 'index', 'url'], true)) { throw new \ValueError(); } + if ($key === 'contextchat.enabled' && $value === 'true' && $this->get('contextchat.enabled') !== 'true' && $this->get('archive.enabled') === 'true') { + $this->jobList->add(ContextChatIndexJob::class, ['user' => $this->userId]); + } $this->config->setUserValue( $this->userId, $this->appName, diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 5f0c857ed..549f11b4e 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -40,6 +40,7 @@ createPositionalParameter(TreeMapper::TYPE_BOOKMARK)]]> createPositionalParameter(TreeMapper::TYPE_BOOKMARK)]]> createPositionalParameter(TreeMapper::TYPE_BOOKMARK)]]> + createPositionalParameter(TreeMapper::TYPE_BOOKMARK)]]> createPositionalParameter(TreeMapper::TYPE_FOLDER)]]> createPositionalParameter(TreeMapper::TYPE_FOLDER)]]> createPositionalParameter(TreeMapper::TYPE_SHARE)]]> diff --git a/src/components/Icons.js b/src/components/Icons.js index 88c4cfb31..201b3bddf 100644 --- a/src/components/Icons.js +++ b/src/components/Icons.js @@ -66,8 +66,10 @@ import UndeleteIcon from 'vue-material-design-icons/Restore.vue' import HotnessZero from 'vue-material-design-icons/Minus.vue' import Hotness from 'vue-material-design-icons/Fire.vue' import BookmarksIcon from './icons/BookmarksIcon.vue' +import ContextChatIcon from 'vue-material-design-icons/ClipboardSearchOutline.vue' export { + ContextChatIcon, FolderPlusIcon, FolderMoveIcon, ContentCopyIcon, diff --git a/src/components/Settings.vue b/src/components/Settings.vue index 783c6b153..8b934cd8b 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -61,6 +61,19 @@ @click="onChangeBackupPath" /> + + +

{{ t('bookmarks', 'The bookmarks app can automatically make available the textual contents of the websites you bookmark to Context Chat, which allows asking questions about and getting answers based on those contents. This is only available if auto-archiving is enabled.') }}

+

+ {{ t('bookmarks', 'Context chat is currently not installed, but is required for this feature.') }} +

+ + {{ t('bookmarks', 'Enable Context Chat integration') }} + +
+