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>
57 KiB
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:
// Change line 19:
#[Autowire('%env(GITEA_ENCRYPTION_KEY)%')]
// To:
#[Autowire('%env(ENCRYPTION_KEY)%')]
And update the assertConfigured() error message:
// 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.dockerdoes not containGITEA_ENCRYPTION_KEY(it may be indocker/.env.docker.localwhich is gitignored). Developers using.env.docker.localshould 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
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
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
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
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
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
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
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:
#[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:
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
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:
make shell
# Then inside the container:
php bin/console doctrine:migrations:diff
Verify the generated migration contains:
-
CREATE TABLE bookstack_configurationwithid,url,encrypted_token_id,encrypted_token_secret -
CREATE TABLE task_bookstack_linkwithid,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
make migration-migrate
Expected: Migration executes successfully.
- Step 3: Commit
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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):
"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
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
<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
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:
- In the
tabsarray, add after the gitea entry:
{ key: 'bookstack', label: 'BookStack' },
-
In the
type TabKeyunion, the new key is automatically included since it's derived fromtypeof tabs[number]['key']. -
In the template, add after
<AdminGiteaTab v-if="activeTab === 'gitea'" />:
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
- Step 2: Commit
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:
bookstackShelfId: number | null
bookstackShelfName: string | null
Add to ProjectWrite type:
bookstackShelfId?: number | null
bookstackShelfName?: string | null
- Step 2: Update ProjectDrawer script
In frontend/components/project/ProjectDrawer.vue:
- Add import at the top of the script:
import type { BookStackShelf } from '~/services/dto/bookstack'
import { useBookStackService } from '~/services/bookstack'
- After the Gitea service setup (around line 93-97), add:
const { listShelves } = useBookStackService()
const bookstackShelves = ref<BookStackShelf[]>([])
const bookstackShelfOptions = computed(() =>
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
)
- Add
bookstackShelfIdto theformreactive object:
bookstackShelfId: null as number | null,
- In the
watchthat populates the form when the drawer opens, add (in theif (props.project)branch):
form.bookstackShelfId = props.project.bookstackShelfId ?? null
And in the else branch:
form.bookstackShelfId = null
- In
handleSubmit, after the Gitea payload block (afterpayload.giteaRepo = null), add:
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
}
- In
onMounted, after the Gitea repos loading, add:
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:
<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
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
<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
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):
<!-- 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):
const hasBookStack = computed(() => {
return !!props.task?.project?.bookstackShelfId
})
- Step 3: Commit
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
make cache-clear
Expected: No errors.
- Step 2: Run PHP CS Fixer
make php-cs-fixer-allow-risky
Fix any code style issues if found.
- Step 3: Build Nuxt
cd frontend && npx nuxi build
Expected: Build succeeds with no TypeScript errors.
- Step 4: Run PHPUnit tests
make test
Expected: All existing tests pass.
- Step 5: Fix any issues found and commit
git add -A
git commit -m "fix(bookstack) : address code style and build issues"
(Only if fixes are needed.)