post_type ) { Auto_Organization::observe( (int) $id ); } }, 150, 2 ); foreach ( array( 'added_post_meta', 'updated_post_meta' ) as $hook ) { add_action( $hook, static function ( $mid, $id, $key ) { if ( in_array( $key, array( '_wp_attached_file', '_wp_attachment_metadata' ), true ) ) { Auto_Organization::observe( (int) $id ); } }, 150, 3 ); } add_action( 'afsd_im_organize_attachment', array( Auto_Organization::class, 'observe' ) ); add_action( 'admin_notices', array( Auto_Organization::class, 'notice' ) ); add_action( 'admin_post_afsd_im_editor_asset', array( Editor::class, 'asset' ) ); add_action( 'delete_attachment', array( Editor::class, 'attachment_deleted' ), 1 ); add_action( self::RETIRE, array( Editor::class, 'retire_deferred' ), 10, 2 ); add_action( 'admin_post_afsd_im_media_asset', array( Media_Draft::class, 'asset' ) ); } /** * What an upload or a metadata write calls: alt text, the largest upload, SVG files, the chosen sizes and the file * sizes shown in the library. * * @param array $s The settings. */ private static function uploads( array $s ): void { add_action( 'add_attachment', array( Auto_Text::class, 'on_upload' ), 160 ); add_action( self::AUTO_TEXT_RETRY, array( Auto_Text::class, 'on_upload' ) ); add_filter( 'big_image_size_threshold', array( Upload_Limit::class, 'threshold' ), 20 ); add_filter( 'wp_get_missing_image_subsizes', array( SVG::class, 'no_sizes' ), 30, 3 ); if ( ! empty( $s['svg_uploads'] ) ) { add_filter( 'upload_mimes', array( SVG::class, 'mimes' ), 20, 2 ); // Late, so no other prefilter changes the file after it is cleaned. add_filter( 'wp_handle_upload_prefilter', array( SVG::class, 'prefilter' ), 1000 ); add_filter( 'wp_handle_sideload_prefilter', array( SVG::class, 'prefilter' ), 1000 ); add_filter( 'wp_check_filetype_and_ext', array( SVG::class, 'filetype' ), 20, 4 ); add_filter( 'wp_upload_bits', array( SVG::class, 'bits' ) ); add_filter( 'wp_generate_attachment_metadata', array( SVG::class, 'metadata' ), 10, 2 ); } add_filter( 'intermediate_image_sizes_advanced', array( Image_Sizes::class, 'chosen' ), 20 ); // Make missing sizes (wp_update_image_subsizes) asks WordPress for the missing sizes through its own filter. add_filter( 'wp_get_missing_image_subsizes', array( Image_Sizes::class, 'chosen' ), 20 ); add_filter( 'wp_update_attachment_metadata', array( Media_Display::class, 'note_bytes' ), 999, 2 ); } /** * Public pages and the content lists: WooCommerce's classic variation gallery, automatic page rechecks, lazy loading, * the folder gallery, folders for other post types, measured sizes and the CDN. * * @param array $s The settings. */ private static function pages( array $s ): void { add_action( 'wp_enqueue_scripts', self::delivered( array( Woo_Delivery::class, 'enqueue' ) ), 30 ); add_filter( 'woocommerce_available_variation', self::delivered( array( Woo_Delivery::class, 'variation' ) ), 30, 3 ); // wp_after_insert_post runs after the native content/meta write, including wp_update_post importers. add_action( 'wp_after_insert_post', array( Auto_Discovery::class, 'post_saved' ), 110, 4 ); add_action( 'upgrader_process_complete', array( Auto_Discovery::class, 'theme_updated' ), 20, 2 ); add_action( 'after_switch_theme', array( Auto_Discovery::class, 'theme_switched' ), 20 ); if ( $s['lazy_loading'] && ! is_admin() ) { add_filter( 'pre_wp_get_loading_optimization_attributes', array( Lazy_Loading::class, 'note_pre' ), PHP_INT_MAX, 4 ); add_filter( 'wp_get_loading_optimization_attributes', array( Lazy_Loading::class, 'note' ), PHP_INT_MAX, 4 ); add_action( 'template_redirect', array( Lazy_Loading::class, 'start_buffer' ), 21 ); } add_action( 'init', array( Folder_Gallery::class, 'register' ), 20 ); // Late, so that post types registered by WooCommerce and other plugins on init exist; only the post types chosen in // the settings get folders. add_action( 'init', static function (): void { if ( Settings::get()['content_folders'] ) { Content_Folders::register(); } }, 99 ); if ( is_admin() ) { add_action( 'load-edit.php', array( Content_Folders::class, 'load' ) ); add_action( 'admin_post_afsd_im_cfolder', array( Content_Folders::class, 'manage_request' ) ); add_action( 'wp_ajax_afsd_im_cfolder_move', array( Content_Folders::class, 'ajax_move' ) ); } // After the theme picture pass and lazy loading, before the CDN pass (PASS_FIT): it sees their output first. if ( ( ! empty( $s['fit'] ) || ! empty( $s['dimensions'] ) ) && ! is_admin() ) { add_action( 'template_redirect', array( Fit::class, 'start_buffer' ), 19 ); } add_action( self::CDN_CHECK, array( CDN::class, 'scheduled' ) ); // The last pass (PASS_CDN): the theme pictures, measured sizes and lazy loading have run when it rewrites addresses. if ( ! is_admin() && $s['cdn'] && CDN::active() ) { add_action( 'template_redirect', array( CDN::class, 'start_buffer' ), 18 ); } } /** Image Manager's screen: its own page under Media when the Suite does not hold it, its forms and the AI parts. */ private static function screen(): void { add_action( 'wp_abilities_api_categories_init', array( AI_Abilities::class, 'category' ) ); add_action( 'wp_abilities_api_init', array( AI_Abilities::class, 'register' ) ); add_action( 'mcp_adapter_init', array( AI_Abilities::class, 'server' ), 20 ); add_action( 'admin_post_' . self::PROPOSAL, array( AI_Proposals::class, 'handle' ) ); add_action( 'admin_post_afsd_im_preview_media', array( Preview::class, 'media' ) ); add_action( 'admin_post_afsd_im_inspect', array( Verification::class, 'inspector' ) ); add_action( 'wp_ajax_afsd_im_media_usage', array( Media_Usage::class, 'ajax' ) ); add_action( 'admin_post_afsd_im_media_usage', array( Media_Usage::class, 'action' ) ); add_action( 'admin_menu', array( self::class, 'menu' ), 30 ); add_action( 'admin_enqueue_scripts', array( self::class, 'assets' ) ); add_action( 'admin_post_afsd_im_action', self::admin( 'action' ) ); add_action( 'wp_ajax_afsd_im_control', self::admin( 'ajax' ) ); add_action( 'wp_ajax_afsd_im_media_details', self::admin( 'ajax_media_details' ) ); } /** * Public image output: attachment and content pictures, the theme's own pictures. While public delivery is off every * one of these leaves the page as it is (Delivery::allowed()), so Delivery, Theme and Woo_Delivery do not load. */ private static function delivery_hooks(): void { // Nothing to forget before Delivery has served a page. add_action( 'clean_post_cache', static function (): void { if ( class_exists( Delivery::class, false ) ) { Delivery::forget_pages(); } } ); add_action( 'wp_enqueue_scripts', self::delivery( 'styles' ) ); add_action( 'template_redirect', self::delivered( array( Theme::class, 'start_buffer' ) ), 20 ); // Only the pictures Delivery has put on this page are allowed through; before Delivery loads there are none. add_filter( 'wp_kses_allowed_html', static function ( $allowed, $context ) { return class_exists( Delivery::class, false ) ? Delivery::shared()->kses( $allowed, $context ) : $allowed; }, 20, 2 ); add_filter( 'wp_get_attachment_image', self::delivery( 'attachment' ), 100, 5 ); add_filter( 'the_content', self::delivery( 'begin_content' ), 0 ); add_filter( 'the_content', self::delivery( 'check_content' ), 11 ); add_filter( 'the_content', self::delivery( 'end_content' ), PHP_INT_MAX ); add_filter( 'wp_content_img_tag', self::delivery( 'content_image' ), 100, 3 ); add_filter( 'comment_text', self::delivery( 'comment' ), 100, 3 ); // Documented Jetpack hooks: only manifest-proven plugin derivatives are bypassed (local_cdn, with or without delivery). $skip_cdn = static function ( ...$args ) { return Delivery::shared()->skip_cdn( ...$args ); }; add_filter( 'jetpack_photon_skip_image', $skip_cdn, 20, 3 ); add_filter( 'jetpack_photon_skip_for_url', $skip_cdn, 20, 4 ); } /** * Every module, from the files this request started with (upgrader_pre_install at -1000). Only an update request * pays for it. * * @param mixed $value The filter's value, passed on unchanged. * @return mixed The value. */ public static function preload( $value ) { foreach ( array_unique( MODULES ) as $file ) { require_once DIR . '/includes/class-' . $file . '.php'; } return $value; } /** Add Image Manager under Media, when AffStackDev Suite is not there to show it. */ public static function menu(): void { if ( ! Suite::available() ) { add_media_page( __( 'Image Manager', 'affstackdev-image-manager' ), __( 'Image Manager', 'affstackdev-image-manager' ), 'manage_options', self::PAGE, self::admin( 'render' ) ); } } /** * The screen's styles and scripts load on Image Manager's page, the own page of a tab other code adds (sections()) * and the Suite's pages only. * * @param string $hook The admin screen's hook name. */ public static function assets( $hook ): void { $page = isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- asset routing only. if ( 'media_page_' . self::PAGE !== $hook && ! self::section_page( $page ) && ! str_starts_with( $page, 'affstackdev-suite' ) ) { return; } self::admin( 'assets' )(); } /** * The tabs that code extending Image Manager adds to its page, after AI assistants (none by default). A tab needs a * key of its own, a heading and subtitle, a label and a callback that prints it. * * @return array Key => array( 'heading' => array( title, subtitle ), 'label' => the tab's text, 'render' => callable, * 'intro' => optional callable, 'page' => optional admin page slug ). */ public static function sections(): array { /** * Filters the tabs that code extending Image Manager adds to its page. * * @since 1.9.0 * * @param array $sections Key => array( 'heading' => array( title, subtitle ), 'label' => the tab's text, 'render' * => callable that prints the tab, 'intro' => optional callable printed under the tabs, * 'page' => optional slug of the tab's own admin page, which loads only the stylesheet ). */ $sections = apply_filters( 'afsd_im_admin_sections', array() ); $out = array(); foreach ( is_array( $sections ) ? $sections : array() as $key => $tab ) { if ( ! is_string( $key ) || ! preg_match( '/^[a-z][a-z0-9_]{0,31}$/D', $key ) || in_array( $key, array( 'optimize', 'images', 'library', 'backups', 'settings', 'tools', 'ai' ), true ) ) { continue; } if ( is_array( $tab ) && is_string( $tab['label'] ?? null ) && is_callable( $tab['render'] ?? null ) && is_array( $tab['heading'] ?? null ) && 2 === count( $tab['heading'] ) ) { $out[ $key ] = $tab; } } return $out; } /** * Whether an admin page slug is the own page of a tab other code adds. * * @param string $page The page slug. * @return bool True when it is. */ public static function section_page( string $page ): bool { foreach ( self::sections() as $tab ) { if ( '' !== $page && ( $tab['page'] ?? '' ) === $page ) { return true; } } return false; } /** * A screen hook: the request's Admin answers it. * * @param string $method The Admin method. * @return \Closure The callback. */ private static function admin( string $method ): \Closure { return static function ( ...$args ) use ( $method ) { self::$admin ??= new Admin(); return self::$admin->$method( ...$args ); }; } /** * A public output hook: Delivery's one instance answers it while public delivery is on; off, the value stays as it is. * * @param string $method The Delivery method. * @return \Closure The callback. */ private static function delivery( string $method ): \Closure { return static function ( ...$args ) use ( $method ) { return self::delivering() ? Delivery::shared()->$method( ...$args ) : ( $args[0] ?? null ); }; } /** * The same for a static answer (Theme, Woo_Delivery). * * @param array $answer The class and method. * @return \Closure The callback. */ private static function delivered( array $answer ): \Closure { return static function ( ...$args ) use ( $answer ) { return self::delivering() ? $answer( ...$args ) : ( $args[0] ?? null ); }; } /** * What Delivery::allowed() asks of the settings first: delivery and the public-pictures confirmation, read now. * * @return bool True when both are on. */ private static function delivering(): bool { $s = Settings::get(); return ! empty( $s['delivery'] ) && ! empty( $s['public_policy'] ); } }