# BookStack Connector — Design Spec

**Date:** 2026-03-15
**BookStack version:** v25.12.8
**Pattern:** Mirror of Gitea connector

## Overview

Connecteur BookStack permettant de lier des documents (pages et livres) du wiki à des tâches Lesstime. Chaque projet peut être associé à une étagère (shelf) BookStack, et les utilisateurs peuvent rechercher et lier des pages/livres de cette étagère à leurs tâches.

## Périmètre

- Types liés : **pages** et **livres** (books)
- Niveau projet : liaison à une **étagère** (shelf)
- Niveau tâche : liaison à une ou plusieurs **pages/livres** de l'étagère du projet
- Recherche : filtrée dans l'étagère du projet uniquement
- Stockage : **référence** (titre + URL), pas d'aperçu du contenu
- Auth BookStack : Token ID + Token Secret (header `Authorization: Token {id}:{secret}`)

## Backend

### Entités

#### BookStackConfiguration (singleton)

```php
// src/Entity/BookStackConfiguration.php
class BookStackConfiguration
{
    private ?int $id;
    private ?string $url = null;
    private ?string $encryptedTokenId = null;
    private ?string $encryptedTokenSecret = null;

    public function hasToken(): bool; // vérifie que les deux sont présents
}
```

- Chiffrement via `TokenEncryptor` existant (même pattern que Gitea)
- Repository avec `findSingleton()`

#### TaskBookStackLink

```php
// src/Entity/TaskBookStackLink.php
class TaskBookStackLink
{
    private ?int $id;
    private Task $task;              // ManyToOne, CASCADE on delete
    private int $bookstackId;        // ID dans BookStack
    private string $bookstackType;   // 'page' | 'book'
    private string $title;           // titre au moment du lien (cache)
    private string $url;             // URL complète
    private \DateTimeImmutable $createdAt;
}
```

#### Project (extension)

Ajout de deux champs :
- `bookstackShelfId` (nullable int)
- `bookstackShelfName` (nullable string) — cache du nom pour affichage

### Service

#### BookStackApiService

```php
// src/Service/BookStackApiService.php
class BookStackApiService
{
    public function testConnection(): bool;
    public function listShelves(): array;
    public function searchInShelf(int $shelfId, string $query): array;
    public function getPage(int $id): array;
    public function getBook(int $id): array;
}
```

- Utilise `HttpClientInterface` (Symfony HttpClient)
- Auth : header `Authorization: Token {tokenId}:{tokenSecret}`
- Timeout : 10 secondes
- `testConnection()` : GET `/api/docs.json`
- `listShelves()` : GET `/api/shelves` (paginé via `count`/`offset`, pas `page`/`limit` — spécificité BookStack)
- `searchInShelf()` : algorithme en 3 étapes :
  1. GET `/api/shelves/{shelfId}` → récupère la liste des `books` de l'étagère (IDs)
  2. GET `/api/search?query={query} {type:page|book}` → recherche globale (espace entre query et filtre type, BookStack syntax)
  3. Filtre côté PHP : pour les **books**, vérifie que `book.id` est dans la liste de l'étagère ; pour les **pages**, vérifie que `page.book_id` est dans la liste. Exclut les résultats `chapter` et `bookshelf`.
  - Note : la liste des books de l'étagère peut être cachée en mémoire pour la durée de la requête.
- `getPage()` : GET `/api/pages/{id}`
- `getBook()` : GET `/api/books/{id}`

#### BookStackApiException

```php
// src/Exception/BookStackApiException.php
class BookStackApiException extends \RuntimeException {}
```

### API Resources & Endpoints

#### Admin

| Méthode | Route | Ressource API Platform | Sécurité |
|---------|-------|----------------------|----------|
| GET | `/api/settings/bookstack` | BookStackSettings | ROLE_ADMIN |
| PUT | `/api/settings/bookstack` | BookStackSettings | ROLE_ADMIN |
| POST | `/api/settings/bookstack/test` | BookStackTestConnection | ROLE_ADMIN |

**BookStackSettings** (DTO) :
- Read : `url`, `hasToken`
- Write : `url`, `tokenId`, `tokenSecret`

**BookStackTestConnection** (DTO) :
- Read : `success`

#### Projet

| Méthode | Route | Ressource API Platform | Sécurité |
|---------|-------|----------------------|----------|
| GET | `/api/bookstack/shelves` | BookStackShelf | ROLE_ADMIN |

**BookStackShelf** (DTO) :
- Read : `id`, `name`

L'étagère sélectionnée est sauvée via le PATCH existant de Project (`bookstackShelfId`, `bookstackShelfName`).

#### Tâche

| Méthode | Route | Ressource API Platform | Sécurité |
|---------|-------|----------------------|----------|
| GET | `/api/tasks/{taskId}/bookstack/links` | BookStackLink | Authenticated |
| POST | `/api/tasks/{taskId}/bookstack/links` | BookStackLink | Authenticated |
| DELETE | `/api/tasks/{taskId}/bookstack/links/{id}` | BookStackLink | Authenticated |
| GET | `/api/tasks/{taskId}/bookstack/search?q=` | BookStackSearchResult | Authenticated |

**BookStackLink** (DTO) :
- Read : `id`, `bookstackId`, `bookstackType`, `title`, `url`, `createdAt`
- Write : `bookstackId`, `bookstackType`, `title`, `url`

**BookStackSearchResult** (DTO) :
- Read : `id`, `type`, `name`, `url`

### State Providers / Processors

| Classe | Rôle |
|--------|------|
| `BookStackSettingsProvider` | Lit config singleton, retourne DTO masqué |
| `BookStackSettingsProcessor` | Persiste config, chiffre tokens |
| `BookStackTestConnectionProvider` | Appelle `testConnection()` |
| `BookStackShelfProvider` | Appelle `listShelves()`, mappe en DTOs |
| `BookStackLinkProvider` | Lit `TaskBookStackLink` par task ID |
| `BookStackLinkProcessor` | POST : crée lien en DB / DELETE : supprime |
| `BookStackSearchResultProvider` | Appelle `searchInShelf()`, mappe en DTOs |

### Migration

```sql
CREATE TABLE bookstack_configuration (
    id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    url VARCHAR(255) DEFAULT NULL,
    encrypted_token_id TEXT DEFAULT NULL,
    encrypted_token_secret TEXT DEFAULT NULL
);

CREATE TABLE task_bookstack_link (
    id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    task_id INT NOT NULL REFERENCES task(id) ON DELETE CASCADE,
    bookstack_id INT NOT NULL,
    bookstack_type VARCHAR(10) NOT NULL,
    title VARCHAR(255) NOT NULL,
    url VARCHAR(500) NOT NULL,
    created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
);

CREATE INDEX IDX_task_bookstack_link_task_id ON task_bookstack_link (task_id);
CREATE UNIQUE INDEX UNIQ_task_bookstack_link ON task_bookstack_link (task_id, bookstack_id, bookstack_type);

ALTER TABLE project ADD bookstack_shelf_id INT DEFAULT NULL;
ALTER TABLE project ADD bookstack_shelf_name VARCHAR(255) DEFAULT NULL;
```

### Variable d'environnement

Prérequis : renommer `GITEA_ENCRYPTION_KEY` en `ENCRYPTION_KEY` (générique) dans `TokenEncryptor`, `.env`, et `docker/.env.docker`. Mettre à jour le message d'erreur dans `TokenEncryptor`. Cela permet de réutiliser le même service pour chiffrer les tokens BookStack (deux appels `encrypt()`/`decrypt()` : un pour tokenId, un pour tokenSecret).

### Notes techniques

- `BookStackTestConnectionProvider` implémente à la fois `ProviderInterface` et `ProcessorInterface` (même pattern que `GiteaTestConnectionProvider`)
- Les endpoints collection du frontend utilisent `extractHydraMembers()` pour extraire les résultats des réponses Hydra
- Les titres/URLs stockés dans `TaskBookStackLink` sont des snapshots au moment du lien — pas de rafraîchissement automatique (intentionnel)
- Le select étagère dans `ProjectDrawer` n'est affiché que pour les admins (endpoint `ROLE_ADMIN`)

## Frontend

### Service

```typescript
// frontend/services/bookstack.ts
export function useBookStackService() {
    // Admin
    async function getSettings(): Promise<BookStackSettings>
    async function saveSettings(payload: BookStackSettingsWrite): Promise<BookStackSettings>
    async function testConnection(): Promise<BookStackTestResult>

    // Projet
    async function listShelves(): Promise<BookStackShelf[]>

    // Tâche
    async function getLinks(taskId: number): Promise<BookStackLink[]>
    async function addLink(taskId: number, payload: BookStackLinkCreate): Promise<BookStackLink>
    async function removeLink(taskId: number, linkId: number): Promise<void>
    async function search(taskId: number, query: string): Promise<BookStackSearchResult[]>
}
```

### DTOs

```typescript
// frontend/services/dto/bookstack.ts
type BookStackSettings = { url: string | null; hasToken: boolean }
type BookStackSettingsWrite = { url: string | null; tokenId: string | null; tokenSecret: string | null }
type BookStackTestResult = { success: boolean }
type BookStackShelf = { id: number; name: string }
type BookStackLink = { id: number; bookstackId: number; bookstackType: 'page' | 'book'; title: string; url: string; createdAt: string }
type BookStackLinkCreate = { bookstackId: number; bookstackType: 'page' | 'book'; title: string; url: string }
type BookStackSearchResult = { id: number; type: 'page' | 'book'; name: string; url: string }
```

### Composants

#### AdminBookStackTab.vue

Onglet admin (même pattern que `AdminGiteaTab.vue`) :
- Champs : URL, Token ID, Token Secret
- Bouton "Tester la connexion" avec indicateur résultat
- Indicateur "Token configuré" (ne montre jamais le token)
- Sauvegarde via `saveSettings()`

#### ProjectDrawer.vue (extension)

- Si BookStack est configuré : select pour choisir une étagère
- Charge `listShelves()` à l'ouverture
- Sauvegarde `bookstackShelfId` + `bookstackShelfName` sur le projet via PATCH

#### TaskBookStackLinks.vue

Petit composant intégré dans `TaskModal.vue`, visible directement :
- **Input de recherche** avec debounce (~300ms) → appel `search(taskId, query)` → dropdown résultats
- Chaque résultat : icône (page 📄 / livre 📕) + titre — clic pour ajouter
- **Liste des liens** sous le champ recherche : icône type + titre cliquable (ouvre BookStack dans nouvel onglet) + bouton × supprimer
- Affiché uniquement si le projet de la tâche a une shelf BookStack configurée
- Charge les liens existants au mount via `getLinks(taskId)`

#### TaskModal.vue (extension)

- Ajoute `<TaskBookStackLinks>` dans le modal, conditionné par `project.bookstackShelfId`
- Passe `taskId` et `projectId` en props

## Fichiers à créer/modifier

### Backend — Nouveaux fichiers

```
src/Entity/BookStackConfiguration.php
src/Entity/TaskBookStackLink.php
src/Repository/BookStackConfigurationRepository.php
src/Repository/TaskBookStackLinkRepository.php
src/Service/BookStackApiService.php
src/Exception/BookStackApiException.php
src/ApiResource/BookStackSettings.php
src/ApiResource/BookStackTestConnection.php
src/ApiResource/BookStackShelf.php
src/ApiResource/BookStackLink.php
src/ApiResource/BookStackSearchResult.php
src/State/BookStackSettingsProvider.php
src/State/BookStackSettingsProcessor.php
src/State/BookStackTestConnectionProvider.php
src/State/BookStackShelfProvider.php
src/State/BookStackLinkProvider.php
src/State/BookStackLinkProcessor.php
src/State/BookStackSearchResultProvider.php
migrations/VersionXXXX.php
```

### Backend — Fichiers modifiés

```
src/Entity/Project.php  (ajout bookstackShelfId, bookstackShelfName)
src/Service/TokenEncryptor.php  (renommage GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY)
```

### Config — Fichiers modifiés

```
.env  (renommage GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY)
```

> Note : `docker/.env.docker` ne contient pas `GITEA_ENCRYPTION_KEY`. Les développeurs utilisant `docker/.env.docker.local` doivent le mettre à jour manuellement.

### Frontend — Nouveaux fichiers

```
frontend/services/bookstack.ts
frontend/services/dto/bookstack.ts
frontend/components/admin/AdminBookStackTab.vue
frontend/components/task/TaskBookStackLinks.vue
```

### Frontend — Fichiers modifiés

```
frontend/components/task/TaskModal.vue  (ajout TaskBookStackLinks)
frontend/components/project/ProjectDrawer.vue  (ajout select étagère)
frontend/components/admin/  (ajout onglet BookStack dans la page admin)
```
