Files
Lesstime/docs/superpowers/specs/2026-03-15-bookstack-connector-design.md
matthieu bfffbe7041 docs : add BookStack connector implementation plan
21-task plan covering backend (entities, migration, service, API
resources) and frontend (DTOs, service, admin tab, project drawer,
task modal integration). Reviewed and fixed: readonly class issue,
page URL construction, Delete provider handling, task:read group,
search query syntax, security attributes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:00:34 +01:00

317 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```