fictioneer-email-notifications/utility.php
2024-09-22 01:25:05 +02:00

2087 lines
56 KiB
PHP

<?php
// No direct access!
defined( 'ABSPATH' ) OR exit;
// =======================================================================================
// GENERAL
// =======================================================================================
define(
'FCNEN_REPLACEMENTS',
array(
'{{site_name}}' => get_bloginfo( 'name' ),
'{{site_link}}' => esc_url( home_url() ),
)
);
/**
* Replace (conditional) placeholders in a string with corresponding values
*
* @since 0.1.0
*
* @param string $string The string containing the placeholders.
* @param array $extra Additional replacements.
*
* @return string The string with replaced placeholders.
*/
function fcnen_replace_placeholders( $string, $extra = [] ) {
// Setup
$replacements = array_merge( FCNEN_REPLACEMENTS, $extra );
// Replace conditional placeholders {{#placeholder}}content{{/placeholder}}
$string = preg_replace_callback( '/{{\#([\w\d_-]+)}}((?:(?!{{\/\1}}).)*){{\/\1}}/s', function( $matches ) use ( $replacements ) {
$placeholder = $matches[1];
$replacement = isset( $replacements[ "{{{$placeholder}}}" ] ) ? $replacements[ "{{{$placeholder}}}" ] : '';
// If the replacement value is not empty, replace the placeholder with the inner content; otherwise, remove the placeholder
return ! empty( $replacement ) ? str_replace( $matches[0], $matches[2], $matches[0] ) : '';
}, $string );
// Replace inverted conditional placeholders {{^placeholder}}content{{/placeholder}}
$string = preg_replace_callback( '/{{\^([\w\d_-]+)}}((?:(?!{{\/\1}}).)*){{\/\1}}/s', function( $matches ) use ( $replacements ) {
$placeholder = $matches[1];
$replacement = isset( $replacements[ "{{{$placeholder}}}" ] ) ? $replacements[ "{{{$placeholder}}}" ] : '';
// If the replacement value is empty, replace the placeholder with the inner content; otherwise, remove the placeholder
return empty( $replacement ) ? str_replace( $matches[0], $matches[2], $matches[0] ) : '';
}, $string );
// Replace regular placeholders {placeholder}
$string = str_replace( array_keys( $replacements ), array_values( $replacements ), $string );
return $string;
}
/**
* Extracts an array from $_POST
*
* @since 0.1.0
*
* @param string $key The key to extract from $_POST.
*
* @return array The extracted array or an empty array.
*/
function fcnen_get_array_from_post_string( $key ) {
return ( $_POST[ $key ] ?? 0 ) ? explode( ',', $_POST[ $key ] ) : [];
}
/**
* Return label of a taxonomy
*
* @since 0.1.0
*
* @param string $term_name Name of the taxonomy.
*
* @return string The taxonomy label.
*/
function fcnen_get_term_label( $term_name ) {
$term_labels = array(
'category' => _x( 'Cat', 'List item term label.', 'fcnen' ),
'post_tag' => _x( 'Tag', 'List item term label.', 'fcnen' ),
'fcn_genre' => _x( 'Genre', 'List item term label.', 'fcnen' ),
'fcn_fandom' => _x( 'Fandom', 'List item term label.', 'fcnen' ),
'fcn_character' => _x( 'Character', 'List item term label.', 'fcnen' ),
'fcn_content_warning' => _x( 'Warning', 'List item term label.', 'fcnen' )
);
$label = $term_labels[ $term_name ] ?? _x( 'Tax', 'Default term label.', 'fcnen' );
return $label;
}
/**
* Return attribute of a taxonomy for HTML elements
*
* @since 0.1.0
*
* @param string $term_name Name of the taxonomy.
*
* @return string The taxonomy attribute.
*/
function fcnen_get_term_html_attribute( $term_name ) {
$attribute = 'taxonomies';
switch ( $term_name ) {
case 'category':
$attribute = 'categories';
break;
case 'post_tag':
$attribute = 'tags';
break;
default:
$attribute = 'taxonomies';
break;
}
return $attribute;
}
/**
* Check whether the right set has a key of the left set
*
* @since 0.1.0
*
* @param array $left_set Array set where the keys are the values.
* @param array $right_set Array set where the keys are the values.
*
* @return bool True if there is a match of keys, false if not.
*/
function fcnen_match_sets( $left_set, $right_set ) {
foreach ( $left_set as $term_id => $value ) {
if ( isset( $right_set[ $term_id ] ) ) {
return true;
}
}
return false;
}
/**
* Get basic plugin info
*
* @since 0.1.0
*
* @return array Associative array with plugin info.
*/
function fcnen_get_plugin_info() {
// Setup
$info = get_option( 'fcnen_plugin_info' ) ?: [];
// Set up if missing
if ( ! $info || ! is_array( $info ) ) {
$info = array(
'install_date' => current_time( 'mysql', 1 ),
'last_update_check' => current_time( 'mysql', 1 ),
'last_update_version' => '',
'last_update_nag' => current_time( 'mysql', 1 ),
'last_update_notes' => '',
'last_sent' => '',
'version' => FCNEN_VERSION
);
update_option( 'fcnen_plugin_info', $info, 'yes' );
}
// Merge with defaults (in case of incomplete data)
$info = array_merge(
array(
'install_date' => current_time( 'mysql', 1 ),
'last_update_check' => current_time( 'mysql', 1 ),
'last_update_version' => '',
'last_update_nag' => '',
'last_update_notes' => '',
'last_sent' => '',
'version' => FCNEN_VERSION
),
$info
);
// Return info
return $info;
}
/**
* Scan a string for SQL injection patterns
*
* Note: Can produce false positives and false negatives.
* Good for security in depth, but should not be relied upon.
*
* @since 0.1.0
*
* @param string $string The string to scan.
*
* @return array|false An array of matched SQL injection patterns, or false if no matches found.
*/
function fcnen_scan_for_injections( $string ) {
static $patterns = null;
// SQL injection patterns
if ( $patterns === null ) {
$patterns = array(
'/\b(select|insert|update|delete|union|drop|truncate|alter|create|exec|execute|declare)\b/i',
'/\bxp_\w+/i',
'/\bsys\./i',
'/\binformation_schema\b/i',
'/\bsleep\(/i',
'/\bbenchmark\(/i',
'/\'\s*?or\s*?\'/i',
'/\'\s*?or\s*?\d+\s*?=\s*?\d+/i',
'/\/\*\W*\w*\W*\*\//i',
'/\/\*/i',
'/--/i',
'/\b1=1\b/i'
);
}
// Match array
$matchedPatterns = [];
// Check for patterns
foreach ( $patterns as $pattern ) {
if ( preg_match( $pattern, $string, $matches ) ) {
if ( ! in_array( $matches[0], $matchedPatterns ) ) {
$matchedPatterns[] = $matches[0];
}
}
}
// Return matched patterns, or null if no matches found
return empty( $matchedPatterns ) ? false : $matchedPatterns;
}
/**
* Pretty HTML output for an array
*
* @since 0.1.0
*
* @param array $array The array to output.
* @param bool $nested Whether this is a nested node in the array.
*
* @return string HTML for the current depth.
*/
function fcnen_array_to_html( $array, $nested = false ) {
if ( ! is_array( $array ) ) {
return '';
}
$content = '';
foreach ( $array as $key => $value ) {
if ( is_array( $value ) ) {
$content .= '<div class="fcnen-array-node"><strong>' . esc_html( $key ) . ':</strong></div>';
$content .= '<div class="fcnen-array-nested">' . fcnen_array_to_html( $value, 1 ) . '</div>';
} else {
$content .= '<div class="fcnen-array-node"><strong>' . esc_html( $key ) . ':</strong> <span>' . esc_html( $value ?? 0 ) . '</span></div>';
}
}
if ( $nested ) {
return $content;
} else {
return '<div class="fcnen-array">' . $content . '</div>';
}
}
// =======================================================================================
// SUBSCRIBERS
// =======================================================================================
/**
* Check whether a subscriber is already in the database
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param string $email The email address to check.
*
* @return bool Subscriber exists (true) or not (false).
*/
function fcnen_subscriber_exists( $email ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_subscribers';
// Query
$query = $wpdb->prepare( "SELECT COUNT(*) FROM $table_name WHERE email = %s", $email );
$result = $wpdb->get_var( $query );
// Result
return (int) $result > 0;
}
/**
* Get a subscriber by email (unserialized)
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param string $email The email address of the subscriber.
*
* @return object|false The subscriber object if found, false if not.
*/
function fcnen_get_subscriber_by_email( $email ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_subscribers';
// Query
$query = $wpdb->prepare( "SELECT * FROM $table_name WHERE email = %s", $email );
$subscriber = $wpdb->get_row( $query );
// Failure?
if ( empty( $subscriber ) ) {
return false;
}
// Unserialize
$subscriber->post_ids = maybe_unserialize( $subscriber->post_ids ?? 'a:0:{}' );
$subscriber->post_types = maybe_unserialize( $subscriber->post_types ?? 'a:0:{}' );
$subscriber->categories = maybe_unserialize( $subscriber->categories ?? 'a:0:{}' );
$subscriber->tags = maybe_unserialize( $subscriber->tags ?? 'a:0:{}' );
$subscriber->taxonomies = maybe_unserialize( $subscriber->taxonomies ?? 'a:0:{}' );
// Return subscriber object
return $subscriber;
}
/**
* Get a subscriber by email and code (unserialized)
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param string $email Email address of the subscriber.
* @param string $code Code of the subscriber.
*
* @return object|false The subscriber object if found, false otherwise.
*/
function fcnen_get_subscriber_by_email_and_code( $email, $code ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_subscribers';
$email = sanitize_email( $email );
$code = sanitize_text_field( $code );
// Validate
if ( empty( $email ) || empty( $code ) ) {
return false;
}
// Query
$query = $wpdb->prepare(
"SELECT * FROM $table_name WHERE trashed = 0 AND email = %s AND code = %s AND trashed = 0",
$email,
$code
);
$subscriber = $wpdb->get_row( $query );
// Failure?
if ( empty( $subscriber ) ) {
return false;
}
// Unserialize
$subscriber->post_ids = maybe_unserialize( $subscriber->post_ids ?? 'a:0:{}' );
$subscriber->post_types = maybe_unserialize( $subscriber->post_types ?? 'a:0:{}' );
$subscriber->categories = maybe_unserialize( $subscriber->categories ?? 'a:0:{}' );
$subscriber->tags = maybe_unserialize( $subscriber->tags ?? 'a:0:{}' );
$subscriber->taxonomies = maybe_unserialize( $subscriber->taxonomies ?? 'a:0:{}' );
// Return subscriber object
return $subscriber;
}
/**
* Get subscriber's scopes as token replacement values
*
* @since 0.1.0
*
* @param object $subscriber The subscriber from the database.
*
* @return array The subscriber's scopes.
*/
function fcnen_get_subscriber_scopes( $subscriber ) {
// Setup
$post_ids = maybe_unserialize( $subscriber->post_ids ?? 'a:0:{}' );
$post_types = maybe_unserialize( $subscriber->post_types ?? 'a:0:{}' );
$categories = maybe_unserialize( $subscriber->categories ?? 'a:0:{}' );
$tags = maybe_unserialize( $subscriber->tags ?? 'a:0:{}' );
$taxonomies = maybe_unserialize( $subscriber->taxonomies ?? 'a:0:{}' );
// Everything
$scope_everything = $subscriber->everything ? _x( 'Everything', 'Subscription scope.', 'fcnen' ) : '';
// Stories
$story_names = [];
$stories = empty( $post_ids ) ? [] : get_posts(
array(
'post_type'=> 'fcn_story',
'post_status'=> ['publish', 'private', 'future'],
'posts_per_page' => -1,
'post__in' => $post_ids,
'orderby' => 'post__in',
'update_post_meta_cache' => false, // Improve performance
'update_post_term_cache' => true, // Improve performance
'no_found_rows' => true // Improve performance
)
);
foreach ( $stories as $story ) {
$story_names[] = fictioneer_get_safe_title( $story->ID, 'fcnen-edit-email' );
}
// Post types
$post_type_names = array(
'post' => _x( 'Blogs', 'Subscription scope.', 'fcnen' ),
'fcn_story' => _x( 'Stories', 'Subscription scope.', 'fcnen' ),
'fcn_chapter' => _x( 'Chapters', 'Subscription scope.', 'fcnen' ),
);
$scope_post_types = [];
foreach ( $post_type_names as $type => $name ) {
if ( in_array( $type, $post_types ) ) {
$scope_post_types[] = $name;
}
}
// Terms
$all_term_ids = array_merge( $categories, $tags, $taxonomies );
$category_names = [];
$tag_names = [];
$genre_names = [];
$fandom_names = [];
$character_names = [];
$warning_names = [];
$terms = $all_term_ids ? get_terms(
array(
'taxonomy' => ['category', 'post_tag', 'fcn_genre', 'fcn_fandom', 'fcn_character', 'fcn_content_warning'],
'include' => $all_term_ids ?: [0],
'hide_empty' => false
)
) : [];
foreach ( $terms as $term ) {
switch ( $term->taxonomy ) {
case 'category':
$category_names[] = $term->name;
break;
case 'post_tag':
$tag_names[] = $term->name;
break;
case 'fcn_genre':
$genre_names[] = $term->name;
break;
case 'fcn_fandom':
$fandom_names[] = $term->name;
break;
case 'fcn_character':
$character_names[] = $term->name;
break;
case 'fcn_content_warning':
$warning_names[] = $term->name;
break;
}
}
// Return replacement values
return array(
'scope_everything' => $scope_everything,
'scope_post_types' => $scope_post_types,
'scope_stories' => $story_names,
'scope_categories' => $category_names,
'scope_tags' => $tag_names,
'scope_genres' => $genre_names,
'scope_fandoms' => $fandom_names,
'scope_characters' => $character_names,
'scope_warnings' => $warning_names
);
}
/**
* Get array of subscriber objects (unserialized)
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param bool $confirmed Whether the subscriber must be confirmed. Default true.
* @param bool $trashed Whether the subscriber must be trashed. Default false.
*
* @return array Array of subscriber objects.
*/
function fcnen_get_subscribers( $confirmed = true, $trashed = false ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_subscribers';
$sql = "SELECT * FROM {$table_name} WHERE confirmed = %d AND trashed = %d";
// Query
$subscribers = $wpdb->get_results( $wpdb->prepare( $sql, $confirmed ? 1 : 0, $trashed ? 1 : 0 ) );
// Failure or empty?
if ( empty( $subscribers ) ) {
return [];
}
// Unserialize
foreach ( $subscribers as $subscriber ) {
$subscriber->post_ids = maybe_unserialize( $subscriber->post_ids ?? 'a:0:{}' );
$subscriber->post_types = maybe_unserialize( $subscriber->post_types ?? 'a:0:{}' );
$subscriber->categories = maybe_unserialize( $subscriber->categories ?? 'a:0:{}' );
$subscriber->tags = maybe_unserialize( $subscriber->tags ?? 'a:0:{}' );
$subscriber->taxonomies = maybe_unserialize( $subscriber->taxonomies ?? 'a:0:{}' );
}
// Return result
return $subscribers;
}
/**
* Get count of subscribers
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param bool $confirmed Whether the subscriber must be confirmed. Default true.
* @param bool $trashed Whether the subscriber must be trashed. Default false.
*
* @return int Count of subscribers.
*/
function fcnen_get_subscribers_count( $confirmed = true, $trashed = false ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_subscribers';
$sql = "SELECT COUNT(*) FROM {$table_name} WHERE confirmed = %d AND trashed = %d";
// Query
$count = $wpdb->get_var( $wpdb->prepare( $sql, $confirmed ? 1 : 0, $trashed ? 1 : 0 ) );
// Return result
return (int) $count;
}
/**
* Get array of email-ready subscribers
*
* @since 0.1.0
*
* @param array|null $subscribers Array of subscriber objects. Defaults to
* all confirmed, non-trashed subscribers.
*
* @return array Array of prepared associative subscriber arrays.
*/
function fcnen_get_email_subscribers( $subscribers = null ) {
// Setup
$excluded_emails = get_option( 'fcnen_excluded_emails', [] );
$excluded_emails = is_array( $excluded_emails ) ? $excluded_emails : [];
$subscribers = $subscribers ? $subscribers : fcnen_get_subscribers();
$prepared = [];
// Prepare subscribers
foreach ( $subscribers as $subscriber ) {
if ( in_array( $subscriber->email, $excluded_emails ) ) {
continue;
}
$data = array(
'id' => $subscriber->id,
'email' => $subscriber->email,
'code' => $subscriber->code,
'everything' => $subscriber->everything,
'post_ids' => maybe_unserialize( $subscriber->post_ids ?? 'a:0:{}' ),
'post_types' => maybe_unserialize( $subscriber->post_types ?? 'a:0:{}' ),
'categories' => maybe_unserialize( $subscriber->categories ?? 'a:0:{}' ),
'tags' => maybe_unserialize( $subscriber->tags ?? 'a:0:{}' ),
'taxonomies' => maybe_unserialize( $subscriber->taxonomies ?? 'a:0:{}' ),
'confirmed' => $subscriber->confirmed,
'trashed' => $subscriber->trashed,
);
$prepared[ $subscriber->email ] = $data;
}
// Return result
return $prepared;
}
// =======================================================================================
// SANITIZATION
// =======================================================================================
/**
* Returns database-ready array of IDs
*
* Note: Saving ID as string instead of integer is better for SQL
* queries, because serialized arrays can also us integers as key
* and that creates problems for certain matching operations.
*
* @since 0.1.0
*
* @param array $ids The IDs to prepare.
*
* @return array Sanitized and stringified IDs.
*/
function fcnen_prepare_id_array( $ids ) {
if ( empty( $ids ) ) {
return [];
}
$ids = array_map( 'absint', $ids );
$ids = array_unique( $ids );
return array_map( 'strval', $ids );
}
/**
* Returns sanitized array of story-type post IDs
*
* @since 0.1.0
*
* @param array $post_ids Post IDs that should be stories.
*
* @return array Array of post IDs of type fcn_story.
*/
function fcnen_sanitize_post_ids( $post_ids ) {
return empty( $post_ids ) ? [] : get_posts(
array(
'post_type'=> 'fcn_story',
'post_status'=> ['publish', 'private', 'future'],
'posts_per_page' => -1,
'post__in' => $post_ids,
'orderby' => 'post__in',
'fields' => 'ids',
'update_post_meta_cache' => false, // Improve performance
'update_post_term_cache' => true, // Improve performance
'no_found_rows' => true // Improve performance
)
);
}
/**
* Returns sanitized array of term IDs
*
* @since 0.1.0
*
* @param array $term_ids IDs that should be terms.
*
* @return array Array of term IDs.
*/
function fcnen_sanitize_term_ids( $term_ids ) {
return empty( $term_ids ) ? [] : get_terms(
array(
'taxonomy' => ['category', 'post_tag', 'fcn_genre', 'fcn_fandom', 'fcn_character', 'fcn_content_warning'],
'include' => $term_ids,
'fields' => 'ids',
'hide_empty' => false,
'update_term_meta_cache' => false // Improve performance
)
);
}
// =======================================================================================
// HTML
// =======================================================================================
/**
* Returns HTML for source list item
*
* @since 0.1.0
*
* @param array $args Arguments for the HTML. Default empty.
*
* @return string The list item HTML.
*/
function fcnen_get_source_node( $args = [] ) {
$name = $args['name'] ?? '';
$type = $args['type'] ?? '';
$id = $args['id'] ?? '';
$label = $args['label'] ?? '';
$title = $args['title'] ?? '';
return "<li class='fcnen-dialog-modal__advanced-li _taxonomy' data-click-action='fcnen-add' data-name='{$name}[]' data-type='{$type}' data-compare='{$type}-{$id}' data-id='{$id}'><span class='fcnen-item-label'>{$label}</span> <span class='fcnen-item-name'>{$title}</span><i class='fa-solid fa-plus fcnen-icon'></i></li>";
}
/**
* Returns HTML for selection list item
*
* @since 0.1.0
*
* @param array $args Arguments for the HTML. Default empty.
*
* @return string The list item HTML.
*/
function fcnen_get_selection_node( $args = [] ) {
$name = $args['name'] ?? '';
$type = $args['type'] ?? '';
$id = $args['id'] ?? '';
$label = $args['label'] ?? '';
$title = $args['title'] ?? '';
return "<li class='fcnen-dialog-modal__advanced-li _selected' data-type='{$type}' data-compare='{$type}-{$id}' data-id='{$id}'><span class='fcnen-item-label'>{$label}</span> <span class='fcnen-item-name'>{$title}</span><i class='fa-solid fa-minus fcnen-icon' data-click-action='fcnen-remove'></i><input type='hidden' name='{$name}[]' value='{$id}'></li>";
}
// =======================================================================================
// LOG
// =======================================================================================
/**
* Returns (or creates) secret log hash used to obscure the log file name
*
* @since 1.0.2
*
* @return string The log hash.
*/
function fcnen_get_log_hash() {
$hash = strval( get_option( 'fictioneer_log_hash' ) );
if ( ! empty( $hash ) ) {
return $hash;
}
$hash = wp_generate_password( 32, false );
update_option( 'fictioneer_log_hash', $hash, 'no' );
return $hash;
}
/**
* Logs a message to the plugin log file
*
* @since 0.1.0
*
* @param string $message The message to log.
*/
function fcnen_log( $message ) {
// Setup
$current_user = wp_get_current_user();
$log_hash = fcnen_get_log_hash();
$log_file = WP_CONTENT_DIR . "/fcnen-{$log_hash}-log.log";
$date = current_time( 'mysql', 1 );
// Acting user?
$user_id = $current_user ? $current_user->ID : '0';
$username = $current_user ? $current_user->user_login : _x( 'Unknown', 'Log file.', 'fcnen' );
if ( empty( $current_user ) && wp_doing_cron() ) {
$username = _x( 'WP Cron', 'Log file.', 'fcnen' );
}
if ( empty( $current_user ) && wp_doing_ajax() ) {
$username = _x( 'AJAX', 'Log file.', 'fcnen' );
}
// Make sure the log file exists
if ( ! file_exists( $log_file ) ) {
file_put_contents( $log_file, '' );
}
// Read
$log_contents = file_get_contents( $log_file );
// Parse
$log_entries = explode( "\n", $log_contents );
// Limit (if too large)
$log_entries = array_slice( $log_entries, -( FCNEN_LOG_LIMIT + 1 ) );
// Add new entry
$log_entries[] = "[{$date} UTC] [#{$user_id}|{$username}] $message";
// Concatenate and save
file_put_contents( $log_file, implode( "\n", $log_entries ) );
// Set file permissions
chmod( $log_file, 0600 );
}
/**
* Retrieves the log entries and returns an HTML representation
*
* @since 0.1.0
*
* @return string The HTML representation of the log entries.
*/
function fcnen_get_log() {
// Setup
$log_hash = fcnen_get_log_hash();
$log_file = WP_CONTENT_DIR . "/fcnen-{$log_hash}-log.log";
// Check whether log file exists
if ( ! file_exists( $log_file ) ) {
return '<ul class="fcnen-log"><li>' . __( 'No log entries yet.', 'fcnen' ) . '</li></ul>';
}
// Read
$log_contents = file_get_contents( $log_file );
// Parse
$log_entries = explode( "\n", $log_contents );
// Limit display to 200
$log_entries = array_slice( $log_entries, -200 );
// Reverse
$log_entries = array_reverse( $log_entries );
// Build HTML
$output = '<ul class="fcnen-log">';
foreach ( $log_entries as $entry ) {
$output .= '<li class="fcnen-log__item">' . $entry . '</li>';
}
$output .= '</ul>';
// Return HTML
return $output;
}
// =======================================================================================
// NOTIFICATIONS
// =======================================================================================
/**
* Returns the number of ready notifications
*
* Note: This does include blocked notifications!
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @return bool True if paused, false if not.
*/
function fcnen_notification_ready_count() {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_notifications';
// Return count
return $wpdb->get_var( "SELECT COUNT(*) FROM $table_name WHERE paused = 0 AND last_sent IS NULL" ) ?? 0;
}
/**
* Returns whether a notification is paused
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param int $post_id Post ID of the notification.
*
* @return bool True if paused, false if not.
*/
function fcnen_notification_paused( $post_id ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_notifications';
// Query
$query = $wpdb->prepare( "SELECT COUNT(*) FROM {$table_name} WHERE post_id = %d AND paused = 1", $post_id );
// Result
return $wpdb->get_var( $query ) > 0;
}
/**
* Returns whether a post can be sent as notification
*
* @since 0.1.0
*
* @param WP_Post|int $post Post or post ID.
* @param bool $with_message Whether to return the cause of failure.
*
* @return bool|array True or false, or an array with the result and message.
*/
function fcnen_post_sendable( $post, $with_message = false ) {
// Resolve post ID
if ( is_numeric( $post ) ) {
$post = get_post( $post );
}
// Post not found?
if ( ! ( $post instanceof WP_Post ) ) {
if ( $with_message ) {
return array( 'sendable' => false, 'message' => 'post-not-found' );
} else {
return false;
}
}
// Setup
$allowed_types = ['post', 'fcn_story', 'fcn_chapter'];
$allow_password = get_option( 'fcnen_flag_allow_passwords' );
$allow_hidden = get_option( 'fcnen_flag_allow_hidden' );
// Reject non-published posts
if ( $post->post_status !== 'publish' ) {
if ( $with_message ) {
return array( 'sendable' => false, 'message' => 'post-unpublished' );
} else {
return false;
}
}
// Maybe reject password-protected posts
if ( ! empty( $post->post_password ) && ! $allow_password ) {
if ( $with_message ) {
return array( 'sendable' => false, 'message' => 'post-protected' );
} else {
return false;
}
}
// Reject invalid post types
if ( ! in_array( $post->post_type, $allowed_types ) ) {
if ( $with_message ) {
return array( 'sendable' => false, 'message' => 'post-invalid-type' );
} else {
return false;
}
}
// Reject excluded posts
$meta = fcnen_get_meta( $post->ID );
if ( $meta['excluded'] ?? 0 ) {
if ( $with_message ) {
return array( 'sendable' => false, 'message' => 'post-excluded' );
} else {
return false;
}
}
// Maybe reject hidden posts
$story_hidden = get_post_meta( $post->ID, 'fictioneer_story_hidden', true );
$chapter_hidden = get_post_meta( $post->ID, 'fictioneer_chapter_hidden', true );
if ( ! $allow_hidden && ( $story_hidden || $chapter_hidden ) ) {
if ( $with_message ) {
return array( 'sendable' => false, 'message' => 'post-hidden' );
} else {
return false;
}
}
// All good
if ( $with_message ) {
return array( 'sendable' => true, 'message' => 'post-sendable' );
} else {
return true;
}
}
/**
* Checks whether a post is already enqueued as unsent notification
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param string $post_id The post ID to check.
*
* @return bool Unsent notification exists (true) or not (false).
*/
function fcnen_unsent_notification_exists( $post_id ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_notifications';
// Query
$query = $wpdb->prepare(
"SELECT COUNT(*) FROM {$table_name} WHERE post_id = %d AND last_sent IS NULL",
$post_id
);
$result = $wpdb->get_var( $query );
// Result
return (int) $result > 0;
}
/**
* Marks a single notification as 'sent' with date
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param string $post_id The post ID of the notification.
*
* @return int|false The number of rows updated, or false on error.
*/
function fcnen_mark_notification_as_sent( $post_id ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_notifications';
// Update DB
$result = $wpdb->update(
$table_name,
array( 'last_sent' => current_time( 'mysql', 1 ) ),
array( 'post_id' => $post_id )
);
// Return result
return $result;
}
/**
* Return a single notification based on a post ID
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param string $post_id The post ID to query for.
*
* @return object|null The notification object or null on failure.
*/
function fcnen_get_notification( $post_id ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_notifications';
// Prepare
$sql = $wpdb->prepare(
"SELECT * FROM {$table_name} WHERE post_id = %d AND last_sent IS NULL LIMIT 1",
$post_id
);
// Query and return
return $wpdb->get_row( $sql );
}
/**
* Get array of notification objects (unserialized)
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param bool $paused Optional. Whether the notification must be paused. Default false.
* @param bool $sent Optional. Whether the notification must have been sent. Default false.
* @param string $output Optional. Return notifications as objects or arrays. Default objects.
*
* @return array Array of notifications.
*/
function fcnen_get_notifications( $paused = false, $sent = false, $output = \OBJECT ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_notifications';
$sql = "SELECT * FROM {$table_name} WHERE paused = %d AND last_sent " . ( $sent ? 'IS NOT NULL' : 'IS NULL' );
// Query
$notifications = $wpdb->get_results( $wpdb->prepare( $sql, $paused ? 1 : 0 ), $output );
// Return result
if ( ! empty( $notifications ) ) {
return $notifications;
} else {
return [];
}
}
/**
* Get array of WP_Post objects for ready notifications
*
* @since 0.1.0
*
* @param array|null $notifications Array of notifications objects. Defaults to
* all ready, unsent notifications.
*
* @return array The WP_Post objects.
*/
function fcnen_get_email_posts( $notifications = null ) {
// Setup
$notifications = $notifications ? $notifications : fcnen_get_notifications();
$excluded_posts = get_option( 'fcnen_excluded_posts', [] ) ?: [];
$excluded_authors = get_option( 'fcnen_excluded_authors', [] ) ?: [];
$post_ids = [];
// Extract post IDs
foreach ( $notifications as $notification ) {
$post_ids[ $notification->post_id ] = $notification->post_id;
if ( $notification->story_id ?? 0 ) {
$post_ids[ $notification->story_id ] = $notification->story_id;
}
}
// Empty IDs?
if ( empty( $post_ids ) ) {
return [];
}
// Get posts
$posts = get_posts(
array(
'post_type' => ['post', 'fcn_story', 'fcn_chapter'],
'post__in' => array_unique( $post_ids ),
'numberposts' => -1,
'update_post_meta_cache' => true,
'update_post_term_cache' => true,
'no_found_rows' => true
)
);
// Prime author cache
if ( function_exists( 'update_post_author_caches' ) ) {
update_post_author_caches( $posts );
}
// Filter posts
$sendable_posts = array_filter( $posts, function( $post ) use ( $excluded_posts, $excluded_authors ) {
$keep = fcnen_post_sendable( $post->ID );
$keep = ! in_array( $post->ID, $excluded_posts ) && $keep;
$keep = ! in_array( $post->post_author, $excluded_authors ) && $keep;
return $keep;
});
// Return result
if ( empty( $sendable_posts ) ) {
return [];
} else {
return $sendable_posts;
}
}
// =======================================================================================
// POST META
// =======================================================================================
/**
* Get un-serialized meta array for post ID
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param int $post_id The ID of the post.
*
* @return array The meta array of an empty array if not found.
*/
function fcnen_get_meta( $post_id ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_meta';
$default = array(
'excluded' => false,
'sent' => []
);
// Query
$meta = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta FROM {$table_name} WHERE post_id = %d",
$post_id
)
);
// Exists?
if ( $meta === null || $meta === false ) {
return $default;
}
// Unserialize
$meta = maybe_unserialize( $meta );
// Return
if ( is_array( $meta ) ) {
return array_merge( $default, $meta );
} else {
return $default;
}
}
/**
* Save serialized meta array for post ID
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param int $post_id The ID of the post.
* @param array $meta_array The meta array to store.
*
* @return bool True on success, false on failure.
*/
function fcnen_set_meta( $post_id, $meta_array ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_meta';
// Array?
if ( ! is_array( $meta_array ) ) {
return false;
}
// Serialize
$meta_serialized = maybe_serialize( $meta_array );
// Exists?
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table_name} WHERE post_id = %d",
$post_id
)
);
// Insert or update
if ( $exists ) {
$result = $wpdb->update(
$table_name,
array( 'meta' => $meta_serialized ),
array( 'post_id' => $post_id )
);
} else {
$result = $wpdb->insert(
$table_name,
array(
'post_id' => $post_id,
'meta' => $meta_serialized
)
);
}
// Return success or failure
return $result !== false;
}
/**
* Delete meta for post ID
*
* @since 0.1.0
* @global wpdb $wpdb The WordPress database object.
*
* @param int $post_id The ID of the post.
*/
function fcnen_delete_meta( $post_id ) {
global $wpdb;
// Setup
$table_name = $wpdb->prefix . 'fcnen_meta';
// Delete meta
$sql = $wpdb->prepare( "DELETE FROM {$table_name} WHERE post_id = %d", $post_id );
$wpdb->query( $sql );
}
// =======================================================================================
// EMAILS
// =======================================================================================
/**
* Get the from email name
*
* @since 0.1.0
*
* @return string The from email name.
*/
function fcnen_get_from_email_name() {
// From email address set?
$from = get_option( 'fcnen_from_email_name' );
if ( $from ) {
return $from;
}
// Return the blog name
return get_bloginfo( 'name' );
}
/**
* Get the from email address
*
* @since 0.1.0
*
* @return string The from email address.
*/
function fcnen_get_from_email_address() {
// From email address set?
$from = get_option( 'fcnen_from_email_address' );
if ( $from ) {
return $from;
}
// Setup
$parsed_url = wp_parse_url( get_home_url() );
$domain = isset( $parsed_url['host'] ) ? preg_replace( '/^www\./i', '', $parsed_url['host'] ) : '';
// Fallback
if ( empty( $domain ) ) {
return get_option( 'admin_email' );
}
// Return the noreply email address
return 'noreply@' . $domain;
}
/**
* Get the subject for the confirmation email
*
* @since 0.1.0
*
* @return string The subject.
*/
function fcnen_get_confirmation_email_subject() {
// Return custom subject if set
$subject = get_option( 'fcnen_template_subject_confirmation' );
if ( $subject ) {
return $subject;
}
// Return default otherwise
return fcnen_option_defaults( 'fcnen_template_subject_confirmation' );
}
/**
* Get the HTML body for the confirmation email
*
* @since 0.1.0
*
* @return string The email HTML.
*/
function fcnen_get_confirmation_email_body() {
// Custom or default
$defaults = fcnen_option_defaults();
$body = get_option( 'fcnen_template_layout_confirmation' ) ?: $defaults['fcnen_template_layout_confirmation'];
// Check for {{code}} presence
if ( strpos( $body, '{{code}}' ) === false ) {
$body = $defaults['fcnen_template_layout_confirmation'];
}
// Return HTML
return $body;
}
/**
* Get the subject for the code email
*
* @since 0.1.0
*
* @return string The subject.
*/
function fcnen_get_code_email_subject() {
// Return custom subject if set
$subject = get_option( 'fcnen_template_subject_code' );
if ( $subject ) {
return $subject;
}
// Return default otherwise
return fcnen_option_defaults( 'fcnen_template_subject_code' );
}
/**
* Get the HTML body for the code email
*
* @since 0.1.0
*
* @return string The email HTML.
*/
function fcnen_get_code_email_body() {
// Custom or default
$defaults = fcnen_option_defaults();
$body = get_option( 'fcnen_template_layout_code' ) ?: $defaults['fcnen_template_layout_code'];
// Check for {{code}} presence
if ( strpos( $body, '{{code}}' ) === false ) {
$body = $defaults['fcnen_template_layout_code'];
}
// Return HTML
return $body;
}
/**
* Get the subject for the edit email
*
* @since 0.1.0
*
* @return string The subject.
*/
function fcnen_get_edit_email_subject() {
// Return custom subject if set
$subject = get_option( 'fcnen_template_subject_edit' );
if ( $subject ) {
return $subject;
}
// Return default otherwise
return fcnen_option_defaults( 'fcnen_template_subject_edit' );
}
/**
* Get the HTML body for the edit email
*
* @since 0.1.0
*
* @return string The email HTML.
*/
function fcnen_get_edit_email_body() {
// Return custom or default
return get_option( 'fcnen_template_layout_edit' ) ?: fcnen_option_defaults( 'fcnen_template_layout_edit' );
}
/**
* Get the subject for the edit email
*
* @since 0.1.0
*
* @return string The subject.
*/
function fcnen_get_notification_email_subject() {
// Return custom subject if set
$subject = get_option( 'fcnen_template_subject_notification' );
if ( $subject ) {
return $subject;
}
// Return default otherwise
return fcnen_option_defaults( 'fcnen_template_subject_notification' );
}
/**
* Get the activation link for the subscriber
*
* @since 0.1.0
*
* @param string $email Email address of the subscriber.
* @param string $code Code of the subscriber.
*
* @return string The activation link.
*/
function fcnen_get_activation_link( $email, $code ) {
// Setup
$query_args = array(
'fcnen' => 1,
'fcnen-action' => 'activation',
'fcnen-email' => urlencode( $email ),
'fcnen-code' => urlencode( $code )
);
// Return link
return add_query_arg( $query_args, home_url() );
}
/**
* Get the unsubscribe link for the subscriber
*
* @since 0.1.0
*
* @param string $email The email address of the subscriber.
* @param string $code The code associated with the subscriber.
*
* @return string The unsubscribe link.
*/
function fcnen_get_unsubscribe_link( $email, $code ) {
// Setup
$query_args = array(
'fcnen' => 1,
'fcnen-action' => 'unsubscribe',
'fcnen-email' => urlencode( $email ),
'fcnen-code' => urlencode( $code )
);
// Return link
return add_query_arg( $query_args, home_url() );
}
/**
* Get the edit link for the subscriber
*
* @since 0.1.0
*
* @param string $email The email address of the subscriber.
* @param string $code The code associated with the subscriber.
*
* @return string The edit link.
*/
function fcnen_get_edit_link( $email, $code ) {
// Setup
$query_args = array(
'fcnen' => 1,
'fcnen-action' => 'edit',
'fcnen-email' => urlencode( $email ),
'fcnen-code' => urlencode( $code )
);
// Return link
return add_query_arg( $query_args, home_url() );
}
/**
* Get matching notification content for each subscriber
*
* @since 0.1.0
*
* @param array|null $subscribers Optional. Array of prepared subscribers.
*
* @return array Associated array of posts, subscribers and matched IDs.
*/
function fcnen_get_notification_contents( $subscribers = null ) {
// Setup
$subscribers = $subscribers ?? fcnen_get_email_subscribers();
$posts = fcnen_get_email_posts();
$post_terms = [];
$contents = array(
'posts' => array_column( $posts, null, 'ID' ), // Use IDs as keys
'subscribers' => []
);
// Prepare terms
foreach ( $posts as $post ) {
$term_ids = wp_get_post_terms(
$post->ID,
['category', 'post_tag', 'fcn_genre', 'fcn_fandom', 'fcn_character', 'fcn_content_warning'],
array( 'fields' => 'ids' )
);
if ( ! is_wp_error( $term_ids ) && ! empty( $term_ids ) ) {
$post_terms[ $post->ID ] = array_flip( $term_ids ); // Values become keys
}
}
// Match notifications to subscriber scopes
foreach ( $subscribers as $subscriber ) {
// Collect matches
$matches = [];
// Everything?
if ( $subscriber['everything'] ?? 0 ) {
$contents['subscribers'][ $subscriber['email'] ] = array(
'subscriber' => $subscriber,
'post_ids' => array_column( $posts, 'ID', 'ID' ) // Use IDs as keys and only keep IDs
);
continue;
}
// Match posts...
foreach ( $posts as $post ) {
// Match post type
if ( in_array( $post->post_type, $subscriber['post_types'] ?? [] ) ) {
$matches[ $post->ID ] = $post->ID;
continue;
}
// Match post ID
if ( in_array( $post->ID, $subscriber['post_ids'] ?? [] ) ) {
$matches[ $post->ID ] = $post->ID;
continue;
}
// Match parent story ID (if any)
if ( $post->post_type === 'fcn_chapter' ) {
$story_id = get_post_meta( $post->ID, 'fictioneer_chapter_story', true );
if ( in_array( $story_id, $subscriber['post_ids'] ?? [] ) ) {
$matches[ $post->ID ] = $post->ID;
continue;
}
}
// Match terms (post terms are flipped)
$post_terms_set = $post_terms[ $post->ID ] ?? [];
if (
fcnen_match_sets( $post_terms_set, array_flip( $subscriber['tags'] ) ) ||
fcnen_match_sets( $post_terms_set, array_flip( $subscriber['taxonomies'] ) ) ||
fcnen_match_sets( $post_terms_set, array_flip( $subscriber['categories'] ) )
) {
$matches[ $post->ID ] = $post->ID;
}
}
// Append subscriber
if ( ! empty( $matches ) ) {
$contents['subscribers'][ $subscriber['email'] ] = array(
'subscriber' => $subscriber,
'post_ids' => $matches
);
}
}
// Return result
return $contents;
}
/**
* Get notification email bodies for subscribers
*
* @since 0.1.0
*
* @param array $args {
* Array of optional arguments.
*
* @type array $subscribers Array of prepared email subscribers. Defaults
* to return value of fcnen_get_email_subscribers().
* @type bool $preview Optional. Whether this is for an email preview.
* }
*
* @return array Associated array of posts and email bodies.
*/
function fcnen_get_notification_emails( $args = [] ) {
// Setup
$defaults = fcnen_option_defaults();
$contents = fcnen_get_notification_contents( $args['subscribers'] ?? null );
$posts = $contents['posts'];
$subscribers = $contents['subscribers'];
$time_format = get_option( 'time_format' );
$date_format = get_option( 'date_format' );
$is_preview = $args['preview'] ?? 0;
$cached_partials = []; // Only need to be composed once (saves resources)
$email_bodies = [];
// No notifications or subscribers?
if ( empty( $posts ) || empty( $subscribers ) ) {
return array(
'email_bodies' => [],
'posts' => []
);
}
// Translations
$type_names = array(
'post' => __( 'Post', 'fcnen' ),
'fcn_story' => __( 'Story', 'fcnen' ),
'fcn_chapter' => __( 'Chapter', 'fcnen' )
);
// Templates
$templates = array(
'notification' => get_option( 'fcnen_template_layout_notification' ) ?: $defaults['fcnen_template_layout_notification'],
'post' => get_option( 'fcnen_template_loop_part_post' ) ?: $defaults['fcnen_template_loop_part_post'],
'fcn_story' => get_option( 'fcnen_template_loop_part_story' ) ?: $defaults['fcnen_template_loop_part_story'],
'fcn_chapter' => get_option( 'fcnen_template_loop_part_chapter' ) ?: $defaults['fcnen_template_loop_part_chapter']
);
// Remove special characters from templates
$templates = array_map( function( $a ) { return preg_replace( '/[\x00-\x1F\x7F\xA0]/u', '', $a ); }, $templates );
// Loop over subscribers...
foreach ( $subscribers as $email => $data ) {
$post_ids = $data['post_ids'];
$subscriber = $data['subscriber'];
$partials = []; // Holds post, story, and chapter HTML snippets
// Loop over matched post IDs...
foreach ( $post_ids as $post_id ) {
// Look for already prepared partial
if ( isset( $cached_partials[ $post_id ] ) ) {
$partials[] = $cached_partials[ $post_id ];
continue;
}
// Prepare replacement content
$post = $posts[ $post_id ]; // WP_Post object
$categories = get_the_category( $post_id );
$tags = get_the_tags( $post_id );
$genres = get_the_terms( $post_id, 'fcn_genre' );
$fandoms = get_the_terms( $post_id, 'fcn_fandom' );
$characters = get_the_terms( $post_id, 'fcn_character' );
$warnings = get_the_terms( $post_id, 'fcn_content_warning' );
if ( is_wp_error( $tags ) || ! $tags ) {
$tags = [];
}
if ( is_wp_error( $genres ) || ! $genres ) {
$genres = [];
}
if ( is_wp_error( $fandoms ) || ! $fandoms ) {
$fandoms = [];
}
if ( is_wp_error( $characters ) || ! $characters ) {
$characters = [];
}
if ( is_wp_error( $warnings ) || ! $warnings ) {
$warnings = [];
}
$all_terms = array_merge( $categories, $tags, $genres, $fandoms, $characters, $warnings );
$extra_replacements = array(
'{{type}}' => $type_names[ $post->post_type ], // Post, Story, or Chapter
'{{title}}' => fictioneer_get_safe_title( $post ),
'{{link}}' => esc_url( get_the_permalink( $post ) ?: '' ),
'{{excerpt}}' => fictioneer_get_forced_excerpt( $post_id, absint( get_option( 'fcnen_excerpt_length', 256 ) ) ),
'{{date}}' => get_the_date( $date_format, $post ),
'{{time}}' => get_the_time( $time_format, $post ),
'{{author}}' => get_the_author_meta( 'display_name', $post->post_author ?? 0 ) ?: __( 'Unknown Author', 'fcnen' ),
'{{author_link}}' => esc_url( get_author_posts_url( $post->post_author ?? 0 ) ?: get_home_url() ),
'{{thumbnail}}' => esc_url( get_the_post_thumbnail_url( $post, 'cover' ) ?: '' ),
'{{categories}}' => implode( ', ', wp_list_pluck( $categories, 'name' ) ) ?: '',
'{{tags}}' => implode( ', ', wp_list_pluck( $tags, 'name' ) ) ?: '',
'{{genres}}' => implode( ', ', wp_list_pluck( $genres, 'name' ) ) ?: '',
'{{fandoms}}' => implode( ', ', wp_list_pluck( $fandoms, 'name' ) ) ?: '',
'{{characters}}' => implode( ', ', wp_list_pluck( $characters, 'name' ) ) ?: '',
'{{warnings}}' => implode( ', ', wp_list_pluck( $warnings, 'name' ) ) ?: '',
'{{all_terms}}' => implode( ', ', wp_list_pluck( $all_terms, 'name' ) ),
'{{story_title}}' => '', // Empty will not be rendered
'{{story_link}}' => '' // Empty will not be rendered
);
// Chapter?
if ( $post->post_type === 'fcn_chapter' ) {
$story_id = get_post_meta( $post_id, 'fictioneer_chapter_story', true );
if ( $story_id ) {
$story = get_post( $story_id );
$extra_replacements['{{story_title}}'] = fictioneer_get_safe_title( $story );
$extra_replacements['{{story_link}}'] = esc_url( get_the_permalink( $story ) ?: '' );
}
}
// Replace placeholders in loop template and cache for next iteration
$cached_partials[ $post_id ] = fcnen_replace_placeholders( $templates[ $post->post_type ], $extra_replacements );
$partials[] = $cached_partials[ $post_id ];
}
// Replace placeholders in notification template
$email_bodies[ $email ] = fcnen_replace_placeholders(
$templates[ 'notification' ],
array(
'{{email}}' => $email,
'{{code}}' => $subscriber['code'],
'{{id}}' => $subscriber['id'],
'{{updates}}' => implode( '', $partials ),
'{{edit_link}}' => $is_preview ? '#' : esc_url( fcnen_get_edit_link( $email, $subscriber['code'] ) ),
'{{unsubscribe_link}}' => $is_preview ? '#' : esc_url( fcnen_get_unsubscribe_link( $email, $subscriber['code'] ) )
)
);
}
// Return ready email HTML for each subscriber
return array(
'email_bodies' => $email_bodies,
'posts' => $posts
);
}
/**
* Get the MailerSend payload for bulk emails
*
* @since 0.1.0
*
* @param array|null $email_bodies Optional. Array of email addresses and bodies. Defaults
* to the return of fcnen_get_notification_emails().
*
* @return array The MailerSend payload.
*/
function fcnen_get_mailersend_payload( $email_bodies = null ) {
// Setup
$email_bodies = $email_bodies ?? fcnen_get_notification_emails()['email_bodies'] ?? [];
$from = fcnen_get_from_email_address();
$name = fcnen_get_from_email_name();
$subject = fcnen_replace_placeholders( fcnen_get_notification_email_subject() );
$payload = [];
// Prepare payload
foreach ( $email_bodies as $email => $body ) {
$payload[] = array(
'from' => array( 'email' => $from, 'name' => $name ),
'to' => array(
array( 'email' => $email )
),
'subject' => $subject,
'html' => $body
);
}
// Return result
return $payload;
}
// =======================================================================================
// QUEUE
// =======================================================================================
/**
* Returns statistics for the (next) queue
*
* @since 0.1.0
*
* @param $array $queue Optional. Queue to get the statistics for, defaults to next queue.
*
* @return array Associative array with statistics.
*/
function fcnen_get_queue_statistics( $queue = null ) {
// Setup
$queue = $queue ?: fcnen_get_email_queue();
$last_sent = get_option( 'fcnen_last_sent' );
$notifications = fcnen_get_notifications( false, false );
$post_ids = [];
$sendable = [];
$blocked = [];
// Extract post IDs
foreach ( $notifications as $notification ) {
$post_ids[ $notification->post_id ] = $notification->post_id;
if ( $notification->story_id ?? 0 ) {
$post_ids[ $notification->story_id ] = $notification->story_id;
}
}
// Query posts
$posts = empty( $post_ids ) ? [] : get_posts(
array(
'post_type' => ['post', 'fcn_story', 'fcn_chapter'],
'post__in' => array_unique( $post_ids ),
'numberposts' => -1,
'update_post_meta_cache' => true,
'update_post_term_cache' => false,
'no_found_rows' => true
)
);
// Sort into sendable and blocked
foreach ( $posts as $post ) {
if ( fcnen_post_sendable( $post ) ) {
$sendable[ $post->ID] = $post;
} else {
$blocked[ $post->ID] = $post;
}
}
// Last sent date
$last_sent = ( $last_sent ?? 0 ) ? get_date_from_gmt(
$last_sent,
sprintf(
_x( '%1$s \<\b\r\> %2$s', 'Queue time format string.', 'fcnen' ),
get_option( 'date_format' ),
get_option( 'time_format' )
)
) : _x( 'Never', 'Last Sent: Never.', 'fcnen' );
// Return statistics
return array(
'total' => count( $posts ),
'sendable' => count( $sendable ),
'blocked' => count( $blocked ),
'last_sent' => $last_sent,
'emails' => $queue['count'] ?? 0,
'batches' => count( $queue['batches'] ?? [] ),
'batch_limit' => max( absint( get_option( 'fcnen_api_bulk_limit', 300 ) ), 1 ),
'api_limit' => 10
);
}
/**
* Returns HTML for the sending queue
*
* @since 0.1.0
*
* @param $array $batches Batches of bulk email payloads and their status.
*
* @return string The HTML.
*/
function fcnen_build_queue_html( $batches, $index = -1 ) {
// Setup
$html = '';
$translations = array(
'pending' => _x( 'Pending', 'Email queue batch status.', 'fcnen' ),
'transmitted' => _x( 'Transmitted', 'Email queue batch status.', 'fcnen' ),
'working' => _x( 'Working', 'Email queue batch status.', 'fcnen' ),
'error' => _x( 'Error', 'Email queue batch status.', 'fcnen' ),
'failure' => _x( 'Failure', 'Email queue batch status.', 'fcnen' )
);
// Build HTML
foreach ( $batches as $key => $batch ) {
$status = ( $index == $key ) ? 'working' : $batch['status'];
$email_count = count( $batch['payload'] );
$icon = '';
$html .= "<div class='fcnen-queue-batch' data-batch-id='{$key}' data-status='{$status}'>";
$html .= '<span class="fcnen-queue-batch__id">' .
sprintf( _x( 'Batch #%s', 'Email queue response.', 'fcnen' ), $key + 1 ) . '</span> | ';
$html .= '<span class="fcnen-queue-batch__items">' .
sprintf( _x( '%s Email(s)', 'Email queue response.', 'fcnen' ), $email_count ) . '</span> | ';
if ( $status === 'working' ) {
$icon = ' <i class="fa-solid fa-spinner fa-spin" style="--fa-animation-duration: .8s;"></i>';
}
if ( $status === 'error' ) {
$status_html = ( $batch['error'] ?? 0 ) ?
sprintf(
_x( 'Error: %s', 'Queue error message.', 'fcnen' ),
$batch['error']
) : $translations[ $status ];
} else {
$status_html = $translations[ $status ];
}
$html .= '<span class="fcnen-queue-batch__status"><span>' . $status_html . '</span>' . $icon . '</span>';
if ( $batch['code'] ?? 0 ) {
$html .= ' | <span class="fcnen-queue-batch__code">' .
sprintf(
_x( 'Code: <a href="%s" target="_blank">%s</a>', 'Email queue response.', 'fcnen' ),
'https://www.mailersend.com/help/rest-api-response-codes',
$batch['code']
) . '</span>';
}
if ( $batch['response'] ?? 0 ) {
$decoded = @json_decode( $batch['response'] );
$note = '';
if (
empty( $decoded ) ||
json_last_error() !== JSON_ERROR_NONE ||
! isset( $decoded->message ) ||
! isset( $decoded->bulk_email_id )
) {
if ( isset( $decoded->message ) ) {
$note = esc_html( $decoded->message );
} else {
$note = esc_html( $batch['response'] );
}
} else {
$note = sprintf(
_x( '%s | ID: %s', 'Email queue response.', 'fcnen' ),
esc_html( $decoded->message ),
sprintf(
'<a href="%s" target="_blank">%s</a>',
wp_nonce_url(
admin_url( "admin-post.php?action=fcnen_check_mailersend_bulk_status&id={$decoded->bulk_email_id}" ),
'fcnen-mailersend-bulk-status',
'fcnen-nonce'
),
$decoded->bulk_email_id
)
);
}
$html .= ' | <span class="fcnen-queue-batch__response">' . $note . '</span>';
}
if ( $batch['date'] ?? 0 ) {
$html .= ' | <span class="fcnen-queue-batch__date">' . $batch['date'] . '</span>';
}
$html .= '</div>';
}
// Return HTML
return $html;
}
/**
* Checks whether a set of payload batches has been completely processed
*
* @since 0.1.0
*
* @param array $batches Batches of email payloads.
*
* @return bool True if completely processed, false otherwise.
*/
function fcnen_are_batches_completed( $batches ) {
// Setup
$complete = true;
// Test
foreach ( $batches as $batch ) {
if ( ! $batch['success'] ) {
$complete = false;
break;
}
}
// Return result
return $complete;
}
/**
* Get the prepared email queue for MailerSend
*
* @since 0.1.0
*
* @return array The email queue with date and batches.
*/
function fcnen_get_email_queue() {
// Setup
$api_bulk_limit = max( absint( get_option( 'fcnen_api_bulk_limit', 300 ) ), 1 );
$emails = fcnen_get_notification_emails();
$payload = fcnen_get_mailersend_payload( $emails['email_bodies'] ?? [] );
$chunks = array_chunk( $payload, $api_bulk_limit );
$queue = array(
'provider' => 'mailersend',
'date' => current_time( 'mysql', 1 ),
'count' => count( $payload ),
'post_ids' => array_keys( $emails['posts'] ?? [] ),
'batches' => []
);
// Prepare batches
foreach ( $chunks as $chunk ) {
$queue['batches'][] = array(
'date' => null,
'success' => false,
'status' => 'pending',
'payload' => $chunk,
'attempts' => 0,
'code' => null,
'response' => null,
'error' => null
);
}
// Return batched queue
return $queue;
}