html = $html; $this->plugin = $plugin; $this->fieldValues = []; $this->cache = $cache; } public abstract function replacePostUpdatedMessage(array $messages): array; protected function getInstanceData(int $postId): AcfGroupInterface { return static::NAME === AcfViews::NAME ? $this->cache->getAcfViewData($postId) : $this->cache->getAcfCardData($postId); } protected function getActionClone(): string { return static::NAME . '_clone'; } protected function getActionCloned(): string { return static::NAME . '_cloned'; } /** * @param int|string $postId Can be string, e.g. 'options' * @param array|null $targetStatuses * * @return bool */ protected function isMyPost($postId, ?array $targetStatuses = ['publish',]): bool { // for 'site-settings' and similar if (! is_numeric($postId) || ! $postId) { return false; } $post = get_post($postId); if (! $post || static::NAME !== $post->post_type || wp_is_post_revision($postId) || ($targetStatuses && ! in_array($post->post_status, $targetStatuses, true))) { return false; } return true; } // by tests, json in post_meta in 13 times quicker than ordinary postMeta way (30ms per 10 objects vs 400ms) protected function replaceAcfFieldsWithJson(): void { // below ->isMyPost() is used without 'targetStatuses', as can be draft e.g. after cloning // 1. save meta field to the local array instead of the postmeta, // 'save_post' hook to make sure code executes only at the right time (not at the front, it would be time-wasting) add_action('acf/save_post', function ($postId) { if (! $this->isMyPost($postId, null)) { return; } add_filter('acf/pre_update_value', function ($isUpdated, $value, int $postId, array $field): bool { // extra check, as probably it's about another post if (! $this->isMyPost($postId, null)) { return $isUpdated; } $this->saveMetaField($value, $postId, $field); // avoid saving to the postmeta return true; }, 10, 4); }); // 2. save the local meta fields array to the post_content field // 'save_post' hook to make sure code executes only at the right time (not at the front, it would be time-wasting) // priority (20) is important here, means only after ACF UI saved all values add_action('acf/save_post', function ($postId) { if (! $this->isMyPost($postId, null)) { return; } $this->saveMetaFieldsToPost($postId); }, 20); // 3. 'replace' loading values for ACF interface from postmeta to my source (post_content) // 'admin_head' hook to make sure code executes only at the right time (not at the front, it would be time-wasting) add_action('acf/input/admin_head', function () { global $post; $postId = $post->ID ?? 0; if (! $this->isMyPost($postId, null)) { return; } // values are cache here, to avoid call instanceData->getFieldValues() every time // as it takes resources (to go through all inner objects) $values = []; add_filter('acf/pre_load_value', function ($value, $postId, $field) use ($values) { // extra check, as probably it's about another post if (! $this->isMyPost($postId, null)) { return $value; } if (! key_exists($postId, $values)) { $instanceData = $this->getInstanceData($postId); $values[$postId] = $instanceData->getFieldValues(); } return $this->getAcfFieldFromInstance($value, $postId, $field, $values[$postId]); }, 10, 3); }); } public function setHooks(): void { add_action('admin_init', [$this, 'cloneItemAction']); add_action('admin_notices', [$this, 'showItemClonedMessage']); add_action('add_meta_boxes', [$this, 'addMetaboxes']); add_action('admin_enqueue_scripts', [$this, 'addCodemirrorToCPTEditScreens']); add_action('post_edit_form_tag', [$this, 'disableAutocompleteForPostEdit']); add_filter('views_edit-' . static::NAME, [$this, 'printPostTypeDescription',]); add_filter('post_row_actions', [$this, 'getRowActions',], 10, 2); add_filter('post_updated_messages', [$this, 'replacePostUpdatedMessage']); $this->replaceAcfFieldsWithJson(); } public function cloneItemAction(): void { if (! isset($_GET[$this->getActionClone()])) { return; } $postId = (int)$_GET[$this->getActionClone()]; $post = get_post($postId); if (! $post || static::NAME !== $post->post_type) { return; } check_admin_referer('bulk-posts'); $isHasGutenbergField = AcfViewData::POST_FIELD_IS_HAS_GUTENBERG; $args = [ 'post_type' => $post->post_type, 'post_status' => 'draft', 'post_name' => $post->post_name, 'post_title' => $post->post_title . ' Clone', 'post_author' => $post->post_author, 'post_content' => wp_slash($post->post_content), // json of fields AcfViewData::POST_FIELD_IS_HAS_GUTENBERG => $post->{$isHasGutenbergField}, // isHasGutenbergBlock ]; $newPostId = wp_insert_post($args); // something went wrong if (is_wp_error($newPostId)) { return; } $targetUrl = get_admin_url(null, '/edit.php?post_type=' . static::NAME); $targetUrl .= '&' . $this->getActionCloned() . '=1'; wp_redirect($targetUrl); exit; } public function showItemClonedMessage(): void { if (! isset($_GET[$this->getActionCloned()])) { return; } echo '
Item success cloned.
' . '%s
', $postType->description); } return $views; // return original input unchanged } /** * Otherwise in case editing fields (without saving) and reloading a page, * then the fields have these unsaved values, it's wrong and breaks logic (e.g. of group-field selects) */ public function disableAutocompleteForPostEdit(WP_Post $post): void { if (static::NAME !== $post->post_type) { return; } echo ' autocomplete="off"'; } public function saveMetaField($value, int $postId, array $field): void { $fieldName = $field['name'] ?? ''; $instanceData = $this->getInstanceData($postId); // convert repeater format. don't check simply 'is_array(value)' as not every array is a repeater // also check to make sure it's array (can be empty string) if (in_array($fieldName, $instanceData->getRepeaterFieldNames(), true) && is_array($value)) { $value = AcfGroup::convertRepeaterFieldValues($fieldName, $value); } // convert clone format // also check to make sure it's array (can be empty string) if (in_array($fieldName, $instanceData->getCloneFieldNames(), true) && is_array($value)) { $newValue = AcfGroup::convertCloneField($fieldName, $value); $this->fieldValues = array_merge($this->fieldValues, $newValue); return; } $this->fieldValues[$fieldName] = $value; } public function saveMetaFieldsToPost(int $postId): void { $instanceData = $this->getInstanceData($postId); // remove slashes added by WP, as it's wrong to have slashes so early // (corrupts next data processing, like markup generation (will be \"e; instead of "e; due to this escaping) // in the 'saveToPostContent()' method using $wpdb that also has 'addslashes()', // it means otherwise \" will be replaced with \\\" and it'll create double slashing issue (every saving amount of slashes before " will be increasing) $fieldValues = array_map('stripslashes_deep', $this->fieldValues); $instanceData->load($postId, '', $fieldValues); $instanceData->saveToPostContent(); } public function getAcfFieldFromInstance($value, int $postId, array $field, array $values) { $fieldName = $field['name'] ?? ''; // skip sub-fields or fields from other groups if (! key_exists($fieldName, $values)) { return $value; } $value = $values[$fieldName]; $instanceData = $this->getInstanceData($postId); // convert repeater format. don't check simply 'is_array(value)' as not every array is a repeater // also check to make sure it's array (can be empty string) $value = in_array($fieldName, $instanceData->getRepeaterFieldNames(), true) && is_array($value) ? AcfGroup::convertRepeaterFieldValues($fieldName, $value, false) : $value; // convert clone format $cloneFieldNames = $instanceData->getCloneFieldNames(); foreach ($cloneFieldNames as $cloneFieldName) { $clonePrefix = $cloneFieldName . '_'; if (0 !== strpos($fieldName, $clonePrefix)) { continue; } // can be string field if (! is_array($value)) { break; } $fieldNameWithoutClonePrefix = substr($fieldName, strlen($clonePrefix)); $value = AcfGroup::convertCloneField($fieldNameWithoutClonePrefix, $value, false); break; } return $value; } }