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,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||
|
||||
use stdClass;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Fingerprints of a node (E11) for lit/library-find-similar, the "similar section" warning (spec §5.5) and
|
||||
* lit/library-make-global. structure(): block names and nesting, column layout (flexType) and preset IDs – two
|
||||
* sections built alike with other texts share it. content(): every attribute but the admin label,
|
||||
* builderVersion and globalParent, children included – "the same section". Whitespace between blocks never
|
||||
* counts; an opaque block counts by its raw opener (content) or its effective type (structure). Pure PHP.
|
||||
*/
|
||||
final class Fingerprint
|
||||
{
|
||||
/** Top-level attributes that say nothing about what visitors see. */
|
||||
private const IGNORED = ['builderVersion', 'globalParent'];
|
||||
|
||||
public static function structure(Block $block): string
|
||||
{
|
||||
return self::hash(self::shape($block));
|
||||
}
|
||||
|
||||
public static function content(Block $block): string
|
||||
{
|
||||
return self::hash(self::body($block));
|
||||
}
|
||||
|
||||
private static function shape(Block $block): string
|
||||
{
|
||||
if ($block->isOpaque()) {
|
||||
return 'opaque:' . GlobalInstance::effectiveName($block);
|
||||
}
|
||||
|
||||
$presets = NodeSummary::presetIds($block);
|
||||
sort($presets);
|
||||
$children = array_map(self::shape(...), $block->children());
|
||||
|
||||
return $block->name() . '|' . (NodeSummary::columnWidth($block) ?? '') . '|' . implode(',', $presets)
|
||||
. '[' . implode(';', $children) . ']';
|
||||
}
|
||||
|
||||
private static function body(Block $block): string
|
||||
{
|
||||
if ($block->isOpaque()) {
|
||||
return 'opaque:' . (string) $block->rawOpen();
|
||||
}
|
||||
|
||||
$attrs = $block->attrs();
|
||||
foreach (self::IGNORED as $key) {
|
||||
unset($attrs[$key]);
|
||||
}
|
||||
if (is_array($attrs['module']['meta'] ?? null)) {
|
||||
unset($attrs['module']['meta']['adminLabel']);
|
||||
}
|
||||
$children = array_map(self::body(...), $block->children());
|
||||
|
||||
return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $children) . ']';
|
||||
}
|
||||
|
||||
/** Object keys sorted at every depth, so the key order Divi or the plugin wrote does not count. */
|
||||
private static function sorted(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof stdClass) {
|
||||
$value = (array) $value;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (! array_is_list($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
return array_map(self::sorted(...), $value);
|
||||
}
|
||||
|
||||
private static function hash(string $value): string
|
||||
{
|
||||
return substr(hash('sha256', $value), 0, 16);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user