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>
2149 lines
57 KiB
Markdown
2149 lines
57 KiB
Markdown
# 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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Entity;
|
|
|
|
use App\Repository\BookStackConfigurationRepository;
|
|
use Doctrine\ORM\Mapping as ORM;
|
|
|
|
#[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)]
|
|
class BookStackConfiguration
|
|
{
|
|
#[ORM\Id]
|
|
#[ORM\GeneratedValue]
|
|
#[ORM\Column]
|
|
private ?int $id = null;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $url = null;
|
|
|
|
#[ORM\Column(type: 'text', nullable: true)]
|
|
private ?string $encryptedTokenId = null;
|
|
|
|
#[ORM\Column(type: 'text', nullable: true)]
|
|
private ?string $encryptedTokenSecret = null;
|
|
|
|
public function getId(): ?int
|
|
{
|
|
return $this->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Repository;
|
|
|
|
use App\Entity\BookStackConfiguration;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
class BookStackConfigurationRepository extends ServiceEntityRepository
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, BookStackConfiguration::class);
|
|
}
|
|
|
|
public function findSingleton(): ?BookStackConfiguration
|
|
{
|
|
return $this->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Entity;
|
|
|
|
use App\Repository\TaskBookStackLinkRepository;
|
|
use Doctrine\ORM\Mapping as ORM;
|
|
|
|
#[ORM\Entity(repositoryClass: TaskBookStackLinkRepository::class)]
|
|
#[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])]
|
|
class TaskBookStackLink
|
|
{
|
|
#[ORM\Id]
|
|
#[ORM\GeneratedValue]
|
|
#[ORM\Column]
|
|
private ?int $id = null;
|
|
|
|
#[ORM\ManyToOne(targetEntity: Task::class)]
|
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
private Task $task;
|
|
|
|
#[ORM\Column]
|
|
private int $bookstackId;
|
|
|
|
#[ORM\Column(length: 10)]
|
|
private string $bookstackType;
|
|
|
|
#[ORM\Column(length: 255)]
|
|
private string $title;
|
|
|
|
#[ORM\Column(length: 500)]
|
|
private string $url;
|
|
|
|
#[ORM\Column]
|
|
private \DateTimeImmutable $createdAt;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Repository;
|
|
|
|
use App\Entity\TaskBookStackLink;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
class TaskBookStackLinkRepository extends ServiceEntityRepository
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, TaskBookStackLink::class);
|
|
}
|
|
|
|
/** @return TaskBookStackLink[] */
|
|
public function findByTaskId(int $taskId): array
|
|
{
|
|
return $this->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Exception;
|
|
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class BookStackApiException extends RuntimeException
|
|
{
|
|
public function __construct(string $message, int $code = 0, ?Throwable $previous = null)
|
|
{
|
|
parent::__construct($message, $code, $previous);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/Exception/BookStackApiException.php
|
|
git commit -m "feat(bookstack) : add BookStackApiException"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: BookStackApiService
|
|
|
|
**Files:**
|
|
- Create: `src/Service/BookStackApiService.php`
|
|
|
|
- [ ] **Step 1: Create the API service**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Entity\BookStackConfiguration;
|
|
use App\Exception\BookStackApiException;
|
|
use App\Repository\BookStackConfigurationRepository;
|
|
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
use Throwable;
|
|
|
|
final class BookStackApiService
|
|
{
|
|
/** @var array<int, int[]> */
|
|
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<array{id: int, name: string}>
|
|
*/
|
|
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<array{id: int, type: string, name: string, url: string}>
|
|
*/
|
|
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<string, mixed> $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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ApiResource;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Get;
|
|
use ApiPlatform\Metadata\Put;
|
|
use App\State\BookStackSettingsProcessor;
|
|
use App\State\BookStackSettingsProvider;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
|
|
#[ApiResource(
|
|
operations: [
|
|
new Get(
|
|
uriTemplate: '/settings/bookstack',
|
|
normalizationContext: ['groups' => ['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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\ApiResource\BookStackSettings;
|
|
use App\Repository\BookStackConfigurationRepository;
|
|
|
|
final readonly class BookStackSettingsProvider implements ProviderInterface
|
|
{
|
|
public function __construct(
|
|
private BookStackConfigurationRepository $configRepository,
|
|
) {}
|
|
|
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookStackSettings
|
|
{
|
|
$config = $this->configRepository->findSingleton();
|
|
$dto = new BookStackSettings();
|
|
|
|
if (null !== $config) {
|
|
$dto->url = $config->getUrl();
|
|
$dto->hasToken = $config->hasToken();
|
|
}
|
|
|
|
return $dto;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create BookStackSettingsProcessor**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use App\ApiResource\BookStackSettings;
|
|
use App\Entity\BookStackConfiguration;
|
|
use App\Repository\BookStackConfigurationRepository;
|
|
use App\Service\TokenEncryptor;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
|
final readonly class BookStackSettingsProcessor implements ProcessorInterface
|
|
{
|
|
public function __construct(
|
|
private EntityManagerInterface $em,
|
|
private BookStackConfigurationRepository $configRepository,
|
|
private TokenEncryptor $tokenEncryptor,
|
|
) {}
|
|
|
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookStackSettings
|
|
{
|
|
assert($data instanceof BookStackSettings);
|
|
|
|
$config = $this->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ApiResource;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Post;
|
|
use App\State\BookStackTestConnectionProvider;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
|
|
#[ApiResource(
|
|
operations: [
|
|
new Post(
|
|
uriTemplate: '/settings/bookstack/test',
|
|
input: false,
|
|
normalizationContext: ['groups' => ['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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\ApiResource\BookStackTestConnection;
|
|
use App\Service\BookStackApiService;
|
|
|
|
final readonly class BookStackTestConnectionProvider implements ProviderInterface, ProcessorInterface
|
|
{
|
|
public function __construct(
|
|
private BookStackApiService $bookStackApiService,
|
|
) {}
|
|
|
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookStackTestConnection
|
|
{
|
|
return new BookStackTestConnection();
|
|
}
|
|
|
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookStackTestConnection
|
|
{
|
|
$result = new BookStackTestConnection();
|
|
$result->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ApiResource;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\GetCollection;
|
|
use App\State\BookStackShelfProvider;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
|
|
#[ApiResource(
|
|
operations: [
|
|
new GetCollection(
|
|
uriTemplate: '/bookstack/shelves',
|
|
normalizationContext: ['groups' => ['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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\ApiResource\BookStackShelf;
|
|
use App\Exception\BookStackApiException;
|
|
use App\Service\BookStackApiService;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
final readonly class BookStackShelfProvider implements ProviderInterface
|
|
{
|
|
public function __construct(
|
|
private BookStackApiService $bookStackApiService,
|
|
) {}
|
|
|
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
|
{
|
|
try {
|
|
$shelves = $this->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ApiResource;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Delete;
|
|
use ApiPlatform\Metadata\GetCollection;
|
|
use ApiPlatform\Metadata\Post;
|
|
use App\State\BookStackLinkProcessor;
|
|
use App\State\BookStackLinkProvider;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
|
|
#[ApiResource(
|
|
operations: [
|
|
new GetCollection(
|
|
uriTemplate: '/tasks/{taskId}/bookstack/links',
|
|
normalizationContext: ['groups' => ['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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Delete;
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\Metadata\Post;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\ApiResource\BookStackLink;
|
|
use App\Repository\TaskBookStackLinkRepository;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
final readonly class BookStackLinkProvider implements ProviderInterface
|
|
{
|
|
public function __construct(
|
|
private TaskBookStackLinkRepository $linkRepository,
|
|
) {}
|
|
|
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|BookStackLink
|
|
{
|
|
if ($operation instanceof Post) {
|
|
return new BookStackLink();
|
|
}
|
|
|
|
if ($operation instanceof Delete) {
|
|
$link = $this->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Delete;
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use App\ApiResource\BookStackLink;
|
|
use App\Entity\Task;
|
|
use App\Entity\TaskBookStackLink;
|
|
use App\Repository\TaskBookStackLinkRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
final readonly class BookStackLinkProcessor implements ProcessorInterface
|
|
{
|
|
public function __construct(
|
|
private EntityManagerInterface $em,
|
|
private TaskBookStackLinkRepository $linkRepository,
|
|
) {}
|
|
|
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?BookStackLink
|
|
{
|
|
if ($operation instanceof Delete) {
|
|
return $this->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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ApiResource;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\GetCollection;
|
|
use App\State\BookStackSearchResultProvider;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
|
|
#[ApiResource(
|
|
operations: [
|
|
new GetCollection(
|
|
uriTemplate: '/tasks/{taskId}/bookstack/search',
|
|
normalizationContext: ['groups' => ['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
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\ApiResource\BookStackSearchResult;
|
|
use App\Entity\Task;
|
|
use App\Exception\BookStackApiException;
|
|
use App\Service\BookStackApiService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
final readonly class BookStackSearchResultProvider implements ProviderInterface
|
|
{
|
|
public function __construct(
|
|
private BookStackApiService $bookStackApiService,
|
|
private EntityManagerInterface $em,
|
|
private RequestStack $requestStack,
|
|
) {}
|
|
|
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
|
{
|
|
$taskId = $uriVariables['taskId'] ?? 0;
|
|
$task = $this->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<BookStackSettings> {
|
|
return api.get<BookStackSettings>('/settings/bookstack')
|
|
}
|
|
|
|
async function saveSettings(payload: BookStackSettingsWrite): Promise<BookStackSettings> {
|
|
return api.put<BookStackSettings>('/settings/bookstack', payload as Record<string, unknown>, {
|
|
toastSuccessKey: 'bookstack.settings.saved',
|
|
})
|
|
}
|
|
|
|
async function testConnection(): Promise<BookStackTestResult> {
|
|
return api.post<BookStackTestResult>('/settings/bookstack/test')
|
|
}
|
|
|
|
async function listShelves(): Promise<BookStackShelf[]> {
|
|
const data = await api.get<HydraCollection<BookStackShelf>>('/bookstack/shelves')
|
|
return extractHydraMembers(data)
|
|
}
|
|
|
|
async function getLinks(taskId: number): Promise<BookStackLink[]> {
|
|
const data = await api.get<HydraCollection<BookStackLink>>(`/tasks/${taskId}/bookstack/links`)
|
|
return extractHydraMembers(data)
|
|
}
|
|
|
|
async function addLink(taskId: number, payload: BookStackLinkCreate): Promise<BookStackLink> {
|
|
return api.post<BookStackLink>(`/tasks/${taskId}/bookstack/links`, payload as Record<string, unknown>)
|
|
}
|
|
|
|
async function removeLink(taskId: number, linkId: number): Promise<void> {
|
|
await api.delete(`/tasks/${taskId}/bookstack/links/${linkId}`)
|
|
}
|
|
|
|
async function search(taskId: number, query: string): Promise<BookStackSearchResult[]> {
|
|
const data = await api.get<HydraCollection<BookStackSearchResult>>(
|
|
`/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
|
|
<template>
|
|
<div>
|
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('bookstack.settings.title') }}</h2>
|
|
|
|
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
|
<MalioInputText
|
|
v-model="form.url"
|
|
:label="$t('bookstack.settings.url')"
|
|
:placeholder="$t('bookstack.settings.urlPlaceholder')"
|
|
input-class="w-full"
|
|
/>
|
|
|
|
<div>
|
|
<MalioInputText
|
|
v-model="form.tokenId"
|
|
:label="$t('bookstack.settings.tokenId')"
|
|
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
|
input-class="w-full"
|
|
type="password"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<MalioInputText
|
|
v-model="form.tokenSecret"
|
|
:label="$t('bookstack.settings.tokenSecret')"
|
|
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
|
input-class="w-full"
|
|
type="password"
|
|
/>
|
|
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
|
{{ $t('bookstack.settings.tokenConfigured') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex gap-3">
|
|
<button
|
|
type="submit"
|
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
|
:disabled="isSaving"
|
|
>
|
|
{{ $t('bookstack.settings.save') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
|
:disabled="isTesting"
|
|
@click="handleTest"
|
|
>
|
|
{{ $t('bookstack.settings.testConnection') }}
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
|
{{ testResult ? $t('bookstack.settings.testSuccess') : $t('bookstack.settings.testFailed') }}
|
|
</p>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useBookStackService } from '~/services/bookstack'
|
|
|
|
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
|
|
|
const form = reactive({
|
|
url: '',
|
|
tokenId: '',
|
|
tokenSecret: '',
|
|
})
|
|
|
|
const hasToken = ref(false)
|
|
const isSaving = ref(false)
|
|
const isTesting = ref(false)
|
|
const testResult = ref<boolean | null>(null)
|
|
|
|
async function loadSettings() {
|
|
const settings = await getSettings()
|
|
form.url = settings.url ?? ''
|
|
hasToken.value = settings.hasToken
|
|
}
|
|
|
|
async function handleSave() {
|
|
isSaving.value = true
|
|
try {
|
|
const result = await saveSettings({
|
|
url: form.url.trim() || null,
|
|
tokenId: form.tokenId || null,
|
|
tokenSecret: form.tokenSecret || null,
|
|
})
|
|
hasToken.value = result.hasToken
|
|
form.tokenId = ''
|
|
form.tokenSecret = ''
|
|
testResult.value = null
|
|
} finally {
|
|
isSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function handleTest() {
|
|
isTesting.value = true
|
|
testResult.value = null
|
|
try {
|
|
const result = await testConnection()
|
|
testResult.value = result.success
|
|
} catch {
|
|
testResult.value = false
|
|
} finally {
|
|
isTesting.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadSettings()
|
|
})
|
|
</script>
|
|
```
|
|
|
|
- [ ] **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 `<AdminGiteaTab v-if="activeTab === 'gitea'" />`:
|
|
|
|
```html
|
|
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
|
```
|
|
|
|
- [ ] **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<BookStackShelf[]>([])
|
|
|
|
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 `<div>` (around line 35-43), add:
|
|
|
|
```html
|
|
<div v-if="bookstackShelves.length" class="mt-4">
|
|
<MalioSelect
|
|
v-model="form.bookstackShelfId"
|
|
:options="bookstackShelfOptions"
|
|
label="Étagère BookStack"
|
|
empty-option-label="Aucune étagère"
|
|
min-width="w-full"
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **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
|
|
<template>
|
|
<div class="mt-5">
|
|
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('bookstack.links.title') }}</p>
|
|
|
|
<!-- Search -->
|
|
<div class="relative">
|
|
<MalioInputText
|
|
v-model="searchQuery"
|
|
:placeholder="$t('bookstack.links.searchPlaceholder')"
|
|
input-class="w-full"
|
|
/>
|
|
|
|
<!-- Dropdown results -->
|
|
<div
|
|
v-if="searchResults.length > 0"
|
|
class="absolute z-30 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg"
|
|
>
|
|
<button
|
|
v-for="result in searchResults"
|
|
:key="`${result.type}-${result.id}`"
|
|
type="button"
|
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
|
@click="handleAdd(result)"
|
|
>
|
|
<Icon
|
|
:name="result.type === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
|
size="16"
|
|
class="shrink-0 text-neutral-400"
|
|
/>
|
|
<span class="truncate">{{ result.name }}</span>
|
|
<span class="ml-auto shrink-0 text-xs text-neutral-400">{{ result.type }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="searchQuery.length >= 2 && !isSearching && searchResults.length === 0 && hasSearched" class="mt-1 text-xs text-neutral-400">
|
|
{{ $t('bookstack.links.noResults') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Linked documents -->
|
|
<div v-if="links.length > 0" class="mt-3 space-y-1">
|
|
<div
|
|
v-for="link in links"
|
|
:key="link.id"
|
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-50"
|
|
>
|
|
<Icon
|
|
:name="link.bookstackType === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
|
size="16"
|
|
class="shrink-0 text-neutral-400"
|
|
/>
|
|
<a
|
|
:href="link.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="truncate text-primary-500 hover:underline"
|
|
>
|
|
{{ link.title }}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
|
@click="handleRemove(link.id)"
|
|
>
|
|
<Icon name="mdi:close" size="16" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-else-if="!isLoading" class="mt-2 text-xs text-neutral-400">
|
|
{{ $t('bookstack.links.empty') }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
|
|
import { useBookStackService } from '~/services/bookstack'
|
|
|
|
const props = defineProps<{
|
|
taskId: number
|
|
}>()
|
|
|
|
const { getLinks, addLink, removeLink, search } = useBookStackService()
|
|
|
|
const links = ref<BookStackLink[]>([])
|
|
const searchQuery = ref('')
|
|
const searchResults = ref<BookStackSearchResult[]>([])
|
|
const isLoading = ref(true)
|
|
const isSearching = ref(false)
|
|
const hasSearched = ref(false)
|
|
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
watch(searchQuery, (query) => {
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
hasSearched.value = false
|
|
searchResults.value = []
|
|
|
|
if (query.trim().length < 2) {
|
|
return
|
|
}
|
|
|
|
debounceTimer = setTimeout(async () => {
|
|
isSearching.value = true
|
|
try {
|
|
searchResults.value = await search(props.taskId, query.trim())
|
|
} catch {
|
|
searchResults.value = []
|
|
} finally {
|
|
isSearching.value = false
|
|
hasSearched.value = true
|
|
}
|
|
}, 300)
|
|
})
|
|
|
|
async function handleAdd(result: BookStackSearchResult) {
|
|
searchQuery.value = ''
|
|
searchResults.value = []
|
|
hasSearched.value = false
|
|
|
|
// Check if already linked
|
|
if (links.value.some(l => l.bookstackId === result.id && l.bookstackType === result.type)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const created = await addLink(props.taskId, {
|
|
bookstackId: result.id,
|
|
bookstackType: result.type,
|
|
title: result.name,
|
|
url: result.url,
|
|
})
|
|
links.value.unshift(created)
|
|
} catch {
|
|
// Error handled by useApi toast
|
|
}
|
|
}
|
|
|
|
async function handleRemove(linkId: number) {
|
|
try {
|
|
await removeLink(props.taskId, linkId)
|
|
links.value = links.value.filter(l => l.id !== linkId)
|
|
} catch {
|
|
// Error handled by useApi toast
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
links.value = await getLinks(props.taskId)
|
|
} catch {
|
|
// Error handled by useApi toast
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
})
|
|
</script>
|
|
```
|
|
|
|
- [ ] **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
|
|
<!-- BookStack links -->
|
|
<TaskBookStackLinks
|
|
v-if="hasBookStack && isEditing && task"
|
|
:task-id="task.id"
|
|
/>
|
|
```
|
|
|
|
- [ ] **Step 2: Add hasBookStack computed**
|
|
|
|
In the `<script setup>` section, after the `hasGitea` computed (around line 136):
|
|
|
|
```typescript
|
|
const hasBookStack = computed(() => {
|
|
return !!props.task?.project?.bookstackShelfId
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/components/task/TaskModal.vue
|
|
git commit -m "feat(bookstack) : integrate TaskBookStackLinks into TaskModal"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 5: Verification
|
|
|
|
### Task 21: End-to-End Verification
|
|
|
|
- [ ] **Step 1: Clear cache and verify backend**
|
|
|
|
```bash
|
|
make cache-clear
|
|
```
|
|
|
|
Expected: No errors.
|
|
|
|
- [ ] **Step 2: Run PHP CS Fixer**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
```
|
|
|
|
Fix any code style issues if found.
|
|
|
|
- [ ] **Step 3: Build Nuxt**
|
|
|
|
```bash
|
|
cd frontend && npx nuxi build
|
|
```
|
|
|
|
Expected: Build succeeds with no TypeScript errors.
|
|
|
|
- [ ] **Step 4: Run PHPUnit tests**
|
|
|
|
```bash
|
|
make test
|
|
```
|
|
|
|
Expected: All existing tests pass.
|
|
|
|
- [ ] **Step 5: Fix any issues found and commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix(bookstack) : address code style and build issues"
|
|
```
|
|
|
|
(Only if fixes are needed.)
|