$field ) { if ( is_array( $field ) ) { $schema['fields'][ $index ]['extensions'] = Extensions::portable_field( (array) ( $field['extensions'] ?? array() ), $field, $schema ); } } return $schema; } /** * Forms the current user may export, by name. * * @return array */ public static function exportable() { $query = new \WP_Query( array( 'post_type' => 'accessible_form', 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 'posts_per_page' => self::LIST_LIMIT, 'orderby' => array( 'title' => 'ASC', 'ID' => 'ASC', ), 'no_found_rows' => true, ) ); $forms = array(); foreach ( $query->posts as $post ) { if ( current_user_can( 'edit_post', $post->ID ) ) { $forms[] = array( 'id' => (int) $post->ID, 'title' => $post->post_title ?: __( 'Untitled form', 'accessible-forms-by-accessible-org' ), 'status' => $post->post_status, ); } } return $forms; } /** * Export forms. A published form exports its published version, not unpublished * edits, so the file matches what visitors see. * * @param int[] $ids Form IDs. * @param bool $with_recipients Include the notification recipients. * @return array[] Forms for Package::build(). */ public static function export( array $ids, $with_recipients = false ) { $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ); $placements = self::placements( $ids ); $forms = array(); foreach ( $ids as $id ) { $post = get_post( $id ); if ( ! $post || 'accessible_form' !== $post->post_type || 'trash' === $post->post_status || ! current_user_can( 'edit_post', $id ) ) { continue; } $published = in_array( $post->post_status, array( 'publish', 'future' ), true ); $schema = ( $published ? Schema::get_live( $id ) : null ) ?: Schema::get( $id, true ); if ( ! $schema ) { continue; } $form = array( 'key' => FormKey::get( $id ), 'title' => $post->post_title, 'status' => $published ? 'publish' : 'draft', 'schema' => self::portable( $schema ), ); if ( $with_recipients ) { $form['recipients'] = self::emails( $schema['recipients'] ?? array() ); } $page = absint( $schema['confirmation_page'] ?? 0 ); if ( $page && 'publish' === get_post_status( $page ) ) { // The slug path finds the page on another site whatever its permalinks. $form['confirmation_page'] = array( 'uri' => (string) get_page_uri( $page ), 'path' => self::path( $page ), 'title' => get_the_title( $page ), ); } if ( ! empty( $placements[ $id ] ) ) { $form['placements'] = $placements[ $id ]; } $forms[] = $form; Audit::record( $with_recipients ? 'form_exported_recipients' : 'form_exported', $id ); } return $forms; } /** * Where each form is placed in published content, from one bounded pass. Only * informational: the importer shows it and never places anything by itself. * * @param int[] $ids Form IDs. * @return array */ public static function placements( array $ids ) { $wanted = array_flip( $ids ); $found = array(); if ( ! $wanted ) { return $found; } for ( $page = 1; $page <= 10; $page++ ) { $query = new \WP_Query( array( 'post_type' => array_merge( array_keys( ContentTypes::types() ), array( 'wp_block', 'wp_template', 'wp_template_part' ) ), 'post_status' => 'publish', 'posts_per_page' => 100, 'paged' => $page, 'orderby' => 'ID', 'order' => 'ASC', ) ); foreach ( $query->posts as $post ) { foreach ( array_unique( self::forms_in( $post->post_content ) ) as $form_id ) { if ( isset( $wanted[ $form_id ] ) && count( $found[ $form_id ] ?? array() ) < self::PLACEMENT_LIMIT ) { $found[ $form_id ][] = array( 'type' => $post->post_type, 'path' => self::path( $post->ID ), 'title' => $post->post_title, ); } } } if ( $page >= $query->max_num_pages ) { break; } } return $found; } /** * Every form a piece of content embeds, through the block or the shortcode. * * @param string $content Post content. * @return int[] */ public static function forms_in( $content ) { $ids = array(); if ( ! is_string( $content ) || ( false === strpos( $content, 'accessible_form' ) && false === strpos( $content, 'accessible-forms/form' ) ) ) { return $ids; } if ( preg_match_all( '/' . get_shortcode_regex( array( 'accessible_form' ) ) . '/s', $content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { $attrs = '[' === $match[1] && ']' === $match[6] ? null : shortcode_parse_atts( $match[3] ); if ( is_array( $attrs ) ) { $ids[] = FormKey::resolve( $attrs['id'] ?? 0, $attrs['key'] ?? '' ); } } } if ( preg_match_all( '##s', $content, $blocks ) ) { foreach ( $blocks[1] as $json ) { $attrs = json_decode( $json, true ); if ( is_array( $attrs ) ) { $ids[] = FormKey::resolve( $attrs['formId'] ?? 0, $attrs['formKey'] ?? '' ); } } } return array_values( array_filter( $ids ) ); } /** * What importing each form in a file would do. * * @param array $package Decoded file. * @return array[] One row per form. */ public static function review( array $package ) { $rows = array(); foreach ( (array) ( $package['forms'] ?? array() ) as $index => $form ) { if ( is_array( $form ) ) { $rows[] = self::review_form( $form, (int) $index ); } } return $rows; } private static function review_form( array $form, $index ) { $incoming = is_array( $form['schema'] ?? null ) ? $form['schema'] : array(); $warnings = array(); $inactive = Extensions::inactive( $incoming['extensions'] ?? array() ); foreach ( (array) ( $incoming['fields'] ?? array() ) as $field ) { if ( is_array( $field ) ) { $inactive = array_merge( $inactive, Extensions::inactive( $field['extensions'] ?? array() ) ); } } foreach ( array_unique( $inactive ) as $slug ) { $warnings[] = sprintf( /* translators: %s: add-on identifier, such as "pro". */ __( 'Settings for the add-on ā€œ%sā€ were left out: it is not active on this site, so they cannot be checked. Activate it and import again to keep them.', 'accessible-forms-by-accessible-org' ), $slug ); } // Applying the add-ons' own rules to the incoming data drops anything they would // not export themselves, such as webhook destinations. $schema = self::portable( $incoming ); // Recipients are chosen in the review; a placeholder lets every other rule run. $schema['recipients'] = array( 'review@example.com' ); [ $clean, $errors ] = Schema::sanitize( $schema ); $clean['recipients'] = array(); $key = FormKey::valid( $form['key'] ?? '' ) ? $form['key'] : ''; $match = $key ? FormKey::find( $key ) : 0; if ( $match && ! current_user_can( 'edit_post', $match ) ) { $match = 0; } $title = sanitize_text_field( is_string( $form['title'] ?? null ) ? $form['title'] : '' ); $title = '' !== $title ? mb_substr( $title, 0, 200 ) : $clean['heading']; $page = 0; if ( is_array( $form['confirmation_page'] ?? null ) ) { $wanted = is_string( $form['confirmation_page']['uri'] ?? null ) && '' !== $form['confirmation_page']['uri'] ? $form['confirmation_page']['uri'] : ( is_string( $form['confirmation_page']['path'] ?? null ) ? $form['confirmation_page']['path'] : '' ); $target = '' !== trim( $wanted, '/' ) ? get_page_by_path( trim( $wanted, '/' ) ) : null; if ( $target && 'publish' === $target->post_status && ! post_password_required( $target ) ) { $page = (int) $target->ID; } else { $warnings[] = __( 'Its thank-you page was not found on this site, so the inline confirmation message is used. Choose a page in Email & confirmation if you want one.', 'accessible-forms-by-accessible-org' ); } } $types = Schema::field_types(); foreach ( $clean['fields'] as $field ) { if ( ! isset( $types[ $field['type'] ] ) && ! empty( $field['addon'] ) ) { $warnings[] = sprintf( /* translators: %s: question label. */ __( 'The question ā€œ%sā€ needs an add-on that is not active here. It is kept but not shown until the add-on is active.', 'accessible-forms-by-accessible-org' ), $field['label'] ); } } $places = array(); foreach ( array_slice( (array) ( $form['placements'] ?? array() ), 0, self::PLACEMENT_LIMIT ) as $place ) { if ( is_array( $place ) && is_string( $place['path'] ?? null ) ) { $places[] = array( 'path' => esc_url_raw( $place['path'] ), 'title' => sanitize_text_field( is_string( $place['title'] ?? null ) ? $place['title'] : '' ), ); } } return array( 'index' => $index, 'title' => $title, 'key' => $key, 'match' => $match, 'revision' => $match ? Revisions::token( $match ) : '', 'same_name' => $match ? 0 : self::find_by_title( $title ), 'schema' => $clean, 'errors' => $errors, 'warnings' => $warnings, 'recipients' => self::emails( $form['recipients'] ?? array() ), 'confirmation_page' => $page, 'placements' => $places, ); } /** * Choices for one reviewed form. Add-ons may add their own, such as updating the * form it matches. * * @param array $row Reviewed form. * @return array */ public static function choices( array $row ) { $choices = array( 'new' => $row['match'] ? __( 'Import as a new copy', 'accessible-forms-by-accessible-org' ) : __( 'Import as a new draft', 'accessible-forms-by-accessible-org' ), 'skip' => __( 'Skip this form', 'accessible-forms-by-accessible-org' ), ); return (array) Extensions::filter( 'accessible_forms_import_choices', $choices, $row ); } /** * Import reviewed forms. Every import is a draft; publishing needs the usual * author review in the editor. * * @param array $package Decoded file. * @param array $choices Choice per form index. * @param array $recipients Chosen recipients per form index. * @param array|null $reviewed Rows captured when the import was reviewed; null for a single-step import. * @return array[] One result per form: title, status (created|updated|skipped|failed), id, message. */ public static function import( array $package, array $choices, array $recipients, ?array $reviewed = null ) { $results = array(); $rows = self::review( $package ); $expected = array_column( $reviewed ?? $rows, null, 'index' ); foreach ( $rows as $row ) { $index = $row['index']; $choice = is_string( $choices[ $index ] ?? null ) ? $choices[ $index ] : 'skip'; $result = array( 'title' => $row['title'], 'status' => 'failed', 'id' => 0, 'message' => '', ); if ( 'update' === $choice ) { $before = $expected[ $index ] ?? array(); if ( ! $row['match'] || $row['match'] !== ( $before['match'] ?? 0 ) || empty( $before['revision'] ) || $row['revision'] !== $before['revision'] ) { $result['message'] = __( 'This form changed after you reviewed the import. Choose the file again to review the latest version.', 'accessible-forms-by-accessible-org' ); $results[] = $result; continue; } $row['revision'] = $before['revision']; } if ( 'skip' === $choice || ! isset( self::choices( $row )[ $choice ] ) ) { $result['status'] = 'skipped'; $results[] = $result; continue; } if ( $row['errors'] ) { $result['message'] = implode( ' ', $row['errors'] ); $results[] = $result; continue; } $list = self::emails( $recipients[ $index ] ?? array() ); $schema = $row['schema']; $schema['recipients'] = $list; $schema['confirmation_page'] = $row['confirmation_page']; if ( 'new' === $choice ) { if ( ! $list ) { $result['message'] = __( 'Choose at least one notification recipient.', 'accessible-forms-by-accessible-org' ); $results[] = $result; continue; } $id = FormLibrary::create( $schema, $row['title'] ); if ( is_wp_error( $id ) ) { $result['message'] = $id->get_error_message(); } else { FormKey::assign( $id, $row['key'] ); Audit::record( 'form_imported', $id ); $result = array_merge( $result, array( 'status' => 'created', 'id' => (int) $id ) ); } $results[] = $result; continue; } // An add-on's own choice, such as updating the matching form. $handled = Extensions::filter( 'accessible_forms_import_form', null, $choice, $row, $schema ); $results[] = is_array( $handled ) ? array_merge( $result, $handled ) : array_merge( $result, array( 'message' => __( 'This choice is not available on this site.', 'accessible-forms-by-accessible-org' ) ) ); } return $results; } /** Valid, unique email addresses from a list. */ public static function emails( $list ) { $clean = array(); foreach ( is_array( $list ) ? $list : array() as $email ) { $email = is_string( $email ) ? sanitize_email( $email ) : ''; if ( is_email( $email ) ) { $clean[ strtolower( $email ) ] = $email; } } return array_values( array_slice( $clean, 0, 20 ) ); } /** A form on this site with exactly this name, or 0. */ public static function find_by_title( $title ) { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'accessible_form' AND post_status NOT IN ('trash', 'auto-draft') AND post_title = %s ORDER BY ID ASC LIMIT 1", $title ) ); } /** Site-relative path of a post, such as /contact/. */ public static function path( $post_id ) { $link = get_permalink( $post_id ); return $link ? (string) wp_make_link_relative( $link ) : ''; } }