kind of text in the editor. */ private const FIELD_TEXTS = array( 'label' => 'label', 'instructions' => 'text', 'placeholder' => 'text', 'prepend' => 'text', 'append' => 'text', 'message' => 'paragraph', 'ui_on_text' => 'label', 'ui_off_text' => 'label', 'button_label' => 'button', ); /** * The field each stored value belongs to, by object and name, as found so far. * * @var array */ private $fields = array(); /** * The field group each field belongs to, by field key, as found so far. * * @var array */ private $groups = array(); /** * Prefixes of the option names under which options pages store their values, e.g. "options_". * * @var string[]|null */ private $prefixes = null; /** * Returns the builder's ID. */ public function id(): string { return 'acf'; } /** * Returns the builder's name. */ public function name(): string { return 'Advanced Custom Fields'; } /** * Whether ACF is active. */ public function isActive(): bool { return class_exists( '\ACF' ) && function_exists( 'acf_get_field' ); } /** * Fields do not build a page: the editor names only the builders that lay a page out. * * @param int $post_id Post ID. */ public function isUsed( int $post_id ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Same answer for every post. return false; } /** * Adds the location rule "SwiftLing language", and shows on a separate copy of a post the field * groups that name its original. */ public function register(): void { if ( did_action( 'acf/init' ) ) { $this->registerLocation(); } else { add_action( 'acf/init', array( $this, 'registerLocation' ) ); } // See acf_match_location_rule() in includes/locations.php. foreach ( array( 'post', 'page' ) as $param ) { add_filter( 'acf/location/match_rule/type=' . $param, array( $this, 'matchOriginal' ), 20, 4 ); } // The Translations box on an options page, under ACF's Publish box. ACF fires these on the // screens where it shows fields, and adds its own boxes on acf/input/admin_head // (ACF_Admin_Options_Page::admin_head() in pro/admin/admin-options-page.php). add_action( 'acf/input/admin_enqueue_scripts', array( $this, 'enqueueOptionsBox' ) ); add_action( 'acf/input/admin_head', array( $this, 'addOptionsBox' ) ); } /** * Loads the styles of the Translations box on an options page. */ public function enqueueOptionsBox(): void { if ( $this->currentOptionsSubject() ) { \SwiftLing\Admin\SubjectBox::enqueue(); } } /** * Adds the Translations box to the options page being shown, for someone who may translate it, * so the way to its translation starts where its fields are filled in. */ public function addOptionsBox(): void { $subject = $this->currentOptionsSubject(); if ( ! $subject ) { return; } add_meta_box( 'swiftling-translations', __( 'Translations', 'swiftling' ), static function () use ( $subject ) { \SwiftLing\Admin\SubjectBox::render( $subject ); }, 'acf_options_page', 'side' ); } /** * Returns the subject of the options page being shown in the admin, when the user may * translate it. */ private function currentOptionsSubject(): ?array { global $plugin_page; if ( ! is_admin() || ! is_string( $plugin_page ) || ! $this->optionsPage( $plugin_page ) ) { return null; } $subject = swiftling_subject( 'acf-options:' . $plugin_page ); return $subject && swiftling_can_translate_subject( $subject ) ? $subject : null; } /** * Registers the location rule "SwiftLing language", see AcfLanguageLocation. */ public function registerLocation(): void { if ( function_exists( 'acf_register_location_type' ) && class_exists( '\ACF_Location' ) ) { acf_register_location_type( AcfLanguageLocation::class ); } } /** * Matches the rules "Post is" and "Page is" on a separate copy as on its original, so the copy * shows the field groups of its original and its fields can be changed in it. Other rules, such * as the post type or the page template, already match: the copy has the same. * * @param bool $result Whether the rule matches, as ACF found. * @param array $rule Rule: param, operator, value. * @param array $screen What ACF shows the field groups for. * @param array $field_group Field group. * @return bool */ public function matchOriginal( $result, $rule, $screen, $field_group ) { $post = empty( $screen['post_id'] ) || ! is_numeric( $screen['post_id'] ) ? null : get_post( (int) $screen['post_id'] ); $original = $post ? (int) swiftling_copy_original_id( $post ) : 0; $location = $original && function_exists( 'acf_get_location_type' ) ? acf_get_location_type( (string) $rule['param'] ) : null; if ( ! $location ) { return $result; } $screen['post_id'] = $original; return (bool) $location->match( $rule, $screen, $field_group ); } /** * Shows the texts of fields translated on a translated page: labels, choices, default values. * * ACF keeps a field once it is loaded (acf_get_field() in includes/acf-field-functions.php keeps * it in the store "fields"), so fields loaded before the translated view started are loaded again. * * @param string $mode probe, control or translate. * @param bool $probe Whether the request is a page check. */ public function start( string $mode, bool $probe ): void { if ( 'translate' !== $mode ) { return; } if ( function_exists( 'acf_get_store' ) ) { acf_get_store( 'fields' )->reset(); } add_filter( 'acf/load_field', array( $this, 'translateField' ), PHP_INT_MAX ); } /** * Puts the translations of a field's texts in place, see self::FIELD_TEXTS: its label, the labels * of its choices and of its layouts, and the default value of a text field. The value a choice * stores stays as it is. * * @param array|mixed $field Field. * @return array|mixed */ public function translateField( $field ) { if ( ! is_array( $field ) || empty( $field['key'] ) ) { return $field; } $key = $this->originalKey( $field ); $owner = $this->groupOwner( $key ); if ( '' === $owner ) { return $field; } foreach ( $this->settingTexts( $field ) as $path => list( $setting, $value ) ) { $field[ $setting ] = swiftling_transform_text( $owner, $path, 'acf-field:' . $setting, $value ); } if ( in_array( $field['type'], self::CHOICE_TYPES, true ) && ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) { foreach ( $field['choices'] as $value => $label ) { if ( is_string( $label ) ) { $field['choices'][ $value ] = swiftling_transform_text( $owner, $this->choicePath( $key, (string) $value ), 'acf-field:choice', $label ); } } } if ( 'flexible_content' === $field['type'] && ! empty( $field['layouts'] ) && is_array( $field['layouts'] ) ) { foreach ( $field['layouts'] as $index => $layout ) { if ( isset( $layout['key'], $layout['label'] ) && is_string( $layout['label'] ) ) { $field['layouts'][ $index ]['label'] = swiftling_transform_text( $owner, 'field/' . swiftling_escape_segment( $key ) . '/layouts/' . swiftling_escape_segment( (string) $layout['key'] ), 'acf-field:layout', $layout['label'] ); } } } return $field; } /** * Whether ACF declares a stored value as text, from the type of its field. See * Builder::fieldHoldsText(). * * Only the value as a whole counts: a link field keeps an address, a title and a target in one * value, and only its title is declared text; its address is left to the probe, as any address. * * @param string $owner Object key of the value. * @param string $path Path of the value inside its object. */ public function fieldHoldsText( string $owner, string $path ): ?bool { $found = $this->storedField( $owner, $path ); if ( null === $found ) { return null; } list( $field, $rest ) = $found; $type = (string) $field['type']; if ( in_array( $type, self::TEXT_TYPES, true ) ) { return '' === $rest ? true : null; } if ( 'link' === $type ) { if ( 'title' === $rest ) { return true; } return 'url' === $rest ? null : false; } if ( in_array( $type, self::NO_TEXT_TYPES, true ) ) { return false; } return null; } /** * Returns the label of the field a stored value belongs to, with the rows it sits in, such as * "Team members 2 › Name". * * @param string $owner Object key of the value. * @param string $path Path of the value inside its object. */ public function fieldLabel( string $owner, string $path ): string { $found = $this->storedField( $owner, $path ); if ( null === $found ) { return ''; } list( $field, , $name ) = $found; // The fields above this one, outermost first, each with the row the value sits in. $chain = array(); $above = $field; for ( $depth = 0; $depth < 10; $depth++ ) { $above = $this->parentField( $above ); if ( ! $above ) { break; } array_unshift( $chain, $above ); } $parts = array(); $offset = 0; foreach ( $chain as $ancestor ) { $label = '' !== (string) $ancestor['label'] ? (string) $ancestor['label'] : (string) $ancestor['name']; $start = (string) $ancestor['name'] . '_'; if ( '' !== (string) $ancestor['name'] && substr( $name, $offset, strlen( $start ) ) === $start ) { $offset += strlen( $start ); if ( in_array( $ancestor['type'], array( 'repeater', 'flexible_content' ), true ) && preg_match( '/^(\d+)_/', substr( $name, $offset ), $row ) ) { $offset += strlen( $row[0] ); /* translators: 1: name of a repeating field, e.g. Team members, 2: number of the row, e.g. 2. */ $label = sprintf( __( '%1$s %2$s', 'swiftling' ), $label, number_format_i18n( (int) $row[1] + 1 ) ); } } $parts[] = $label; } $parts[] = '' !== (string) $field['label'] ? (string) $field['label'] : (string) $field['name']; return implode( ' › ', $parts ); } /** * Returns a subject of the editor: an options page ("acf-options:") or the texts of a * field group ("acf-fields:"). * * @param string $key Subject key. */ public function subject( string $key ): ?array { if ( 0 === strpos( $key, 'acf-options:' ) ) { $page = $this->optionsPage( substr( $key, 12 ) ); if ( ! $page ) { return null; } return array( 'key' => $key, 'kind' => 'fields', 'title' => (string) $page['page_title'], 'type' => __( 'Options page', 'swiftling' ), 'url' => '', 'probe' => false, 'post' => null, 'cap' => (string) $page['capability'], 'note' => __( 'Every field of this options page that holds text. What the site shows from it, such as the footer, shows translated on every page.', 'swiftling' ), ); } if ( 0 === strpos( $key, 'acf-fields:' ) ) { $group = acf_get_field_group( substr( $key, 11 ) ); if ( ! $group ) { return null; } return array( 'key' => 'acf-fields:' . $group['key'], 'kind' => 'fields', 'title' => (string) $group['title'], 'type' => __( 'Labels and choices of a field group', 'swiftling' ), 'url' => '', 'probe' => false, 'post' => null, 'cap' => (string) acf_get_setting( 'capability' ), 'note' => __( 'The labels, choices and default values of these fields, as visitors see them where the theme or a form shows them. What a page stores in the fields is translated with the page.', 'swiftling' ), ); } return null; } /** * Returns the options pages and field groups, for the Templates screen. */ public function subjects(): array { $pages = array(); foreach ( $this->optionsPages() as $slug => $page ) { // The field groups it shows, where they are named other than the page. $names = array_diff( wp_list_pluck( acf_get_field_groups( array( 'options_page' => $slug ) ), 'title' ), array( (string) $page['page_title'] ) ); $pages[] = array( 'key' => 'acf-options:' . $slug, 'title' => (string) $page['page_title'], /* translators: %l: names of field groups, e.g. Header and Footer. */ 'description' => $names ? wp_sprintf( __( 'Fields: %l', 'swiftling' ), array_values( $names ) ) : '', ); } $groups = array(); foreach ( acf_get_field_groups() as $group ) { $groups[] = array( 'key' => 'acf-fields:' . $group['key'], 'title' => (string) $group['title'], 'description' => empty( $group['active'] ) ? __( 'Inactive: shows only where another group clones its fields.', 'swiftling' ) : '', ); } $subjects = array(); if ( $pages ) { $subjects[] = array( 'title' => __( 'Options pages', 'swiftling' ), 'description' => __( 'Settings pages made with ACF, such as the texts of the header and the footer.', 'swiftling' ), 'subjects' => $pages, ); } if ( $groups ) { $subjects[] = array( 'title' => __( 'Labels and choices of field groups', 'swiftling' ), 'description' => __( 'Where the theme or a form shows the label of a field or the name of a choice.', 'swiftling' ), 'subjects' => $groups, ); } return $subjects; } /** * Returns the texts ACF keeps for a subject: the fields of a post or of an options page, read as * ACF reads them, or the texts of a field group. * * @param array $subject Subject, see swiftling_subject(). */ public function listTexts( array $subject ): array { if ( 'post' === $subject['kind'] && $subject['post'] instanceof \WP_Post ) { $post_id = $subject['post']->ID; return $this->valueTexts( $post_id, acf_get_field_groups( array( 'post_id' => $post_id ) ) ); } if ( 0 === strpos( $subject['key'], 'acf-options:' ) ) { $slug = substr( $subject['key'], 12 ); $page = $this->optionsPage( $slug ); return $page ? $this->valueTexts( (string) $page['post_id'], acf_get_field_groups( array( 'options_page' => $slug ) ) ) : array(); } if ( 0 === strpos( $subject['key'], 'acf-fields:' ) ) { return $this->groupTexts( substr( $subject['key'], 11 ) ); } return array(); } /** * Returns the texts stored in the fields of some field groups, for a post or an options page. * * Every field is loaded as ACF loads it, with acf_get_value(), which loads the rows of * repeaters and flexible content and the fields of groups and clones by itself, while * swiftling_collect() notes each value read. So every text gets the object and path under which * the translated view finds it, without this class knowing how ACF names the values of rows. * Files of the media library a field shows are listed too, as the probe lists them where a page * shows them: their address and, for an image, its alt text. * * @param int|string $post_id ACF's post ID: a post's ID, or the post ID of an options page. * @param array $groups Field groups. * @return array Texts, see Builder::listTexts(). */ private function valueTexts( $post_id, array $groups ): array { $fields = array(); foreach ( $groups as $group ) { array_push( $fields, ...acf_get_fields( $group ) ); } if ( ! $fields ) { return array(); } $media = array(); $recorder = static function ( $value, $value_post_id, $field ) use ( &$media ) { if ( isset( $field['type'] ) && in_array( $field['type'], self::MEDIA_TYPES, true ) ) { foreach ( (array) $value as $id ) { if ( is_numeric( $id ) && (int) $id > 0 ) { $media[ (int) $id ] = true; } } } return $value; }; // Values ACF already holds would not be read again, and the values read here carry markers. acf_get_store( 'values' )->reset(); add_filter( 'acf/load_value', $recorder, PHP_INT_MAX, 3 ); try { $leaves = swiftling_collect( static function () use ( $fields, $post_id ) { foreach ( $fields as $field ) { acf_get_value( $post_id, $field ); } } ); } finally { remove_filter( 'acf/load_value', $recorder, PHP_INT_MAX ); acf_get_store( 'values' )->reset(); } $texts = array(); foreach ( $leaves as $leaf ) { $found = $this->storedField( $leaf['o'], $leaf['p'] ); $kind = null === $found ? '' : $this->textKind( $found[0], $found[1], $leaf['v'] ); if ( '' !== $kind ) { $texts[] = array( 'o' => $leaf['o'], 'p' => $leaf['p'], 'v' => $leaf['v'], 'r' => $leaf['r'], 'kind' => $kind, 'place' => $this->fieldLabel( $leaf['o'], $leaf['p'] ), ); } } foreach ( array_keys( $media ) as $id ) { array_push( $texts, ...swiftling_media_texts( $id ) ); } return $texts; } /** * Returns what kind of text a stored value is, from its field, or nothing when it is no text: * a text, a paragraph, the text of a link, or an address. * * @param array $field Field. * @param string $rest Path of the value inside the field's value, e.g. "title" in a link. * @param string $value Value. */ private function textKind( array $field, string $rest, string $value ): string { $type = (string) $field['type']; $address = swiftling_is_url_like( $value ) ? swiftling_media_kind( $value ) : ''; $address = swiftling_is_url_like( $value ) && '' === $address ? 'url' : $address; if ( in_array( $type, self::TEXT_TYPES, true ) && '' === $rest ) { // A full address typed into a text field is an address all the same. if ( '' !== $address && 1 === preg_match( '~^(?:https?:)?//~i', $value ) ) { return $address; } if ( 1 !== preg_match( '/\p{L}/u', $value ) ) { return ''; } return 'text' === $type ? 'text' : 'paragraph'; } if ( 'link' === $type && 'title' === $rest && 1 === preg_match( '/\p{L}/u', $value ) ) { return 'link'; } if ( ( 'link' === $type && 'url' === $rest ) || ( in_array( $type, self::ADDRESS_TYPES, true ) && '' === $rest ) ) { return $address; } return ''; } /** * Returns the texts of a field group's fields, see self::FIELD_TEXTS, and of their choices and * layouts, also of the fields inside repeaters, groups and flexible content. A clone shows the * fields of another group, whose texts are that group's. * * @param string $group_key Field group key. * @return array Texts, see Builder::listTexts(). */ private function groupTexts( string $group_key ): array { $texts = array(); $owner = 'acf:' . $group_key; $walk = function ( array $fields, string $above ) use ( &$walk, &$texts, $owner ) { foreach ( $fields as $field ) { if ( empty( $field['key'] ) ) { continue; } $key = $this->originalKey( $field ); $label = '' !== (string) $field['label'] ? (string) $field['label'] : (string) $field['name']; $place = ltrim( $above . ' › ' . $label, ' ›' ); foreach ( $this->settingTexts( $field ) as $path => list( $setting, $value ) ) { $texts[] = array( 'o' => $owner, 'p' => $path, 'v' => $value, 'r' => swiftling_rule_key( 'acf-field:' . $setting, '', '' ), 'kind' => 'default_value' === $setting ? $this->defaultKind( $field ) : self::FIELD_TEXTS[ $setting ], 'place' => 'label' === $setting ? $above : $place, ); } if ( in_array( $field['type'], self::CHOICE_TYPES, true ) && ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) { foreach ( $field['choices'] as $value => $choice ) { if ( is_string( $choice ) && 1 === preg_match( '/\p{L}/u', $choice ) ) { $texts[] = array( 'o' => $owner, 'p' => $this->choicePath( $key, (string) $value ), 'v' => $choice, 'r' => swiftling_rule_key( 'acf-field:choice', '', '' ), 'kind' => 'label', 'place' => $place, ); } } } if ( 'clone' === $field['type'] ) { continue; } if ( ! empty( $field['sub_fields'] ) && is_array( $field['sub_fields'] ) ) { $walk( $field['sub_fields'], $place ); } if ( 'flexible_content' === $field['type'] && ! empty( $field['layouts'] ) && is_array( $field['layouts'] ) ) { foreach ( $field['layouts'] as $layout ) { if ( empty( $layout['key'] ) ) { continue; } if ( is_string( $layout['label'] ) && 1 === preg_match( '/\p{L}/u', $layout['label'] ) ) { $texts[] = array( 'o' => $owner, 'p' => 'field/' . swiftling_escape_segment( $key ) . '/layouts/' . swiftling_escape_segment( (string) $layout['key'] ), 'v' => $layout['label'], 'r' => swiftling_rule_key( 'acf-field:layout', '', '' ), 'kind' => 'label', 'place' => $place, ); } if ( ! empty( $layout['sub_fields'] ) && is_array( $layout['sub_fields'] ) ) { $walk( $layout['sub_fields'], $place . ' › ' . $layout['label'] ); } } } } }; $walk( acf_get_fields( $group_key ), '' ); return $texts; } /** * Returns the texts among a field's settings that a visitor can read, see self::FIELD_TEXTS, * and the default value of a text field, keyed by their path in the field group's object. * * @param array $field Field. * @return array Path => setting, text. */ private function settingTexts( array $field ): array { $key = $this->originalKey( $field ); $settings = array_keys( self::FIELD_TEXTS ); if ( in_array( $field['type'], self::TEXT_TYPES, true ) ) { $settings[] = 'default_value'; } $texts = array(); foreach ( $settings as $setting ) { $value = isset( $field[ $setting ] ) ? $field[ $setting ] : ''; if ( is_string( $value ) && 1 === preg_match( '/\p{L}/u', $value ) ) { $texts[ 'field/' . swiftling_escape_segment( $key ) . '/' . $setting ] = array( $setting, $value ); } } return $texts; } /** * Returns the kind of text a field's default value is: as its value would be. * * @param array $field Field. */ private function defaultKind( array $field ): string { return 'text' === $field['type'] ? 'text' : 'paragraph'; } /** * Returns the path of a choice's label in the field group's object. * * @param string $key Field key. * @param string $value Value of the choice. */ private function choicePath( string $key, string $value ): string { return 'field/' . swiftling_escape_segment( $key ) . '/choices/' . swiftling_escape_segment( $value ); } /** * Returns the key of the field as its field group defines it. A field shown through a clone has * a key made of the clone's and its own; ACF keeps its own as "__key". * * @param array $field Field. */ private function originalKey( array $field ): string { return isset( $field['__key'] ) && is_string( $field['__key'] ) ? $field['__key'] : (string) $field['key']; } /** * Returns the object under which the texts of a field's settings are kept: its field group, * as "acf:". Empty when the field belongs to no group, as a field made in code * without one. * * @param string $key Field key. */ private function groupOwner( string $key ): string { if ( ! isset( $this->groups[ $key ] ) ) { $owner = ''; $field = $this->rawField( $key ); for ( $depth = 0; $field && $depth < 10; $depth++ ) { $above = $field['parent'] ?? ''; $group = $this->groupKey( $above ); if ( '' !== $group ) { $owner = 'acf:' . $group; break; } $field = $this->rawField( $above ); } if ( ! did_action( 'acf/init' ) ) { return $owner; } $this->groups[ $key ] = $owner; } return $this->groups[ $key ]; } /** * Returns a field as stored, without running ACF's filters for loaded fields, which include * SwiftLing's own. * * @param int|string $id Field key, or the ID of its post. * @return array|null */ private function rawField( $id ): ?array { if ( is_string( $id ) && acf_is_local_field( $id ) ) { $field = acf_get_local_field( $id ); } elseif ( ( is_string( $id ) && acf_is_field_key( $id ) ) || ( is_numeric( $id ) && 'acf-field' === get_post_type( (int) $id ) ) ) { $field = acf_get_raw_field( $id ); } else { $field = null; } return is_array( $field ) ? $field : null; } /** * Returns the key of the field group a field's parent names, when the parent is a field group. * * @param int|string $above Parent of a field: the key or post ID of a field or a field group. */ private function groupKey( $above ): string { if ( is_string( $above ) && 0 === strpos( $above, 'group_' ) ) { return $above; } if ( is_numeric( $above ) && 'acf-field-group' === get_post_type( (int) $above ) ) { return (string) get_post_field( 'post_name', (int) $above ); } return ''; } /** * Returns the field a field sits in: a repeater, group, flexible content or clone; null for a * field at the top of its group. * * @param array $field Field. */ private function parentField( array $field ): ?array { $above = $field['parent'] ?? ''; return '' === $this->groupKey( $above ) ? $this->rawField( $above ) : null; } /** * Returns the field a stored value belongs to, from the key ACF stores next to the value. * * @param string $owner Object key: "post:", "term:" for a field of a term, or "options" for options pages. * @param string $path Path of the value: "meta/…" or "option/