diff --git a/src/Integrations/Divi/Design/DesignSystemWriter.php b/src/Integrations/Divi/Design/DesignSystemWriter.php new file mode 100644 index 0000000..d909a8b --- /dev/null +++ b/src/Integrations/Divi/Design/DesignSystemWriter.php @@ -0,0 +1,139 @@ + The stored inventory (GlobalPreset::get_data(), formats §3). */ + public function presets(): array + { + $data = GlobalPreset::get_data(); + + return is_array($data) ? $data : []; + } + + /** + * @param array $rest REST/sync format, see PresetBook::toRest(). + * @param bool $mayRemove Deleting or restoring: fewer presets are allowed and removed ones + * leave Divi's legacy option (like a DELETE_* sync). + * @return array The inventory Divi stored. + * @throws DiviLocked + * @throws RuntimeException When Divi refuses the inventory. + */ + public function savePresets(array $rest, bool $mayRemove): array + { + ReleaseGate::assertWritable(); + $prepared = GlobalPreset::prepare_data($rest); + $current = GlobalPreset::get_data(); + $refusal = GlobalPreset::validate_preset_count( + $current, + $prepared, + $mayRemove ? self::ACTION_DELETE_MODULE : self::ACTION_SAVE + ); + if ($refusal instanceof WP_Error) { + throw new RuntimeException('preset sync refused: ' . $refusal->get_error_code() . ' ' + . $refusal->get_error_message()); + } + + if ($mayRemove) { + foreach (['module', 'group'] as $kind) { + foreach (GlobalPreset::get_deleted_preset_ids($current, $prepared, $kind) as $deleted) { + GlobalPreset::remove_preset_from_legacy_option( + (string) ($deleted['id'] ?? ''), + (string) ($deleted['moduleName'] ?? '') + ); + } + } + } + + $saved = GlobalPreset::save_data($prepared); + + return is_array($saved) ? $saved : []; + } + + /** + * The parts of $attrs a preset of $moduleName carries (Divi's preset attribute mapping): all, style + * (styleAttrs) and render (renderAttrs: html + script). + * + * @param array $attrs + * @return array{all: array, style: array, render: array} + */ + public function presetAttrs(string $moduleName, array $attrs): array + { + $map = Conversion::get_preset_attrs_mapping($moduleName); + + return [ + 'all' => (array) Conversion::get_preset_attrs($attrs, self::PRESET_TYPES, $map), + 'style' => (array) Conversion::get_preset_attrs($attrs, ['style'], $map), + 'render' => (array) Conversion::get_preset_attrs($attrs, ['html', 'script'], $map), + ]; + } + + /** Divi's role right "Visual Builder verwenden"; fail closed without Divi. */ + public function canUseVisualBuilder(): bool + { + return class_exists(UserRole::class) && UserRole::can_current_user_use_visual_builder(); + } +} diff --git a/src/Integrations/Divi/Release/DiviAdapters.php b/src/Integrations/Divi/Release/DiviAdapters.php index 96488b9..4182a8e 100644 --- a/src/Integrations/Divi/Release/DiviAdapters.php +++ b/src/Integrations/Divi/Release/DiviAdapters.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Release; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Pages\DiviPages; @@ -25,6 +26,7 @@ final class DiviAdapters DiviRoles::class, DiviThemeBuilder::class, DesignSystemReader::class, + DesignSystemWriter::class, DiviModules::class, DynamicContentMap::class, ]; diff --git a/src/Integrations/Divi/Release/fingerprints.json b/src/Integrations/Divi/Release/fingerprints.json index a84b423..698a3f8 100644 --- a/src/Integrations/Divi/Release/fingerprints.json +++ b/src/Integrations/Divi/Release/fingerprints.json @@ -1,5 +1,6 @@ { "library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02", + "preset-sync": "9586f8005b8065893c0350977a58ee9171bf715fff21ac0c84663cc512f45522", "tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4", "tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e", "tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d", diff --git a/tests/Fixtures/divi/reference/preset-sync.txt b/tests/Fixtures/divi/reference/preset-sync.txt new file mode 100644 index 0000000..9f6c3a3 --- /dev/null +++ b/tests/Fixtures/divi/reference/preset-sync.txt @@ -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 ); + } diff --git a/tests/Unit/Cli/DiviCheckCommandTest.php b/tests/Unit/Cli/DiviCheckCommandTest.php index df93374..ce03b91 100644 --- a/tests/Unit/Cli/DiviCheckCommandTest.php +++ b/tests/Unit/Cli/DiviCheckCommandTest.php @@ -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; diff --git a/tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php b/tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php new file mode 100644 index 0000000..28fdd96 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php @@ -0,0 +1,51 @@ +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()); + } +}