$input */ public function page(array $input): DiviPage|WP_Error { $page = $this->loader->loadWritable($input['post_id'] ?? null); if ($page instanceof WP_Error) { return $page; } if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { return Errors::revMismatch( 'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde ' . 'geschrieben.', DiviPageLoader::OUTLINE_HINT ); } return $page; } /** * The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$ * references are checked against Divi's dynamic content options for it (ruling S22); a Theme * Builder layout also by its area (F22: modules Divi renders only elsewhere are refused). */ public function composition(DiviPage $page): DiviComposition { return $this->composition->forPost((int) $page->post->ID, $page->themeBuilderArea()); } /** * Saves the changed tree of $page and returns the ToolResult array. Call it only when the * whole input validated – every error before means nothing is written. * * @param array $input * @return array * @throws WriteFailed */ public function save( string $ability, DiviPage $page, DiviChange $change, array $input, SaveScope $scope = SaveScope::Page ): array { $post = $page->post; $content = Serializer::document($page->document); if ($content === (string) $post->post_content) { return ToolResult::ok('Keine Änderung – die Seite ist bereits so.') ->rev(Rev::ofPost($post)) ->with('tree_rev', $page->treeRev()) ->warnings($change->warnings()) ->urls((string) get_preview_post_link($post), $page->editUrl()) ->toArray(); } $index = NodeIndex::build($page->document); $quality = $this->composition->warnings()->collect($index, $change->touched()); $similar = $ability === self::FROM_LIBRARY ? [] : ($this->similar?->warnings($change) ?? []); $warnings = array_merge($change->warnings(), $quality, $similar); try { $saved = $this->saver->save($post, $content, $scope); } catch (WriteFailed $e) { if (! $e->rolledBack) { $this->log->recordPartial( $ability, (string) $post->post_type, (string) $post->ID, sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title), $e->revisionId ); } throw $e; } $outline = $this->outline($index, $change); if ($outline['truncated']) { $warnings[] = self::OUTLINE_TRUNCATED; } $result = (new PostSaveFollowUp( $ability, $this->log, (int) $post->ID, 'die Änderung', array_merge($warnings, $saved['warnings']) ))->afterSave($input); $updated = $result->reread($post) ?? $post; $summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary); $result->log((string) $post->post_type, $summary, $saved['revision_id']); $status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status); $tool = $result->result($status, $updated) ->revisionId($saved['revision_id']) ->outline($outline['text']) ->with('tree_rev', Rev::of((string) $updated->post_content)) ->urls((string) get_preview_post_link($updated), $page->editUrl()); foreach ($change->entries() as $entry) { $tool->changed(DiviChange::idOf($entry, $index) ?? '?', $entry['type'], $entry['action'], $entry['label']); } $restore = self::restoreUrl($page, $saved['revision_id']); if ($restore !== null) { $tool->with('restore_url', $restore); } return $tool->toArray(); } /** * Audit L3: lit/revision-restore takes only the content types (ContentSchema) – not library entries or Theme * Builder layouts. Their revision is restored in WordPress' revision screen; null for everything else. */ private static function restoreUrl(DiviPage $page, mixed $revisionId): ?string { $noRestoreTool = $page->isLibraryItem() || $page->isThemeBuilderLayout(); if (! $noRestoreTool || ! is_int($revisionId) || $revisionId <= 0) { return null; } return admin_url('revision.php?revision=' . $revisionId); } /** * save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one * needs confirm (dry run first), gets a backup when revisions are off and clears every page's * cache; the result then names the affected pages. Theme Builder layouts: commitLayout(). * * @param array $input * @return array|WP_Error * @throws WriteFailed */ public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error { if ($page->isThemeBuilderLayout()) { return $this->commitLayout($ability, $page, $change, $input); } if (! $page->isLibraryItem()) { return $this->save($ability, $page, $change, $input); } if ($this->globals === null) { return Errors::unsupported( 'Bibliothekseinträge lassen sich über diese Verbindung nicht ändern.', 'Den Eintrag im Divi-Builder bearbeiten.' ); } if (Serializer::document($page->document) === (string) $page->post->post_content) { return $this->save($ability, $page, $change, $input, SaveScope::LibraryItem); } $prepared = $this->globals->prepare($page, $input); if ($prepared instanceof WP_Error) { return $prepared; } return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Bibliothekseintrags'); } /** * save() for a Theme Builder layout: a layout visitors see needs confirm and gets the restore point * of a visible change (ThemeBuilderEdits, rulings Q2/Q9); the result names the templates that show it. * * @param array $input * @return array|WP_Error * @throws WriteFailed */ private function commitLayout(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error { if ($this->themeBuilder === null) { return Errors::unsupported( 'Theme-Builder-Layouts lassen sich über diese Verbindung nicht ändern.', 'Das Layout im Divi Theme Builder bearbeiten.' ); } if (Serializer::document($page->document) === (string) $page->post->post_content) { return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout); } // E12: fields in a body checked against the templates' post types – warnings only, also in the dry run // and in any refusal (review M-12 a). The live templates are read once for both checks. $live = $this->themeBuilder->live(); $fieldWarnings = $this->themeBuilder->fieldWarnings($page, $change, $live); foreach ($fieldWarnings as $warning) { $change->warning($warning); } $prepared = $this->themeBuilder->prepare($page, $input, $live); if ($prepared instanceof WP_Error) { return $fieldWarnings === [] ? $prepared : self::withWarnings($prepared, $fieldWarnings); } return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts'); } /** * $error with the field warnings of the change (key warnings): a confirm_required also says so in its message. * * @param list $warnings */ private static function withWarnings(WP_Error $error, array $warnings): WP_Error { if (Errors::codeOf($error) === 'confirm_required') { return VisibleEdits::merged($error, 'Hinweise zu Feldern siehe warnings.', ['warnings' => $warnings]); } $data = $error->get_error_data(); $data = is_array($data) ? $data : []; return Errors::make( Errors::codeOf($error), Errors::clientMessage($error), (string) ($data['hint'] ?? ''), (int) ($data['status'] ?? 400), array_merge(array_diff_key($data, ['status' => true, 'hint' => true]), ['warnings' => $warnings]) ); } /** * save() with the scope GlobalEdits/ThemeBuilderEdits prepared; their details join the result, and a * failure after their backup names it (ruling P14, VisibleEdits). $entry names the entry in that error. * * @param array $input * @param array{scope: SaveScope, details: array} $prepared * @return array * @throws WriteFailed */ private function savePrepared( string $ability, DiviPage $page, DiviChange $change, array $input, array $prepared, string $entry ): array { try { $saved = $this->save($ability, $page, $change, $input, $prepared['scope']); } catch (WriteFailed $e) { throw VisibleEdits::keepingBackup($e, $prepared['details']['backup_id'] ?? null, $entry); } return array_merge($saved, $prepared['details']); } /** A tool's confirm_required on a global library entry or a live Theme Builder layout, extended by who sees it. */ public function notice(DiviPage $page, WP_Error $error): WP_Error { if ($page->isThemeBuilderLayout()) { return $this->themeBuilder?->notice($page, $error) ?? $error; } return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error; } /** * Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed * node has no block any more, but its ID from before the removal still names the (surviving) * section – unless the removed node was that section itself: its ID now names the next section, * which did not change (review m-7). * * @return array{text: ?string, truncated: bool} */ private function outline(NodeIndex $index, DiviChange $change): array { $sections = []; foreach ($change->entries() as $entry) { $id = DiviChange::idOf($entry, $index); $wholeSection = $entry['block'] === null && $id !== null && ! str_contains($id, '.'); if ($id !== null && ! $wholeSection) { $sections[explode('.', $id)[0]] = true; } } if ($sections === []) { return ['text' => null, 'truncated' => false]; } $outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible()); return $outline->summary($index, array_map('strval', array_keys($sections))); } }