*/ 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); } } }