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(
'
',
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 );
}
}