PluginProbe ʕ •ᴥ•ʔ
Secure Custom Fields / trunk
Secure Custom Fields vtrunk
6.9.1 6.9.0 6.8.9 6.8.7 6.8.8 6.8.6 6.8.4 6.8.5 trunk 6.4.0-beta1 6.4.0-beta2 6.4.1 6.4.1-beta3 6.4.1-beta4 6.4.1-beta5 6.4.1-beta6 6.4.1-beta7 6.4.2 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.6.0 6.7.0 6.7.1 6.8.0 6.8.1 6.8.2 6.8.3
secure-custom-fields / src / CLI / JsonCommand.php
secure-custom-fields / src / CLI Last commit date
CLI.php 2 months ago JsonCommand.php 2 months ago
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