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

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

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.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
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"

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_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

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"

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:

  1. In the tabs array, add after the gitea entry:
    { key: 'bookstack', label: 'BookStack' },
  1. In the type TabKey union, the new key is automatically included since it's derived from typeof tabs[number]['key'].

  2. 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:

  1. Add import at the top of the script:
import type { BookStackShelf } from '~/services/dto/bookstack'
import { useBookStackService } from '~/services/bookstack'
  1. 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 }))
)
  1. Add bookstackShelfId to the form reactive object:
    bookstackShelfId: null as number | null,
  1. In the watch that populates the form when the drawer opens, add (in the if (props.project) branch):
            form.bookstackShelfId = props.project.bookstackShelfId ?? null

And in the else branch:

            form.bookstackShelfId = null
  1. In handleSubmit, after the Gitea payload block (after payload.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
        }
  1. 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"

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.)