diff --git a/docs/superpowers/plans/2026-03-15-bookstack-connector.md b/docs/superpowers/plans/2026-03-15-bookstack-connector.md new file mode 100644 index 0000000..a13bcae --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-bookstack-connector.md @@ -0,0 +1,2148 @@ +# BookStack Connector Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a BookStack connector that lets users link wiki pages and books to tasks, with project-level shelf configuration and admin settings. + +**Architecture:** Mirrors the existing Gitea connector pattern — singleton config entity, API service, API Platform DTOs with Provider/Processor, frontend service + components. Links stored in a dedicated join table `task_bookstack_link`. + +**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / PostgreSQL 16 / Nuxt 4 / Vue 3 / TypeScript + +**Spec:** `docs/superpowers/specs/2026-03-15-bookstack-connector-design.md` + +--- + +## Chunk 1: Prerequisites & Backend Foundation + +### Task 1: Rename GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY + +**Files:** +- Modify: `src/Service/TokenEncryptor.php` +- Modify: `.env` + +- [ ] **Step 1: Update TokenEncryptor to use generic env var** + +In `src/Service/TokenEncryptor.php`, change the `#[Autowire]` attribute and error message: + +```php +// Change line 19: +#[Autowire('%env(GITEA_ENCRYPTION_KEY)%')] +// To: +#[Autowire('%env(ENCRYPTION_KEY)%')] +``` + +And update the `assertConfigured()` error message: + +```php +// Change: +throw new GiteaApiException('Gitea encryption is not configured. Please set a valid GITEA_ENCRYPTION_KEY.'); +// To: +throw new \RuntimeException('Encryption is not configured. Please set a valid ENCRYPTION_KEY.'); +``` + +Also update the `use` statement: replace `use App\Exception\GiteaApiException;` with `use RuntimeException;`. + +> **Note:** `docker/.env.docker` does not contain `GITEA_ENCRYPTION_KEY` (it may be in `docker/.env.docker.local` which is gitignored). Developers using `.env.docker.local` should update it manually. + +- [ ] **Step 2: Update .env** + +``` +# Change: +GITEA_ENCRYPTION_KEY=aaaaaaaaa +# To: +ENCRYPTION_KEY=aaaaaaaaa +``` + +- [ ] **Step 3: Verify app still works** + +Run: `make cache-clear` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/Service/TokenEncryptor.php .env +git commit -m "refactor : rename GITEA_ENCRYPTION_KEY to ENCRYPTION_KEY" +``` + +--- + +### Task 2: BookStackConfiguration Entity + +**Files:** +- Create: `src/Entity/BookStackConfiguration.php` +- Create: `src/Repository/BookStackConfigurationRepository.php` + +- [ ] **Step 1: Create BookStackConfiguration entity** + +```php +id; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $url): static + { + $this->url = $url; + + return $this; + } + + public function getEncryptedTokenId(): ?string + { + return $this->encryptedTokenId; + } + + public function setEncryptedTokenId(?string $encryptedTokenId): static + { + $this->encryptedTokenId = $encryptedTokenId; + + return $this; + } + + public function getEncryptedTokenSecret(): ?string + { + return $this->encryptedTokenSecret; + } + + public function setEncryptedTokenSecret(?string $encryptedTokenSecret): static + { + $this->encryptedTokenSecret = $encryptedTokenSecret; + + return $this; + } + + public function hasToken(): bool + { + return null !== $this->encryptedTokenId && null !== $this->encryptedTokenSecret; + } +} +``` + +- [ ] **Step 2: Create BookStackConfigurationRepository** + +```php +findOneBy([]); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/BookStackConfiguration.php src/Repository/BookStackConfigurationRepository.php +git commit -m "feat(bookstack) : add BookStackConfiguration entity and repository" +``` + +--- + +### Task 3: TaskBookStackLink Entity + +**Files:** +- Create: `src/Entity/TaskBookStackLink.php` +- Create: `src/Repository/TaskBookStackLinkRepository.php` + +- [ ] **Step 1: Create TaskBookStackLink entity** + +```php +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTask(): Task + { + return $this->task; + } + + public function setTask(Task $task): static + { + $this->task = $task; + + return $this; + } + + public function getBookstackId(): int + { + return $this->bookstackId; + } + + public function setBookstackId(int $bookstackId): static + { + $this->bookstackId = $bookstackId; + + return $this; + } + + public function getBookstackType(): string + { + return $this->bookstackType; + } + + public function setBookstackType(string $bookstackType): static + { + $this->bookstackType = $bookstackType; + + return $this; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getUrl(): string + { + return $this->url; + } + + public function setUrl(string $url): static + { + $this->url = $url; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} +``` + +- [ ] **Step 2: Create TaskBookStackLinkRepository** + +```php +findBy(['task' => $taskId], ['createdAt' => 'DESC']); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/TaskBookStackLink.php src/Repository/TaskBookStackLinkRepository.php +git commit -m "feat(bookstack) : add TaskBookStackLink entity and repository" +``` + +--- + +### Task 4: Extend Project Entity + +**Files:** +- Modify: `src/Entity/Project.php` + +- [ ] **Step 1: Add BookStack fields to Project** + +After the `giteaRepo` property (around line 76), add: + +```php + #[ORM\Column(nullable: true)] + #[Groups(['project:read', 'project:write', 'task:read'])] + private ?int $bookstackShelfId = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['project:read', 'project:write'])] + private ?string $bookstackShelfName = null; +``` + +At the end of the class (before the closing `}`), add getters and setters: + +```php + public function getBookstackShelfId(): ?int + { + return $this->bookstackShelfId; + } + + public function setBookstackShelfId(?int $bookstackShelfId): static + { + $this->bookstackShelfId = $bookstackShelfId; + + return $this; + } + + public function getBookstackShelfName(): ?string + { + return $this->bookstackShelfName; + } + + public function setBookstackShelfName(?string $bookstackShelfName): static + { + $this->bookstackShelfName = $bookstackShelfName; + + return $this; + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Project.php +git commit -m "feat(bookstack) : add bookstackShelfId and bookstackShelfName to Project" +``` + +--- + +### Task 5: Generate and Run Migration + +**Files:** +- Create: `migrations/VersionXXXX.php` (auto-generated) + +- [ ] **Step 1: Generate migration** + +Run inside the PHP container: + +```bash +make shell +# Then inside the container: +php bin/console doctrine:migrations:diff +``` + +Verify the generated migration contains: +- `CREATE TABLE bookstack_configuration` with `id`, `url`, `encrypted_token_id`, `encrypted_token_secret` +- `CREATE TABLE task_bookstack_link` with `id`, `task_id`, `bookstack_id`, `bookstack_type`, `title`, `url`, `created_at` +- Index on `task_bookstack_link.task_id` +- Unique constraint on `(task_id, bookstack_id, bookstack_type)` +- `ALTER TABLE project ADD bookstack_shelf_id`, `bookstack_shelf_name` + +- [ ] **Step 2: Run migration** + +```bash +make migration-migrate +``` + +Expected: Migration executes successfully. + +- [ ] **Step 3: Commit** + +```bash +git add migrations/ +git commit -m "feat(bookstack) : add migration for BookStack tables and Project columns" +``` + +--- + +### Task 6: BookStackApiException + +**Files:** +- Create: `src/Exception/BookStackApiException.php` + +- [ ] **Step 1: Create exception class** + +```php + */ + 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'], + ]; + } + } + // Ignore chapter and bookshelf types + } + + 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); + } + } +} +``` + +- [ ] **Step 2: Verify no syntax errors** + +Run: `make cache-clear` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/Service/BookStackApiService.php +git commit -m "feat(bookstack) : add BookStackApiService" +``` + +--- + +## Chunk 2: API Resources & State Providers/Processors + +### Task 8: BookStackSettings API Resource + +**Files:** +- Create: `src/ApiResource/BookStackSettings.php` +- Create: `src/State/BookStackSettingsProvider.php` +- Create: `src/State/BookStackSettingsProcessor.php` + +- [ ] **Step 1: Create BookStackSettings DTO** + +```php + ['bookstack_settings:read']], + provider: BookStackSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Put( + uriTemplate: '/settings/bookstack', + denormalizationContext: ['groups' => ['bookstack_settings:write']], + normalizationContext: ['groups' => ['bookstack_settings:read']], + provider: BookStackSettingsProvider::class, + processor: BookStackSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class BookStackSettings +{ + #[Groups(['bookstack_settings:read', 'bookstack_settings:write'])] + public ?string $url = null; + + #[Groups(['bookstack_settings:write'])] + public ?string $tokenId = null; + + #[Groups(['bookstack_settings:write'])] + public ?string $tokenSecret = null; + + #[Groups(['bookstack_settings:read'])] + public bool $hasToken = false; +} +``` + +- [ ] **Step 2: Create BookStackSettingsProvider** + +```php +configRepository->findSingleton(); + $dto = new BookStackSettings(); + + if (null !== $config) { + $dto->url = $config->getUrl(); + $dto->hasToken = $config->hasToken(); + } + + return $dto; + } +} +``` + +- [ ] **Step 3: Create BookStackSettingsProcessor** + +```php +configRepository->findSingleton(); + if (null === $config) { + $config = new BookStackConfiguration(); + } + + $config->setUrl($data->url); + + if (null !== $data->tokenId && '' !== $data->tokenId + && null !== $data->tokenSecret && '' !== $data->tokenSecret) { + $config->setEncryptedTokenId($this->tokenEncryptor->encrypt($data->tokenId)); + $config->setEncryptedTokenSecret($this->tokenEncryptor->encrypt($data->tokenSecret)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new BookStackSettings(); + $result->url = $config->getUrl(); + $result->hasToken = $config->hasToken(); + + return $result; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/BookStackSettings.php src/State/BookStackSettingsProvider.php src/State/BookStackSettingsProcessor.php +git commit -m "feat(bookstack) : add BookStackSettings API resource with provider and processor" +``` + +--- + +### Task 9: BookStackTestConnection API Resource + +**Files:** +- Create: `src/ApiResource/BookStackTestConnection.php` +- Create: `src/State/BookStackTestConnectionProvider.php` + +- [ ] **Step 1: Create BookStackTestConnection DTO** + +```php + ['bookstack_test:read']], + provider: BookStackTestConnectionProvider::class, + processor: BookStackTestConnectionProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class BookStackTestConnection +{ + #[Groups(['bookstack_test:read'])] + public bool $success = false; +} +``` + +- [ ] **Step 2: Create BookStackTestConnectionProvider** + +```php +success = $this->bookStackApiService->testConnection(); + + return $result; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/BookStackTestConnection.php src/State/BookStackTestConnectionProvider.php +git commit -m "feat(bookstack) : add BookStackTestConnection API resource" +``` + +--- + +### Task 10: BookStackShelf API Resource + +**Files:** +- Create: `src/ApiResource/BookStackShelf.php` +- Create: `src/State/BookStackShelfProvider.php` + +- [ ] **Step 1: Create BookStackShelf DTO** + +```php + ['bookstack_shelf:read']], + provider: BookStackShelfProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class BookStackShelf +{ + #[Groups(['bookstack_shelf:read'])] + public int $id = 0; + + #[Groups(['bookstack_shelf:read'])] + public string $name = ''; +} +``` + +- [ ] **Step 2: Create BookStackShelfProvider** + +```php +bookStackApiService->listShelves(); + } catch (BookStackApiException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + return array_map(static function (array $shelf): BookStackShelf { + $dto = new BookStackShelf(); + $dto->id = $shelf['id'] ?? 0; + $dto->name = $shelf['name'] ?? ''; + + return $dto; + }, $shelves); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/BookStackShelf.php src/State/BookStackShelfProvider.php +git commit -m "feat(bookstack) : add BookStackShelf API resource for listing shelves" +``` + +--- + +### Task 11: BookStackLink API Resource (CRUD) + +**Files:** +- Create: `src/ApiResource/BookStackLink.php` +- Create: `src/State/BookStackLinkProvider.php` +- Create: `src/State/BookStackLinkProcessor.php` + +- [ ] **Step 1: Create BookStackLink DTO** + +```php + ['bookstack_link:read']], + provider: BookStackLinkProvider::class, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + new Post( + uriTemplate: '/tasks/{taskId}/bookstack/links', + denormalizationContext: ['groups' => ['bookstack_link:write']], + normalizationContext: ['groups' => ['bookstack_link:read']], + provider: BookStackLinkProvider::class, + processor: BookStackLinkProcessor::class, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + new Delete( + uriTemplate: '/tasks/{taskId}/bookstack/links/{id}', + provider: BookStackLinkProvider::class, + processor: BookStackLinkProcessor::class, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + ], +)] +final class BookStackLink +{ + #[Groups(['bookstack_link:read'])] + public ?int $id = null; + + #[Groups(['bookstack_link:read', 'bookstack_link:write'])] + public int $bookstackId = 0; + + #[Groups(['bookstack_link:read', 'bookstack_link:write'])] + public string $bookstackType = ''; + + #[Groups(['bookstack_link:read', 'bookstack_link:write'])] + public string $title = ''; + + #[Groups(['bookstack_link:read', 'bookstack_link:write'])] + public string $url = ''; + + #[Groups(['bookstack_link:read'])] + public ?string $createdAt = null; +} +``` + +- [ ] **Step 2: Create BookStackLinkProvider** + +```php +linkRepository->find($uriVariables['id'] ?? 0); + if (null === $link) { + throw new NotFoundHttpException('Link not found.'); + } + $dto = new BookStackLink(); + $dto->id = $link->getId(); + + return $dto; + } + + $taskId = $uriVariables['taskId'] ?? 0; + $links = $this->linkRepository->findByTaskId($taskId); + + return array_map(static function (\App\Entity\TaskBookStackLink $link): BookStackLink { + $dto = new BookStackLink(); + $dto->id = $link->getId(); + $dto->bookstackId = $link->getBookstackId(); + $dto->bookstackType = $link->getBookstackType(); + $dto->title = $link->getTitle(); + $dto->url = $link->getUrl(); + $dto->createdAt = $link->getCreatedAt()->format('c'); + + return $dto; + }, $links); + } +} +``` + +- [ ] **Step 3: Create BookStackLinkProcessor** + +```php +handleDelete($uriVariables); + } + + return $this->handleCreate($data, $uriVariables); + } + + private function handleCreate(mixed $data, array $uriVariables): BookStackLink + { + assert($data instanceof BookStackLink); + + $taskId = $uriVariables['taskId'] ?? 0; + $task = $this->em->getRepository(Task::class)->find($taskId); + + if (null === $task) { + throw new NotFoundHttpException('Task not found.'); + } + + $link = new TaskBookStackLink(); + $link->setTask($task); + $link->setBookstackId($data->bookstackId); + $link->setBookstackType($data->bookstackType); + $link->setTitle($data->title); + $link->setUrl($data->url); + + $this->em->persist($link); + $this->em->flush(); + + $result = new BookStackLink(); + $result->id = $link->getId(); + $result->bookstackId = $link->getBookstackId(); + $result->bookstackType = $link->getBookstackType(); + $result->title = $link->getTitle(); + $result->url = $link->getUrl(); + $result->createdAt = $link->getCreatedAt()->format('c'); + + return $result; + } + + private function handleDelete(array $uriVariables): null + { + $linkId = $uriVariables['id'] ?? 0; + $link = $this->linkRepository->find($linkId); + + if (null === $link) { + throw new NotFoundHttpException('Link not found.'); + } + + $this->em->remove($link); + $this->em->flush(); + + return null; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/BookStackLink.php src/State/BookStackLinkProvider.php src/State/BookStackLinkProcessor.php +git commit -m "feat(bookstack) : add BookStackLink API resource with CRUD operations" +``` + +--- + +### Task 12: BookStackSearchResult API Resource + +**Files:** +- Create: `src/ApiResource/BookStackSearchResult.php` +- Create: `src/State/BookStackSearchResultProvider.php` + +- [ ] **Step 1: Create BookStackSearchResult DTO** + +```php + ['bookstack_search:read']], + provider: BookStackSearchResultProvider::class, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + ], +)] +final class BookStackSearchResult +{ + #[Groups(['bookstack_search:read'])] + public int $id = 0; + + #[Groups(['bookstack_search:read'])] + public string $type = ''; + + #[Groups(['bookstack_search:read'])] + public string $name = ''; + + #[Groups(['bookstack_search:read'])] + public string $url = ''; +} +``` + +- [ ] **Step 2: Create BookStackSearchResultProvider** + +The provider reads the `q` query parameter from the request, resolves the task's project shelf, and calls the API service. + +```php +em->getRepository(Task::class)->find($taskId); + + if (null === $task || null === $task->getProject()) { + return []; + } + + $shelfId = $task->getProject()->getBookstackShelfId(); + if (null === $shelfId) { + return []; + } + + $request = $this->requestStack->getCurrentRequest(); + $query = $request?->query->get('q', '') ?? ''; + + if ('' === trim($query)) { + return []; + } + + try { + $results = $this->bookStackApiService->searchInShelf($shelfId, $query); + } catch (BookStackApiException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + return array_map(static function (array $item): BookStackSearchResult { + $dto = new BookStackSearchResult(); + $dto->id = $item['id']; + $dto->type = $item['type']; + $dto->name = $item['name']; + $dto->url = $item['url']; + + return $dto; + }, $results); + } +} +``` + +- [ ] **Step 3: Verify cache clear passes** + +Run: `make cache-clear` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/BookStackSearchResult.php src/State/BookStackSearchResultProvider.php +git commit -m "feat(bookstack) : add BookStackSearchResult API resource for shelf-scoped search" +``` + +--- + +## Chunk 3: Frontend — Service, DTOs, Admin Tab + +### Task 13: Frontend DTOs + +**Files:** +- Create: `frontend/services/dto/bookstack.ts` + +- [ ] **Step 1: Create BookStack DTOs** + +```typescript +export type BookStackSettings = { + url: string | null + hasToken: boolean +} + +export type BookStackSettingsWrite = { + url: string | null + tokenId: string | null + tokenSecret: string | null +} + +export type BookStackTestResult = { + success: boolean +} + +export type BookStackShelf = { + id: number + name: string +} + +export type BookStackLink = { + id: number + bookstackId: number + bookstackType: 'page' | 'book' + title: string + url: string + createdAt: string +} + +export type BookStackLinkCreate = { + bookstackId: number + bookstackType: 'page' | 'book' + title: string + url: string +} + +export type BookStackSearchResult = { + id: number + type: 'page' | 'book' + name: string + url: string +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/dto/bookstack.ts +git commit -m "feat(bookstack) : add frontend BookStack DTOs" +``` + +--- + +### Task 14: Frontend BookStack Service + +**Files:** +- Create: `frontend/services/bookstack.ts` + +- [ ] **Step 1: Create the service** + +```typescript +import type { + BookStackSettings, + BookStackSettingsWrite, + BookStackTestResult, + BookStackShelf, + BookStackLink, + BookStackLinkCreate, + BookStackSearchResult, +} from './dto/bookstack' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useBookStackService() { + const api = useApi() + + async function getSettings(): Promise { + return api.get('/settings/bookstack') + } + + async function saveSettings(payload: BookStackSettingsWrite): Promise { + return api.put('/settings/bookstack', payload as Record, { + toastSuccessKey: 'bookstack.settings.saved', + }) + } + + async function testConnection(): Promise { + return api.post('/settings/bookstack/test') + } + + async function listShelves(): Promise { + const data = await api.get>('/bookstack/shelves') + return extractHydraMembers(data) + } + + async function getLinks(taskId: number): Promise { + const data = await api.get>(`/tasks/${taskId}/bookstack/links`) + return extractHydraMembers(data) + } + + async function addLink(taskId: number, payload: BookStackLinkCreate): Promise { + return api.post(`/tasks/${taskId}/bookstack/links`, payload as Record) + } + + async function removeLink(taskId: number, linkId: number): Promise { + await api.delete(`/tasks/${taskId}/bookstack/links/${linkId}`) + } + + async function search(taskId: number, query: string): Promise { + const data = await api.get>( + `/tasks/${taskId}/bookstack/search`, + { q: query }, + ) + return extractHydraMembers(data) + } + + return { + getSettings, + saveSettings, + testConnection, + listShelves, + getLinks, + addLink, + removeLink, + search, + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/bookstack.ts +git commit -m "feat(bookstack) : add frontend BookStack service" +``` + +--- + +### Task 15: i18n Translations + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +- [ ] **Step 1: Add BookStack translations** + +Add the following block inside the root JSON object (after the `"gitea"` block): + +```json + "bookstack": { + "settings": { + "title": "Configuration BookStack", + "url": "URL du serveur", + "urlPlaceholder": "https://wiki.example.com", + "tokenId": "Token ID", + "tokenIdPlaceholder": "Entrez le Token ID", + "tokenSecret": "Token Secret", + "tokenSecretPlaceholder": "Entrez le Token Secret", + "tokenConfigured": "Token configuré", + "save": "Enregistrer", + "saved": "Configuration BookStack sauvegardée.", + "testConnection": "Tester la connexion", + "testSuccess": "Connexion réussie.", + "testFailed": "Connexion échouée." + }, + "links": { + "title": "Documentation", + "searchPlaceholder": "Rechercher une page ou un livre...", + "noResults": "Aucun résultat", + "empty": "Aucun document lié" + } + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(bookstack) : add i18n translations for BookStack" +``` + +--- + +### Task 16: AdminBookStackTab Component + +**Files:** +- Create: `frontend/components/admin/AdminBookStackTab.vue` + +- [ ] **Step 1: Create the admin tab component** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/admin/AdminBookStackTab.vue +git commit -m "feat(bookstack) : add AdminBookStackTab component" +``` + +--- + +### Task 17: Add BookStack Tab to Admin Page + +**Files:** +- Modify: `frontend/pages/admin.vue` + +- [ ] **Step 1: Add the BookStack tab** + +In `frontend/pages/admin.vue`: + +1. In the `tabs` array, add after the gitea entry: + +```typescript + { key: 'bookstack', label: 'BookStack' }, +``` + +2. In the `type TabKey` union, the new key is automatically included since it's derived from `typeof tabs[number]['key']`. + +3. In the template, add after ``: + +```html + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/pages/admin.vue +git commit -m "feat(bookstack) : add BookStack tab to admin page" +``` + +--- + +## Chunk 4: Frontend — Project Drawer, Task Component, Task Modal + +### Task 18: Update ProjectDrawer with Shelf Select + +**Files:** +- Modify: `frontend/components/project/ProjectDrawer.vue` +- Modify: `frontend/services/dto/project.ts` + +- [ ] **Step 1: Update Project DTOs** + +In `frontend/services/dto/project.ts`: + +Add to `Project` type: + +```typescript + bookstackShelfId: number | null + bookstackShelfName: string | null +``` + +Add to `ProjectWrite` type: + +```typescript + bookstackShelfId?: number | null + bookstackShelfName?: string | null +``` + +- [ ] **Step 2: Update ProjectDrawer script** + +In `frontend/components/project/ProjectDrawer.vue`: + +1. Add import at the top of the script: + +```typescript +import type { BookStackShelf } from '~/services/dto/bookstack' +import { useBookStackService } from '~/services/bookstack' +``` + +2. After the Gitea service setup (around line 93-97), add: + +```typescript +const { listShelves } = useBookStackService() +const bookstackShelves = ref([]) + +const bookstackShelfOptions = computed(() => + bookstackShelves.value.map(s => ({ label: s.name, value: s.id })) +) +``` + +3. Add `bookstackShelfId` to the `form` reactive object: + +```typescript + bookstackShelfId: null as number | null, +``` + +4. In the `watch` that populates the form when the drawer opens, add (in the `if (props.project)` branch): + +```typescript + form.bookstackShelfId = props.project.bookstackShelfId ?? null +``` + +And in the `else` branch: + +```typescript + form.bookstackShelfId = null +``` + +5. In `handleSubmit`, after the Gitea payload block (after `payload.giteaRepo = null`), add: + +```typescript + if (form.bookstackShelfId) { + const shelf = bookstackShelves.value.find(s => s.id === form.bookstackShelfId) + payload.bookstackShelfId = form.bookstackShelfId + payload.bookstackShelfName = shelf?.name ?? null + } else { + payload.bookstackShelfId = null + payload.bookstackShelfName = null + } +``` + +6. In `onMounted`, after the Gitea repos loading, add: + +```typescript + try { + bookstackShelves.value = await listShelves() + } catch { + // BookStack not configured, ignore + } +``` + +- [ ] **Step 3: Update ProjectDrawer template** + +After the Gitea repo select `
` (around line 35-43), add: + +```html +
+ +
+``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/services/dto/project.ts frontend/components/project/ProjectDrawer.vue +git commit -m "feat(bookstack) : add shelf select to ProjectDrawer" +``` + +--- + +### Task 19: TaskBookStackLinks Component + +**Files:** +- Create: `frontend/components/task/TaskBookStackLinks.vue` + +- [ ] **Step 1: Create the component** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/task/TaskBookStackLinks.vue +git commit -m "feat(bookstack) : add TaskBookStackLinks component" +``` + +--- + +### Task 20: Integrate into TaskModal + +**Files:** +- Modify: `frontend/components/task/TaskModal.vue` + +- [ ] **Step 1: Add BookStack section to TaskModal template** + +In `frontend/components/task/TaskModal.vue`, after the `TaskGitSection` block (around line 82-84): + +```html + + +``` + +- [ ] **Step 2: Add hasBookStack computed** + +In the `