feat: lit/divi-presets-assign – Presets site-weit zuweisen mit Probelauf und höchstens 50 Seiten je Aufruf

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 17:07:04 +02:00
co-authored by Claude Sonnet 5.5
parent 65a207488e
commit d4f235ae5e
8 changed files with 904 additions and 0 deletions
@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-presets-assign (spec §6.7, G2, A6): assigns a module preset stack and/or group presets to every module of
* one type – on all posts that contain it or only on post_ids. Every value passes PresetAssignment (choke point D7),
* AssignRewrite changes the blocks and the PageRewriter writes: without confirm a dry run, with confirm at most
* PageRewriter::PAGE_LIMIT posts, each with a revision; "remaining" counts the rest. The filters skip modules that
* already carry the change, so the same call continues where the last one stopped. An empty dry run is a normal
* result ("nichts zu tun"), not confirm_required: there is nothing to confirm, and a repeated call ends cleanly.
* null removes nothing here – lit/divi-design-detach does.
*/
final class DiviPresetsAssignAbility implements Ability
{
public const MAX_STACK = 10;
/** Path prefix for PresetAssignment's errors; removed again so fields name the input keys. */
private const ROOT = 'input';
private const USE_DETACH = 'null entfernt site-weit nichts; Preset-Verweise entfernt lit/divi-design-detach';
/** @param Closure(): PresetAssignment $assignment A fresh assignment (own preset book) per call. */
public function __construct(
private DesignSystemWriter $writer,
private PageRewriter $pages,
private Closure $assignment
) {
}
public function name(): string
{
return 'lit/divi-presets-assign';
}
public function label(): string
{
return 'Divi-Presets site-weit zuweisen';
}
public function description(): string
{
return 'Weist allen Modulen eines Typs (module, z. B. "button") site-weit ein Modul-Preset (preset: Name oder '
. 'ID oder Stapel als Liste; bei gleicher Priorität gewinnt das letzte Preset im Stapel (Divi-Verhalten)) '
. 'und/oder Gruppen-Presets (group_presets) zu. Filter: post_ids, only_without_preset oder '
. 'only_with_preset; clear_overrides: true entfernt lokale Werte, die das Preset überdecken. Zuerst '
. 'Probelauf (betroffene Seiten und Module), nach Rückfrage beim Nutzer confirm: true; je Aufruf höchstens '
. '50 Seiten, jede mit Revision; remaining nennt den Rest. Mit post_ids können nur die genannten Seiten '
. 'das Schreiben blockieren, die Obergrenze für sehr viele Seiten gilt dann nicht, und eine genannte '
. 'Seite ohne Bearbeitungsrecht steht in skipped. Verweise entfernen: lit/divi-design-detach.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" oder "divi/button".'],
'preset' => [
'type' => ['string', 'array'],
'items' => ['type' => 'string'],
'minItems' => 1,
'maxItems' => self::MAX_STACK,
'description' => 'Modul-Preset (Name oder ID) oder Stapel; bei gleicher Priorität gewinnt das '
. 'letzte Preset im Stapel (Divi-Verhalten).',
],
'group_presets' => [
'type' => 'object',
'description' => '{"<Gruppe>": "<Name>" | ["<Name>", …]}; Gruppe als Kurzname (font-body), voller '
. 'Name (divi/font-body) oder Attributpfad.',
],
'post_ids' => PostIdsInput::schema(),
'only_without_preset' => [
'type' => 'boolean',
'description' => 'Nur Module ohne eigenes Modul-Preset.',
],
'only_with_preset' => [
'type' => 'string',
'description' => 'Nur Module mit diesem Modul-Preset (Name oder ID); nicht zusammen mit '
. 'only_without_preset.',
],
'clear_overrides' => [
'type' => 'boolean',
'description' => 'Lokale Werte entfernen, die die zugewiesenen Presets überdecken.',
],
] + array_intersect_key(DesignChange::inputProperties(), ['confirm' => true]),
'required' => ['module'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return PageRewriteReport::outputSchema();
}
public function annotations(): array
{
return Annotations::write(true);
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::presets($this->writer) ?? true;
}
public function execute(array $input): mixed
{
$refusal = DesignRights::presets($this->writer);
if ($refusal !== null) {
return $refusal;
}
$request = $this->request($input);
if ($request instanceof WP_Error) {
return $request;
}
$query = new ModuleUsageQuery($request['module']);
$change = 'Zuweisung an ' . $request['module'];
if (($input['confirm'] ?? false) !== true) {
return $this->dryRun($query, $request, $change);
}
$result = $this->pages->apply($this->name(), $query, $request['rewrite'], $request['post_ids'], $change);
if ($result instanceof WP_Error) {
return $result;
}
return ToolResult::ok(PageRewriteReport::savedSummary($result, $change))
->warnings($result['warnings'])
->with('saved', $result['saved'])
->with('remaining', $result['remaining'])
->with('skipped', $result['skipped'])
->toArray();
}
/**
* @param array{module: string, rewrite: Closure(): NodeRewrite, post_ids: list<int>|null} $request
* @return array<string, mixed>|WP_Error
*/
private function dryRun(ModuleUsageQuery $query, array $request, string $change): array|WP_Error
{
$preview = $this->pages->preview($query, $request['rewrite'], $request['post_ids']);
if ($preview instanceof WP_Error) {
return $preview;
}
if (PageRewriteReport::isEmpty($preview)) {
return PageRewriteReport::nothingToDo($change . ' – keine Seite betroffen, nichts zu tun.', $preview);
}
return Errors::confirmRequired(PageRewriteReport::dryRunMessage($preview, $change), $preview);
}
/**
* The checked call: full module name, the rewrite strategy per page and the post filter.
*
* @param array<string, mixed> $input
* @return array{module: string, rewrite: Closure(): NodeRewrite, post_ids: list<int>|null}|WP_Error
*/
private function request(array $input): array|WP_Error
{
$errors = [];
$module = self::module($input['module'] ?? null, $errors);
$spec = self::spec($input, $errors);
$postIds = self::postIds($input['post_ids'] ?? null, $errors);
$onlyWithout = self::flag($input, 'only_without_preset', $errors);
$clear = self::flag($input, 'clear_overrides', $errors);
if ($module === null || $errors !== []) {
return self::invalid($errors);
}
$assignment = ($this->assignment)();
foreach ($assignment->apply($module, [], $spec, self::ROOT)['errors'] as $path => $problem) {
$errors[self::field($path)] = $problem;
}
$onlyWith = $this->onlyWith($module, $input, $onlyWithout, $errors);
if ($errors !== []) {
return self::invalid($errors);
}
return [
'module' => $module,
'post_ids' => $postIds,
'rewrite' => static fn (): NodeRewrite => new AssignRewrite(
$module,
$assignment,
$spec,
$onlyWithout,
$onlyWith,
$clear
),
];
}
/** @param array<string, string> $errors */
private static function module(mixed $value, array &$errors): ?string
{
try {
return PresetScope::fromInput([PresetScope::MODULE => $value], 'module')->name;
} catch (InvalidArgumentException) {
$errors['module'] = 'Modulname wie "button" oder "divi/button" erwartet';
return null;
}
}
/**
* preset and group_presets as given; null removes nothing site-wide (lit/divi-design-detach does).
*
* @param array<string, mixed> $input
* @param array<string, string> $errors
* @return array<string, mixed>
*/
private static function spec(array $input, array &$errors): array
{
$spec = array_intersect_key($input, ['preset' => true, 'group_presets' => true]);
if ($spec === []) {
$errors['preset'] = 'preset oder group_presets angeben';
}
if (array_key_exists('preset', $spec) && $spec['preset'] === null) {
$errors['preset'] = self::USE_DETACH;
}
$groups = $spec['group_presets'] ?? null;
if ($groups === []) {
$errors['group_presets'] = 'mindestens eine Gruppe angeben';
}
foreach (is_array($groups) ? $groups : [] as $key => $value) {
if ($value === null) {
$errors['group_presets.' . $key] = self::USE_DETACH;
}
}
return $spec;
}
/**
* @param array<string, string> $errors
* @return list<int>|null
*/
private static function postIds(mixed $value, array &$errors): ?array
{
try {
return PostIdsInput::parse($value);
} catch (InvalidArgumentException $e) {
$errors['post_ids'] = $e->getMessage();
return null;
}
}
/**
* @param array<string, mixed> $input
* @param array<string, string> $errors
*/
private static function flag(array $input, string $key, array &$errors): bool
{
$value = $input[$key] ?? false;
if (! is_bool($value)) {
$errors[$key] = 'true oder false erwartet';
return false;
}
return $value;
}
/**
* The ID of the module preset only_with_preset names; null without that filter or after noting a problem.
*
* @param array<string, mixed> $input
* @param array<string, string> $errors
*/
private function onlyWith(string $module, array $input, bool $onlyWithout, array &$errors): ?string
{
$value = $input['only_with_preset'] ?? null;
if ($value === null) {
return null;
}
if ($onlyWithout) {
$errors['only_with_preset'] = 'nicht zusammen mit only_without_preset';
return null;
}
$scope = new PresetScope(PresetScope::MODULE, $module);
$name = is_string($value) ? $value : '';
try {
$found = PresetBook::fromDivi($this->writer->presets())->find($scope, $name);
} catch (InvalidArgumentException $e) {
$errors['only_with_preset'] = $e->getMessage();
return null;
}
if ($found === null) {
$errors['only_with_preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $name);
return null;
}
return (string) $found['id'];
}
private static function field(string $path): string
{
$prefix = self::ROOT . '.';
return str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : $path;
}
/** @param array<string, string> $errors */
private static function invalid(array $errors): WP_Error
{
return Errors::invalidInput('Ungültige Angaben; nichts wurde geändert.', $errors);
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\ToolResult;
defined('ABSPATH') || exit;
/**
* The words and the output schema around the PageRewriter (spec §6.6, A6), shared by lit/divi-presets-assign and
* lit/divi-design-detach: when a dry run has nothing to do, the dry run message with the next step, the summary of a
* confirmed call with "remaining". $preview is a PageRewriter::preview() result, $result an apply() result. Pure PHP.
*/
final class PageRewriteReport
{
/** @param array<string, mixed> $preview */
public static function isEmpty(array $preview): bool
{
return $preview['pages_count'] === 0 && $preview['blocked_ids'] === [] && $preview['hidden_count'] === 0;
}
/** @param array<string, mixed> $preview */
public static function dryRunMessage(array $preview, string $change): string
{
$message = sprintf(
'%s – betroffen (Seiten: %d, Module: %d). Nichts wurde gespeichert (Probelauf). Nach Rückfrage beim '
. 'Nutzer mit confirm: true erneut aufrufen; je Aufruf werden höchstens %d Seiten geschrieben.',
$change,
$preview['pages_count'],
$preview['nodes_count'],
PageRewriter::PAGE_LIMIT
);
if ($preview['blocked_ids'] !== []) {
$message .= ' Seiten unter blocked_ids darf dieses Konto nicht bearbeiten; mit confirm würde nichts '
. 'gespeichert.';
}
return $message;
}
/** @param array<string, mixed> $result */
public static function savedSummary(array $result, string $change): string
{
$summary = sprintf(
'%s – gespeichert (Seiten: %d, Module: %d).',
$change,
count($result['saved']),
array_sum(array_column($result['saved'], 'nodes'))
);
if ($result['remaining'] > 0) {
return $summary . sprintf(
' Noch %d Seiten offen: mit denselben Angaben erneut mit confirm: true aufrufen.',
$result['remaining']
);
}
if ($result['skipped'] !== []) {
return $summary . sprintf(' Übersprungen: %d (siehe skipped).', count($result['skipped']));
}
return $summary . ' Alle Fundstellen sind erledigt.';
}
/**
* @param array<string, mixed> $preview
* @param list<string> $warnings
* @return array<string, mixed>
*/
public static function nothingToDo(string $summary, array $preview, array $warnings = []): array
{
return ToolResult::ok($summary)
->warnings($warnings)
->with('saved', [])
->with('remaining', 0)
->with('skipped', $preview['skipped'])
->toArray();
}
/**
* @param array<string, array<string, mixed>> $extra Further optional output properties.
* @return array<string, mixed>
*/
public static function outputSchema(array $extra = []): array
{
return ToolResult::schema([
'saved' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer'],
'revision_id' => ['type' => ['integer', 'null']],
'nodes' => ['type' => 'integer'],
],
'required' => ['post_id', 'revision_id', 'nodes'],
],
],
'remaining' => ['type' => 'integer'],
'skipped' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer'], 'reason' => ['type' => 'string']],
'required' => ['post_id', 'reason'],
],
],
] + $extra, ['saved', 'remaining', 'skipped']);
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* The post filter "post_ids" of the site-wide preset tools (lit/divi-presets-assign, lit/divi-design-detach):
* 1 to PageRewriter::PAGE_LIMIT positive post IDs – as many as one confirmed call writes. Pure PHP.
*/
final class PostIdsInput
{
public const MAX = PageRewriter::PAGE_LIMIT;
/** @return array<string, mixed> */
public static function schema(): array
{
return [
'type' => 'array',
'items' => ['type' => 'integer', 'minimum' => 1],
'minItems' => 1,
'maxItems' => self::MAX,
'description' => 'Nur diese Beiträge (IDs); ohne Angabe alle Fundstellen.',
];
}
/**
* @return list<int>|null null without a filter; duplicates removed.
* @throws InvalidArgumentException For anything but a list of 1 to MAX positive integers.
*/
public static function parse(mixed $value): ?array
{
if ($value === null) {
return null;
}
if (! is_array($value) || ! array_is_list($value) || $value === [] || count($value) > self::MAX) {
throw self::invalid();
}
foreach ($value as $id) {
if (! is_int($id) || $id < 1) {
throw self::invalid();
}
}
return array_values(array_unique($value));
}
private static function invalid(): InvalidArgumentException
{
return new InvalidArgumentException(sprintf(
'Liste von 1 bis %d Beitrags-IDs (positive ganze Zahlen) erwartet',
self::MAX
));
}
}
+13
View File
@@ -15,9 +15,11 @@ use LIT\MCP\Integrations\Divi\Design\DesignRights;
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
use LIT\MCP\Integrations\Divi\Design\PageRewriter;
use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
@@ -185,6 +187,11 @@ final class DiviIntegration implements Integration
new SnapshotStore(),
DesignRights::unfilteredHtml()
),
new DiviPresetsAssignAbility(
$this->designWriter,
$this->pageRewriter($loader),
$this->presetAssignment(...)
),
];
}
@@ -284,6 +291,12 @@ final class DiviIntegration implements Integration
);
}
/** A fresh PresetAssignment (own preset book) per lit/divi-presets-assign call, wired like the Composer's (D7). */
private function presetAssignment(): PresetAssignment
{
return $this->composition()->presets();
}
private function loader(): DiviPageLoader
{
return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess);