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(); + +?> +
+
+ +
+ + + + $label ) : ?> + $log_type, + ), + $all_filter_url + ); + $is_selected = $log_type === $logs_type_filter; + ?> + + + + +
+
+
+ %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/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'] ); + } +}