' . esc_html__( 'Dashboard', 'aim-transparency' ) . '' ); return $links; } /** * Enqueue the dashboard's styles and scripts on its admin page only. * * @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(), $js_ver, true ); wp_add_inline_style( 'aicl-dashboard', '#wpcontent{padding-left:0}#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}' ); } /** * 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. * * @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' ) ), 'userEmail' => $cu->user_email, 'user' => $initials, 'profileUrl' => esc_url_raw( admin_url( 'profile.php' ) ), 'onWaitlist' => $this->on_waitlist(), '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. * * @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' ), ), 'callback' => array( $this, 'rest_media_list' ), ) ); register_rest_route( 'aim-transparency/v1', '/media/flag', array( 'methods' => 'POST', // Looser than the dashboard (so the post-editor featured-image control works for // Editors/Authors); the handler still enforces edit_post on the specific 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' ), ) ); } /** * Return a paginated list of image attachments with their current flag, for in-dashboard tagging. * * @since 1.0.0 * * @param WP_REST_Request $request The REST request, with page, search, and filter 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' ); $args = array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => 'image', 'posts_per_page' => 24, 'paged' => $page, 'orderby' => 'date', 'order' => 'DESC', ); 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 ( 'unflagged' === $filter ) { $args['meta_query'] = array( array( 'key' => AICL_Plugin::META_KEY, 'compare' => 'NOT EXISTS' ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin library filter. } $q = new WP_Query( $args ); AICL_Plugin::prime( wp_list_pluck( $q->posts, 'ID' ) ); $items = array(); foreach ( $q->posts as $post ) { $id = $post->ID; $file = (string) get_attached_file( $id ); $thumb = wp_get_attachment_image_url( $id, 'thumbnail' ); $items[] = array( 'id' => $id, 'title' => get_the_title( $id ), 'filename' => $file ? wp_basename( $file ) : '', 'thumb' => $thumb ? $thumb : '', 'type' => AICL_Plugin::get_type( $id ), ); } return rest_ensure_response( array( 'items' => $items, '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 ) { $incoming = $request->get_json_params(); $current = AICL_Plugin::instance()->options(); $merged = array_merge( $current, is_array( $incoming ) ? $incoming : array() ); $clean = AICL_Settings::sanitize( $merged ); update_option( AICL_Plugin::OPTION, $clean ); 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. * * @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_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'], ); } /** * Compute live library stats from real attachment data. * * @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; } } /* Aggregated in SQL rather than by walking the flagged attachments. The previous loop fetched a bounded page of rows, so a library past that bound was silently under-reported: the dashboard showed a smaller flagged count than the site actually had, and coverage was computed over the truncated set. Grouping in the database is both correct at any size and cheaper than priming the meta cache for every row. */ $any_count = $wpdb->esc_like( 'embedded' ) . '%'; $has_count = $wpdb->esc_like( 'embedded ' ) . '%'; /* Collapsed to one row per attachment first. WordPress permits more than one postmeta row for the same key, and although this plugin only ever writes with update_post_meta, an import or a third-party tool can leave duplicates behind. Aggregating straight over the joins would then count an attachment twice, which the row-walk this replaced never did. */ $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' ); 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 = ( 'WebP' === $label && ! $exif ) || ( 'GIF' === $label && ! $exif ); $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 : '', 'token' => $token, ); } return array( 'total' => $total, 'flagged' => $flagged, 'embedded' => $files, 'unmarked' => 0, 'coverage' => $flagged ? (int) round( $emb_att / $flagged * 100 ) : 0, 'byFormat' => $by_format, 'recent' => $recent, ); } }