'bookstack_allowed_shelf_ids_creation', self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view', ]; /** * Content types selectable via the admin's "Przeszukuj" checkboxes. * Deliberately excludes 'bookshelf' — shelves are only ever a filter * (dozwolone półki), never a suggestion result in their own right. */ public const SEARCH_TYPES = ['book', 'page', 'chapter']; /** * How the query text is matched, via the admin's "Szukaj po" option — * 'name' restricts to the title ({in_name:...}), 'tags' matches a tag * whose name equals the query (expected to hold the helpdesk * category/subcategory name, e.g. a "Drukarki" tag on the relevant * BookStack pages), 'both' runs both and merges the results (BookStack's * query syntax ANDs filters together, so there's no single-query way to * express "name OR tag"). */ public const SEARCH_BY_OPTIONS = ['name', 'tags', 'both']; /** * Content types the bulk-tagging command operates over — same set as * SEARCH_TYPES (book/page/chapter, no bookshelf), named separately since * the two consts serve different features that happen to share a domain. */ public const CONTENT_TYPES = self::SEARCH_TYPES; 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 (subset of SEARCH_TYPES, via * BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'| * 'tags'|'both', via `{in_name:...}`/`[...]`) settings. 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. * * $tagQuery is the text matched by the 'tags' variant, separate from * $query (matched by the 'name' variant) — callers pass the bare * subcategory name here (what bookstack:tag-content actually writes as * a tag), while $query stays the fuller "Category Subcategory" text * that's more useful for a plain title/body search. Defaults to $query * so existing call sites that don't pass it keep working. * * @return array */ public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION, ?string $tagQuery = null): array { $query = trim($query); $tagQuery = trim($tagQuery ?? $query); $allowedShelfIds = $this->allowedShelfIds($context); if (! $this->enabled() || $query === '' || ! $allowedShelfIds) { return []; } $searchBy = $this->searchBy(); $bookstackQueries = $searchBy === 'both' ? [$this->buildQuery($query, 'name'), $this->buildQuery($tagQuery, 'tags')] : [$this->buildQuery($searchBy === 'tags' ? $tagQuery : $query, $searchBy)]; $cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds)); return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $limit, $allowedShelfIds) { try { $shelfMap = $this->shelfBookMap(); $allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds); $bookShelfNames = $this->bookShelfNames($shelfMap); $items = collect(); foreach ($bookstackQueries as $bookstackQuery) { $response = $this->client()->get('/api/search', ['query' => $bookstackQuery, 'count' => $limit]); if ($response->successful()) { $items = $items->concat($response->json('data', [])); } } return $items ->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'] !== '') ->unique(fn (array $item) => $item['url'] ?? $item['name']) ->take($limit) ->values() ->all(); } catch (\Throwable) { return []; } }); } /** * Builds one BookStack search-syntax query string for $query, restricted * to $by ('name' -> `{in_name:...}`, 'tags' -> `[...]`) and to the * configured content types (`{type:a|b}`, omitted if all types are * allowed since that's equivalent to no filter). */ protected function buildQuery(string $query, string $by): string { $parts = [$by === 'tags' ? "[{$query}]" : "{in_name:{$query}}"]; $types = $this->searchTypes(); if (array_diff(self::SEARCH_TYPES, $types)) { $parts[] = '{type:'.implode('|', $types).'}'; } return implode(' ', $parts); } /** * @return string[] non-empty subset of SEARCH_TYPES */ protected function searchTypes(): array { return self::normalizeSearchTypes(Settings::get('bookstack_search_types', '')); } /** * Normalizes bookstack_search_types storage into a non-empty subset of * SEARCH_TYPES — shared with Admin\Panel so the checkbox UI and the * actual search agree on the same format. Also understands the legacy * single-value 'both'/'page'/'book' storage from before the setting * became a checkbox list, so existing configuration keeps working. * * @param string[]|string $raw * @return string[] */ public static function normalizeSearchTypes(array|string $raw): array { $legacy = ['both' => self::SEARCH_TYPES, 'page' => ['page'], 'book' => ['book']]; if (is_string($raw) && isset($legacy[$raw])) { return $legacy[$raw]; } $types = collect(is_array($raw) ? $raw : explode(',', $raw)) ->map(fn ($v) => trim((string) $v)) ->filter(fn ($v) => in_array($v, self::SEARCH_TYPES, true)) ->unique() ->values() ->all(); return $types ?: self::SEARCH_TYPES; } protected function searchBy(): string { $raw = Settings::get('bookstack_search_by', 'both'); return in_array($raw, self::SEARCH_BY_OPTIONS, true) ? $raw : 'both'; } /** * 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 []; } }); } /** * Every book/chapter/page of $type across the whole BookStack instance — * NOT filtered by the allowed-shelf settings, unlike search(). Those only * gate which suggestions are ever shown to a client/operator; the bulk * tagger is meant to cover every piece of content regardless. Paginates * through BookStack's count/offset list endpoints (count capped at 500, * the API's own per-page maximum). Uncached — this is a one-shot batch * read, not a repeated request-path lookup. * * @return array */ public function listAll(string $type): array { if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) { return []; } $items = []; $offset = 0; try { do { $response = $this->client()->get("/api/{$type}s", ['count' => 500, 'offset' => $offset]); if (! $response->successful()) { break; } $page = $response->json('data', []); $items = [...$items, ...$page]; $offset += 500; $total = $response->json('total', 0); } while (count($page) > 0 && count($items) < $total); } catch (\Throwable) { return $items; } return $items; } /** * Full detail for a single book/chapter/page — its current tags (needed * to merge rather than clobber when the tagger writes new ones) and the * text used to classify it (a page's markdown source, or a book/ * chapter's description). Null if $type is invalid or the item can't be * fetched. * * @return array{id: int, name: string, tags: array, content: string}|null */ public function detail(string $type, int $id): ?array { if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) { return null; } try { $response = $this->client()->get("/api/{$type}s/{$id}"); if (! $response->successful()) { return null; } $data = $response->json(); return [ 'id' => $data['id'], 'name' => $data['name'] ?? '', 'tags' => $data['tags'] ?? [], 'content' => $data['markdown'] ?? $data['description'] ?? '', ]; } catch (\Throwable) { return null; } } /** * Overwrites just the tags field on a book/chapter/page — BookStack * treats every field on its update endpoints as optional, so this never * touches the item's name/content/other attributes. Callers are * responsible for merging in any tags they want to keep (this replaces * the whole array, it doesn't append). * * @param array $tags */ public function updateTags(string $type, int $id, array $tags): bool { if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) { return false; } try { return $this->client()->put("/api/{$type}s/{$id}", ['tags' => $tags])->successful(); } catch (\Throwable) { return false; } } /** * @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'), '/')); } }