diff --git a/css/settings.css b/css/settings.css
index 7dbdfcda..b21afe77 100644
--- a/css/settings.css
+++ b/css/settings.css
@@ -2593,6 +2593,7 @@ li.draggable-item .components-panel__body-toggle.components-button{
border-bottom: 0;
padding: 24px 0 0;
}
+
.fz-fallback-images {
display: flex;
flex-wrap: wrap;
@@ -2673,4 +2674,194 @@ button.feedzy-action-button {
width: 100%;
cursor: pointer;
height: unset;
-}
\ No newline at end of file
+}
+
+/* Feedzy Logs */
+.fz-logs {
+ padding: 10px;
+ margin: 10px 0;
+}
+
+.fz-logs h3 {
+ margin-bottom: 10px;
+ font-size: 1.25em;
+ color: #333;
+}
+
+/* Logs view container */
+.fz-logs-view {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+/* Individual log container */
+.fz-log-container {
+ display: flex;
+ gap: 10px;
+ background-color: #fff;
+ border: 1px solid #e0e0e0;
+ border-left-width: 5px;
+ padding: 8px 10px;
+ transition: background-color 0.1s ease;
+ font-size: 0.875em;
+}
+
+.fz-log-container:hover {
+ background-color: #f5f5f5;
+}
+
+/* Left section */
+.fz-log-container__left {
+ flex: 0 0 50%;
+ min-width: 0;
+}
+
+/* Header with level and date */
+.fx-log-container__header {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-bottom: 4px;
+ font-size: 1.1em;
+}
+
+/* Log level styling */
+.fx-log-container__header > *:first-child {
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 0.7em;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+ text-transform: uppercase;
+ min-width: 50px;
+ text-align: center;
+}
+
+/* Date styling */
+.fz-log-container__date {
+ color: #757575;
+ font-size: 0.85em;
+}
+
+/* Message styling */
+.fz-log-container__message {
+ color: #212529;
+ line-height: 1.3;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ font-size: 1em;
+ margin-top: 10px;
+}
+
+/* Right section - Context */
+.fz-log-container__right {
+ flex: 1;
+ min-width: 0;
+}
+
+/* Context styling - compact */
+.fz-log-container__context {
+ background-color: #f8f9fa;
+ border: 1px solid #e9ecef;
+ border-radius: 5px;
+ padding: 6px 8px;
+ font-family: 'Courier New', Consolas, Monaco, monospace;
+ font-size: 0.9em;
+ line-height: 1.3;
+ color: #495057;
+ white-space: pre-wrap;
+ word-break: break-all;
+ overflow-wrap: break-word;
+}
+
+.fz-log-container--error {
+ border-left-color: red;
+}
+
+.fz-log-container--info {
+ border-left-color: blue;
+}
+
+.fz-log-container--debug {
+ border-left-color: green;
+}
+
+.fz-log-container--warning {
+ border-left-color: yellow;
+}
+
+.fz-log-container--critical {
+ border-left-color: violet;
+}
+
+.fz-logs-header {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+}
+
+.fz-logs-header-actions {
+ display: flex;
+ flex-direction: row;
+ gap: 0.5rem;
+}
+
+.fz-logs-header-title {
+ display: flex;
+ flex-direction: row;
+ gap: 0.5rem;
+ align-items: baseline;
+}
+
+.fz-block__column {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.fz-group__row {
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+ align-items: center;
+}
+
+.fz-group__left {
+ justify-content: flex-end;
+}
+
+.btn-outline-primary.fz-is-destructive {
+ color: #cc1818;
+ border-color: #cc1818;
+}
+
+.btn-outline-primary.fz-is-destructive:hover {
+ color: #cc1818;
+ border-color: #cc1818;
+ box-shadow: inset 0 0 0 1px #cc1818, inset 0 0 0 2px #f7f9fd;
+ background: #fdf7f7;
+}
+
+.fz-log-file-size-wrapper {
+ display: flex;
+ flex-direction: row;
+ gap: 0.5rem;
+ align-items: baseline;
+}
+
+.fz-log-file-size-wrapper .dashicons {
+ font-size: 1.9em;
+}
+
+.fz-hidden {
+ display: none;
+}
+
+.fz-quick-link-actions {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 20px;
+}
diff --git a/feedzy-rss-feed.php b/feedzy-rss-feed.php
index bf944d72..eb3fd085 100644
--- a/feedzy-rss-feed.php
+++ b/feedzy-rss-feed.php
@@ -271,6 +271,8 @@ function feedzy_register_parrot( $plugins ) {
* @param string $type Error type.
* @param string $file File where the event occurred.
* @param int $line Line number where the event occurred.
+ *
+ * @deprecated 5.1.0 Use Feedzy_Rss_Feeds_Log instead.
*/
function feedzy_themeisle_log_event( $name, $msg, $type, $file, $line ) {
if ( FEEDZY_NAME === $name ) {
@@ -279,35 +281,6 @@ function feedzy_themeisle_log_event( $name, $msg, $type, $file, $line ) {
}
}
-/**
- * Store import job errors in metadata.
- *
- * @param string $name Name.
- * @param string $msg Error message.
- * @param string $type Error type.
- *
- * @return void
- */
-function feedzy_import_job_logs( $name, $msg, $type ) {
- if ( ! in_array( $type, apply_filters( 'feedzy_allowed_store_log_types', array( 'error' ) ), true ) ) {
- return;
- }
- if ( ! wp_doing_ajax() || wp_doing_cron() ) {
- return;
- }
- if ( apply_filters( 'feedzy_skip_store_error_logs', false ) ) {
- return;
- }
- global $themeisle_log_event;
-
- if ( ! empty( $themeisle_log_event ) && count( $themeisle_log_event ) >= 200 ) {
- return;
- }
-
- $themeisle_log_event[] = $msg;
-}
-add_action( 'themeisle_log_event', 'feedzy_import_job_logs', 20, 3 );
-
add_filter(
'feedzy_rss_feeds_float_widget_metadata',
function () {
diff --git a/includes/abstract/feedzy-rss-feeds-admin-abstract.php b/includes/abstract/feedzy-rss-feeds-admin-abstract.php
index 0027ae1e..54c983c1 100644
--- a/includes/abstract/feedzy-rss-feeds-admin-abstract.php
+++ b/includes/abstract/feedzy-rss-feeds-admin-abstract.php
@@ -527,6 +527,8 @@ public function rest_route() {
),
)
);
+
+ Feedzy_Rss_Feeds_Log::get_instance()->register_endpoints();
}
/**
@@ -861,7 +863,14 @@ function ( $time ) use ( $cache_time ) {
if ( ! $wp_filesystem->exists( $dir ) ) {
$done = $wp_filesystem->mkdir( $dir );
if ( false === $done ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Unable to create directory %s', $dir ), 'error', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::error(
+ sprintf( 'Unable to create SimplePie cache directory: %s', $dir ),
+ array(
+ 'feed_url' => $feed_url,
+ 'cache' => $cache,
+ 'sc' => $sc,
+ )
+ );
}
}
$feed->set_cache_location( $dir );
@@ -902,14 +911,35 @@ function ( $time ) use ( $cache_time ) {
}
if ( ! empty( $error ) ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Error while parsing feed: %s', $error ), 'error', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::error(
+ sprintf( 'Error while parsing feed: %s', $error ),
+ array(
+ 'feed_url' => $feed_url,
+ 'cache' => $cache,
+ 'sc' => $sc,
+ )
+ );
// curl: (60) SSL certificate problem: unable to get local issuer certificate.
if ( strpos( $error, 'SSL certificate' ) !== false ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Got an SSL Error (%s), retrying by ignoring SSL', $error ), 'debug', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::error(
+ sprintf( 'Got an SSL Error (%s), retrying by ignoring SSL', $error ),
+ array(
+ 'feed_url' => $feed_url,
+ 'cache' => $cache,
+ 'sc' => $sc,
+ )
+ );
$feed = $this->init_feed( $feed_url, $cache, $sc, false );
} elseif ( is_string( $feed_url ) || ( is_array( $feed_url ) && 1 === count( $feed_url ) ) ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, 'Trying to use raw data', 'debug', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Using raw data for feed: %s', $feed_url ),
+ array(
+ 'cache' => $cache,
+ 'sc' => $sc,
+ )
+ );
+
$data = wp_remote_retrieve_body( wp_safe_remote_get( $feed_url, array( 'user-agent' => $default_agent ) ) );
$cloned_feed->set_raw_data( $data );
$cloned_feed->init();
@@ -920,7 +950,14 @@ function ( $time ) use ( $cache_time ) {
$feed = $cloned_feed;
}
} else {
- do_action( 'themeisle_log_event', FEEDZY_NAME, 'Cannot use raw data as this is a multifeed URL', 'debug', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::debug(
+ 'Cannot use raw data as this is a multifeed URL',
+ array(
+ 'feed_url' => $feed_url,
+ 'cache' => $cache,
+ 'sc' => $sc,
+ )
+ );
}
}
return $feed;
@@ -1934,7 +1971,15 @@ public function feedzy_image_encode( $img_url ) {
}
$filtered_url = apply_filters( 'feedzy_image_encode', esc_url( $img_url ), $img_url );
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Changing image URL from %s to %s', $img_url, $filtered_url ), 'debug', __FILE__, __LINE__ );
+
+ Feedzy_Rss_Feeds_Log::debug(
+ 'Change featured image via feedzy_image_encode',
+ array(
+ 'old_url' => $img_url,
+ 'new_url' => $filtered_url,
+ )
+ );
+
return $filtered_url;
}
diff --git a/includes/admin/feedzy-rss-feeds-admin.php b/includes/admin/feedzy-rss-feeds-admin.php
index d6be8d7b..555dedf5 100644
--- a/includes/admin/feedzy-rss-feeds-admin.php
+++ b/includes/admin/feedzy-rss-feeds-admin.php
@@ -1185,6 +1185,10 @@ function ( $item ) {
$settings['general']['auto-categories'] = array_values( $auto_categories );
$settings['general']['feedzy-telemetry'] = isset( $_POST['feedzy-telemetry'] ) ? absint( wp_unslash( $_POST['feedzy-telemetry'] ) ) : '';
$settings['general']['feedzy-delete-media'] = isset( $_POST['feedzy-delete-media'] ) ? absint( wp_unslash( $_POST['feedzy-delete-media'] ) ) : '';
+
+ $settings['logs']['level'] = isset( $_POST['logs-logging-level'] ) ? sanitize_text_field( wp_unslash( $_POST['logs-logging-level'] ) ) : '';
+ $settings['logs']['email'] = isset( $_POST['feedzy-email-error-address'] ) ? sanitize_email( wp_unslash( $_POST['feedzy-email-error-address'] ) ) : '';
+ $settings['logs']['send_email_report'] = isset( $_POST['feedzy-email-error-enabled'] ) ? absint( wp_unslash( $_POST['feedzy-email-error-enabled'] ) ) : '';
break;
case 'headers':
$settings['header']['user-agent'] = isset( $_POST['user-agent'] ) ? sanitize_text_field( wp_unslash( $_POST['user-agent'] ) ) : '';
@@ -1247,7 +1251,13 @@ private function add_proxy( $url ) {
if ( $settings && isset( $settings['proxy'] ) && is_array( $settings['proxy'] ) && ! empty( $settings['proxy'] ) ) {
// if even one constant is defined, escape.
if ( defined( 'WP_PROXY_HOST' ) || defined( 'WP_PROXY_PORT' ) || defined( 'WP_PROXY_USERNAME' ) || defined( 'WP_PROXY_PASSWORD' ) ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, 'Some proxy constants already defined; ignoring proxy settings', 'info', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::info(
+ 'Some proxy constants already defined; ignoring proxy settings',
+ array(
+ 'url' => $url,
+ 'settings' => $settings['proxy'],
+ )
+ );
return;
}
@@ -1304,7 +1314,14 @@ public function http_request_args( $args ) {
public function add_user_agent( $ua ) {
$settings = apply_filters( 'feedzy_get_settings', null );
if ( $settings && isset( $settings['header']['user-agent'] ) && ! empty( $settings['header']['user-agent'] ) ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Override user-agent from %s to %s', $ua, $settings['header']['user-agent'] ), 'info', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::info(
+ 'Overriding user-agent',
+ array(
+ 'old_user_agent' => $ua,
+ 'new_user_agent' => $settings['header']['user-agent'],
+ )
+ );
+
$ua = $settings['header']['user-agent'];
}
@@ -1323,7 +1340,14 @@ public function add_user_agent( $ua ) {
public function send_through_proxy( $return_value, $uri, $check, $home ) {
$proxied = defined( 'FEEZY_URL_THRU_PROXY' ) ? FEEZY_URL_THRU_PROXY : null;
if ( $proxied && ( ( is_array( $proxied ) && in_array( $uri, $proxied, true ) ) || $uri === $proxied ) ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'sending %s through proxy', $uri ), 'info', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::info(
+ 'Sending through proxy',
+ array(
+ 'uri' => $uri,
+ 'check' => $check,
+ 'home' => $home,
+ )
+ );
return true;
}
diff --git a/includes/admin/feedzy-rss-feeds-import.php b/includes/admin/feedzy-rss-feeds-import.php
index 0584dc5c..719b217f 100644
--- a/includes/admin/feedzy-rss-feeds-import.php
+++ b/includes/admin/feedzy-rss-feeds-import.php
@@ -161,11 +161,20 @@ public function enqueue_styles() {
$this->plugin_name . '_metabox_edit_script',
'feedzy',
array(
- 'ajax' => array(
+ 'ajax' => array(
'security' => wp_create_nonce( FEEDZY_BASEFILE ),
'url' => admin_url( 'admin-ajax.php' ),
),
- 'i10n' => array(
+ 'pages' => array(
+ 'logs' => add_query_arg(
+ array(
+ 'page' => 'feedzy-settings',
+ 'tab' => 'logs',
+ ),
+ admin_url( 'admin.php' )
+ ),
+ ),
+ 'i10n' => array(
'importing' => __( 'Importing', 'feedzy-rss-feeds' ) . '...',
'run_now' => __( 'Run Now', 'feedzy-rss-feeds' ),
'dry_run_loading' => '
' . __( 'Processing the source and loading the items that will be imported when it runs', 'feedzy-rss-feeds' ) . '...
'
@@ -179,6 +188,7 @@ public function enqueue_styles() {
'action_btn_text_2' => __( 'Replace image', 'feedzy-rss-feeds' ),
'author_helper' => __( 'We display up to 100 users. If the desired username isn’t listed, type the exact existing username manually to save it.', 'feedzy-rss-feeds' ),
'clearLogButton' => __( 'Clear Log', 'feedzy-rss-feeds' ),
+ 'goToLogsTab' => __( 'See more details', 'feedzy-rss-feeds' ),
'okButton' => __( 'Ok', 'feedzy-rss-feeds' ),
'removeErrorLogsMsg' => __( 'Removed all error logs.', 'feedzy-rss-feeds' ),
// translators: %d select images count.
@@ -1532,6 +1542,7 @@ public function run_cron( $max = 100, $job_id = 0 ) {
*/
private function run_job( $job, $max ) {
Feedzy_Rss_Feeds_Usage::get_instance()->track_rss_import();
+ Feedzy_Rss_Feeds_Log::get_instance()->enable_error_messages_retention();
global $themeisle_log_event;
$source = get_post_meta( $job->ID, 'source', true );
@@ -1565,6 +1576,36 @@ private function run_job( $job, $max ) {
$import_remove_html = get_post_meta( $job->ID, 'import_remove_html', true );
$import_order = get_post_meta( $job->ID, 'import_order', true );
+ Feedzy_Rss_Feeds_Log::info(
+ 'Running import job: ' . $job->post_title . ' (ID: ' . $job->ID . ')',
+ array(
+ 'job_id' => $job->ID,
+ 'source' => $source,
+ 'max' => $max,
+ 'status' => $job->post_status,
+ 'exc_key' => $exc_key,
+ 'inc_key' => $inc_key,
+ 'inc_on' => $inc_on,
+ 'exc_on' => $exc_on,
+ 'import_title' => $import_title,
+ 'import_date' => $import_date,
+ 'post_excerpt' => $post_excerpt,
+ 'import_content' => $import_content,
+ 'import_featured_img' => $import_featured_img,
+ 'import_post_type' => $import_post_type,
+ 'import_post_term' => $import_post_term,
+ 'import_feed_limit' => $import_feed_limit,
+ 'import_item_img_url' => $import_item_img_url,
+ 'import_remove_duplicates' => $import_remove_duplicates,
+ 'import_selected_language' => $import_selected_language,
+ 'from_datetime' => $from_datetime,
+ 'mark_duplicate_tag' => $mark_duplicate_tag,
+ 'filter_conditions' => $filter_conditions,
+ 'import_auto_translation' => $import_auto_translation,
+ 'import_translation_lang' => $import_translation_lang,
+ )
+ );
+
if ( empty( $filter_conditions ) ) {
$filter_conditions = apply_filters(
'feedzy_filter_conditions_migration',
@@ -1643,6 +1684,14 @@ private function run_job( $job, $max ) {
$job
);
+ Feedzy_Rss_Feeds_Log::info(
+ 'Running job options via feedzy_shortcode_options',
+ array(
+ 'job_id' => $job->ID,
+ 'options' => $options,
+ )
+ );
+
$admin = Feedzy_Rss_Feeds::instance()->get_admin();
$options = $admin->sanitize_attr( $options, $source );
@@ -1670,18 +1719,34 @@ private function run_job( $job, $max ) {
$import_info = array();
$results = $this->get_job_feed( $options, $import_content, true );
$language_code = $results['feed']->get_language();
-
+
$xml_results = '';
if ( str_contains( $import_content, '_full_content' ) ) {
$xml_results = $this->get_job_feed( $options, '[#item_content]', true );
}
-
+
if ( is_wp_error( $results ) ) {
- $import_errors[] = $results->get_error_message();
+ // BUG: If $results is error, the import run details will not show the results even if the errors are set.
+ Feedzy_Rss_Feeds_Log::error(
+ sprintf(
+ // translators: %s is the error message.
+ __( 'Error when fetching the feed items: %s', 'feedzy-rss-feeds' ),
+ $results->get_error_message()
+ ),
+ array(
+ 'job_id' => $job->ID,
+ 'errors' => $results->get_error_messages(),
+ 'options' => $options,
+ )
+ );
+
+ $import_errors = Feedzy_Rss_Feeds_Log::get_instance()->get_error_messages_accumulator();
+ Feedzy_Rss_Feeds_Log::get_instance()->disable_error_messages_retention();
+
update_post_meta( $job->ID, 'import_errors', $import_errors );
update_post_meta( $job->ID, 'imported_items_count', 0 );
- return;
+ return 0;
}
$result = $results['items'];
@@ -1699,6 +1764,13 @@ private function run_job( $job, $max ) {
if ( empty( $import_title ) && empty( $import_content ) ) {
$import_errors[] = __( 'Title & Content are both empty.', 'feedzy-rss-feeds' );
$start_import = false;
+
+ Feedzy_Rss_Feeds_Log::error(
+ 'Import job cannot start because both title and content are empty.',
+ array(
+ 'job_id' => $job->ID,
+ )
+ );
}
if ( ! $start_import ) {
@@ -1712,7 +1784,16 @@ private function run_job( $job, $max ) {
$duplicates = array();
$items_found = array();
$found_duplicates = array();
+ $result_count = count( $result );
+
foreach ( $result as $key => $item ) {
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Processing item %1$s/%2$s', $key, $result_count ),
+ array(
+ 'item_url' => $item['item_url'],
+ )
+ );
+
$item_obj = $item;
// find item index key when import full content.
if ( ! empty( $xml_results ) ) {
@@ -1761,7 +1842,18 @@ function ( $tag ) use ( $item_obj, $item ) {
}
}
if ( $is_duplicate ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Ignoring %s as it is a duplicate (%s hash).', $item_hash, $use_new_hash ? 'new' : 'old' ), 'warn', __FILE__, __LINE__ );
+ do_action(
+ 'feedzy_log_event',
+ array(
+ 'type' => 'info',
+ 'output' => sprintf( 'Ignoring URl %1$s with hash %2$s as it is a duplicate (%3$s hash).', $item['item_url'], $item_hash, $use_new_hash ? 'new' : 'old' ),
+ 'file' => __FILE__,
+ 'line' => __LINE__,
+ )
+ );
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Ignoring item %1$s as it is a duplicate (%2$s hash).', $item['item_url'], $use_new_hash ? 'new' : 'old' )
+ );
++$index;
$duplicates[ $item['item_url'] ] = $item['item_title'];
continue;
@@ -1777,10 +1869,17 @@ function ( $tag ) use ( $item_obj, $item ) {
$author = $item['item_author']->get_email();
}
}
- } else {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Author is empty for %s.', $item['item_title'] ), 'warn', __FILE__, __LINE__ );
+
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Found author: %s', $author ),
+ array(
+ 'item_author' => $item['item_author'],
+ 'item_url' => $item['item_url'],
+ )
+ );
}
+
// phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
$item_date = wp_date( get_option( 'date_format' ) . ' at ' . get_option( 'time_format' ), $item['item_date'] );
$item_date = $item['item_date_formatted'];
@@ -1877,6 +1976,14 @@ function ( $attr, $key ) {
if ( $rewrite_service_endabled && false !== strpos( $post_title, '[#title_feedzy_rewrite]' ) ) {
$title_feedzy_rewrite = apply_filters( 'feedzy_invoke_content_rewrite_services', $item['item_title'], '[#title_feedzy_rewrite]', $job, $item );
$post_title = str_replace( '[#title_feedzy_rewrite]', $title_feedzy_rewrite, $post_title );
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Using Feedzy rewrite service for item title: %1$s', $item['item_title'] ),
+ array(
+ 'job_id' => $job->ID,
+ 'service' => 'feedzy_invoke_content_rewrite_services',
+ 'service_output' => $title_feedzy_rewrite,
+ )
+ );
}
if ( is_string( $post_title ) ) {
@@ -1892,6 +1999,16 @@ function ( $attr, $key ) {
$translated_description = '';
if ( $import_auto_translation && ( false !== strpos( $import_content, '[#translated_description]' ) || false !== strpos( $post_excerpt, '[#translated_description]' ) ) ) {
$translated_description = apply_filters( 'feedzy_invoke_auto_translate_services', $item['item_full_description'], '[#translated_description]', $import_translation_lang, $job, $language_code, $item );
+
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Using auto-translation service for item description: %1$s', $item['item_full_description'] ),
+ array(
+ 'job_id' => $job->ID,
+ 'service' => 'feedzy_invoke_auto_translate_services',
+ 'service_output' => $translated_description,
+ 'language_code' => $language_code,
+ )
+ );
}
// Get translated item content.
@@ -1899,6 +2016,15 @@ function ( $attr, $key ) {
if ( $import_auto_translation && ( false !== strpos( $import_content, '[#translated_content]' ) || false !== strpos( $post_excerpt, '[#translated_content]' ) ) ) {
$translated_content = ! empty( $item['item_content'] ) ? $item['item_content'] : $item['item_description'];
$translated_content = apply_filters( 'feedzy_invoke_auto_translate_services', $translated_content, '[#translated_content]', $import_translation_lang, $job, $language_code, $item );
+
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Using auto-translation service for item content: %1$s', $item['item_content'] ),
+ array(
+ 'job_id' => $job->ID,
+ 'service' => 'feedzy_invoke_auto_translate_services',
+ 'service_output' => $translated_description,
+ )
+ );
}
// Used as a new line character in import content.
@@ -1949,6 +2075,14 @@ function ( $attr, $key ) {
__( 'Full content is empty. Error: %s', 'feedzy-rss-feeds' ),
$full_content_error
);
+
+ Feedzy_Rss_Feeds_Log::error(
+ sprintf( 'Full content is empty for item %1$s. Error: %2$s', $item['item_url'], $full_content_error ),
+ array(
+ 'job_id' => $job->ID,
+ 'import_errors' => $import_errors,
+ )
+ );
}
$post_content = str_replace(
@@ -2068,9 +2202,15 @@ function ( $attr, $key ) {
// no point creating a post if either the title or the content is null.
if ( is_null( $post_title ) || is_null( $post_content ) ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'NOT creating a new post as title (%s) or content (%s) is null.', $post_title, $post_content ), 'info', __FILE__, __LINE__ );
++$index;
- $import_errors[] = __( 'Title or Content is empty.', 'feedzy-rss-feeds' );
+
+ Feedzy_Rss_Feeds_Log::error(
+ __( 'Title or Content is empty.', 'feedzy-rss-feeds' ),
+ array(
+ 'job_id' => $job->ID,
+ 'new_post' => $new_post,
+ )
+ );
continue;
}
@@ -2088,7 +2228,18 @@ function ( $attr, $key ) {
} else {
$img_success = false;
}
+
+ Feedzy_Rss_Feeds_Log::debug(
+ 'Set the image source URL from item image tag for attachment post type.',
+ array(
+ 'job_id' => $job->ID,
+ 'feed_img_tag' => $feed_img_tag,
+ 'image_source_url' => $image_source_url,
+ 'item_img_path' => $item['item_img_path'],
+ )
+ );
} elseif ( strpos( $feed_img_tag, '[#item_custom' ) !== false ) {
+ $value = '';
if ( $this->feedzy_is_business() || $this->feedzy_is_personal() ) {
$value = apply_filters( 'feedzy_parse_custom_tags', $feed_img_tag, $item_obj );
}
@@ -2098,14 +2249,39 @@ function ( $attr, $key ) {
} else {
$img_success = false;
}
+
+ Feedzy_Rss_Feeds_Log::debug(
+ 'Set the image source URL from custom tag for attachment post type.',
+ array(
+ 'job_id' => $job->ID,
+ 'feed_img_tag' => $feed_img_tag,
+ 'image_source_url' => $image_source_url,
+ 'item_custom' => $value,
+ 'is_business' => $this->feedzy_is_business(),
+ 'is_personal' => $this->feedzy_is_personal(),
+ )
+ );
} else {
$image_source_url = $feed_img_tag;
$img_title = pathinfo( basename( $image_source_url ), PATHINFO_FILENAME );
}
if ( ! empty( $image_source_url ) ) {
- $img_success = $this->try_save_featured_image( $image_source_url, 0, $img_title, $import_errors, $import_info, $new_post );
+ $img_success = $this->try_save_featured_image( $image_source_url, 0, $img_title, $import_info, $new_post );
$new_post_id = $img_success;
+
+ Feedzy_Rss_Feeds_Log::debug(
+ 'Try to save featured image for attachment post type.',
+ array(
+ 'job_id' => $job->ID,
+ 'feed_img_tag' => $feed_img_tag,
+ 'img_title' => $img_title,
+ 'post_id' => $new_post_id,
+ 'image_source_url' => $image_source_url,
+ 'is_business' => $this->feedzy_is_business(),
+ 'is_personal' => $this->feedzy_is_personal(),
+ )
+ );
}
if ( ! $img_success ) {
@@ -2123,18 +2299,33 @@ function ( $attr, $key ) {
}
if ( 0 === $new_post_id || is_wp_error( $new_post_id ) ) {
- $error_reason = 'N/A';
if ( is_wp_error( $new_post_id ) ) {
- $error_reason = $new_post_id->get_error_message();
- if ( ! empty( $error_reason ) ) {
- $import_errors[] = $error_reason;
- }
+ Feedzy_Rss_Feeds_Log::error(
+ sprintf(
+ // translators: %1$s is the item URL, %2$s is the error message.
+ 'Error while inserting post for %1$s: %2$s',
+ esc_url( $item['item_url'] ),
+ $new_post_id->get_error_message()
+ ),
+ array(
+ 'job_id' => $job->ID,
+ 'new_post' => $new_post,
+ )
+ );
}
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Unable to create a new post with params %s. Error: %s', print_r( $new_post, true ), $error_reason ), 'error', __FILE__, __LINE__ );
+
++$index;
continue;
}
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'created new post with ID %d with post_content %s', $new_post_id, $post_content ), 'debug', __FILE__, __LINE__ );
+
+ Feedzy_Rss_Feeds_Log::info(
+ 'Created a new post: ' . $new_post['post_title'],
+ array(
+ 'job_id' => $job->ID,
+ 'post_id' => $new_post_id,
+ )
+ );
+
if ( ! in_array( $item_hash, $found_duplicates, true ) ) {
$imported_items[] = $item_hash;
++$count;
@@ -2177,7 +2368,13 @@ function ( $term ) {
}
$result = wp_set_object_terms( $new_post_id, intval( $term_id ), $taxonomy, true );
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'After creating post in %s/%d, result = %s', $taxonomy, $term_id, print_r( $result, true ) ), 'debug', __FILE__, __LINE__ );
+
+ Feedzy_Rss_Feeds_Log::info(
+ sprintf( 'Set term "%1$s" with ID %2$s for feedzy import ID %3$s', $taxonomy, $term_id, $new_post_id ),
+ array(
+ 'job_id' => $job->ID,
+ )
+ );
}
// If the default category is not used, remove it.
@@ -2201,7 +2398,7 @@ function ( $term ) {
$job,
$item_obj,
$new_post_id,
- $import_errors,
+ Feedzy_Rss_Feeds_Log::get_instance()->get_error_messages_accumulator(),
$import_info,
array(
'translation_lang' => $import_translation_lang,
@@ -2224,6 +2421,16 @@ function ( $term ) {
} else {
$img_success = false;
}
+
+ Feedzy_Rss_Feeds_Log::debug(
+ 'Found an image for [#item_image]',
+ array(
+ 'job_id' => $job->ID,
+ 'feed_img_tag' => $feed_img_tag,
+ 'image_source_url' => $image_source_url,
+ 'item_img_path' => $item['item_img_path'],
+ )
+ );
} elseif (
( $this->feedzy_is_business() || $this->feedzy_is_personal() ) && // PRO feature.
false !== strpos( $feed_img_tag, '[#item_custom' )
@@ -2234,6 +2441,18 @@ function ( $term ) {
} else {
$img_success = false;
}
+
+ Feedzy_Rss_Feeds_Log::debug(
+ 'Set the image source URL from custom tag for created post.',
+ array(
+ 'job_id' => $job->ID,
+ 'feed_img_tag' => $feed_img_tag,
+ 'image_source_url' => $image_source_url,
+ 'item_custom' => $value,
+ 'is_business' => $this->feedzy_is_business(),
+ 'is_personal' => $this->feedzy_is_personal(),
+ )
+ );
} elseif ( wp_http_validate_url( $import_featured_img ) ) {
$image_source_url = $import_featured_img;
$img_title = pathinfo( basename( $image_source_url ), PATHINFO_FILENAME );
@@ -2263,24 +2482,38 @@ function ( $term ) {
)
);
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Fetching image from Graby for item %1$s with URL %2$s', $item['item_url'], FEEDZY_PRO_FETCH_ITEM_IMG_URL ),
+ array(
+ 'job_id' => $job->ID,
+ 'response' => $response,
+ )
+ );
+
if ( ! is_wp_error( $response ) ) {
- if ( array_key_exists( 'response', $response ) && array_key_exists( 'code', $response['response'] ) && intval( $response['response']['code'] ) !== 200 ) {
- // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'error in response = %s', print_r( $response, true ) ), 'error', __FILE__, __LINE__ );
- }
$body = wp_remote_retrieve_body( $response );
if ( ! is_wp_error( $body ) ) {
$response_data = json_decode( $body, true );
if ( isset( $response_data['url'] ) ) {
$image_source_url = $response_data['url'];
+
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Fetched image from Graby for item %1$s with URL %2$s', $item['item_url'], $image_source_url ),
+ array(
+ 'job_id' => $job->ID,
+ 'image_source_url' => $image_source_url,
+ )
+ );
}
- } else {
- // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'error in body = %s', print_r( $body, true ) ), 'error', __FILE__, __LINE__ );
}
} else {
- // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'error in request = %s', print_r( $response, true ) ), 'error', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::error(
+ sprintf( 'Error fetching image from Graby for item %1$s: %2$s', $item['item_url'], $response->get_error_message() ),
+ array(
+ 'job_id' => $job->ID,
+ 'response' => $response,
+ )
+ );
}
}
@@ -2296,9 +2529,24 @@ function ( $term ) {
if ( 'yes' === $import_item_img_url ) {
// Set external image URL.
update_post_meta( $new_post_id, 'feedzy_item_external_url', $image_source_url );
+
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Replaced image URL with external URL for post ID %1$s: %2$s', $new_post_id, $image_source_url ),
+ array(
+ 'job_id' => $job->ID,
+ )
+ );
} else {
// if import_featured_img is a tag.
- $img_success = $this->try_save_featured_image( $image_source_url, $new_post_id, $img_title, $import_errors, $import_info );
+ $img_success = $this->try_save_featured_image( $image_source_url, $new_post_id, $img_title, $import_info );
+
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Saved featured image for post ID %1$s: %2$s', $new_post_id, $image_source_url ),
+ array(
+ 'job_id' => $job->ID,
+ 'image_source_url' => $image_source_url,
+ )
+ );
}
}
}
@@ -2312,6 +2560,13 @@ function ( $term ) {
$default_thumbnail_id = $default_thumbnail;
}
$img_success = set_post_thumbnail( $new_post_id, $default_thumbnail_id );
+
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Try to set default thumbnail for post ID %1$s with ID %2$s. Success: %3$s', $new_post_id, $default_thumbnail_id, $img_success ? 'yes' : 'no' ),
+ array(
+ 'job_id' => $job->ID,
+ )
+ );
}
if ( ! $img_success ) {
@@ -2327,9 +2582,26 @@ function ( $term ) {
update_post_meta( $new_post_id, 'feedzy_job', $job->ID );
update_post_meta( $new_post_id, 'feedzy_item_author', sanitize_text_field( $author ) );
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Update post meta for "%s"', $new_post['post_title'] ),
+ array(
+ 'feedzy_item_url' => esc_url_raw( $item['item_url'] ),
+ 'feedzy_item_author' => sanitize_text_field( $author ),
+ 'feedzy_job' => $job->ID,
+ 'post_id' => $new_post_id,
+ )
+ );
+
// Verify that the `$mark_duplicate_key` does not match `'item_url'` to ensure the condition applies only when a different tag is specified.
if ( $mark_duplicate_key && 'item_url' !== $mark_duplicate_key ) {
update_post_meta( $new_post_id, 'feedzy_' . $mark_duplicate_key, $duplicate_tag_value );
+
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Mark post (%s) as duplicated', $new_post_id ),
+ array(
+ 'feedzy_' . $mark_duplicate_key => $duplicate_tag_value,
+ )
+ );
}
// we can use this to associate the items that were imported in a particular run.
@@ -2346,25 +2618,41 @@ function ( $term ) {
update_post_meta( $job->ID, 'imported_items_count', $count );
if ( $import_image_errors > 0 ) {
- $import_errors[] = sprintf(
- // translators: %1$d is the number of items without images, %2$d is the total number of items imported.
- __( 'Unable to find an image for %1$d out of %2$d items imported', 'feedzy-rss-feeds' ),
- $import_image_errors,
- $count
+ Feedzy_Rss_Feeds_Log::error(
+ sprintf(
+ // translators: %1$d is the number of items without images, %2$d is the total number of items imported.
+ __( 'Unable to find an image for %1$d out of %2$d items imported', 'feedzy-rss-feeds' ),
+ $import_image_errors,
+ $count
+ ),
+ array(
+ 'job_id' => $job->ID,
+ )
);
}
-
- if ( ! empty( $themeisle_log_event ) ) {
- $import_errors = array_merge( $themeisle_log_event, $import_errors );
- }
- update_post_meta( $job->ID, 'import_errors', $import_errors );
-
+
// the order of these matters in how they are finally shown in the summary.
$import_info['total'] = $items_found;
$import_info['duplicates'] = $duplicates;
+ $import_errors = Feedzy_Rss_Feeds_Log::get_instance()->get_error_messages_accumulator();
+ Feedzy_Rss_Feeds_Log::get_instance()->disable_error_messages_retention();
+
update_post_meta( $job->ID, 'import_info', $import_info );
+ update_post_meta( $job->ID, 'import_errors', $import_errors );
+ Feedzy_Rss_Feeds_Log::info(
+ sprintf(
+ 'Import completed for job ID (%1$s).',
+ $job->ID
+ ),
+ array(
+ 'job_id' => $job->ID,
+ 'import_info' => $import_info,
+ 'import_errors' => $import_errors,
+ )
+ );
+
return $count;
}
@@ -2533,7 +2821,6 @@ private function convert_url_to_ascii( $url ) {
* @param string $img_source_url The download source URL for the image.
* @param integer $post_id The post ID.
* @param string $post_title Post title.
- * @param array $import_errors Array of import error messages.
* @param array $import_info Array of import information messages.
* @param array $post_data Additional post data.
*
@@ -2542,7 +2829,7 @@ private function convert_url_to_ascii( $url ) {
* @since 1.2.0
* @access private
*/
- private function try_save_featured_image( $img_source_url, $post_id, $post_title, &$import_errors, &$import_info, $post_data = array() ) {
+ private function try_save_featured_image( $img_source_url, $post_id, $post_title, &$import_info, $post_data = array() ) {
if ( ! function_exists( 'post_exists' ) ) {
require_once ABSPATH . 'wp-admin/includes/post.php';
}
@@ -2555,11 +2842,28 @@ private function try_save_featured_image( $img_source_url, $post_id, $post_title
// This is necessary because FILTER_VALIDATE_URL only validates against ASCII URLs.
$escaped_url = $this->convert_url_to_ascii( $img_source_url );
if ( filter_var( $escaped_url, FILTER_VALIDATE_URL ) === false ) {
- $import_errors[] = 'Invalid Featured Image URL: ' . $img_source_url;
+ Feedzy_Rss_Feeds_Log::error(
+ // translators: %s is the invalid image URL.
+ sprintf( __( 'Invalid image URL: %s', 'feedzy-rss-feeds' ), $img_source_url ),
+ array(
+ 'post_id' => $post_id,
+ 'post_title' => $post_title,
+ )
+ );
+
return false;
}
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Trying to save the featured image for %s and postID %d', $img_source_url, $post_id ), 'debug', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Save the image as featured image with upload in Media Library for post ID' ),
+ array(
+ 'post_id' => $post_id,
+ 'post_title' => $post_title,
+ 'img_source_url' => $img_source_url,
+ 'escaped_url' => $escaped_url,
+ 'post_data' => $post_data,
+ )
+ );
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
@@ -2569,7 +2873,14 @@ private function try_save_featured_image( $img_source_url, $post_id, $post_title
$img_source_url = trim( $img_source_url, chr( 0xC2 ) . chr( 0xA0 ) );
$local_file = download_url( $img_source_url );
if ( is_wp_error( $local_file ) ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Unable to download file = %s and postID %d', print_r( $local_file, true ), $post_id ), 'error', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::error(
+ // translators: %s is the image source URL.
+ sprintf( __( 'Unable to download image: %s', 'feedzy-rss-feeds' ), $img_source_url ),
+ array(
+ 'post_id' => $post_id,
+ 'errors' => $local_file->get_error_messages(),
+ )
+ );
return false;
}
@@ -2597,7 +2908,16 @@ private function try_save_featured_image( $img_source_url, $post_id, $post_title
if ( $renamed ) {
$local_file = $new_local_file;
} else {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Unable to rename file for postID %d', $post_id ), 'error', __FILE__, __LINE__ );
+
+ Feedzy_Rss_Feeds_Log::error(
+ // translators: %s the name of the post.
+ sprintf( __( 'Could not rename temporary file for %s', 'feedzy-rss-feeds' ), get_the_title( $post_id ) ),
+ array(
+ 'post_id' => $post_id,
+ 'local_file' => $local_file,
+ 'new_local_file' => $new_local_file,
+ )
+ );
return false;
}
@@ -2608,7 +2928,14 @@ private function try_save_featured_image( $img_source_url, $post_id, $post_title
$id = media_handle_sideload( $file_array, $post_id, $post_title, $post_data );
if ( is_wp_error( $id ) ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Unable to attach file for postID %d = %s', $post_id, print_r( $id, true ) ), 'error', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::error(
+ // translators: %s is the image source URL.
+ sprintf( __( 'Cannot upload the image to Media Library: %s', 'feedzy-rss-feeds' ), $img_source_url ),
+ array(
+ 'post_id' => $post_id,
+ 'errors' => $id->get_error_messages(),
+ )
+ );
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_unlink
unlink( $file_array['tmp_name'] );
@@ -2616,7 +2943,14 @@ private function try_save_featured_image( $img_source_url, $post_id, $post_title
return false;
}
} else {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Found an existing attachment(ID: %d) image for %s and postID %d', $id, $img_source_url, $post_id ), 'debug', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Reusing existing attachment image for post ID %d', $post_id ),
+ array(
+ 'post_id' => $post_id,
+ 'img_source_url' => $img_source_url,
+ 'attachment_id' => $id,
+ )
+ );
}
if ( ! empty( $post_data ) ) {
@@ -2625,9 +2959,24 @@ private function try_save_featured_image( $img_source_url, $post_id, $post_title
$success = set_post_thumbnail( $post_id, $id );
if ( false === $success ) {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Unable to attach file for postID %d for no apparent reason', $post_id ), 'error', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::error(
+ // translators: %s is the post title.
+ sprintf( __( 'Could not set the thumbnail for: %s', 'feedzy-rss-feeds' ), get_the_title( $post_id ) ),
+ array(
+ 'post_id' => $post_id,
+ 'attachment_id' => $id,
+ 'image_source_url' => $img_source_url,
+ )
+ );
} else {
- do_action( 'themeisle_log_event', FEEDZY_NAME, sprintf( 'Attached file as featured image for postID %d', $post_id ), 'info', __FILE__, __LINE__ );
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Attached image with ID (%d) to post ID (%d)', $id, $post_id ),
+ array(
+ 'post_id' => $post_id,
+ 'attachment_id' => $id,
+ 'image_source_url' => $img_source_url,
+ )
+ );
}
return $success;
@@ -2676,7 +3025,17 @@ public function add_cron() {
)
);
+
if ( ! empty( $import_job_crons ) ) {
+ Feedzy_Rss_Feeds_Log::debug(
+ sprintf( 'Registering cron job with schedule: %s', $schedule ),
+ array(
+ 'job_id' => 0,
+ 'schedule' => $schedule,
+ 'import_job_crons' => $import_job_crons,
+ )
+ );
+
foreach ( $import_job_crons as $job_id ) {
$fz_cron_schedule = get_post_meta( $job_id, 'fz_cron_schedule', true );
if ( false === Feedzy_Rss_Feeds_Util_Scheduler::is_scheduled( 'feedzy_cron', array( 100, $job_id ) ) ) {
diff --git a/includes/admin/feedzy-rss-feeds-log.php b/includes/admin/feedzy-rss-feeds-log.php
new file mode 100644
index 00000000..1d499028
--- /dev/null
+++ b/includes/admin/feedzy-rss-feeds-log.php
@@ -0,0 +1,938 @@
+ Log levels.
+ */
+ private static $levels = array(
+ self::DEBUG => 'debug',
+ self::INFO => 'info',
+ self::WARNING => 'warning',
+ self::ERROR => 'error',
+ );
+
+ const PRIORITIES_MAPPING = array(
+ 'debug' => self::DEBUG,
+ 'info' => self::INFO,
+ 'warning' => self::WARNING,
+ 'error' => self::ERROR,
+ 'none' => self::NONE,
+ );
+
+ /**
+ * The single instance of the class.
+ *
+ * @var ?self The single instance of the class.
+ */
+ private static $instance = null;
+
+ /**
+ * The path to the log file.
+ *
+ * @var string The path to the log file.
+ */
+ private $filepath;
+
+ /**
+ * The WordPress filesystem instance.
+ *
+ * @var \WP_Filesystem_Base|null The WordPress filesystem instance.
+ */
+ private $filesystem;
+
+ /**
+ * The context for the logger.
+ *
+ * @var array The context for the logger.
+ */
+ private $context = array();
+
+ /**
+ * The minimum log level threshold for logging messages.
+ *
+ * @var int The minimum log level threshold.
+ */
+ public $level_threshold = self::ERROR;
+
+ /**
+ * Whether to retain error messages for import run errors meta.
+ *
+ * @var string[]
+ */
+ private $error_messages_accumulator = array();
+
+ /**
+ * Whether to retain error messages for import run errors meta.
+ *
+ * @var bool Whether to retain error messages.
+ */
+ private $retain_error_messages = false;
+
+ /**
+ * Whether email reports can be sent.
+ *
+ * @var bool Whether email reports can be sent.
+ */
+ public $can_send_email = false;
+
+ /**
+ * The email address to send reports to.
+ *
+ * @var string The email address to send reports to.
+ */
+ public $to_email = '';
+
+ /**
+ * Feedzy_Rss_Feeds_Logger constructor.
+ *
+ * @since 5.1.0
+ */
+ public function __construct() {
+ $this->init_filesystem();
+ $this->setup_log_directory();
+ $this->init_saved_settings();
+ }
+
+ /**
+ * Get the single instance of the class.
+ *
+ * @since 5.1.0
+ * @return self
+ */
+ public static function get_instance() {
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Initialize the WordPress filesystem.
+ *
+ * @since 5.1.0
+ * @return void
+ */
+ private function init_filesystem() {
+ global $wp_filesystem;
+
+ if ( ! function_exists( 'WP_Filesystem' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+
+ WP_Filesystem();
+ $this->filesystem = $wp_filesystem;
+ }
+
+ /**
+ * Setup the log directory.
+ *
+ * @since 5.1.0
+ * @return void
+ */
+ private function setup_log_directory() {
+ $upload_dir = wp_upload_dir();
+ $log_dir = $upload_dir['basedir'] . '/feedzy-logs';
+
+ if ( ! $this->filesystem->exists( $log_dir ) ) {
+ $this->filesystem->mkdir( $log_dir, FS_CHMOD_DIR );
+ $this->filesystem->put_contents( $log_dir . '/.htaccess', "Deny from all\n", FS_CHMOD_FILE );
+ $this->filesystem->put_contents( $log_dir . '/index.php', "filepath = $this->get_log_file_path();
+ }
+
+ /**
+ * Initialize saved settings for logger.
+ *
+ * @since 5.1.0
+ * @return void
+ */
+ private function init_saved_settings() {
+ $feedzy_settings = get_option( 'feedzy-settings', array() );
+ if ( ! isset( $feedzy_settings['logs'] ) ) {
+ return;
+ }
+
+ if ( isset( $feedzy_settings['logs']['level'] ) && isset( self::PRIORITIES_MAPPING[ $feedzy_settings['logs']['level'] ] ) ) {
+ $this->level_threshold = self::PRIORITIES_MAPPING[ $feedzy_settings['logs']['level'] ];
+ }
+
+ $this->can_send_email = isset( $feedzy_settings['logs']['send_email_report'] ) && $feedzy_settings['logs']['send_email_report'];
+ $this->to_email = isset( $feedzy_settings['logs']['email'] ) ? sanitize_email( $feedzy_settings['logs']['email'] ) : '';
+ }
+
+ /**
+ * Set the context for the logger.
+ *
+ * @since 5.1.0
+ * @param array $context The context to set.
+ * @return self
+ */
+ public function set_context( array $context ) {
+ $this->context = $context;
+ return $this;
+ }
+
+ /**
+ * Log a message.
+ *
+ * @since 5.1.0
+ * @param int $level The log level.
+ * @param string $message The log message.
+ * @param array $context The log context.
+ * @return void
+ */
+ public static function log( $level, $message, array $context = array() ) {
+ try {
+ $instance = self::get_instance();
+ $instance->add_log_record( $level, $message, $context );
+ } catch ( Throwable $e ) {
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ error_log( sprintf( 'Feedzy_Rss_Feeds_Log error: %s', $e->getMessage() ) );
+ }
+ }
+ }
+
+ /**
+ * Write log to file.
+ *
+ * @since 5.1.0
+ * @param int $level The log level.
+ * @param string $message The log message.
+ * @param array $context The log context.
+ * @return void
+ */
+ private function add_log_record( $level, $message, array $context = array() ) {
+ if ( self::ERROR === $level ) {
+ $this->increment_log_stat( 'error_count' );
+ $this->try_accumulate_error_message( $message );
+ }
+
+ if ( $this->level_threshold > $level ) {
+ return;
+ }
+
+ // Ensure the origin is present in the context so we know which function/method produced the log.
+ $merged_context = array_merge( $this->context, $context );
+ if ( ! isset( $merged_context['function'] ) || ! isset( $merged_context['line'] ) ) {
+ $origin = $this->get_calling_origin();
+ if ( ! isset( $merged_context['function'] ) ) {
+ $merged_context['function'] = $origin['function'];
+ }
+ if ( ! isset( $merged_context['line'] ) ) {
+ $merged_context['line'] = $origin['line'];
+ }
+ }
+
+ $log_entry = array(
+ 'timestamp' => gmdate( 'c' ),
+ 'level' => isset( self::$levels[ $level ] ) ? self::$levels[ $level ] : 'unknown',
+ 'message' => $message,
+ 'context' => $merged_context,
+ );
+
+ if ( wp_doing_ajax() ) {
+ $log_entry['doing_ajax'] = true;
+ }
+
+ if ( wp_doing_cron() ) {
+ $log_entry['doing_cron'] = true;
+ }
+
+ $this->append_log_to_file( $log_entry );
+ }
+
+ /**
+ * Append the log to the log file.
+ *
+ * @param array $log_entry The log entry to append.
+ * @return void
+ */
+ public function append_log_to_file( $log_entry ) {
+ if ( ! $this->filesystem ) {
+ return;
+ }
+
+ // Ensure the directory exists before writing.
+ $log_dir = dirname( $this->filepath );
+ if ( ! $this->filesystem->is_dir( $log_dir ) ) {
+ $this->setup_log_directory();
+ }
+
+ $formatted = wp_json_encode( $log_entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . PHP_EOL;
+
+ error_log( $formatted, 3, $this->filepath );
+ }
+
+ /**
+ * Fallback method to append log to ThemeIsle's logging system.
+ *
+ * @param array $log_entry Log entry to append.
+ * @return void
+ */
+ public function append_to_themeisle_log( $log_entry ) {
+
+ $formatted_message = $log_entry['message'];
+ if ( ! empty( $log_entry['context'] ) ) {
+ $formatted_message .= ' ' . wp_json_encode( $log_entry['context'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+ }
+
+ $context = isset( $log_entry['context'] ) && is_array( $log_entry['context'] ) ? $log_entry['context'] : array();
+ $function_signature = (string) ( $context['function'] ?? 'unknown' );
+ $line_number = $context['line'] ?? null;
+
+ do_action( 'themeisle_log_event', FEEDZY_NAME, $formatted_message, $log_entry['level'], $function_signature, $line_number );
+ }
+
+ /**
+ * Determine the caller origin (Class::method() or function()) for the current log entry,
+ * returning both the function signature and source line number.
+ *
+ * @since 5.1.0
+ * @return array{function:string,line:int|null}
+ */
+ private function get_calling_origin() {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
+ $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 10 );
+ foreach ( $trace as $frame ) {
+ // Skip frames from this logger class.
+ if ( isset( $frame['class'] ) && __CLASS__ === $frame['class'] ) {
+ continue;
+ }
+
+ $function_name = $frame['function'];
+ $func = isset( $frame['class'] )
+ ? ( $frame['class'] . ( isset( $frame['type'] ) ? $frame['type'] : '::' ) . $function_name . '()' )
+ : ( $function_name . '()' );
+ $line = isset( $frame['line'] ) ? (int) $frame['line'] : null;
+
+ return array(
+ 'function' => $func,
+ 'line' => $line,
+ );
+ }
+ return array(
+ 'function' => 'unknown',
+ 'line' => null,
+ );
+ }
+
+ /**
+ * Log a debug message.
+ *
+ * @since 5.1.0
+ * @param string $message The log message.
+ * @param array $context The log context.
+ * @return void
+ */
+ public static function debug( $message, array $context = array() ) {
+ self::log( self::DEBUG, $message, $context );
+ }
+
+ /**
+ * Log an info message.
+ *
+ * @since 5.1.0
+ * @param string $message The log message.
+ * @param array $context The log context.
+ * @return void
+ */
+ public static function info( $message, array $context = array() ) {
+ self::log( self::INFO, $message, $context );
+ }
+
+ /**
+ * Log a warning message.
+ *
+ * @since 5.1.0
+ * @param string $message The log message.
+ * @param array $context The log context.
+ * @return void
+ */
+ public static function warning( $message, array $context = array() ) {
+ self::log( self::WARNING, $message, $context );
+ }
+
+ /**
+ * Log an error message.
+ *
+ * @since 5.1.0
+ * @param string $message The log message.
+ * @param array $context The log context.
+ * @return void
+ */
+ public static function error( $message, array $context = array() ) {
+ self::log( self::ERROR, $message, $context );
+ }
+
+ /**
+ * Get all logs as raw entries.
+ *
+ * @since 5.1.0
+ * @return string|false The log entries.
+ */
+ public function get_all_logs_raw() {
+ if ( ! file_exists( $this->filepath ) ) {
+ return false;
+ }
+
+ return $this->filesystem->get_contents( $this->filepath );
+ }
+
+ /**
+ * Get recent log entries.
+ *
+ * @since 5.1.0
+ * @param int $limit The number of entries to return.
+ * @param string|null $level The log level to filter by.
+ * @return array>|WP_Error
+ */
+ public function get_recent_logs( $limit = 100, $level = null ) {
+ if ( ! file_exists( $this->filepath ) ) {
+ return array();
+ }
+
+ $lines = $this->get_last_lines( $limit * 2 ); // Read more lines to account for filtering.
+ if ( is_wp_error( $lines ) ) {
+ return array();
+ }
+
+ $logs = array();
+ foreach ( $lines as $line ) {
+ if ( empty( $line ) ) {
+ continue;
+ }
+
+ $log = json_decode( $line, true );
+ if ( $log && ( null === $level || ( isset( $log['level'] ) && $log['level'] === $level ) ) ) {
+ $logs[] = $log;
+ if ( count( $logs ) >= $limit ) {
+ break;
+ }
+ }
+ }
+
+ return $logs;
+ }
+
+ /**
+ * Get the last N lines from the log file.
+ *
+ * @since 5.1.0
+ * @param int $num_lines The number of lines to return.
+ * @return array|WP_Error The last N lines or error messages or WP_Error.
+ */
+ public function get_last_lines( $num_lines = 50 ) {
+ if ( $this->can_use_direct_file_access() ) {
+ return $this->tail_file( $num_lines );
+ }
+
+ // Fallback to WP_Filesystem method (slower but works with all filesystem types).
+ return $this->read_last_lines_wp_filesystem( $num_lines );
+ }
+
+ /**
+ * Check if direct file access is available.
+ *
+ * @since 5.1.0
+ * @return bool Whether direct file access is available.
+ */
+ private function can_use_direct_file_access() {
+ return 'direct' === get_filesystem_method();
+ }
+
+ /**
+ * Efficiently read the last N lines using direct file access.
+ *
+ * @since 5.1.0
+ * @param int $lines The number of lines to read.
+ * @return array|WP_Error The last N lines.
+ */
+ private function tail_file( $lines = 50 ) {
+ if ( ! $this->log_file_exists() || ! $this->is_log_readable() ) {
+ return new WP_Error( 'log_file_not_found' );
+ }
+
+ try {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
+ $handle = fopen( $this->filepath, 'r' );
+ if ( ! $handle ) {
+ return new WP_Error( 'unable_to_open_log_file' );
+ }
+
+ $line_counter = $lines;
+ $pos = -2;
+ $beginning = false;
+ $text = array();
+
+ while ( $line_counter > 0 ) {
+ $t = ' ';
+ while ( "\n" !== $t ) {
+ if ( -1 === fseek( $handle, $pos, SEEK_END ) ) {
+ $beginning = true;
+ break;
+ }
+ $t = fgetc( $handle );
+ if ( false === $t ) {
+ $beginning = true;
+ break;
+ }
+ --$pos;
+ }
+ --$line_counter;
+ if ( $beginning ) {
+ rewind( $handle );
+ }
+ $line = fgets( $handle );
+ if ( false !== $line ) {
+ $text[] = $line;
+ }
+ if ( $beginning ) {
+ break;
+ }
+ }
+ fclose( $handle );
+
+ return $text;
+ } catch ( Throwable $e ) {
+ return new WP_Error( 'error_reading_log_file', $e->getMessage() );
+ }
+ }
+
+ /**
+ * Read the last N lines using WP_Filesystem (compatible with all filesystem types).
+ *
+ * @since 5.1.0
+ * @param int $lines_count The number of lines to read.
+ * @return array|WP_Error The last N lines.
+ */
+ private function read_last_lines_wp_filesystem( $lines_count ) {
+ if ( ! $this->log_file_exists() || ! $this->is_log_readable() ) {
+ return new WP_Error( 'log_file_not_found' );
+ }
+
+ $content = $this->filesystem->get_contents( $this->filepath );
+ if ( false === $content ) {
+ return new WP_Error( 'unable_to_read_log_file' );
+ }
+
+ $lines = explode( "\n", $content );
+ $lines = array_filter(
+ $lines,
+ function ( $line ) {
+ return '' !== trim( $line );
+ }
+ );
+
+ return array_reverse( array_slice( $lines, -$lines_count ) );
+ }
+
+ /**
+ * Delete the log file if it is older than 14 days or if the size exceeds the maximum allowed size.
+ *
+ * @since 5.1.0
+ * @return bool
+ */
+ public function should_clean_logs() {
+ if ( ! file_exists( $this->filepath ) ) {
+ return false;
+ }
+
+ $is_too_big = $this->get_log_file_size() > self::DEFAULT_MAX_FILE_SIZE;
+ $is_too_old = filemtime( $this->filepath ) < strtotime( '-14 days' );
+
+ if ( $is_too_big || $is_too_old ) {
+ return $this->delete_log_file();
+ }
+
+ return false;
+ }
+
+ /**
+ * Static convenience methods.
+ *
+ * @since 5.1.0
+ * @param string $name The method name.
+ * @param array $arguments The method arguments.
+ * @return mixed
+ */
+ public static function __callStatic( $name, $arguments ) {
+ $instance = self::get_instance();
+ if ( method_exists( $instance, $name ) ) {
+ return call_user_func_array( array( $instance, $name ), $arguments );
+ }
+ }
+
+ /**
+ * Register REST API endpoints for logs.
+ *
+ * @since 5.1.0
+ * @return void
+ */
+ public function register_endpoints() {
+ register_rest_route(
+ 'feedzy/v1',
+ '/logs/download',
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'export_logs_endpoint' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_options' );
+ },
+ )
+ );
+ register_rest_route(
+ 'feedzy/v1',
+ '/logs',
+ array(
+ 'methods' => 'DELETE',
+ 'callback' => array( $this, 'delete_log_file_endpoint' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_options' );
+ },
+ )
+ );
+ }
+
+ /**
+ * REST API endpoint to export logs.
+ *
+ * @since 5.1.0
+ * @param WP_REST_Request> $request The REST request.
+ * @return WP_Error|void
+ */
+ public function export_logs_endpoint( $request ) {
+ if ( ! $request instanceof WP_REST_Request ) {
+ return new WP_Error( 'invalid_request', '', array( 'status' => 400 ) );
+ }
+
+ if ( ! $this->log_file_exists() || ! $this->is_log_readable() ) {
+ return new WP_Error( 'no_logs', '', array( 'status' => 404 ) );
+ }
+
+ header( 'Content-Description: File Transfer' );
+ header( 'Content-Type: application/octet-stream' );
+ header( 'Content-Disposition: attachment; filename=feedzy-log.jsonl' );
+ header( 'Content-Transfer-Encoding: binary' );
+ header( 'Expires: 0' );
+ header( 'Connection: Keep-Alive' );
+ header( 'Cache-Control: must-revalidate' );
+ header( 'Pragma: public' );
+ header( 'Content-Length: ' . filesize( $this->filepath ) );
+
+ readfile( $this->filepath );
+
+ exit;
+ }
+
+ /**
+ * REST API endpoint to delete log file.
+ *
+ * @since 5.1.0
+ * @param WP_REST_Request> $request The REST request.
+ * @return WP_Error|array
+ */
+ public function delete_log_file_endpoint( $request ) {
+ if ( ! $request instanceof WP_REST_Request ) {
+ return new WP_Error( 'invalid_request', '', array( 'status' => 400 ) );
+ }
+
+ if ( ! $this->log_file_exists() ) {
+ return new WP_Error( 'no_logs', '', array( 'status' => 404 ) );
+ }
+
+ if ( $this->delete_log_file() ) {
+ $this->reset_log_stats();
+ return array( 'success' => true );
+ }
+
+ return new WP_Error( 'delete_failed', '', array( 'status' => 500 ) );
+ }
+
+ /**
+ * Get the size of the log file.
+ *
+ * @since 5.1.0
+ * @return bool|int
+ */
+ public function get_log_file_size() {
+ if ( ! file_exists( $this->filepath ) ) {
+ return 0;
+ }
+
+ return $this->filesystem->size( $this->filepath );
+ }
+
+ /**
+ * Check if the log file is readable.
+ *
+ * @since 5.1.0
+ * @return bool Whether the log file is readable.
+ */
+ public function is_log_readable() {
+ if ( ! file_exists( $this->filepath ) ) {
+ return false;
+ }
+
+ return $this->filesystem->is_readable( $this->filepath );
+ }
+
+ /**
+ * Check if the log file exists.
+ *
+ * @since 5.1.0
+ * @return bool Whether the log file exists.
+ */
+ public function log_file_exists() {
+ if ( ! file_exists( $this->filepath ) ) {
+ return false;
+ }
+
+ return $this->filesystem->exists( $this->filepath );
+ }
+
+ /**
+ * Delete the log file.
+ *
+ * @since 5.1.0
+ * @return bool
+ */
+ public function delete_log_file() {
+ if ( file_exists( $this->filepath ) ) {
+ return $this->filesystem->delete( $this->filepath );
+ }
+ return false;
+ }
+
+ /**
+ * Check if email reports can be sent.
+ *
+ * @since 5.1.0
+ * @return bool Whether email reports can be sent.
+ */
+ public function can_send_email() {
+ return $this->can_send_email && ! empty( $this->to_email );
+ }
+
+ /**
+ * Get the email address for reports.
+ *
+ * @since 5.1.0
+ * @return string The email address.
+ */
+ public function get_email_address() {
+ return $this->to_email;
+ }
+
+ /**
+ * Check if there are reportable logs or stats.
+ *
+ * @since 5.1.0
+ * @return bool Whether there are logs or stats to report.
+ */
+ public function has_reportable_data() {
+ $logs_entries = $this->get_recent_logs( 50, 'error' );
+ $stats = get_option( self::STATS_OPTION_KEY, array() );
+
+ return ( ! empty( $logs_entries ) && is_array( $logs_entries ) ) || ! empty( $stats );
+ }
+
+ /**
+ * Get error logs for email reports.
+ *
+ * @since 5.1.0
+ * @param int $limit The number of logs to retrieve.
+ * @return array> The error log entries.
+ */
+ public function get_error_logs_for_email( $limit = 50 ) {
+ $recent_logs = $this->get_recent_logs( $limit, 'error' );
+ if ( is_wp_error( $recent_logs ) ) {
+ return array();
+ }
+ return $recent_logs;
+ }
+
+ /**
+ * Get log statistics.
+ *
+ * @since 5.1.0
+ * @return array The log statistics.
+ */
+ public function get_log_statistics() {
+ return get_option( self::STATS_OPTION_KEY, array() );
+ }
+
+ /**
+ * Increment a log statistic.
+ *
+ * @since 5.1.0
+ * @param string $stat_name The statistic name.
+ * @return void
+ */
+ public function increment_log_stat( $stat_name ) {
+ $stats = get_option( self::STATS_OPTION_KEY, array() );
+ if ( ! isset( $stats[ $stat_name ] ) ) {
+ $stats[ $stat_name ] = 0;
+ }
+
+ if ( ! isset( $stats['stats_since'] ) ) {
+ $stats['stats_since'] = current_datetime()->format( DATE_ATOM );
+ }
+
+ ++$stats[ $stat_name ];
+ update_option( self::STATS_OPTION_KEY, $stats );
+ }
+
+ /**
+ * Reset log statistics.
+ *
+ * @since 5.1.0
+ * @return void
+ */
+ public function reset_log_stats() {
+ delete_option( self::STATS_OPTION_KEY );
+ }
+
+ /**
+ * Get the full path to the log file.
+ *
+ * @since 5.1.0
+ * @return string The full path to the log file.
+ */
+ public function get_log_file_path() {
+ $upload_dir = wp_upload_dir();
+ $log_dir = $upload_dir['basedir'] . '/feedzy-logs';
+ return $log_dir . '/' . self::FILE_NAME . self::FILE_EXT;
+ }
+
+ /**
+ * Enable retention of error messages.
+ *
+ * @return void
+ */
+ public function enable_error_messages_retention() {
+ $this->retain_error_messages = true;
+ }
+
+ /**
+ * Disable retention of error messages.
+ *
+ * @return void
+ */
+ public function disable_error_messages_retention() {
+ $this->retain_error_messages = false;
+ $this->error_messages_accumulator = array();
+ }
+
+ /**
+ * Get the accumulated error messages.
+ *
+ * @return string[]
+ */
+ public function get_error_messages_accumulator() {
+ return $this->error_messages_accumulator;
+ }
+
+ /**
+ * Add an error message to the accumulator.
+ *
+ * @param string $message The error message to accumulate.
+ * @return void
+ */
+ public function try_accumulate_error_message( $message ) {
+ if ( ! $this->retain_error_messages ) {
+ return;
+ }
+
+ if ( 200 <= count( $this->error_messages_accumulator ) ) {
+ return;
+ }
+
+ $this->error_messages_accumulator[] = $message;
+ }
+}
diff --git a/includes/admin/feedzy-rss-feeds-task-manager.php b/includes/admin/feedzy-rss-feeds-task-manager.php
new file mode 100644
index 00000000..482b91db
--- /dev/null
+++ b/includes/admin/feedzy-rss-feeds-task-manager.php
@@ -0,0 +1,132 @@
+should_clean_logs();
+ }
+ );
+
+ add_action(
+ 'init',
+ function () {
+ $this->schedule_weekly_tasks();
+ $this->schedule_hourly_tasks();
+ }
+ );
+ }
+
+ /**
+ * Schedule weekly tasks.
+ *
+ * @since 5.1.0
+ * @return void
+ */
+ public function schedule_weekly_tasks() {
+ $log_instance = Feedzy_Rss_Feeds_Log::get_instance();
+
+ if (
+ ! $log_instance->can_send_email() ||
+ false !== Feedzy_Rss_Feeds_Util_Scheduler::is_scheduled( 'task_feedzy_send_error_report' )
+ ) {
+ return;
+ }
+
+ Feedzy_Rss_Feeds_Util_Scheduler::schedule_event( time(), 'weekly', 'task_feedzy_send_error_report' );
+ }
+
+ /**
+ * Schedule daily tasks.
+ *
+ * @since 5.1.0
+ * @return void
+ */
+ public function schedule_hourly_tasks() {
+ if (
+ false !== Feedzy_Rss_Feeds_Util_Scheduler::is_scheduled( 'task_feedzy_cleanup_logs' )
+ ) {
+ return;
+ }
+
+ Feedzy_Rss_Feeds_Util_Scheduler::schedule_event( time(), 'hourly', 'task_feedzy_cleanup_logs' );
+ }
+
+ /**
+ * Send error report email.
+ *
+ * @since 5.1.0
+ * @return void.
+ */
+ public function send_error_report() {
+ $log_instance = Feedzy_Rss_Feeds_Log::get_instance();
+
+ if ( ! $log_instance->can_send_email() ) {
+ return;
+ }
+
+ if ( ! $log_instance->has_reportable_data() ) {
+ return;
+ }
+
+ $logs_entries = $log_instance->get_error_logs_for_email( 50 );
+ $stats = $log_instance->get_log_statistics();
+
+ $message = $this->get_email_report_content( $logs_entries, $stats );
+
+ $subject = sprintf( 'Feedzy RSS Feeds Log Report for %s', get_bloginfo( 'name' ) );
+ $headers = array( 'Content-Type: text/html; charset=UTF-8' );
+
+ // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_mail_wp_mail
+ if ( wp_mail( $log_instance->get_email_address(), $subject, $message, $headers ) ) {
+ $log_instance->reset_log_stats();
+ }
+ }
+
+ /**
+ * Get the email report content using the layout template.
+ *
+ * @since 5.1.0
+ * @param array> $logs_entries The log entries to include in the report.
+ * @param array $stats The log statistics.
+ * @return string The rendered email content.
+ */
+ private function get_email_report_content( $logs_entries, $stats ) {
+ // Prepare variables for the template
+ $site_name = get_bloginfo( 'name' );
+ $generated_date = date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) );
+
+ // Start output buffering to capture the template output
+ ob_start();
+
+ // Include the email template
+ include FEEDZY_ABSPATH . '/includes/layouts/feedzy-email-report.php';
+
+ // Get the buffered content and clean the buffer
+ $content = ob_get_clean();
+
+ return $content;
+ }
+}
diff --git a/includes/feedzy-rss-feeds.php b/includes/feedzy-rss-feeds.php
index 3d7ff76a..10c53a9a 100644
--- a/includes/feedzy-rss-feeds.php
+++ b/includes/feedzy-rss-feeds.php
@@ -281,6 +281,32 @@ function () {
$plugin_slug = FEEDZY_DIRNAME . '/' . basename( FEEDZY_BASEFILE );
$this->loader->add_filter( "plugin_action_links_$plugin_slug", self::$instance->admin, 'plugin_actions', 10, 2 );
+
+ add_action(
+ 'feedzy_log',
+ function ( $log_data ) {
+ $level = isset( $log_data['level'] ) ? $log_data['level'] : 'debug';
+ $message = isset( $log_data['message'] ) ? $log_data['message'] : '';
+ $context = isset( $log_data['context'] ) ? $log_data['context'] : array();
+
+ if ( ! isset( Feedzy_Rss_Feeds_Log::PRIORITIES_MAPPING[ $level ] ) ) {
+ return;
+ }
+
+ Feedzy_Rss_Feeds_Log::log( Feedzy_Rss_Feeds_Log::PRIORITIES_MAPPING[ $level ], $message, $context );
+ }
+ );
+ ( new Feedzy_Rss_Feeds_Task_Manager() )->register_actions();
+
+ add_filter(
+ 'feedzy_disable_db_cache',
+ function ( $a, $b ) {
+ return true;
+ },
+ 10,
+ 2
+ );
+
if ( ! defined( 'TI_UNIT_TESTING' ) ) {
add_action(
'plugins_loaded',
diff --git a/includes/layouts/feedzy-email-report.php b/includes/layouts/feedzy-email-report.php
new file mode 100644
index 00000000..e32a6e34
--- /dev/null
+++ b/includes/layouts/feedzy-email-report.php
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+ 0 ) : ?>
+ format( DATE_ATOM );
+ ?>
+
+
+
+ ' . esc_html( $stats['error_count'] ) . '',
+ esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $stats_since ) ) )
+ );
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ []
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/includes/layouts/feedzy-logs-viewer.php b/includes/layouts/feedzy-logs-viewer.php
new file mode 100644
index 00000000..b65c1000
--- /dev/null
+++ b/includes/layouts/feedzy-logs-viewer.php
@@ -0,0 +1,122 @@
+ __( 'Debug', 'feedzy-rss-feeds' ),
+ 'info' => __( 'Info', 'feedzy-rss-feeds' ),
+ 'warning' => __( 'Warning', 'feedzy-rss-feeds' ),
+ 'error' => __( 'Error', 'feedzy-rss-feeds' ),
+);
+
+$file_size = Feedzy_Rss_Feeds_Log::get_instance()->get_log_file_size();
+if ( is_numeric( $file_size ) && $file_size > 0 ) {
+ $file_size = size_format( $file_size, 0 );
+}
+
+$logs_entries = isset( $logs ) && is_array( $logs ) ? $logs : array();
+
+?>
+
+
+
+ %1$s (%2$s)',
+ esc_html__( 'An error occurred while fetching logs.', 'feedzy-rss-feeds' ),
+ esc_html( $logs->get_error_message() )
+ );
+ } elseif ( empty( $logs_entries ) ) {
+ echo '
' . esc_html__( 'No logs found.', 'feedzy-rss-feeds' ) . '
';
+ $logs = array();
+ }
+
+ foreach ( $logs_entries as $log ) :
+ ?>
+
+
+
+
+
\ No newline at end of file
diff --git a/includes/layouts/feedzy-tutorial.php b/includes/layouts/feedzy-tutorial.php
index a2aea99e..84dc7a82 100644
--- a/includes/layouts/feedzy-tutorial.php
+++ b/includes/layouts/feedzy-tutorial.php
@@ -6,6 +6,26 @@
-->
'feedzy-settings',
+ ],
+ admin_url( 'admin.php' )
+);
+$feed_imports_link = add_query_arg(
+ [
+ 'post_type' => 'feedzy_imports',
+ ],
+ admin_url( 'edit.php' )
+);
+$logs_link = add_query_arg(
+ [
+ 'tab' => 'logs',
+ ],
+ $settings_link
+);
+
?>
@@ -35,8 +55,35 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/includes/layouts/settings.php b/includes/layouts/settings.php
index e37386af..f3c1162f 100644
--- a/includes/layouts/settings.php
+++ b/includes/layouts/settings.php
@@ -25,6 +25,24 @@
} elseif ( 'amazon-product-advertising' === $active_tab ) {
$help_btn_url = 'https://docs.themeisle.com/article/1745-how-to-display-amazon-products-using-feedzy';
}
+
+ $logs = array();
+ $logging_level = isset( $settings['logs'], $settings['logs']['level'] ) ? $settings['logs']['level'] : 'error';
+ $email_error_address = isset( $settings['logs'], $settings['logs']['email'] ) ? $settings['logs']['email'] : '';
+ $email_error_enabled = isset( $settings['logs'], $settings['logs']['send_email_report'] ) ? $settings['logs']['send_email_report'] : 0;
+ $email_error_address_placeholder = ( ! empty( $email_error_address ) ) ? $email_error_address : get_option( 'admin_email' );
+
+ if ( 'logs' === $active_tab ) {
+ $logs_type = isset( $_REQUEST['logs_type'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['logs_type'] ) ) : null;// phpcs:ignore WordPress.Security.NonceVerification
+ $logs = Feedzy_Rss_Feeds_Log::get_instance()->get_recent_logs( 50, $logs_type );
+ }
+
+ $file_size = Feedzy_Rss_Feeds_Log::get_instance()->get_log_file_size();
+ if ( is_numeric( $file_size ) && $file_size > 0 ) {
+ $file_size = size_format( $file_size, 0 );
+ }
+
+
?>
notice ) { ?>
@@ -50,16 +68,36 @@
-
+
diff --git a/includes/views/js/import-metabox-edit.js b/includes/views/js/import-metabox-edit.js
index fdcc6c77..5b0e72ac 100644
--- a/includes/views/js/import-metabox-edit.js
+++ b/includes/views/js/import-metabox-edit.js
@@ -1141,6 +1141,13 @@
);
},
},
+ {
+ text: window.feedzy.i10n.goToLogsTab,
+ class: 'button button-secondary',
+ click: () => {
+ window.location.href = window.feedzy.pages.logs;
+ }
+ },
{
text: feedzy.i10n.okButton,
class: 'alignright',
diff --git a/js/feedzy-setting.js b/js/feedzy-setting.js
index 51fe0741..fc058b02 100644
--- a/js/feedzy-setting.js
+++ b/js/feedzy-setting.js
@@ -170,4 +170,53 @@ jQuery(function ($) {
};
initializeAutoCatActions();
+
+ $('#feedzy-delete-log-file').on('click', function (e) {
+ e.preventDefault();
+ const _this = $(this);
+ const originalText = _this.html();
+ _this.attr('disabled', true).addClass('fz-checking');
+
+ const deleteUrl = new URL(`${window.wpApiSettings.root}feedzy/v1/logs`);
+ deleteUrl.searchParams.append('_wpnonce', window.wpApiSettings.nonce);
+
+ fetch(deleteUrl, {
+ method: 'DELETE',
+ })
+ .then((response) => response.json())
+ .then((response) => {
+ if (!response.success) {
+ _this.html(
+ ''
+ );
+ setTimeout(function () {
+ _this.html(originalText);
+ _this.removeAttr('disabled').removeClass('fz-checking');
+ }, 3000);
+ } else {
+ window.location.reload();
+ }
+ })
+ .catch((error) => {
+ _this.html('');
+ setTimeout(function () {
+ _this.html(originalText);
+ _this.removeAttr('disabled').removeClass('fz-checking');
+ }, 3000);
+ });
+ });
+
+ /**
+ * Toggle visibility of the email error address field based on email error enabled checkbox.
+ */
+ const toggleEmailErrorField = () => {
+ const checkbox = $('#feedzy-email-error-enabled');
+ const emailField = checkbox
+ .closest('.fz-form-group')
+ .next('.fz-form-group');
+
+ emailField.toggleClass('fz-hidden', !checkbox.is(':checked'));
+ };
+
+ $('#feedzy-email-error-enabled').on('change', toggleEmailErrorField);
});
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 48144c95..4666aec4 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -920,21 +920,11 @@ parameters:
count: 1
path: includes/admin/feedzy-rss-feeds-import.php
- -
- message: "#^Method Feedzy_Rss_Feeds_Import\\:\\:run_job\\(\\) should return int but empty return statement found\\.$#"
- count: 1
- path: includes/admin/feedzy-rss-feeds-import.php
-
-
message: "#^Method Feedzy_Rss_Feeds_Import\\:\\:set_wpml_element_language_details\\(\\) has no return type specified\\.$#"
count: 1
path: includes/admin/feedzy-rss-feeds-import.php
- -
- message: "#^Method Feedzy_Rss_Feeds_Import\\:\\:try_save_featured_image\\(\\) has parameter \\$import_errors with no value type specified in iterable type array\\.$#"
- count: 1
- path: includes/admin/feedzy-rss-feeds-import.php
-
-
message: "#^Method Feedzy_Rss_Feeds_Import\\:\\:try_save_featured_image\\(\\) has parameter \\$import_info with no value type specified in iterable type array\\.$#"
count: 1
@@ -1020,11 +1010,6 @@ parameters:
count: 1
path: includes/admin/feedzy-rss-feeds-import.php
- -
- message: "#^Result of && is always true\\.$#"
- count: 1
- path: includes/admin/feedzy-rss-feeds-import.php
-
-
message: "#^Unreachable statement \\- code above always terminates\\.$#"
count: 1
diff --git a/tests/e2e/specs/logger.spec.js b/tests/e2e/specs/logger.spec.js
new file mode 100644
index 00000000..6d6f14eb
--- /dev/null
+++ b/tests/e2e/specs/logger.spec.js
@@ -0,0 +1,61 @@
+/**
+ * WordPress dependencies
+ */
+import { test, expect } from '@wordpress/e2e-test-utils-playwright';
+import { createAndRunSampleImport, deleteAllFeedImports } from '../utils';
+
+test.describe('Logger', () => {
+ test.beforeEach(async ({ requestUtils }) => {
+ await deleteAllFeedImports(requestUtils);
+ await requestUtils.deleteAllPosts();
+ await requestUtils.deleteAllMedia();
+ });
+
+ test('check settings rendering', async ({ page, admin }) => {
+ await admin.visitAdminPage('admin.php?page=feedzy-settings');
+
+ await expect(
+ page.locator('select[name="logs-logging-level"]')
+ ).toBeVisible();
+
+ await expect(
+ page.getByText('Report errors via email (Once')
+ ).toBeVisible();
+ });
+
+ test('check logs tabs', async ({ page, admin }) => {
+ await admin.visitAdminPage('admin.php?page=feedzy-settings');
+
+ await page
+ .locator('select[name="logs-logging-level"]')
+ .selectOption('debug');
+
+ await page
+ .getByRole('button', { name: 'Save Settings' })
+ .click({ force: true });
+
+ // Create some logs via a sample import.
+ await createAndRunSampleImport(page);
+
+ await admin.visitAdminPage('admin.php?page=feedzy-settings&tab=logs');
+
+ await expect(
+ page.getByRole('heading', { name: 'Recent Logs' })
+ ).toBeVisible();
+
+ // Check that logs are displayed.
+ expect(
+ await page.locator('.fz-log-container--info').count()
+ ).toBeGreaterThan(0);
+ expect(
+ await page.locator('.fz-log-container--debug').count()
+ ).toBeGreaterThan(0);
+
+ // Filter messages by Debug.
+ await page.getByRole('link', { name: 'Debug' }).click();
+ expect(await page.locator('.fz-log-container--info').count()).toBe(0);
+ expect(
+ await page.locator('.fz-log-container--debug').count()
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/e2e/utils.js b/tests/e2e/utils.js
index e3656267..44e48d16 100644
--- a/tests/e2e/utils.js
+++ b/tests/e2e/utils.js
@@ -1,18 +1,21 @@
-import {expect} from "@wordpress/e2e-test-utils-playwright";
+import { expect } from '@wordpress/e2e-test-utils-playwright';
/**
* WordPress dependencies
*/
-const {RequestUtils } = require( '@wordpress/e2e-test-utils-playwright' )
+const { RequestUtils } = require('@wordpress/e2e-test-utils-playwright');
+
+export const CUSTOM_FEED_URL =
+ 'https://s3.amazonaws.com/verti-utils/sample-feed.xml';
/**
* Close the tour modal if it is visible.
*
* @param {import('playwright').Page} page The page object.
*/
-export async function tryCloseTourModal( page ) {
+export async function tryCloseTourModal(page) {
if (await page.getByRole('button', { name: 'Skip' }).isVisible()) {
- await page.getByRole('button', { name: 'Skip' }).click();
+ await page.getByRole('button', { name: 'Skip' }).click({ force: true });
await page.waitForTimeout(500);
}
}
@@ -20,117 +23,145 @@ export async function tryCloseTourModal( page ) {
/**
* Add feeds to the import on Feed Edit page.
*
- * @param {import('playwright').Page} page The page object.
- * @param {string[]} feedURLs The feed URLs to add in the input.
- * @returns {Promise} The promise that resolves when the feeds are added.
+ * @param {import('playwright').Page} page The page object.
+ * @param {string[]} feedURLs The feed URLs to add in the input.
+ * @return {Promise} The promise that resolves when the feeds are added.
*/
-export async function addFeeds( page, feedURLs ) {
- await page.evaluate( ( urls ) => {
- document.querySelector( 'input[name="feedzy_meta_data[source]"]' ).value = urls?.join(', ');
- }, feedURLs );
+export async function addFeeds(page, feedURLs) {
+ await page.waitForSelector('input[name="feedzy_meta_data[source]"]', {
+ state: 'attached',
+ });
+ await page.evaluate((urls) => {
+ document.querySelector('input[name="feedzy_meta_data[source]"]').value =
+ urls?.join(', ');
+ }, feedURLs);
}
/**
* Add tags to the Featured Image on Feed Edit page.
*
- * @param {import('playwright').Page} page The page object.
- * @param {string} feedTag The tag that import the image from the feed.
- * @returns {Promise} The promise that resolves when the tags are added.
+ * @param {import('playwright').Page} page The page object.
+ * @param {string} feedTag The tag that import the image from the feed.
+ * @return {Promise} The promise that resolves when the tags are added.
*/
-export async function addFeaturedImage( page, feedTag ) {
- await page.evaluate( ( feedTag ) => {
- document.querySelector( 'input[name="feedzy_meta_data[import_post_featured_img]"]' ).value = feedTag;
- }, feedTag );
+export async function addFeaturedImage(page, feedTag) {
+ await page.evaluate((feedTag) => {
+ document.querySelector(
+ 'input[name="feedzy_meta_data[import_post_featured_img]"]'
+ ).value = feedTag;
+ }, feedTag);
}
/**
* Add content mapping to the import on Feed Edit page.
- * @param page The page object.
- * @param mapping The content mapping to add.
- * @returns {Promise}
+ * @param page The page object.
+ * @param mapping The content mapping to add.
+ * @return {Promise}
*/
-export async function addContentMapping( page, mapping ) {
- await page.evaluate( ( mapping ) => {
- document.querySelector( 'textarea[name="feedzy_meta_data[import_post_content]"]' ).value = mapping;
- }, mapping );
+export async function addContentMapping(page, mapping) {
+ await page.evaluate((mapping) => {
+ document.querySelector(
+ 'textarea[name="feedzy_meta_data[import_post_content]"]'
+ ).value = mapping;
+ }, mapping);
}
/**
* Set the item limit on the Feed Edit page.
- * @param {import('playwright').Page} page The page object.
- * @param {number} limit The limit to set.
- * @returns {Promise}
+ * @param {import('playwright').Page} page The page object.
+ * @param {number} limit The limit to set.
+ * @return {Promise}
*/
-export async function setItemLimit( page, limit ) {
- await page.evaluate( ( limit ) => {
- document.querySelector( 'input[name="feedzy_meta_data[import_feed_limit]"]' ).value = limit;
- } , limit );
+export async function setItemLimit(page, limit) {
+ try {
+ await page.waitForSelector(
+ 'input[name="feedzy_meta_data[import_feed_limit]"]',
+ {
+ state: 'attached',
+ }
+ );
+ await page.evaluate((importLimit) => {
+ document.querySelector(
+ 'input[name="feedzy_meta_data[import_feed_limit]"]'
+ ).value = importLimit;
+ }, limit);
+ } catch (error) {
+ // Element not found or not attached - ignore silently
+ }
}
/**
* Create an empty chained actions.
* @param {string} defaultFeedTag The default feed tag.
- * @returns {string} The empty chained actions.
+ * @return {string} The empty chained actions.
*/
-export function getEmptyChainedActions( defaultFeedTag ) {
- return wrapSerializedChainedActions(serializeChainedActions([ { id: '', tag: defaultFeedTag, data: {} } ] ) );
+export function getEmptyChainedActions(defaultFeedTag) {
+ return wrapSerializedChainedActions(
+ serializeChainedActions([{ id: '', tag: defaultFeedTag, data: {} }])
+ );
}
/**
* Serialize the chained actions.
* @param {any[]} actions The actions to serialize.
- * @returns {string} The serialized actions.
+ * @return {string} The serialized actions.
*/
-export function serializeChainedActions( actions ) {
- return encodeURIComponent( JSON.stringify( actions ) );
+export function serializeChainedActions(actions) {
+ return encodeURIComponent(JSON.stringify(actions));
}
/**
* Wrap the serialized chained actions to match the format used in the input.
* @param {string} actions The serialized actions.
- * @returns {string} The wrapped actions.
+ * @return {string} The wrapped actions.
*/
-export function wrapSerializedChainedActions( actions ) {
- return `[[{"value":"${actions}"}]]`;
+export function wrapSerializedChainedActions(actions) {
+ return `[[{"value":"${actions}"}]]`;
}
/**
* Run the feed import.
*
* @param {import('playwright').Page} page The page object.
- * @returns {Promise} The promise that resolves when the feed is imported.
+ * @return {Promise} The promise that resolves when the feed is imported.
*/
-export async function runFeedImport( page ) {
- await page.goto('/wp-admin/edit.php?post_type=feedzy_imports');
- await page.waitForSelector('.feedzy-import-status-row');
-
- await page.getByRole('button', { name: 'Run Now' }).click();
-
- const runNowResponse = await page.waitForResponse(
- response => (
- response.url().includes('/wp-admin/admin-ajax.php')&&
- response.request().method() === 'POST' &&
- response.request().postData().includes('action=feedzy&_action=run_now')
- )
- );
-
- expect( runNowResponse ).not.toBeNull();
- const responseBody = await runNowResponse.json();
- expect( responseBody.success ).toBe(true);
- expect( responseBody.data.import_success ).toBe(true);
-
- // Reload the page to check the status.
- await page.reload();
- await page.waitForSelector('.feedzy-items');
-
- // We should have some imported posts in the stats.
- const feedzyCumulative = parseInt(await page.$eval('.feedzy-items a', (element) => element.innerText));
- expect(feedzyCumulative).toBeGreaterThan(0);
-
- // Open the dialog with the imported feeds.
- await page.locator('.feedzy-items a').click();
- await expect( page.locator('#ui-id-2').locator('li a').count() ).resolves.toBeGreaterThan(0);
- await page.getByRole('button', { name: 'Ok' }).click();
+export async function runFeedImport(page) {
+ await page.goto('/wp-admin/edit.php?post_type=feedzy_imports');
+ await page.waitForSelector('.feedzy-import-status-row');
+
+ await page.getByRole('button', { name: 'Run Now' }).click();
+
+ const runNowResponse = await page.waitForResponse(
+ (response) =>
+ response.url().includes('/wp-admin/admin-ajax.php') &&
+ response.request().method() === 'POST' &&
+ response
+ .request()
+ .postData()
+ .includes('action=feedzy&_action=run_now')
+ );
+
+ expect(runNowResponse).not.toBeNull();
+ const responseBody = await runNowResponse.json();
+ expect(responseBody.success).toBe(true);
+ expect(responseBody.data.import_success).toBe(true);
+
+ // Reload the page to check the status.
+ await page.reload();
+ await page.waitForSelector('.feedzy-items');
+
+ // We should have some imported posts in the stats.
+ const feedzyCumulative = parseInt(
+ await page.$eval('.feedzy-items a', (element) => element.innerText)
+ );
+ expect(feedzyCumulative).toBeGreaterThan(0);
+
+ // Open the dialog with the imported feeds.
+ await page.locator('.feedzy-items a').click();
+ await expect(
+ page.locator('#ui-id-2').locator('li a').count()
+ ).resolves.toBeGreaterThan(0);
+ await page.getByRole('button', { name: 'Ok' }).click();
}
/**
@@ -138,42 +169,68 @@ export async function runFeedImport( page ) {
*
* @param {RequestUtils} requestUtils The request utils object.
*/
-export async function deleteAllFeedImports( requestUtils ) {
- const feeds = await requestUtils.rest( {
- path: '/wp/v2/feedzy_imports',
- params: {
- per_page: 100,
- status: 'publish,future,draft,pending,private,trash',
- },
- } );
-
- await Promise.all(
- feeds.map( ( post ) =>
- requestUtils.rest( {
- method: 'DELETE',
- path: `/wp/v2/feedzy_imports/${ post.id }`,
- params: {
- force: true,
- },
- } )
- )
- );
+export async function deleteAllFeedImports(requestUtils) {
+ const feeds = await requestUtils.rest({
+ path: '/wp/v2/feedzy_imports',
+ params: {
+ per_page: 100,
+ status: 'publish,future,draft,pending,private,trash',
+ },
+ });
+
+ await Promise.all(
+ feeds.map((post) =>
+ requestUtils.rest({
+ method: 'DELETE',
+ path: `/wp/v2/feedzy_imports/${post.id}`,
+ params: {
+ force: true,
+ },
+ })
+ )
+ );
}
/**
* Get post created with Feedzy.
* @param {RequestUtils} requestUtils The request utils object.
- * @returns {Promise<*>}
+ * @return {Promise<*>}
+ */
+export async function getPostsByFeedzy(requestUtils) {
+ return await requestUtils.rest({
+ path: '/wp/v2/posts',
+ params: {
+ per_page: 100,
+ status: 'publish',
+ meta_key: 'feedzy',
+ meta_value: 1,
+ meta_compare: '=',
+ },
+ });
+}
+
+/**
+ * Create and run a sample import with the given feed URL.
+ *
+ * @param {import('playwright').Page} page
+ * @param {string} feedUrl
+ *
+ * @return {Promise} The promise that resolves when the import is created and run.
*/
-export async function getPostsByFeedzy( requestUtils ) {
- return await requestUtils.rest({
- path: '/wp/v2/posts',
- params: {
- per_page: 100,
- status: 'publish',
- meta_key: 'feedzy',
- meta_value: 1,
- meta_compare: '=',
- },
- });
+export async function createAndRunSampleImport(
+ page,
+ feedUrl = CUSTOM_FEED_URL
+) {
+ const importName = `Create and run sample import: ${new Date().toISOString()}`;
+
+ await page.goto('/wp-admin/post-new.php?post_type=feedzy_imports');
+ await tryCloseTourModal(page);
+
+ await page.getByPlaceholder('Add a name for your import').fill(importName);
+ await addFeeds(page, [feedUrl]);
+ await page
+ .getByRole('button', { name: 'Save & Activate importing' })
+ .click({ force: true });
+
+ await runFeedImport(page);
}
diff --git a/tests/test-image-import.php b/tests/test-image-import.php
index bf589bd7..ae8ff88a 100644
--- a/tests/test-image-import.php
+++ b/tests/test-image-import.php
@@ -23,16 +23,26 @@ public function test_image_import_url() {
$try_save_featured_image->setAccessible( true );
// Check that NON-IMAGE URL returns invalid
- $import_errors = array();
$import_info = array();
- $arguments = array( 'a random string', 0, 'Post Title', &$import_errors, &$import_info, array() );
+ $arguments = array( 'a random string', 0, 'Post Title', &$import_info, array() );
$response = $try_save_featured_image->invokeArgs( $feedzy, $arguments );
$this->assertFalse( $response );
- $this->assertTrue( count( $import_errors ) > 0 );
- $this->assertEquals( 'Invalid Featured Image URL: a random string', $import_errors[0] );
-
+ // Check that error was logged for invalid URL
+ $logger = Feedzy_Rss_Feeds_Log::get_instance();
+ $recent_logs = $logger->get_recent_logs( 5, 'ERROR' );
+ $this->assertNotEmpty( $recent_logs );
+
+ // Find the error log for invalid image URL
+ $found_error = false;
+ foreach ( $recent_logs as $log ) {
+ if ( strpos( $log['message'], 'Invalid image URL' ) !== false && strpos( $log['message'], 'a random string' ) !== false ) {
+ $found_error = true;
+ break;
+ }
+ }
+ $this->assertTrue( $found_error, 'Expected error log for invalid image URL not found' );
// For the next test, we will use a valid URL, but the image does not exist. We will check that the error is logged and is the expected one.
add_filter( 'themeisle_log_event', function( $product, $message, $type, $file, $line ) {
@@ -41,23 +51,19 @@ public function test_image_import_url() {
}
}, 10, 5 );
- $import_errors = array();
$import_info = array();
- $arguments = array( 'https://example.com/path_to_image/image.jpeg', 0, 'Post Title', &$import_errors, &$import_info, array() );
+ $arguments = array( 'https://example.com/path_to_image/image.jpeg', 0, 'Post Title', &$import_info, array() );
$response = $try_save_featured_image->invokeArgs( $feedzy, $arguments );
- // expected response is false because the image does not exist, but the URL is valid so no $import_errors should be set.
+ // expected response is false because the image does not exist, but the URL is valid so no errors should be logged for URL validation
$this->assertFalse( $response );
- $this->assertTrue( empty( $import_errors ) );
- $import_errors = array();
$import_info = array();
- $arguments = array( 'https://example.com/path_to_image/image w space in name.jpeg', 0, 'Post Title', &$import_errors, &$import_info, array() );
+ $arguments = array( 'https://example.com/path_to_image/image w space in name.jpeg', 0, 'Post Title', &$import_info, array() );
$response = $try_save_featured_image->invokeArgs( $feedzy, $arguments );
- // expected response is false because the image does not exist, but the URL is valid so no $import_errors should be set.
+ // expected response is false because the image does not exist, but the URL is valid so no errors should be logged for URL validation
$this->assertFalse( $response );
- $this->assertTrue( empty( $import_errors ) );
}
public function test_import_image_special_characters() {
@@ -67,10 +73,9 @@ public function test_import_image_special_characters() {
$try_save_featured_image = $reflector->getMethod( 'try_save_featured_image' );
$try_save_featured_image->setAccessible( true );
- $import_errors = array();
$import_info = array();
- $arguments = array( 'https://example.com/path_to_image/çöp.jpg?itok=ZYU_ihPB', 0, 'Post Title', &$import_errors, &$import_info, array() );
+ $arguments = array( 'https://example.com/path_to_image/çöp.jpg?itok=ZYU_ihPB', 0, 'Post Title', &$import_info, array() );
$response = $try_save_featured_image->invokeArgs( $feedzy, $arguments );
// expected response is false because the image does not exist, but the URL is valid so no $import_errors should be set.
diff --git a/tests/test-log.php b/tests/test-log.php
new file mode 100644
index 00000000..e128d7e5
--- /dev/null
+++ b/tests/test-log.php
@@ -0,0 +1,543 @@
+assertStringContainsString( $needle, $haystack, $message );
+ } else {
+ $this->assertContains( $needle, $haystack, $message );
+ }
+ }
+
+ /**
+ * Helper method for type assertion (PHPUnit compatibility).
+ *
+ * @param string $type The expected type.
+ * @param mixed $value The value to check.
+ * @param string $message Optional failure message.
+ */
+ private function assertIsType( $type, $value, $message = '' ) {
+ $method = 'assertIs' . ucfirst( $type );
+ if ( method_exists( $this, $method ) ) {
+ $this->$method( $value, $message );
+ } else {
+ $this->assertInternalType( $type, $value, $message );
+ }
+ }
+
+ /**
+ * Reset the logger singleton to ensure fresh state per test.
+ */
+ private function reset_logger_singleton() {
+ if ( class_exists( 'Feedzy_Rss_Feeds_Log' ) ) {
+ $ref = new ReflectionClass( 'Feedzy_Rss_Feeds_Log' );
+ if ( $ref->hasProperty( 'instance' ) ) {
+ $prop = $ref->getProperty( 'instance' );
+ $prop->setAccessible( true );
+ $prop->setValue( null, null );
+ }
+ }
+ }
+
+ /**
+ * Set up test environment.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ // Ensure fresh singleton for each test.
+ $this->reset_logger_singleton();
+ $this->logger = Feedzy_Rss_Feeds_Log::get_instance();
+
+ // Use the class API to determine the log path.
+ $this->test_log_path = $this->logger->get_log_file_path();
+
+ // Clean up any existing log files
+ if ( file_exists( $this->test_log_path ) ) {
+ unlink( $this->test_log_path );
+ }
+
+ // Reset log statistics
+ delete_option( Feedzy_Rss_Feeds_Log::STATS_OPTION_KEY );
+
+ // Reset logger settings
+ delete_option( 'feedzy-settings' );
+ }
+
+ /**
+ * Clean up after tests.
+ */
+ public function tearDown(): void {
+ // Clean up test files
+ if ( file_exists( $this->test_log_path ) ) {
+ unlink( $this->test_log_path );
+ }
+
+ // Reset options
+ delete_option( Feedzy_Rss_Feeds_Log::STATS_OPTION_KEY );
+ delete_option( 'feedzy-settings' );
+
+ // Reset the singleton to avoid side effects between tests.
+ $this->reset_logger_singleton();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test logger singleton pattern.
+ */
+ public function test_singleton_instance() {
+ $instance1 = Feedzy_Rss_Feeds_Log::get_instance();
+ $instance2 = Feedzy_Rss_Feeds_Log::get_instance();
+
+ $this->assertSame( $instance1, $instance2, 'Logger should return the same instance (singleton)' );
+ $this->assertInstanceOf( 'Feedzy_Rss_Feeds_Log', $instance1, 'Instance should be of correct class' );
+ }
+
+ /**
+ * Test log level constants.
+ */
+ public function test_log_level_constants() {
+ $this->assertEquals( 100, Feedzy_Rss_Feeds_Log::DEBUG );
+ $this->assertEquals( 200, Feedzy_Rss_Feeds_Log::INFO );
+ $this->assertEquals( 300, Feedzy_Rss_Feeds_Log::WARNING );
+ $this->assertEquals( 400, Feedzy_Rss_Feeds_Log::ERROR );
+ $this->assertEquals( 500, Feedzy_Rss_Feeds_Log::NONE );
+ }
+
+ /**
+ * Test debug logging.
+ */
+ public function test_debug_logging() {
+ // Set threshold to DEBUG to ensure debug messages are logged
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::DEBUG;
+
+ Feedzy_Rss_Feeds_Log::debug( 'Test debug message', array( 'test_data' => 'debug_value' ) );
+
+ $this->assertTrue( file_exists( $this->test_log_path ), 'Log file should be created' );
+
+ $logs = $this->logger->get_recent_logs( 1 );
+ $this->assertNotEmpty( $logs, 'Should have log entries' );
+ $this->assertEquals( 'debug', $logs[0]['level'] );
+ $this->assertEquals( 'Test debug message', $logs[0]['message'] );
+ $this->assertEquals( 'debug_value', $logs[0]['context']['test_data'] );
+ }
+
+ /**
+ * Test info logging.
+ */
+ public function test_info_logging() {
+ // Set threshold to INFO to ensure info messages are logged
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::INFO;
+
+ Feedzy_Rss_Feeds_Log::info( 'Test info message', array( 'test_data' => 'info_value' ) );
+
+ $logs = $this->logger->get_recent_logs( 1 );
+ $this->assertNotEmpty( $logs );
+ $this->assertEquals( 'info', $logs[0]['level'] );
+ $this->assertEquals( 'Test info message', $logs[0]['message'] );
+ }
+
+ /**
+ * Test warning logging.
+ */
+ public function test_warning_logging() {
+ // Set threshold to WARNING to ensure warning messages are logged
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::WARNING;
+
+ Feedzy_Rss_Feeds_Log::warning( 'Test warning message', array( 'test_data' => 'warning_value' ) );
+
+ $logs = $this->logger->get_recent_logs( 1 );
+ $this->assertNotEmpty( $logs );
+ $this->assertEquals( 'warning', $logs[0]['level'] );
+ $this->assertEquals( 'Test warning message', $logs[0]['message'] );
+ }
+
+ /**
+ * Test error logging.
+ */
+ public function test_error_logging() {
+ // Errors should always be logged regardless of threshold
+ Feedzy_Rss_Feeds_Log::error( 'Test error message', array( 'test_data' => 'error_value' ) );
+
+ $logs = $this->logger->get_recent_logs( 1 );
+ $this->assertNotEmpty( $logs );
+ $this->assertEquals( 'error', $logs[0]['level'] );
+ $this->assertEquals( 'Test error message', $logs[0]['message'] );
+
+ // Check that error count statistic is incremented
+ $stats = $this->logger->get_log_statistics();
+ $this->assertEquals( 1, $stats['error_count'] );
+ }
+
+ /**
+ * Test log level threshold filtering.
+ */
+ public function test_log_level_threshold() {
+ // Set threshold to ERROR - only errors should be logged
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::ERROR;
+
+ Feedzy_Rss_Feeds_Log::debug( 'Debug message' );
+ Feedzy_Rss_Feeds_Log::info( 'Info message' );
+ Feedzy_Rss_Feeds_Log::warning( 'Warning message' );
+ Feedzy_Rss_Feeds_Log::error( 'Error message' );
+
+ $logs = $this->logger->get_recent_logs( 10 );
+ $this->assertCount( 1, $logs, 'Only error message should be logged' );
+ $this->assertEquals( 'error', $logs[0]['level'] );
+ $this->assertEquals( 'Error message', $logs[0]['message'] );
+ }
+
+ /**
+ * Test context setting.
+ */
+ public function test_context_setting() {
+ $context = array(
+ 'import_id' => 123,
+ 'feed_url' => 'https://example.com/feed.xml'
+ );
+
+ $this->logger->set_context( $context );
+
+ // Set threshold to DEBUG to ensure message is logged
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::DEBUG;
+ Feedzy_Rss_Feeds_Log::debug( 'Test with context' );
+
+ $logs = $this->logger->get_recent_logs( 1 );
+ $this->assertEquals( 123, $logs[0]['context']['import_id'] );
+ $this->assertEquals( 'https://example.com/feed.xml', $logs[0]['context']['feed_url'] );
+
+ // New: origin should be auto-injected.
+ $this->assertArrayHasKey( 'function', $logs[0]['context'] );
+ $this->assertArrayHasKey( 'line', $logs[0]['context'] );
+ }
+
+ /**
+ * Test log entry structure.
+ */
+ public function test_log_entry_structure() {
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::DEBUG;
+
+ Feedzy_Rss_Feeds_Log::debug( 'Test message', array( 'key' => 'value' ) );
+
+ $logs = $this->logger->get_recent_logs( 1 );
+ $log = $logs[0];
+
+ $this->assertArrayHasKey( 'timestamp', $log );
+ $this->assertArrayHasKey( 'level', $log );
+ $this->assertArrayHasKey( 'message', $log );
+ $this->assertArrayHasKey( 'context', $log );
+
+ // Validate timestamp format (ISO 8601, allow Z or explicit offset)
+ $this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\+\d{2}:\d{2}|Z)$/', $log['timestamp'] );
+ }
+
+ /**
+ * Test getting recent logs with limit.
+ */
+ public function test_get_recent_logs_with_limit() {
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::DEBUG;
+
+ // Log multiple messages
+ for ( $i = 1; $i <= 5; $i++ ) {
+ Feedzy_Rss_Feeds_Log::debug( "Message $i" );
+ // Add small delay to ensure different timestamps
+ usleep( 1000 );
+ }
+
+ $logs = $this->logger->get_recent_logs( 3 );
+ $this->assertCount( 3, $logs, 'Should return only 3 recent logs' );
+
+ // Should be in reverse chronological order (most recent first)
+ $this->assertEquals( 'Message 5', $logs[0]['message'] );
+ $this->assertEquals( 'Message 4', $logs[1]['message'] );
+ $this->assertEquals( 'Message 3', $logs[2]['message'] );
+ }
+
+ /**
+ * Test getting logs filtered by level.
+ */
+ public function test_get_recent_logs_filtered_by_level() {
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::DEBUG;
+
+ Feedzy_Rss_Feeds_Log::debug( 'Debug message' );
+ Feedzy_Rss_Feeds_Log::info( 'Info message' );
+ Feedzy_Rss_Feeds_Log::error( 'Error message' );
+
+ $error_logs = $this->logger->get_recent_logs( 10, 'error' );
+ $this->assertCount( 1, $error_logs );
+ $this->assertEquals( 'error', $error_logs[0]['level'] );
+ $this->assertEquals( 'Error message', $error_logs[0]['message'] );
+ }
+
+ /**
+ * Test log file operations.
+ */
+ public function test_log_file_operations() {
+ // Initially no log file should exist
+ $this->assertFalse( $this->logger->log_file_exists() );
+ $this->assertEquals( 0, $this->logger->get_log_file_size() );
+
+ // Log something to create the file
+ Feedzy_Rss_Feeds_Log::error( 'Test error' );
+
+ $this->assertTrue( $this->logger->log_file_exists() );
+ $this->assertTrue( $this->logger->is_log_readable() );
+ $this->assertGreaterThan( 0, $this->logger->get_log_file_size() );
+
+ // Test deleting log file
+ $this->assertTrue( $this->logger->delete_log_file() );
+ $this->assertFalse( $this->logger->log_file_exists() );
+ }
+
+ /**
+ * Test raw log content retrieval.
+ */
+ public function test_get_all_logs_raw() {
+ // No logs initially
+ $this->assertFalse( $this->logger->get_all_logs_raw() );
+
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::DEBUG;
+ Feedzy_Rss_Feeds_Log::debug( 'Test message' );
+
+ $raw_content = $this->logger->get_all_logs_raw();
+ $this->assertIsType( 'string', $raw_content );
+ $this->assertStringContains( 'Test message', $raw_content );
+ $this->assertStringContains( '"level":"debug"', $raw_content );
+ }
+
+ /**
+ * Test log statistics.
+ */
+ public function test_log_statistics() {
+ // Initially no stats
+ $stats = $this->logger->get_log_statistics();
+ $this->assertEmpty( $stats );
+
+ // Log some errors
+ Feedzy_Rss_Feeds_Log::error( 'Error 1' );
+ Feedzy_Rss_Feeds_Log::error( 'Error 2' );
+
+ $stats = $this->logger->get_log_statistics();
+ $this->assertEquals( 2, $stats['error_count'] );
+ $this->assertArrayHasKey( 'stats_since', $stats );
+
+ // Test resetting stats
+ $this->logger->reset_log_stats();
+ $stats = $this->logger->get_log_statistics();
+ $this->assertEmpty( $stats );
+ }
+
+ /**
+ * Test error message accumulation.
+ */
+ public function test_error_message_accumulation() {
+ // Initially disabled
+ $this->assertEmpty( $this->logger->get_error_messages_accumulator() );
+
+ // Enable retention
+ $this->logger->enable_error_messages_retention();
+
+ Feedzy_Rss_Feeds_Log::error( 'First error' );
+ Feedzy_Rss_Feeds_Log::error( 'Second error' );
+
+ $messages = $this->logger->get_error_messages_accumulator();
+ $this->assertContains( 'First error', $messages );
+ $this->assertContains( 'Second error', $messages );
+
+ // Disable retention
+ $this->logger->disable_error_messages_retention();
+ $this->assertEmpty( $this->logger->get_error_messages_accumulator() );
+ }
+
+ /**
+ * Test email configuration.
+ */
+ public function test_email_configuration() {
+ // Initially no email configured
+ $this->assertFalse( $this->logger->can_send_email() );
+ $this->assertEmpty( $this->logger->get_email_address() );
+
+ // Configure email settings
+ $settings = array(
+ 'logs' => array(
+ 'send_email_report' => true,
+ 'email' => 'test@example.com'
+ )
+ );
+ update_option( 'feedzy-settings', $settings );
+
+ // Recreate logger singleton to pick up new settings
+ $this->reset_logger_singleton();
+ $this->logger = Feedzy_Rss_Feeds_Log::get_instance();
+
+ $this->assertTrue( $this->logger->can_send_email() );
+ $this->assertEquals( 'test@example.com', $this->logger->get_email_address() );
+ }
+
+ /**
+ * Test reportable data detection.
+ */
+ public function test_has_reportable_data() {
+ // Initially no reportable data
+ $this->assertFalse( $this->logger->has_reportable_data() );
+
+ // Log an error
+ Feedzy_Rss_Feeds_Log::error( 'Test error for reporting' );
+
+ // Should now have reportable data
+ $this->assertTrue( $this->logger->has_reportable_data() );
+ }
+
+ /**
+ * Test error logs for email.
+ */
+ public function test_get_error_logs_for_email() {
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::DEBUG;
+
+ // Log different levels
+ Feedzy_Rss_Feeds_Log::debug( 'Debug message' );
+ Feedzy_Rss_Feeds_Log::info( 'Info message' );
+ Feedzy_Rss_Feeds_Log::error( 'Error for email' );
+
+ $error_logs = $this->logger->get_error_logs_for_email( 10 );
+ $this->assertCount( 1, $error_logs );
+ $this->assertEquals( 'error', $error_logs[0]['level'] );
+ $this->assertEquals( 'Error for email', $error_logs[0]['message'] );
+ }
+
+ /**
+ * Test settings initialization.
+ */
+ public function test_settings_initialization() {
+ $settings = array(
+ 'logs' => array(
+ 'level' => 'debug',
+ 'send_email_report' => true,
+ 'email' => 'admin@example.com'
+ )
+ );
+ update_option( 'feedzy-settings', $settings );
+
+ // Create new logger instance to test initialization
+ $this->reset_logger_singleton();
+ $logger = Feedzy_Rss_Feeds_Log::get_instance();
+
+ $this->assertEquals( Feedzy_Rss_Feeds_Log::DEBUG, $logger->level_threshold );
+ $this->assertTrue( $logger->can_send_email );
+ $this->assertEquals( 'admin@example.com', $logger->to_email );
+ }
+
+ /**
+ * Test invalid settings values.
+ */
+ public function test_invalid_settings_values() {
+ $settings = array(
+ 'logs' => array(
+ 'level' => 'invalid_level',
+ 'email' => 'invalid-email'
+ )
+ );
+ update_option( 'feedzy-settings', $settings );
+
+ $this->reset_logger_singleton();
+ $logger = Feedzy_Rss_Feeds_Log::get_instance();
+
+ // Should fallback to default ERROR level
+ $this->assertEquals( Feedzy_Rss_Feeds_Log::ERROR, $logger->level_threshold );
+ // Invalid email should be sanitized to empty
+ $this->assertEmpty( $logger->to_email );
+ }
+
+ /**
+ * Test log cleaning based on file size and age.
+ */
+ public function test_should_clean_logs() {
+ // Create a log file
+ Feedzy_Rss_Feeds_Log::error( 'Test error' );
+ $this->assertTrue( $this->logger->log_file_exists() );
+
+ $file_path = $this->logger->get_log_file_path();
+
+ // Simulate an old log file by setting its modification time far in the past
+ $this->assertTrue( file_exists( $file_path ), 'Log file should exist before aging it' );
+ $old_time = time() - ( defined( 'YEAR_IN_SECONDS' ) ? YEAR_IN_SECONDS : 365 * 24 * 60 * 60 );
+ $this->assertTrue( touch( $file_path, $old_time ), 'Should be able to update mtime to an old timestamp' );
+ clearstatcache( true, $file_path );
+
+ // Old file should be cleaned
+ $this->logger->should_clean_logs();
+ $this->assertFalse( $this->logger->log_file_exists(), 'Old log file should be cleaned (deleted)' );
+
+ // Create a fresh small log again
+ Feedzy_Rss_Feeds_Log::error( 'Recent small error' );
+ $this->assertTrue( $this->logger->log_file_exists(), 'Recent log file should exist' );
+
+ // File is small and recent, should not be cleaned
+ $this->logger->should_clean_logs();
+ $this->assertTrue( $this->logger->log_file_exists(), 'Recent small log should not be cleaned' );
+
+ // Enlarge the log to exceed the max size and ensure it gets cleaned
+ $fp = fopen( $file_path, 'r+' );
+ $this->assertNotFalse( $fp, 'Should be able to open log file for resizing' );
+ $target_size = Feedzy_Rss_Feeds_Log::DEFAULT_MAX_FILE_SIZE + 1;
+ $this->assertTrue( ftruncate( $fp, $target_size ), 'Should be able to enlarge log file' );
+ fclose( $fp );
+ clearstatcache( true, $file_path );
+ $this->assertGreaterThan( Feedzy_Rss_Feeds_Log::DEFAULT_MAX_FILE_SIZE, filesize( $file_path ), 'Log size should exceed max size' );
+
+ $this->logger->should_clean_logs();
+ $this->assertFalse( $this->logger->log_file_exists(), 'Oversized log file should be cleaned (deleted)' );
+ }
+
+ /**
+ * Test JSON formatting of log entries.
+ */
+ public function test_json_log_format() {
+ $this->logger->level_threshold = Feedzy_Rss_Feeds_Log::DEBUG;
+
+ $context = array(
+ 'unicode' => 'Test with émojis 🚀',
+ 'special_chars' => 'Test with "quotes" and \backslashes\\'
+ );
+
+ Feedzy_Rss_Feeds_Log::debug( 'Test message', $context );
+
+ $raw_content = $this->logger->get_all_logs_raw();
+ $this->assertIsType( 'string', $raw_content );
+
+ // Should be valid JSON
+ $log_entry = json_decode( trim( $raw_content ), true );
+ $this->assertNotNull( $log_entry );
+ $this->assertEquals( 'Test with émojis 🚀', $log_entry['context']['unicode'] );
+ }
+}