'GET', 'permission_callback' => $perm, 'callback' => array( $this, 'rest_get' ), ) ); register_rest_route( 'aim-transparency/v1', '/setup/step', array( 'methods' => 'POST', 'permission_callback' => $perm, 'callback' => array( $this, 'rest_step' ), 'args' => array( 'step' => array( 'required' => true, 'validate_callback' => function ( $v ) { return in_array( $v, self::STEPS, true ); }, ), 'action' => array( 'required' => true ), ), ) ); register_rest_route( 'aim-transparency/v1', '/setup/undo', array( 'methods' => 'POST', 'permission_callback' => $perm, 'callback' => array( $this, 'rest_undo' ), 'args' => array( 'entry' => array( 'required' => true ) ), ) ); } /** * The stored flow state, with defaults filled in. * * @since 2.0.0 * * @return array{done:array,journal:array,started:string,finished:string} */ public static function state() { $saved = get_option( self::OPTION, array() ); return wp_parse_args( is_array( $saved ) ? $saved : array(), array( 'done' => array(), 'journal' => array(), 'started' => '', 'finished' => '', ) ); } /** * The human sentence for a journalled action, built when the record is read. * * The wording must not be stored. A sentence translated at write time is frozen in whatever language * the admin happened to be using at that moment, so the record would keep showing French to a reader * who has since switched to English. Storing the action and its values instead means the record is * always in the reader's own language. * * Skips get a sentence of their own rather than a silence, because a record that lists only the things * that went well is marketing. "Left for later" is evidence; a gap is not. * * @since 2.0.0 * * @param string $action The recorded action key. * @param string[] $params Values captured with it. * @return string The sentence to display. */ public static function detail_for( $action, $params = array() ) { $one = isset( $params[0] ) ? $params[0] : ''; $two = isset( $params[1] ) ? $params[1] : ''; switch ( $action ) { case 'usage_skipped': return __( 'You left the question about AI images unanswered. Nothing was assumed either way.', 'aim-transparency' ); case 'declared_ai_images': return __( 'You told us some images on this site are made or edited with AI. No image was marked by this answer.', 'aim-transparency' ); case 'declared_no_ai_images': return __( 'You told us nothing on this site is made with AI. No setting changed and no image was marked.', 'aim-transparency' ); case 'declared_unsure': return __( 'You told us you are not sure whether this site publishes AI images. Nothing was assumed either way.', 'aim-transparency' ); case 'images_skipped': return __( 'Left for later: no image was marked. Your library is unchanged.', 'aim-transparency' ); case 'images_marked': return sprintf( /* translators: 1: number of images, 2: the source type they were marked as. */ _n( '%1$s image you ticked was marked as %2$s.', '%1$s images you ticked were marked as %2$s.', (int) $one, 'aim-transparency' ), number_format_i18n( (int) $one ), AICL_Plugin::label_for( $two ) ); case 'images_dismissed': return sprintf( /* translators: %s: number of images. */ _n( 'You looked at %s file and left it unmarked. It will not be raised again.', 'You looked at %s files and left them unmarked. They will not be raised again.', (int) $one, 'aim-transparency' ), number_format_i18n( (int) $one ) ); case 'badge_skipped': return __( 'Left for later: the badge is as it was.', 'aim-transparency' ); case 'badge_configured': return sprintf( /* translators: 1: badge style, 2: badge corner. */ __( 'Badge on, %1$s style, %2$s. Metadata and JSON-LD on.', 'aim-transparency' ), $one, $two ); case 'chatbot_skipped': return __( 'Left for later: the chat disclosure is as it was.', 'aim-transparency' ); case 'declared_human_staffed': return __( 'You told us people answer this chat, so no AI notice was added.', 'aim-transparency' ); case 'notice_enabled_ai': return __( 'Notice on. You told us this chat replies with AI.', 'aim-transparency' ); case 'notice_enabled_mixed': return __( 'Notice on. You told us this chat sometimes replies with AI.', 'aim-transparency' ); case 'literacy_skipped': return __( 'Left for later: your Article 4 record is exactly as it was.', 'aim-transparency' ); case 'literacy_ticked': return sprintf( /* translators: 1: number of statements ticked, 2: total statements. */ __( 'You recorded %1$s of %2$s Article 4 statements as true of this site. This is your own attestation; we did not check it.', 'aim-transparency' ), number_format_i18n( (int) $one ), number_format_i18n( (int) $two ) ); case 'setup_finished': return __( 'You marked the set-up flow finished.', 'aim-transparency' ); } return ''; } /** * Everything the setup screen needs to render, computed without a single HTTP request. * * The scan outcome is read from the cached transient only. Setup must never trigger a fresh loopback * fetch: that is up to six requests with a five-second timeout each, and paying it on screen entry * would make the flow feel broken on exactly the sites whose loopback is blocked. * * @since 2.0.0 * * @return array The bootstrap payload. */ public static function payload() { $state = self::state(); $detector = new AICL_Detector(); $systems = $detector->systems( false, true ); $settings = AICL_Plugin::instance()->options(); $chats = array(); foreach ( $systems as $system ) { if ( isset( $system['category'] ) && 'chatbot' === $system['category'] ) { $chats[] = array( 'id' => isset( $system['id'] ) ? $system['id'] : '', 'name' => isset( $system['name'] ) ? $system['name'] : '', 'staffing' => isset( $system['staffing'] ) ? $system['staffing'] : 'unknown', 'how' => isset( $system['how'] ) ? $system['how'] : array(), ); } } $cached = get_transient( 'aicl_detect_html' ); return array( 'done' => $state['done'], 'journal' => array_map( function ( $row ) { $row['detail'] = self::detail_for( isset( $row['action'] ) ? $row['action'] : '', isset( $row['params'] ) ? (array) $row['params'] : array() ); return $row; }, array_slice( (array) $state['journal'], 0, 20 ) ), 'started' => $state['started'], 'finished' => $state['finished'], 'systems' => $systems, 'chatbots' => $chats, 'flagged' => AICL_Plugin::flagged_count(), 'readiness' => AICL_Readiness::get(), 'showcase' => self::showcase(), 'settings' => array( 'badge_enabled' => (int) $settings['badge_enabled'], 'badge_style' => $settings['badge_style'], 'badge_position' => $settings['badge_position'], 'embed_metadata' => (int) $settings['embed_metadata'], 'jsonld_enabled' => (int) $settings['jsonld_enabled'], 'chatbot_notice' => (int) $settings['chatbot_notice'], ), 'scan' => is_array( $cached ) ? array( 'ok' => ! empty( $cached['ok'] ), 'reason' => isset( $cached['reason'] ) ? $cached['reason'] : '', 'pages' => isset( $cached['pages'] ) ? (int) $cached['pages'] : 0, 'at' => isset( $cached['at'] ) ? $cached['at'] : '', ) : null, ); } /** * A published page of this site that genuinely shows a marked image, or null when there is none. * * The closing panel wants to hand the owner a link that answers "is my shop done?", and the home page * is the wrong answer: on most sites it shows none of the marked images, so the badge is absent and * the plugin looks broken. A post whose featured image carries the flag is a link where the badge is * genuinely rendered, because AICL_Frontend filters post_thumbnail_html. When no such post exists the * screen asks the owner which page to open rather than guessing on their behalf. * * @since 1.4.1 * * @return array|null Array with url, title and how, or null when nothing qualifies. */ protected static function showcase() { global $wpdb; if ( AICL_Plugin::flagged_count() < 1 ) { return null; } $post_id = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- join from featured-image meta to the flag meta, not expressible via WP_Query. $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} tm ON tm.post_id = p.ID AND tm.meta_key = '_thumbnail_id' INNER JOIN {$wpdb->postmeta} fm ON fm.post_id = tm.meta_value AND fm.meta_key = %s WHERE p.post_status = 'publish' ORDER BY p.post_date DESC LIMIT 1", AICL_Plugin::META_KEY ) ); if ( $post_id > 0 ) { return array( 'url' => get_permalink( $post_id ), 'title' => get_the_title( $post_id ), 'how' => 'featured', ); } $names = (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded lookup of the newest flagged files, admin-only. $wpdb->prepare( "SELECT m.meta_value FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->postmeta} fm ON fm.post_id = m.post_id AND fm.meta_key = %s WHERE m.meta_key = '_wp_attached_file' ORDER BY m.post_id DESC LIMIT 5", AICL_Plugin::META_KEY ) ); foreach ( $names as $name ) { $base = wp_basename( (string) $name ); if ( '' === $base ) { continue; } $found = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded content lookup, admin-only. $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type NOT IN ( 'attachment', 'revision' ) AND post_content LIKE %s ORDER BY post_date DESC LIMIT 1", '%' . $wpdb->esc_like( $base ) . '%' ) ); if ( $found > 0 ) { return array( 'url' => get_permalink( $found ), 'title' => get_the_title( $found ), 'how' => 'content', ); } } return null; } /** * Return the bootstrap payload. * * @since 2.0.0 * * @param WP_REST_Request $request The request; unused. * @return WP_REST_Response The payload. */ public function rest_get( $request ) { return rest_ensure_response( self::payload() ); } /** * Perform one step and journal what it changed. * * The response carries the refreshed payload plus a `result` block for the call itself, which is how * a marking batch reports back the ids it was not permitted to touch. Dropping those silently would * let "marked" mean less on screen than it does in the database. * * @since 2.0.0 * * @param WP_REST_Request $request The request carrying step, action and an optional payload. * @return WP_REST_Response|WP_Error The refreshed payload, or an error when the step is not supported. */ public function rest_step( $request ) { $step = (string) $request->get_param( 'step' ); $action = (string) $request->get_param( 'action' ); $data = (array) $request->get_param( 'payload' ); switch ( $step ) { case 'usage': $result = $this->do_usage( $action ); break; case 'images': $result = $this->do_images( $action, $data ); break; case 'badge': $result = $this->do_badge( $action, $data ); break; case 'chatbot': $result = $this->do_chatbot( $action, $data ); break; case 'literacy': $result = $this->do_literacy( $action, $data ); break; case 'finish': $result = $this->do_finish(); break; default: return new WP_Error( 'aicl_step_unsupported', __( 'That step is not available yet.', 'aim-transparency' ), array( 'status' => 400 ) ); } if ( is_wp_error( $result ) ) { return $result; } $this->record( $step, $result ); $payload = self::payload(); $payload['result'] = isset( $result['result'] ) ? $result['result'] : array(); return rest_ensure_response( $payload ); } /** * Record what the owner says about AI images on this site, without touching anything. * * This answer routes the rest of the screen and nothing else. It is not allowed to suppress evidence: * a site that answers "no" and whose files declare AI provenance is still shown those files, under * their own answer, unedited. A declaration is a dated statement by a person, not a verdict. * * @since 1.4.1 * * @param string $action One of 'yes', 'no', 'unsure' or 'skip'. * @return array|WP_Error The journal fields for this write. */ protected function do_usage( $action ) { $map = array( 'yes' => 'declared_ai_images', 'no' => 'declared_no_ai_images', 'unsure' => 'declared_unsure', 'skip' => 'usage_skipped', ); if ( ! isset( $map[ $action ] ) ) { return new WP_Error( 'aicl_bad_action', __( 'Unrecognised answer.', 'aim-transparency' ), array( 'status' => 400 ) ); } return array( 'action' => $map[ $action ], 'params' => array(), 'before' => array(), ); } /** * Mark the attachments the owner ticked, or record that they looked and left them alone. * * Only ids sent by the client are touched, and every one of them was ticked by hand: there is no path * here that marks an image the owner did not choose. Capability is re-checked per attachment because * the route's own permission callback says nothing about who may edit a particular file, and refused * ids are handed back so the screen can name them instead of overstating what happened. * * 'dismiss' writes the detector's own reviewed marker. That is a decision about looking, not a claim * about the picture, so it is stored separately from the source-type flag and undone separately too. * * @since 1.4.1 * * @param string $action One of 'mark', 'dismiss' or 'skip'. * @param array $data Ticked ids and, for 'mark', the source type to write. * @return array|WP_Error The journal fields for this write. */ protected function do_images( $action, $data ) { if ( 'skip' === $action ) { return array( 'action' => 'images_skipped', 'params' => array(), 'before' => array() ); } if ( ! in_array( $action, array( 'mark', 'dismiss' ), true ) ) { return new WP_Error( 'aicl_bad_action', __( 'Unrecognised answer.', 'aim-transparency' ), array( 'status' => 400 ) ); } $ids = array_values( array_unique( array_filter( array_map( 'absint', (array) ( isset( $data['ids'] ) ? $data['ids'] : array() ) ) ) ) ); if ( empty( $ids ) ) { return new WP_Error( 'aicl_no_images', __( 'Tick at least one image first.', 'aim-transparency' ), array( 'status' => 400 ) ); } $ids = array_slice( $ids, 0, self::MAX_MARK ); $refused = array(); $touched = array(); $restores = array(); if ( 'dismiss' === $action ) { foreach ( $ids as $id ) { if ( ! current_user_can( 'edit_post', $id ) ) { $refused[] = $id; continue; } update_post_meta( $id, AICL_Detector::REVIEWED, time() ); $touched[] = $id; } if ( empty( $touched ) ) { return new WP_Error( 'aicl_forbidden', __( 'You are not allowed to edit any of those files.', 'aim-transparency' ), array( 'status' => 403 ) ); } return array( 'action' => 'images_dismissed', 'params' => array( count( $touched ) ), 'before' => array(), 'reviewed' => $touched, 'result' => array( 'dismissed' => $touched, 'refused' => $refused ), ); } $type = sanitize_text_field( (string) ( isset( $data['type'] ) ? $data['type'] : '' ) ); if ( ! AICL_Plugin::is_valid_type( $type ) ) { return new WP_Error( 'aicl_bad_type', __( 'That is not a source type this plugin can write.', 'aim-transparency' ), array( 'status' => 400 ) ); } foreach ( $ids as $id ) { if ( ! current_user_can( 'edit_post', $id ) ) { $refused[] = $id; continue; } $restores[] = array( 'id' => $id, 'was' => (string) AICL_Plugin::get_type( $id ), ); update_post_meta( $id, AICL_Plugin::META_KEY, $type ); AICL_Embed::embed_attachment( $id ); $touched[] = $id; } if ( empty( $touched ) ) { return new WP_Error( 'aicl_forbidden', __( 'You are not allowed to edit any of those files.', 'aim-transparency' ), array( 'status' => 403 ) ); } AICL_Plugin::flush_flagged_count(); return array( 'action' => 'images_marked', 'params' => array( count( $touched ), $type ), 'before' => array(), 'images' => $restores, 'result' => array( 'marked' => $touched, 'refused' => $refused, 'type' => $type ), ); } /** * Choose the badge look and make sure the disclosure layers are actually on. * * @since 2.0.0 * * @param string $action Either 'apply' or 'skip'. * @param array $data Optional badge_style and badge_position. * @return array|WP_Error The journal fields for this write. */ protected function do_badge( $action, $data ) { if ( 'skip' === $action ) { return array( 'action' => 'badge_skipped', 'params' => array(), 'before' => array() ); } $settings = AICL_Plugin::instance()->options(); $before = array( 'badge_enabled' => (int) $settings['badge_enabled'], 'badge_style' => $settings['badge_style'], 'badge_position' => $settings['badge_position'], 'embed_metadata' => (int) $settings['embed_metadata'], 'jsonld_enabled' => (int) $settings['jsonld_enabled'], ); $patch = array( 'badge_enabled' => 1, 'embed_metadata' => 1, 'jsonld_enabled' => 1, ); if ( isset( $data['badge_style'] ) ) { $patch['badge_style'] = sanitize_text_field( wp_unslash( $data['badge_style'] ) ); } if ( isset( $data['badge_position'] ) ) { $patch['badge_position'] = sanitize_text_field( wp_unslash( $data['badge_position'] ) ); } $clean = AICL_Plugin::apply_settings( $patch ); return array( 'action' => 'badge_configured', 'params' => array( $clean['badge_style'], $clean['badge_position'] ), 'before' => $before, ); } /** * Record the site's answer about who replies in its chat, and only then switch on the notice. * * The disclosure is written for 'ai' and 'both' and never for 'human'. Most detected chat tools are * live chat with an optional bot, so assuming AI would put "you are chatting with an AI assistant" * in front of visitors who are talking to a person. Declaring human staffing therefore changes no * setting at all; it is journalled so the answer is not asked for twice. * * @since 2.0.0 * * @param string $action One of 'ai', 'human', 'both' or 'skip'. * @param array $data Optional notice text, position and extra selectors. * @return array|WP_Error The journal fields for this write. */ protected function do_chatbot( $action, $data ) { if ( 'skip' === $action ) { return array( 'action' => 'chatbot_skipped', 'params' => array(), 'before' => array() ); } if ( 'human' === $action ) { return array( 'action' => 'declared_human_staffed', 'params' => array(), 'before' => array() ); } if ( ! in_array( $action, array( 'ai', 'both' ), true ) ) { return new WP_Error( 'aicl_bad_action', __( 'Unrecognised answer.', 'aim-transparency' ), array( 'status' => 400 ) ); } $settings = AICL_Plugin::instance()->options(); $before = array( 'chatbot_notice' => (int) $settings['chatbot_notice'], 'chatbot_notice_text' => $settings['chatbot_notice_text'], 'chatbot_notice_position' => $settings['chatbot_notice_position'], 'chatbot_notice_selectors' => $settings['chatbot_notice_selectors'], ); $patch = array( 'chatbot_notice' => 1 ); if ( isset( $data['text'] ) ) { $patch['chatbot_notice_text'] = sanitize_text_field( wp_unslash( $data['text'] ) ); } if ( isset( $data['position'] ) ) { $patch['chatbot_notice_position'] = sanitize_text_field( wp_unslash( $data['position'] ) ); } if ( isset( $data['selectors'] ) ) { $extra = sanitize_textarea_field( wp_unslash( $data['selectors'] ) ); $patch['chatbot_notice_selectors'] = trim( $before['chatbot_notice_selectors'] . "\n" . $extra ); } AICL_Plugin::apply_settings( $patch ); return array( 'action' => 'both' === $action ? 'notice_enabled_mixed' : 'notice_enabled_ai', 'params' => array(), 'before' => $before, ); } /** * Store the owner's Article 4 attestation exactly as they ticked it. * * The whole map is written every time, and the map that was there immediately before is kept with the * row, so Undo restores the record rather than approximating it. Nothing is ever ticked here that the * client did not send: these nine statements are the owner saying something about their own * organisation, and a plugin has no standing to say any of it for them. * * @since 1.4.1 * * @param string $action Either 'tick' or 'skip'. * @param array $data The submitted items map. * @return array|WP_Error The journal fields for this write. */ protected function do_literacy( $action, $data ) { if ( 'skip' === $action ) { return array( 'action' => 'literacy_skipped', 'params' => array(), 'before' => array() ); } if ( 'tick' !== $action ) { return new WP_Error( 'aicl_bad_action', __( 'Unrecognised answer.', 'aim-transparency' ), array( 'status' => 400 ) ); } $before = AICL_Readiness::get(); $clean = AICL_Readiness::sanitize( (array) ( isset( $data['items'] ) ? $data['items'] : array() ) ); $met = 0; foreach ( $clean as $item ) { $met += empty( $item['done'] ) ? 0 : 1; } update_option( AICL_Readiness::OPTION, $clean, false ); return array( 'action' => 'literacy_ticked', 'params' => array( $met, count( AICL_Readiness::ITEMS ) ), 'before' => array(), 'readiness' => $before, ); } /** * Stamp the flow finished. * * Deliberately not one of the five answers: the closing panel reports, it does not ask, so whether * the owner pressed its button must never decide if the flow counts as done. * * @since 1.4.1 * * @return array The journal fields for this write. */ protected function do_finish() { $state = self::state(); $state['finished'] = gmdate( 'c' ); update_option( self::OPTION, $state ); return array( 'action' => 'setup_finished', 'params' => array(), 'before' => array() ); } /** * Append a journal row and mark the step done. * * Pressing the same answer twice is one decision, not two, so a row is normally replaced rather than * duplicated. Rows that carry a restore payload are the exception in two different ways: a marking * batch is never collapsed, because each batch names its own images and merging them would make one * Undo silently cover files the owner ticked at a different moment; an Article 4 row is collapsed but * inherits the replaced row's stored map, so the single row still restores the state from before the * first tick instead of the state one tick ago. * * @since 2.0.0 * * @param string $step The step key. * @param array $result The action, restore payloads and before-state to record. * @return void */ protected function record( $step, $result ) { $state = self::state(); if ( '' === $state['started'] ) { $state['started'] = gmdate( 'c' ); } $row = array( 'id' => uniqid( 'j', false ), 'step' => $step, 'action' => isset( $result['action'] ) ? $result['action'] : '', 'params' => isset( $result['params'] ) ? array_map( 'strval', (array) $result['params'] ) : array(), 'before' => isset( $result['before'] ) ? $result['before'] : array(), 'at' => gmdate( 'c' ), 'by' => wp_get_current_user()->user_login, ); if ( ! empty( $result['images'] ) ) { $row['images'] = $result['images']; } if ( ! empty( $result['reviewed'] ) ) { $row['reviewed'] = $result['reviewed']; } if ( isset( $result['readiness'] ) ) { $row['readiness'] = $result['readiness']; } $collapse = empty( $row['images'] ) && empty( $row['reviewed'] ); if ( $collapse ) { $state['journal'] = array_values( array_filter( (array) $state['journal'], function ( $existing ) use ( &$row ) { $same = isset( $existing['step'], $existing['action'] ) && $existing['step'] === $row['step'] && $existing['action'] === $row['action']; if ( $same && isset( $existing['readiness'] ) ) { $row['readiness'] = $existing['readiness']; } return ! $same; } ) ); } array_unshift( $state['journal'], $row ); $state['journal'] = array_slice( $state['journal'], 0, self::MAX_JOURNAL ); if ( ! in_array( $step, (array) $state['done'], true ) ) { $state['done'][] = $step; } update_option( self::OPTION, $state ); } /** * Put back whatever a journalled step changed, whichever kind of change it was. * * The order matters. Attachment meta, the reviewed marker and the Article 4 map are restored before * settings, so a row that carries more than one kind of change lands the site back in one consistent * state. Only the keys captured before that step are written back, so an unrelated change made * afterwards in Settings survives. A step that recorded nothing, such as declaring the chat * human-staffed, has nothing to restore and simply drops out of the journal. * * @since 2.0.0 * * @param WP_REST_Request $request The request carrying the journal entry id. * @return WP_REST_Response|WP_Error The refreshed payload, or an error when the entry is unknown. */ public function rest_undo( $request ) { $id = (string) $request->get_param( 'entry' ); $state = self::state(); $found = null; foreach ( $state['journal'] as $index => $row ) { if ( isset( $row['id'] ) && $row['id'] === $id ) { $found = $index; break; } } if ( null === $found ) { return new WP_Error( 'aicl_no_entry', __( 'That entry is no longer in the record.', 'aim-transparency' ), array( 'status' => 404 ) ); } $row = $state['journal'][ $found ]; if ( ! empty( $row['images'] ) ) { $this->restore_images( (array) $row['images'] ); } if ( ! empty( $row['reviewed'] ) ) { foreach ( (array) $row['reviewed'] as $attachment ) { if ( current_user_can( 'edit_post', (int) $attachment ) ) { delete_post_meta( (int) $attachment, AICL_Detector::REVIEWED ); } } } if ( isset( $row['readiness'] ) ) { update_option( AICL_Readiness::OPTION, AICL_Readiness::sanitize( (array) $row['readiness'] ), false ); } if ( ! empty( $row['before'] ) ) { AICL_Plugin::apply_settings( (array) $row['before'] ); } if ( isset( $row['step'] ) && 'finish' === $row['step'] ) { $state['finished'] = ''; } array_splice( $state['journal'], $found, 1 ); $state['done'] = array_values( array_filter( (array) $state['done'], function ( $step ) use ( $row, $state ) { if ( $step !== $row['step'] ) { return true; } foreach ( $state['journal'] as $other ) { if ( isset( $other['step'] ) && $other['step'] === $step ) { return true; } } return false; } ) ); update_option( self::OPTION, $state ); return rest_ensure_response( self::payload() ); } /** * Put each attachment back to the source type it carried before a marking batch. * * An image that had no type before must end with no type and no embed status, otherwise undoing a * mark would leave the library reporting a file as marked when the flag has gone. * * @since 1.4.1 * * @param array $images Rows of id and the previous source type. * @return void */ protected function restore_images( $images ) { foreach ( $images as $entry ) { $id = isset( $entry['id'] ) ? (int) $entry['id'] : 0; if ( $id < 1 || ! current_user_can( 'edit_post', $id ) ) { continue; } $was = isset( $entry['was'] ) ? (string) $entry['was'] : ''; if ( AICL_Plugin::is_valid_type( $was ) ) { update_post_meta( $id, AICL_Plugin::META_KEY, $was ); AICL_Embed::embed_attachment( $id ); continue; } delete_post_meta( $id, AICL_Plugin::META_KEY ); delete_post_meta( $id, AICL_Plugin::META_EMBED ); } AICL_Plugin::flush_flagged_count(); } }