From 704d54f03885ab5c00d07302693143545603506e Mon Sep 17 00:00:00 2001 From: Edward Ly Date: Mon, 25 Nov 2024 16:22:12 -0800 Subject: [PATCH] feat: add Zulip messages to unified search Signed-off-by: Edward Ly --- css/zulip-search.css | 9 + lib/AppInfo/Application.php | 2 + lib/Search/ZulipSearchMessagesProvider.php | 181 +++++++++++++++++++++ lib/Service/ZulipAPIService.php | 23 +++ 4 files changed, 215 insertions(+) create mode 100644 css/zulip-search.css create mode 100644 lib/Search/ZulipSearchMessagesProvider.php diff --git a/css/zulip-search.css b/css/zulip-search.css new file mode 100644 index 0000000..ddab846 --- /dev/null +++ b/css/zulip-search.css @@ -0,0 +1,9 @@ +.icon-zulip-search-fallback { + background-image: url('../img/app-dark.svg'); + filter: var(--background-invert-if-dark); +} + +/* for NC <= 24 */ +body.theme--dark .icon-zulip-search-fallback { + background-image: url('../img/app.svg'); +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2cbd207..6c4b8e0 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -18,6 +18,7 @@ namespace OCA\Zulip\AppInfo; use OCA\Zulip\Listener\FilesMenuListener; +use OCA\Zulip\Search\ZulipSearchMessagesProvider; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -34,6 +35,7 @@ public function __construct(array $urlParams = []) { public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadAdditionalScriptsEvent::class, FilesMenuListener::class); + $context->registerSearchProvider(ZulipSearchMessagesProvider::class); } public function boot(IBootContext $context): void { diff --git a/lib/Search/ZulipSearchMessagesProvider.php b/lib/Search/ZulipSearchMessagesProvider.php new file mode 100644 index 0000000..e0daeb3 --- /dev/null +++ b/lib/Search/ZulipSearchMessagesProvider.php @@ -0,0 +1,181 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OCA\Zulip\Search; + +use OCA\Zulip\AppInfo\Application; +use OCA\Zulip\Service\SecretService; +use OCA\Zulip\Service\ZulipAPIService; +use OCP\App\IAppManager; +use OCP\IConfig; +use OCP\IDateTimeFormatter; +use OCP\IDateTimeZone; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\Search\IProvider; +use OCP\Search\ISearchQuery; +use OCP\Search\SearchResult; +use OCP\Search\SearchResultEntry; + +class ZulipSearchMessagesProvider implements IProvider { + + public function __construct( + private IAppManager $appManager, + private IL10N $l10n, + private IConfig $config, + private IURLGenerator $urlGenerator, + private IDateTimeFormatter $dateTimeFormatter, + private IDateTimeZone $dateTimeZone, + private SecretService $secretService, + private ZulipAPIService $apiService + ) { + } + + /** + * @inheritDoc + */ + public function getId(): string { + return 'zulip-search-messages'; + } + + /** + * @inheritDoc + */ + public function getName(): string { + return $this->l10n->t('Zulip messages'); + } + + /** + * @inheritDoc + */ + public function getOrder(string $route, array $routeParameters): int { + if (strpos($route, Application::APP_ID . '.') === 0) { + // Active app, prefer Zulip results + return -1; + } + + return 20; + } + + /** + * @inheritDoc + */ + public function search(IUser $user, ISearchQuery $query): SearchResult { + if (!$this->appManager->isEnabledForUser(Application::APP_ID, $user)) { + return SearchResult::complete($this->getName(), []); + } + + $limit = $query->getLimit(); + $term = $query->getTerm(); + $offset = $query->getCursor(); + $offset = $offset ? intval($offset) : 0; + + $url = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'url'); + $email = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'email'); + $apiKey = $this->secretService->getEncryptedUserValue($user->getUID(), 'api_key'); + $searchMessagesEnabled = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'search_messages_enabled', '0') === '1'; + if ($url === '' || $email === '' || $apiKey === '' || !$searchMessagesEnabled) { + return SearchResult::paginated($this->getName(), [], 0); + } + + $searchResult = $this->apiService->searchMessages($user->getUID(), $term, $offset, $limit); + if (isset($searchResult['error'])) { + return SearchResult::paginated($this->getName(), [], 0); + } + + $formattedResults = array_map(function (array $entry) use ($url): SearchResultEntry { + $finalThumbnailUrl = $this->getThumbnailUrl($entry); + return new SearchResultEntry( + $finalThumbnailUrl, + $this->getMainText($entry), + $this->getSubline($entry), + $this->getLinkToZulip($entry, $url), + $finalThumbnailUrl === '' ? 'icon-zulip-search-fallback' : '', + true + ); + }, $searchResult); + + return SearchResult::paginated( + $this->getName(), + $formattedResults, + $offset + $limit + ); + } + + /** + * @param array $entry + * @return string + */ + protected function getMainText(array $entry): string { + return strip_tags($entry['content']); + } + + /** + * @param array $entry + * @return string + */ + protected function getSubline(array $entry): string { + if ($entry['type'] === 'stream') { + return $this->l10n->t('%s in #%s > %s at %s', [$entry['sender_full_name'], $entry['display_recipient'], $entry['subject'], $this->getFormattedDate($entry['timestamp'])]); + } + + $recipients = array_map(fn (array $user): string => $user['full_name'], $entry['display_recipient']); + $displayRecipients = '@' . $recipients[0] . (count($recipients) > 1 ? ' (+' . strval(count($recipients) - 1) . ')' : ''); + return $this->l10n->t('%s in %s at %s', [$entry['sender_full_name'], $displayRecipients, $this->getFormattedDate($entry['timestamp'])]); + } + + protected function getFormattedDate(int $timestamp): string { + return $this->dateTimeFormatter->formatDateTime($timestamp, 'long', 'short', $this->dateTimeZone->getTimeZone()); + } + + /** + * @param array $entry + * @param string $url + * @return string + */ + protected function getLinkToZulip(array $entry, string $url): string { + if ($entry['type'] === 'private') { + $userIds = array_map(fn (array $recipient): string => strval($recipient['id']), $entry['display_recipient']); + return rtrim($url, '/') . '/#narrow/dm/' . implode(',', $userIds) . '/near/' . $entry['id']; + } + + $topic = str_replace('%', '.', rawurlencode($entry['subject'])); + return rtrim($url, '/') . '/#narrow/channel/' . $entry['stream_id'] . '/topic/' . $topic . '/near/' . $entry['id']; + } + + /** + * @param array $entry + * @return string + */ + protected function getThumbnailUrl(array $entry): string { + return ''; + // $senderId = $entry['sender_id'] ?? ''; + // return $senderId + // ? $this->urlGenerator->getAbsoluteURL( + // $this->urlGenerator->linkToRoute('integration_zulip.zulipAPI.getUserAvatar', ['zulipUserId' => $senderId]) + // ) + // : ''; + } +} diff --git a/lib/Service/ZulipAPIService.php b/lib/Service/ZulipAPIService.php index 1d026bc..9676031 100644 --- a/lib/Service/ZulipAPIService.php +++ b/lib/Service/ZulipAPIService.php @@ -58,6 +58,29 @@ public function __construct( $this->client = $clientService->newClient(); } + /** + * @param string $userId + * @return array + * @throws PreConditionNotMetException + */ + public function searchMessages(string $userId, string $term, int $offset = 0, int $limit = 5): array { + $result = $this->request($userId, 'messages', [ + 'anchor' => 'newest', + 'num_before' => $offset + $limit, + 'num_after' => 0, + 'narrow' => '[{"operator": "search", "operand": "' . $term . '"}]', + 'client_gravatar' => 'true', + ]); + + if (isset($result['error'])) { + return (array) $result; + } + + // sort by most recent + $messages = array_reverse($result['messages'] ?? []); + return array_slice($messages, $offset, $limit); + } + /** * @param string $userId * @param int $zulipUserId