Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions contentrain-bridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ) {
Expand Down
33 changes: 24 additions & 9 deletions includes/class-contentrain-bridge-source.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
6 changes: 5 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion tests/integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down
Loading