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 $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|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, 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> $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, 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, ]; } }