Index: src/wp-admin/includes/admin-filters.php
===================================================================
--- src/wp-admin/includes/admin-filters.php	(revision 42890)
+++ src/wp-admin/includes/admin-filters.php	(working copy)
@@ -128,3 +128,7 @@
 add_action( 'upgrader_process_complete', 'wp_version_check', 10, 0 );
 add_action( 'upgrader_process_complete', 'wp_update_plugins', 10, 0 );
 add_action( 'upgrader_process_complete', 'wp_update_themes', 10, 0 );
+
+// Privacy hooks
+add_action( 'admin_init', 'wp_privacy_delete_old_export_files' );
+add_filter( 'wp_privacy_personal_data_export_page', 'wp_privacy_process_personal_data_export_page', 10, 4 );
Index: src/wp-admin/includes/file.php
===================================================================
--- src/wp-admin/includes/file.php	(revision 42890)
+++ src/wp-admin/includes/file.php	(working copy)
@@ -1934,3 +1934,263 @@
 	</div>
 	<?php
 }
+
+// TODO phpDocs
+function wp_privacy_generate_personal_data_export_group_html( $group_data ) {
+	$group_html = '';
+
+	$group_html .= '<h2>' . esc_html( $group_data['group_label'] ) . '</h2>';
+	$group_html .= '<div>';
+
+	foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) {
+		$group_html .= '<table>';
+		$group_html .= '<tbody>';
+
+		foreach ( (array) $group_item_data as $group_item_datum ) {
+			$group_html .= '<tr>';
+			$group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>';
+			// TODO entities!
+			$group_html .= '<td>' . esc_html( $group_item_datum['value'] ) . '</td>';
+			// TODO support attachment links
+			$group_html .= '</tr>';
+		}
+
+		$group_html .= '</tbody>';
+		$group_html .= '</table>';
+	}
+
+	$group_html .= '</div>';
+
+	return $group_html;
+}
+
+// TODO phpDocs
+function wp_privacy_generate_personal_data_export( $exports_dir, $email_address, $personal_data ) {
+
+	// TODO Instead of HTML, return a ZIP containing the HTML and the attachments
+	$timestamp = current_time( 'timestamp' );
+	$index_filename = 'wp-personal-data-export-' . md5( $email_address ) . '-' . md5( $timestamp ) . '.html';
+
+	$index_path = trailingslashit( $exports_dir ) . $index_filename;
+	$file = fopen( $index_path, 'w' );
+	// TODO catch fopen error
+
+	$title = sprintf(
+		__( 'Personal Data Export for %s' ),
+		$email_address
+	);
+
+	// Open HTML
+	fwrite( $file, "<!DOCTYPE html>\n" );
+	fwrite( $file, "<html>\n" );
+
+	// Head
+	fwrite( $file, "<head>\n" );
+	fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" );
+	fwrite( $file, "<style type='text/css'>" );
+	fwrite( $file, "body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }" );
+	fwrite( $file, "table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }" );
+	fwrite( $file, "th { padding: 5px; text-align: left; width: 20%; }" );
+	fwrite( $file, "td { padding: 5px; }" );
+	fwrite( $file, "tr:nth-child(odd) { background-color: #fafafa; }" );
+	fwrite( $file, "</style>" );
+	fwrite( $file, "<title>" );
+	fwrite( $file, esc_html( $title ) );
+	fwrite( $file, "</title>" );
+	fwrite( $file, "</head>\n" );
+
+	// Body
+	fwrite( $file, "<body>\n" );
+
+	// Heading
+	fwrite( $file, "<h1>" . esc_html__( 'Personal Data Export' ) . "</h1>" );
+
+	// And now, all the Groups
+	$groups = array();
+
+	// First, build a "About" group on the fly for this report
+	$groups[] = array(
+		'group_id'    => 'about',
+		'group_label' => __( 'About' ),
+		'items'       => array(
+			'about-1' => array(
+				array(
+					'name'  => __( 'Report generated for' ),
+					'value' => $email_address,
+				),
+				array(
+					'name'  => __( 'For site' ),
+					'value' => get_bloginfo( 'name' ),
+				),
+				array(
+					'name'  => __( 'At URL' ),
+					'value' => get_bloginfo( 'url' ),
+				),
+				array(
+					'name'  => __( 'On' ),
+					'value' => current_time( 'mysql' ),
+				),
+			),
+		)
+	);
+
+	// Next, iterate over every item in $personal_data
+	// Extract all the unique group_ids, group_labels and item_ids
+	// Initialize/append the data for each item under its item_id
+	foreach ( (array) $personal_data as $personal_datum ) {
+		$group_id    = $personal_datum['group_id'];
+		$group_label = $personal_datum['group_label'];
+		if ( ! array_key_exists( $group_id, $groups ) ) {
+			$groups[] = array(
+				'group_id'    => $group_id,
+				'group_label' => $group_label,
+				'items'       => array(),
+			);
+		}
+
+		$item_id = $personal_datum['item_id'];
+		if ( ! array_key_exists( $item_id, $groups[ $group_id ]['items'] ) ) {
+			$groups[ $group_id ]['items'][ $item_id ] = array();
+		}
+
+		$old_item_data = $groups[ $group_id ]['items'][ $item_id ];
+		$merged_item_data = array_merge( $personal_datum['data'], $old_item_data );
+		$groups[ $group_id ]['items'][ $item_id ] = $merged_item_data;
+	}
+
+	// Now, iterate over every group in $groups and have the formatter render it in HTML
+	foreach ( (array) $groups as $group ) {
+		fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group ) );
+	}
+
+	fwrite( $file, "</body>\n" );
+
+	// Close HTML
+	fwrite( $file, "</html>\n" );
+	fclose( $file );
+
+	// Now, generate the ZIP
+	$archive_filename = 'wp-personal-data-export-' . md5( $email_address ) . '-' . md5( $timestamp ) . '.zip';
+	$archive_path = trailingslashit( $exports_dir ) . $archive_filename;
+
+	$zip = new ZipArchive;
+	// TODO test for no ZipArchive to work with
+
+	if ( TRUE === $zip->open( $archive_path, ZipArchive::CREATE ) ) {
+		$zip->addFile( $index_path, 'index.html' );
+		// TODO - add things referenced in wp-content/uploads
+		$zip->close();
+	} else {
+		error_log( "unable to open zip for creation" );
+		// TODO handle error here
+	}
+
+	// And remove the HTML file
+	unlink( $index_path );
+
+	return(
+		array(
+			'timestamp' => $timestamp,
+			'filename' => $archive_filename,
+		)
+	);
+}
+
+// TODO phpDocs
+function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page ) {
+
+	// Housekeeping
+	$upload_dir  = wp_upload_dir();
+	$exports_dir = $upload_dir['basedir'] . '/exports';
+	$exports_url = $upload_dir['baseurl'] . '/exports';
+
+	// Create the exports folder if needed
+	$result = wp_mkdir_p( $exports_dir );
+	if ( is_wp_error( $result ) ) {
+		return $result;
+	}
+
+	// TODO Make sure the exports folder is protected (htaccess, index)
+
+	// Generate a export file option key from the email address
+	$export_file_option = '_wp_privacy_export_file_' . md5( $email_address );
+
+	// See if we have an export URL cached for this user already
+	$export_file_details = get_site_option( $export_file_option, '', false );
+
+	// And if we do, short circuit the export and send the path back instead
+	if ( ! empty( $export_file_details ) ) {
+		$pieces = explode( ':', $export_file_details ); // path:timestamp
+		$export_file_path = trailingslashit( $exports_dir ) . $pieces[0];
+
+		if ( file_exists( $export_file_path ) ) {
+			return(
+				array(
+					'data' => array(),
+					'done' => true,
+					'url'  => $pieces[0],
+				)
+			);
+		}
+
+		// No file? Delete the option and continue building the export
+		delete_site_option( $export_file_option );
+	}
+
+	// Generate a (temporary) data storage option key from the email address
+	$export_data_option = '_wp_privacy_export_data_' . md5( $email_address );
+
+	// First page of first exporter? Prepare/clear the option where we will assemble all the responses
+	if ( 1 === $exporter_index && 1 === $page ) {
+		update_site_option( $export_data_option, array() );
+	}
+
+	// Grab whatever data has already been collected on previous exporters and/or pages
+	$export_data = get_site_option( $export_data_option, array() );
+
+	// Does the response include data? If so, add this response's data to all the data we have received so far
+	// TODO - enforce the shape?
+	// TODO - enforce data as a numeric (not associative) array
+	$exporter_page_data = $response['data'];
+	if ( ! empty( $exporter_page_data ) && is_array( $exporter_page_data ) ) {
+		$export_data = array_merge( $export_data, $exporter_page_data );
+		update_site_option( $export_data_option, $export_data );
+	}
+
+	// Are we on the last exporter, and did that export say it was done? If not, return now
+	// so we can continue to collect data for export
+	$exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() );
+	$is_last_exporter = $exporter_index === count( $exporters );
+	$exporter_done = $response['done'];
+	if ( ! $is_last_exporter || ! $exporter_done ) {
+		return $response;
+	}
+
+	// TODO de-repetitive-ize the data
+
+	// Generate the export
+	$result = wp_privacy_generate_personal_data_export( $exports_dir, $email_address, $export_data );
+	if ( is_wp_error( $result ) ) {
+		return $result;
+	}
+
+	// Get the timestamp and filename from the export
+	$timestamp = $result['timestamp'];
+	$filename = $result['filename'];
+
+	// Build the URL for the file
+	$export_url = trailingslashit( $exports_url ) . $filename;
+
+	// Modify the response to include the URL of the export file
+	$response['url'] = $export_url;
+
+	// TODO Save the export file in an option for safekeeping
+
+	return $response;
+}
+
+function wp_privacy_delete_old_export_files() {
+
+	// TODO delete old export files and their options
+
+}
