Foundation::available( '1.4' ) && Reviewed_Store::supported(), 'provider_version' => VERSION, 'reason' => 'Reviewed actions use the active Image Manager owning services and require InnoDB on MySQL/MariaDB.', ); } /** * Register every catalog action with the AI connector, when it is installed. */ public static function register(): void { if ( ! class_exists( '\AFSD_Connector_Actions' ) ) { return; } foreach ( Reviewed_Owner::catalog() as $d ) { $op = $d['operation']; $schema = $d['input_schema']; if ( ! $schema['properties'] ) { $schema['properties'] = (object) array(); } \AFSD_Connector_Actions::register( $d['id'], array( 'schema' => 1, 'provider_id' => 'image_manager', 'provider_label' => 'AffStackDev Image Manager', 'plugin_id' => 'plugin:affstackdev-image-manager/affstackdev-image-manager.php', 'title' => $d['title'], 'description' => $d['effect'], 'contract_version' => self::CONTRACT, 'input_schema' => $schema, 'availability' => array( __CLASS__, 'availability' ), 'permission' => array( __CLASS__, 'permission' ), 'inspect' => static fn( $params, $p ) => self::inspect( $op, $params, $p ), 'prepare' => static fn( $params, $p ) => self::prepare( $op, $params, $p ), 'assert_current' => array( __CLASS__, 'assert_current', ), 'execute' => array( __CLASS__, 'execute', ), 'reconcile' => array( __CLASS__, 'reconcile', ), ) ); } } /** * An action error with its code and words. * * @param \Throwable $e The error. * @return mixed The WP_Error. */ private static function error( \Throwable $e ) { return Reviewed_Store::error( sanitize_key( $e->getMessage() ), self::message( $e->getMessage() ) ); } /** * The words for an action's reason code. * * @param string $code The reason code. * @return string The words. */ private static function message( string $code ): string { $map = array( 'reviewed_ambiguous_attachment' => 'More than one permitted media item matches. Inspect the library candidates and choose its title plus the returned identifier.', 'reviewed_ambiguous_source' => 'More than one source matches. Inspect source candidates and select the exact image before review.', 'reviewed_ambiguous_term' => 'More than one folder or collection matches. Inspect organization labels and select the exact parent/identifier.', 'reviewed_attachment_missing' => 'No permitted attachment matches this reference.', 'reviewed_source_missing' => 'No permitted indexed image matches. Scan first if it has not been discovered.', 'reviewed_term_missing' => 'The selected folder or collection is missing.', 'reviewed_state_limit' => 'This complete proposal exceeds the shared review bounds. Split the selected media into smaller separately reviewed groups.', 'reviewed_review_limit' => 'The exact before/after values exceed the shared review rows. Split the selected media into smaller separately reviewed groups.', 'reviewed_run_active' => 'A saved image run is active. Pause, resume or stop that run before starting another.', 'reviewed_input_missing' => 'Stage this file or secret in Image Manager AI inputs using the connected WordPress account, then provide its unexpired reference.', 'reviewed_input_changed' => 'The staged file or secret identity changed. Stage a fresh input and prepare again.', 'reviewed_password_required' => 'Enter the backup password in Image Manager AI inputs and supply its reference; passwords cannot go into AI parameters.', 'reviewed_empty_selection' => 'This image selection is empty. Inspect current sources or choose another native scope.', 'reviewed_term_exists' => 'This organization name already exists in that parent. Select it or choose another name.', 'reviewed_no_fields' => 'Supply at least one explicitly changed field.', 'reviewed_delivery_enabled' => 'Turn global optimized delivery off and clear cached pages before reviewing removal of managed copies.', 'reviewed_gallery_unchanged' => 'This folder already has that website gallery setting. Nothing would change.', 'reviewed_gallery_failed' => 'The website gallery setting of this folder could not be saved.', 'reviewed_unused_collected' => 'Every file the saved check listed is already in its collection. Start a new check first.', 'reviewed_content_folder_unchanged' => 'That folder already looks like this (or every item is already there). Nothing would change.', 'reviewed_ambiguous_item' => 'More than one item of that list has this title. Use its ID (#123) from the list.', 'reviewed_item_missing' => 'No item of that list you may edit matches. Use its ID (#123) or its exact title.', 'reviewed_cdn_unchanged' => 'The CDN is already off with this address. Nothing would change.', ); if ( isset( $map[ $code ] ) ) { return $map[ $code ]; } if ( str_starts_with( $code, 'content_folder_' ) ) { return Content_Folders::human_error( $code ); }if ( str_starts_with( $code, 'cdn_' ) ) { return CDN::human( $code ); }if ( str_starts_with( $code, 'fit_' ) ) { return Results::reason( $code ); } if ( str_starts_with( $code, 'folder_import_' ) ) { return Folder_Import::human_error( $code ); }if ( str_starts_with( $code, 'unused_' ) ) { return Unused_Media::human_error( $code ); }if ( str_starts_with( $code, 'backup_' ) ) { return Backup::human_error( $code ); }if ( str_starts_with( $code, 'remote_' ) ) { return Remote_Backup::human_error( $code ); }if ( str_starts_with( $code, 'media_' ) ) { return Media_Actions::human_error( $code ); } return 'The native owner rejected this preparation (' . sanitize_key( $code ) . '). Inspect the corresponding Image Manager screen and current readiness.'; } /** * Read Image Manager's state for an action (no change). * * @param string $op The action. * @param array $params Its parameters. * @param array $p The principal. * @return array|\WP_Error The state, or why not. */ public static function inspect( string $op, array $params, array $p ) { $ok = self::permission( $p ); if ( is_wp_error( $ok ) ) { return $ok; } try { if ( 'inspect' === $op ) { $out = Reviewed_Owner::inspect( $params ); } else { $plan = Reviewed_Owner::plan( $op, $params ); $out = array( 'operation' => $op, 'targets' => $plan['state']['targets'], 'current' => $plan['before'], 'expected_after' => $plan['expected_after'], 'native_inputs' => Reviewed_Inputs::url(), 'change_applied' => false, ); } if ( strlen( wp_json_encode( $out ) ) > 32768 ) { return Reviewed_Store::error( 'inspection_limit', 'The current data exceeds the inspection bound. Select a smaller page or media group.' ); }return $out; } catch ( \Throwable $e ) { if ( str_contains( $e->getMessage(), 'ambiguous' ) ) { $section = str_contains( $e->getMessage(), 'source' ) ? 'sources' : 'library'; return array( 'blocker' => self::message( $e->getMessage() ), 'candidates' => Reviewed_Owner::inspect( array( 'section' => $section, 'query' => $params['attachment'] ?? $params['source'] ?? $params['term'] ?? '', 'limit' => 20, ) ), ); } return self::error( $e ); } } /** * Plain review text preserves exact strings; HTML/control bytes use a visible JSON escape representation. * * @param mixed $value A value to show. * @return string The text. */ private static function text( $value ): string { if ( is_bool( $value ) ) { return $value ? 'Enabled' : 'Disabled'; }if ( null === $value ) { return 'Not present'; } if ( is_string( $value ) && wp_strip_all_tags( $value ) === $value && ! preg_match( '/[\x00-\x1f\x7f]/', $value ) ) { return $value; } return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); } /** * A long text cut into parts of at most 450 characters (texts up to 1,900 stay whole). * * @param string $value The text. * @return array The parts. */ private static function chunks( string $value ): array { if ( strlen( $value ) <= 1900 ) { return array( $value ); }preg_match_all( '/.{1,450}/us', $value, $parts ); return $parts[0] ?: str_split( $value, 450 ); } /** * Add a before-and-after row (or several, for long values) to a review. * * @param array $rows The rows so far, changed in place. * @param string $label What changes. * @param mixed $before The value before. * @param mixed $after The value after. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function row( array &$rows, string $label, $before, $after ): void { $a = self::chunks( self::text( $before ) ); $b = self::chunks( self::text( $after ) ); $n = max( count( $a ), count( $b ) ); for ( $i = 0; $i < $n; ++$i ) { $rows[] = array( 'label' => substr( $label, 0, 130 ) . ( $n > 1 ? ' — part ' . ( $i + 1 ) . ' of ' . $n : '' ), 'before' => $a[ $i ] ?? '', 'after' => $b[ $i ] ?? '', ); } if ( count( $rows ) > 50 ) { throw new \RuntimeException( 'reviewed_review_limit' ); } } /** * Prepare a change: its targets, current state, expected result and the rows the administrator reviews. * * @param string $op The action. * @param array $params Its parameters. * @param array $p The principal. * @return array|\WP_Error The prepared change, or why not. */ public static function prepare( string $op, array $params, array $p ) { $ok = self::permission( $p ); if ( is_wp_error( $ok ) ) { return $ok; } try { $plan = Reviewed_Owner::plan( $op, $params ); $state = $plan['state']; $rows = array(); $def = Reviewed_Owner::definition( $op ); self::row( $rows, 'Operation', $def['screen'], $def['title'] ); foreach ( $state['attachments'] as $t ) { self::row( $rows, 'Media #' . $t['id'] . ' — ' . $t['title'], $t['mime'], 'Exact selected attachment #' . $t['id'] ); } foreach ( $state['sources'] as $t ) { self::row( $rows, 'Image source #' . $t['id'], $t['title'] . '; ' . $t['state'] . '; delivery ' . $t['delivery'] . '; excluded ' . $t['excluded'], $def['title'] ); } foreach ( $state['terms'] as $t ) { self::row( $rows, 'Organization #' . $t['id'], $t['name'] . ' (parent ' . $t['parent'] . ')', 'Exact ' . $t['taxonomy'] . ' #' . $t['id'] ); } // All supplied and normalized field values plus native pre-edit values are represented, never silently shortened. foreach ( $plan['before'] as $key => $value ) { self::row( $rows, 'Current ' . str_replace( '_', ' ', $key ), $value, 'Reviewed changes below' ); } foreach ( $state['native'] as $key => $value ) { if ( in_array( $key, array( 'attachment_ids', 'source_ids', 'attachment_id', 'source_id', 'usage_run', 'usage_cursor', 'checkpoint_before', 'secret_reference', 'input_reference', 'previous', 'position_before' ), true ) ) { continue; } if ( 'settings' === $key ) { foreach ( $value as $field => $new ) { self::row( $rows, str_replace( '_', ' ', $field ), $plan['before']['settings'][ $field ] ?? '', $new ); } } elseif ( 'fields' === $key ) { foreach ( $value as $field => $new ) { self::row( $rows, 'Set ' . str_replace( '_', ' ', $field ), 'Each exact old value is listed above', $new ); } } else { self::row( $rows, 'Set ' . str_replace( '_', ' ', $key ), 'Omitted fields are retained', $value ); } } foreach ( array( 'input', 'secret' ) as $key ) { if ( isset( $state['dependencies'][ $key ] ) ) { $i = $state['dependencies'][ $key ]; self::row( $rows, 'Secure ' . $key . ' reference', 'No model secret content', array( 'reference' => $i['reference'], 'purpose' => $i['kind'], 'files' => $i['files'], 'ready' => $i['secret_ready'], 'expires_utc' => gmdate( 'c', $i['expires'] ), ) ); } } $effects = array( array( 'type' => 'native_owner', 'description' => $def['effect'], ), array( 'type' => 'native_hooks', 'description' => 'The same Image Manager and WordPress owning services run their normal hooks. Installed extensions may respond to attachment, taxonomy, metadata, backup or update events; the project does not change their ownership.', ), ); if ( 'cdn_check' === $op && ! empty( $state['native']['deliver'] ) ) { $effects[] = array( 'type' => 'outbound_request', 'description' => 'One request from this site to ' . $state['native']['address'] . ' for a new test picture, which is deleted afterwards. If it comes back unchanged, public pages point pictures in the uploads folder to that address and the same check runs daily.', ); } if ( 'remote_settings_update' === $op || 'backup_start' === $op || 'remote_upload' === $op || 'remote_advance' === $op ) { $c = Remote_Backup::connection(); $effects[] = array( 'type' => 'remote_destination', 'description' => self::text( array( 'provider' => $state['native']['remote_provider'] ?? $c['provider'], 'schedule' => $state['native']['remote_schedule'] ?? $c['schedule'], 'retention' => $state['native']['remote_retention'] ?? $c['retention'], 'keep_local' => $state['native']['remote_keep_local'] ?? $c['keep_local'], ) ) . ' Remote jobs may submit the portable backup, prune older copies and remove the completed local snapshot when keep_local is disabled.', ); } return array( 'state' => $state, 'expected_after' => $plan['expected_after'], 'changes' => $rows, 'effects' => $effects, 'notices' => array( 'Apply this exact project in the AffStackDev AI WordPress review screen. Native upload, secret entry and external account consent use the connected administrator’s session.', 'Queued work is a saved job, not finished image processing or a proven browser download. Open the native status or inspection to follow completion.' ), ); } catch ( \Throwable $e ) { return self::error( $e ); } } /** * Check a prepared change still matches the current state before it runs. * * @param array $prepared The prepared change. * @param array $p The principal. * @return true|\WP_Error True, or why it is stale. */ public static function assert_current( array $prepared, array $p ) { $ok = self::permission( $p ); if ( is_wp_error( $ok ) ) { return $ok; } if ( ( $prepared['proposed']['provider_version'] ?? '' ) !== VERSION || ( $prepared['proposed']['contract_version'] ?? '' ) !== self::CONTRACT ) { return Reviewed_Store::error( 'provider_changed', 'The Image Manager implementation changed. Prepare a fresh review.' ); } if ( Reviewed_Store::owns_execution( $prepared, $p ) ) { if ( ! hash_equals( Reviewed_Store::hash( $prepared['snapshot']['state']['environment'] ), Reviewed_Store::hash( Reviewed_Owner::environment() ) ) ) { return Reviewed_Store::error( 'dependency_changed', 'An active plugin, theme, version or owning dependency changed during execution.' ); } // The shared engine calls this inside every guard. The native owner mutex/row locks fence intermediate writes. foreach ( $prepared['snapshot']['state']['targets']['attachments'] as $id ) { if ( ! current_user_can( 'edit_post', $id ) ) { return Reviewed_Store::error( 'target_permission', 'Attachment permission changed during execution.' ); } } return true; } try { $op = $prepared['snapshot']['state']['operation']; $fresh = Reviewed_Owner::plan( $op, $prepared['proposed']['parameters'] ); return hash_equals( Reviewed_Store::hash( $fresh['state'] ), Reviewed_Store::hash( $prepared['snapshot']['state'] ) ) && hash_equals( Reviewed_Store::hash( $fresh['expected_after'] ), Reviewed_Store::hash( $prepared['proposed']['expected_after'] ) ) ? true : Reviewed_Store::error( 'stale', 'The selected media, settings, dependency, draft or saved selection changed. Prepare a new exact review.' ); } catch ( \Throwable $e ) { return self::error( $e ); } } /** * Run an approved change through Image Manager's owner. * * @param array $prepared The prepared change. * @param array $context The execution's context. * @param array $p The principal. * @return mixed The result. */ public static function execute( array $prepared, array $context, array $p ) { return Reviewed_Store::execute( $prepared, $context, $p ); } /** * Find out what happened to a change whose result was not known. * * @param array $prepared The prepared change. * @param string $marker Its execution's marker. * @param array $p The principal. * @return mixed The result. */ public static function reconcile( array $prepared, string $marker, array $p ) { return Reviewed_Store::reconcile( $prepared, $marker, $p ); } /** * Whether an observed result holds every expected value (lists in the same order). * * @param mixed $wanted The expected value. * @param mixed $observed The observed value. * @return bool True when it does. */ public static function matches( $wanted, $observed ): bool { if ( ! is_array( $wanted ) ) { return $wanted === $observed; }if ( ! is_array( $observed ) ) { return false; }if ( ! $wanted ) { return ! $observed; } if ( array_is_list( $wanted ) && array_keys( $wanted ) !== array_keys( $observed ) ) { return false; } foreach ( $wanted as $k => $v ) { if ( ! array_key_exists( $k, $observed ) || ! self::matches( $v, $observed[ $k ] ) ) { return false; } }return true; } }