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