self::MAX_JOB_BYTES) { return self::error(__('This job exceeds the 2 MB journal limit. Use fewer posts.', 'customfields-com')); } update_option(self::job_key(), $job, false); wp_cache_delete(self::job_key(), 'options'); if (self::hash(get_option(self::job_key())) !== self::hash($job)) { return self::error(__('The journal could not be saved. No further writes were attempted.', 'customfields-com')); } return $job; } /** Database connection lock survives long batches and releases on disconnect after a crash. */ private static function lock() { global $wpdb; $name = 'cfwf_' . substr(hash('sha256', DB_NAME . ':' . $wpdb->options . ':' . get_current_user_id()), 0, 48); $locked = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. return (string) $locked === '1' ? $name : self::error(__('Another workflow request is running, or this database does not support advisory locks.', 'customfields-com')); } private static function unlock($lock) { global $wpdb; $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. } /** Require transactional storage; never silently downgrade recovery guarantees. */ private static function begin($extra_tables = array()) { global $wpdb; foreach (array_unique(array_merge(array($wpdb->postmeta, $wpdb->options), $extra_tables)) as $table) { $row = $wpdb->get_row($wpdb->prepare('SHOW TABLE STATUS LIKE %s', $wpdb->esc_like($table)), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inspect/provision the actual database schema; cached application metadata cannot establish storage-engine or column guarantees. if (!$row || strtolower($row['Engine'] ?? '') !== 'innodb') { return self::error(__('These workflows require InnoDB metadata and options tables.', 'customfields-com')); } } if (false === $wpdb->query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE') || false === $wpdb->query('START TRANSACTION')) { return self::error(__('Could not start a protected database transaction.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. return true; } public static function remember($groups, $before) { $history = get_option(self::HISTORY, array()); if (!is_array($history)) { $history = array(); } $skipped = false; // Enforce the same bounds even when older code left an oversized history option. while ($history && (count($history) > self::MAX_HISTORY || strlen(serialize($history)) > self::MAX_HISTORY_BYTES)) { array_shift($history); } foreach (array($before, $groups) as $snapshot) { if (!is_array($snapshot)) { continue; } $hash = self::hash($snapshot); $last = $history ? end($history) : array(); if (($last['hash'] ?? '') === $hash) { continue; } $entry = array('id' => wp_generate_uuid4(), 'time' => gmdate('c'), 'user' => get_current_user_id(), 'hash' => $hash, 'groups' => $snapshot); // Include the array wrapper in the byte check; one snapshot must never escape the cap. if (strlen(serialize(array($entry))) > self::MAX_HISTORY_BYTES) { $skipped = true; continue; } $history[] = $entry; while ($history && (count($history) > self::MAX_HISTORY || strlen(serialize($history)) > self::MAX_HISTORY_BYTES)) { array_shift($history); } } update_option(self::HISTORY, $history, false); if ($skipped) { update_option(self::HISTORY_NOTICE, __('A schema snapshot exceeded the 4 MB history limit and was not retained. Existing bounded snapshots remain available. This does not undo a schema save; export the current definitions separately.', 'customfields-com'), false); } else { delete_option(self::HISTORY_NOTICE); } } /** Stable group/field identities produce useful property changes, not positional JSON noise. */ public static function diff($before, $after) { $index = static function ($groups) { $result = array(); foreach ($groups as $group) { $fields = array(); foreach ($group['fields'] as $field) { $fields[$field['key']] = $field; } $group['field_order'] = array_keys($fields); $group['fields'] = $fields; $result[$group['key']] = $group; } return $result; }; $changes = array(); self::diff_walk($index($before), $index($after), '', $changes); return $changes; } private static function diff_walk($before, $after, $path, &$changes) { if ($before === $after) { return; } if (is_array($before) && is_array($after)) { foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $key) { $next = $path . '/' . $key; if (!array_key_exists($key, $before)) { $changes[] = array('path' => $next, 'change' => 'added', 'before' => null, 'after' => $after[$key]); } elseif (!array_key_exists($key, $after)) { $changes[] = array('path' => $next, 'change' => 'removed', 'before' => $before[$key], 'after' => null); } else { self::diff_walk($before[$key], $after[$key], $next, $changes); } } } else { $changes[] = array('path' => $path, 'change' => 'changed', 'before' => $before, 'after' => $after); } } private static function decode($raw) { $value = is_serialized($raw) ? @unserialize($raw, array('allowed_classes' => false)) : $raw; return self::safe_value($value) ? $value : self::error(__('Object or overly nested metadata is unsupported.', 'customfields-com')); } private static function safe_value($value, $depth = 0) { if ($depth > 8 || is_object($value) || is_resource($value)) { return false; } if (is_array($value)) { foreach ($value as $item) { if (!self::safe_value($item, $depth + 1)) { return false; } } } return true; } private static function raw($value) { return (string) maybe_serialize($value); } private static function option_row($name) { global $wpdb; $rows = $wpdb->get_results($wpdb->prepare("SELECT option_id AS meta_id, LEFT(option_value, %d) AS meta_value FROM {$wpdb->options} WHERE BINARY option_name = BINARY %s LIMIT 2", self::MAX_JOB_BYTES + 1, $name), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows. if (!is_array($rows) || $wpdb->last_error || count($rows) > 1 || ($rows && strlen($rows[0]['meta_value']) > self::MAX_JOB_BYTES)) { return self::error(__('The options container is unavailable, ambiguous or too large.', 'customfields-com')); } foreach ($rows as &$row) { $row['meta_id'] = (int) $row['meta_id']; } return $rows; } /** Query raw values to preserve exact old bytes, duplicate rows and storage identity. */ private static function meta($post_id, $name, $multiple = false, $target = 'post') { global $wpdb; if ($target === 'options') { $rows = self::option_row('customfields_pro_values'); if (is_wp_error($rows) || !$rows) { return $rows; } $values = self::decode($rows[0]['meta_value']); if (!is_array($values)) { return self::error(__('The native options container has an unsupported shape.', 'customfields-com')); } return array_key_exists($name, $values) ? array(array('meta_id' => $rows[0]['meta_id'], 'meta_value' => self::raw($values[$name]))) : array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Metadata column name or presentation value, not an unbounded WP_Query meta scan. } $table = _get_meta_table($target); $identity = $target === 'user' ? 'umeta_id' : 'meta_id'; $owner = $target . '_id'; if (!$table || !in_array($target, array('post', 'user', 'term'), true)) { return self::error(__('Unsupported metadata target.', 'customfields-com')); } $rows = $wpdb->get_results($wpdb->prepare("SELECT %i AS meta_id, LEFT(meta_value, %d) AS meta_value FROM %i WHERE %i = %d AND BINARY meta_key = BINARY %s ORDER BY %i LIMIT 101", $identity, self::MAX_JOB_BYTES + 1, $table, $owner, $post_id, $name, $identity), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows. if (null === $rows || $wpdb->last_error || count($rows) > 100) { return self::error(__('Metadata could not be read within the supported bounds.', 'customfields-com')); } if (!$multiple && count($rows) > 1) { return self::error(__('Multiple metadata rows make this field ambiguous. Resolve duplicates before migrating.', 'customfields-com')); } foreach ($rows as &$row) { $row['meta_id'] = (int) $row['meta_id']; } return $rows; } private static function field($name, $post_id = 0, $target = 'post') { $found = array(); foreach (customfields_get_groups() as $group) { if (($group['target'] ?? 'post') !== $target) { continue; } if ($post_id && !self::object_matches($group, $target, $post_id)) { continue; } foreach ($group['fields'] as $field) { if ($field['name'] === $name) { $found[] = $field; } } } return count($found) === 1 ? $found[0] : self::error(__('Select an unambiguous registered field applicable to every selected object.', 'customfields-com')); } private static function matches($group, $post) { if (!$post) { return false; } return class_exists('CustomFields\\Editor') ? \CustomFields\Editor::matches($group, $post) : in_array($post->post_type, $group['post_types'], true); } private static function object_matches($group, $target, $id) { if (($group['target'] ?? 'post') !== $target) { return false; } if ($target === 'post') { return self::matches($group, get_post($id)); } if ($target === 'options') { return (int) $id === 1 && current_user_can($group['options_page']['capability'] ?? 'manage_options'); } if ($target === 'user') { return (bool) get_userdata($id); } if ($target === 'term') { $term = get_term($id); return $term && !is_wp_error($term) && in_array($term->taxonomy, $group['taxonomies'] ?? array(), true); } return false; } private static function permission($post_id, $destination, $source = '', $target = 'post') { if ($target === 'options') { return self::allowed() && (int) $post_id === 1 && !is_wp_error(self::field($destination, 1, 'options')) && (!$source || !is_wp_error(self::field($source, 1, 'options'))) ? true : self::error(__('You cannot access the registered options fields.', 'customfields-com')); } $exists = $target === 'post' ? (get_post($post_id) && !wp_is_post_revision($post_id) && !wp_is_post_autosave($post_id)) : ($target === 'user' ? get_userdata($post_id) : ($target === 'term' ? get_term($post_id) : false)); if (!self::allowed() || !$exists || is_wp_error($exists) || !\CustomFields\Editor::can_edit($target, $post_id, $destination) || ($source && !\CustomFields\Editor::can_edit($target, $post_id, $source))) { return self::error(__('You cannot edit the selected object or field metadata.', 'customfields-com')); } return true; } /** Flat ACF source inventory must come from ACF's active registry, never a user-supplied metakey. */ private static function acf_inventory($key, $post_id, $destination) { if (!function_exists('acf_get_field') || !function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) { return self::error(__('Activate ACF with the original registered field definitions before converting content.', 'customfields-com')); } if (!is_string($key) || !preg_match('/^field_[a-zA-Z0-9_-]{1,80}$/D', $key)) { return self::error(__('Use the registered ACF field key, beginning field_.', 'customfields-com')); } $source = acf_get_field($key); if (!is_array($source) || ($source['key'] ?? '') !== $key || ($source['type'] ?? '') !== 'repeater' || !preg_match('/^[a-z][a-z0-9_]{0,63}$/D', $source['name'] ?? '') || $source['name'] === $destination['name']) { return self::error(__('Select a registered flat ACF repeater and a differently named CustomFields.com repeater destination.', 'customfields-com')); } $matches = 0; foreach ((array) acf_get_field_groups(array('post_id' => $post_id)) as $group) { if (isset($group['active']) && !$group['active']) { continue; } foreach ((array) acf_get_fields($group) as $candidate) { if (($candidate['key'] ?? '') === $key && ($candidate['name'] ?? '') === $source['name']) { $matches++; } } } if ($matches !== 1 || $destination['type'] !== 'repeater') { return self::error(__('The ACF source must be a top-level field in exactly one applicable active group; the destination must be a repeater.', 'customfields-com')); } $map = array('text' => 'text', 'textarea' => 'textarea', 'number' => 'number', 'email' => 'email', 'url' => 'url', 'select' => 'select', 'true_false' => 'checkbox', 'color_picker' => 'color'); $children = $source['sub_fields'] ?? array(); $target = array_column($destination['sub_fields'] ?? array(), null, 'name'); if (!$children || count($children) > 20 || count($children) !== count($target)) { return self::error(__('Source and destination must contain the same 1–20 flat child names.', 'customfields-com')); } $seen = array(); foreach ($children as $index => $child) { $name = $child['name'] ?? ''; if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/D', $name) || !preg_match('/^field_[a-zA-Z0-9_-]{1,80}$/D', $child['key'] ?? '') || isset($seen[$name]) || !isset($target[$name], $map[$child['type'] ?? '']) || $map[$child['type']] !== $target[$name]['type'] || !empty($child['multiple']) || !empty($child['conditional_logic']) || !empty($child['sub_fields'])) { return self::error(__('Only matching flat scalar children are supported; nested, conditional, multiple and unknown ACF child formats are rejected.', 'customfields-com')); } $seen[$name] = true; $schema = \CustomFields\Schema::validate_field(array('key' => $child['key'], 'name' => $name, 'label' => $child['label'] ?? $name, 'type' => $map[$child['type']], 'required' => !empty($child['required']), 'choices' => $child['choices'] ?? array(), 'default' => '')); if (is_wp_error($schema)) { return self::error(__('An ACF child inventory cannot be validated as a supported scalar schema.', 'customfields-com')); } $source['sub_fields'][$index]['_cfwf_schema'] = $schema; } if (!empty($source['conditional_logic'])) { return self::error(__('Conditional ACF repeater sources are not supported by this conversion.', 'customfields-com')); } return $source; } private static function acf_values($source, $post_id, &$snapshots) { $name = $source['name']; $root = self::meta($post_id, $name); if (is_wp_error($root)) { return $root; } $snapshots[$name] = $root; if (!$root) { return null; } $count = $root[0]['meta_value']; if (!preg_match('/^(0|[1-9][0-9]{0,2})$/D', $count) || (int) $count > 100) { return self::error(__('ACF source must contain a row counter of 0–100, not serialized or already converted data.', 'customfields-com')); } if ((int) $count < (int) ($source['min'] ?? 0) || (!empty($source['max']) && (int) $count > (int) $source['max'])) { return self::error(__('ACF row count conflicts with its registered source constraints.', 'customfields-com')); } $reference = self::meta($post_id, '_' . $name); if (is_wp_error($reference)) { return $reference; } $snapshots['_' . $name] = $reference; if (!$reference || $reference[0]['meta_value'] !== $source['key']) { return self::error(__('ACF source field-key reference does not match its registered inventory.', 'customfields-com')); } $rows = array(); for ($i = 0; $i < (int) $count; $i++) { $row = array(); foreach ($source['sub_fields'] as $child) { $key = $name . '_' . $i . '_' . $child['name']; if (!current_user_can('edit_post_meta', $post_id, $key)) { return self::error(__('You cannot access an ACF source child.', 'customfields-com')); } $value = self::meta($post_id, $key); $ref = self::meta($post_id, '_' . $key); if (is_wp_error($value) || is_wp_error($ref)) { return self::error(__('ACF child metadata is ambiguous.', 'customfields-com')); } $snapshots[$key] = $value; $snapshots['_' . $key] = $ref; if (!$value || !$ref || $ref[0]['meta_value'] !== $child['key'] || is_serialized($value[0]['meta_value'])) { return self::error(__('Missing, serialized or mismatched ACF child data is unsupported.', 'customfields-com')); } if (is_wp_error(\CustomFields\Fields::sanitize($value[0]['meta_value'], $child['_cfwf_schema']))) { return self::error(__('ACF child content conflicts with its registered source type or choices.', 'customfields-com')); } $row[$child['name']] = $value[0]['meta_value']; } $rows[] = $row; } return $rows; } public static function preview_migration($mode, $source_name, $destination_name, $post_ids, $replace = false, $target = 'post') { if (!self::allowed()) { return self::error(__('Administrator access required.', 'customfields-com')); } $lock = self::lock(); if (is_wp_error($lock)) { return $lock; } try { return self::build_preview($mode, $source_name, $destination_name, $post_ids, $replace, $target); } finally { self::unlock($lock); } } /** Adapt trusted native storage to editor transport without defaulting hidden children. */ private static function migration_input($value, $field) { if (!is_array($value) || !in_array($field['type'], array('group', 'repeater', 'flexible', 'clone'), true)) { return $value; } $multiple = in_array($field['type'], array('repeater', 'flexible'), true); $rows = $multiple ? $value : array($value); $layouts = array_column($field['layouts'] ?? array(), null, 'name'); foreach ($rows as $index => &$row) { $children = $field['type'] === 'flexible' ? ($layouts[$row['__layout']]['sub_fields'] ?? array()) : ($field['sub_fields'] ?? array()); foreach ($children as $child) { if (array_key_exists($child['name'], $row)) { $row[$child['name']] = self::migration_input($row[$child['name']], $child); } else { $row[$child['name']] = self::migration_input($child['default'], $child); } } if ($multiple) { $row['__cfp_source'] = $index; } } unset($row); $input = $multiple ? $rows : $rows[0]; $input['__cfp_fingerprint'] = Fields::fingerprint($value); return $input; } private static function references($value, $field) { if (in_array($field['type'], array('relationship', 'post', 'user', 'taxonomy', 'image', 'file'), true)) { return \CustomFields\Fields::sanitize($value, $field); } if (in_array($field['type'], array('group', 'clone', 'repeater', 'flexible'), true) && is_array($value)) { $rows = in_array($field['type'], array('repeater', 'flexible'), true) ? $value : array($value); $layouts = array_column($field['layouts'] ?? array(), null, 'name'); foreach ($rows as $row) { $children = $field['type'] === 'flexible' ? ($layouts[$row['__layout'] ?? '']['sub_fields'] ?? array()) : ($field['sub_fields'] ?? array()); foreach ($children as $child) { if (array_key_exists($child['name'], $row)) { $checked = self::references($row[$child['name']], $child); if (is_wp_error($checked)) { return $checked; } } } } } return true; } private static function build_preview($mode, $source_name, $destination_name, $post_ids, $replace, $target = 'post') { if (!self::allowed()) { return self::error(__('Administrator access required.', 'customfields-com')); } if (!in_array($target, array('post', 'user', 'term', 'options'), true) || ($mode === 'acf' && $target !== 'post') || !array_key_exists($mode, self::modes()) || !is_string($source_name) || !is_string($destination_name) || $source_name === $destination_name || !is_array($post_ids) || !$post_ids || count($post_ids) > self::MAX_POSTS) { return self::error(__('Choose separate fields and 1–50 explicit object IDs. The legacy ACF adapter supports posts only.', 'customfields-com')); } $existing = self::job(); if ($existing && !in_array($existing['status'] ?? '', array('preview', 'complete', 'rolled_back'), true)) { return self::error(__('Finish or roll back the current job before creating another preview.', 'customfields-com')); } $ids = array(); foreach ($post_ids as $id) { if ((!is_int($id) && !is_string($id)) || !preg_match('/^[1-9][0-9]*$/D', (string) $id)) { return self::error(__('Object IDs must be positive integers.', 'customfields-com')); } $ids[] = (int) $id; } $job = array('id' => wp_generate_uuid4(), 'owner' => get_current_user_id(), 'created' => time(), 'schema_hash' => self::schema_hash(), 'mode' => $mode, 'target' => $target, 'source' => $source_name, 'destination' => $destination_name, 'replace' => (bool) $replace, 'status' => 'preview', 'items' => array()); foreach (array_unique($ids) as $post_id) { $destination = self::field($destination_name, $post_id, $target); if (is_wp_error($destination)) { return $destination; } $permission = self::permission($post_id, $destination_name, $mode === 'copy' ? $source_name : '', $target); if (is_wp_error($permission)) { return $permission; } $old = self::meta($post_id, $destination_name, false, $target); if (is_wp_error($old)) { return $old; } $snapshots = array(); if ($mode === 'copy') { $source = self::field($source_name, $post_id, $target); if (is_wp_error($source) || $source['type'] !== $destination['type']) { return self::error(__('Copy requires registered source and destination fields of the same type.', 'customfields-com')); } $rows = self::meta($post_id, $source_name, false, $target); if (is_wp_error($rows)) { return $rows; } $snapshots[$source_name] = $rows; $value = $rows ? self::decode($rows[0]['meta_value']) : null; if (!is_wp_error($value) && $rows && is_wp_error(\CustomFields\Fields::storage_error($value, $source))) { return self::error(__('The source data is incompatible with its registered field schema.', 'customfields-com')); } $inventory = null; } elseif ($mode !== 'acf') { $converted = apply_filters('customfields_workflow_source', null, $mode, $source_name, $post_id, $destination, $target); if (is_wp_error($converted)) { return $converted; } if (!is_array($converted) || !isset($converted['definition'], $converted['snapshots']) || !array_key_exists('value', $converted)) { return self::error(__('This converter is unavailable.', 'customfields-com')); } $value = $converted['value']; $snapshots = $converted['snapshots']; $inventory = self::hash($converted['definition']); } else { $source = self::acf_inventory($source_name, $post_id, $destination); if (is_wp_error($source)) { return $source; } if (is_wp_error(self::permission($post_id, $destination_name, $source['name']))) { return self::error(__('You cannot access this ACF source.', 'customfields-com')); } $value = self::acf_values($source, $post_id, $snapshots); $inventory = self::hash($source); } if (is_wp_error($value)) { return $value; } $new = null; if (null !== $value) { $references = self::references($value, $destination); if (is_wp_error($references)) { return $references; } $compatible = \CustomFields\Fields::storage_error($value, $destination); if (is_wp_error($compatible)) { return self::error(sprintf(/* translators: 1: Object ID; 2: validation error. */ __('Object %1$d does not fit the destination schema: %2$s', 'customfields-com'), $post_id, $compatible->get_error_message())); } $clean = \CustomFields\Fields::sanitize(self::migration_input($value, $destination), $destination, array('has_stored_value' => true, 'stored_value' => $value, 'migration' => true)); if (is_wp_error($clean)) { return self::error(sprintf(/* translators: 1: Object ID; 2: validation error. */ __('Object %1$d does not fit the destination schema: %2$s', 'customfields-com'), $post_id, $clean->get_error_message())); } $new = self::raw($clean); } $collision = null !== $new && $old && $old[0]['meta_value'] !== $new; $state = null === $new ? 'skipped' : ($old && $old[0]['meta_value'] === $new ? 'unchanged' : ($collision && !$replace ? 'blocked' : 'pending')); $container = $target === 'options' ? self::option_row('customfields_pro_values') : null; if (is_wp_error($container)) { return $container; } $job['items'][] = array('options_before' => $container, 'post_id' => $post_id, 'source' => $snapshots, 'inventory' => $inventory, 'before' => $old, 'after_raw' => $new, 'collision' => (bool) $collision, 'state' => $state, 'message' => ''); if (strlen(serialize($job)) > self::MAX_JOB_BYTES) { return self::error(__('This preview exceeds the 2 MB journal limit. Use fewer posts.', 'customfields-com')); } } return self::save_job($job); } private static function same_source($item, $target = 'post', $mode = 'copy') { foreach ($item['source'] as $name => $expected) { $actual = $target === 'options' && $mode !== 'copy' ? self::option_row($name) : self::meta($item['post_id'], $name, count($expected) > 1, $target); if (is_wp_error($actual) || $actual !== $expected) { return false; } } return true; } private static function current_schema() { global $wpdb; if (false === $wpdb->query($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s FOR UPDATE", 'customfields_groups'))) { return self::error(__('Could not lock the schema; the transaction was aborted.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Read or lock authoritative rows for the transaction; an object-cache result cannot provide database locking or conflict detection. wp_cache_delete('customfields_groups', 'options'); wp_cache_delete('alloptions', 'options'); return self::schema_hash(); } /** Called only after the immutable destination and exact old value are durably journaled. */ private static function write_item($job, $index, $rollback, &$committed_item = null) { global $wpdb; $item = $job['items'][$index]; $post_id = $item['post_id']; $target = $job['target'] ?? 'post'; $table = _get_meta_table($target); $identity = $target === 'user' ? 'umeta_id' : 'meta_id'; $owner = $target . '_id'; $name = $job['destination']; $destination = self::field($name, $post_id, $target); if (is_wp_error($destination) || is_wp_error(self::permission($post_id, $name, $job['mode'] === 'copy' ? $job['source'] : '', $target))) { return self::error(__('Field registration or permissions changed.', 'customfields-com')); } if (!$rollback && $job['mode'] === 'acf') { $inventory = self::acf_inventory($job['source'], $post_id, $destination); if (is_wp_error($inventory) || self::hash($inventory) !== $item['inventory']) { return self::error(__('The registered ACF inventory changed; make a new preview.', 'customfields-com')); } } if (!$rollback && !in_array($job['mode'], array('copy', 'acf'), true)) { $converted = apply_filters('customfields_workflow_source', null, $job['mode'], $job['source'], $post_id, $destination, $target); if (is_wp_error($converted) || !is_array($converted) || !isset($converted['definition']) || self::hash($converted['definition']) !== $item['inventory']) { return self::error(__('Source registration or permissions changed; review the conversion again.', 'customfields-com')); } } $started = self::begin(array($wpdb->usermeta, $wpdb->termmeta)); if (is_wp_error($started)) { return $started; } try { $current_schema = self::current_schema(); if (is_wp_error($current_schema)) { throw new \RuntimeException($current_schema->get_error_message()); } if ($current_schema !== $job['schema_hash']) { throw new \RuntimeException(__('Schema changed after preview. Restore the reviewed schema or create a new job.', 'customfields-com')); } // Lock the object's full metadata range, including insertion gaps, under SERIALIZABLE. if (false === ($target === 'options' ? $wpdb->query($wpdb->prepare("SELECT option_id FROM {$wpdb->options} WHERE option_name = %s FOR UPDATE", 'customfields_pro_values')) : $wpdb->query($wpdb->prepare("SELECT %i FROM %i WHERE %i = %d FOR UPDATE", $identity, $table, $owner, $post_id)))) { throw new \RuntimeException(__('Could not lock object metadata; the transaction was aborted.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Read or lock authoritative rows for the transaction; an object-cache result cannot provide database locking or conflict detection. $actual = self::meta($post_id, $name, false, $target); if (is_wp_error($actual)) { throw new \RuntimeException($actual->get_error_message()); } if (!$rollback) { if (!in_array($job['mode'], array('copy', 'acf'), true)) { // Re-read registered sources inside SERIALIZABLE. Table adapters use only verified InnoDB tables; // this acquires row/gap locks and metadata locks until the destination and journal commit. $locked_source = apply_filters('customfields_workflow_source', null, $job['mode'], $job['source'], $post_id, $destination, $target); $same = is_array($locked_source) && isset($locked_source['definition'], $locked_source['snapshots']) && self::hash($locked_source['definition']) === $item['inventory'] && $locked_source['snapshots'] === $item['source']; } else { $same = self::same_source($item, $target, $job['mode']); } if (!$same) { throw new \RuntimeException(__('Source changed since preview; this destination was not written.', 'customfields-com')); } } $expected = $rollback ? ($item['after'] ?? array()) : $item['before']; $desired = $rollback ? ($item['before'] ? $item['before'][0]['meta_value'] : null) : $item['after_raw']; if (!$rollback && $desired !== null) { $value = self::decode($desired); $references = is_wp_error($value) ? $value : self::references($value, $destination); if (is_wp_error($references)) { throw new \RuntimeException($references->get_error_message()); } } if ($actual !== $expected) { throw new \RuntimeException(__('Destination changed after review. It was preserved; review a new preview.', 'customfields-com')); } if ($target === 'options') { $container = self::option_row('customfields_pro_values'); $expected_container = $rollback ? ($item['options_after'] ?? null) : ($item['options_before'] ?? null); if (is_wp_error($container) || $container !== $expected_container) { throw new \RuntimeException(__('The options container changed after review. Other options and this destination were preserved.', 'customfields-com')); } if ($rollback) { $wanted = $item['options_before'] ? $item['options_before'][0]['meta_value'] : null; } else { $values = $container ? self::decode($container[0]['meta_value']) : array(); if (!is_array($values)) { throw new \RuntimeException(__('The options container is not a plain array.', 'customfields-com')); } $value = self::decode($desired); if (is_wp_error($value)) { throw new \RuntimeException($value->get_error_message()); } $values[$name] = $value; $wanted = self::raw($values); } if ($wanted === null) { $changed = $wpdb->delete($wpdb->options, array('option_id' => $container[0]['meta_id']), array('%d')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic workflow write with explicit formats/prepared values; surrounding transaction performs cache invalidation after commit. elseif ($container) { $changed = $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET option_value = %s WHERE option_id = %d AND BINARY option_value = BINARY %s", $wanted, $container[0]['meta_id'], $container[0]['meta_value'])); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic workflow write with explicit formats/prepared values; surrounding transaction performs cache invalidation after commit. else { $changed = $wpdb->insert($wpdb->options, array('option_name' => 'customfields_pro_values', 'option_value' => $wanted, 'autoload' => 'no'), array('%s', '%s', '%s')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic workflow write with explicit formats/prepared values; surrounding transaction performs cache invalidation after commit. if ($changed === false) { throw new \RuntimeException(__('The protected options write failed.', 'customfields-com')); } $verified_container = self::option_row('customfields_pro_values'); if (is_wp_error($verified_container) || ($wanted === null ? $verified_container !== array() : (!$verified_container || $verified_container[0]['meta_value'] !== $wanted))) { throw new \RuntimeException(__('Options readback failed.', 'customfields-com')); } if (!$rollback) { $job['items'][$index]['options_after'] = $verified_container; } } elseif (null === $desired) { if ($actual) { $changed = $wpdb->query($wpdb->prepare("DELETE FROM %i WHERE %i = %d AND %i = %d AND BINARY meta_key = BINARY %s AND BINARY meta_value = BINARY %s", $table, $identity, $actual[0]['meta_id'], $owner, $post_id, $name, $actual[0]['meta_value'])); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic workflow write with explicit formats/prepared values; surrounding transaction performs cache invalidation after commit. if ($changed !== 1) { throw new \RuntimeException(__('Destination changed during rollback.', 'customfields-com')); } } } elseif ($actual) { $changed = $wpdb->query($wpdb->prepare("UPDATE %i SET meta_value = %s WHERE %i = %d AND %i = %d AND BINARY meta_key = BINARY %s AND BINARY meta_value = BINARY %s", $table, $desired, $identity, $actual[0]['meta_id'], $owner, $post_id, $name, $actual[0]['meta_value'])); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic workflow write with explicit formats/prepared values; surrounding transaction performs cache invalidation after commit. if ($changed === false || ($changed !== 1 && $actual[0]['meta_value'] !== $desired)) { throw new \RuntimeException(__('Compare-and-swap failed; the destination was preserved.', 'customfields-com')); } } else { if (!$wpdb->insert($table, array($owner => $post_id, 'meta_key' => $name, 'meta_value' => $desired), array('%d', '%s', '%s'))) { throw new \RuntimeException(__('Destination insertion failed.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Atomic workflow write with explicit formats/prepared values; surrounding transaction performs cache invalidation after commit. Metadata column name or presentation value, not an unbounded WP_Query meta scan. } $verified = self::meta($post_id, $name, false, $target); if (is_wp_error($verified) || (null === $desired ? $verified !== array() : (count($verified) !== 1 || $verified[0]['meta_value'] !== $desired))) { throw new \RuntimeException(__('Readback verification failed. The transaction was rolled back.', 'customfields-com')); } foreach (customfields_get_groups() as $group) { if (!self::object_matches($group, $target, $post_id)) { continue; } foreach ($group['fields'] as $registered) { if ($registered['name'] !== $name) { continue; } $native = null === $desired ? null : self::decode($desired); if (is_wp_error($native)) { throw new \RuntimeException($native->get_error_message()); } do_action('customfields_metadata_transaction_written', $group, $target, $post_id, array($name => $native), array($name => $actual)); } } // The result identity and data commit atomically; crashes cannot orphan a write. $job['items'][$index]['state'] = $rollback ? 'rolled_back' : 'done'; if (!$rollback) { $job['items'][$index]['after'] = $verified; } $journal = self::save_job($job); if (is_wp_error($journal)) { throw new \RuntimeException($journal->get_error_message()); } if (false === $wpdb->query('COMMIT')) { throw new \RuntimeException(__('Commit failed.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. wp_cache_delete($post_id, $target . '_meta'); if ($target === 'options') { wp_cache_delete('customfields_pro_values', 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); } if (class_exists('CustomFields\\ContentStore')) { \CustomFields\ContentStore::flush(); } $committed_item = $job['items'][$index]; return $verified; } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. wp_cache_delete(self::job_key(), 'options'); wp_cache_delete($post_id, $target . '_meta'); if ($target === 'options') { wp_cache_delete('customfields_pro_values', 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); } if (class_exists('CustomFields\\ContentStore')) { \CustomFields\ContentStore::flush(); } return self::error($e->getMessage()); } } public static function run($job_id, $rollback = false) { if (!self::allowed()) { return self::error(__('Administrator access required.', 'customfields-com')); } $lock = self::lock(); if (is_wp_error($lock)) { return $lock; } try { wp_cache_delete(self::job_key(), 'options'); $job = self::job(); if (!$job || !is_string($job_id) || !hash_equals($job['id'], $job_id) || (int) $job['owner'] !== get_current_user_id()) { return self::error(__('The reviewed job is unavailable.', 'customfields-com')); } if ($job['schema_hash'] !== self::schema_hash()) { return self::error(__('The schema changed after preview. No batch was run.', 'customfields-com')); } if (!$rollback && $job['status'] === 'preview' && time() - $job['created'] > 1800) { return self::error(__('The preview expired. Create and review it again.', 'customfields-com')); } foreach ($job['items'] as $item) { if (!$rollback && $item['state'] === 'blocked') { return self::error(__('A destination collision blocks this job. Preview again with explicit replacement selected.', 'customfields-com')); } } if (!$rollback && in_array($job['status'], array('rolling_back', 'rolled_back'), true)) { return self::error(__('A rolled-back job cannot be reapplied. Create a new preview.', 'customfields-com')); } $job['status'] = $rollback ? 'rolling_back' : 'running'; $count = 0; foreach ($job['items'] as $index => $item) { $eligible = $rollback ? array('done', 'rollback_prepared', 'rollback_conflict', 'prepared') : array('pending', 'prepared', 'conflict'); if (!in_array($item['state'], $eligible, true)) { continue; } if ($count++ >= self::BATCH) { break; } if ($rollback && $item['state'] === 'prepared' && empty($item['after'])) { $actual = self::meta($item['post_id'], $job['destination'], false, $job['target'] ?? 'post'); $job['items'][$index]['state'] = $actual === $item['before'] ? 'rolled_back' : 'rollback_conflict'; $job['items'][$index]['message'] = $actual === $item['before'] ? '' : __('An unverified destination changed; it was preserved.', 'customfields-com'); if ($job['items'][$index]['state'] === 'rollback_conflict') { break; } continue; } $job['items'][$index]['state'] = $rollback ? 'rollback_prepared' : 'prepared'; $job['items'][$index]['message'] = ''; $saved = self::save_job($job); // Durable journal BEFORE the metadata transaction. if (is_wp_error($saved)) { return $saved; } $committed_item = null; $result = self::write_item($job, $index, $rollback, $committed_item); if (is_wp_error($result)) { $job['items'][$index]['state'] = $rollback ? 'rollback_conflict' : 'conflict'; $job['items'][$index]['message'] = $result->get_error_message(); } else { $job['items'][$index]['state'] = $rollback ? 'rolled_back' : 'done'; if (!$rollback) { $job['items'][$index]['after'] = $result; if (($job['target'] ?? 'post') === 'options') { $job['items'][$index]['options_after'] = $committed_item['options_after']; } } } $saved = self::save_job($job); if (is_wp_error($saved)) { return $saved; } if (is_wp_error($result)) { break; } } $states = array_column($job['items'], 'state'); $outstanding = array_intersect($states, $rollback ? array('done', 'prepared', 'rollback_prepared', 'rollback_conflict') : array('pending', 'prepared', 'conflict')); $job['status'] = $outstanding ? ($rollback ? 'rolling_back' : 'running') : ($rollback ? 'rolled_back' : 'complete'); return self::save_job($job); } finally { self::unlock($lock); } } /** A restore is schema-only. Scan the prospective schema against retained values before replacing definitions. */ private static function scan($groups, $lock = false) { global $wpdb; $evidence = array(); $total = 0; $options_loaded = false; $option_values = array(); foreach ($groups as $group) { $target = $group['target'] ?? 'post'; if (!in_array($target, array('post', 'user', 'term', 'options'), true)) { return self::error(__('This snapshot contains an unsupported target; automatic restore cannot safely inspect it.', 'customfields-com')); } foreach ($group['fields'] as $field) { if ($target === 'options') { if (!$options_loaded) { if ($lock && false === $wpdb->query($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s FOR UPDATE", 'customfields_pro_values'))) { return self::error(__('Could not lock option values; the transaction was aborted.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Read or lock authoritative rows for the transaction; an object-cache result cannot provide database locking or conflict detection. wp_cache_delete('customfields_pro_values', 'options'); $option_values = get_option('customfields_pro_values', array()); if (!is_array($option_values)) { return self::error(__('Stored option values have an unsupported shape.', 'customfields-com')); } $options_loaded = true; } if (array_key_exists($field['name'], $option_values)) { if (++$total > self::MAX_SCAN) { return self::error(__('This restore would require checking more than 2,000 values. Use a staged manual schema review for this larger dataset.', 'customfields-com')); } $value = $option_values[$field['name']]; if (!self::safe_value($value) || is_wp_error(\CustomFields\Fields::storage_error($value, $field))) { return self::error(sprintf(/* translators: %s: Option name. */ __('Stored option %s conflicts with this schema.', 'customfields-com'), $field['name'])); } $evidence[] = array('options', $field['name'], $value); } } else { if ($target === 'post') { $types = $group['post_types']; if (!$types) { continue; } $placeholders = implode(',', array_fill(0, count($types), '%s')); $sql = "SELECT m.meta_id, m.post_id AS object_id, m.meta_value FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id WHERE BINARY m.meta_key = BINARY %s AND p.post_type IN ($placeholders) ORDER BY m.meta_id"; $args = array_merge(array($field['name']), $types); } elseif ($target === 'user') { $sql = "SELECT m.umeta_id AS meta_id, m.user_id AS object_id, m.meta_value FROM {$wpdb->usermeta} m INNER JOIN {$wpdb->users} u ON u.ID = m.user_id WHERE BINARY m.meta_key = BINARY %s ORDER BY m.umeta_id"; $args = array($field['name']); } else { $taxonomies = $group['taxonomies'] ?? array(); if (!$taxonomies) { return self::error(__('A term restore needs an explicit taxonomy scope.', 'customfields-com')); } $placeholders = implode(',', array_fill(0, count($taxonomies), '%s')); $sql = "SELECT m.meta_id, m.term_id AS object_id, m.meta_value FROM {$wpdb->termmeta} m WHERE BINARY m.meta_key = BINARY %s AND EXISTS (SELECT 1 FROM {$wpdb->term_taxonomy} t WHERE t.term_id = m.term_id AND t.taxonomy IN ($placeholders)) ORDER BY m.meta_id"; $args = array_merge(array($field['name']), $taxonomies); } $sql .= ' LIMIT ' . (self::MAX_SCAN + 1) . ($lock ? ' FOR UPDATE' : ''); $rows = $wpdb->get_results($wpdb->prepare($sql, $args), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows. sql is selected from fixed post/user/term templates; only generated placeholders and an integer limit are appended, with all values supplied to prepare. if (null === $rows || $wpdb->last_error) { return self::error(__('Could not inspect stored content for restore conflicts.', 'customfields-com')); } $seen = array(); foreach ($rows as $row) { if (++$total > self::MAX_SCAN) { return self::error(__('This restore would require checking more than 2,000 values. Use a staged manual schema review for this larger dataset.', 'customfields-com')); } $id = (int) $row['object_id']; if ($target === 'post' && !self::matches($group, get_post($id))) { continue; } if (isset($seen[$id]) || !current_user_can('edit_' . $target, $id) || !current_user_can('edit_' . $target . '_meta', $id, $field['name'])) { return self::error(__('Duplicate metadata or unavailable object permissions block this restore.', 'customfields-com')); } $seen[$id] = true; $value = self::decode($row['meta_value']); if (is_wp_error($value) || is_wp_error(\CustomFields\Fields::storage_error($value, $field))) { return self::error(sprintf(/* translators: 1: Object kind; 2: object ID; 3: field name. */ __('%1$s %2$d field %3$s conflicts with this schema. Content was preserved.', 'customfields-com'), $target, $id, $field['name'])); } $evidence[] = array($target, $field['name'], $row); } } } } return array('hash' => self::hash($evidence), 'values' => count($evidence)); } public static function preview_restore($snapshot_id) { if (!self::allowed()) { return self::error(__('Administrator access required.', 'customfields-com')); } $snapshot = null; foreach (get_option(self::HISTORY, array()) as $entry) { if ($entry['id'] === $snapshot_id) { $snapshot = $entry; break; } } if (!$snapshot) { return self::error(__('That schema snapshot is unavailable.', 'customfields-com')); } return self::preview_schema($snapshot['groups'], array('kind' => 'history', 'snapshot' => $snapshot)); } /** Shared review engine: no content writes; commit rechecks schema and stored values. */ public static function preview_schema($groups, $context = array()) { if (!self::allowed()) { return self::error(__('Administrator access required.', 'customfields-com')); } $groups = \CustomFields\Schema::validate_groups($groups); if (is_wp_error($groups)) { return $groups; } $scan = self::scan($groups); if (is_wp_error($scan)) { return $scan; } $preview = array('id' => wp_generate_uuid4(), 'schema_hash' => self::schema_hash(), 'snapshot' => $context['snapshot'] ?? array('title' => 'Reviewed definitions'), 'context' => $context, 'groups' => $groups, 'scan' => $scan, 'diff' => self::diff(customfields_get_groups(), $groups)); set_transient(self::preview_key(), $preview, 15 * MINUTE_IN_SECONDS); return $preview; } public static function restore($preview_id) { $preview = get_transient(self::preview_key()); if (is_array($preview) && ($preview['context']['kind'] ?? '') === 'agency') { if (!class_exists('CustomFieldsPro\\Agency') || !is_callable(array('CustomFieldsPro\\Access', 'run'))) { return self::error(__('Activate the compatible Pro add-on before applying this deployment preview. Ordinary Free restores remain available.', 'customfields-com')); } return \CustomFieldsPro\Access::run(static function () use ($preview_id) { return self::restore_review($preview_id, true); }); } return self::restore_review($preview_id, false); } /** A Pro deployment lease must cover the complete shared transaction. */ private static function restore_review($preview_id, $agency) { global $wpdb; if (!self::allowed()) { return self::error(__('Administrator access required.', 'customfields-com')); } $lock = self::lock(); if (is_wp_error($lock)) { return $lock; } $transaction = false; try { $preview = get_transient(self::preview_key()); if (!is_array($preview) || !is_string($preview_id) || !hash_equals($preview['id'], $preview_id)) { return self::error(__('The restore preview expired or was replaced. Review it again.', 'customfields-com')); } if ((($preview['context']['kind'] ?? '') === 'agency') !== $agency) { return self::error(__('The preview changed. Review it again before applying.', 'customfields-com')); } $groups = \CustomFields\Schema::validate_groups($preview['groups']); if (is_wp_error($groups)) { return $groups; } $started = self::begin(array($wpdb->usermeta, $wpdb->termmeta, $wpdb->posts, $wpdb->users, $wpdb->term_taxonomy)); if (is_wp_error($started)) { return $started; } $transaction = true; $current_schema = self::current_schema(); if (is_wp_error($current_schema)) { throw new \RuntimeException($current_schema->get_error_message()); } if ($current_schema !== $preview['schema_hash']) { throw new \RuntimeException(__('Schema changed after the restore preview.', 'customfields-com')); } $scan = self::scan($groups, true); if (is_wp_error($scan) || $scan !== $preview['scan']) { throw new \RuntimeException(is_wp_error($scan) ? $scan->get_error_message() : __('Stored data changed after preview. Review the restore again.', 'customfields-com')); } \CustomFields\Admin::write($groups); wp_cache_delete('customfields_groups', 'options'); if (self::hash(customfields_get_groups()) !== self::hash($groups)) { throw new \RuntimeException(__('Schema restore verification failed.', 'customfields-com')); } do_action('customfields_schema_review_committing', $preview, $groups); if (false === $wpdb->query('COMMIT')) { throw new \RuntimeException(__('Commit failed.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. $transaction = false; delete_transient(self::preview_key()); return true; } catch (\Throwable $e) { return self::error($e->getMessage()); } finally { if ($transaction) { \CustomFields\ContentStore::rollback_transaction(); } foreach (array('customfields_groups', self::HISTORY, 'customfields_pro_values', 'alloptions') as $key) { wp_cache_delete($key, 'options'); } self::unlock($lock); } } public static function assets($hook) { if (strpos($hook, 'customfields-workflows') === false) { return; } wp_enqueue_style('customfields-pro', CUSTOMFIELDS_LOCAL_URL . 'admin.css', array(), CUSTOMFIELDS_LOCAL_VERSION); wp_enqueue_script('customfields-pro-workflows', CUSTOMFIELDS_LOCAL_URL . 'workflows.js', array('wp-i18n'), CUSTOMFIELDS_LOCAL_VERSION, true); wp_set_script_translations('customfields-pro-workflows', 'customfields-com', CUSTOMFIELDS_PATH . 'languages'); wp_localize_script('customfields-pro-workflows', 'CustomFieldsWorkflows', array('ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('customfields_workflow'))); } private static function ajax_guard() { if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Administrator access required.', 'customfields-com')), 403); } if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce'])) { wp_send_json_error(array('message' => __('Your session expired. Reload the workflow before continuing.', 'customfields-com')), 403); } if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'customfields_workflow')) { wp_send_json_error(array('message' => __('Your session expired. Reload the workflow before continuing.', 'customfields-com')), 403); } } /** Names and titles only; every returned item must be editable in this operation's exact scope. */ public static function search_content() { self::ajax_guard(); $string = static function($key) { return isset($_POST[$key]) && is_string($_POST[$key]) ? sanitize_text_field(wp_unslash($_POST[$key])) : ''; }; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Authenticated workflow input: ajax_guard verifies customfields_workflow for AJAX; handle verifies the same action before its local input closure. $destination = $string('destination'); $source = $string('source'); $mode = $string('mode'); $kind = $string('target') ?: 'post'; if (!in_array($kind, array('post', 'user', 'term', 'options'), true)) { wp_send_json_error(array('message' => __('Choose a supported content location.', 'customfields-com')), 400); } $field = self::field($destination, 0, $kind); if (is_wp_error($field) || !array_key_exists($mode, self::modes()) || ($mode === 'acf' && $kind !== 'post')) { wp_send_json_error(array('message' => __('Choose source and destination fields first.', 'customfields-com')), 400); } $search = substr($string('search'), 0, 100); $page = max(1, min(1000, absint($string('page')))); $more = false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Authenticated workflow input: ajax_guard verifies customfields_workflow for AJAX; handle verifies the same action before its local input closure. if ($kind === 'post') { $query = new \WP_Query(array('post_type' => array_values(get_post_types(array('show_ui' => true))), 'post_status' => array('publish', 'private', 'draft', 'pending', 'future'), 's' => $search, 'posts_per_page' => 30, 'paged' => $page, 'orderby' => 'ID', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => false)); $ids = $query->posts; $more = $query->max_num_pages > $page; } elseif ($kind === 'user') { if (!current_user_can('list_users')) { wp_send_json_error(array('message' => __('User-list access is required.', 'customfields-com')), 403); } $query = new \WP_User_Query(array('search' => $search !== '' ? '*' . $search . '*' : '', 'search_columns' => array('display_name'), 'number' => 30, 'offset' => ($page - 1) * 30, 'fields' => 'ID', 'orderby' => 'ID', 'order' => 'DESC', 'count_total' => true)); $ids = $query->get_results(); $more = $query->get_total() > $page * 30; } elseif ($kind === 'term') { $ids = get_terms(array('taxonomy' => array_values(get_taxonomies(array('show_ui' => true))), 'hide_empty' => false, 'search' => $search, 'number' => 31, 'offset' => ($page - 1) * 30, 'fields' => 'ids', 'orderby' => 'id', 'order' => 'DESC')); if (is_wp_error($ids)) { wp_send_json_error(array('message' => $ids->get_error_message()), 400); } $more = count($ids) > 30; $ids = array_slice($ids, 0, 30); } else { $ids = array(1); } $items = array(); foreach ($ids as $id) { $target = self::field($destination, $id, $kind); if (is_wp_error($target) || is_wp_error(self::permission($id, $destination, $mode === 'copy' ? $source : '', $kind))) { continue; } if ($mode === 'copy') { $origin = self::field($source, $id, $kind); if (is_wp_error($origin) || $origin['type'] !== $target['type']) { continue; } } elseif ($mode === 'acf') { $origin = self::acf_inventory($source, $id, $target); if (is_wp_error($origin) || is_wp_error(self::permission($id, $destination, $origin['name']))) { continue; } } else { $origin = apply_filters('customfields_workflow_source', null, $mode, $source, $id, $target, $kind); if (!is_array($origin)) { continue; } } $items[] = array('id' => $id, 'title' => self::object_title($kind, $id), 'type' => $kind === 'post' ? get_post_type($id) : $kind, 'status' => $kind === 'post' ? get_post_status($id) : __('Editable', 'customfields-com')); } wp_send_json_success(array('items' => $items, 'more' => $more)); } private static function object_title($target, $id) { if ($target === 'options') { return __('Site options workspace', 'customfields-com'); } if ($target === 'user') { $user = get_userdata($id); return $user ? $user->display_name : __('Unavailable user', 'customfields-com'); } if ($target === 'term') { $term = get_term($id); return $term && !is_wp_error($term) ? $term->name : __('Unavailable term', 'customfields-com'); } return get_the_title($id) ?: __('Untitled', 'customfields-com'); } public static function ajax_batch() { self::ajax_guard(); $operation = isset($_POST['operation']) && is_string($_POST['operation']) ? sanitize_key(wp_unslash($_POST['operation'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Authenticated workflow input: ajax_guard verifies customfields_workflow for AJAX; handle verifies the same action before its local input closure. if (!in_array($operation, array('run', 'rollback'), true) || ($_POST['reviewed'] ?? '') !== '1') { wp_send_json_error(array('message' => __('Review the job and explicitly start it before running a batch.', 'customfields-com')), 400); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Authenticated workflow input: ajax_guard verifies customfields_workflow for AJAX; handle verifies the same action before its local input closure. Exact comparison to the expected completion/confirmation or form-identity value; invalid strings and arrays are rejected, not rendered or persisted. $result = self::run(isset($_POST['job']) && is_string($_POST['job']) ? sanitize_text_field(wp_unslash($_POST['job'])) : '', $operation === 'rollback'); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Authenticated workflow input: ajax_guard verifies customfields_workflow for AJAX; handle verifies the same action before its local input closure. if (is_wp_error($result)) { wp_send_json_error(array('message' => $result->get_error_message()), 409); } $items = array(); $failed = false; $done = 0; foreach ($result['items'] as $item) { $items[] = array('id' => $item['post_id'], 'state' => $item['state'], 'message' => $item['message']); if (in_array($item['state'], array('conflict', 'rollback_conflict', 'blocked'), true)) { $failed = true; } if (in_array($item['state'], $operation === 'rollback' ? array('rolled_back', 'skipped', 'unchanged') : array('done', 'skipped', 'unchanged'), true)) { $done++; } } wp_send_json_success(array('status' => $result['status'], 'items' => $items, 'completed' => $done, 'total' => count($items), 'stopped' => $failed, 'finished' => in_array($result['status'], array('complete', 'rolled_back'), true))); } private static function source_choices() { $items = array(); foreach (customfields_get_groups() as $group) { foreach ($group['fields'] as $field) { $items[] = array('target' => $group['target'] ?? 'post', 'mode' => 'copy', 'value' => $field['name'], 'label' => $group['title'] . ' / ' . $field['label'], 'type' => $field['type']); } } if (function_exists('acf_get_field_groups') && function_exists('acf_get_fields')) { $seen = 0; foreach (array_slice((array) acf_get_field_groups(array()), 0, 100) as $group) { foreach ((array) acf_get_fields($group) as $field) { if (++$seen > 500) { break 2; } if (($field['type'] ?? '') === 'repeater' && is_string($field['key'] ?? null)) { $items[] = array('mode' => 'acf', 'value' => $field['key'], 'label' => 'ACF / ' . ($group['title'] ?? $group['key']) . ' / ' . ($field['label'] ?? $field['name'] ?? $field['key']), 'type' => 'repeater'); } } } } return apply_filters('customfields_workflow_source_choices', $items); } public static function modes() { return apply_filters('customfields_workflow_modes', array('copy' => __('Copy to a new registered field', 'customfields-com'), 'acf' => __('Convert an ACF flat repeater (legacy)', 'customfields-com'))); } private static function value_summary($raw) { if (null === $raw) { return __('No value', 'customfields-com'); } $value = self::decode($raw); if (is_wp_error($value)) { return __('Unsupported stored format', 'customfields-com'); } if (is_array($value)) { return sprintf(/* translators: %d: Item count. */ __('%d items — open exact review for details', 'customfields-com'), count($value)); } if (is_bool($value)) { return $value ? __('Yes', 'customfields-com') : __('No', 'customfields-com'); } return wp_html_excerpt(wp_strip_all_tags((string) $value), 160, '…'); } private static function state_label($state) { $labels = array('preview' => __('Ready for review', 'customfields-com'), 'pending' => __('Ready to copy', 'customfields-com'), 'done' => __('Copied', 'customfields-com'), 'unchanged' => __('Already matches', 'customfields-com'), 'skipped' => __('No source value', 'customfields-com'), 'blocked' => __('Needs replacement permission', 'customfields-com'), 'conflict' => __('Changed since review', 'customfields-com'), 'rollback_conflict' => __('Changed after copying', 'customfields-com'), 'rolled_back' => __('Restored', 'customfields-com'), 'prepared' => __('Verification needed', 'customfields-com'), 'rollback_prepared' => __('Checking rollback', 'customfields-com'), 'running' => __('Partially complete', 'customfields-com'), 'rolling_back' => __('Rollback in progress', 'customfields-com'), 'complete' => __('Complete', 'customfields-com')); return $labels[$state] ?? $state; } private static function diff_table($changes, $before, $after) { $labels = array(); foreach (array_merge($before, $after) as $group) { $labels[$group['key']] = $group['title']; foreach ($group['fields'] as $field) { $labels[$field['key']] = $field['label']; } } echo '
| ' . esc_html__('What changes', 'customfields-com') . ' | ' . esc_html__('Currently', 'customfields-com') . ' | ' . esc_html__('After restore', 'customfields-com') . ' |
|---|---|---|
| ' . esc_html(implode(' → ', $path)) . ' (' . esc_html($change['change']) . ') | '; foreach (array('before', 'after') as $side) { $value = $change[$side]; echo '';
if (is_array($value)) { echo '' . esc_html(sprintf(/* translators: %d: Setting count. */ __('%d settings — view details', 'customfields-com'), count($value))) . '' . esc_html(self::show_value($value)) . ' | ';
}
echo '
' . esc_html__('Choose the source, check the changes, then copy. Your original values stay in place.', 'customfields-com') . '
'; $notice = get_transient('customfields_pro_workflow_notice_' . get_current_user_id()); if (is_array($notice)) { delete_transient('customfields_pro_workflow_notice_' . get_current_user_id()); echo '' . esc_html($notice[1]) . '
' . esc_html__('Up to 20 snapshots, bounded to 4 MB. Restores change definitions only; content stays in place. Automatic restore checks up to 2,000 stored post/user/term/option values and blocks incompatibilities.', 'customfields-com') . '
'; $history_notice = get_option(self::HISTORY_NOTICE, ''); if (is_string($history_notice) && $history_notice !== '') { echo '' . esc_html($history_notice) . '
' . esc_html($entry['time']) . ' — ' . count($entry['groups']) . ' ' . esc_html__('groups', 'customfields-com') . ' ';
self::form('preview_restore', __('Review restore', 'customfields-com'), array('snapshot' => $entry['id']));
echo '' . intval($preview['scan']['values']) . ' ' . esc_html__('stored values checked. Removed definitions keep their saved content.', 'customfields-com') . '
' . esc_html(self::show_value($preview['diff'])) . '
' . esc_html__('Choose the original field and a separately named destination. Keep the original plugin active while converting its values.', 'customfields-com') . '
' . esc_html(self::state_label($job['status'])) . ' — ' . esc_html($job['source']) . ' → ' . esc_html($job['destination']) . '
' . esc_html__('Review these items before applying. Starting another migration replaces this completed recovery record; finish any undo first.', 'customfields-com') . '
| ' . esc_html__('Content', 'customfields-com') . ' | ' . esc_html__('State', 'customfields-com') . ' | ' . esc_html__('Current value', 'customfields-com') . ' | ' . esc_html__('Proposed value', 'customfields-com') . ' | ' . esc_html__('Exact review', 'customfields-com') . ' |
|---|---|---|---|---|
| ' . esc_html(self::object_title($job['target'] ?? 'post', $item['post_id'])) . ' #' . intval($item['post_id']) . ' | ' . esc_html(self::state_label($item['state'])) . ($item['collision'] ? ' — ' . esc_html__('destination collision', 'customfields-com') : '') . ' ' . esc_html($item['message']) . ' | ' . esc_html(self::value_summary($item['before'][0]['meta_value'] ?? null)) . ' | ' . esc_html(self::value_summary($item['after_raw'])) . ' | ' . esc_html__('Source, old destination and proposed value', 'customfields-com') . '' . esc_html(self::show_value(array('source_metadata' => $item['source'], 'destination_before' => $item['before'], 'destination_after_raw' => $item['after_raw']))) . ' |
'.esc_html__('Start a migration and choose the values you want to copy.','customfields-com').'
'.esc_html__('Start a migration','customfields-com').'