feat: DesignSystemWriter – Presets über Divis Sync-Ablauf schreiben (Mirror preset-sync)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:34:49 +02:00
co-authored by Claude Sonnet 5.5
parent 6ee8f9258a
commit 3fe83e39d3
6 changed files with 343 additions and 2 deletions
@@ -0,0 +1,143 @@
function sync( WP_REST_Request $request ) {
$is_chunked = $request->get_param( 'isChunked' );
$chunk_index = $request->get_param( 'chunkIndex' );
$total_chunks = $request->get_param( 'totalChunks' );
$is_last_chunk = $request->get_param( 'isLastChunk' );
$request_id = $request->get_param( 'requestId' );
$action_type = $request->get_param( 'actionType' ) ?? '';
// Validate chunked request: if isChunked is true, all chunking parameters must be present.
if ( true === $is_chunked ) {
if ( null === $chunk_index || null === $total_chunks || null === $is_last_chunk || null === $request_id ) {
return RESTController::response_error(
'invalid_chunked_request',
esc_html__( 'Invalid chunked request: missing required chunking parameters.', 'et_builder_5' ),
[
'status' => 400,
]
);
}
}
// Validate non-chunked request: if isChunked is false (or null/defaults to false), chunking parameters must not be present.
if ( false === $is_chunked || null === $is_chunked ) {
if ( null !== $chunk_index || null !== $total_chunks || null !== $is_last_chunk || null !== $request_id ) {
return RESTController::response_error(
'invalid_non_chunked_request',
esc_html__( 'Invalid non-chunked request: chunking parameters should not be present.', 'et_builder_5' ),
[
'status' => 400,
]
);
}
}
// Handle chunked requests: accumulate chunks before processing.
// Check explicit isChunked flag first, then verify all required chunking parameters are present.
if ( true === $is_chunked ) {
// Get transient key for this request ID.
$transient_key = self::_get_chunk_transient_key( $request_id );
// Get existing accumulated chunks from transient.
$accumulated_data = get_transient( $transient_key );
// Initialize accumulation for this request ID if not exists.
if ( false === $accumulated_data ) {
$accumulated_data = [
'chunks' => [],
'actionType' => $action_type,
'totalChunks' => $total_chunks,
];
}
// Store this chunk.
$chunk_presets = $request->get_param( 'presets' );
$accumulated_data['chunks'][ $chunk_index ] = $chunk_presets;
// Update transient with new chunk data.
set_transient( $transient_key, $accumulated_data, self::CHUNK_TRANSIENT_EXPIRATION );
// If this is not the last chunk, return early with success (chunk received).
if ( ! $is_last_chunk ) {
return RESTController::response_success( (object) [] );
}
// This is the last chunk - verify we received all expected chunks before merging.
$accumulated_chunks = $accumulated_data['chunks'];
$received_chunk_count = count( $accumulated_chunks );
// Verify we have all chunks (chunk indices are 0-based, so total should match).
if ( $received_chunk_count !== $total_chunks ) {
// Clean up transient on error.
delete_transient( $transient_key );
return RESTController::response_error(
'incomplete_chunks',
sprintf(
// translators: %1$d: received chunks, %2$d: expected chunks.
esc_html__( 'Incomplete chunk data received. Expected %1$d chunks, received %2$d chunks.', 'et_builder_5' ),
$total_chunks,
$received_chunk_count
),
[
'status' => 400,
'expectedChunks' => $total_chunks,
'receivedChunks' => $received_chunk_count,
'receivedIndices' => array_keys( $accumulated_chunks ),
]
);
}
// Merge all chunks and process.
$merged_presets = self::_merge_chunks( $accumulated_chunks );
// Clean up transient after successful merge.
delete_transient( $transient_key );
// Use merged presets for processing.
$prepared_data = GlobalPreset::prepare_data( $merged_presets );
} else {
// Non-chunked request - process normally.
$prepared_data = GlobalPreset::prepare_data( $request->get_param( 'presets' ) );
}
// CRITICAL SAFETY CHECK: Validate that we're not accidentally losing presets during sync.
// Only DELETE actions should reduce the preset count. All other operations (save, add, update)
// should preserve or increase the preset count.
// This prevents bugs where incomplete restore points or data corruption causes preset loss.
// We compare against the current database state (source of truth), not client-side state.
$current_presets = GlobalPreset::get_data();
$validation_error = GlobalPreset::validate_preset_count( $current_presets, $prepared_data, $action_type );
if ( is_wp_error( $validation_error ) ) {
// Clean up transient on validation error.
if ( null !== $request_id ) {
$transient_key = self::_get_chunk_transient_key( $request_id );
delete_transient( $transient_key );
}
return $validation_error;
}
// Handle deletion cleanup for D4 legacy presets.
// When a preset is deleted in D5, we need to also remove it from the D4 legacy option
// to prevent re-migration on page refresh.
if ( 'DELETE_MODULE_PRESET' === $action_type || 'DELETE_OPTION_GROUP_PRESET' === $action_type ) {
$preset_type = 'DELETE_MODULE_PRESET' === $action_type ? 'module' : 'group';
// Get deleted preset IDs by comparing current (before deletion) vs incoming (after deletion).
$deleted_presets = GlobalPreset::get_deleted_preset_ids( $current_presets, $prepared_data, $preset_type );
// Remove each deleted preset from the D4 legacy option.
foreach ( $deleted_presets as $deleted_preset ) {
GlobalPreset::remove_preset_from_legacy_option(
$deleted_preset['id'],
$deleted_preset['moduleName']
);
}
}
$saved_data = GlobalPreset::save_data( $prepared_data );
return RESTController::response_success( (object) $saved_data );
}
+7 -2
View File
@@ -184,7 +184,10 @@ final class DiviCheckCommandTest extends TestCase
self::assertSame(0, $code);
self::assertSame(
['library-submit-layout', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags'],
[
'library-submit-layout', 'preset-sync', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template',
'vb-sync-flags',
],
array_keys(DiviReference::expected($this->expectedFile))
);
self::assertStringStartsWith(
@@ -193,7 +196,7 @@ final class DiviCheckCommandTest extends TestCase
);
}
/** Minimal stand-ins for the five mirrored Divi functions at their real paths. */
/** Minimal stand-ins for the six mirrored Divi functions at their real paths. */
private function writeDivi(): void
{
$files = [
@@ -204,6 +207,8 @@ final class DiviCheckCommandTest extends TestCase
. "function et_theme_builder_load_template_setting_options( \$ids ) {\n}",
'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php' =>
"class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}",
'includes/builder-5/server/Packages/GlobalData/GlobalPresetController.php' =>
"class GlobalPresetController {\n\tpublic static function sync( \$request ) {\n\t}\n}",
];
foreach ($files as $path => $code) {
$file = $this->themeDir . '/' . $path;
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
final class DesignSystemWriterTest extends TestCase
{
public function testIsARegisteredDesignAdapterWithTheSyncMirror(): void
{
$manifest = DesignSystemWriter::manifest();
self::assertContains(DesignSystemWriter::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->area);
self::assertSame(
[
'GlobalPreset::get_data()', 'GlobalPreset::prepare_data()', 'GlobalPreset::validate_preset_count()',
'GlobalPreset::get_deleted_preset_ids()', 'GlobalPreset::remove_preset_from_legacy_option()',
'GlobalPreset::save_data()', 'Conversion::get_preset_attrs_mapping()', 'Conversion::get_preset_attrs()',
'UserRole::can_current_user_use_visual_builder()',
],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame(['preset-sync'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors));
self::assertSame('GlobalPresetController', $manifest->mirrors[0]->class);
}
public function testWritesNeedAReleasedDivi(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0'));
$this->expectException(DiviLocked::class);
(new DesignSystemWriter())->savePresets(['module' => [], 'group' => []], false);
}
public function testWithoutDiviNobodyUsesTheVisualBuilder(): void
{
self::assertFalse((new DesignSystemWriter())->canUseVisualBuilder());
}
}