' . 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(), 'detected' => ( new AICL_Detector() )->plugins(), '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() { $counts = (array) wp_count_attachments(); $total = 0; foreach ( $counts as $mime => $n ) { if ( 0 === strpos( (string) $mime, 'image/' ) ) { $total += (int) $n; } } $flagged_ids = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids', 'posts_per_page' => 2000, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- admin-only, cached. 'meta_key' => AICL_Plugin::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- admin-only, cached. 'no_found_rows' => true, ) ); AICL_Plugin::prime( $flagged_ids ); $by = array(); // Keyed by mime-label, each value holds the total and embedded counts. $files = 0; $emb_att = 0; $exif = (bool) AICL_Embed::exiftool(); $labels = array( 'image/jpeg' => 'JPEG', 'image/png' => 'PNG', 'image/webp' => 'WebP', 'image/gif' => 'GIF' ); foreach ( $flagged_ids as $id ) { $mime = get_post_mime_type( $id ); $label = isset( $labels[ $mime ] ) ? $labels[ $mime ] : strtoupper( ltrim( strrchr( '/' . $mime, '/' ), '/' ) ); if ( ! isset( $by[ $label ] ) ) { $by[ $label ] = array( 'total' => 0, 'emb' => 0 ); } $by[ $label ]['total']++; $status = (string) get_post_meta( $id, AICL_Plugin::META_EMBED, true ); if ( 0 === strpos( $status, 'embedded' ) ) { $emb_att++; $by[ $label ]['emb']++; if ( preg_match( '/embedded\s+(\d+)/', $status, $m ) ) { $files += (int) $m[1]; } } } $flagged = count( $flagged_ids ); $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, ); } }