PluginProbe ʕ •ᴥ•ʔ
Advanced Custom Fields (ACF®) / 6.8.5
Advanced Custom Fields (ACF®) v6.8.5
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 6.0.1 6.0.2 6.0.3 6.0.4 6.0.5 6.0.6 6.0.7 6.1.0 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.1.6 6.1.7 6.1.8 6.2.0 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.2.6 6.2.6.1 6.2.7 6.2.8 6.2.9 6.3.0 6.3.1 6.3.10.2 6.3.11 6.3.12 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.6.1 6.4.0 6.4.0.1 6.4.1 6.4.2 6.4.3 6.5.0 6.5.1 6.6.0 6.6.1 6.6.2 6.7.0 6.7.1 6.7.2 6.8.0 trunk 1.0.0 1.0.2 1.0.3 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.1.1 2.1.3 2.1.4 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.6 3.0.7 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 3.1.8 3.1.9 3.2.0 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.3.8 3.3.9 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.5 3.5.6 3.5.7 3.5.8 4.0.0 4.0.1 4.0.2 4.0.3 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.8 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.3.7 4.3.8 4.3.9 4.4.0 4.4.1 4.4.10 4.4.11 4.4.12 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 5.10 5.10.1 5.10.2 5.11 5.11.1 5.11.2 5.11.3 5.11.4 5.12 5.12.1 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.6.10 5.6.2 5.6.3 5.6.4 5.6.5 5.6.6 5.6.7 5.6.8 5.6.9 5.7.0 5.7.1 5.7.10 5.7.12 5.7.13 5.7.2 5.7.3 5.7.4 5.7.5 5.7.6 5.7.7 5.7.8 5.7.9 5.8.0 5.8.1 5.8.10 5.8.11 5.8.12 5.8.13 5.8.14 5.8.2 5.8.3 5.8.4
advanced-custom-fields / src / CLI / JsonCommand.php
advanced-custom-fields / src / CLI Last commit date
CLI.php 5 months ago JsonCommand.php 5 months ago
JsonCommand.php
883 lines
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