'GET', 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'args' => array( 'format' => array( 'type' => 'string', 'enum' => $this->formats(), 'default' => 'csv', ), ), 'callback' => array( $this, 'export' ), ) ); } /** * Stream the compliance report in the requested format and terminate the request. * * @since 1.1.0 * * @param WP_REST_Request $request The REST request. * @return void Streams the file. */ public function export( $request ) { $format = sanitize_key( (string) $request->get_param( 'format' ) ); if ( '' === $format || ! in_array( $format, $this->formats(), true ) ) { $format = 'csv'; } $record = $this->record(); $headers = array_values( self::image_columns() ); $rows = self::flat_rows( $record ); $lit = $record['literacy']; $date = $record['generated_date']; nocache_headers(); if ( 'csv' !== $format ) { /** * Stream a report format registered by an add-on. * * The handler streams the document itself and returns true when it has * handled the format; any unhandled format falls back to CSV. * * @since 1.1.0 * @param bool $handled Whether the format was streamed. Default false. * @param string $format The requested format key. * @param string[] $headers Image-table column headers. * @param array[] $rows Image-table rows, in header order. * @param array $lit Article 4 literacy data. * @param string $date Report date. * @param array $record The canonical report record. Added in 1.4.0. */ $handled = apply_filters( 'aicl_report_stream', false, $format, $headers, $rows, $lit, $date, $record ); if ( true === $handled ) { exit; } } $this->send_csv( $record ); exit; } /** * The available report formats: CSV, plus any registered by add-ons. * * @since 1.1.0 * * @return string[] The format keys. */ protected function formats() { /** * Filter the available compliance-report formats. * * @since 1.1.0 * @param string[] $formats The format keys. Default array( 'csv' ). */ $formats = apply_filters( 'aicl_report_formats', array( 'csv' ) ); return array_values( array_unique( array_merge( array( 'csv' ), array_map( 'sanitize_key', (array) $formats ) ) ) ); } /** * Build the download filename for a given extension. * * @since 1.1.0 * * @param string $date Report date. * @param string $ext File extension. * @return string The filename. */ protected function filename( $date, $ext ) { return 'aim-transparency-report-' . $date . '.' . $ext; } /** * Assemble the one canonical record every format renders. * * A compliance document is handed to somebody who does not have the dashboard in * front of them, so the facts, the wording and the column names are settled here * once. A renderer decides layout only; if it needs a number, the number is * already in this array. * * @since 2.0.0 * * @return array The report record. */ protected function record() { $images = $this->images(); $posts = $this->posts(); $literacy = $this->literacy(); $count = count( $images ); $software = 'AIM Transparency ' . AICL_VERSION; if ( defined( 'AIMPRO_VERSION' ) ) { $software .= ', Pro ' . AIMPRO_VERSION; } $record = array( 'title' => __( 'AI content disclosure record', 'aim-transparency' ), 'statement' => __( 'This record lists every image on this site that has been marked to say how it was made, together with the AI literacy steps recorded here, as they stood when the file was produced.', 'aim-transparency' ), 'notice' => __( 'It is produced by the site itself and is a statement by the site owner, not a certificate issued by anyone else.', 'aim-transparency' ), 'site_name' => (string) get_bloginfo( 'name', 'display' ), 'site_url' => home_url( '/' ), 'generated_date' => wp_date( 'Y-m-d' ), 'generated_at' => wp_date( 'c' ), 'generated_human' => wp_date( 'j F Y, H:i' ), 'timezone' => wp_timezone_string(), 'software' => $software, 'standard' => __( 'IPTC Digital Source Type', 'aim-transparency' ), 'law' => __( 'EU AI Act Article 50 (transparency) and Article 4 (AI literacy)', 'aim-transparency' ), 'image_count' => $count, 'post_count' => count( $posts ), 'row_limit' => self::LIMIT, 'truncated' => ( self::LIMIT === $count ), 'codes_used' => self::codes_used( $images ), 'columns' => self::columns(), 'headings' => self::headings(), 'images' => $images, 'posts' => $posts, 'literacy' => $literacy, ); $record['images_line'] = sprintf( /* translators: %s: number of images listed. */ _n( '%s image.', '%s images.', $count, 'aim-transparency' ), number_format_i18n( $count ) ); $record['steps_value'] = sprintf( /* translators: 1: steps recorded, 2: total steps. */ __( '%1$s of %2$s', 'aim-transparency' ), number_format_i18n( $literacy['recorded'] ), number_format_i18n( $literacy['total'] ) ); $record['truncated_note'] = $record['truncated'] ? sprintf( /* translators: %s: the maximum number of images one report can list. */ __( 'Images listed: %s (the limit for one report; the site has more)', 'aim-transparency' ), number_format_i18n( self::LIMIT ) ) : ''; $record['provenance'] = self::provenance( $record ); return $record; } /** * The provenance pairs, in the order every format prints them. * * Kept beside the record rather than in each renderer so a reader comparing a CSV * against the printed HTML sees the same lines in the same order. * * @since 2.0.0 * * @param array $record The record so far. * @return array[] Ordered array of array( 'key' => string, 'label' => string, 'value' => string ). */ protected static function provenance( $record ) { $pairs = array( array( 'key' => 'site_name', 'label' => __( 'Site', 'aim-transparency' ), 'value' => $record['site_name'], ), array( 'key' => 'site_url', 'label' => __( 'Site address', 'aim-transparency' ), 'value' => $record['site_url'], ), array( 'key' => 'produced', 'label' => __( 'Produced', 'aim-transparency' ), 'value' => $record['generated_human'], ), array( 'key' => 'timezone', 'label' => __( 'Time zone', 'aim-transparency' ), 'value' => $record['timezone'], ), array( 'key' => 'software', 'label' => __( 'Produced by', 'aim-transparency' ), 'value' => $record['software'], ), array( 'key' => 'law', 'label' => __( 'Covers', 'aim-transparency' ), 'value' => $record['law'], ), array( 'key' => 'standard', 'label' => __( 'Standard', 'aim-transparency' ), 'value' => $record['standard'], ), array( 'key' => 'image_count', 'label' => __( 'Images listed', 'aim-transparency' ), 'value' => number_format_i18n( $record['image_count'] ), ), array( 'key' => 'post_count', 'label' => __( 'Posts listed', 'aim-transparency' ), 'value' => number_format_i18n( $record['post_count'] ), ), array( 'key' => 'steps', 'label' => __( 'Steps recorded', 'aim-transparency' ), 'value' => $record['steps_value'], ), ); if ( '' !== $record['truncated_note'] ) { $pairs[] = array( 'key' => 'truncated', 'label' => __( 'Note', 'aim-transparency' ), 'value' => $record['truncated_note'], ); } return $pairs; } /** * The plain-English name printed above every column, in every format. * * One map so a person reading the CSV and a person reading the PDF are looking at * the same words, and a translator has one string to translate per column. * * @since 2.0.0 * * @return array Column key to label. */ protected static function columns() { return array( 'id' => __( 'Image ID', 'aim-transparency' ), 'file_name' => __( 'File name', 'aim-transparency' ), 'made' => __( 'How it was made', 'aim-transparency' ), 'code' => __( 'Standard code', 'aim-transparency' ), 'mark' => __( 'Mark inside the file', 'aim-transparency' ), 'url' => __( 'Web address', 'aim-transparency' ), 'address' => __( 'Address written into the file', 'aim-transparency' ), 'step' => __( 'Step', 'aim-transparency' ), 'status' => __( 'Status', 'aim-transparency' ), 'note' => __( 'Note', 'aim-transparency' ), 'post_id' => __( 'Post ID', 'aim-transparency' ), 'post_title' => __( 'Title', 'aim-transparency' ), 'written' => __( 'How it was written', 'aim-transparency' ), 'duty' => __( 'Disclosed to readers', 'aim-transparency' ), 'editor' => __( 'Editorial responsibility', 'aim-transparency' ), 'reviewed' => __( 'Reviewed on', 'aim-transparency' ), ); } /** * The six image-table columns, in print order. * * Separated from the full label map because the filter contract still hands * third-party handlers a flat header row in exactly this order. * * @since 2.0.0 * * @return array Column key to label, in order. */ protected static function image_columns() { $columns = self::columns(); return array( 'id' => $columns['id'], 'file_name' => $columns['file_name'], 'made' => $columns['made'], 'code' => $columns['code'], 'mark' => $columns['mark'], 'url' => $columns['url'], ); } /** * The section titles, declared once and free of legal shorthand. * * The plain words come first because that is what a reader scans for; the article * number follows in brackets for the auditor who wants it. * * @since 2.0.0 * * @return array Heading key to title. */ protected static function headings() { return array( 'codes' => __( 'Codes used in this record', 'aim-transparency' ), 'images' => __( 'Images you have marked (EU AI Act Article 50)', 'aim-transparency' ), 'posts' => __( 'Posts you have marked (EU AI Act Article 50(4))', 'aim-transparency' ), 'literacy' => __( 'AI literacy record (EU AI Act Article 4)', 'aim-transparency' ), ); } /** * Flatten the image records into the positional rows the stream filter promises. * * Third-party handlers registered against the original six-argument signature keep * working, and read the same values the record carries. * * @since 2.0.0 * * @param array $record The report record. * @return array[] One positional row per image. */ protected static function flat_rows( $record ) { $rows = array(); foreach ( $record['images'] as $image ) { $rows[] = array( $image['id'], $image['file_name'], $image['made'], $image['code'], $image['mark']['text'], $image['url'], ); } return $rows; } /** * Turn the raw embedding status meta into a fact a person can act on. * * The stored value counts files, not images, and "embedded 5" tells a reader * nothing about what the 5 counts or whether anything went wrong. The counts are * kept as integers for machines and restated as a sentence for everyone else. * * @since 2.0.0 * * @param string $raw The _aicl_embed_status meta value. * @return array array( 'written' => int, 'skipped' => int, 'failed' => int, 'state' => string, 'text' => string ). */ public static function mark( $raw ) { $raw = (string) $raw; $written = 0; $skipped = 0; $failed = 0; if ( '' === $raw ) { return array( 'written' => 0, 'skipped' => 0, 'failed' => 0, 'state' => 'none', 'text' => __( 'Not yet written', 'aim-transparency' ), ); } if ( 'file missing' === $raw ) { return array( 'written' => 0, 'skipped' => 0, 'failed' => 0, 'state' => 'missing', 'text' => __( 'File missing', 'aim-transparency' ), ); } if ( preg_match( '/^embedded (\d+)/', $raw, $m ) ) { $written = (int) $m[1]; } if ( preg_match( '/(\d+) unsupported/', $raw, $m ) ) { $skipped = (int) $m[1]; } if ( preg_match( '/(\d+) failed/', $raw, $m ) ) { $failed = (int) $m[1]; } if ( 0 === $written ) { if ( 0 < $failed ) { /* translators: %s: number of files the write failed on. */ $text = sprintf( __( 'No, %s failed', 'aim-transparency' ), number_format_i18n( $failed ) ); $state = 'partial'; } elseif ( 0 < $skipped ) { /* translators: %s: number of files whose format cannot carry the mark. */ $text = sprintf( __( 'No, %s skipped', 'aim-transparency' ), number_format_i18n( $skipped ) ); $state = 'none'; } else { $text = __( 'Not yet written', 'aim-transparency' ); $state = 'none'; } return array( 'written' => 0, 'skipped' => $skipped, 'failed' => $failed, 'state' => $state, 'text' => $text, ); } /* translators: %s: number of files the mark was written into. */ $text = sprintf( _n( 'Yes, in %s file', 'Yes, in %s files', $written, 'aim-transparency' ), number_format_i18n( $written ) ); $state = 'written'; $notes = array(); if ( 0 < $skipped ) { /* translators: %s: number of files whose format cannot carry the mark. */ $notes[] = sprintf( __( '%s skipped', 'aim-transparency' ), number_format_i18n( $skipped ) ); } if ( 0 < $failed ) { /* translators: %s: number of files the write failed on. */ $notes[] = sprintf( __( '%s failed', 'aim-transparency' ), number_format_i18n( $failed ) ); } if ( $notes ) { $text = $text . ' (' . implode( ', ', $notes ) . ')'; $state = 'partial'; } return array( 'written' => $written, 'skipped' => $skipped, 'failed' => $failed, 'state' => $state, 'text' => $text, ); } /** * List each standard code this report actually uses, with its IPTC address. * * The address used to be repeated on every row, which is noise, and repeating it * invited the reader to assume the address is the prefix plus the code. It is not: * the basic AI mark deliberately writes a different code's address. Stating each * one once, from uri_for(), keeps that true. * * @since 2.0.0 * * @param array[] $images The image records. * @return array[] One entry per distinct code, in the order the types are declared. */ protected static function codes_used( $images ) { $seen = array(); foreach ( $images as $image ) { $seen[ $image['code'] ] = true; } $used = array(); foreach ( AICL_Plugin::SOURCE_TYPES as $token => $ignored ) { if ( isset( $seen[ $token ] ) ) { $used[] = array( 'made' => AICL_Plugin::label_for( $token ), 'code' => $token, 'address' => AICL_Plugin::uri_for( $token ), ); } } return $used; } /** * Neutralise a cell a spreadsheet would execute rather than display. * * Filenames and the Article 4 notes are user text and reach this export unchanged; * sanitize_text_field leaves a leading = intact. This report is meant to be handed * to a client or an auditor, so a cell must never run in their spreadsheet. * Prefixing with an apostrophe is the convention Excel, Numbers and LibreOffice all * read as "treat the rest as literal text". * * @since 1.3.0 * * @param array $row One CSV row. * @return array The row, with any formula-triggering cell escaped. */ protected static function safe_row( $row ) { return array_map( static function ( $cell ) { $cell = (string) $cell; return ( '' !== $cell && false !== strpos( "=+-@\t\r", $cell[0] ) ) ? "'" . $cell : $cell; }, (array) $row ); } /** * Stream the report as CSV. * * Ragged on purpose: the provenance pairs are two columns and the image table is * six. Excel, Numbers and Sheets all read that, and it lets one file carry the * whole record rather than forcing the reader to open four. * * The Article 50(4) section is printed only when there is something in it. A site that never * marks a post should not be handed an empty heading in its compliance record: an auditor * reads a blank section as an unanswered question, when the truth is the duty never applied. * * @since 1.1.0 * * @param array $record The report record. * @return void */ protected function send_csv( $record ) { header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $this->filename( $record['generated_date'], 'csv' ) . '"' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming to the HTTP response, not the filesystem. // Excel assumes the system codepage without it, mangling every accented filename. fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming to the HTTP response, not the filesystem. $put = static function ( $row ) use ( $out ) { fputcsv( $out, self::safe_row( $row ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv -- streaming to the HTTP response, not the filesystem. }; $columns = $record['columns']; $put( array( $record['title'] ) ); $put( array( $record['statement'] ) ); $put( array( $record['notice'] ) ); $put( array() ); foreach ( $record['provenance'] as $pair ) { $put( array( $pair['label'], $pair['value'] ) ); } if ( $record['codes_used'] ) { $put( array() ); $put( array( $record['headings']['codes'] ) ); $put( array( $columns['made'], $columns['code'], $columns['address'] ) ); foreach ( $record['codes_used'] as $code ) { $put( array( $code['made'], $code['code'], $code['address'] ) ); } } $put( array() ); $put( array( $record['headings']['images'] ) ); $put( array( $columns['id'], $columns['file_name'], $columns['made'], $columns['code'], $columns['mark'], $columns['url'] ) ); foreach ( $record['images'] as $image ) { $put( array( $image['id'], $image['file_name'], $image['made'], $image['code'], $image['mark']['text'], $image['url'], ) ); } if ( $record['posts'] ) { $put( array() ); $put( array( $record['headings']['posts'] ) ); $put( array( $columns['post_id'], $columns['post_title'], $columns['written'], $columns['code'], $columns['duty'], $columns['editor'], $columns['reviewed'], $columns['url'], ) ); foreach ( $record['posts'] as $post ) { $put( array( $post['id'], $post['title'], $post['written'], $post['code'], $post['duty'], $post['editor'], $post['reviewed'], $post['url'], ) ); } } $put( array() ); $put( array( $record['headings']['literacy'] ) ); $put( array( $record['literacy']['summary'] ) ); $put( array( $columns['step'], $columns['status'], $columns['note'] ) ); foreach ( $record['literacy']['steps'] as $step ) { $put( array( $step['step'], $step['status'], $step['note'] ) ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- streaming to the HTTP response, not the filesystem. } /** * Gather one record per marked image attachment. * * @since 1.1.0 * * @return array[] One keyed record per marked attachment. */ protected function images() { $ids = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => 'image', 'fields' => 'ids', 'posts_per_page' => self::LIMIT, 'meta_key' => AICL_Plugin::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- exporting the marked subset only. 'no_found_rows' => true, 'update_post_term_cache' => false, ) ); AICL_Plugin::prime( $ids ); $images = array(); foreach ( $ids as $id ) { $token = AICL_Plugin::get_type( $id ); $file = get_attached_file( $id ); $images[] = array( 'id' => (int) $id, 'file_name' => $file ? wp_basename( $file ) : '', 'made' => AICL_Plugin::label_for( $token ), 'code' => (string) $token, 'mark' => self::mark( get_post_meta( $id, AICL_Plugin::META_EMBED, true ) ), 'url' => (string) wp_get_attachment_url( $id ), ); } return $images; } /** * Gather one record per post whose text has been marked. * * The two columns that matter here are the last two. A disclosure needs no defending: it is on * the page for anyone to read. An *exemption* does, and Article 50(4) grants it only where the * text had human review or editorial control **and** a natural or legal person holds editorial * responsibility for the publication. So the record names that person and gives the date the * claim was made. A row claiming exemption with those cells empty is exactly the row an auditor * should stop at, which is why it is printed rather than hidden. * * Two different things share the disclosure column: whether a reader sees one, and whether one * was owed. The wording keeps them apart, because a row saying "shown" for a site with no duty * is describing a choice, and an auditor should be able to see which is which. * * @since 2.1.0 * * @return array[] One keyed record per marked post. */ protected function posts() { $ids = get_posts( array( 'post_type' => AICL_Text::post_types(), 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 'fields' => 'ids', 'posts_per_page' => self::LIMIT, 'meta_key' => AICL_Text::META_TYPE, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- exporting the marked subset only. 'no_found_rows' => true, 'update_post_term_cache' => false, ) ); $labels = array( 'discloses' => __( 'Yes, a disclosure is shown', 'aim-transparency' ), 'incomplete' => __( 'Yes, and the exemption is incomplete', 'aim-transparency' ), 'exempt' => __( 'No, exempt after human review', 'aim-transparency' ), 'recorded' => __( 'Shown by choice, not required on this site', 'aim-transparency' ), 'none' => __( 'Not marked', 'aim-transparency' ), ); $off = __( 'No, the disclosure is switched off', 'aim-transparency' ); $posts = array(); foreach ( $ids as $id ) { $record = AICL_Text::record( $id ); $status = AICL_Text::status( $id ); $posts[] = array( 'id' => (int) $id, 'title' => get_the_title( $id ), 'written' => AICL_Plugin::label_for( $record['type'] ), 'code' => $record['type'], 'duty' => AICL_Text::discloses_on_page( $id ) || 'exempt' === $status ? ( isset( $labels[ $status ] ) ? $labels[ $status ] : $labels['none'] ) : $off, 'status' => $status, 'editor' => $record['editor'], 'reviewed' => $record['reviewed_on'], 'url' => (string) get_permalink( $id ), ); } return $posts; } /** * Build the Article 4 (AI literacy) section from the saved readiness checklist. * * Ticking a step is the site owner saying it happened, so the status reads * "Recorded", not "in place": the report should not claim to have verified * anything it has not. * * @since 1.1.0 * * @return array array( 'recorded' => int, 'total' => int, 'summary' => string, 'steps' => array[] ). */ protected function literacy() { $state = AICL_Readiness::get(); $labels = AICL_Readiness::labels(); $steps = array(); $recorded = 0; foreach ( $labels as $key => $label ) { $done = ! empty( $state[ $key ]['done'] ); $recorded += $done ? 1 : 0; $steps[] = array( 'step' => $label, 'recorded' => $done, 'status' => $done ? __( 'Recorded', 'aim-transparency' ) : __( 'Not yet recorded', 'aim-transparency' ), 'note' => isset( $state[ $key ]['note'] ) ? (string) $state[ $key ]['note'] : '', ); } $total = count( $labels ); return array( 'recorded' => $recorded, 'total' => $total, 'summary' => sprintf( /* translators: 1: steps recorded, 2: total steps. */ __( '%1$s of %2$s steps recorded', 'aim-transparency' ), number_format_i18n( $recorded ), number_format_i18n( $total ) ), 'steps' => $steps, ); } }