'bookstack_allowed_shelf_ids_creation', self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view', ]; public function enabled(): bool { return Settings::bool('bookstack_enabled') && Settings::get('bookstack_base_url') && Settings::get('bookstack_token_id'); } /** * Suggested-article lookup, shared by the ticket-creation wizard and the * operator ticket-view sidebar — returns [] whenever the integration is * off/unconfigured, the query is empty, or no shelf has been allow-listed * yet for the given $context (an empty allow-list means "search nothing", * not "search everything" — an admin has to opt specific shelves in * before any content is ever suggested, independently per context). * Cached briefly since the same category/subcategory query repeats * across every ticket created/viewed with that combination. Respects the * admin-configured bookstack_search_types setting ('both'|'page'|'book') * via BookStack's own `{type:x}` query syntax. The cache key folds in the * allowed-shelf list so changing it in Admin > Konfiguracja is reflected * immediately, instead of possibly serving a pre-change result for up to * 10 minutes. * * @return array */ public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION): array { $query = trim($query); $allowedShelfIds = $this->allowedShelfIds($context); if (! $this->enabled() || $query === '' || ! $allowedShelfIds) { return []; } $typeFilter = Settings::get('bookstack_search_types', 'both'); if (in_array($typeFilter, ['page', 'book'], true)) { $query .= " {type:{$typeFilter}}"; } $cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds)); return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) { try { $response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]); if (! $response->successful()) { return []; } $shelfMap = $this->shelfBookMap(); $allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds); $bookShelfNames = $this->bookShelfNames($shelfMap); return collect($response->json('data', [])) ->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) { $type = $item['type'] ?? null; if ($type === 'bookshelf') { return in_array($item['id'] ?? null, $allowedShelfIds, true); } if ($type === 'book') { return in_array($item['id'] ?? null, $allowedBookIds, true); } // pages/chapters carry the id of the book they live in return isset($item['book_id']) && in_array($item['book_id'], $allowedBookIds, true); }) ->map(function (array $item) use ($bookShelfNames) { $bookId = $item['book_id'] ?? (($item['type'] ?? null) === 'book' ? $item['id'] : null); return [ 'name' => $item['name'] ?? '', 'url' => $item['url'] ?? null, 'type' => $item['type'] ?? 'page', 'book' => $item['book']['name'] ?? null, 'shelf' => $bookId ? ($bookShelfNames[$bookId] ?? null) : null, ]; }) ->filter(fn (array $item) => $item['name'] !== '') ->values() ->all(); } catch (\Throwable) { return []; } }); } /** * Drops the cached shelf list and shelf>book membership map — used by * the admin's "Odśwież listę półek" button so a shelf renamed/added/ * removed in BookStack shows up immediately instead of after up to 30 * minutes. Doesn't touch the per-query search-result cache (10 min TTL, * self-invalidates on the next config save via the allow-list in its key). */ public function clearShelfCache(): void { Cache::forget('bookstack:shelves'); Cache::forget('bookstack:shelf-book-map'); } /** * Bookshelves for the admin's two "dozwolone półki" checklists — cached * since shelf structure changes rarely and this is fetched on every * Admin > Konfiguracja page load while the BookStack section is expanded. * * @return array */ public function shelves(): array { if (! $this->enabled()) { return []; } return Cache::remember('bookstack:shelves', now()->addMinutes(30), function () { try { $response = $this->client()->get('/api/shelves', ['count' => 200]); if (! $response->successful()) { return []; } return collect($response->json('data', [])) ->map(fn (array $s) => ['id' => $s['id'], 'name' => $s['name']]) ->values() ->all(); } catch (\Throwable) { return []; } }); } /** * @return int[] */ protected function allowedShelfIds(string $context): array { $key = self::CONTEXT_SETTINGS_KEYS[$context] ?? self::CONTEXT_SETTINGS_KEYS[self::CONTEXT_CREATION]; $raw = Settings::get($key, ''); return collect(explode(',', (string) $raw)) ->map(fn ($v) => (int) trim($v)) ->filter() ->values() ->all(); } /** * Every shelf's book membership, fetched once and cached — the single * source both shelf-exclusion and the "Shelf > Book" breadcrumb are * derived from, so there's only one place that talks to /api/shelves/{id}. * * @return array */ protected function shelfBookMap(): array { return Cache::remember('bookstack:shelf-book-map', now()->addMinutes(30), function () { $map = []; foreach ($this->shelves() as $shelf) { $bookIds = []; try { $response = $this->client()->get("/api/shelves/{$shelf['id']}"); if ($response->successful()) { $bookIds = collect($response->json('books', []))->pluck('id')->all(); } } catch (\Throwable) { // Skip an unreachable/deleted shelf rather than failing the whole search. } $map[$shelf['id']] = ['name' => $shelf['name'], 'bookIds' => $bookIds]; } return $map; }); } /** * @param array $shelfMap * @param int[] $shelfIds * @return int[] */ protected function bookIdsForShelves(array $shelfMap, array $shelfIds): array { $ids = []; foreach ($shelfIds as $shelfId) { $ids = [...$ids, ...($shelfMap[$shelfId]['bookIds'] ?? [])]; } return $ids; } /** * Book id -> owning shelf name, for the suggestion list's breadcrumb. A * book that sits on more than one shelf just shows whichever is last in * the map — there's no single "correct" shelf to prefer in that case. * * @param array $shelfMap * @return array */ protected function bookShelfNames(array $shelfMap): array { $names = []; foreach ($shelfMap as $shelf) { foreach ($shelf['bookIds'] as $bookId) { $names[$bookId] = $shelf['name']; } } return $names; } /** * Tests unsaved admin-form values directly, rather than whatever's * currently stored — mirrors testLdapConnection()/testMailConnection() * in Admin\Panel. Returns a message alongside the ok/error flag (BookStack's * API returns a specific, useful reason — e.g. missing "Access System API" * role permission — that a plain boolean would hide from the admin. * * @return array{ok: bool, message: ?string} */ public function testConnection(string $baseUrl, string $tokenId, string $tokenSecret, bool $verifySsl = true): array { try { $response = Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"]) ->withOptions(['verify' => $verifySsl]) ->timeout(6) ->get(rtrim($baseUrl, '/').'/api/search', ['query' => 'test', 'count' => 1]); if ($response->successful()) { return ['ok' => true, 'message' => null]; } return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())]; } catch (\Throwable $e) { return ['ok' => false, 'message' => $e->getMessage()]; } } protected function client() { $tokenId = Settings::get('bookstack_token_id'); $tokenSecret = Settings::get('bookstack_token_secret'); return Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"]) ->withOptions(['verify' => Settings::bool('bookstack_verify_ssl')]) ->timeout(4) ->baseUrl(rtrim(Settings::get('bookstack_base_url'), '/')); } }