# 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 `