fix: tb-create-template rolls back posts Divi inserted before failing (ruling Q19)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -60,6 +61,9 @@ class DiviThemeBuilder
|
||||
'wp_die_xml_handler', 'wp_die_xmlrpc_handler',
|
||||
];
|
||||
|
||||
/** Actions of wp_insert_post() that name a post's ID after its row exists, earliest first (Q19). */
|
||||
private const INSERT_ACTIONS = ['clean_post_cache', 'save_post', 'wp_insert_post'];
|
||||
|
||||
/** "header", "body" or "footer" for a layout post type, else null. */
|
||||
public static function areaOf(string $postType): ?string
|
||||
{
|
||||
@@ -168,7 +172,8 @@ class DiviThemeBuilder
|
||||
$type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
|
||||
$this->assertCanWrite('et_theme_builder_insert_layout');
|
||||
$id = $this->guarded(
|
||||
static fn (): mixed => et_theme_builder_insert_layout(['post_type' => $type, 'post_title' => $title])
|
||||
static fn (): mixed => et_theme_builder_insert_layout(['post_type' => $type, 'post_title' => $title]),
|
||||
[$type]
|
||||
);
|
||||
if (! is_int($id) || $id <= 0) {
|
||||
$reason = $id instanceof WP_Error ? $id->get_error_message() : 'no id';
|
||||
@@ -198,7 +203,10 @@ class DiviThemeBuilder
|
||||
'use_on' => $template['use_on'],
|
||||
'exclude_from' => $template['exclude_from'],
|
||||
];
|
||||
$id = $this->guarded(static fn (): mixed => et_theme_builder_store_template($themeBuilderId, $payload, false));
|
||||
$id = $this->guarded(
|
||||
static fn (): mixed => et_theme_builder_store_template($themeBuilderId, $payload, false),
|
||||
[self::TEMPLATE]
|
||||
);
|
||||
if (! is_int($id) || $id <= 0) {
|
||||
throw new RuntimeException('et_theme_builder_store_template stored no template');
|
||||
}
|
||||
@@ -338,9 +346,50 @@ class DiviThemeBuilder
|
||||
* the exception leaves that WP_Hook mid-iteration; its nesting level and $wp_current_filter are not
|
||||
* restored (WordPress offers no API for that), so later hooks of this request may see a stale filter
|
||||
* stack. The request ends with the tool's error anyway; the check before Divi is the primary defense.
|
||||
*
|
||||
* New posts of $types inserted meanwhile are watched (ruling Q19): wp_insert_post_data marks a pending insert
|
||||
* (not an update) of such a type, the first clean_post_cache/save_post/wp_insert_post for a post of that type
|
||||
* after it names its ID. When $call then throws, InsertedBeforeFailure carries those IDs so the caller removes
|
||||
* them too. Limit: a throw between the row insert and WordPress' first clean_post_cache() (default terms or
|
||||
* meta_input of a taxonomy/field registered for these types – none by default) leaves the ID unknown.
|
||||
*
|
||||
* @param list<string> $types
|
||||
* @throws RuntimeException InsertedBeforeFailure when posts were inserted before the failure.
|
||||
*/
|
||||
private function guarded(callable $call): mixed
|
||||
private function guarded(callable $call, array $types = []): mixed
|
||||
{
|
||||
$pending = [];
|
||||
$inserted = [];
|
||||
$data = static function (
|
||||
mixed $data,
|
||||
mixed $postarr = [],
|
||||
mixed $unsanitized = [],
|
||||
bool $update = false
|
||||
) use (
|
||||
$types,
|
||||
&$pending
|
||||
): mixed {
|
||||
$type = is_array($data) ? (string) ($data['post_type'] ?? '') : '';
|
||||
if (! $update && in_array($type, $types, true)) {
|
||||
$pending[$type] = ($pending[$type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $data;
|
||||
};
|
||||
$named = static function (mixed $postId, mixed $post = null) use (&$pending, &$inserted): void {
|
||||
$id = (int) $postId;
|
||||
$type = $post instanceof \WP_Post ? (string) $post->post_type : (string) get_post_type($id);
|
||||
if ($id > 0 && ($pending[$type] ?? 0) > 0 && ! in_array($id, $inserted, true)) {
|
||||
$inserted[] = $id;
|
||||
--$pending[$type];
|
||||
}
|
||||
};
|
||||
if ($types !== []) {
|
||||
add_filter('wp_insert_post_data', $data, PHP_INT_MIN, 4);
|
||||
foreach (self::INSERT_ACTIONS as $action) {
|
||||
add_action($action, $named, PHP_INT_MIN, 2);
|
||||
}
|
||||
}
|
||||
$handler = static function (): callable {
|
||||
return static function (mixed $message = ''): never {
|
||||
if ($message instanceof WP_Error) {
|
||||
@@ -359,6 +408,11 @@ class DiviThemeBuilder
|
||||
|
||||
try {
|
||||
return $call();
|
||||
} catch (Throwable $e) {
|
||||
if ($inserted !== []) {
|
||||
throw new InsertedBeforeFailure($e->getMessage(), $inserted, $e);
|
||||
}
|
||||
throw $e;
|
||||
} finally {
|
||||
while (ob_get_level() > $level) {
|
||||
ob_end_clean();
|
||||
@@ -366,6 +420,12 @@ class DiviThemeBuilder
|
||||
foreach (self::DIE_FILTERS as $filter) {
|
||||
remove_filter($filter, $handler, PHP_INT_MAX);
|
||||
}
|
||||
if ($types !== []) {
|
||||
remove_filter('wp_insert_post_data', $data, PHP_INT_MIN);
|
||||
foreach (self::INSERT_ACTIONS as $action) {
|
||||
remove_action($action, $named, PHP_INT_MIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Divi failed after its own wp_insert_post() already created posts (a throwing hook, a wp_die() turned into an
|
||||
* exception by DiviThemeBuilder::guarded()): $postIds are the new posts of the expected types, so the caller can
|
||||
* roll them back (ruling Q19). The message is the original failure's.
|
||||
*/
|
||||
final class InsertedBeforeFailure extends RuntimeException
|
||||
{
|
||||
/** @param list<int> $postIds */
|
||||
public function __construct(string $message, public readonly array $postIds, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
@@ -215,10 +215,11 @@ final class TbCreateTemplateAbility implements Ability
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/** The default template, only when the user may edit it (ruling Q19, like tb-list Q8/B11). */
|
||||
private function defaultTemplate(): ?Template
|
||||
{
|
||||
foreach ($this->access->store()->liveTemplates() as $template) {
|
||||
if ($template->default) {
|
||||
if ($template->default && $this->access->canEditTemplate($template->id)) {
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ final class TemplateBuild
|
||||
/** @var list<int> */
|
||||
private array $created = [];
|
||||
|
||||
/** @var list<int> Posts the last undo() could not remove (or the template still listed). */
|
||||
private array $left = [];
|
||||
|
||||
/** @var array{0: int, 1: int}|null Theme Builder post, template. */
|
||||
private ?array $attached = null;
|
||||
|
||||
@@ -37,7 +40,7 @@ final class TemplateBuild
|
||||
/** @throws RuntimeException */
|
||||
public function layout(string $area, string $title): int
|
||||
{
|
||||
$id = $this->store->insertLayout($area, $title);
|
||||
$id = $this->recording(fn (): int => $this->store->insertLayout($area, $title));
|
||||
$this->created[] = $id;
|
||||
|
||||
return $id;
|
||||
@@ -72,7 +75,7 @@ final class TemplateBuild
|
||||
*/
|
||||
public function template(int $themeBuilderId, array $template): int
|
||||
{
|
||||
$id = $this->store->storeTemplate($themeBuilderId, $template);
|
||||
$id = $this->recording(fn (): int => $this->store->storeTemplate($themeBuilderId, $template));
|
||||
$this->created[] = $id;
|
||||
|
||||
return $id;
|
||||
@@ -85,6 +88,26 @@ final class TemplateBuild
|
||||
$this->store->attach($themeBuilderId, $templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one Divi insert; posts Divi created before it failed (InsertedBeforeFailure, ruling Q19) join the build.
|
||||
*
|
||||
* @param callable(): int $insert
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function recording(callable $insert): int
|
||||
{
|
||||
try {
|
||||
return $insert();
|
||||
} catch (InsertedBeforeFailure $e) {
|
||||
foreach ($e->postIds as $id) {
|
||||
if (! in_array($id, $this->created, true)) {
|
||||
$this->created[] = $id;
|
||||
}
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public function created(): array
|
||||
{
|
||||
@@ -101,7 +124,10 @@ final class TemplateBuild
|
||||
{
|
||||
$failures = $this->undo();
|
||||
if ($failures !== []) {
|
||||
throw $this->partial(new RuntimeException($failures[0]), '');
|
||||
throw $this->partial(
|
||||
new RuntimeException(implode('; ', $failures)),
|
||||
' after refusal: ' . implode('; ', $failures)
|
||||
);
|
||||
}
|
||||
|
||||
return $refusal;
|
||||
@@ -122,17 +148,40 @@ final class TemplateBuild
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
/**
|
||||
* Detaches, then deletes every created post newest first with its layout_type term relationships (Divi sets
|
||||
* them in insert_layout; WordPress does not remove them because that taxonomy is not registered for the TB
|
||||
* layout types). Notes in $left what could not be removed.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function undo(): array
|
||||
{
|
||||
$this->left = [];
|
||||
$steps = [];
|
||||
if ($this->attached !== null) {
|
||||
[$themeBuilderId, $templateId] = $this->attached;
|
||||
$steps[] = fn () => $this->store->detach($themeBuilderId, $templateId);
|
||||
$steps[] = function () use ($themeBuilderId, $templateId): void {
|
||||
try {
|
||||
$this->store->detach($themeBuilderId, $templateId);
|
||||
} catch (Throwable $e) {
|
||||
$this->left[] = $templateId;
|
||||
throw $e;
|
||||
}
|
||||
};
|
||||
}
|
||||
foreach (array_reverse($this->created) as $id) {
|
||||
$steps[] = static function () use ($id): void {
|
||||
if (! wp_delete_post($id, true)) {
|
||||
$steps[] = function () use ($id): void {
|
||||
try {
|
||||
wp_delete_object_term_relationships($id, 'layout_type');
|
||||
$deleted = wp_delete_post($id, true);
|
||||
} catch (Throwable $e) {
|
||||
$deleted = false;
|
||||
}
|
||||
if (! $deleted) {
|
||||
if (! in_array($id, $this->left, true)) {
|
||||
$this->left[] = $id;
|
||||
}
|
||||
throw new RuntimeException(sprintf('post %d not deleted', $id));
|
||||
}
|
||||
};
|
||||
@@ -143,7 +192,7 @@ final class TemplateBuild
|
||||
|
||||
private function partial(Throwable $cause, string $detail): WriteFailed
|
||||
{
|
||||
$ids = implode(', ', $this->created);
|
||||
$ids = implode(', ', $this->left);
|
||||
$this->log->recordPartial(
|
||||
$this->ability,
|
||||
DiviThemeBuilder::TEMPLATE,
|
||||
@@ -157,7 +206,7 @@ final class TemplateBuild
|
||||
'Theme Builder rollback incomplete' . $detail,
|
||||
state: sprintf('Möglicherweise sind die Beiträge %s noch vorhanden (deaktiviert).', $ids),
|
||||
hint: sprintf('Im Divi Theme Builder prüfen und die Beiträge %s bei Bedarf löschen.', $ids),
|
||||
details: ['created_ids' => $this->created]
|
||||
details: ['left_ids' => $this->left]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user