' . 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( 'wp-i18n', 'wp-element' ), $js_ver, true ); /* The bundle calls wp.i18n rather than embedding a copy of the library, so the locale data has to be handed to that runtime separately. Without this the dashboard renders in English even when the plugin's .mo files are loaded, because PHP translations never reach JavaScript. */ wp_set_script_translations( 'aicl-dashboard', 'aim-transparency', AICL_DIR . 'languages' ); $this->admin_scheme_css(); /* The full-bleed treatment dated from when Neo and Classic each drew their own sidebar, which had to sit flush against wp-admin's. All three skins now render into WordPress's own tab layout and none has a sidebar, so zeroing the gutter only pushed the panel against the admin menu with nothing left to justify it. The vertical trims stay: they close the gap the plugin itself opens at the bottom of the page. */ 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. * * @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' ) ), 'siteUrl' => esc_url_raw( home_url( '/' ) ), 'userEmail' => $cu->user_email, 'user' => $initials, 'profileUrl' => esc_url_raw( admin_url( 'profile.php' ) ), 'onWaitlist' => $this->on_waitlist(), // Drives the translation-feedback line in the dashboard 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 people best placed to // catch what is left are the admins reading it. English admins have nothing to report. '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. * * @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 (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. * * 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' ) ) ) ) ); $args = array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => 'image', 'posts_per_page' => 24, 'paged' => $page, 'orderby' => 'date', 'order' => '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'] ); unset( $args['order'] ); } 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 ) { $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' ); $medium = wp_get_attachment_image_url( $id, 'medium' ); $items[] = array( 'id' => $id, 'title' => get_the_title( $id ), 'filename' => $file ? wp_basename( $file ) : '', 'thumb' => $thumb ? $thumb : '', 'medium' => $medium ? $medium : ( $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 ) { $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. * * @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', 'image/avif' => 'AVIF', 'image/tiff' => 'TIFF', ); /* The formats the built-in PHP writer cannot open, so writing into them needs exiftool on the server. Listed once here rather than tested inline: the condition below used to name WebP and GIF only, which left AVIF and TIFF reporting a bare "0 / 5" with nothing saying why, even though the embedder writes them happily wherever exiftool exists. */ $needs_exiftool = array( 'WebP', 'GIF', 'AVIF', 'TIFF' ); 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 : '', '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, ); } }