diff --git a/contentrain-bridge.php b/contentrain-bridge.php index 333f965..dd00773 100644 --- a/contentrain-bridge.php +++ b/contentrain-bridge.php @@ -3,7 +3,7 @@ * Plugin Name: Contentrain Bridge * Plugin URI: https://github.com/Contentrain/wordpress-bridge * Description: Model and export WordPress content and interface text as Contentrain JSON/Markdown, with optional GitHub delivery. - * Version: 0.6.0 + * Version: 0.6.1 * Requires at least: 6.2 * Requires PHP: 7.4 * Author: Contentrain @@ -16,7 +16,7 @@ */ defined( 'ABSPATH' ) || exit; -define( 'CONTENTRAIN_BRIDGE_VERSION', '0.6.0' ); +define( 'CONTENTRAIN_BRIDGE_VERSION', '0.6.1' ); define( 'CONTENTRAIN_BRIDGE_FILE', __FILE__ ); foreach ( array( 'policy', 'files', 'exporter', 'menus', 'source', 'inventory', 'delta', 'seo-render', 'seo', 'redirects', 'routing', 'scanner', 'text', 'coverage', 'integrations', 'acf', 'models', 'validator', 'rawir', 'jobs', 'github', 'key', 'remote', 'admin' ) as $contentrain_bridge_class ) { diff --git a/includes/class-contentrain-bridge-source.php b/includes/class-contentrain-bridge-source.php index 27325cf..513e345 100644 --- a/includes/class-contentrain-bridge-source.php +++ b/includes/class-contentrain-bridge-source.php @@ -315,22 +315,37 @@ public static function acf_value( $field, $value, &$excluded, $path ) { foreach ( $field['layouts'] ?? array() as $layout ) { $sub_fields = array_merge( $sub_fields, $layout['sub_fields'] ?? array() ); } + $by_key = array(); + foreach ( $sub_fields as $sub ) { + $by_key[ (string) ( $sub['key'] ?? '' ) ] = $sub; + } + $by_name = array(); + foreach ( $sub_fields as $sub ) { + $by_name[ (string) ( $sub['name'] ?? '' ) ] = $sub; + } foreach ( $rows as &$row ) { if ( ! is_array( $row ) ) { continue; } - foreach ( $sub_fields as $sub ) { - foreach ( array_unique( array( $sub['name'] ?? '', $sub['key'] ?? '' ) ) as $key ) { - if ( array_key_exists( $key, $row ) ) { - $clean = self::acf_value( $sub, $row[ $key ], $excluded, $path . '/' . $key ); - if ( null === $clean ) { - unset( $row[ $key ] ); - } else { - $row[ $key ] = $clean; - } + // ACF hands unformatted rows keyed by sub-field KEY (`field_5f3…`); the REST API and every + // editor know the NAME. Rows leave here under names, at every depth, so both read alike. + $named = array(); + foreach ( $row as $key => $cell ) { + $sub = $by_name[ (string) $key ] ?? $by_key[ (string) $key ] ?? null; + if ( ! $sub ) { + if ( is_string( $key ) && preg_match( '/^field_/', $key ) ) { + $excluded[] = array( 'source' => $path . '/' . $key, 'reason' => 'acf-key-unmapped' ); } + $named[ $key ] = $cell; + continue; + } + $name = '' !== (string) ( $sub['name'] ?? '' ) ? $sub['name'] : $key; + $clean = self::acf_value( $sub, $cell, $excluded, $path . '/' . $name ); + if ( null !== $clean ) { + $named[ $name ] = $clean; } } + $row = $named; } unset( $row ); $value = in_array( $field['type'] ?? '', array( 'repeater', 'flexible_content' ), true ) ? $rows : $rows[0]; diff --git a/readme.txt b/readme.txt index f37e18e..090dc24 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: export, migration, headless, astro, content Requires at least: 6.2 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 0.6.0 +Stable tag: 0.6.1 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -76,6 +76,10 @@ The plugin stores export ownership and a source revision marker. It sends nothin == Changelog == += 0.6.1 = + +* Advanced Custom Fields repeaters, groups and flexible content now reach the migration data under their field names (`label`, `url`), not ACF's internal keys (`field_5f3a…`), so they match what the REST API gives and the migrated fields are readable in Studio. Fields on posts and options pages are both affected. A key no field owns is kept as it is and reported. + = 0.6.0 = * Advanced Custom Fields options pages ("Site settings", footer, contact details) are exported as site-wide fields in the migration data, typed like the fields of a post. They were already in the export as their own entry; a migration could not use them until now. diff --git a/tests/integration.php b/tests/integration.php index bed2cde..6cedf56 100644 --- a/tests/integration.php +++ b/tests/integration.php @@ -571,10 +571,24 @@ function rejects( $callback, $message ) { $redactions = array(); $private_schema = array( 'type' => 'group', 'name' => 'public_group', 'sub_fields' => array( array( 'key' => 'field_opaque', 'name' => 'connection', 'type' => 'password' ), array( 'key' => 'field_heading', 'name' => 'heading', 'type' => 'text' ) ) ); $clean_acf = Source::acf_value( $private_schema, array( 'field_opaque' => 'hidden-credential', 'field_heading' => 'Safe headline' ), $redactions, 'acf' ); -check( ! isset( $clean_acf['field_opaque'] ) && 'Safe headline' === $clean_acf['field_heading'], 'nested ACF password is removed by its type even behind an opaque field key' ); +check( ! isset( $clean_acf['field_opaque'] ) && ! isset( $clean_acf['connection'] ) && 'Safe headline' === $clean_acf['heading'] && ! isset( $clean_acf['field_heading'] ), 'nested ACF password is removed by its type even behind an opaque field key, and the rest comes out under its name' ); $team_schema = array( 'type' => 'repeater', 'name' => 'team', 'sub_fields' => array( array( 'key' => 'field_team_name', 'name' => 'name', 'type' => 'text' ), array( 'key' => 'field_team_email', 'name' => 'email', 'type' => 'email' ), array( 'key' => 'field_team_secret', 'name' => 'secret', 'type' => 'text' ) ) ); $clean_team = Source::acf_value( $team_schema, array( array( 'name' => 'Ada', 'email' => 'ada@example.test', 'secret' => 'planted' ) ), $redactions, 'acf' ); check( 'ada@example.test' === $clean_team[0]['email'] && ! isset( $clean_team[0]['secret'] ), 'a nested ACF email sub-field is content; a secret-named one is not' ); +// Rows come from ACF keyed by sub-field key; they leave under names, at every depth. +$keyed = array(); +$soc_schema = array( 'type' => 'repeater', 'name' => 'socials', 'sub_fields' => array( array( 'key' => 'field_soc_label', 'name' => 'label', 'type' => 'text' ), array( 'key' => 'field_soc_url', 'name' => 'url', 'type' => 'url' ) ) ); +$clean_soc = Source::acf_value( $soc_schema, array( array( 'field_soc_label' => 'LinkedIn', 'field_soc_url' => 'https://example.test/in' ), array( 'label' => 'Named', 'url' => 'https://example.test/n' ) ), $keyed, 'acf/socials' ); +check( array( array( 'label' => 'LinkedIn', 'url' => 'https://example.test/in' ), array( 'label' => 'Named', 'url' => 'https://example.test/n' ) ) === $clean_soc && array() === $keyed, 'a repeater row keyed by sub-field key comes out under names; a row already named is unchanged' ); +$group_schema = array( 'type' => 'group', 'name' => 'office', 'sub_fields' => array( array( 'key' => 'field_o_city', 'name' => 'city', 'type' => 'text' ), array( 'key' => 'field_o_phones', 'name' => 'phones', 'type' => 'repeater', 'sub_fields' => array( array( 'key' => 'field_o_num', 'name' => 'number', 'type' => 'text' ) ) ) ) ); +$clean_group = Source::acf_value( $group_schema, array( 'field_o_city' => 'Istanbul', 'field_o_phones' => array( array( 'field_o_num' => '+90 212 000 00 00' ) ) ), $keyed, 'acf/office' ); +check( array( 'city' => 'Istanbul', 'phones' => array( array( 'number' => '+90 212 000 00 00' ) ) ) === $clean_group, 'a group and the repeater nested in it come out under names' ); +$flex_keyed = array( 'type' => 'flexible_content', 'name' => 'blocks', 'layouts' => array( array( 'name' => 'quote', 'sub_fields' => array( array( 'key' => 'field_q_text', 'name' => 'text', 'type' => 'text' ) ) ) ) ); +$clean_flex = Source::acf_value( $flex_keyed, array( array( 'acf_fc_layout' => 'quote', 'field_q_text' => 'Hello' ) ), $keyed, 'acf/blocks' ); +check( array( array( 'acf_fc_layout' => 'quote', 'text' => 'Hello' ) ) === $clean_flex && array() === $keyed, 'a flexible content row keyed by key comes out under names and keeps its layout marker, uncounted' ); +$unknown = array(); +$clean_unknown = Source::acf_value( $soc_schema, array( array( 'field_soc_label' => 'LinkedIn', 'field_gone' => 'orphan' ) ), $unknown, 'acf/socials' ); +check( 'LinkedIn' === $clean_unknown[0]['label'] && 'orphan' === $clean_unknown[0]['field_gone'] && array( array( 'source' => 'acf/socials/field_gone', 'reason' => 'acf-key-unmapped' ) ) === $unknown, 'a key no sub-field owns stays as it is and is counted, not given a name' ); check( array() === Policy::clean( array( 'customer_email' => 'x@example.test' ), $redactions, 'meta' ), 'unknown meta keeps the broad name rule' ); foreach ( array( 'user_pass', 'apiKey', 'access_token', 'client-secret', 'credentials', 'private_key' ) as $name ) { check( Policy::secret_name( $name ), $name . ' is a credential name' );