Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
393 lines
15 KiB
PHP
393 lines
15 KiB
PHP
<?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\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/library-find-similar (spec §5.7, E11): nodes across the site built like a reference node (post_id + id) or a
|
||
* library entry (library_id) – "identical" (Fingerprint::content) or "structure" (Fingerprint::structure, other
|
||
* values). Scans at most MAX_POSTS entries per call in ID order (cursor), only of Divi-enabled managed types – never
|
||
* library entries or Theme Builder layouts, which have their own gates –, only entries the user may edit (query
|
||
* restricted to own posts without edit_others_posts, like content-query), no index or meta (F31). Contents come in
|
||
* chunks of CONTENT_CHUNK through one primed post cache each (no query per post, bounded memory, as GlobalUsages).
|
||
* A call stops after the post that reaches MAX_MATCHES matches or MAX_NODES fingerprinted nodes; `note` says so
|
||
* and next_cursor resumes behind it. Needs the library right (canList); a hidden library answers its entries like
|
||
* missing IDs (LibraryAccess::item, P9/P12). Only hashes and the matched nodes' labels are returned.
|
||
*/
|
||
final class LibraryFindSimilarAbility implements Ability, RequiresDiviFeatures
|
||
{
|
||
public const MAX_POSTS = 200;
|
||
public const DEFAULT_POSTS = 100;
|
||
public const MAX_MATCHES = 200;
|
||
|
||
/** At most this many nodes of the reference's type are fingerprinted per call. */
|
||
public const MAX_NODES = 5000;
|
||
|
||
/** Post contents read with one primed cache (bounded memory). */
|
||
public const CONTENT_CHUNK = 50;
|
||
|
||
|
||
private const LIVING = ['publish', 'draft', 'pending', 'private', 'future'];
|
||
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviPageLoader $loader,
|
||
private ContentSchema $schema,
|
||
private Parser $parser = new Parser()
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/library-find-similar';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Ähnliche Abschnitte finden';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Sucht seitenübergreifend Knoten, die wie ein gegebener Knoten (post_id und id aus '
|
||
. 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher '
|
||
. 'Inhalt (Admin-Labels ausgenommen), "structure" = gleicher Aufbau (Modultypen, Spalten, Presets) mit '
|
||
. 'anderen Inhalten. Nur Einträge, die dieses Konto bearbeiten darf; höchstens 200 Einträge je Aufruf '
|
||
. '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen in Seiten und Beiträgen (replaceable: true) '
|
||
. 'ersetzt lit/library-make-global (library_id, targets).';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Vergleichsknotens, z. B. "s1".'],
|
||
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Statt post_id und id.'],
|
||
'post_type' => ['type' => 'string', 'description' => 'Nur diesen Inhaltstyp durchsuchen.'],
|
||
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_POSTS],
|
||
'cursor' => ['type' => 'string'],
|
||
],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'reference' => ['type' => 'object'],
|
||
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'scanned' => ['type' => 'integer'],
|
||
'next_cursor' => ['type' => ['string', 'null']],
|
||
'note' => ['type' => 'string'],
|
||
],
|
||
'required' => ['reference', 'matches', 'scanned', 'next_cursor'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::read();
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Scans Divi pages (PageRead) of the types that use the builder (Catalog), after the library right (Roles). A
|
||
* library entry as reference is read through LibraryAccess (Roles), no Divi call beyond that.
|
||
*/
|
||
public function diviFeatures(array $input): array
|
||
{
|
||
return [DiviFeature::PageRead, DiviFeature::Catalog, DiviFeature::Roles];
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return $this->access->canList() ?? true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->access->canList();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$reference = $this->reference($input);
|
||
if ($reference instanceof WP_Error) {
|
||
return $reference;
|
||
}
|
||
|
||
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
|
||
if ($offset === null) {
|
||
return Cursor::invalid();
|
||
}
|
||
|
||
$types = $this->postTypes($input['post_type'] ?? null);
|
||
if ($types instanceof WP_Error) {
|
||
return $types;
|
||
}
|
||
|
||
$limit = is_int($input['limit'] ?? null)
|
||
? max(1, min(self::MAX_POSTS, $input['limit']))
|
||
: self::DEFAULT_POSTS;
|
||
$args = [
|
||
'post_type' => $types,
|
||
'post_status' => self::LIVING,
|
||
'posts_per_page' => $limit + 1,
|
||
'offset' => $offset,
|
||
'orderby' => 'ID',
|
||
'order' => 'ASC',
|
||
'fields' => 'ids',
|
||
'no_found_rows' => true,
|
||
'suppress_filters' => false,
|
||
];
|
||
foreach ($types as $type) {
|
||
$own = PostGuard::ownPostsOnly($type);
|
||
if ($own !== null) {
|
||
$args['author'] = $own;
|
||
break;
|
||
}
|
||
}
|
||
$ids = array_values(array_map('intval', (array) get_posts($args)));
|
||
$scan = array_slice($ids, 0, $limit);
|
||
|
||
$matches = [];
|
||
$nodes = 0;
|
||
$scanned = 0;
|
||
foreach (array_chunk($scan, self::CONTENT_CHUNK) as $chunk) {
|
||
$fresh = _get_non_cached_ids($chunk, 'posts');
|
||
_prime_post_caches($chunk, false, false);
|
||
foreach ($chunk as $id) {
|
||
if (self::full($matches, $nodes)) {
|
||
break;
|
||
}
|
||
++$scanned;
|
||
$post = current_user_can('edit_post', $id) ? get_post($id) : null;
|
||
if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::notScanned(), true)) {
|
||
$this->collect($post, $reference, $matches, $nodes);
|
||
}
|
||
}
|
||
foreach ($fresh as $id) {
|
||
wp_cache_delete((int) $id, 'posts');
|
||
}
|
||
if (self::full($matches, $nodes)) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
$stopped = $scanned < count($scan);
|
||
$result = [
|
||
'reference' => $reference['info'],
|
||
'matches' => $matches,
|
||
'scanned' => $scanned,
|
||
'next_cursor' => $stopped || count($ids) > $limit ? Cursor::encode($offset + $scanned) : null,
|
||
];
|
||
if (self::full($matches, $nodes)) {
|
||
$result['note'] = sprintf(
|
||
'Suche nach %d Einträgen angehalten (höchstens %d Treffer bzw. %d verglichene Knoten je Aufruf); '
|
||
. 'der letzte Eintrag kann weitere Treffer enthalten. Weiter mit next_cursor, oder mit post_type '
|
||
. 'eingrenzen.',
|
||
$scanned,
|
||
self::MAX_MATCHES,
|
||
self::MAX_NODES
|
||
);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/** @param list<array<string, mixed>> $matches */
|
||
private static function full(array $matches, int $nodes): bool
|
||
{
|
||
return count($matches) >= self::MAX_MATCHES || $nodes >= self::MAX_NODES;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string}|WP_Error
|
||
*/
|
||
private function reference(array $input): array|WP_Error
|
||
{
|
||
if (isset($input['library_id'])) {
|
||
$item = $this->access->item($input['library_id'], false);
|
||
if ($item === null) {
|
||
return PostGuard::notFound((int) PostGuard::id($input['library_id']));
|
||
}
|
||
if ($item instanceof WP_Error) {
|
||
return $item;
|
||
}
|
||
|
||
try {
|
||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||
} catch (MalformedMarkup) {
|
||
$roots = [];
|
||
}
|
||
if (count($roots) !== 1 || $roots[0]->isOpaque()) {
|
||
return Errors::invalidInput(
|
||
sprintf('Bibliothekseintrag %d hat nicht genau einen lesbaren Knoten.', (int) $item->ID),
|
||
['library_id' => 'Eintrag mit genau einem Abschnitt, einer Zeile oder einem Modul']
|
||
);
|
||
}
|
||
|
||
return self::describe($roots[0], null, null, (int) $item->ID);
|
||
}
|
||
|
||
if (! isset($input['post_id'])) {
|
||
return Errors::invalidInput('Vergleich fehlt: library_id oder post_id und id angeben.', [
|
||
'library_id' => 'oder post_id und id',
|
||
]);
|
||
}
|
||
|
||
$page = $this->loader->load($input['post_id'], false);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
|
||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||
$node = $page->index->find($id);
|
||
if ($node === null) {
|
||
return DiviErrors::unknownNode($id, $page->post);
|
||
}
|
||
if ($node->isOpaque()) {
|
||
return Errors::invalidInput(
|
||
'Der Knoten ist ein globales Modul oder nicht lesbar; nichts wurde gesucht.',
|
||
['id' => 'lesbarer Knoten']
|
||
);
|
||
}
|
||
|
||
return self::describe($node, (int) $page->post->ID, $id, null);
|
||
}
|
||
|
||
/** @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} */
|
||
private static function describe(Block $block, ?int $postId, ?string $id, ?int $libraryId): array
|
||
{
|
||
$info = array_filter([
|
||
'type' => $block->name(),
|
||
'structure' => Fingerprint::structure($block),
|
||
'content' => Fingerprint::content($block),
|
||
'post_id' => $postId,
|
||
'id' => $id,
|
||
'library_id' => $libraryId,
|
||
], static fn (mixed $value): bool => $value !== null);
|
||
|
||
return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id];
|
||
}
|
||
|
||
/**
|
||
* Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them
|
||
* (review M-3: the Theme Builder types from DiviThemeBuilder::postTypes()).
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private static function notScanned(): array
|
||
{
|
||
return array_merge([DiviLibraryStore::POST_TYPE], DiviThemeBuilder::postTypes());
|
||
}
|
||
|
||
/** @return list<string>|WP_Error */
|
||
private function postTypes(mixed $postType): array|WP_Error
|
||
{
|
||
if ($postType !== null) {
|
||
// Review M-7: an excluded type answers like any unavailable one, by its name.
|
||
$type = in_array($postType, self::notScanned(), true)
|
||
? PostGuard::unavailableType((string) $postType)
|
||
: PostGuard::postType($postType, $this->schema, 'edit_posts');
|
||
|
||
return $type instanceof WP_Error ? $type : [(string) $postType];
|
||
}
|
||
|
||
$types = array_values(array_filter(
|
||
array_diff($this->schema->postTypeNames(), self::notScanned()),
|
||
fn (string $type): bool => $this->schema->diviEnabled($type)
|
||
));
|
||
|
||
return $types !== [] ? $types : Errors::notFound(
|
||
'Kein Inhaltstyp mit Divi-Builder verfügbar; nichts wurde gesucht.',
|
||
'Inhaltstypen mit lit/content-types (direkt: lit-content-types) prüfen.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} $reference
|
||
* @param list<array<string, mixed>> $matches
|
||
*/
|
||
private function collect(WP_Post $post, array $reference, array &$matches, int &$nodes): void
|
||
{
|
||
$content = (string) $post->post_content;
|
||
if (! str_contains($content, '<!-- wp:divi/')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$index = NodeIndex::build($this->parser->parse($content));
|
||
} catch (MalformedMarkup) {
|
||
return;
|
||
}
|
||
|
||
$name = $reference['block']->name();
|
||
foreach ($index->entries() as $entry) {
|
||
$block = $entry['block'];
|
||
$self = (int) $post->ID === $reference['post_id'] && $entry['id'] === $reference['id'];
|
||
if ($self || $block->isOpaque() || $block->name() !== $name) {
|
||
continue;
|
||
}
|
||
if (++$nodes > self::MAX_NODES) {
|
||
return;
|
||
}
|
||
if (Fingerprint::structure($block) !== $reference['info']['structure']) {
|
||
continue;
|
||
}
|
||
|
||
$identical = Fingerprint::content($block) === $reference['info']['content'];
|
||
$matches[] = [
|
||
'post_id' => (int) $post->ID,
|
||
'title' => (string) $post->post_title,
|
||
'status' => (string) $post->post_status,
|
||
'type' => (string) $post->post_type,
|
||
'id' => $entry['id'],
|
||
'label' => NodeSummary::label($block),
|
||
'tree_rev' => Rev::of($content),
|
||
'match' => $identical ? 'identical' : 'structure',
|
||
// Review M-8: lit/library-make-global replaces identical occurrences in pages and posts only.
|
||
'replaceable' => $identical
|
||
&& in_array((string) $post->post_type, LibraryMakeGlobalAbility::POST_TYPES, true),
|
||
];
|
||
if (count($matches) >= self::MAX_MATCHES) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|