ruạṛ
<?php /** * @package ACF * @author WP Engine * * © 2026 Advanced Custom Fields (ACF®). All rights reserved. * "ACF" is a trademark of WP Engine. * Licensed under the GNU General Public License v2 or later. * https://www.gnu.org/licenses/gpl-2.0.html */ namespace ACF\CLI; use WP_CLI; use function WP_CLI\Utils\format_items; use function WP_CLI\Utils\get_flag_value; // Exit if accessed directly. defined( 'ABSPATH' ) || exit; /** * Manages ACF JSON import, export, and synchronization. * * ## EXAMPLES * * # Show sync status for all item types (field groups, post types, taxonomies, options pages) * $ wp acf json status * * # Sync all pending local JSON changes to database * $ wp acf json sync * * # Import from a JSON file * $ wp acf json import ./acf-export.json * * # Export all items to a directory * $ wp acf json export --dir=./exports/ * * # Export to stdout * $ wp acf json export --stdout */ class JsonCommand { /** * Map of CLI type flags to internal ACF post types. * * @var array */ private const TYPE_MAP = array( 'field-group' => 'acf-field-group', 'post-type' => 'acf-post-type', 'taxonomy' => 'acf-taxonomy', 'options-page' => 'acf-ui-options-page', ); /** * Success message when there are no items to sync. * * @var string */ private const MESSAGE_ALREADY_IN_SYNC = 'Everything is already in sync.'; /** * Records a first-run event for a CLI sub-command. * * @since 6.8 * * @param string $subcommand The sub-command name (e.g., 'status', 'sync', 'import', 'export'). */ private function log_command( $subcommand ) { $site_health = acf_get_instance( 'ACF\Site_Health\Site_Health' ); if ( method_exists( $site_health, 'log_cli_command' ) ) { $site_health->log_cli_command( 'acf json ' . $subcommand ); } } /** * Shows the sync status for ACF items. * * Displays how many items are pending sync. Items are considered "pending" * when the JSON file is newer than the database entry, or when the item * exists in JSON but not in the database. * * ## OPTIONS * * [--type=<type>] * : Limit to field groups, post types, taxonomies, or options pages. Defaults to all item types (field groups, post types, taxonomies, options pages). * --- * options: * - field-group * - post-type * - taxonomy * - options-page * --- * * [--detailed] * : Show detailed list of modified items instead of just counts. * * [--format=<format>] * : Output format. * --- * default: table * options: * - table * - json * - yaml * - csv * --- * * ## EXAMPLES * * # Check all item types * $ wp acf json status * +---------------+---------+-------+----------------+ * | Type | Pending | Total | Status | * +---------------+---------+-------+----------------+ * | field-group | 3 | 12 | Sync available | * | post-type | 0 | 2 | In sync | * | taxonomy | 1 | 3 | Sync available | * | options-page | 0 | 1 | In sync | * +---------------+---------+-------+----------------+ * * # Check only field groups * $ wp acf json status --type=field-group * * # Show detailed list of pending items * $ wp acf json status --detailed * +-------------------+------------------+---------------+--------+ * | Key | Title | Type | Action | * +-------------------+------------------+---------------+--------+ * | group_abc123 | Product Fields | field-group | Update | * | group_def456 | Homepage | field-group | Create | * | taxonomy_ghi789 | Product Category | taxonomy | Update | * +-------------------+------------------+---------------+--------+ * * # Output status as JSON for scripts * $ wp acf json status --format=json * [{"Type":"field-group","Pending":3,"Total":12,"Status":"Sync available"}] * * @since 6.8 * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function status( $args, $assoc_args ) { $this->log_command( 'status' ); $type_filter = get_flag_value( $assoc_args, 'type' ); $format = get_flag_value( $assoc_args, 'format', 'table' ); $detailed = get_flag_value( $assoc_args, 'detailed', false ); $post_types = $this->get_post_types( $type_filter ); if ( $detailed ) { $this->display_detailed_status( $post_types, $format ); return; } $rows = array(); $total_pending = 0; foreach ( $post_types as $post_type ) { $syncable = $this->get_syncable_items( $post_type ); $all_items = acf_get_internal_post_type_posts( $post_type ); $count = count( $syncable ); $total_count = count( $all_items ); $total_pending += $count; $rows[] = array( 'Type' => $this->get_type_label( $post_type ), 'Pending' => $count, 'Total' => $total_count, 'Status' => $count > 0 ? 'Sync available' : 'In sync', ); } format_items( $format, $rows, array( 'Type', 'Pending', 'Total', 'Status' ) ); if ( 'table' === $format ) { if ( $total_pending > 0 ) { WP_CLI::log( sprintf( '%d item(s) pending sync. Run `wp acf json sync` to apply changes.', $total_pending ) ); } else { WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC ); } } } /** * Syncs local JSON changes to the database. * * Imports pending JSON changes for ACF items (field groups, post types, * taxonomies, and options pages). This command reads JSON files from your * theme/plugin acf-json directory and creates or updates the corresponding * database entries. * * WARNING: This command modifies your database. Use --dry-run first to * preview changes before running on production. * * ## OPTIONS * * [--type=<type>] * : Limit sync to a specific item type. Defaults to all item types (field groups, post types, taxonomies, options pages). * --- * options: * - field-group * - post-type * - taxonomy * - options-page * --- * * [--key=<key>] * : Sync a specific item by its ACF key (e.g., group_abc123). * * [--dry-run] * : Preview what would be synced without making changes. Recommended for * production deployments. * * ## EXAMPLES * * # Preview what will be synced (safe) * $ wp acf json sync --dry-run * 3 item(s) pending sync: * +-------------------+------------------+---------------+--------+ * | Key | Title | Type | Action | * +-------------------+------------------+---------------+--------+ * | group_abc123 | Product Fields | field-group | Update | * +-------------------+------------------+---------------+--------+ * * # Sync all pending changes * $ wp acf json sync * Updated field-group: Product Fields (group_abc123) * Success: 1 item(s) synced. * * # Sync only field groups (during deployment) * $ wp acf json sync --type=field-group * * # Sync a specific field group after manual JSON edit * $ wp acf json sync --key=group_abc123 * * # CI/CD deployment workflow * $ wp acf json status --format=json | jq '.[] | select(.Pending > 0)' * $ wp acf json sync --dry-run * $ wp acf json sync * * @since 6.8 * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function sync( $args, $assoc_args ) { $this->log_command( 'sync' ); $type_filter = get_flag_value( $assoc_args, 'type' ); $key_filter = get_flag_value( $assoc_args, 'key' ); $dry_run = get_flag_value( $assoc_args, 'dry-run', false ); $post_types = $this->get_post_types( $type_filter ); $all_syncable = array(); foreach ( $post_types as $post_type ) { $syncable = $this->get_syncable_items( $post_type ); foreach ( $syncable as $key => $post ) { $all_syncable[ $key ] = array( 'post' => $post, 'post_type' => $post_type, ); } } if ( $key_filter ) { if ( ! isset( $all_syncable[ $key_filter ] ) ) { WP_CLI::error( sprintf( "No syncable item found with key '%s'.\n\n" . "Possible reasons:\n" . " - Key does not exist in JSON files\n" . " - Item is already in sync with database\n" . " - Item is marked as private\n\n" . "To see all syncable items, run:\n" . ' wp acf json sync --dry-run', $key_filter ) ); } $all_syncable = array( $key_filter => $all_syncable[ $key_filter ] ); } if ( empty( $all_syncable ) ) { WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC ); return; } if ( $dry_run ) { $this->display_dry_run( $all_syncable ); return; } // Disable Local JSON controller to prevent .json files from being modified during import. $json_enabled = acf_get_setting( 'json' ); acf_update_setting( 'json', false ); // Build file index per post type before the loop (matches admin UI pattern). $files_by_type = array(); foreach ( $all_syncable as $item ) { $pt = $item['post_type']; if ( ! isset( $files_by_type[ $pt ] ) ) { $files_by_type[ $pt ] = acf_get_local_json_files( $pt ); } } $synced_count = 0; foreach ( $all_syncable as $key => $item ) { $post = $item['post']; $post_type = $item['post_type']; $files = $files_by_type[ $post_type ]; if ( ! isset( $files[ $key ] ) ) { WP_CLI::warning( sprintf( "JSON file not found for key '%s'. Skipping.\n" . 'The JSON file may have been deleted or moved.', $key ) ); continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $local_post = json_decode( file_get_contents( $files[ $key ] ), true ); if ( ! is_array( $local_post ) ) { WP_CLI::warning( sprintf( "Invalid JSON in file for key '%s'. Skipping.", $key ) ); continue; } $local_post['ID'] = $post['ID']; $result = acf_import_internal_post_type( $local_post, $post_type ); if ( empty( $result ) || ! isset( $result['ID'] ) ) { WP_CLI::warning( sprintf( "Failed to sync item with key '%s'.", $key ) ); continue; } $action = $post['ID'] ? 'Updated' : 'Created'; $type_label = $this->get_type_label( $post_type ); WP_CLI::log( sprintf( '%s %s: %s (%s)', $action, $type_label, $post['title'], $key ) ); ++$synced_count; } // Restore Local JSON setting. acf_update_setting( 'json', $json_enabled ); if ( 0 === $synced_count ) { WP_CLI::warning( 'No items were synced.' ); return; } WP_CLI::success( sprintf( '%d item(s) synced.', $synced_count ) ); } /** * Imports field groups, post types, taxonomies, and options pages from a JSON file. * * Reads an ACF export JSON file and imports the items into the database, * replicating the functionality of the import UI in the WordPress admin. * If an item with the same key already exists, it will be updated. * Options pages require ACF PRO. * * ## OPTIONS * * <file> * : Path to the JSON file to import. * * ## EXAMPLES * * # Import field groups, post types, taxonomies, and options pages from a file * $ wp acf json import ./acf-export-2025-01-01.json * Imported field-group: My Field Group (group_abc123) * Imported post-type: Book (post_type_def456) * Success: Imported 2 item(s). * * # Import a single field group JSON file * $ wp acf json import ./group_abc123.json * * # Re-import to update existing items * $ wp acf json import ./acf-export.json * Updated field-group: My Field Group (group_abc123) * Success: Imported 1 item(s). * * @since 6.8 * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function import( $args, $assoc_args ) { $this->log_command( 'import' ); if ( empty( $args[0] ) ) { WP_CLI::error( "Missing required file argument.\n\n" . "Usage: wp acf json import <file>\n\n" . "Example:\n" . " wp acf json import ./acf-export.json\n\n" . "See: wp help acf json import" ); } $file_path = $args[0]; if ( ! file_exists( $file_path ) ) { WP_CLI::error( sprintf( 'File not found: %s', $file_path ) ); } if ( 'json' !== pathinfo( $file_path, PATHINFO_EXTENSION ) ) { WP_CLI::error( 'File must have .json extension.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $json = file_get_contents( $file_path ); $json = json_decode( $json, true ); if ( ! $json || ! is_array( $json ) ) { WP_CLI::error( 'Import file is empty or contains invalid JSON.' ); } // Normalize single item to array (matches admin UI behavior). if ( isset( $json['key'] ) ) { $json = array( $json ); } $ids = array(); foreach ( $json as $to_import ) { if ( ! is_array( $to_import ) ) { WP_CLI::warning( 'Skipping invalid item (expected array, got ' . gettype( $to_import ) . ').' ); continue; } if ( empty( $to_import['key'] ) ) { WP_CLI::warning( 'Skipping item with no key.' ); continue; } $post_type = acf_determine_internal_post_type( $to_import['key'] ); if ( ! $post_type ) { WP_CLI::warning( sprintf( "Could not determine post type for key '%s'. Skipping.", $to_import['key'] ) ); continue; } $post = acf_get_internal_post_type_post( $to_import['key'], $post_type ); if ( $post ) { $to_import['ID'] = $post->ID; } $result = acf_import_internal_post_type( $to_import, $post_type ); if ( empty( $result ) || ! isset( $result['ID'] ) ) { WP_CLI::warning( sprintf( "Failed to import item with key '%s'.", $to_import['key'] ) ); continue; } $action = ! empty( $to_import['ID'] ) ? 'Updated' : 'Imported'; $title = ! empty( $result['title'] ) ? $result['title'] : $to_import['key']; $type_label = $this->get_type_label( $post_type ); WP_CLI::log( sprintf( '%s %s: %s (%s)', $action, $type_label, $title, $to_import['key'] ) ); $ids[] = $result['ID']; } if ( empty( $ids ) ) { WP_CLI::warning( 'No items were imported.' ); return; } WP_CLI::success( sprintf( 'Imported %d item(s).', count( $ids ) ) ); } /** * Exports field groups, post types, taxonomies, and options pages to a JSON file. * * Exports ACF items to a JSON file, replicating the functionality of * the export tool in the WordPress admin. * * ## OPTIONS * * [--field-groups=<keys>] * : Export specific field groups by key or label, comma separated. * * [--post-types=<keys>] * : Export specific post types by key or label, comma separated. * * [--taxonomies=<keys>] * : Export specific taxonomies by key or label, comma separated. * * [--options-pages=<keys>] * : Export specific options pages by key or label, comma separated. Requires ACF PRO. * * [--dir=<directory>] * : Directory path to write the JSON file to. * * [--stdout] * : Print the JSON to stdout instead of writing to a file. * * ## EXAMPLES * * # Export all items to a directory * $ wp acf json export --dir=./exports/ * * # Export specific field groups by key * $ wp acf json export --field-groups=group_abc123,group_def456 --dir=./ * * # Export a field group by label * $ wp acf json export --field-groups="My Field Group" --dir=./ * * # Export mixed items (field groups and post types) * $ wp acf json export --field-groups=group_abc --post-types=post_type_def --dir=./ * * # Export to stdout for piping * $ wp acf json export --stdout * $ wp acf json export --field-groups=group_abc123 --stdout | jq . * * @since 6.8 * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function export( $args, $assoc_args ) { $this->log_command( 'export' ); $field_groups_arg = get_flag_value( $assoc_args, 'field-groups' ); $post_types_arg = get_flag_value( $assoc_args, 'post-types' ); $taxonomies_arg = get_flag_value( $assoc_args, 'taxonomies' ); $options_pages_arg = get_flag_value( $assoc_args, 'options-pages' ); $output_dir = get_flag_value( $assoc_args, 'dir' ); $stdout = get_flag_value( $assoc_args, 'stdout', false ); if ( ! $output_dir && ! $stdout ) { WP_CLI::error( 'You must specify --dir=<directory> or --stdout.' ); } if ( $output_dir && $stdout ) { WP_CLI::error( 'Cannot specify both --dir and --stdout.' ); } if ( $output_dir && ! is_dir( $output_dir ) ) { WP_CLI::error( sprintf( 'Directory not found: %s', $output_dir ) ); } if ( $output_dir && ! wp_is_writable( $output_dir ) ) { WP_CLI::error( sprintf( 'Directory is not writable: %s', $output_dir ) ); } $keys = $this->resolve_export_keys( $field_groups_arg, $post_types_arg, $taxonomies_arg, $options_pages_arg ); if ( empty( $keys ) ) { WP_CLI::error( 'No items found to export.' ); } $json = array(); foreach ( $keys as $key ) { $post_type = acf_determine_internal_post_type( $key ); $post = acf_get_internal_post_type( $key, $post_type ); if ( empty( $post ) ) { WP_CLI::warning( sprintf( "Item not found for key '%s'. Skipping.", $key ) ); continue; } if ( 'acf-field-group' === $post_type ) { $post['fields'] = acf_get_fields( $post ); } $post = acf_prepare_internal_post_type_for_export( $post, $post_type ); $json[] = $post; } if ( empty( $json ) ) { WP_CLI::error( 'No items could be exported.' ); } $encoded = acf_json_encode( $json ); if ( $stdout ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo $encoded . "\n"; return; } $file_name = 'acf-export-' . date( 'Y-m-d' ) . '.json'; $file_path = trailingslashit( $output_dir ) . $file_name; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $result = file_put_contents( $file_path, $encoded . "\r\n" ); if ( false === $result ) { WP_CLI::error( sprintf( 'Failed to write to %s', $file_path ) ); } WP_CLI::success( sprintf( 'Exported %d item(s) to %s', count( $json ), $file_path ) ); } /** * Resolves export arguments into an array of ACF keys. * * When no arguments are provided, collects all items across all types. * Accepts keys directly (group_xxx) or labels which are matched against * existing items. * * @since 6.8 * * @param string|null $field_groups_arg Comma-separated field group keys/labels. * @param string|null $post_types_arg Comma-separated post type keys/labels. * @param string|null $taxonomies_arg Comma-separated taxonomy keys/labels. * @param string|null $options_pages_arg Comma-separated options page keys/labels. * @return array List of ACF keys to export. */ private function resolve_export_keys( $field_groups_arg, $post_types_arg, $taxonomies_arg, $options_pages_arg ) { $no_filters = ! $field_groups_arg && ! $post_types_arg && ! $taxonomies_arg && ! $options_pages_arg; $keys = array(); if ( $no_filters ) { foreach ( $this->get_post_types() as $post_type ) { $keys = array_merge( $keys, $this->resolve_keys_for_type( $post_type, null ) ); } return $keys; } if ( $field_groups_arg ) { $keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-field-group', $field_groups_arg ) ); } if ( $post_types_arg ) { $keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-post-type', $post_types_arg ) ); } if ( $taxonomies_arg ) { $keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-taxonomy', $taxonomies_arg ) ); } if ( $options_pages_arg ) { if ( ! acf_is_pro() ) { WP_CLI::error( "Options pages require ACF PRO.\n\n" . "To export options pages, you need:\n" . " - ACF PRO license\n" . " - Active license key\n\n" . 'See: https://www.advancedcustomfields.com/pro/' ); } $keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-ui-options-page', $options_pages_arg ) ); } return $keys; } /** * Resolves a comma-separated list of keys or labels into ACF keys for a given post type. * * @since 6.8 * * @param string $post_type The item type (field group, post type, taxonomy, or options page). * @param string|null $arg Comma-separated keys/labels, or null for all. * @return array List of ACF keys. */ private function resolve_keys_for_type( $post_type, $arg ) { $posts = acf_get_internal_post_type_posts( $post_type ); $posts = array_filter( $posts, 'acf_internal_post_object_contains_valid_key' ); if ( ! $arg ) { return wp_list_pluck( $posts, 'key' ); } $identifiers = array_filter( array_map( 'trim', explode( ',', $arg ) ) ); $keys = array(); foreach ( $identifiers as $identifier ) { $found = false; foreach ( $posts as $post ) { if ( $post['key'] === $identifier || strcasecmp( $post['title'], $identifier ) === 0 ) { $keys[] = $post['key']; $found = true; break; } } if ( ! $found ) { WP_CLI::warning( sprintf( 'No item found matching "%s". Skipping.', $identifier ) ); } } return array_unique( $keys ); } /** * Determines which item types to process. * * @since 6.8 * * @param string|null $type_filter The CLI type flag value. * @return array List of item type slugs. */ private function get_post_types( $type_filter = null ) { if ( $type_filter ) { if ( ! isset( self::TYPE_MAP[ $type_filter ] ) ) { WP_CLI::error( sprintf( "Unknown type '%s'.\n\n" . "Valid types:\n" . " - field-group\n" . " - post-type\n" . " - taxonomy\n" . " - options-page (ACF PRO only)\n\n" . 'See: wp help acf json', $type_filter ) ); } $post_type = self::TYPE_MAP[ $type_filter ]; if ( 'acf-ui-options-page' === $post_type && ! acf_is_pro() ) { WP_CLI::error( "Options pages require ACF PRO.\n\n" . "To sync options pages, you need:\n" . " - ACF PRO license\n" . " - Active license key\n\n" . 'See: https://www.advancedcustomfields.com/pro/' ); } return array( $post_type ); } $post_types = acf_get_internal_post_types(); // Remove options pages from non-PRO installs. if ( ! acf_is_pro() ) { $post_types = array_filter( $post_types, function ( $pt ) { return 'acf-ui-options-page' !== $pt; } ); } return array_values( $post_types ); } /** * Returns the friendly CLI type label for an internal post type slug. * * @since 6.8 * * @param string $post_type The internal post type slug (e.g. 'acf-field-group'). * @return string The friendly label (e.g. 'field-group'), or the original slug if not found. */ private function get_type_label( $post_type ) { $label = array_search( $post_type, self::TYPE_MAP, true ); return $label ? $label : $post_type; } /** * Finds syncable items for a given item type using the same logic as the admin UI. * * @since 6.8 * * @param string $post_type The item type. * @return array Associative array of key => post data for syncable items. */ private function get_syncable_items( $post_type ) { $syncable = array(); $files = acf_get_local_json_files( $post_type ); if ( empty( $files ) ) { return $syncable; } $all_posts = acf_get_internal_post_type_posts( $post_type ); foreach ( $all_posts as $post ) { $local = acf_maybe_get( $post, 'local' ); $modified = acf_maybe_get( $post, 'modified' ); $private = acf_maybe_get( $post, 'private' ); if ( $private ) { continue; } if ( 'json' !== $local ) { continue; } // New item (not yet in database). if ( ! $post['ID'] ) { $syncable[ $post['key'] ] = $post; continue; } // Updated item (JSON is newer than database). if ( $modified && $modified > get_post_modified_time( 'U', true, $post['ID'] ) ) { $syncable[ $post['key'] ] = $post; } } return $syncable; } /** * Displays detailed status showing individual items that need syncing. * * @since 6.8 * * @param array $post_types List of post types to check. * @param string $format Output format. */ private function display_detailed_status( $post_types, $format ) { $rows = array(); $total_pending = 0; foreach ( $post_types as $post_type ) { $syncable = $this->get_syncable_items( $post_type ); foreach ( $syncable as $key => $post ) { $action = $post['ID'] ? 'Update' : 'Create'; ++$total_pending; $rows[] = array( 'Key' => $key, 'Title' => $post['title'], 'Type' => $this->get_type_label( $post_type ), 'Action' => $action, ); } } if ( empty( $rows ) ) { WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC ); return; } format_items( $format, $rows, array( 'Key', 'Title', 'Type', 'Action' ) ); if ( 'table' === $format ) { WP_CLI::log( sprintf( '%d item(s) pending sync. Run `wp acf json sync` to apply changes.', $total_pending ) ); } } /** * Displays a table of pending sync items for dry-run mode. * * @since 6.8 * * @param array $all_syncable The syncable items. */ private function display_dry_run( $all_syncable ) { $rows = array(); foreach ( $all_syncable as $key => $item ) { $post = $item['post']; $action = $post['ID'] ? 'Update' : 'Create'; $rows[] = array( 'Key' => $key, 'Title' => $post['title'], 'Type' => $this->get_type_label( $item['post_type'] ), 'Action' => $action, ); } WP_CLI::log( sprintf( '%d item(s) pending sync:', count( $rows ) ) ); format_items( 'table', $rows, array( 'Key', 'Title', 'Type', 'Action' ) ); } }
cải xoăn