Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
164 lines
6.0 KiB
PHP
164 lines
6.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The page tree as rows and as compact text (lit-divi-outline, spec §5.3/§5.7; also the
|
||
* "outline" of write results). Preset IDs are shown with their names when known.
|
||
*
|
||
* Ruling S18: a page can have far more nodes at one level than are useful to show (e.g. a
|
||
* 200-item slider). rows() therefore caps the number of *direct* children shown per parent
|
||
* (breadth, not depth) at $limit; the parent whose children got cut is marked
|
||
* 'truncated' => true. wasTruncated() additionally reports whether ANY cap fired, including at
|
||
* the top level (the page root has no row of its own to mark).
|
||
*/
|
||
final class Outline
|
||
{
|
||
/** Sane default breadth cap per parent when no explicit limit is given. */
|
||
public const DEFAULT_LIMIT = 50;
|
||
|
||
private bool $truncated = false;
|
||
|
||
/** @param array<string, string> $presetNames Preset ID => name. */
|
||
public function __construct(private array $presetNames = [])
|
||
{
|
||
}
|
||
|
||
/** Whether the last rows() call cut off any children (at any level, including the top). */
|
||
public function wasTruncated(): bool
|
||
{
|
||
return $this->truncated;
|
||
}
|
||
|
||
/**
|
||
* The outline text of a write result: the whole page ($sectionIds null) or the given
|
||
* sections one after another (none: text null), and whether any breadth cap fired (S18).
|
||
*
|
||
* @param list<string>|null $sectionIds
|
||
* @return array{text: ?string, truncated: bool}
|
||
*/
|
||
public function summary(NodeIndex $index, ?array $sectionIds = null): array
|
||
{
|
||
if ($sectionIds === []) {
|
||
return ['text' => null, 'truncated' => false];
|
||
}
|
||
|
||
$texts = [];
|
||
$truncated = false;
|
||
foreach ($sectionIds ?? [null] as $sectionId) {
|
||
$texts[] = $this->text($this->rows($index, $sectionId));
|
||
$truncated = $truncated || $this->wasTruncated();
|
||
}
|
||
|
||
return ['text' => implode("\n", $texts), 'truncated' => $truncated];
|
||
}
|
||
|
||
/**
|
||
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
|
||
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool,
|
||
* global_id: ?string}>
|
||
*/
|
||
public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array
|
||
{
|
||
$limit = $limit ?? self::DEFAULT_LIMIT;
|
||
$this->truncated = false;
|
||
|
||
$rows = [];
|
||
$rowIndexById = [];
|
||
$counts = [];
|
||
$skipped = [];
|
||
$baseDepth = null;
|
||
foreach ($index->entries() as $entry) {
|
||
$id = $entry['id'];
|
||
if ($rootId !== null && $id !== $rootId && ! str_starts_with($id, $rootId . '.')) {
|
||
continue;
|
||
}
|
||
|
||
$parentId = $index->parentId($entry['block']);
|
||
if ($parentId !== null && isset($skipped[$parentId])) {
|
||
$skipped[$id] = true;
|
||
continue;
|
||
}
|
||
|
||
$baseDepth ??= $entry['depth'];
|
||
$depth = $entry['depth'] - $baseDepth;
|
||
if ($maxDepth !== null && $depth > $maxDepth) {
|
||
continue;
|
||
}
|
||
|
||
$parentKey = $parentId ?? '';
|
||
$counts[$parentKey] = ($counts[$parentKey] ?? 0) + 1;
|
||
if ($counts[$parentKey] > $limit) {
|
||
$this->truncated = true;
|
||
$skipped[$id] = true;
|
||
if ($parentId !== null && isset($rowIndexById[$parentId])) {
|
||
$rows[$rowIndexById[$parentId]]['truncated'] = true;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
$rowIndexById[$id] = count($rows);
|
||
$rows[] = $this->row($entry['block'], $id, $depth);
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/** @param list<array<string, mixed>> $rows Output of rows(). */
|
||
public function text(array $rows): string
|
||
{
|
||
$lines = [];
|
||
foreach ($rows as $row) {
|
||
$line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type'];
|
||
$line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : '';
|
||
$line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : '';
|
||
$line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : '';
|
||
$line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : '';
|
||
$line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : '';
|
||
$line .= $row['editable'] ? '' : ' (nur lesbar)';
|
||
$line .= ($row['truncated'] ?? false) ? ' (weitere Kinder abgeschnitten)' : '';
|
||
$lines[] = $line;
|
||
}
|
||
|
||
return implode("\n", $lines);
|
||
}
|
||
|
||
/**
|
||
* @return array{id: string, type: string, label: string, excerpt: string, info: string,
|
||
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool,
|
||
* global_id: ?string}
|
||
*/
|
||
private function row(Block $block, string $id, int $depth): array
|
||
{
|
||
$editable = $block->isDivi() && ! $block->isOpaque();
|
||
$global = NodeSummary::globalRef($block);
|
||
$type = $block->isDivi() ? $block->shortName() : $block->name();
|
||
if ($global !== null && $global['block_name'] !== '') {
|
||
$type = str_starts_with($global['block_name'], 'divi/')
|
||
? substr($global['block_name'], 5)
|
||
: $global['block_name'];
|
||
}
|
||
|
||
return [
|
||
'id' => $id,
|
||
'type' => $type,
|
||
'label' => NodeSummary::label($block),
|
||
'excerpt' => NodeSummary::excerpt($block),
|
||
'info' => $editable ? NodeSummary::info($block) : '',
|
||
'presets' => array_map(
|
||
fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId,
|
||
NodeSummary::presetIds($block)
|
||
),
|
||
'children' => $editable ? count($block->children()) : 0,
|
||
'editable' => $editable,
|
||
'depth' => $depth,
|
||
'truncated' => false,
|
||
'global_id' => $global['id'] ?? null,
|
||
];
|
||
}
|
||
}
|