feat: group Divi-Bibliothek with lit/library-list

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:29:37 +02:00
co-authored by Claude Opus 5.5
parent 520a639411
commit b7c714939d
7 changed files with 528 additions and 4 deletions
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration;
defined('ABSPATH') || exit;
/**
* The group "Divi-Bibliothek" (E9): lit/library-list, later lit/library-save and lit-library-insert.
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
* Divi 5 builder, like the Divi group.
*/
final class LibraryIntegration implements Integration
{
public function __construct(
private LibraryAccess $access,
private DiviPageLoader $loader,
private DesignSystemReader $design
) {
}
public function id(): string
{
return LibraryAccess::GROUP;
}
public function label(): string
{
return __('Divi-Bibliothek', 'wp-lit-mcp');
}
public function isAvailable(): bool
{
return DiviModules::isAvailable();
}
public function abilities(): array
{
return [
new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()),
];
}
}
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* lit/library-list: the Divi Library (spec §5.7, E9 – replaces lit/library-get). Without id a page
* of published entries (filters, cursor); with id one entry with its node outline, tree_rev and,
* for a global entry, the pages that show it. Rights: LibraryAccess (edit_posts + divi_library;
* every listed entry also edit_post – others are left out, without title or count). With id,
* everything but a library entry the user may read answers like a missing ID (ruling P12).
*/
final class LibraryListAbility implements Ability
{
/** At most this many entries are looked at per call while skipping entries the user may not edit. */
public const MAX_SCAN = 500;
public function __construct(
private LibraryAccess $access,
private DiviPageLoader $loader,
private DesignSystemReader $design,
private GlobalUsages $usages
) {
}
public function name(): string
{
return 'lit/library-list';
}
public function label(): string
{
return 'Divi-Bibliothek auflisten';
}
public function description(): string
{
return 'Listet Einträge der Divi-Bibliothek (Layouts, Abschnitte, Zeilen, Module; normal oder global), '
. 'filterbar nach layout_type, scope, search und category, blätterbar mit cursor. Mit id: ein Eintrag '
. 'im Detail – Knoten-Übersicht, tree_rev und bei globalen Einträgen die Seiten, die ihn zeigen '
. '(affected). Einfügen mit lit-library-insert; bearbeiten mit den Divi-Werkzeugen und der ID als post_id.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Ein Eintrag im Detail.'],
'layout_type' => ['type' => 'string', 'enum' => DiviLibraryStore::LAYOUT_TYPES],
'scope' => ['type' => 'string', 'enum' => ['normal', 'global']],
'search' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Suche im Titel.'],
'category' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Name einer Kategorie.'],
], Cursor::inputProperties()),
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'items' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'layout_type' => ['type' => ['string', 'null']],
'scope' => ['type' => 'string'],
'module_type' => ['type' => ['string', 'null']],
'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
'modified' => ['type' => 'string'],
],
'required' => [
'id', 'title', 'layout_type', 'scope', 'module_type', 'categories', 'tags', 'modified',
],
],
],
'next_cursor' => ['type' => ['string', 'null']],
'outline' => ['type' => 'string'],
'tree_rev' => ['type' => 'string'],
'rev' => ['type' => 'string'],
'edit_url' => ['type' => 'string'],
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
'affected_count' => ['type' => 'integer'],
],
'required' => ['items', 'next_cursor'],
];
}
public function annotations(): array
{
return Annotations::read();
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
if (isset($input['id'])) {
$page = $this->entry($input['id']);
return $page instanceof WP_Error ? $page : true;
}
return $this->access->canList() ?? true;
}
public function execute(array $input): mixed
{
if (isset($input['id'])) {
$page = $this->entry($input['id']);
return $page instanceof WP_Error ? $page : $this->detail($page);
}
$refusal = $this->access->canList();
if ($refusal !== null) {
return $refusal;
}
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$filters = array_filter(
[
'layout_type' => $input['layout_type'] ?? null,
'scope' => $input['scope'] ?? null,
'search' => $input['search'] ?? null,
'category' => $input['category'] ?? null,
],
static fn (mixed $value): bool => is_string($value) && $value !== ''
);
return $this->page($filters, Cursor::limit($input['limit'] ?? null), $offset);
}
/**
* One page of the entries the user may edit. The cursor counts the store's rows, so entries
* left out never shift a page; next_cursor points at the next readable entry, null when none
* follows (or the scan cap is reached with nothing left).
*
* @param array<string, string> $filters
* @return array{items: list<array<string, mixed>>, next_cursor: ?string}
*/
private function page(array $filters, int $limit, int $offset): array
{
$store = $this->access->store();
$items = [];
$position = $offset;
while ($position - $offset < self::MAX_SCAN) {
$rows = $store->find($filters, $limit, $position);
foreach ($rows as $post) {
if ($this->access->canRead($post) !== null) {
++$position;
continue;
}
if (count($items) === $limit) {
return ['items' => $items, 'next_cursor' => Cursor::encode($position)];
}
$items[] = $store->describe($post);
++$position;
}
if (count($rows) <= $limit) {
return ['items' => $items, 'next_cursor' => null];
}
}
return ['items' => $items, 'next_cursor' => Cursor::encode($position)];
}
/**
* The library entry $id loaded for reading. Anything that is not a library entry reads like a
* missing ID, so the tool tells nothing about other posts (ruling P12).
*/
private function entry(mixed $id): DiviPage|WP_Error
{
$postId = PostGuard::id($id);
if ($postId === null) {
return Errors::invalidInput('id ist keine gültige Eintrags-ID.', ['id' => 'positive Ganzzahl']);
}
$item = $this->access->item($postId, false);
if (! $item instanceof WP_Post) {
return $item ?? PostGuard::notFound($postId);
}
$page = $this->loader->load($postId, false);
if ($page instanceof WP_Error) {
return $page;
}
return $page->isLibraryItem() ? $page : PostGuard::notFound($postId);
}
/** @return array<string, mixed> */
private function detail(DiviPage $page): array
{
$item = $this->access->store()->describe($page->post);
$outline = new Outline($this->design->read()->presetNames());
$result = [
'items' => [$item],
'next_cursor' => null,
'outline' => $outline->text($outline->rows($page->index)),
'tree_rev' => $page->treeRev(),
'rev' => Rev::ofPost($page->post),
'edit_url' => $page->editUrl(),
];
if ($item['scope'] === 'global') {
$usages = $this->usages->of((int) $page->post->ID);
$result['affected'] = $usages['items'];
$result['affected_count'] = $usages['count'];
}
return $result;
}
}