} * @throws WriteFailed */ public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array { $postId = (int) $post->ID; $revisionId = null; try { // Ruling S37: the restore point is captured inside the same kses window. $result = $this->writeContent($postId, $content, static function () use ($postId, &$revisionId): void { $revisionId = RevisionPoint::capture($postId); }); } catch (Throwable $e) { $this->restore($postId, (string) $post->post_content, $revisionId, $e); } $refusal = self::refusal($result); if ($refusal !== null) { throw WriteFailed::rolledBack( self::REASON, detail: sprintf('wp_update_post(%d) refused: %s', $postId, $refusal), state: self::keeping($revisionId), postId: $postId, revisionId: $revisionId ); } $warnings = []; if ($scope->setsFlags()) { FollowUp::run( static fn () => DiviFlags::set($postId), 'divi builder flags', 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' . 'einmal im Divi-Builder öffnen und speichern.', $warnings ); } FollowUp::run( static function () use ($postId, $scope): void { if (! class_exists('ET_Core_PageResource')) { return; } \ET_Core_PageResource::remove_static_resources($postId, 'all', true); // A global library entry is part of every page that shows it (spec §5.6 step 5). if ($scope->clearsSiteCss()) { \ET_Core_PageResource::remove_static_resources('all', 'all', true); if (function_exists('et_theme_builder_clear_wp_cache')) { et_theme_builder_clear_wp_cache('all'); } } }, 'divi static css', 'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. ' . 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).', $warnings ); $stored = get_post($postId); if ($stored instanceof WP_Post && (string) $stored->post_content !== $content) { $warnings[] = self::CHANGED_ON_SAVE; } return ['revision_id' => $revisionId, 'warnings' => $warnings]; } /** * The first content of a brand-new post: a draft of lit/content-create with divi or a library * entry of lit/library-save, both inserted with their normal WordPress fields first (title and * excerpt went through kses as usual). Same kses window as save(), holding only this * wp_update_post() and its hooks; no restore point (the post has no earlier state) and no * static CSS to clear. With SaveScope::Page the Divi flags are part of the write (without them * a new page is no Divi layout); SaveScope::LibraryItem sets none. Every failure throws – the * caller deletes the new post (all or nothing). * * @return list Warnings (CHANGED_ON_SAVE). * @throws RuntimeException */ public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array { $refusal = self::refusal($this->writeContent($postId, $content)); if ($refusal !== null) { throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal)); } if ($scope->setsFlags()) { DiviFlags::set($postId); } $stored = get_post($postId); return $stored instanceof WP_Post && (string) $stored->post_content !== $content ? [self::CHANGED_ON_SAVE] : []; } /** * The one guarded content write: wp_update_post() (wp_slash, status unchanged) of $content in * the kses window. $before runs first inside the same window – save() captures the restore * point there (ruling S37); nothing else ever runs in it (see the class docblock). * * @param (callable(): void)|null $before */ private function writeContent(int $postId, string $content, ?callable $before = null): int|WP_Error { return $this->withoutKses(static function () use ($postId, $content, $before): int|WP_Error { if ($before !== null) { $before(); } return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true); }); } /** Why wp_update_post() refused ($result WP_Error or 0), null when it saved. */ private static function refusal(int|WP_Error $result): ?string { if ($result instanceof WP_Error) { return $result->get_error_message(); } return $result === 0 ? 'returned 0' : null; } /** * Runs $write with kses' filters off for users without unfiltered_html (D7); restored in * finally, right when $write returns or throws. * * @param callable(): (int|WP_Error) $write */ private function withoutKses(callable $write): int|WP_Error { $kses = ! current_user_can('unfiltered_html') && has_filter('content_save_pre', 'wp_filter_post_kses') !== false; if ($kses) { kses_remove_filters(); } try { return $write(); } finally { if ($kses) { kses_init_filters(); } } } /** @throws WriteFailed Always. */ private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never { $detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage()); $failures = Rollback::attempt(function () use ($postId, $before): void { PostGuard::assertNoMissedSchedule($postId, 'restoring would publish a missed schedule'); if (self::refusal($this->writeContent($postId, $before)) !== null) { throw new RuntimeException('previous content could not be written back'); } }); if ($failures !== []) { $detail .= '; rollback: ' . $failures[0]; throw WriteFailed::partial(self::REASON, $cause, $detail, $postId, $revisionId); } throw WriteFailed::rolledBack( self::REASON, $cause, $detail, state: self::keeping($revisionId), postId: $postId, revisionId: $revisionId ); } /** write_failed must not claim "nothing was saved" while the restore-point revision stays. */ private static function keeping(?int $revisionId): ?string { return $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId)); } }