' . esc_html__( 'Dashboard', 'aim-transparency' ) . '' ); return $links; } /** * Enqueue the dashboard's styles and scripts on its admin page only. * * Script translations are registered separately because the bundle calls wp.i18n rather than * embedding a copy of it. Without that step the dashboard renders in English even when the .mo * files are loaded, since PHP translations never reach JavaScript. * * The vertical trims applied to the non-wp skins close the gap the plugin opens at the bottom of * the page. There is no full-bleed treatment any more: it existed when Neo and Classic drew their * own sidebars flush against wp-admin's, and all three skins now render into WordPress's own tab * layout with no sidebar at all. * * @since 1.0.0 * * @param string $hook The current admin page hook suffix. * @return void */ public function assets( $hook ) { if ( self::HOOK !== $hook ) { return; } $geist_css = AICL_DIR . 'dashboard/assets/fonts/geist.css'; $geist_ver = file_exists( $geist_css ) ? filemtime( $geist_css ) : AICL_VERSION; wp_enqueue_style( 'aicl-geist', AICL_URL . 'dashboard/assets/fonts/geist.css', array(), $geist_ver ); $family_css = AICL_DIR . 'dashboard/assets/fonts/family.css'; $family_ver = file_exists( $family_css ) ? filemtime( $family_css ) : AICL_VERSION; wp_enqueue_style( 'aicl-family-fonts', AICL_URL . 'dashboard/assets/fonts/family.css', array(), $family_ver ); $css_path = AICL_DIR . 'dashboard/assets/app.css'; $js_path = AICL_DIR . 'dashboard/assets/app.js'; $css_ver = file_exists( $css_path ) ? filemtime( $css_path ) : AICL_VERSION; $js_ver = file_exists( $js_path ) ? filemtime( $js_path ) : AICL_VERSION; wp_enqueue_style( 'aicl-dashboard', AICL_URL . 'dashboard/assets/app.css', array(), $css_ver ); wp_enqueue_script( 'aicl-dashboard', AICL_URL . 'dashboard/assets/app.js', array( 'wp-i18n', 'wp-element' ), $js_ver, true ); wp_set_script_translations( 'aicl-dashboard', 'aim-transparency', AICL_DIR . 'languages' ); $this->admin_scheme_css(); if ( 'wp' !== $this->skin() ) { wp_add_inline_style( 'aicl-dashboard', '#wpbody-content{padding-bottom:0}' . '#wpfooter{display:none}.plugin-ui .shell{min-height:calc(100vh - 32px)}' . 'body.toplevel_page_aim-transparency #wpbody-content > .notice,' . 'body.toplevel_page_aim-transparency #wpbody-content > .update-nag{display:none}' ); } } /** * Inline the admin colour scheme this user chose, as CSS variables. * * The WordPress skin is meant to look like the rest of this person's admin, and "the rest of their * admin" is whatever scheme they picked under Users > Profile. Hardcoding core's default blue makes * the plugin the one screen that ignores the choice, which is exactly the seam the skin exists to * remove. Reading $_wp_admin_css_colors means custom schemes registered by other plugins work too. * * Schemes declare between three and four colours. The last is the highlight and the one before it is * the primary, which holds across every scheme core ships. * * @since 2.0.0 * * @return void */ protected function admin_scheme_css() { global $_wp_admin_css_colors; $scheme = get_user_option( 'admin_color' ); $scheme = $scheme ? $scheme : 'fresh'; if ( ! isset( $_wp_admin_css_colors[ $scheme ]->colors ) ) { return; } $colors = array_values( (array) $_wp_admin_css_colors[ $scheme ]->colors ); $count = count( $colors ); if ( $count < 2 ) { return; } $primary = $colors[ $count - 2 ]; $highlight = $colors[ $count - 1 ]; wp_add_inline_style( 'aicl-dashboard', sprintf( '.plugin-ui[data-skin="wp"]{--accent:%1$s;--accent-hover:%2$s;}' . '.plugin-ui[data-skin="wp"] .switch input{accent-color:%1$s;}' . '.plugin-ui[data-skin="wp"] .segment button{border-color:%1$s;color:%1$s;}' . '.plugin-ui[data-skin="wp"] .segment button.on{background:%1$s;color:#fff;}' . '.plugin-ui[data-skin="wp"] .input:focus,.plugin-ui[data-skin="wp"] select:focus,' . '.plugin-ui[data-skin="wp"] textarea:focus{border-color:%1$s;box-shadow:0 0 0 1px %1$s;}', sanitize_hex_color( $primary ) ? $primary : '#2271b1', sanitize_hex_color( $highlight ) ? $highlight : '#72aee6' ) ); } /** * The skin this admin is using, read from the same cookie the dashboard writes. * * The choice lives client-side so it can switch without a reload, but the page chrome has to be * decided in PHP before render. Reading the cookie is the only way to get both, and an unknown or * missing value falls through to the default rather than guessing. * * @since 2.0.0 * * @return string One of wp, neo or classic. */ protected function skin() { $raw = isset( $_COOKIE['pui_skin'] ) ? sanitize_key( wp_unslash( $_COOKIE['pui_skin'] ) ) : ''; return in_array( $raw, array( 'wp', 'neo', 'classic' ), true ) ? $raw : 'wp'; } /** * Load the Vite ES-module bundle as a module. * * Filters only the external script tag's attributes, so the inline * window.AICL_DASH data attached via wp_add_inline_script() is untouched. * * @since 1.0.0 * * @param array $attributes The script tag attributes. * @return array The attributes with type="module" for the dashboard handle. */ public function module_attr( $attributes ) { if ( isset( $attributes['id'] ) && 'aicl-dashboard-js' === $attributes['id'] ) { $attributes['type'] = 'module'; } return $attributes; } /** * Output the dashboard mount point and inject its live data payload. * * `translated` drives the translation-feedback line in the footer. The bundled translations were * produced with AI assistance and checked against the Regulation's own language versions, but not * by a native speaker of every language, so the admins reading them are the people best placed to * catch what is left. English admins have nothing to report, hence the locale test. * * @since 1.0.0 * * @return void */ public function render() { if ( ! current_user_can( 'manage_options' ) ) { return; } $cu = wp_get_current_user(); $words = preg_split( '/\s+/', trim( $cu->display_name ), -1, PREG_SPLIT_NO_EMPTY ); $initials = $words ? strtoupper( substr( $words[0], 0, 1 ) . ( count( $words ) > 1 ? substr( (string) end( $words ), 0, 1 ) : '' ) ) : strtoupper( substr( $cu->user_login, 0, 2 ) ); $data = array( // ?rest_route= form: avoids permalink/canonical-redirect issues that break GET routes. 'restUrl' => esc_url_raw( home_url( '/' ) ) . '?rest_route=/', 'nonce' => wp_create_nonce( 'wp_rest' ), 'isPro' => AICL_Plugin::is_pro(), 'proInstalled' => AICL_Plugin::pro_installed(), 'exiftool' => (bool) AICL_Embed::exiftool(), 'accent' => $this->accent(), 'iconBase' => AICL_URL . 'assets/eu-icons/', 'sceneBase' => AICL_URL . 'dashboard/assets/scenes/', 'adminUrl' => esc_url_raw( admin_url( 'upload.php' ) ), 'postsUrl' => esc_url_raw( admin_url( 'edit.php' ) ), 'siteUrl' => esc_url_raw( home_url( '/' ) ), 'userEmail' => $cu->user_email, 'user' => $initials, 'profileUrl' => esc_url_raw( admin_url( 'profile.php' ) ), 'onWaitlist' => $this->on_waitlist(), 'translated' => 0 !== strpos( determine_locale(), 'en' ), 'directory' => ( new AICL_Directory() )->state(), 'settings' => $this->settings_payload(), 'stats' => $this->stats(), 'readiness' => AICL_Readiness::get(), ); wp_add_inline_script( 'aicl-dashboard', 'window.AICL_DASH = ' . wp_json_encode( $data ) . ';', 'before' ); echo '
'; } /* ---------------------------------------------------------------------- */ /* REST */ /* ---------------------------------------------------------------------- */ /** * Register the dashboard's REST API routes. * * /media/flag takes `upload_files` rather than `manage_options` so the post-editor featured-image * control works for Editors and Authors. The handler still enforces edit_post on the specific * attachment, so the looser route does not widen what anyone can actually change. * * @since 1.0.0 * * @return void */ public function rest() { $perm = function () { return current_user_can( 'manage_options' ); }; register_rest_route( 'aim-transparency/v1', '/settings', array( array( 'methods' => 'GET', 'permission_callback' => $perm, 'callback' => function () { return rest_ensure_response( $this->settings_payload() ); }, ), array( 'methods' => 'POST', 'permission_callback' => $perm, 'callback' => array( $this, 'rest_save' ), ), ) ); register_rest_route( 'aim-transparency/v1', '/waitlist', array( 'methods' => 'POST', 'permission_callback' => $perm, 'args' => array( 'email' => array( 'type' => 'string', 'required' => true, 'format' => 'email', ), ), 'callback' => array( $this, 'rest_waitlist' ), ) ); register_rest_route( 'aim-transparency/v1', '/media', array( 'methods' => 'GET', 'permission_callback' => $perm, 'args' => array( 'page' => array( 'type' => 'integer', 'default' => 1, ), 'search' => array( 'type' => 'string' ), 'filter' => array( 'type' => 'string' ), 'ids' => array( 'type' => 'string' ), ), 'callback' => array( $this, 'rest_media_list' ), ) ); register_rest_route( 'aim-transparency/v1', '/media/flag', array( 'methods' => 'POST', // Looser than the dashboard; the handler still enforces edit_post per attachment. 'permission_callback' => function () { return current_user_can( 'upload_files' ); }, 'args' => array( 'id' => array( 'type' => 'integer', 'required' => true, ), 'type' => array( 'type' => 'string' ), ), 'callback' => array( $this, 'rest_media_flag' ), ) ); } /** * Whether a browser can render the file at this URL. * * Judged on the extension actually being served rather than the attachment's own type, because * a host whose Imagick has the TIFF delegate produces JPEG sub-sizes and those display fine. It * is only where no derivative exists, and the raw file is what the screen gets, that the answer * is no. Saying "this image is not on the server" about a TIFF that is sitting right there would * be a plain falsehood, which in a compliance plugin costs more than a missing thumbnail. * * @since 2.1.5 * * @param string $url URL of the file the screen intends to display. * @return bool True when a browser can paint it. */ protected static function paintable( $url ) { $path = (string) wp_parse_url( (string) $url, PHP_URL_PATH ); $ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ); return in_array( $ext, array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg' ), true ); } /** * Counts the attachments this screen will never list. * * Audio, video, documents and anything else are excluded at the query, because the plugin can * only write an IPTC mark into an image. Reporting the number turns a short list into a stated * scope, which is the difference between a library that looks broken and one that is bounded. * * Non-image types are added up directly rather than subtracted from a total. Not every key * `wp_count_attachments()` returns is a MIME type: it appends a `trash` count, which the MIME * rows already exclude. Subtracting images from the sum of every key therefore reports each * trashed image as a non-image, and would report any future non-MIME key the same way. * * @since 2.1.5 * * @return int Attachments that are not images. */ protected static function non_image_count() { $counts = (array) wp_count_attachments(); $other = 0; foreach ( $counts as $mime => $n ) { if ( 'trash' === $mime ) { continue; } if ( 0 !== strpos( (string) $mime, 'image/' ) ) { $other += (int) $n; } } return $other; } /** * Return a paginated list of image attachments with their current flag, for in-dashboard tagging. * * Accepts an explicit id list because the detector reports its findings as bare integers, and a * screen that asks somebody to tick "is this one AI?" has to show them the picture and the file name. * Without this the wizard could only print numbers and ask the owner to trust them. * * @since 1.0.0 * * @param WP_REST_Request $request The REST request, with page, search, filter and ids params. * @return WP_REST_Response The paginated list of attachments and pagination metadata. */ public function rest_media_list( $request ) { $page = max( 1, (int) $request->get_param( 'page' ) ); $search = sanitize_text_field( (string) $request->get_param( 'search' ) ); $filter = (string) $request->get_param( 'filter' ); $wanted = array_values( array_filter( array_map( 'absint', explode( ',', (string) $request->get_param( 'ids' ) ) ) ) ); // ID breaks ties on date. Uploads made in the same second sort equal on date alone, and MySQL // is free to return equal rows in any order, so a row could sit on page 1 in one request and // page 2 in the next. That moves the picture under the arrows and makes the "N of M" counter // point at a different image between loads, which on a screen that records what an image is // matters more than it would on an ordinary gallery. $args = array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => 'image', 'posts_per_page' => 24, 'paged' => $page, 'orderby' => array( 'date' => 'DESC', 'ID' => 'DESC', ), ); if ( ! empty( $wanted ) ) { $args['post__in'] = array_slice( $wanted, 0, 24 ); $args['orderby'] = 'post__in'; $args['paged'] = 1; $args['posts_per_page'] = count( $args['post__in'] ); } if ( '' !== $search ) { $args['s'] = $search; } if ( 'flagged' === $filter ) { $args['meta_key'] = AICL_Plugin::META_KEY; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- admin library filter. } elseif ( 'ai' === $filter ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin library filter. $args['meta_query'] = array( array( 'key' => AICL_Plugin::META_KEY, 'value' => AICL_Plugin::ai_types(), 'compare' => 'IN', ), ); } elseif ( 'human' === $filter ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin library filter. $args['meta_query'] = array( array( 'key' => AICL_Plugin::META_KEY, 'value' => AICL_Plugin::NON_AI_TYPES, 'compare' => 'IN', ), ); } elseif ( 'unflagged' === $filter ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin library filter. $args['meta_query'] = array( array( 'key' => AICL_Plugin::META_KEY, 'compare' => 'NOT EXISTS', ), ); } $args['update_post_term_cache'] = false; $args['lazy_load_term_meta'] = false; $q = new WP_Query( $args ); AICL_Plugin::prime( wp_list_pluck( $q->posts, 'ID' ) ); $items = array(); $uploads = wp_get_upload_dir(); $baseurl = trailingslashit( (string) $uploads['baseurl'] ); foreach ( $q->posts as $post ) { $id = $post->ID; $file = (string) get_attached_file( $id ); $meta = wp_get_attachment_metadata( $id ); $mark = AICL_Report::mark( (string) get_post_meta( $id, AICL_Plugin::META_EMBED, true ) ); $rel = isset( $meta['file'] ) ? (string) $meta['file'] : ''; $sub = ( $rel && false !== strpos( $rel, '/' ) ) ? trailingslashit( dirname( $rel ) ) : ''; $sizes = ( isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) ? $meta['sizes'] : array(); $full = $rel ? $baseurl . $rel : (string) wp_get_attachment_url( $id ); $thumb = isset( $sizes['thumbnail']['file'] ) ? $baseurl . $sub . $sizes['thumbnail']['file'] : $full; $medium = isset( $sizes['medium']['file'] ) ? $baseurl . $sub . $sizes['medium']['file'] : $full; // The detail view shows `large`, not the original: an arrow press should not pull a // multi-megabyte camera file. `full` stays for the open-original link. $large = isset( $sizes['large']['file'] ) ? $baseurl . $sub . $sizes['large']['file'] : $full; $items[] = array( 'id' => $id, 'title' => get_the_title( $id ), 'filename' => $file ? wp_basename( $file ) : '', // Whether a browser can actually paint the file being handed over. TIFF and HEIC are // perfectly good uploads that WordPress accepts and this plugin marks, but no browser // decodes them, and where the server has no delegate to make JPEG sub-sizes the raw // file is what gets served. Judged on the URL being sent, not the original's type, so // a host that does produce derivatives is correctly reported as displayable. 'displayable' => self::paintable( $large ? $large : $full ), 'thumb' => $thumb ? $thumb : '', 'medium' => $medium ? $medium : ( $thumb ? $thumb : '' ), 'full' => $full ? $full : ( $medium ? $medium : '' ), 'large' => $large ? $large : ( $full ? $full : '' ), 'type' => AICL_Plugin::get_type( $id ), 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : 0, 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : 0, 'filesize' => isset( $meta['filesize'] ) ? (int) $meta['filesize'] : 0, 'mime' => (string) get_post_mime_type( $id ), 'date' => (string) get_the_date( '', $id ), 'alt' => (string) get_post_meta( $id, '_wp_attachment_image_alt', true ), 'sizes' => ( isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) ? count( $meta['sizes'] ) : 0, 'hasOriginal' => ! empty( $meta['original_image'] ), // Whether that original gets the mark as well. Since 2.1.5 it does, unless a site // filters it off, and the screen has to say which of the two is true rather than // assuming the pre-2.1.5 answer. 'marksOriginal' => (bool) apply_filters( 'aicl_mark_original_image', true, $id ), 'embedState' => (string) $mark['state'], 'embedText' => (string) $mark['text'], 'editLink' => admin_url( 'post.php?post=' . $id . '&action=edit' ), ); } return rest_ensure_response( array( 'items' => $items, // Everything the library holds that this screen never shows; see non_image_count(). 'skipped' => self::non_image_count(), 'total' => (int) $q->found_posts, 'pages' => (int) $q->max_num_pages, 'page' => $page, ) ); } /** * Set or clear an attachment's AI flag (per-item capability checked). * * @since 1.0.0 * * @param WP_REST_Request $request The REST request, with the attachment id and flag type. * @return WP_REST_Response|WP_Error The updated flag state, or an error if not permitted. */ public function rest_media_flag( $request ) { $id = (int) $request->get_param( 'id' ); $type = sanitize_text_field( (string) $request->get_param( 'type' ) ); if ( ! $id || ! current_user_can( 'edit_post', $id ) ) { return new WP_Error( 'aicl_forbidden', __( 'Not allowed.', 'aim-transparency' ), array( 'status' => 403 ) ); } if ( AICL_Plugin::is_valid_type( $type ) ) { update_post_meta( $id, AICL_Plugin::META_KEY, $type ); AICL_Embed::embed_attachment( $id ); } else { delete_post_meta( $id, AICL_Plugin::META_KEY ); delete_post_meta( $id, AICL_Plugin::META_EMBED ); } AICL_Plugin::flush_flagged_count(); return rest_ensure_response( array( 'id' => $id, 'type' => AICL_Plugin::get_type( $id ), 'embed' => (string) get_post_meta( $id, AICL_Plugin::META_EMBED, true ), ) ); } /** * Store the site owner's email for the Pro waitlist (admin-only, so no spam surface). * * @since 1.0.0 * * @param WP_REST_Request $request The REST request, with the email param. * @return WP_REST_Response|WP_Error A success response, or an error for an invalid email. */ public function rest_waitlist( $request ) { $email = sanitize_email( (string) $request->get_param( 'email' ) ); if ( ! is_email( $email ) ) { return new WP_Error( 'aicl_invalid_email', __( 'Please enter a valid email.', 'aim-transparency' ), array( 'status' => 400 ) ); } $list = get_option( 'aicl_waitlist', array() ); if ( ! is_array( $list ) ) { $list = array(); } $list[ $email ] = current_time( 'mysql' ); update_option( 'aicl_waitlist', $list, false ); // Forward to the central backend (which holds the email-tool key). Non-blocking. /** * Filter the Pro waitlist endpoint URL. * * @since 1.0.0 * * @param string $endpoint The waitlist endpoint URL. */ $endpoint = apply_filters( 'aicl_waitlist_endpoint', 'https://directory.aimtransparency.com/api/waitlist' ); if ( $endpoint ) { wp_remote_post( $endpoint, array( 'timeout' => 5, 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'email' => $email, 'source' => 'plugin', ) ), ) ); } return rest_ensure_response( array( 'ok' => true ) ); } /** * Determine whether the current user's email is already on the Pro waitlist. * * @since 1.0.0 * * @return bool True if the current user's email is on the waitlist, false otherwise. */ protected function on_waitlist() { $list = get_option( 'aicl_waitlist', array() ); $email = wp_get_current_user()->user_email; return is_array( $list ) && $email && isset( $list[ $email ] ); } /** * Merge, sanitize, and persist the dashboard's incoming settings. * * @since 1.0.0 * * @param WP_REST_Request $request The REST request, carrying the settings JSON body. * @return WP_REST_Response A success response with the saved settings payload. */ public function rest_save( $request ) { $clean = AICL_Plugin::apply_settings( $request->get_json_params() ); return rest_ensure_response( array( 'ok' => true, 'settings' => $this->settings_payload( $clean ), ) ); } /** * Accent color = the highlight of the current user's WP admin color scheme * (Profile > Admin Color Scheme), falling back to the brand purple. * * @since 1.0.0 * * @return string The accent color as a hex string. */ protected function accent() { $slug = get_user_option( 'admin_color' ); if ( ! $slug ) { $slug = 'fresh'; } $map = array( 'fresh' => '#2271b1', 'light' => '#04a4cc', 'modern' => '#3858e9', 'blue' => '#096484', 'coffee' => '#c7a589', 'ectoplasm' => '#a3b745', 'midnight' => '#e14d43', 'ocean' => '#9ebaa0', 'sunrise' => '#dd823b', ); if ( isset( $map[ $slug ] ) ) { return $map[ $slug ]; } global $_wp_admin_css_colors; if ( isset( $_wp_admin_css_colors[ $slug ]->colors ) && ! empty( $_wp_admin_css_colors[ $slug ]->colors ) ) { $c = $_wp_admin_css_colors[ $slug ]->colors; return isset( $c[2] ) ? $c[2] : reset( $c ); } return '#6d5cff'; } /* ---------------------------------------------------------------------- */ /* Payloads */ /* ---------------------------------------------------------------------- */ /** * Build the settings payload sent to the dashboard, cast to predictable types. * * `public_interest` stays a string. Casting it to bool here would collapse "not asked yet" into * "no" before the screen ever saw the difference, and the Settings control is a three-way choice * precisely so the owner can see which one they are in. * * @since 1.0.0 * * @param array|null $opts Optional options array; falls back to the stored plugin options. * @return array The normalized settings payload. */ protected function settings_payload( $opts = null ) { $o = $opts ? $opts : AICL_Plugin::instance()->options(); return array( 'badge_enabled' => (bool) $o['badge_enabled'], 'badge_style' => (string) $o['badge_style'], 'badge_text' => (string) $o['badge_text'], 'badge_position' => (string) $o['badge_position'], 'badge_bg' => (string) $o['badge_bg'], 'badge_color' => (string) $o['badge_color'], 'badge_shape' => (string) $o['badge_shape'], 'badge_dot' => (bool) $o['badge_dot'], 'badge_modal' => (bool) $o['badge_modal'], 'badge_labels' => is_array( $o['badge_labels'] ) ? $o['badge_labels'] : array(), 'badge_show' => array_map( 'boolval', wp_parse_args( is_array( $o['badge_show'] ) ? $o['badge_show'] : array(), AICL_Plugin::defaults()['badge_show'] ) ), 'badge_radius' => (int) $o['badge_radius'], 'badge_border' => (int) $o['badge_border'], 'badge_border_color' => (string) $o['badge_border_color'], 'badge_shadow' => (int) $o['badge_shadow'], 'badge_modal_title' => (string) $o['badge_modal_title'], 'badge_modal_text' => (string) $o['badge_modal_text'], 'badge_logo' => (string) $o['badge_logo'], 'embed_metadata' => (bool) $o['embed_metadata'], 'jsonld_enabled' => (bool) $o['jsonld_enabled'], 'scan_all' => (bool) $o['scan_all'], 'auto_flag' => (bool) $o['auto_flag'], 'chatbot_notice' => (bool) $o['chatbot_notice'], 'chatbot_notice_text' => (string) $o['chatbot_notice_text'], 'chatbot_notice_position' => (string) $o['chatbot_notice_position'], 'chatbot_notice_selectors' => (string) $o['chatbot_notice_selectors'], 'public_interest' => (string) $o['public_interest'], 'text_disclosure' => (bool) $o['text_disclosure'], 'text_disclosure_position' => (string) $o['text_disclosure_position'], 'text_disclosure_text' => (string) $o['text_disclosure_text'], 'text_disclosure_style' => (string) $o['text_disclosure_style'], 'text_disclosure_bg' => (string) $o['text_disclosure_bg'], 'text_disclosure_color' => (string) $o['text_disclosure_color'], 'text_disclosure_mark' => (bool) $o['text_disclosure_mark'], 'text_disclosure_mark_text' => (string) $o['text_disclosure_mark_text'], 'text_disclosure_mark_style' => (string) $o['text_disclosure_mark_style'], 'text_disclosure_radius' => (int) $o['text_disclosure_radius'], 'text_disclosure_mark_radius' => (int) $o['text_disclosure_mark_radius'], 'text_disclosure_mark_bg' => (string) $o['text_disclosure_mark_bg'], 'text_disclosure_mark_color' => (string) $o['text_disclosure_mark_color'], ); } /** * Compute live library stats from real attachment data. * * The per-format figures are aggregated in SQL rather than by walking the flagged attachments. The * loop this replaced fetched a bounded page of rows, so a library past that bound was silently * under-reported. Rows are collapsed to one per attachment first, because WordPress permits more * than one postmeta row for a key and an import can leave duplicates that would otherwise be * counted twice. * * The built-in PHP writer covers every format this plugin supports as of 2.2.0, so no format is * reported as blocked on a server tool any more. The list is kept, and empty, because the screen * has twice fallen out of step with what the embedder can actually do: a format covered by the * writer while the screen still called it blocked, and the reverse. One list, checked in one * place, is what stops that recurring. * * @since 1.0.0 * * @return array The dashboard stats payload (totals, coverage, per-format, and recent items). */ protected function stats() { global $wpdb; $counts = (array) wp_count_attachments(); $total = 0; foreach ( $counts as $mime => $n ) { if ( 0 === strpos( (string) $mime, 'image/' ) ) { $total += (int) $n; } } $any_count = $wpdb->esc_like( 'embedded' ) . '%'; $has_count = $wpdb->esc_like( 'embedded ' ) . '%'; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- aggregate over postmeta, admin-only, no core API expresses it. $wpdb->prepare( "SELECT mime, COUNT(*) AS total, SUM( is_emb ) AS emb, SUM( n_files ) AS files FROM ( SELECT p.ID, p.post_mime_type AS mime, MAX( CASE WHEN em.meta_value LIKE %s THEN 1 ELSE 0 END ) AS is_emb, MAX( CASE WHEN em.meta_value LIKE %s THEN CAST( SUBSTRING_INDEX( SUBSTRING_INDEX( em.meta_value, 'embedded ', -1 ), ' ', 1 ) AS UNSIGNED ) ELSE 0 END ) AS n_files FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} fm ON fm.post_id = p.ID AND fm.meta_key = %s LEFT JOIN {$wpdb->postmeta} em ON em.post_id = p.ID AND em.meta_key = %s WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' GROUP BY p.ID, p.post_mime_type ) t GROUP BY mime", $any_count, $has_count, AICL_Plugin::META_KEY, AICL_Plugin::META_EMBED ) ); $by = array(); // Keyed by mime-label, each value holds the total and embedded counts. $files = 0; $emb_att = 0; $flagged = 0; $exif = (bool) AICL_Embed::exiftool(); $labels = array( 'image/jpeg' => 'JPEG', 'image/png' => 'PNG', 'image/webp' => 'WebP', 'image/gif' => 'GIF', 'image/avif' => 'AVIF', 'image/tiff' => 'TIFF', 'image/heic' => 'HEIC', 'image/heif' => 'HEIF', ); /* * Formats the built-in PHP writer cannot open. Empty since 2.2.0, when the AVIF writer * landed and completed the set: JPEG, PNG, WebP, GIF, TIFF and AVIF/HEIC/HEIF are all * written without a server tool. Anything added here again must genuinely be unwritable * in PHP. */ $needs_exiftool = array(); foreach ( (array) $rows as $row ) { $mime = (string) $row->mime; $label = isset( $labels[ $mime ] ) ? $labels[ $mime ] : strtoupper( ltrim( strrchr( '/' . $mime, '/' ), '/' ) ); if ( ! isset( $by[ $label ] ) ) { $by[ $label ] = array( 'total' => 0, 'emb' => 0, ); } $by[ $label ]['total'] += (int) $row->total; $by[ $label ]['emb'] += (int) $row->emb; $flagged += (int) $row->total; $emb_att += (int) $row->emb; $files += (int) $row->files; } $by_format = array(); foreach ( $by as $label => $d ) { $pct = $d['total'] ? (int) round( $d['emb'] / $d['total'] * 100 ) : 0; $warn = ! $exif && in_array( $label, $needs_exiftool, true ); $by_format[] = array( 'label' => $label, 'pct' => $warn && 0 === $pct ? 8 : $pct, 'note' => $warn ? __( 'needs exiftool', 'aim-transparency' ) : ( $d['emb'] . ' / ' . $d['total'] ), 'warn' => $warn, ); } $recent_ids = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids', 'posts_per_page' => 5, 'orderby' => 'modified', 'order' => 'DESC', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- admin-only, cached. 'meta_key' => AICL_Plugin::META_KEY, 'no_found_rows' => true, ) ); $recent = array(); foreach ( $recent_ids as $id ) { $token = AICL_Plugin::get_type( $id ); $status = (string) get_post_meta( $id, AICL_Plugin::META_EMBED, true ); $thumb = wp_get_attachment_image_url( $id, 'thumbnail' ); $file = (string) get_attached_file( $id ); $ext = $file ? pathinfo( $file, PATHINFO_EXTENSION ) : ''; if ( preg_match( '/^embedded (\d+)/', $status, $m ) ) { $status = 'embedded in ' . $m[1] . ( '1' === $m[1] ? ' file' : ' files' ) . substr( $status, strlen( 'embedded ' . $m[1] ) ); } $recent[] = array( 'title' => get_the_title( $id ) . ( $ext ? '.' . $ext : '' ), 'sub' => AICL_Plugin::label_for( $token ) . ( $status ? ' ยท ' . $status : '' ), 'thumb' => $thumb ? $thumb : '', // Same reason as the Media list: a TIFF or HEIC with no JPEG derivative is served raw // and no browser can draw it, so the row would show a torn image for a file that is // perfectly fine. Naming the format is honest; a broken icon is not. 'displayable' => self::paintable( $thumb ), 'mime' => (string) get_post_mime_type( $id ), 'token' => $token, ); } return array( 'total' => $total, 'flagged' => $flagged, 'embedded' => $files, 'unmarked' => 0, 'coverage' => $flagged ? (int) round( $emb_att / $flagged * 100 ) : 0, 'byFormat' => $by_format, 'delivery' => AICL_Delivery::last(), 'recent' => $recent, ); } }