JsonCommand.php
881 lines
| 1 | <?php |
| 2 | /** |
| 3 | * ACF 6.8.0 feature port. |
| 4 | * |
| 5 | * @package wordpress/secure-custom-fields |
| 6 | */ |
| 7 | |
| 8 | // phpcs:disable -- Upstream ACF 6.8.0 feature-port files are kept close to source. |
| 9 | |
| 10 | namespace SCF\CLI; |
| 11 | |
| 12 | use WP_CLI; |
| 13 | use function WP_CLI\Utils\format_items; |
| 14 | use function WP_CLI\Utils\get_flag_value; |
| 15 | |
| 16 | // Exit if accessed directly. |
| 17 | defined( 'ABSPATH' ) || exit; |
| 18 | |
| 19 | /** |
| 20 | * Manages ACF JSON import, export, and synchronization. |
| 21 | * |
| 22 | * ## EXAMPLES |
| 23 | * |
| 24 | * # Show sync status for all item types (field groups, post types, taxonomies, options pages) |
| 25 | * $ wp acf json status |
| 26 | * |
| 27 | * # Sync all pending local JSON changes to database |
| 28 | * $ wp acf json sync |
| 29 | * |
| 30 | * # Import from a JSON file |
| 31 | * $ wp acf json import ./acf-export.json |
| 32 | * |
| 33 | * # Export all items to a directory |
| 34 | * $ wp acf json export --dir=./exports/ |
| 35 | * |
| 36 | * # Export to stdout |
| 37 | * $ wp acf json export --stdout |
| 38 | */ |
| 39 | class JsonCommand { |
| 40 | |
| 41 | /** |
| 42 | * Map of CLI type flags to internal ACF post types. |
| 43 | * |
| 44 | * @var array |
| 45 | */ |
| 46 | private const TYPE_MAP = array( |
| 47 | 'field-group' => 'acf-field-group', |
| 48 | 'post-type' => 'acf-post-type', |
| 49 | 'taxonomy' => 'acf-taxonomy', |
| 50 | 'options-page' => 'acf-ui-options-page', |
| 51 | ); |
| 52 | |
| 53 | /** |
| 54 | * Success message when there are no items to sync. |
| 55 | * |
| 56 | * @var string |
| 57 | */ |
| 58 | private const MESSAGE_ALREADY_IN_SYNC = 'Everything is already in sync.'; |
| 59 | |
| 60 | /** |
| 61 | * Records a first-run event for a CLI sub-command. |
| 62 | * |
| 63 | * @since 6.8 |
| 64 | * |
| 65 | * @param string $subcommand The sub-command name (e.g., 'status', 'sync', 'import', 'export'). |
| 66 | */ |
| 67 | private function log_command( $subcommand ) { |
| 68 | $site_health = acf_get_instance( 'SCF\Site_Health\Site_Health' ); |
| 69 | |
| 70 | if ( method_exists( $site_health, 'log_cli_command' ) ) { |
| 71 | $site_health->log_cli_command( 'acf json ' . $subcommand ); |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /** |
| 76 | * Shows the sync status for ACF items. |
| 77 | * |
| 78 | * Displays how many items are pending sync. Items are considered "pending" |
| 79 | * when the JSON file is newer than the database entry, or when the item |
| 80 | * exists in JSON but not in the database. |
| 81 | * |
| 82 | * ## OPTIONS |
| 83 | * |
| 84 | * [--type=<type>] |
| 85 | * : Limit to field groups, post types, taxonomies, or options pages. Defaults to all item types (field groups, post types, taxonomies, options pages). |
| 86 | * --- |
| 87 | * options: |
| 88 | * - field-group |
| 89 | * - post-type |
| 90 | * - taxonomy |
| 91 | * - options-page |
| 92 | * --- |
| 93 | * |
| 94 | * [--detailed] |
| 95 | * : Show detailed list of modified items instead of just counts. |
| 96 | * |
| 97 | * [--format=<format>] |
| 98 | * : Output format. |
| 99 | * --- |
| 100 | * default: table |
| 101 | * options: |
| 102 | * - table |
| 103 | * - json |
| 104 | * - yaml |
| 105 | * - csv |
| 106 | * --- |
| 107 | * |
| 108 | * ## EXAMPLES |
| 109 | * |
| 110 | * # Check all item types |
| 111 | * $ wp acf json status |
| 112 | * +---------------+---------+-------+----------------+ |
| 113 | * | Type | Pending | Total | Status | |
| 114 | * +---------------+---------+-------+----------------+ |
| 115 | * | field-group | 3 | 12 | Sync available | |
| 116 | * | post-type | 0 | 2 | In sync | |
| 117 | * | taxonomy | 1 | 3 | Sync available | |
| 118 | * | options-page | 0 | 1 | In sync | |
| 119 | * +---------------+---------+-------+----------------+ |
| 120 | * |
| 121 | * # Check only field groups |
| 122 | * $ wp acf json status --type=field-group |
| 123 | * |
| 124 | * # Show detailed list of pending items |
| 125 | * $ wp acf json status --detailed |
| 126 | * +-------------------+------------------+---------------+--------+ |
| 127 | * | Key | Title | Type | Action | |
| 128 | * +-------------------+------------------+---------------+--------+ |
| 129 | * | group_abc123 | Product Fields | field-group | Update | |
| 130 | * | group_def456 | Homepage | field-group | Create | |
| 131 | * | taxonomy_ghi789 | Product Category | taxonomy | Update | |
| 132 | * +-------------------+------------------+---------------+--------+ |
| 133 | * |
| 134 | * # Output status as JSON for scripts |
| 135 | * $ wp acf json status --format=json |
| 136 | * [{"Type":"field-group","Pending":3,"Total":12,"Status":"Sync available"}] |
| 137 | * |
| 138 | * @since 6.8 |
| 139 | * |
| 140 | * @param array $args Positional arguments. |
| 141 | * @param array $assoc_args Associative arguments. |
| 142 | */ |
| 143 | public function status( $args, $assoc_args ) { |
| 144 | $this->log_command( 'status' ); |
| 145 | |
| 146 | $type_filter = get_flag_value( $assoc_args, 'type' ); |
| 147 | $format = get_flag_value( $assoc_args, 'format', 'table' ); |
| 148 | $detailed = get_flag_value( $assoc_args, 'detailed', false ); |
| 149 | $post_types = $this->get_post_types( $type_filter ); |
| 150 | |
| 151 | if ( $detailed ) { |
| 152 | $this->display_detailed_status( $post_types, $format ); |
| 153 | return; |
| 154 | } |
| 155 | |
| 156 | $rows = array(); |
| 157 | $total_pending = 0; |
| 158 | |
| 159 | foreach ( $post_types as $post_type ) { |
| 160 | $syncable = $this->get_syncable_items( $post_type ); |
| 161 | $all_items = acf_get_internal_post_type_posts( $post_type ); |
| 162 | $count = count( $syncable ); |
| 163 | $total_count = count( $all_items ); |
| 164 | $total_pending += $count; |
| 165 | |
| 166 | $rows[] = array( |
| 167 | 'Type' => $this->get_type_label( $post_type ), |
| 168 | 'Pending' => $count, |
| 169 | 'Total' => $total_count, |
| 170 | 'Status' => $count > 0 ? 'Sync available' : 'In sync', |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | format_items( $format, $rows, array( 'Type', 'Pending', 'Total', 'Status' ) ); |
| 175 | |
| 176 | if ( 'table' === $format ) { |
| 177 | if ( $total_pending > 0 ) { |
| 178 | WP_CLI::log( sprintf( '%d item(s) pending sync. Run `wp acf json sync` to apply changes.', $total_pending ) ); |
| 179 | } else { |
| 180 | WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC ); |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * Syncs local JSON changes to the database. |
| 187 | * |
| 188 | * Imports pending JSON changes for ACF items (field groups, post types, |
| 189 | * taxonomies, and options pages). This command reads JSON files from your |
| 190 | * theme/plugin acf-json directory and creates or updates the corresponding |
| 191 | * database entries. |
| 192 | * |
| 193 | * WARNING: This command modifies your database. Use --dry-run first to |
| 194 | * preview changes before running on production. |
| 195 | * |
| 196 | * ## OPTIONS |
| 197 | * |
| 198 | * [--type=<type>] |
| 199 | * : Limit sync to a specific item type. Defaults to all item types (field groups, post types, taxonomies, options pages). |
| 200 | * --- |
| 201 | * options: |
| 202 | * - field-group |
| 203 | * - post-type |
| 204 | * - taxonomy |
| 205 | * - options-page |
| 206 | * --- |
| 207 | * |
| 208 | * [--key=<key>] |
| 209 | * : Sync a specific item by its ACF key (e.g., group_abc123). |
| 210 | * |
| 211 | * [--dry-run] |
| 212 | * : Preview what would be synced without making changes. Recommended for |
| 213 | * production deployments. |
| 214 | * |
| 215 | * ## EXAMPLES |
| 216 | * |
| 217 | * # Preview what will be synced (safe) |
| 218 | * $ wp acf json sync --dry-run |
| 219 | * 3 item(s) pending sync: |
| 220 | * +-------------------+------------------+---------------+--------+ |
| 221 | * | Key | Title | Type | Action | |
| 222 | * +-------------------+------------------+---------------+--------+ |
| 223 | * | group_abc123 | Product Fields | field-group | Update | |
| 224 | * +-------------------+------------------+---------------+--------+ |
| 225 | * |
| 226 | * # Sync all pending changes |
| 227 | * $ wp acf json sync |
| 228 | * Updated field-group: Product Fields (group_abc123) |
| 229 | * Success: 1 item(s) synced. |
| 230 | * |
| 231 | * # Sync only field groups (during deployment) |
| 232 | * $ wp acf json sync --type=field-group |
| 233 | * |
| 234 | * # Sync a specific field group after manual JSON edit |
| 235 | * $ wp acf json sync --key=group_abc123 |
| 236 | * |
| 237 | * # CI/CD deployment workflow |
| 238 | * $ wp acf json status --format=json | jq '.[] | select(.Pending > 0)' |
| 239 | * $ wp acf json sync --dry-run |
| 240 | * $ wp acf json sync |
| 241 | * |
| 242 | * @since 6.8 |
| 243 | * |
| 244 | * @param array $args Positional arguments. |
| 245 | * @param array $assoc_args Associative arguments. |
| 246 | */ |
| 247 | public function sync( $args, $assoc_args ) { |
| 248 | $this->log_command( 'sync' ); |
| 249 | |
| 250 | $type_filter = get_flag_value( $assoc_args, 'type' ); |
| 251 | $key_filter = get_flag_value( $assoc_args, 'key' ); |
| 252 | $dry_run = get_flag_value( $assoc_args, 'dry-run', false ); |
| 253 | |
| 254 | $post_types = $this->get_post_types( $type_filter ); |
| 255 | |
| 256 | $all_syncable = array(); |
| 257 | |
| 258 | foreach ( $post_types as $post_type ) { |
| 259 | $syncable = $this->get_syncable_items( $post_type ); |
| 260 | |
| 261 | foreach ( $syncable as $key => $post ) { |
| 262 | $all_syncable[ $key ] = array( |
| 263 | 'post' => $post, |
| 264 | 'post_type' => $post_type, |
| 265 | ); |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | if ( $key_filter ) { |
| 270 | if ( ! isset( $all_syncable[ $key_filter ] ) ) { |
| 271 | WP_CLI::error( |
| 272 | sprintf( |
| 273 | "No syncable item found with key '%s'.\n\n" . |
| 274 | "Possible reasons:\n" . |
| 275 | " - Key does not exist in JSON files\n" . |
| 276 | " - Item is already in sync with database\n" . |
| 277 | " - Item is marked as private\n\n" . |
| 278 | "To see all syncable items, run:\n" . |
| 279 | ' wp acf json sync --dry-run', |
| 280 | $key_filter |
| 281 | ) |
| 282 | ); |
| 283 | } |
| 284 | $all_syncable = array( $key_filter => $all_syncable[ $key_filter ] ); |
| 285 | } |
| 286 | |
| 287 | if ( empty( $all_syncable ) ) { |
| 288 | WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC ); |
| 289 | return; |
| 290 | } |
| 291 | |
| 292 | if ( $dry_run ) { |
| 293 | $this->display_dry_run( $all_syncable ); |
| 294 | return; |
| 295 | } |
| 296 | |
| 297 | // Disable Local JSON controller to prevent .json files from being modified during import. |
| 298 | $json_enabled = acf_get_setting( 'json' ); |
| 299 | acf_update_setting( 'json', false ); |
| 300 | |
| 301 | // Build file index per post type before the loop (matches admin UI pattern). |
| 302 | $files_by_type = array(); |
| 303 | foreach ( $all_syncable as $item ) { |
| 304 | $pt = $item['post_type']; |
| 305 | if ( ! isset( $files_by_type[ $pt ] ) ) { |
| 306 | $files_by_type[ $pt ] = acf_get_local_json_files( $pt ); |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | $synced_count = 0; |
| 311 | |
| 312 | foreach ( $all_syncable as $key => $item ) { |
| 313 | $post = $item['post']; |
| 314 | $post_type = $item['post_type']; |
| 315 | $files = $files_by_type[ $post_type ]; |
| 316 | |
| 317 | if ( ! isset( $files[ $key ] ) ) { |
| 318 | WP_CLI::warning( |
| 319 | sprintf( |
| 320 | "JSON file not found for key '%s'. Skipping.\n" . |
| 321 | 'The JSON file may have been deleted or moved.', |
| 322 | $key |
| 323 | ) |
| 324 | ); |
| 325 | continue; |
| 326 | } |
| 327 | |
| 328 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 329 | $local_post = json_decode( file_get_contents( $files[ $key ] ), true ); |
| 330 | |
| 331 | if ( ! is_array( $local_post ) ) { |
| 332 | WP_CLI::warning( sprintf( "Invalid JSON in file for key '%s'. Skipping.", $key ) ); |
| 333 | continue; |
| 334 | } |
| 335 | |
| 336 | $local_post['ID'] = $post['ID']; |
| 337 | $result = acf_import_internal_post_type( $local_post, $post_type ); |
| 338 | |
| 339 | if ( empty( $result ) || ! isset( $result['ID'] ) ) { |
| 340 | WP_CLI::warning( sprintf( "Failed to sync item with key '%s'.", $key ) ); |
| 341 | continue; |
| 342 | } |
| 343 | |
| 344 | $action = $post['ID'] ? 'Updated' : 'Created'; |
| 345 | $type_label = $this->get_type_label( $post_type ); |
| 346 | WP_CLI::log( sprintf( '%s %s: %s (%s)', $action, $type_label, $post['title'], $key ) ); |
| 347 | ++$synced_count; |
| 348 | } |
| 349 | |
| 350 | // Restore Local JSON setting. |
| 351 | acf_update_setting( 'json', $json_enabled ); |
| 352 | |
| 353 | if ( 0 === $synced_count ) { |
| 354 | WP_CLI::warning( 'No items were synced.' ); |
| 355 | return; |
| 356 | } |
| 357 | |
| 358 | WP_CLI::success( sprintf( '%d item(s) synced.', $synced_count ) ); |
| 359 | } |
| 360 | |
| 361 | /** |
| 362 | * Imports field groups, post types, taxonomies, and options pages from a JSON file. |
| 363 | * |
| 364 | * Reads an ACF export JSON file and imports the items into the database, |
| 365 | * replicating the functionality of the import UI in the WordPress admin. |
| 366 | * If an item with the same key already exists, it will be updated. |
| 367 | * Options pages require ACF PRO. |
| 368 | * |
| 369 | * ## OPTIONS |
| 370 | * |
| 371 | * <file> |
| 372 | * : Path to the JSON file to import. |
| 373 | * |
| 374 | * ## EXAMPLES |
| 375 | * |
| 376 | * # Import field groups, post types, taxonomies, and options pages from a file |
| 377 | * $ wp acf json import ./acf-export-2025-01-01.json |
| 378 | * Imported field-group: My Field Group (group_abc123) |
| 379 | * Imported post-type: Book (post_type_def456) |
| 380 | * Success: Imported 2 item(s). |
| 381 | * |
| 382 | * # Import a single field group JSON file |
| 383 | * $ wp acf json import ./group_abc123.json |
| 384 | * |
| 385 | * # Re-import to update existing items |
| 386 | * $ wp acf json import ./acf-export.json |
| 387 | * Updated field-group: My Field Group (group_abc123) |
| 388 | * Success: Imported 1 item(s). |
| 389 | * |
| 390 | * @since 6.8 |
| 391 | * |
| 392 | * @param array $args Positional arguments. |
| 393 | * @param array $assoc_args Associative arguments. |
| 394 | */ |
| 395 | public function import( $args, $assoc_args ) { |
| 396 | $this->log_command( 'import' ); |
| 397 | |
| 398 | if ( empty( $args[0] ) ) { |
| 399 | WP_CLI::error( |
| 400 | "Missing required file argument.\n\n" . |
| 401 | "Usage: wp acf json import <file>\n\n" . |
| 402 | "Example:\n" . |
| 403 | " wp acf json import ./acf-export.json\n\n" . |
| 404 | "See: wp help acf json import" |
| 405 | ); |
| 406 | } |
| 407 | |
| 408 | $file_path = $args[0]; |
| 409 | |
| 410 | if ( ! file_exists( $file_path ) ) { |
| 411 | WP_CLI::error( sprintf( 'File not found: %s', $file_path ) ); |
| 412 | } |
| 413 | |
| 414 | if ( 'json' !== pathinfo( $file_path, PATHINFO_EXTENSION ) ) { |
| 415 | WP_CLI::error( 'File must have .json extension.' ); |
| 416 | } |
| 417 | |
| 418 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 419 | $json = file_get_contents( $file_path ); |
| 420 | $json = json_decode( $json, true ); |
| 421 | |
| 422 | if ( ! $json || ! is_array( $json ) ) { |
| 423 | WP_CLI::error( 'Import file is empty or contains invalid JSON.' ); |
| 424 | } |
| 425 | |
| 426 | // Normalize single item to array (matches admin UI behavior). |
| 427 | if ( isset( $json['key'] ) ) { |
| 428 | $json = array( $json ); |
| 429 | } |
| 430 | |
| 431 | $ids = array(); |
| 432 | |
| 433 | foreach ( $json as $to_import ) { |
| 434 | if ( ! is_array( $to_import ) ) { |
| 435 | WP_CLI::warning( 'Skipping invalid item (expected array, got ' . gettype( $to_import ) . ').' ); |
| 436 | continue; |
| 437 | } |
| 438 | |
| 439 | if ( empty( $to_import['key'] ) ) { |
| 440 | WP_CLI::warning( 'Skipping item with no key.' ); |
| 441 | continue; |
| 442 | } |
| 443 | |
| 444 | $post_type = acf_determine_internal_post_type( $to_import['key'] ); |
| 445 | |
| 446 | if ( ! $post_type ) { |
| 447 | WP_CLI::warning( sprintf( "Could not determine post type for key '%s'. Skipping.", $to_import['key'] ) ); |
| 448 | continue; |
| 449 | } |
| 450 | |
| 451 | $post = acf_get_internal_post_type_post( $to_import['key'], $post_type ); |
| 452 | |
| 453 | if ( $post ) { |
| 454 | $to_import['ID'] = $post->ID; |
| 455 | } |
| 456 | |
| 457 | $result = acf_import_internal_post_type( $to_import, $post_type ); |
| 458 | |
| 459 | if ( empty( $result ) || ! isset( $result['ID'] ) ) { |
| 460 | WP_CLI::warning( sprintf( "Failed to import item with key '%s'.", $to_import['key'] ) ); |
| 461 | continue; |
| 462 | } |
| 463 | |
| 464 | $action = ! empty( $to_import['ID'] ) ? 'Updated' : 'Imported'; |
| 465 | $title = ! empty( $result['title'] ) ? $result['title'] : $to_import['key']; |
| 466 | $type_label = $this->get_type_label( $post_type ); |
| 467 | WP_CLI::log( sprintf( '%s %s: %s (%s)', $action, $type_label, $title, $to_import['key'] ) ); |
| 468 | |
| 469 | $ids[] = $result['ID']; |
| 470 | } |
| 471 | |
| 472 | if ( empty( $ids ) ) { |
| 473 | WP_CLI::warning( 'No items were imported.' ); |
| 474 | return; |
| 475 | } |
| 476 | |
| 477 | WP_CLI::success( sprintf( 'Imported %d item(s).', count( $ids ) ) ); |
| 478 | } |
| 479 | |
| 480 | /** |
| 481 | * Exports field groups, post types, taxonomies, and options pages to a JSON file. |
| 482 | * |
| 483 | * Exports ACF items to a JSON file, replicating the functionality of |
| 484 | * the export tool in the WordPress admin. |
| 485 | * |
| 486 | * ## OPTIONS |
| 487 | * |
| 488 | * [--field-groups=<keys>] |
| 489 | * : Export specific field groups by key or label, comma separated. |
| 490 | * |
| 491 | * [--post-types=<keys>] |
| 492 | * : Export specific post types by key or label, comma separated. |
| 493 | * |
| 494 | * [--taxonomies=<keys>] |
| 495 | * : Export specific taxonomies by key or label, comma separated. |
| 496 | * |
| 497 | * [--options-pages=<keys>] |
| 498 | * : Export specific options pages by key or label, comma separated. Requires ACF PRO. |
| 499 | * |
| 500 | * [--dir=<directory>] |
| 501 | * : Directory path to write the JSON file to. |
| 502 | * |
| 503 | * [--stdout] |
| 504 | * : Print the JSON to stdout instead of writing to a file. |
| 505 | * |
| 506 | * ## EXAMPLES |
| 507 | * |
| 508 | * # Export all items to a directory |
| 509 | * $ wp acf json export --dir=./exports/ |
| 510 | * |
| 511 | * # Export specific field groups by key |
| 512 | * $ wp acf json export --field-groups=group_abc123,group_def456 --dir=./ |
| 513 | * |
| 514 | * # Export a field group by label |
| 515 | * $ wp acf json export --field-groups="My Field Group" --dir=./ |
| 516 | * |
| 517 | * # Export mixed items (field groups and post types) |
| 518 | * $ wp acf json export --field-groups=group_abc --post-types=post_type_def --dir=./ |
| 519 | * |
| 520 | * # Export to stdout for piping |
| 521 | * $ wp acf json export --stdout |
| 522 | * $ wp acf json export --field-groups=group_abc123 --stdout | jq . |
| 523 | * |
| 524 | * @since 6.8 |
| 525 | * |
| 526 | * @param array $args Positional arguments. |
| 527 | * @param array $assoc_args Associative arguments. |
| 528 | */ |
| 529 | public function export( $args, $assoc_args ) { |
| 530 | $this->log_command( 'export' ); |
| 531 | |
| 532 | $field_groups_arg = get_flag_value( $assoc_args, 'field-groups' ); |
| 533 | $post_types_arg = get_flag_value( $assoc_args, 'post-types' ); |
| 534 | $taxonomies_arg = get_flag_value( $assoc_args, 'taxonomies' ); |
| 535 | $options_pages_arg = get_flag_value( $assoc_args, 'options-pages' ); |
| 536 | $output_dir = get_flag_value( $assoc_args, 'dir' ); |
| 537 | $stdout = get_flag_value( $assoc_args, 'stdout', false ); |
| 538 | |
| 539 | if ( ! $output_dir && ! $stdout ) { |
| 540 | WP_CLI::error( 'You must specify --dir=<directory> or --stdout.' ); |
| 541 | } |
| 542 | |
| 543 | if ( $output_dir && $stdout ) { |
| 544 | WP_CLI::error( 'Cannot specify both --dir and --stdout.' ); |
| 545 | } |
| 546 | |
| 547 | if ( $output_dir && ! is_dir( $output_dir ) ) { |
| 548 | WP_CLI::error( sprintf( 'Directory not found: %s', $output_dir ) ); |
| 549 | } |
| 550 | |
| 551 | if ( $output_dir && ! wp_is_writable( $output_dir ) ) { |
| 552 | WP_CLI::error( sprintf( 'Directory is not writable: %s', $output_dir ) ); |
| 553 | } |
| 554 | |
| 555 | $keys = $this->resolve_export_keys( $field_groups_arg, $post_types_arg, $taxonomies_arg, $options_pages_arg ); |
| 556 | |
| 557 | if ( empty( $keys ) ) { |
| 558 | WP_CLI::error( 'No items found to export.' ); |
| 559 | } |
| 560 | |
| 561 | $json = array(); |
| 562 | |
| 563 | foreach ( $keys as $key ) { |
| 564 | $post_type = acf_determine_internal_post_type( $key ); |
| 565 | $post = acf_get_internal_post_type( $key, $post_type ); |
| 566 | |
| 567 | if ( empty( $post ) ) { |
| 568 | WP_CLI::warning( sprintf( "Item not found for key '%s'. Skipping.", $key ) ); |
| 569 | continue; |
| 570 | } |
| 571 | |
| 572 | if ( 'acf-field-group' === $post_type ) { |
| 573 | $post['fields'] = acf_get_fields( $post ); |
| 574 | } |
| 575 | |
| 576 | $post = acf_prepare_internal_post_type_for_export( $post, $post_type ); |
| 577 | $json[] = $post; |
| 578 | } |
| 579 | |
| 580 | if ( empty( $json ) ) { |
| 581 | WP_CLI::error( 'No items could be exported.' ); |
| 582 | } |
| 583 | |
| 584 | $encoded = acf_json_encode( $json ); |
| 585 | |
| 586 | if ( $stdout ) { |
| 587 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 588 | echo $encoded . "\n"; |
| 589 | return; |
| 590 | } |
| 591 | |
| 592 | $file_name = 'acf-export-' . date( 'Y-m-d' ) . '.json'; |
| 593 | $file_path = trailingslashit( $output_dir ) . $file_name; |
| 594 | |
| 595 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents |
| 596 | $result = file_put_contents( $file_path, $encoded . "\r\n" ); |
| 597 | |
| 598 | if ( false === $result ) { |
| 599 | WP_CLI::error( sprintf( 'Failed to write to %s', $file_path ) ); |
| 600 | } |
| 601 | |
| 602 | WP_CLI::success( sprintf( 'Exported %d item(s) to %s', count( $json ), $file_path ) ); |
| 603 | } |
| 604 | |
| 605 | /** |
| 606 | * Resolves export arguments into an array of ACF keys. |
| 607 | * |
| 608 | * When no arguments are provided, collects all items across all types. |
| 609 | * Accepts keys directly (group_xxx) or labels which are matched against |
| 610 | * existing items. |
| 611 | * |
| 612 | * @since 6.8 |
| 613 | * |
| 614 | * @param string|null $field_groups_arg Comma-separated field group keys/labels. |
| 615 | * @param string|null $post_types_arg Comma-separated post type keys/labels. |
| 616 | * @param string|null $taxonomies_arg Comma-separated taxonomy keys/labels. |
| 617 | * @param string|null $options_pages_arg Comma-separated options page keys/labels. |
| 618 | * @return array List of ACF keys to export. |
| 619 | */ |
| 620 | private function resolve_export_keys( $field_groups_arg, $post_types_arg, $taxonomies_arg, $options_pages_arg ) { |
| 621 | $no_filters = ! $field_groups_arg && ! $post_types_arg && ! $taxonomies_arg && ! $options_pages_arg; |
| 622 | $keys = array(); |
| 623 | |
| 624 | if ( $no_filters ) { |
| 625 | foreach ( $this->get_post_types() as $post_type ) { |
| 626 | $keys = array_merge( $keys, $this->resolve_keys_for_type( $post_type, null ) ); |
| 627 | } |
| 628 | |
| 629 | return $keys; |
| 630 | } |
| 631 | |
| 632 | if ( $field_groups_arg ) { |
| 633 | $keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-field-group', $field_groups_arg ) ); |
| 634 | } |
| 635 | |
| 636 | if ( $post_types_arg ) { |
| 637 | $keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-post-type', $post_types_arg ) ); |
| 638 | } |
| 639 | |
| 640 | if ( $taxonomies_arg ) { |
| 641 | $keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-taxonomy', $taxonomies_arg ) ); |
| 642 | } |
| 643 | |
| 644 | if ( $options_pages_arg ) { |
| 645 | if ( ! acf_is_pro() ) { |
| 646 | WP_CLI::error( |
| 647 | "Options pages require ACF PRO.\n\n" . |
| 648 | "To export options pages, you need:\n" . |
| 649 | " - ACF PRO license\n" . |
| 650 | " - Active license key\n\n" . |
| 651 | 'See: https://www.advancedcustomfields.com/pro/' |
| 652 | ); |
| 653 | } |
| 654 | |
| 655 | $keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-ui-options-page', $options_pages_arg ) ); |
| 656 | } |
| 657 | |
| 658 | return $keys; |
| 659 | } |
| 660 | |
| 661 | /** |
| 662 | * Resolves a comma-separated list of keys or labels into ACF keys for a given post type. |
| 663 | * |
| 664 | * @since 6.8 |
| 665 | * |
| 666 | * @param string $post_type The item type (field group, post type, taxonomy, or options page). |
| 667 | * @param string|null $arg Comma-separated keys/labels, or null for all. |
| 668 | * @return array List of ACF keys. |
| 669 | */ |
| 670 | private function resolve_keys_for_type( $post_type, $arg ) { |
| 671 | $posts = acf_get_internal_post_type_posts( $post_type ); |
| 672 | $posts = array_filter( $posts, 'acf_internal_post_object_contains_valid_key' ); |
| 673 | |
| 674 | if ( ! $arg ) { |
| 675 | return wp_list_pluck( $posts, 'key' ); |
| 676 | } |
| 677 | |
| 678 | $identifiers = array_filter( array_map( 'trim', explode( ',', $arg ) ) ); |
| 679 | $keys = array(); |
| 680 | |
| 681 | foreach ( $identifiers as $identifier ) { |
| 682 | $found = false; |
| 683 | |
| 684 | foreach ( $posts as $post ) { |
| 685 | if ( $post['key'] === $identifier || strcasecmp( $post['title'], $identifier ) === 0 ) { |
| 686 | $keys[] = $post['key']; |
| 687 | $found = true; |
| 688 | break; |
| 689 | } |
| 690 | } |
| 691 | |
| 692 | if ( ! $found ) { |
| 693 | WP_CLI::warning( sprintf( 'No item found matching "%s". Skipping.', $identifier ) ); |
| 694 | } |
| 695 | } |
| 696 | |
| 697 | return array_unique( $keys ); |
| 698 | } |
| 699 | |
| 700 | /** |
| 701 | * Determines which item types to process. |
| 702 | * |
| 703 | * @since 6.8 |
| 704 | * |
| 705 | * @param string|null $type_filter The CLI type flag value. |
| 706 | * @return array List of item type slugs. |
| 707 | */ |
| 708 | private function get_post_types( $type_filter = null ) { |
| 709 | if ( $type_filter ) { |
| 710 | if ( ! isset( self::TYPE_MAP[ $type_filter ] ) ) { |
| 711 | WP_CLI::error( |
| 712 | sprintf( |
| 713 | "Unknown type '%s'.\n\n" . |
| 714 | "Valid types:\n" . |
| 715 | " - field-group\n" . |
| 716 | " - post-type\n" . |
| 717 | " - taxonomy\n" . |
| 718 | " - options-page (ACF PRO only)\n\n" . |
| 719 | 'See: wp help acf json', |
| 720 | $type_filter |
| 721 | ) |
| 722 | ); |
| 723 | } |
| 724 | |
| 725 | $post_type = self::TYPE_MAP[ $type_filter ]; |
| 726 | |
| 727 | if ( 'acf-ui-options-page' === $post_type && ! acf_is_pro() ) { |
| 728 | WP_CLI::error( |
| 729 | "Options pages require ACF PRO.\n\n" . |
| 730 | "To sync options pages, you need:\n" . |
| 731 | " - ACF PRO license\n" . |
| 732 | " - Active license key\n\n" . |
| 733 | 'See: https://www.advancedcustomfields.com/pro/' |
| 734 | ); |
| 735 | } |
| 736 | |
| 737 | return array( $post_type ); |
| 738 | } |
| 739 | |
| 740 | $post_types = acf_get_internal_post_types(); |
| 741 | |
| 742 | // Remove options pages from non-PRO installs. |
| 743 | if ( ! acf_is_pro() ) { |
| 744 | $post_types = array_filter( |
| 745 | $post_types, |
| 746 | function ( $pt ) { |
| 747 | return 'acf-ui-options-page' !== $pt; |
| 748 | } |
| 749 | ); |
| 750 | } |
| 751 | |
| 752 | return array_values( $post_types ); |
| 753 | } |
| 754 | |
| 755 | /** |
| 756 | * Returns the friendly CLI type label for an internal post type slug. |
| 757 | * |
| 758 | * @since 6.8 |
| 759 | * |
| 760 | * @param string $post_type The internal post type slug (e.g. 'acf-field-group'). |
| 761 | * @return string The friendly label (e.g. 'field-group'), or the original slug if not found. |
| 762 | */ |
| 763 | private function get_type_label( $post_type ) { |
| 764 | $label = array_search( $post_type, self::TYPE_MAP, true ); |
| 765 | |
| 766 | return $label ? $label : $post_type; |
| 767 | } |
| 768 | |
| 769 | /** |
| 770 | * Finds syncable items for a given item type using the same logic as the admin UI. |
| 771 | * |
| 772 | * @since 6.8 |
| 773 | * |
| 774 | * @param string $post_type The item type. |
| 775 | * @return array Associative array of key => post data for syncable items. |
| 776 | */ |
| 777 | private function get_syncable_items( $post_type ) { |
| 778 | $syncable = array(); |
| 779 | $files = acf_get_local_json_files( $post_type ); |
| 780 | |
| 781 | if ( empty( $files ) ) { |
| 782 | return $syncable; |
| 783 | } |
| 784 | |
| 785 | $all_posts = acf_get_internal_post_type_posts( $post_type ); |
| 786 | |
| 787 | foreach ( $all_posts as $post ) { |
| 788 | $local = acf_maybe_get( $post, 'local' ); |
| 789 | $modified = acf_maybe_get( $post, 'modified' ); |
| 790 | $private = acf_maybe_get( $post, 'private' ); |
| 791 | |
| 792 | if ( $private ) { |
| 793 | continue; |
| 794 | } |
| 795 | |
| 796 | if ( 'json' !== $local ) { |
| 797 | continue; |
| 798 | } |
| 799 | |
| 800 | // New item (not yet in database). |
| 801 | if ( ! $post['ID'] ) { |
| 802 | $syncable[ $post['key'] ] = $post; |
| 803 | continue; |
| 804 | } |
| 805 | |
| 806 | // Updated item (JSON is newer than database). |
| 807 | if ( $modified && $modified > get_post_modified_time( 'U', true, $post['ID'] ) ) { |
| 808 | $syncable[ $post['key'] ] = $post; |
| 809 | } |
| 810 | } |
| 811 | |
| 812 | return $syncable; |
| 813 | } |
| 814 | |
| 815 | /** |
| 816 | * Displays detailed status showing individual items that need syncing. |
| 817 | * |
| 818 | * @since 6.8 |
| 819 | * |
| 820 | * @param array $post_types List of post types to check. |
| 821 | * @param string $format Output format. |
| 822 | */ |
| 823 | private function display_detailed_status( $post_types, $format ) { |
| 824 | $rows = array(); |
| 825 | $total_pending = 0; |
| 826 | |
| 827 | foreach ( $post_types as $post_type ) { |
| 828 | $syncable = $this->get_syncable_items( $post_type ); |
| 829 | |
| 830 | foreach ( $syncable as $key => $post ) { |
| 831 | $action = $post['ID'] ? 'Update' : 'Create'; |
| 832 | ++$total_pending; |
| 833 | |
| 834 | $rows[] = array( |
| 835 | 'Key' => $key, |
| 836 | 'Title' => $post['title'], |
| 837 | 'Type' => $this->get_type_label( $post_type ), |
| 838 | 'Action' => $action, |
| 839 | ); |
| 840 | } |
| 841 | } |
| 842 | |
| 843 | if ( empty( $rows ) ) { |
| 844 | WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC ); |
| 845 | return; |
| 846 | } |
| 847 | |
| 848 | format_items( $format, $rows, array( 'Key', 'Title', 'Type', 'Action' ) ); |
| 849 | |
| 850 | if ( 'table' === $format ) { |
| 851 | WP_CLI::log( sprintf( '%d item(s) pending sync. Run `wp acf json sync` to apply changes.', $total_pending ) ); |
| 852 | } |
| 853 | } |
| 854 | |
| 855 | /** |
| 856 | * Displays a table of pending sync items for dry-run mode. |
| 857 | * |
| 858 | * @since 6.8 |
| 859 | * |
| 860 | * @param array $all_syncable The syncable items. |
| 861 | */ |
| 862 | private function display_dry_run( $all_syncable ) { |
| 863 | $rows = array(); |
| 864 | |
| 865 | foreach ( $all_syncable as $key => $item ) { |
| 866 | $post = $item['post']; |
| 867 | $action = $post['ID'] ? 'Update' : 'Create'; |
| 868 | |
| 869 | $rows[] = array( |
| 870 | 'Key' => $key, |
| 871 | 'Title' => $post['title'], |
| 872 | 'Type' => $this->get_type_label( $item['post_type'] ), |
| 873 | 'Action' => $action, |
| 874 | ); |
| 875 | } |
| 876 | |
| 877 | WP_CLI::log( sprintf( '%d item(s) pending sync:', count( $rows ) ) ); |
| 878 | format_items( 'table', $rows, array( 'Key', 'Title', 'Type', 'Action' ) ); |
| 879 | } |
| 880 | } |
| 881 |