From df00b27a643060b2d8f42018b148267a867a276c Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 18:08:51 +0100 Subject: [PATCH] feat(bookstack) : add BookStackApiService Co-Authored-By: Claude Sonnet 4.6 --- src/Service/BookStackApiService.php | 235 ++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 src/Service/BookStackApiService.php diff --git a/src/Service/BookStackApiService.php b/src/Service/BookStackApiService.php new file mode 100644 index 0000000..39fdae7 --- /dev/null +++ b/src/Service/BookStackApiService.php @@ -0,0 +1,235 @@ + */ + private array $shelfBookCache = []; + + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly BookStackConfigurationRepository $configRepository, + private readonly TokenEncryptor $tokenEncryptor, + ) {} + + public function testConnection(): bool + { + try { + $this->request('GET', '/api/docs.json'); + + return true; + } catch (BookStackApiException) { + return false; + } + } + + /** + * @return array + */ + public function listShelves(): array + { + $result = []; + $offset = 0; + $count = 100; + + do { + $data = $this->request('GET', '/api/shelves', [ + 'query' => ['count' => $count, 'offset' => $offset], + ]); + $items = $data['data'] ?? []; + $result = array_merge($result, $items); + $offset += $count; + } while (!empty($items) && $count === count($items)); + + return $result; + } + + /** + * Search for pages and books within a specific shelf. + * + * Algorithm: + * 1. Fetch the shelf's book IDs + * 2. Run two search queries (one for pages, one for books) + * 3. Filter results: pages must belong to a book on the shelf, books must be on the shelf + * + * @return array + */ + public function searchInShelf(int $shelfId, string $query): array + { + $bookIds = $this->getShelfBookIds($shelfId); + + if (empty($bookIds)) { + return []; + } + + $config = $this->getConfiguration(); + $baseUrl = rtrim($config->getUrl() ?? '', '/'); + $trimmed = trim($query); + + // BookStack search API accepts {type:X} for one type at a time — run two queries + $pageResults = $this->request('GET', '/api/search', [ + 'query' => ['query' => $trimmed.' {type:page}', 'count' => 50], + ]); + $bookResults = $this->request('GET', '/api/search', [ + 'query' => ['query' => $trimmed.' {type:book}', 'count' => 50], + ]); + + $allResults = array_merge($pageResults['data'] ?? [], $bookResults['data'] ?? []); + + // Build a map of bookId → bookSlug for URL construction + $shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId)); + $bookSlugs = []; + foreach ($shelfData['books'] ?? [] as $book) { + $bookSlugs[$book['id']] = $book['slug'] ?? ''; + } + + $filtered = []; + foreach ($allResults as $item) { + $type = $item['type'] ?? ''; + + if ('page' === $type) { + $bookId = $item['book_id'] ?? 0; + if (in_array($bookId, $bookIds, true)) { + $bookSlug = $bookSlugs[$bookId] ?? ''; + $filtered[] = [ + 'id' => $item['id'], + 'type' => 'page', + 'name' => $item['name'] ?? '', + 'url' => $baseUrl.'/books/'.$bookSlug.'/page/'.$item['slug'], + ]; + } + } elseif ('book' === $type) { + if (in_array($item['id'], $bookIds, true)) { + $filtered[] = [ + 'id' => $item['id'], + 'type' => 'book', + 'name' => $item['name'] ?? '', + 'url' => $baseUrl.'/books/'.$item['slug'], + ]; + } + } + } + + return $filtered; + } + + /** + * @return array{id: int, name: string, slug: string} + */ + public function getPage(int $id): array + { + return $this->request('GET', sprintf('/api/pages/%d', $id)); + } + + /** + * @return array{id: int, name: string, slug: string} + */ + public function getBook(int $id): array + { + return $this->request('GET', sprintf('/api/books/%d', $id)); + } + + /** + * @return int[] + */ + private function getShelfBookIds(int $shelfId): array + { + if (isset($this->shelfBookCache[$shelfId])) { + return $this->shelfBookCache[$shelfId]; + } + + $data = $this->request('GET', sprintf('/api/shelves/%d', $shelfId)); + $books = $data['books'] ?? []; + + $ids = array_map(static fn (array $book): int => $book['id'], $books); + $this->shelfBookCache[$shelfId] = $ids; + + return $ids; + } + + private function getConfiguration(): BookStackConfiguration + { + $config = $this->configRepository->findSingleton(); + if (null === $config) { + throw new BookStackApiException('BookStack is not configured.'); + } + + return $config; + } + + /** + * @return array{tokenId: string, tokenSecret: string} + */ + private function getDecryptedTokens(BookStackConfiguration $config): array + { + $encryptedId = $config->getEncryptedTokenId(); + $encryptedSecret = $config->getEncryptedTokenSecret(); + + if (null === $encryptedId || null === $encryptedSecret) { + throw new BookStackApiException('BookStack tokens are not set.'); + } + + try { + return [ + 'tokenId' => $this->tokenEncryptor->decrypt($encryptedId), + 'tokenSecret' => $this->tokenEncryptor->decrypt($encryptedSecret), + ]; + } catch (Throwable $e) { + throw new BookStackApiException('Failed to decrypt BookStack tokens: '.$e->getMessage(), 0, $e); + } + } + + private function extractError(HttpExceptionInterface $e): string + { + try { + $body = $e->getResponse()->getContent(false); + $data = json_decode($body, true); + + if (is_array($data)) { + return $data['message'] ?? $data['error'] ?? $body; + } + + return $body ?: 'Unknown BookStack error'; + } catch (ExceptionInterface) { + return 'BookStack API error (HTTP '.$e->getResponse()->getStatusCode().')'; + } + } + + /** + * @param array $options + */ + private function request(string $method, string $path, array $options = []): array + { + $config = $this->getConfiguration(); + $tokens = $this->getDecryptedTokens($config); + + $options['headers'] = array_merge($options['headers'] ?? [], [ + 'Authorization' => sprintf('Token %s:%s', $tokens['tokenId'], $tokens['tokenSecret']), + 'Accept' => 'application/json', + ]); + $options['timeout'] = 10; + + try { + $response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options); + + return $response->toArray(); + } catch (HttpExceptionInterface $e) { + $message = $this->extractError($e); + + throw new BookStackApiException($message, $e->getResponse()->getStatusCode(), $e); + } catch (ExceptionInterface $e) { + throw new BookStackApiException('BookStack API error: '.$e->getMessage(), 0, $e); + } + } +}