logger = $logger; } /** * Which of these builders owns this post, if any. Read from the post's own * stored state — never from a plugin being active, and never from anything * the caller said. */ public function detect( int $post_id ): ?string { if ( '' !== $this->meta_string( $post_id, '_fl_builder_enabled' ) && is_array( get_post_meta( $post_id, self::BEAVER_PUBLISHED_KEY, true ) ) ) { return self::BUILDER_BEAVER; } $bricks = get_post_meta( $post_id, self::BRICKS_KEY, true ); if ( is_array( $bricks ) && array() !== $bricks ) { return self::BUILDER_BRICKS; } return null; } /** * The page as normalized text leaves, plus the complete state behind them. * * @return array{builder: string, state_hash: string, state: string, nodes: array>}|\WP_Error */ public function read( int $post_id ) { $builder = $this->detect( $post_id ); if ( null === $builder ) { return new \WP_Error( 'wppilot_builder_not_detected', 'No supported structured builder owns this post.', array( 'status' => 409 ) ); } $state = $this->load_state( $post_id, $builder ); if ( $state instanceof \WP_Error ) { return $state; } $nodes = self::BUILDER_BEAVER === $builder ? $this->beaver_nodes( $state ) : $this->bricks_nodes( $state ); $draft = $this->beaver_draft( $post_id, $builder ); return array( 'builder' => $builder, 'state_hash' => $this->hash_state( $state ), 'state' => $this->encode_state( $state ), 'draft_state_hash' => null === $draft ? '' : $this->hash_state( $draft ), 'draft_state' => null === $draft ? '' : $this->encode_state( $draft ), 'nodes' => $nodes, ); } /** * Beaver's draft copy, when the owner has one. * * This is not a cache of the published layout — it is the version being * edited, and Beaver's own `save_layout()` publishes it by DELETING the * published data and replacing it with the draft wholesale. Two consequences * follow, and both are why the draft is treated as state here rather than as * a courtesy copy: * * - a draft holding the old sentence silently reverses an approved edit the * next time anybody presses Publish in the builder; * - a draft holding unrelated unpublished work is the owner's, and * overwriting it with the published graph would destroy it. * * `enable_editing()` copies published into draft only when the draft is * EMPTY, so a non-empty draft always means somebody has been working. * * @return array|null */ private function beaver_draft( int $post_id, string $builder ): ?array { if ( self::BUILDER_BEAVER !== $builder ) { return null; } $draft = get_post_meta( $post_id, self::BEAVER_DRAFT_KEY, true ); return is_array( $draft ) && array() !== $draft ? $draft : null; } /** * Apply one addressed text replacement. * * Every guard here is a refusal to write, and each answers a different way * this could be wrong: * * - `expected_state_hash` — the page has not changed since it was read, * so the address still means what it meant. * - `expected_text` — that leaf still holds exactly the text the owner * was shown, so this is the sentence they approved. * - `result_state_hash` — the state this produces is byte-identical to * the one the backend derived and the owner approved. This is the check * that makes "nothing else changed" a proof rather than a promise: if * applying one leaf edit somehow moved anything else, the hashes differ * and nothing is written. * * `dry_run` applies the edit in memory and reports the state it WOULD have * produced, writing nothing. That is how the backend learns the identity of * the page it is about to ask the owner to approve: it cannot derive a PHP * serialization itself, and guessing one would defeat the point of the * result check. Every guard above still runs, so a dry run on a page that * has already drifted fails before anyone is shown a preview. * * @param array $params node_id, expected_text, new_text, * expected_state_hash, result_state_hash, * dry_run. * @return array|\WP_Error */ public function apply( int $post_id, array $params ) { $builder = $this->detect( $post_id ); if ( null === $builder ) { return new \WP_Error( 'wppilot_builder_not_detected', 'No supported structured builder owns this post.', array( 'status' => 409 ) ); } $node_id = (string) ( $params['node_id'] ?? '' ); $expected_text = (string) ( $params['expected_text'] ?? '' ); $new_text = (string) ( $params['new_text'] ?? '' ); $expected_hash = (string) ( $params['expected_state_hash'] ?? '' ); $result_hash = (string) ( $params['result_state_hash'] ?? '' ); $expected_draft_hash = (string) ( $params['expected_draft_state_hash'] ?? '' ); $result_draft_hash = (string) ( $params['result_draft_state_hash'] ?? '' ); $dry_run = ! empty( $params['dry_run'] ); if ( '' === $node_id || '' === $expected_hash || ( ! $dry_run && '' === $result_hash ) ) { return new \WP_Error( 'wppilot_builder_bad_request', 'A builder edit needs an address and both state hashes.', array( 'status' => 400 ) ); } if ( strlen( $new_text ) > self::MAX_TEXT_LENGTH ) { return new \WP_Error( 'wppilot_builder_text_too_long', 'The replacement text is larger than this action will write.', array( 'status' => 400 ) ); } /* * Beaver Builder and Bricks print a text leaf as stored, and an * administrator with `unfiltered_html` is not filtered by kses: the new * text may not add a script, an event handler or an unsafe address the * leaf did not already hold — the rule the Elementor write keeps * (`Markup_Safety::change_refusal`, read as a browser reads it). Compared * with `expected_text`, which the leaf must hold exactly or nothing is * written. Checked before the page is read, so a dry run refuses too. */ $unsafe = Markup_Safety::change_refusal( $expected_text, $new_text ); if ( null !== $unsafe ) { return new \WP_Error( $unsafe['code'], $unsafe['message'], array( 'status' => 400 ) ); } $state = $this->load_state( $post_id, $builder ); if ( $state instanceof \WP_Error ) { return $state; } $current_hash = $this->hash_state( $state ); if ( ! hash_equals( $expected_hash, $current_hash ) ) { return new \WP_Error( 'wppilot_builder_state_drift', 'The page changed since it was read.', array( 'status' => 409, 'state_hash' => $current_hash ) ); } /* * The draft is state, and it drifts on its own clock. * * Somebody can open the builder and change the draft without the * published hash moving at all, so checking only the published side * leaves a window in which their unpublished work is overwritten by a * change they approved for a different version of the page. */ $draft = $this->beaver_draft( $post_id, $builder ); $current_draft = null === $draft ? '' : $this->hash_state( $draft ); if ( ! hash_equals( $expected_draft_hash, $current_draft ) ) { return new \WP_Error( 'wppilot_builder_draft_drift', 'The unpublished draft of this page changed since it was read.', array( 'status' => 409, 'draft_state_hash' => $current_draft ) ); } $mutated = $this->mutate( $builder, $state, $node_id, $expected_text, $new_text ); if ( $mutated instanceof \WP_Error ) { return $mutated; } /* * The same addressed leaf in the draft, or nothing at all. * * Applying it keeps the draft's other pending work untouched — this is a * one-field write into the owner's own graph, not a replacement of it — * and stops the next ordinary Publish from reversing what was approved. * * When that leaf is absent, or says something else, the two versions have * genuinely diverged and there is no safe answer available here: writing * the draft would overwrite an edit somebody is midway through, and * leaving it would arm the page to undo itself. So the whole operation * refuses, the page is untouched, and the owner is told why. */ $mutated_draft = null; if ( null !== $draft ) { $mutated_draft = $this->mutate( $builder, $draft, $node_id, $expected_text, $new_text ); if ( $mutated_draft instanceof \WP_Error ) { return new \WP_Error( 'wppilot_builder_draft_diverged', 'This page has unpublished changes in the builder that say something different, so the edit was not applied.', array( 'status' => 409 ) ); } } $mutated_hash = $this->hash_state( $mutated ); $mutated_draft_hash = null === $mutated_draft ? '' : $this->hash_state( $mutated_draft ); if ( $dry_run ) { return array( 'success' => true, 'builder' => $builder, 'dry_run' => true, 'previous_state_hash' => $current_hash, 'state_hash' => $mutated_hash, 'state' => $this->encode_state( $mutated ), 'draft_state_hash' => $mutated_draft_hash, 'draft_state' => null === $mutated_draft ? '' : $this->encode_state( $mutated_draft ), ); } if ( ! hash_equals( $result_hash, $mutated_hash ) ) { return new \WP_Error( 'wppilot_builder_result_mismatch', 'Applying this edit did not produce the approved page state.', array( 'status' => 409, 'state_hash' => $mutated_hash ) ); } if ( ! hash_equals( $result_draft_hash, $mutated_draft_hash ) ) { return new \WP_Error( 'wppilot_builder_result_mismatch', 'Applying this edit did not produce the approved draft state.', array( 'status' => 409, 'draft_state_hash' => $mutated_draft_hash ) ); } $persisted = $this->persist( $post_id, $builder, $mutated, $mutated_draft, $state, $draft ); if ( $persisted instanceof \WP_Error ) { return $persisted; } $this->logger->info( 'Builder content edit applied for post ' . $post_id . ' (' . $builder . ')' ); return array( 'success' => true, 'builder' => $builder, 'dry_run' => false, 'previous_state_hash' => $current_hash, 'state_hash' => $mutated_hash, 'state' => $this->encode_state( $mutated ), 'draft_state_hash' => $mutated_draft_hash, 'draft_state' => null === $mutated_draft ? '' : $this->encode_state( $mutated_draft ), ); } /* ------ state ------------------------------------------------------- */ /** * @return array|\WP_Error */ private function load_state( int $post_id, string $builder ) { $key = self::BUILDER_BEAVER === $builder ? self::BEAVER_PUBLISHED_KEY : self::BRICKS_KEY; $raw = get_post_meta( $post_id, $key, true ); if ( ! is_array( $raw ) || array() === $raw ) { return new \WP_Error( 'wppilot_builder_state_unreadable', 'The builder layout for this post could not be read.', array( 'status' => 422 ) ); } return $raw; } /** * A stable fingerprint of the complete authoritative state. * * `serialize()` is deterministic for identical structures, and identity is * exactly the question being asked: is this the same page, byte for byte, * that was read a moment ago. It is never used as a checksum against * tampering, only as an equality test between two states of one site. * * @param array $state */ private function hash_state( array $state ): string { return hash( 'sha256', serialize( $state ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize } /** * The complete state, transportable and inert. * * Base64 of the serialized graph: enough for the snapshot to record exactly * what the page held, and never given back to PHP — restoring runs through * the same addressed write as the forward edit, so nothing on this site ever * unserializes a payload that arrived over the network. * * @param array $state */ private function encode_state( array $state ): string { // Both functions are on WordPress's discouraged list for good reasons that // do not apply here: this is not obfuscation and not a storage format. It // is the page's own state, made inert for transport and never decoded on // this side — the restore path is the same addressed write as the edit. // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize, WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode return base64_encode( serialize( $state ) ); } /** * @param array $state * @return array|\WP_Error */ private function mutate( string $builder, array $state, string $node_id, string $expected_text, string $new_text ) { return self::BUILDER_BEAVER === $builder ? $this->beaver_mutate( $state, $node_id, $expected_text, $new_text ) : $this->bricks_mutate( $state, $node_id, $expected_text, $new_text ); } /** * @param array $state * @return true|\WP_Error */ private function persist( int $post_id, string $builder, array $state, ?array $draft, array $original_state, ?array $original_draft ) { if ( self::BUILDER_BRICKS === $builder ) { if ( $this->write_and_verify( $post_id, self::BRICKS_KEY, $state ) ) { $this->purge_bricks_cache( $post_id ); return true; } // One layout, so there is nothing half-done to compensate — but the // site is left proving it holds the original rather than assuming it. return $this->write_and_verify( $post_id, self::BRICKS_KEY, $original_state ) ? $this->write_failed() : $this->write_stranded(); } /* * Beaver writes two layouts, and either write can fail on its own. * * A half-applied pair is the worst outcome available here: the action * would report success, verification would later find published=approved * and draft=original, and the automatic rollback that fires on that * mismatch expects BOTH sides to be the approved state before it will * restore them — so it would refuse on drift and the owner would be told * a recovered page could not be recovered. * * So this call is all-or-nothing. Each side is written and immediately * read back; the first failure puts every side already changed back to * the graph it held when `apply()` read it, and proves that too. Nothing * reports success until the complete pair is the approved state, and * nothing is left for a later rollback to rescue — an operation that * never completed is not one the owner should have to undo. */ if ( ! $this->write_and_verify( $post_id, self::BEAVER_PUBLISHED_KEY, $state ) ) { // Nothing else has been touched yet; restore what this attempt may // have half-written and stop. return $this->write_and_verify( $post_id, self::BEAVER_PUBLISHED_KEY, $original_state ) ? $this->write_failed() : $this->write_stranded(); } // The draft written here is the owner's own graph with one leaf changed — // never the published graph copied over it. Everything else they have // pending survives, and the next Publish carries the approved wording // rather than reversing it. if ( null !== $draft && ! $this->write_and_verify( $post_id, self::BEAVER_DRAFT_KEY, $draft ) ) { $restored_published = $this->write_and_verify( $post_id, self::BEAVER_PUBLISHED_KEY, $original_state ); $restored_draft = null === $original_draft ? true : $this->write_and_verify( $post_id, self::BEAVER_DRAFT_KEY, $original_draft ); $this->purge_beaver_cache( $post_id ); return $restored_published && $restored_draft ? $this->write_failed() : $this->write_stranded(); } $this->purge_beaver_cache( $post_id ); return true; } /** * Write one layout and prove the site stored exactly that. * * The read-back is the point: `update_post_meta` returning false is * ambiguous (it says that about an unchanged value too), and a filter, a * failed query or a serialization surprise can all leave the stored graph * different from the one handed over. Hashing what came back answers the * only question worth asking. * * @param array $state */ private function write_and_verify( int $post_id, string $key, array $state ): bool { update_post_meta( $post_id, $key, $this->slash_deep( $state ) ); $written = get_post_meta( $post_id, $key, true ); return is_array( $written ) && $this->hash_state( $written ) === $this->hash_state( $state ); } private function write_failed(): \WP_Error { return new \WP_Error( 'wppilot_builder_write_failed', 'The builder layout did not store the approved state, so the page was put back as it was.', array( 'status' => 500 ) ); } /** * The write failed AND putting the page back failed. * * Its own code because it is its own situation: the page may be holding * something nobody approved, and saying "the change did not go through" * would be the one answer that is definitely wrong. */ private function write_stranded(): \WP_Error { return new \WP_Error( 'wppilot_builder_write_stranded', 'The builder layout could not be written and could not be put back. Open the page in the builder before making further changes.', array( 'status' => 500 ) ); } /** * WordPress unslashes meta on write, so values must be slashed on the way in * or every backslash and quote in the layout erodes by one level per save — * which is why both builders do exactly this in their own save paths. * * @param mixed $value * @return mixed */ private function slash_deep( $value ) { if ( is_array( $value ) ) { foreach ( $value as $key => $item ) { $value[ $key ] = $this->slash_deep( $item ); } return $value; } if ( is_object( $value ) ) { foreach ( get_object_vars( $value ) as $key => $item ) { $value->$key = $this->slash_deep( $item ); } return $value; } if ( is_string( $value ) ) { return addslashes( $value ); } return $value; } private function purge_beaver_cache( int $post_id ): void { if ( class_exists( '\FLBuilderModel' ) && method_exists( '\FLBuilderModel', 'delete_asset_cache_for_post' ) ) { \FLBuilderModel::delete_asset_cache_for_post( $post_id ); } clean_post_cache( $post_id ); } private function purge_bricks_cache( int $post_id ): void { // Bricks regenerates its per-page CSS from a stored file/meta; deleting // the record is what makes the next view rebuild it. Guarded because a // version without it must not fatal a write that already succeeded. if ( class_exists( '\Bricks\Assets_Files' ) && method_exists( '\Bricks\Assets_Files', 'generate_post_css_file' ) ) { \Bricks\Assets_Files::generate_post_css_file( $post_id ); } delete_post_meta( $post_id, '_bricks_page_content_2_css' ); clean_post_cache( $post_id ); } /* ------ Beaver ------------------------------------------------------ */ /** * Beaver's layout is an array of `stdClass` nodes keyed by node id, each with * a `type` (row / column / column-group / module) and a `settings` object; * a module's kind is `settings->type`. Nodes carry `global` when they are a * shared node, which is the case this must never write: the same text is on * pages nobody asked about. * * @param array $state * @return array> */ private function beaver_nodes( array $state ): array { $nodes = array(); foreach ( $state as $node_key => $node ) { if ( ! is_object( $node ) || count( $nodes ) >= self::MAX_NODES ) { continue; } if ( ! empty( $node->global ) || isset( $node->template_id ) || isset( $node->template_node_id ) ) { continue; } $settings = $node->settings ?? null; if ( ! is_object( $settings ) ) { continue; } $module_type = isset( $settings->type ) && is_string( $settings->type ) ? strtolower( $settings->type ) : ''; if ( '' !== $module_type && in_array( $module_type, self::OPAQUE_TYPES, true ) ) { continue; } foreach ( get_object_vars( $settings ) as $key => $value ) { if ( ! $this->is_editable_leaf( (string) $key, $value ) ) { continue; } $nodes[] = array( 'id' => 'bb:' . (string) $node_key . ':' . (string) $key, 'kind' => $this->kind_for( (string) $key, $module_type, (string) $value ), 'text' => (string) $value, 'context' => $this->context_for( $module_type, (string) $node_key ), ); if ( count( $nodes ) >= self::MAX_NODES ) { break; } } } return $nodes; } /** * @param array $state * @return array|\WP_Error */ private function beaver_mutate( array $state, string $node_id, string $expected_text, string $new_text ) { $parts = explode( ':', $node_id, 3 ); if ( 3 !== count( $parts ) || 'bb' !== $parts[0] ) { return $this->bad_address(); } $node_key = $parts[1]; $field = $parts[2]; if ( ! isset( $state[ $node_key ] ) || ! is_object( $state[ $node_key ] ) ) { return $this->bad_address(); } $node = $state[ $node_key ]; if ( ! empty( $node->global ) || ! is_object( $node->settings ?? null ) ) { return $this->bad_address(); } $settings = $node->settings; if ( ! isset( $settings->$field ) || ! is_string( $settings->$field ) ) { return $this->bad_address(); } if ( ! $this->is_editable_leaf( $field, $settings->$field ) ) { return $this->bad_address(); } if ( $settings->$field !== $expected_text ) { return $this->stale_text(); } // The node objects are shared with the cached copy WordPress handed us, // so the graph is cloned before anything is written into it. $next = $this->clone_state( $state ); $next[ $node_key ]->settings->$field = $new_text; return $next; } /* ------ Bricks ------------------------------------------------------ */ /** * Bricks stores an array of element arrays — `id`, `name`, `parent`, * `children`, `settings` — with visible copy under `settings`. Elements that * point at a global element, a template or a query are skipped: their text * either lives somewhere else or is not text at all. * * @param array $state * @return array> */ private function bricks_nodes( array $state ): array { $nodes = array(); foreach ( $state as $element ) { if ( ! is_array( $element ) || count( $nodes ) >= self::MAX_NODES ) { continue; } $id = isset( $element['id'] ) && is_string( $element['id'] ) ? $element['id'] : ''; if ( '' === $id ) { continue; } if ( isset( $element['global'] ) || isset( $element['globalElement'] ) || isset( $element['templateId'] ) ) { continue; } $name = isset( $element['name'] ) && is_string( $element['name'] ) ? strtolower( $element['name'] ) : ''; if ( '' !== $name && in_array( $name, self::OPAQUE_TYPES, true ) ) { continue; } $settings = $element['settings'] ?? null; if ( ! is_array( $settings ) ) { continue; } foreach ( $settings as $key => $value ) { if ( ! is_string( $key ) || ! $this->is_editable_leaf( $key, $value ) ) { continue; } $nodes[] = array( 'id' => 'br:' . $id . ':' . $key, 'kind' => $this->kind_for( $key, $name, (string) $value ), 'text' => (string) $value, 'context' => $this->context_for( $name, $id ), ); if ( count( $nodes ) >= self::MAX_NODES ) { break; } } } return $nodes; } /** * @param array $state * @return array|\WP_Error */ private function bricks_mutate( array $state, string $node_id, string $expected_text, string $new_text ) { $parts = explode( ':', $node_id, 3 ); if ( 3 !== count( $parts ) || 'br' !== $parts[0] ) { return $this->bad_address(); } $element_id = $parts[1]; $field = $parts[2]; $found = null; foreach ( $state as $index => $element ) { if ( is_array( $element ) && ( $element['id'] ?? null ) === $element_id ) { if ( null !== $found ) { // Two elements answering to one id is not an address. return $this->bad_address(); } $found = $index; } } if ( null === $found ) { return $this->bad_address(); } $element = $state[ $found ]; if ( isset( $element['global'] ) || isset( $element['globalElement'] ) || isset( $element['templateId'] ) ) { return $this->bad_address(); } $settings = $element['settings'] ?? null; if ( ! is_array( $settings ) || ! isset( $settings[ $field ] ) || ! is_string( $settings[ $field ] ) ) { return $this->bad_address(); } if ( ! $this->is_editable_leaf( $field, $settings[ $field ] ) ) { return $this->bad_address(); } if ( $settings[ $field ] !== $expected_text ) { return $this->stale_text(); } $next = $this->clone_state( $state ); $next[ $found ]['settings'][ $field ] = $new_text; return $next; } /* ------ shared judgement -------------------------------------------- */ /** * Whether one settings value is visible prose this action may rewrite. * * The key must be one we can name, and the value must survive every test for * being something else: a URL, a colour, a CSS length, a class list, an * enum, a number, a dynamic-data tag, a shortcode, or code. A value that * fails any of these is left alone even when the key looks like copy — * `title` holding `{post_title}` is a dynamic tag, and replacing it with a * sentence would freeze a page that was meant to change with its content. * * @param mixed $value */ private function is_editable_leaf( string $key, $value ): bool { if ( ! is_string( $value ) ) { return false; } $normalized_key = strtolower( trim( $key ) ); if ( ! in_array( $normalized_key, self::TEXT_KEYS, true ) ) { return false; } $trimmed = trim( $value ); if ( '' === $trimmed || strlen( $trimmed ) > self::MAX_TEXT_LENGTH ) { return false; } // Dynamic data, shortcodes, template tags and code: all of these render // into something other than themselves. if ( preg_match( '/\{[a-z0-9_:\-\.\|\s]+\}|\[[a-z0-9_\-]+[^\]]*\]|<\?php|]*>/', ' ', $trimmed ) ?? '' ); $plain = trim( preg_replace( '/\s+/', ' ', $plain ) ?? '' ); if ( '' === $plain ) { return false; } if ( preg_match( '#^(?:https?://|//|/|mailto:|tel:|[a-z]+://)#i', $plain ) ) { return false; } if ( preg_match( '/^(?:#[0-9a-f]{3,8}|rgba?\(|hsla?\(|var\()/i', $plain ) ) { return false; } if ( preg_match( '/^-?(?:\d+|\d*\.\d+)\s*(?:px|em|rem|%|vh|vw|deg|s|ms)?$/i', $plain ) ) { return false; } if ( preg_match( '/^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+$/', $plain ) ) { return false; } if ( preg_match( '/^(?:auto|default|inherit|initial|none|normal|yes|no|true|false|left|right|center|top|bottom|solid|full|wide)$/i', $plain ) ) { return false; } // Something a person would read: at least one real word. return 1 === preg_match( '/[\p{L}]{2,}/u', $plain ); } private function kind_for( string $key, string $type, string $value ): string { $key = strtolower( $key ); $type = strtolower( $type ); if ( 'heading' === $key || 'heading' === $type || 'title' === $key ) { return 'heading'; } if ( 'button' === $type || 'button_text' === $key || 'cta_text' === $key || 'link_text' === $key || 'label' === $key ) { return 'label'; } if ( 'caption' === $key ) { return 'caption'; } if ( preg_match( '/<[a-z][^>]*>/i', $value ) ) { return 'rich_text'; } return 'paragraph'; } private function context_for( string $type, string $node_key ): string { $label = '' !== $type ? $type : 'element'; return substr( $label . ' ' . $node_key, 0, self::MAX_CONTEXT_LENGTH ); } /** * Deep copy so a mutation cannot reach the object graph WordPress cached. * * @param array $state * @return array */ private function clone_state( array $state ): array { $copy = array(); foreach ( $state as $key => $value ) { $copy[ $key ] = $this->clone_value( $value ); } return $copy; } /** * @param mixed $value * @return mixed */ private function clone_value( $value ) { if ( is_object( $value ) ) { $clone = clone $value; foreach ( get_object_vars( $clone ) as $key => $item ) { $clone->$key = $this->clone_value( $item ); } return $clone; } if ( is_array( $value ) ) { foreach ( $value as $key => $item ) { $value[ $key ] = $this->clone_value( $item ); } return $value; } return $value; } private function meta_string( int $post_id, string $key ): string { $value = get_post_meta( $post_id, $key, true ); return is_string( $value ) ? $value : ( $value ? '1' : '' ); } private function bad_address(): \WP_Error { return new \WP_Error( 'wppilot_builder_bad_address', 'That address does not name an editable piece of text on this page.', array( 'status' => 409 ) ); } private function stale_text(): \WP_Error { return new \WP_Error( 'wppilot_builder_text_drift', 'That text has changed since it was read.', array( 'status' => 409 ) ); } }