file = $file; $this->version = $version; $this->define_constants(); $this->includes(); $this->init_hooks(); } /** * Define the plugin constants. * * @since 1.0.0 * @return void */ private function define_constants() { define( 'AICW_VERSION', $this->version ); define( 'AICW_FILE', $this->file ); define( 'AICW_PATH', plugin_dir_path( $this->file ) ); define( 'AICW_URL', plugin_dir_url( $this->file ) ); define( 'AICW_ASSETS_URL', AICW_URL . 'assets/' ); } /** * Include the required files. * * @since 1.0.0 * @return void */ private function includes() { require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/deprecated.php'; } /** * Initialize the plugin hooks. * * @since 1.0.0 * @return void */ private function init_hooks() { register_activation_hook( AICW_FILE, array( Installer::class, 'install' ) ); register_deactivation_hook( AICW_FILE, array( $this, 'deactivate' ) ); add_filter( 'plugin_action_links_' . plugin_basename( AICW_FILE ), array( $this, 'action_links' ) ); add_action( 'admin_notices', array( $this, 'display_flash_notices' ), 12 ); add_action( 'init', array( $this, 'init' ), 0 ); } /** * Get the plugin version. * * @since 1.0.0 * @return string The plugin version. */ public function get_version() { return $this->version; } /** * Deactivate the plugin. * * @since 1.0.0 * @return void */ public function deactivate() { flush_rewrite_rules(); } /** * Add action links to the plugin. * * @param array $links The plugin action links. * * @since 1.0.0 * @return array The modified plugin action links. */ public function action_links( $links ) { $action_links = array( 'settings' => sprintf( /* translators: %1$s: Settings URL, %2$s: Settings text */ '%2$s', esc_url( admin_url( 'admin.php?page=aicw-settings' ) ), esc_html__( 'Settings', 'ai-content-writer' ) ), ); $action_links = array_merge( $action_links, $links ); if ( ! defined( 'AICW_PRO_VERSION' ) ) { $action_links['go_pro'] = '' . esc_html__( 'Go Pro', 'ai-content-writer' ) . ''; } return $action_links; } /** * Add a flash notice. * * @param string $notice Notice message. * @param string $type This can be "info", "warning", "error" or "success", "success" as default. * @param boolean $dismissible Whether the notice is-dismissible or not. * * @since 1.0.0 * @return void */ public function flash_notice( $notice = '', $type = 'success', $dismissible = true ) { $notices = get_option( 'aicw_flash_notices', array() ); $dismissible_text = ( $dismissible ) ? 'is-dismissible' : ''; // Add new notice. $notices[] = array( 'notice' => $notice, 'type' => $type, 'dismissible' => $dismissible_text, ); // Update the notices array. update_option( 'aicw_flash_notices', $notices ); } /** * Display flash notices after that, remove the option to prevent notices being displayed forever. * * @since 1.0.0 * @return void */ public function display_flash_notices() { $notices = get_option( 'aicw_flash_notices', array() ); foreach ( $notices as $notice ) { printf( '

%3$s

', esc_attr( $notice['type'] ), esc_attr( $notice['dismissible'] ), esc_html( $notice['notice'] ), ); } // Reset options to prevent notices being displayed forever. if ( ! empty( $notices ) ) { delete_option( 'aicw_flash_notices', array() ); } } /** * Initialize the plugin. * * @since 1.0.0 * @return void */ public function init() { // Load common classes. new Installer(); new PostTypes(); new Cron(); new RestAPI(); new Frontend(); // Load admin classes. if ( is_admin() ) { new Admin\Admin(); new Admin\Dashboard(); new Admin\Actions(); new Admin\Settings(); new Admin\Notices(); new Admin\BlockEditor(); new Admin\ClassicEditor(); } } /** * Fetch stock images from the configured image source. * * All sources return the same (Pexels-shaped) photo structure: * `src.original`, `alt`, `photographer`, `url` — so downstream consumers * (thumbnail cron, featured-image picker) work with any source. * * @param string $query The query to search images. * @param int $per_page The number of images to fetch. * * @since 1.0.0 * @return array|\WP_Error The images array. */ public function generate_images( $query, $per_page = 20 ) { $source = get_option( 'aicw_image_source', 'pexels' ); return $this->fetch_images( $source, $query, $per_page ); } /** * Fetch stock images from a specific source. * * @param string $source The image source: pexels, unsplash, or pixabay. * @param string $query The query to search images. * @param int $per_page The number of images to fetch. * * @since 2.3.0 * @return array|\WP_Error Normalized images array. */ public function fetch_images( $source, $query, $per_page = 20 ) { /** * Short-circuit image fetching for add-on sources. * * Return a non-null value (normalized photos array or WP_Error) to * serve the request. Sources registered via `aicw_image_sources` * implement their fetching here. * * @param array|\WP_Error|null $images The images, or null to continue. * @param string $source The image source slug. * @param string $query The search query. * @param int $per_page The number of images requested. * * @since 2.4.0 */ $images = apply_filters( 'aicw_pre_fetch_images', null, $source, $query, $per_page ); if ( null !== $images ) { return $images; } switch ( $source ) { case 'unsplash': return $this->fetch_unsplash_images( $query, $per_page ); case 'pixabay': return $this->fetch_pixabay_images( $query, $per_page ); case 'pexels': default: return $this->fetch_pexels_images( $query, $per_page ); } } /** * Fetch images from Pexels. * * @param string $query The search query. * @param int $per_page The number of images to fetch. * * @since 2.3.0 * @return array|\WP_Error */ protected function fetch_pexels_images( $query, $per_page ) { $api_key = get_option( 'aicw_pexels_api_key' ); if ( empty( $api_key ) ) { return new \WP_Error( 'aicw_api_key_not_set', esc_html__( 'Please configure the API settings. A valid Pexels API key is required to generate the images.', 'ai-content-writer' ) ); } $url = 'https://api.pexels.com/v1/search?query=' . rawurlencode( $query ) . '&per_page=' . absint( $per_page ); $data = $this->fetch_image_api( $url, array( 'Authorization' => $api_key ) ); if ( is_wp_error( $data ) ) { return $data; } if ( empty( $data['photos'] ) ) { return new \WP_Error( 'unexpected_api_response', esc_html__( 'An unexpected error occurred while generating the images.', 'ai-content-writer' ) ); } // Pexels is already the canonical shape. return $data['photos']; } /** * Fetch images from Unsplash, normalized to the Pexels shape. * * @param string $query The search query. * @param int $per_page The number of images to fetch. * * @since 2.3.0 * @return array|\WP_Error */ protected function fetch_unsplash_images( $query, $per_page ) { $api_key = get_option( 'aicw_unsplash_api_key' ); if ( empty( $api_key ) ) { return new \WP_Error( 'aicw_api_key_not_set', esc_html__( 'Please configure the API settings. A valid Unsplash access key is required to generate the images.', 'ai-content-writer' ) ); } $url = 'https://api.unsplash.com/search/photos?query=' . rawurlencode( $query ) . '&per_page=' . absint( $per_page ); $data = $this->fetch_image_api( $url, array( 'Authorization' => 'Client-ID ' . $api_key ) ); if ( is_wp_error( $data ) ) { return $data; } if ( empty( $data['results'] ) ) { return new \WP_Error( 'unexpected_api_response', esc_html__( 'An unexpected error occurred while generating the images.', 'ai-content-writer' ) ); } $photos = array(); foreach ( $data['results'] as $photo ) { if ( empty( $photo['urls']['regular'] ) ) { continue; } $photos[] = array( 'src' => array( 'original' => $photo['urls']['regular'], 'large' => $photo['urls']['regular'], 'medium' => isset( $photo['urls']['small'] ) ? $photo['urls']['small'] : $photo['urls']['regular'], ), 'alt' => isset( $photo['alt_description'] ) ? (string) $photo['alt_description'] : '', 'photographer' => isset( $photo['user']['name'] ) ? (string) $photo['user']['name'] : '', 'url' => isset( $photo['links']['html'] ) ? (string) $photo['links']['html'] : '', ); } return $photos; } /** * Fetch images from Pixabay, normalized to the Pexels shape. * * @param string $query The search query. * @param int $per_page The number of images to fetch. * * @since 2.3.0 * @return array|\WP_Error */ protected function fetch_pixabay_images( $query, $per_page ) { $api_key = get_option( 'aicw_pixabay_api_key' ); if ( empty( $api_key ) ) { return new \WP_Error( 'aicw_api_key_not_set', esc_html__( 'Please configure the API settings. A valid Pixabay API key is required to generate the images.', 'ai-content-writer' ) ); } // Pixabay rejects a q longer than 100 characters, so trim at a word boundary. if ( mb_strlen( $query ) > 100 ) { $query = mb_substr( $query, 0, 100 ); $space = mb_strrpos( $query, ' ' ); $query = false !== $space ? mb_substr( $query, 0, $space ) : $query; } // Pixabay matches every word, so full titles often find nothing. Fall back to fewer keywords. $keywords = $this->get_search_keywords( $query ); $queries = array_unique( array_filter( array( $query, implode( ' ', array_slice( $keywords, 0, 4 ) ), implode( ' ', array_slice( $keywords, 0, 2 ) ) ) ) ); // Pixabay requires per_page between 3 and 200. $per_page = max( 3, absint( $per_page ) ); foreach ( $queries as $search ) { $url = 'https://pixabay.com/api/?key=' . rawurlencode( $api_key ) . '&q=' . rawurlencode( $search ) . '&image_type=photo&per_page=' . $per_page; $data = $this->fetch_image_api( $url, array() ); if ( is_wp_error( $data ) ) { return $data; } if ( ! empty( $data['hits'] ) ) { break; } } if ( empty( $data['hits'] ) ) { return new \WP_Error( 'aicw_no_images_found', esc_html__( 'No matching images were found for this title.', 'ai-content-writer' ) ); } $photos = array(); foreach ( $data['hits'] as $photo ) { if ( empty( $photo['largeImageURL'] ) ) { continue; } $photos[] = array( 'src' => array( 'original' => $photo['largeImageURL'], 'large' => $photo['largeImageURL'], 'medium' => isset( $photo['webformatURL'] ) ? $photo['webformatURL'] : $photo['largeImageURL'], ), 'alt' => isset( $photo['tags'] ) ? (string) $photo['tags'] : '', 'photographer' => isset( $photo['user'] ) ? (string) $photo['user'] : '', 'url' => isset( $photo['pageURL'] ) ? (string) $photo['pageURL'] : '', ); } return $photos; } /** * Extract the meaningful search keywords from a title, in order. * * @param string $query The search query. * * @since 2.5.1 * @return array Keywords without stop words, numbers, or punctuation. */ protected function get_search_keywords( $query ) { $stop_words = array( 'about', 'add', 'and', 'are', 'best', 'can', 'create', 'does', 'easier', 'easy', 'fix', 'for', 'from', 'get', 'guide', 'how', 'improve', 'into', 'its', 'make', 'new', 'not', 'set', 'the', 'this', 'tips', 'top', 'two', 'use', 'ways', 'what', 'when', 'why', 'with', 'without', 'you', 'your' ); $words = preg_split( '/[^\p{L}\p{N}]+/u', mb_strtolower( $query ), -1, PREG_SPLIT_NO_EMPTY ); return array_values( array_unique( array_filter( $words, function ( $word ) use ( $stop_words ) { return mb_strlen( $word ) > 2 && ! is_numeric( $word ) && ! in_array( $word, $stop_words, true ); } ) ) ); } /** * Perform an image API GET request and decode the JSON response. * * @param string $url The request URL. * @param array $headers Request headers. * * @since 2.3.0 * @return array|\WP_Error Decoded response. */ protected function fetch_image_api( $url, $headers ) { $response = wp_remote_get( $url, array( 'timeout' => 30, 'headers' => $headers, ) ); if ( is_wp_error( $response ) ) { return new \WP_Error( 'api_error', $response->get_error_message() ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); // Some APIs (e.g. Pixabay) return errors as plain text; surface that message. if ( ! is_array( $data ) && wp_remote_retrieve_response_code( $response ) >= 400 && '' !== trim( $body ) ) { return new \WP_Error( 'api_error', sanitize_text_field( mb_substr( wp_strip_all_tags( $body ), 0, 300 ) ) ); } if ( ! is_array( $data ) ) { return new \WP_Error( 'unexpected_api_response', esc_html__( 'An unexpected error occurred while generating the images.', 'ai-content-writer' ) ); } return $data; } /** * Create a log entry * * @param string $message The log message. * @param string $type The log type (e.g., 'success', 'info', 'error', 'warning'). * @param int $campaign_id Optional. The campaign ID associated with the log. Default is empty. * * @since 2.0.6 * @return int|\WP_Error The ID of the created log post or WP_Error on failure. */ public function create_log( $message, $type = 'info', $campaign_id = '' ) { if ( empty( $message ) ) { return new \WP_Error( 'aicw_log_message_empty', esc_html__( 'Log message cannot be empty.', 'ai-content-writer' ) ); } $log_data = array( 'post_title' => sanitize_title( $type ), 'post_content' => wp_kses_post( $message ), 'post_status' => 'publish', 'post_type' => 'aicw_log', ); // If campaign ID is provided, add it as post meta. if ( ! empty( $campaign_id ) ) { $log_data['meta_input']['_aicw_related_campaign'] = absint( $campaign_id ); } return wp_insert_post( $log_data ); } }