feat: structure and content fingerprints, lit/library-find-similar
Fingerprint::structure()/content() (E11) and lit/library-find-similar (indirect, group Divi-Bibliothek): at most 200 posts per call (cursor), editable entries only (own posts without edit_others_posts), contents primed in chunks of 50, stops at 200 matches or 5000 compared nodes with a note; library entries and Theme Builder layouts are never scanned. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
<?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\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
|
||||
{
|
||||
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;
|
||||
|
||||
/** Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them. */
|
||||
private const NOT_SCANNED = [
|
||||
DiviLibraryStore::POST_TYPE,
|
||||
'et_theme_builder',
|
||||
'et_template',
|
||||
'et_header_layout',
|
||||
'et_body_layout',
|
||||
'et_footer_layout',
|
||||
];
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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::NOT_SCANNED, 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];
|
||||
}
|
||||
|
||||
/** @return list<string>|WP_Error */
|
||||
private function postTypes(mixed $postType): array|WP_Error
|
||||
{
|
||||
if ($postType !== null) {
|
||||
$type = in_array($postType, self::NOT_SCANNED, true)
|
||||
? PostGuard::postType('', $this->schema, 'edit_posts')
|
||||
: 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::NOT_SCANNED),
|
||||
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;
|
||||
}
|
||||
|
||||
$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' => Fingerprint::content($block) === $reference['info']['content'] ? 'identical' : 'structure',
|
||||
];
|
||||
if (count($matches) >= self::MAX_MATCHES) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save and lit-library-insert (direct).
|
||||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, lit-library-insert (direct) and, with the
|
||||
* content schema, lit/library-find-similar (E11).
|
||||
* 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.
|
||||
@@ -32,7 +34,8 @@ final class LibraryIntegration implements Integration
|
||||
private DiviSaver $saver,
|
||||
private ChangeLog $log,
|
||||
private DiviModules $modules,
|
||||
private ?Closure $diviWritable = null
|
||||
private ?Closure $diviWritable = null,
|
||||
private ?ContentSchema $schema = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -53,7 +56,7 @@ final class LibraryIntegration implements Integration
|
||||
|
||||
public function abilities(): array
|
||||
{
|
||||
return [
|
||||
$abilities = [
|
||||
new LibraryListAbility(
|
||||
$this->access,
|
||||
$this->loader,
|
||||
@@ -70,5 +73,10 @@ final class LibraryIntegration implements Integration
|
||||
),
|
||||
new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable),
|
||||
];
|
||||
if ($this->schema !== null) {
|
||||
$abilities[] = new LibraryFindSimilarAbility($this->access, $this->loader, $this->schema);
|
||||
}
|
||||
|
||||
return $abilities;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user