0 ) {
$title .= sprintf( ' %s ', esc_html( number_format_i18n( $unread ) ) );
}
$hook = add_submenu_page(
'edit.php?post_type=' . Form_Post_Type::POST_TYPE,
__( 'Entries', 'flexsolutions-form-builder' ),
$title,
'read_tflexforms_entries',
self::PAGE,
array( self::class, 'render' )
);
if ( false !== $hook ) {
add_action( 'load-' . $hook, array( self::class, 'on_load' ) );
}
}
/**
* Screen options and bulk actions (before output, so we can redirect).
*/
public static function on_load(): void {
add_screen_option(
'per_page',
array(
'default' => 20,
'option' => 'tflexforms_entries_per_page',
)
);
$table = new Entries_List_Table();
$action = $table->current_action();
$statuses = array(
'mark_read' => 'read',
'mark_unread' => 'unread',
'not_spam' => 'unread',
'mark_spam' => Entry_Store::SPAM,
);
if ( false === $action || ( 'delete' !== $action && ! isset( $statuses[ $action ] ) ) ) {
return;
}
check_admin_referer( 'bulk-entries' );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Verified just above.
$ids = isset( $_REQUEST['entry_ids'] ) && is_array( $_REQUEST['entry_ids'] ) ? array_map( 'absint', wp_unslash( $_REQUEST['entry_ids'] ) ) : array();
if ( 'delete' === $action ) {
if ( ! current_user_can( 'delete_tflexforms_entries' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to delete entries.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
$count = Entry_Store::delete( $ids );
wp_safe_redirect( add_query_arg( 'deleted', $count, self::list_url() ) );
exit;
}
Entry_Store::set_status( $ids, $statuses[ $action ] );
wp_safe_redirect( add_query_arg( 'updated', count( $ids ), self::list_url() ) );
exit;
}
/**
* Saves the per-page screen option.
*
* @param mixed $status Default.
* @param string $option Option name.
* @param mixed $value Value.
* @return int
*/
public static function save_per_page( $status, $option, $value ): int {
unset( $status, $option );
return max( 1, min( 200, (int) $value ) );
}
/**
* Page URLs.
*/
public static function list_url(): string {
return admin_url( 'edit.php?post_type=' . Form_Post_Type::POST_TYPE . '&page=' . self::PAGE );
}
/**
* URL of one entry.
*
* @param int $id Entry ID.
*/
public static function entry_url( int $id ): string {
return add_query_arg( 'entry', $id, self::list_url() );
}
/**
* Nonced delete URL.
*
* @param int $id Entry ID.
*/
public static function delete_url( int $id ): string {
return wp_nonce_url( admin_url( 'admin-post.php?action=tflexforms_delete_entry&entry=' . $id ), 'tflexforms_delete_entry_' . $id );
}
/**
* Nonced URL that changes one entry's status.
*
* @param int $id Entry ID.
* @param string $status unread|read|spam.
*/
public static function status_url( int $id, string $status ): string {
return wp_nonce_url( admin_url( 'admin-post.php?action=tflexforms_entry_status&status=' . $status . '&entry=' . $id ), 'tflexforms_entry_status_' . $id );
}
/**
* Nonced URL that resends an entry's notification.
*
* @param int $id Entry ID.
*/
public static function resend_url( int $id ): string {
return wp_nonce_url( admin_url( 'admin-post.php?action=tflexforms_resend_mail&entry=' . $id ), 'tflexforms_resend_mail_' . $id );
}
/**
* URL of the form that edits one entry's values.
*
* @param int $id Entry ID.
*/
public static function edit_url( int $id ): string {
return add_query_arg( 'edit', '1', self::entry_url( $id ) );
}
/**
* Nonced URL that deletes one note.
*
* @param int $note_id Note ID.
*/
public static function delete_note_url( int $note_id ): string {
return wp_nonce_url( admin_url( 'admin-post.php?action=tflexforms_delete_note¬e=' . $note_id ), 'tflexforms_delete_note_' . $note_id );
}
/**
* Nonced file download URL.
*
* @param int $file_id File row ID.
*/
public static function download_url( int $file_id ): string {
return wp_nonce_url( admin_url( 'admin-post.php?action=tflexforms_download&file=' . $file_id ), 'tflexforms_download_' . $file_id );
}
/**
* Renders the list or a single entry.
*/
public static function render(): void {
if ( ! current_user_can( 'read_tflexforms_entries' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to view entries.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
$entry_id = absint( Entries_List_Table::filter( 'entry' ) );
if ( $entry_id > 0 ) {
if ( '1' === Entries_List_Table::filter( 'edit' ) && current_user_can( 'edit_tflexforms_entries' ) ) {
self::render_edit( $entry_id );
} else {
self::render_entry( $entry_id );
}
return;
}
$table = new Entries_List_Table();
$table->prepare_items();
?>
0 ) {
printf( '
', esc_html( sprintf( /* translators: %d: number of entries. */ _n( '%d entry deleted.', '%d entries deleted.', $deleted, 'flexsolutions-form-builder' ), $deleted ) ) );
}
?>
';
echo ' ';
wp_nonce_field( 'tflexforms_export', '_wpnonce', false );
foreach ( Entries_List_Table::query_args() as $key => $value ) {
$name = array(
'status' => 'entry_status',
'search' => 's',
)[ $key ] ?? $key;
if ( '' !== (string) $value && 0 !== $value ) {
printf( ' ', esc_attr( $name ), esc_attr( 'order' === $key ? strtolower( (string) $value ) : (string) $value ) );
}
}
echo '';
}
/**
* Single entry view. Marks the entry as read.
*
* @param int $id Entry ID.
*/
private static function render_entry( int $id ): void {
$entry = Entry_Store::get( $id );
if ( null === $entry ) {
echo '';
return;
}
if ( 'unread' === $entry->status ) {
Entry_Store::set_status( array( $id ), 'read' );
}
$form_post = get_post( (int) $entry->form_id );
$time = strtotime( $entry->created_at . ' UTC' );
$spam = Entry_Store::SPAM === $entry->status;
$mail_failed = ! $spam && ! (int) $entry->mail_sent;
$can_resend = $mail_failed && null !== Entry_Store::pending_mail( $entry ) && $form_post instanceof \WP_Post;
$retry = $can_resend ? Jobs::find_pending( 'retry_mail', array( 'entry_id' => $id ) ) : null;
$mail_log = Entry_Store::mail_log( $entry );
$can_edit = current_user_can( 'edit_tflexforms_entries' );
?>
' . esc_html__( 'Notification sent.', 'flexsolutions-form-builder' ) . '
';
} elseif ( '0' === $resent ) {
echo '' . esc_html__( 'The notification could not be sent. Check your mail settings and try again.', 'flexsolutions-form-builder' ) . '
';
}
if ( $spam ) {
echo '' . esc_html__( 'This entry was flagged as spam, so no notification was sent. If it is genuine, mark it as not spam; you can then send the notification.', 'flexsolutions-form-builder' ) . '
';
} elseif ( $mail_failed ) {
if ( null !== $retry ) {
$notice = sprintf(
/* translators: %s: date and time of the next attempt. */
__( 'The notification email for this entry could not be sent yet. The entry is safe, and the email will be tried again automatically (next attempt: %s). You can also send it now.', 'flexsolutions-form-builder' ),
self::format_time( (string) $retry->run_at )
);
} elseif ( $can_resend ) {
$notice = __( 'The notification email for this entry could not be sent. The entry is safe; you can try sending it again.', 'flexsolutions-form-builder' );
} else {
$notice = __( 'The notification email for this entry could not be sent, and there is no saved message to resend.', 'flexsolutions-form-builder' );
}
echo '' . esc_html( $notice ) . '
';
}
?>
values as $name => $value ) : ?>
files as $file ) : ?>
field_name ); ?>
original_name ); ?>
(size ) ); ?>)
mail_sent ) {
esc_html_e( 'Yes', 'flexsolutions-form-builder' );
} elseif ( $spam ) {
esc_html_e( 'No — held because the entry was flagged as spam', 'flexsolutions-form-builder' );
} else {
esc_html_e( 'No — the mail server reported an error', 'flexsolutions-form-builder' );
}
?>
page_url ) : ?>
page_url ); ?>
user_id > 0 ) : ?>
user_id ); ?>
display_name . ' (' . $user->user_login . ')' ) : esc_html( '#' . (int) $entry->user_id ); ?>
ip ) : ?>
ip ); ?>
0
/* translators: %d: number of values changed. */
? sprintf( _n( '%d value changed.', '%d values changed.', $count, 'flexsolutions-form-builder' ), $count )
: __( 'Nothing was changed.', 'flexsolutions-form-builder' );
echo '' . esc_html( $text ) . '
';
}
$messages = array(
'tags' => __( 'Tags saved.', 'flexsolutions-form-builder' ),
'note_added' => __( 'Note added.', 'flexsolutions-form-builder' ),
'note_deleted' => __( 'Note deleted.', 'flexsolutions-form-builder' ),
);
$done = Entries_List_Table::filter( 'done' );
if ( isset( $messages[ $done ] ) ) {
echo '' . esc_html( $messages[ $done ] ) . '
';
}
}
/**
* The entry's tags, with a form to change them.
*
* @param int $id Entry ID.
* @param bool $can_edit Whether the user may change them.
*/
private static function render_tags( int $id, bool $can_edit ): void {
$tags = Entry_Tags::for_entries( array( $id ) )[ $id ] ?? array();
if ( ! $can_edit && array() === $tags ) {
return;
}
echo '' . esc_html__( 'Tags', 'flexsolutions-form-builder' ) . ' ';
if ( ! $can_edit ) {
echo '' . esc_html( implode( ', ', $tags ) ) . '
';
return;
}
?>
' . esc_html__( 'Notes', 'flexsolutions-form-builder' ) . '';
if ( array() !== $notes ) {
echo '';
}
if ( ! $can_edit ) {
return;
}
?>
content as $name => $change ) : ?>
created_at ) ); ?>
user_id ) ); ?>
Field name => whether to edit it as multi-line text.
*/
private static function editable_fields( object $entry ): array {
$types = array();
$form_post = get_post( (int) $entry->form_id );
if ( $form_post instanceof \WP_Post && Form_Post_Type::POST_TYPE === $form_post->post_type ) {
foreach ( Form::from_post( $form_post )->input_fields() as $name => $field ) {
$types[ (string) $name ] = $field->type;
}
}
$files = array_map( static fn( $f ) => (string) $f->field_name, (array) $entry->files );
$out = array();
foreach ( $entry->values as $name => $value ) {
if ( ! in_array( (string) $name, $files, true ) && ! in_array( $types[ $name ] ?? '', array( 'file', 'quiz' ), true ) ) {
$out[ (string) $name ] = str_contains( (string) $value, "\n" ) || 'textarea' === ( $types[ $name ] ?? '' );
}
}
foreach ( $types as $name => $type ) {
if ( ! isset( $out[ $name ] ) && ! isset( $entry->values[ $name ] ) && ! in_array( $type, array( 'file', 'quiz' ), true ) ) {
$out[ $name ] = 'textarea' === $type;
}
}
return $out;
}
/**
* A user's display name for notes and history.
*
* @param int $user_id User ID.
*/
private static function user_name( int $user_id ): string {
$user = $user_id > 0 ? get_userdata( $user_id ) : false;
if ( $user instanceof \WP_User ) {
return $user->display_name;
}
/* translators: %d: user ID. */
return $user_id > 0 ? sprintf( __( 'Deleted user #%d', 'flexsolutions-form-builder' ), $user_id ) : __( 'Unknown', 'flexsolutions-form-builder' );
}
/**
* Stops with a 403 unless the current user may change entries.
*/
private static function require_edit_cap(): void {
if ( ! current_user_can( 'edit_tflexforms_entries' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to change entries.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
}
/**
* Saves edited values (admin-post).
*/
public static function save_entry(): void {
$id = isset( $_POST['entry'] ) ? absint( wp_unslash( $_POST['entry'] ) ) : 0;
check_admin_referer( 'tflexforms_save_entry_' . $id );
self::require_edit_cap();
$entry = Entry_Store::get( $id );
if ( null === $entry ) {
wp_die( esc_html__( 'Entry not found.', 'flexsolutions-form-builder' ), '', array( 'response' => 404 ) );
}
$posted = isset( $_POST['values'] ) && is_array( $_POST['values'] ) ? wp_unslash( $_POST['values'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each value is sanitized below.
$allowed = self::editable_fields( $entry );
$values = array();
foreach ( $posted as $name => $value ) {
$name = (string) $name;
if ( ! isset( $allowed[ $name ] ) || ! is_scalar( $value ) ) {
continue;
}
$value = str_replace( array( "\r\n", "\r" ), "\n", (string) $value );
$values[ $name ] = str_contains( $value, "\n" ) ? sanitize_textarea_field( $value ) : sanitize_text_field( $value );
}
$changes = Entry_Store::update_values( $id, $values, get_current_user_id() );
wp_safe_redirect( add_query_arg( 'changed', count( $changes ), self::entry_url( $id ) ) );
exit;
}
/**
* Saves an entry's tags (admin-post).
*/
public static function save_tags(): void {
$id = isset( $_POST['entry'] ) ? absint( wp_unslash( $_POST['entry'] ) ) : 0;
check_admin_referer( 'tflexforms_entry_tags_' . $id );
self::require_edit_cap();
if ( null === Entry_Store::get( $id ) ) {
wp_die( esc_html__( 'Entry not found.', 'flexsolutions-form-builder' ), '', array( 'response' => 404 ) );
}
Entry_Tags::set( $id, isset( $_POST['tags'] ) ? sanitize_text_field( wp_unslash( $_POST['tags'] ) ) : '' );
wp_safe_redirect( add_query_arg( 'done', 'tags', self::entry_url( $id ) ) );
exit;
}
/**
* Adds a note to an entry (admin-post).
*/
public static function add_note(): void {
$id = isset( $_POST['entry'] ) ? absint( wp_unslash( $_POST['entry'] ) ) : 0;
check_admin_referer( 'tflexforms_add_note_' . $id );
self::require_edit_cap();
if ( null === Entry_Store::get( $id ) ) {
wp_die( esc_html__( 'Entry not found.', 'flexsolutions-form-builder' ), '', array( 'response' => 404 ) );
}
$note = isset( $_POST['note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note'] ) ) : '';
$added = Entry_Notes::add( $id, $note, get_current_user_id() ) > 0;
wp_safe_redirect( $added ? add_query_arg( 'done', 'note_added', self::entry_url( $id ) ) : self::entry_url( $id ) );
exit;
}
/**
* Deletes a note (admin-post).
*/
public static function delete_note(): void {
$note_id = isset( $_GET['note'] ) ? absint( wp_unslash( $_GET['note'] ) ) : 0;
check_admin_referer( 'tflexforms_delete_note_' . $note_id );
$note = Entry_Notes::get( $note_id );
if ( null === $note || ! Entry_Notes::current_user_can_delete( $note ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to delete this note.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
Entry_Notes::delete( $note_id );
wp_safe_redirect( add_query_arg( 'done', 'note_deleted', self::entry_url( (int) $note->entry_id ) ) );
exit;
}
/**
* What started an email attempt, for the attempts table.
*
* @param string $source submit, retry or resend.
*/
private static function attempt_label( string $source ): string {
switch ( $source ) {
case 'submit':
return __( 'On submission', 'flexsolutions-form-builder' );
case 'retry':
return __( 'Automatic retry', 'flexsolutions-form-builder' );
default:
return __( 'Sent by hand', 'flexsolutions-form-builder' );
}
}
/**
* A UTC database time in the site's date and time format.
*
* @param string $utc Y-m-d H:i:s in UTC.
*/
private static function format_time( string $utc ): string {
$time = strtotime( $utc . ' UTC' );
return (string) wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), false !== $time ? $time : null );
}
/**
* Deletes one entry (admin-post).
*/
public static function delete_single(): void {
$id = isset( $_GET['entry'] ) ? absint( wp_unslash( $_GET['entry'] ) ) : 0;
check_admin_referer( 'tflexforms_delete_entry_' . $id );
if ( ! current_user_can( 'delete_tflexforms_entries' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to delete entries.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
$count = Entry_Store::delete( array( $id ) );
wp_safe_redirect( add_query_arg( 'deleted', $count, self::list_url() ) );
exit;
}
/**
* Marks one entry read/unread (admin-post).
*/
public static function change_status(): void {
$id = isset( $_GET['entry'] ) ? absint( wp_unslash( $_GET['entry'] ) ) : 0;
check_admin_referer( 'tflexforms_entry_status_' . $id );
if ( ! current_user_can( 'read_tflexforms_entries' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to change entries.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
$status = isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : '';
if ( 'not_spam' === $status ) {
// Back to the entry, where its held notification can be sent.
Entry_Store::set_status( array( $id ), 'unread' );
wp_safe_redirect( self::entry_url( $id ) );
exit;
}
Entry_Store::set_status( array( $id ), in_array( $status, array( 'read', Entry_Store::SPAM ), true ) ? $status : 'unread' );
wp_safe_redirect( self::list_url() );
exit;
}
/**
* Resends an entry's kept notification (admin-post).
*/
public static function resend_mail(): void {
$id = isset( $_GET['entry'] ) ? absint( wp_unslash( $_GET['entry'] ) ) : 0;
check_admin_referer( 'tflexforms_resend_mail_' . $id );
if ( ! current_user_can( 'read_tflexforms_entries' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to change entries.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
$sent = Submission_Handler::resend_notification( $id );
wp_safe_redirect( add_query_arg( 'resent', $sent ? '1' : '0', self::entry_url( $id ) ) );
exit;
}
/**
* Streams an uploaded file. Always sent as a download (never displayed
* inline) so an uploaded file can never run as a page on your site.
*/
public static function download(): void {
$file_id = isset( $_GET['file'] ) ? absint( wp_unslash( $_GET['file'] ) ) : 0;
check_admin_referer( 'tflexforms_download_' . $file_id );
if ( ! current_user_can( 'read_tflexforms_entries' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to download this file.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table lookup.
$row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', Database::table( 'entry_files' ), $file_id ) );
$path = is_object( $row ) ? Uploads::entry_file_path( (string) $row->stored_name ) : null;
if ( null === $path ) {
wp_die( esc_html__( 'File not found.', 'flexsolutions-form-builder' ), '', array( 'response' => 404 ) );
}
// Stored files are encrypted; decrypt before sending anything.
$plain = Uploads::open_plain( $path );
if ( null === $plain ) {
wp_die( esc_html__( 'The file could not be decrypted. It may be damaged.', 'flexsolutions-form-builder' ), '', array( 'response' => 500 ) );
}
$name = sanitize_file_name( (string) $row->original_name );
nocache_headers();
header( 'Content-Type: application/octet-stream' );
header( 'Content-Disposition: attachment; filename="' . $name . '"; filename*=UTF-8\'\'' . rawurlencode( $name ) );
header( 'Content-Length: ' . (string) fstat( $plain )['size'] );
header( 'X-Content-Type-Options: nosniff' );
header( "Content-Security-Policy: default-src 'none'; sandbox" );
fpassthru( $plain );
fclose( $plain ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Our own stream.
exit;
}
/**
* Exports the entries list's current view as CSV (admin-post): the same
* form, status, tag and search filters and order, every page.
*/
public static function export(): void {
check_admin_referer( 'tflexforms_export' );
if ( ! current_user_can( 'read_tflexforms_entries' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to export entries.', 'flexsolutions-form-builder' ), '', array( 'response' => 403 ) );
}
$args = Entries_List_Table::query_args();
$bom = '1' === Entries_List_Table::filter( 'bom' );
$rows = self::csv_rows( $args );
$form_post = $args['form_id'] > 0 ? get_post( $args['form_id'] ) : null;
$filename = sanitize_file_name( 'flexsolutions-form-builder-' . ( $form_post instanceof \WP_Post ? $form_post->post_name : 'entries' ) . '-' . gmdate( 'Y-m-d' ) . '.csv' );
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'X-Content-Type-Options: nosniff' );
$out = fopen( 'php://output', 'w' );
if ( false === $out ) {
exit;
}
self::write_csv( $out, $rows, $bom );
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
exit;
}
/**
* The CSV for a list view as rows of cells, header first. Columns: entry
* ID, submitted time, status, the form (only when the view spans several
* forms), the form's current fields, any other names found in the
* entries, then tags.
*
* @param array $args Entry_Store::query() filters (form_id, status, search, tag, order).
* @return array
*/
public static function csv_rows( array $args ): array {
$form_id = (int) ( $args['form_id'] ?? 0 );
$columns = array();
$form_post = $form_id > 0 ? get_post( $form_id ) : null;
if ( $form_post instanceof \WP_Post && Form_Post_Type::POST_TYPE === $form_post->post_type ) {
foreach ( Form::from_post( $form_post )->input_fields() as $name => $field ) {
if ( 'quiz' !== $field->type ) {
$columns[ $name ] = true;
}
}
}
$items = array();
$page = 1;
do {
$batch = Entry_Store::query(
array_merge(
$args,
array(
'per_page' => 500,
'page' => $page,
)
)
);
$ids = array_map( static fn( $e ) => (int) $e->id, $batch['items'] );
$values = Entry_Store::values_for( $ids );
$tags = Entry_Tags::for_entries( $ids );
foreach ( $batch['items'] as $item ) {
$item_values = $values[ (int) $item->id ] ?? array();
foreach ( array_keys( $item_values ) as $name ) {
$columns[ $name ] = true;
}
$items[] = array( $item, $item_values, $tags[ (int) $item->id ] ?? array() );
}
++$page;
$batch_size = count( $batch['items'] );
} while ( 500 === $batch_size && $page < 200 );
$with_form = $form_id <= 0;
$header = array( 'Entry ID', 'Submitted', 'Status' );
if ( $with_form ) {
$header[] = 'Form';
}
$rows = array( array_merge( $header, array_map( 'strval', array_keys( $columns ) ), array( 'Tags' ) ) );
$titles = array();
foreach ( $items as [ $item, $item_values, $item_tags ] ) {
$time = strtotime( $item->created_at . ' UTC' );
$line = array( (string) $item->id, (string) wp_date( 'Y-m-d H:i:s', false !== $time ? $time : null ), (string) $item->status );
if ( $with_form ) {
$fid = (int) $item->form_id;
if ( ! isset( $titles[ $fid ] ) ) {
$titles[ $fid ] = null !== get_post( $fid ) ? get_the_title( $fid ) : __( '(deleted form)', 'flexsolutions-form-builder' );
}
$line[] = $titles[ $fid ];
}
foreach ( array_keys( $columns ) as $name ) {
$line[] = (string) ( $item_values[ $name ] ?? '' );
}
$line[] = implode( ', ', $item_tags );
$rows[] = $line;
}
return $rows;
}
/**
* Writes CSV rows to a stream, every cell made formula-safe.
*
* @param resource $out Stream.
* @param array $rows Rows from csv_rows().
* @param bool $bom Start with a UTF-8 byte order mark, which
* Excel needs to read non-English text.
*/
public static function write_csv( $out, array $rows, bool $bom ): void {
if ( $bom ) {
fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
}
foreach ( $rows as $row ) {
fputcsv( $out, array_map( array( self::class, 'csv_cell' ), $row ), ',', '"', '' );
}
}
/**
* Neutralises spreadsheet formulas (CSV injection): a cell starting with
* = + - @ or a control character gets a leading apostrophe.
*
* @param string $value Cell value.
*/
public static function csv_cell( string $value ): string {
return '' !== $value && str_contains( "=+-@\t\r\n", $value[0] ) ? "'" . $value : $value;
}
}