diff --git a/includes/admin/class-admin-columns.php b/includes/admin/class-admin-columns.php index fa85579..42028c0 100644 --- a/includes/admin/class-admin-columns.php +++ b/includes/admin/class-admin-columns.php @@ -1,8 +1,6 @@ 'ID', ); + // Only add Product column for wzkb_category taxonomy. + $screen = get_current_screen(); + if ( isset( $screen->taxonomy ) && 'wzkb_category' === $screen->taxonomy ) { + $new_columns['product'] = __( 'Product', 'knowledgebase' ); + } + return array_merge( $columns, $new_columns ); } /** - * Add taxonomy ID to the admin column. + * Make the Product column sortable. + * + * @since 3.0.0 + * + * @param array $columns Array of sortable columns. + * @return array Modified array of sortable columns. + */ + public function tax_sortable_columns( $columns ) { + $columns['product'] = 'product'; + return $columns; + } + + /** + * Add taxonomy ID and Product to the admin column. * * @since 2.3.0 * @@ -67,6 +90,156 @@ public static function tax_columns( $columns ) { * @return int|string */ public static function tax_id( $value, $name, $id ) { - return 'tax_id' === $name ? $id : $value; + if ( 'tax_id' === $name ) { + return $id; + } + if ( 'product' === $name ) { + // Get linked product for this section. + $product_id = get_term_meta( $id, 'product_id', true ); + if ( $product_id ) { + $product = get_term( $product_id, 'wzkb_product' ); + if ( $product && ! is_wp_error( $product ) ) { + return sprintf( + '%s', + esc_url( admin_url( 'edit.php?post_type=wz_knowledgebase&wzkb_product=' . $product->slug ) ), + esc_html( $product->name ) + ); + } + } + return '—'; // Em dash if not linked. + } + return $value; + } + + /** + * Sort wzkb_category terms by wzkb_product name. + * + * @since 3.0.0 + * + * @param array $pieces Array of query SQL clauses. + * @param array $taxonomies Array of taxonomy names. + * @return array Modified clauses. + */ + public function sort_terms_by_product( $pieces, $taxonomies ) { + global $wpdb; + + // Only run for wzkb_category in admin. + if ( ! is_admin() || ! in_array( 'wzkb_category', $taxonomies, true ) ) { + return $pieces; + } + + // Check if sorting by product. + $orderby = isset( $_GET['orderby'] ) ? sanitize_text_field( wp_unslash( $_GET['orderby'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( 'product' !== $orderby ) { + return $pieces; + } + + // Get sort order. + $order = isset( $_GET['order'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ) ) ) : 'ASC'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $order = in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'ASC'; + + // Join with termmeta to get product_id. + $pieces['join'] .= " LEFT JOIN {$wpdb->termmeta} AS tm ON t.term_id = tm.term_id AND tm.meta_key = 'product_id'"; + + // Join with terms and term_taxonomy to get wzkb_product name. + $pieces['join'] .= " LEFT JOIN {$wpdb->terms} AS pt ON tm.meta_value = pt.term_id"; + $pieces['join'] .= " LEFT JOIN {$wpdb->term_taxonomy} AS ptt ON pt.term_id = ptt.term_id AND ptt.taxonomy = 'wzkb_product'"; + + // Set the ORDER BY clause with the "ORDER BY" prefix. + $pieces['orderby'] = "ORDER BY COALESCE(pt.name, '') $order, t.name $order"; + + // Prevent WordPress from appending the order. + $pieces['order'] = ''; + + return $pieces; + } + + /** + * Add product filter dropdown to Knowledgebase admin screen. + * + * @since 3.0.0 + */ + public function add_product_filter_dropdown() { + global $pagenow; + + // Only run on the edit.php page for wz_knowledgebase post type. + if ( 'edit.php' !== $pagenow || ! isset( $_GET['post_type'] ) || 'wz_knowledgebase' !== $_GET['post_type'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + // Get all wzkb_product terms. + $terms = get_terms( + array( + 'taxonomy' => 'wzkb_product', + 'hide_empty' => false, + ) + ); + + if ( empty( $terms ) || is_wp_error( $terms ) ) { + return; + } + + // Get the currently selected product filter. + $selected = isset( $_GET['wzkb_product'] ) ? sanitize_text_field( wp_unslash( $_GET['wzkb_product'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + // Output the dropdown. + ?> + + is_main_query() ) { + return; + } + + $post_type = isset( $_GET['post_type'] ) ? sanitize_text_field( wp_unslash( $_GET['post_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( 'wz_knowledgebase' !== $post_type ) { + return; + } + + // Get the product filter value. + $product = isset( $_GET['wzkb_product'] ) ? sanitize_text_field( wp_unslash( $_GET['wzkb_product'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( empty( $product ) ) { + return; + } + + // Ensure the taxonomy exists. + if ( ! taxonomy_exists( 'wzkb_product' ) ) { + return; + } + + // Add the tax query. + $tax_query = array( + array( + 'taxonomy' => 'wzkb_product', + 'field' => is_numeric( $product ) ? 'term_id' : 'slug', + 'terms' => is_numeric( $product ) ? absint( $product ) : $product, + ), + ); + + $query->set( 'tax_query', $tax_query ); } } diff --git a/includes/admin/class-admin.php b/includes/admin/class-admin.php index 5098217..29ef4c6 100644 --- a/includes/admin/class-admin.php +++ b/includes/admin/class-admin.php @@ -10,7 +10,6 @@ namespace WebberZone\Knowledge_Base\Admin; use WebberZone\Knowledge_Base\Util\Cache; -use WebberZone\Knowledge_Base\Admin\Activator; // If this file is called directly, abort. if ( ! defined( 'WPINC' ) ) { @@ -60,6 +59,15 @@ class Admin { */ public $admin_columns; + /** + * Product Migrator class. + * + * @since 3.0.0 + * + * @var object Product Migrator class. + */ + public $product_migrator; + /** * Main constructor class. * @@ -69,10 +77,11 @@ public function __construct() { $this->hooks(); // Initialise admin classes. - $this->settings = new Settings\Settings(); - $this->activator = new Activator(); - $this->cache = new Cache(); - $this->admin_columns = new Admin_Columns(); + $this->settings = new Settings(); + $this->activator = new Activator(); + $this->cache = new Cache(); + $this->admin_columns = new Admin_Columns(); + $this->product_migrator = new Product_Migrator(); } /** @@ -98,28 +107,36 @@ public function admin_enqueue_scripts() { $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; wp_register_script( - 'wzkb-admin-js', - plugins_url( 'js/admin-scripts' . $minimize . '.js', __FILE__ ), + 'wzkb-admin', + plugins_url( "js/admin-scripts{$minimize}.js", __FILE__ ), array( 'jquery', 'jquery-ui-tabs' ), WZKB_VERSION, true ); wp_localize_script( - 'wzkb-admin-js', - 'wzkb_admin', + 'wzkb-admin', + 'WZKBAdminData', array( - 'nonce' => wp_create_nonce( 'wzkb_admin_nonce' ), + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'security' => wp_create_nonce( 'wzkb-admin' ), + 'strings' => array( + 'confirm_message' => esc_html__( 'Are you sure you want to clear the cache?', 'knowledgebase' ), + 'success_message' => esc_html__( 'Cache cleared successfully!', 'knowledgebase' ), + 'fail_message' => esc_html__( 'Failed to clear cache. Please try again.', 'knowledgebase' ), + 'request_fail_message' => esc_html__( 'Request failed: ', 'knowledgebase' ), + ), ) ); + wp_register_style( - 'wzkb-admin-ui-css', - plugins_url( 'css/admin' . $minimize . '.css', __FILE__ ), + 'wzkb-admin-ui', + plugins_url( "css/admin{$minimize}.css", __FILE__ ), array(), WZKB_VERSION ); if ( isset( $_GET['post_type'] ) && 'wz_knowledgebase' === $_GET['post_type'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended - wp_enqueue_style( 'wzkb-admin-ui-css' ); + wp_enqueue_style( 'wzkb-admin-ui' ); } } @@ -129,9 +146,10 @@ public function admin_enqueue_scripts() { * @since 2.3.0 */ public function admin_notices() { - $kbslug = \wzkb_get_option( 'kb_slug', 'not-set-random-string' ); - $catslug = \wzkb_get_option( 'category_slug', 'not-set-random-string' ); - $tagslug = \wzkb_get_option( 'tag_slug', 'not-set-random-string' ); + $kb_slug = \wzkb_get_option( 'kb_slug', 'not-set-random-string' ); + $product_slug = \wzkb_get_option( 'product_slug', 'not-set-random-string' ); + $cat_slug = \wzkb_get_option( 'category_slug', 'not-set-random-string' ); + $tag_slug = \wzkb_get_option( 'tag_slug', 'not-set-random-string' ); // Only add the notice if the user is an admin. if ( ! current_user_can( 'manage_options' ) ) { @@ -139,13 +157,16 @@ public function admin_notices() { } // Only add the notice if the settings cannot be found. - if ( 'not-set-random-string' === $kbslug || 'not-set-random-string' === $catslug || 'not-set-random-string' === $tagslug ) { + if ( 'not-set-random-string' === $kb_slug || 'not-set-random-string' === $product_slug || 'not-set-random-string' === $cat_slug || 'not-set-random-string' === $tag_slug ) { ?>
admin page to update and save the options.', 'knowledgebase' ), esc_url( admin_url( 'edit.php?post_type=wz_knowledgebase&page=wzkb-settings' ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + printf( + /* translators: 1. Link to admin page. */ + esc_html__( 'Knowledge Base settings for the slug have not been registered. Please visit the admin page to update and save the options.', 'knowledgebase' ), + esc_url( admin_url( 'edit.php?post_type=wz_knowledgebase&page=wzkb-settings' ) ) + ); ?>
+ ' . esc_html__( 'New Multi-Products Mode available!', 'knowledgebase' ) . ' + ' . esc_html__( 'Organize your knowledge base by product with our new Multi-Products mode! You can migrate your existing content using the migration wizard. If you don\'t want to use this feature, you can dismiss this notice by saving the settings page.', 'knowledgebase' ) . ' +
++ ' . + esc_html__( 'Enable Multi-Products', 'knowledgebase' ) . + ''; + + // Only show Migration Wizard link if migration is not completed yet. + if ( ! $migration_complete ) { + echo ' ' . + esc_html__( 'Migration Wizard', 'knowledgebase' ) . + ''; + } + + echo '
+ '; + } + + /** + * Register the migration wizard admin page (submenu under KB, but no visible link unless you add one). + */ + public function register_migration_wizard_page() { + $migration_complete = get_option( 'wzkb_product_migration_complete', false ); + if ( $migration_complete ) { + return; + } + $this->menu_page = add_submenu_page( + 'edit.php?post_type=wz_knowledgebase', + esc_html__( 'Product Migration', 'knowledgebase' ), + esc_html__( 'Product Migration', 'knowledgebase' ), + 'manage_options', + 'wzkb-product-migration', + array( $this, 'render_migration_wizard' ), + ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); + } + + /** + * Enqueue scripts for migration wizard. + * + * @param string $hook The current admin screen hook. + */ + public function enqueue_scripts( $hook ) { + if ( $this->menu_page !== $hook ) { + return; + } + $minimize = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min'; + + wp_enqueue_script( + 'wzkb-product-migrator', + plugins_url( "js/product-migrator{$minimize}.js", __FILE__ ), + array( 'jquery' ), + WZKB_VERSION, + true + ); + wp_localize_script( + 'wzkb-product-migrator', + 'wzkbProductMigrator', + array( + 'nonce' => wp_create_nonce( 'wzkb_product_migration' ), + 'strings' => array( + 'migration_failed' => esc_html__( 'Migration failed', 'knowledgebase' ), + 'unknown_error' => esc_html__( 'Unknown error', 'knowledgebase' ), + 'migration_complete' => esc_html__( 'Migration complete!', 'knowledgebase' ), + ), + ) + ); + } + + /** + * Render the migration wizard screen and handle migration logic. + */ + public function render_migration_wizard() { + + $migration_complete = get_option( 'wzkb_product_migration_complete', false ); + + // Verify user capabilities. + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'knowledgebase' ) ); + } + + ob_start(); + ?> ++ +
' . esc_html( $message ) . '
' . esc_html( $message ) . '
' . esc_html( $message ) . '
[' + timestamp + '] ' + html + '
'); + // Scroll to bottom + $log.scrollTop($log[0].scrollHeight); + // Force render + setTimeout(function () { $log[0].offsetHeight; }, 0); + } + } + + /** + * Proceeds to the next migration step. + */ + function nextMigrationStep() { + var stateToSend = JSON.parse(JSON.stringify(migrationState)); + // Track consecutive identical indices + if (stateToSend.current_top_section_index !== undefined && stateToSend.current_top_section_index === lastTopSectionIndex) { + if (!window.wzkbMigrationLoopCount) { + window.wzkbMigrationLoopCount = 0; + } + window.wzkbMigrationLoopCount++; + if (window.wzkbMigrationLoopCount >= 5) { + console.warn('Warning: current_top_section_index has not changed after 5 attempts:', stateToSend.current_top_section_index); + window.wzkbMigrationLoopCount = 0; // Reset to avoid spamming. + } + } else { + window.wzkbMigrationLoopCount = 0; // Reset on change. + } + lastTopSectionIndex = stateToSend.current_top_section_index; + + $.ajax({ + url: ajaxurl, + method: 'POST', + data: { + action: 'wzkb_product_migration_batch', + _nonce: wzkbProductMigrator.nonce, + step: step, + dry_run: $('#wzkb-dry-run').is(':checked') ? 1 : 0, + state: stateToSend + }, + success: function (response) { + if (!response.success) { + showError(response.data || wzkbProductMigrator.strings.unknown_error); + updateProgressBar(100, wzkbProductMigrator.strings.migration_failed); + $('#wzkb-migration-start').prop('disabled', false); + return; + } + + if (response.data.log && Array.isArray(response.data.log)) { + response.data.log.forEach(function (line) { appendToLog(line); }); + } + + if (response.data.message) { + appendToLog(response.data.message); + } + + if (response.data.progress) { + updateProgressBar(response.data.progress, response.data.message); + } + + if (response.data.errors && response.data.errors.length) { + response.data.errors.forEach(showError); + } + + if (response.data.dry_run && response.data.done) { + updateProgressBar(100, response.data.message); + $('#wzkb-migration-start').prop('disabled', false); + return; + } + + if (response.data.done) { + var completionMessage = response.data.message || wzkbProductMigrator.strings.migration_complete; + updateProgressBar(100, completionMessage); + $('#wzkb-migration-start').prop('disabled', false); + return; + } + + step = response.data.next_step; + migrationState = JSON.parse(JSON.stringify(response.data.state)); + + setTimeout(nextMigrationStep, 100); + }, + error: function (xhr, status, error) { + console.error('AJAX error:', status, error); + showError(wzkbProductMigrator.strings.ajax_error + ': ' + error); + updateProgressBar(100, wzkbProductMigrator.strings.migration_failed); + $('#wzkb-migration-start').prop('disabled', false); + } + }); + } + + $(document).ready(function () { + $('#wzkb-migration-start').prop('disabled', true); + $('#wzkb-backup-confirm').on('change', function () { + $('#wzkb-migration-start').prop('disabled', !this.checked); + }); + + $('#wzkb-migration-start').on('click', function (e) { + e.preventDefault(); + step = 0; + migrationState = {}; + lastTopSectionIndex = -1; + $('#wzkb-migration-progress-bar').css('width', '0%'); + $('#wzkb-migration-progress-text').html(''); + $('#wzkb-migration-errors').empty(); + $('#wzkb-migration-log').empty(); + $(this).prop('disabled', true); + updateProgressBar(0, wzkbProductMigrator.strings.starting_migration); + + nextMigrationStep(); + }); + + // Copy log to clipboard functionality + $('#wzkb-copy-log').on('click', function () { + var $button = $(this); + var $log = $('#wzkb-migration-log'); + var logText = ''; + + // Extract text from each paragraph with line breaks + $log.find('p').each(function () { + logText += $(this).text() + '\n\n'; + }); + + // Trim extra line breaks at the end + logText = logText.trim(); + + if (!logText) { + $button.html(' Empty Log'); + setTimeout(function () { + $button.html(' Copy Log'); + }, 2000); + return; + } + + // Modern approach using Clipboard API + navigator.clipboard.writeText(logText).then(function () { + $button.html(' Copied!'); + setTimeout(function () { + $button.html(' Copy Log'); + }, 2000); + }).catch(function (err) { + console.error('Failed to copy: ', err); + $button.html(' Failed!'); + setTimeout(function () { + $button.html(' Copy Log'); + }, 2000); + }); + }); + }); + +})(jQuery); \ No newline at end of file diff --git a/includes/admin/js/product-migrator.min.js b/includes/admin/js/product-migrator.min.js new file mode 100644 index 0000000..74ab410 --- /dev/null +++ b/includes/admin/js/product-migrator.min.js @@ -0,0 +1 @@ +!function(t){"use strict";var o=0,i={},a=-1;function r(o,i){t("#wzkb-migration-progress-bar").css("width",o+"%"),t("#wzkb-migration-progress-bar").html(o+"%"),t("#wzkb-migration-progress-text").html(i)}function n(o){t("#wzkb-migration-errors").append("["+i+"] "+o+"
"),a.scrollTop(a[0].scrollHeight),setTimeout((function(){a[0].offsetHeight}),0)}}function e(){var d=JSON.parse(JSON.stringify(i));void 0!==d.current_top_section_index&&d.current_top_section_index===a?(window.wzkbMigrationLoopCount||(window.wzkbMigrationLoopCount=0),window.wzkbMigrationLoopCount++,window.wzkbMigrationLoopCount>=5&&(window.wzkbMigrationLoopCount=0)):window.wzkbMigrationLoopCount=0,a=d.current_top_section_index,t.ajax({url:ajaxurl,method:"POST",data:{action:"wzkb_product_migration_batch",_nonce:wzkbProductMigrator.nonce,step:o,dry_run:t("#wzkb-dry-run").is(":checked")?1:0,state:d},success:function(a){return a.success?(a.data.log&&Array.isArray(a.data.log)&&a.data.log.forEach((function(t){s(t)})),a.data.message&&s(a.data.message),a.data.progress&&r(a.data.progress,a.data.message),a.data.errors&&a.data.errors.length&&a.data.errors.forEach(n),a.data.dry_run&&a.data.done?(r(100,a.data.message),void t("#wzkb-migration-start").prop("disabled",!1)):a.data.done?(r(100,a.data.message||wzkbProductMigrator.strings.migration_complete),void t("#wzkb-migration-start").prop("disabled",!1)):(o=a.data.next_step,i=JSON.parse(JSON.stringify(a.data.state)),void setTimeout(e,100))):(n(a.data||wzkbProductMigrator.strings.unknown_error),r(100,wzkbProductMigrator.strings.migration_failed),void t("#wzkb-migration-start").prop("disabled",!1))},error:function(o,i,a){n(wzkbProductMigrator.strings.ajax_error+": "+a),r(100,wzkbProductMigrator.strings.migration_failed),t("#wzkb-migration-start").prop("disabled",!1)}})}t(document).ready((function(){t("#wzkb-migration-start").prop("disabled",!0),t("#wzkb-backup-confirm").on("change",(function(){t("#wzkb-migration-start").prop("disabled",!this.checked)})),t("#wzkb-migration-start").on("click",(function(n){n.preventDefault(),o=0,i={},a=-1,t("#wzkb-migration-progress-bar").css("width","0%"),t("#wzkb-migration-progress-text").html(""),t("#wzkb-migration-errors").empty(),t("#wzkb-migration-log").empty(),t(this).prop("disabled",!0),r(0,wzkbProductMigrator.strings.starting_migration),e()})),t("#wzkb-copy-log").on("click",(function(){var o=t(this),i=t("#wzkb-migration-log"),a="";if(i.find("p").each((function(){a+=t(this).text()+"\n\n"})),!(a=a.trim()))return o.html(' Empty Log'),void setTimeout((function(){o.html(' Copy Log')}),2e3);navigator.clipboard.writeText(a).then((function(){o.html(' Copied!'),setTimeout((function(){o.html(' Copy Log')}),2e3)})).catch((function(t){o.html(' Failed!'),setTimeout((function(){o.html(' Copy Log')}),2e3)}))}))}))}(jQuery); \ No newline at end of file diff --git a/includes/admin/settings/class-metabox-api.php b/includes/admin/settings/class-metabox-api.php index c635466..f5c1b63 100644 --- a/includes/admin/settings/class-metabox-api.php +++ b/includes/admin/settings/class-metabox-api.php @@ -16,8 +16,6 @@ /** * ATA Metabox class to register the metabox for ata_snippets post type. - * - * @since 3.5.0 */ #[\AllowDynamicProperties] class Metabox_API { @@ -126,14 +124,14 @@ public function add_meta_boxes() { */ public function admin_enqueue_scripts( $hook ) { if ( in_array( $hook, array( 'post.php', 'post-new.php' ), true ) || get_current_screen()->post_type === $this->post_type ) { - self::enqueue_scripts_styles(); + $this->enqueue_scripts_styles(); } } /** * Enqueues all scripts, styles, settings, and templates necessary to use the Settings API. */ - public static function enqueue_scripts_styles() { + public function enqueue_scripts_styles() { $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; @@ -156,10 +154,10 @@ public static function enqueue_scripts_styles() { ); // Enqueue WZ Admin JS. - wp_enqueue_script( 'wz-admin-js' ); - wp_enqueue_script( 'wz-codemirror-js' ); - wp_enqueue_script( 'wz-taxonomy-suggest-js' ); - wp_enqueue_script( 'wz-media-selector-js' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-admin' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-codemirror' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-taxonomy-suggest' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-media-selector' ); } /** @@ -190,7 +188,12 @@ public function save( $post_id ) { return; } - $settings_sanitize = new Settings_Sanitize(); + $settings_sanitize = new Settings_Sanitize( + array( + 'settings_key' => $this->settings_key, + 'prefix' => $this->prefix, + ) + ); $posted = $_POST[ $this->settings_key ]; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash @@ -298,7 +301,7 @@ public function html( $post ) { echo ''; /** - * Action triggered when displaying Better Search meta box. + * Action triggered when displaying the metabox. * * @param object $post Post object. */ diff --git a/includes/admin/settings/class-settings-api.php b/includes/admin/settings/class-settings-api.php index 0402f7c..f28d615 100644 --- a/includes/admin/settings/class-settings-api.php +++ b/includes/admin/settings/class-settings-api.php @@ -18,9 +18,8 @@ /** * Settings API wrapper class * - * @version 2.5.2 + * @version 2.7.0 */ -#[\AllowDynamicProperties] class Settings_API { /** @@ -28,7 +27,7 @@ class Settings_API { * * @var string */ - const VERSION = '2.5.2'; + public const VERSION = '2.7.0'; /** * Settings Key. @@ -180,22 +179,6 @@ public function hooks() { add_action( 'admin_init', array( $this, 'admin_init' ) ); add_filter( 'admin_footer_text', array( $this, 'admin_footer_text' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) ); - add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) ); - } - - /** - * Filters the CSS classes for the body tag in the admin. - * - * @param string $classes Space-separated list of CSS classes. - * @return string Space-separated list of CSS classes. - */ - public function admin_body_class( $classes ) { - $current_screen = get_current_screen(); - - if ( in_array( $current_screen->id, $this->menu_pages, true ) ) { - $classes .= ' ' . $this->prefix . '-dashboard-page'; - } - return $classes; } /** @@ -335,6 +318,8 @@ public function set_upgraded_settings( $upgraded_settings = array() ) { * Add a menu page to the WordPress admin area. * * @param array $menu Array of settings for the menu page. + * + * @return string|false The resulting page’s hook_suffix, or false if the user does not have the capability required. */ public function add_custom_menu_page( $menu ) { $defaults = array( @@ -357,6 +342,8 @@ public function add_custom_menu_page( $menu ) { ); $menu = wp_parse_args( $menu, $defaults ); + $menu_page = false; + switch ( $menu['type'] ) { case 'submenu': $menu_page = add_submenu_page( @@ -381,8 +368,6 @@ public function add_custom_menu_page( $menu ) { case 'pages': case 'comments': $f = 'add_' . $menu['type'] . '_page'; - - $menu_page = null; if ( function_exists( $f ) ) { $menu_page = $f( $menu['page_title'], @@ -478,43 +463,71 @@ public function admin_enqueue_scripts( $hook ) { // Settings API scripts. wp_register_script( - 'wz-admin-js', + 'wz-' . $this->prefix . '-admin', plugins_url( 'js/settings-admin-scripts' . $minimize . '.js', __FILE__ ), array( 'jquery' ), self::VERSION, true ); wp_register_script( - 'wz-codemirror-js', + 'wz-' . $this->prefix . '-codemirror', plugins_url( 'js/apply-codemirror' . $minimize . '.js', __FILE__ ), array( 'jquery' ), self::VERSION, true ); wp_register_script( - 'wz-taxonomy-suggest-js', + 'wz-' . $this->prefix . '-taxonomy-suggest', plugins_url( 'js/taxonomy-suggest' . $minimize . '.js', __FILE__ ), array( 'jquery' ), self::VERSION, true ); wp_register_script( - 'wz-media-selector-js', + 'wz-' . $this->prefix . '-media-selector', plugins_url( 'js/media-selector' . $minimize . '.js', __FILE__ ), array( 'jquery' ), self::VERSION, true ); + wp_register_style( + 'wz-' . $this->prefix . '-admin', + plugins_url( 'css/admin-style' . $minimize . '.css', __FILE__ ), + array(), + self::VERSION + ); + + // Top Select scripts and styles. + wp_register_style( + 'wz-' . $this->prefix . '-tom-select', + 'https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.min.css', + array(), + '2.3.1' + ); + wp_register_script( + 'wz-' . $this->prefix . '-tom-select', + 'https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js', + array( 'jquery' ), + '2.3.1', + true + ); + wp_register_script( + 'wz-' . $this->prefix . '-tom-select-init', + plugin_dir_url( __FILE__ ) . 'js/tom-select-init' . $minimize . '.js', + array( 'jquery', 'wz-' . $this->prefix . '-tom-select' ), + self::VERSION, + true + ); if ( $hook === $this->settings_page ) { - self::enqueue_scripts_styles(); + $this->enqueue_scripts_styles(); } } /** * Enqueues all scripts, styles, settings, and templates necessary to use the Settings API. */ - public static function enqueue_scripts_styles() { + public function enqueue_scripts_styles() { wp_enqueue_style( 'wp-color-picker' ); @@ -534,9 +547,28 @@ public static function enqueue_scripts_styles() { ) ); - wp_enqueue_script( 'wz-admin-js' ); - wp_enqueue_script( 'wz-codemirror-js' ); - wp_enqueue_script( 'wz-taxonomy-suggest-js' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-admin' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-codemirror' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-taxonomy-suggest' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-media-selector' ); + + // Enqueue Tom Select. + wp_enqueue_style( 'wz-' . $this->prefix . '-tom-select' ); + wp_enqueue_script( 'wz-' . $this->prefix . '-tom-select' ); + + // Localize Tom Select settings. + wp_localize_script( + 'wz-' . $this->prefix . '-tom-select-init', + 'WZTomSelectSettings', + array( + 'action' => $this->prefix . '_kit_search', + 'nonce' => wp_create_nonce( $this->prefix . '_kit_search' ), + 'endpoint' => 'forms', + ) + ); + wp_enqueue_script( 'wz-' . $this->prefix . '-tom-select-init' ); + + wp_enqueue_style( 'wz-' . $this->prefix . '-admin' ); } /** @@ -574,26 +606,7 @@ public function admin_init() { foreach ( $settings as $setting ) { - $args = wp_parse_args( - $setting, - array( - 'section' => $section, - 'id' => null, - 'name' => '', - 'desc' => '', - 'type' => null, - 'default' => '', - 'options' => '', - 'max' => null, - 'min' => null, - 'step' => null, - 'size' => null, - 'field_class' => '', - 'field_attributes' => '', - 'placeholder' => '', - 'pro' => false, - ) - ); + $args = self::parse_field_args( $setting, $section ); $id = $args['id']; $name = $args['name']; @@ -666,8 +679,12 @@ public function settings_defaults() { $options[ $option['id'] ] = 0; } // If an option is set. - if ( in_array( $option['type'], array( 'textarea', 'css', 'html', 'text', 'url', 'csv', 'color', 'numbercsv', 'postids', 'posttypes', 'number', 'wysiwyg', 'file', 'password' ), true ) && isset( $option['options'] ) ) { - $options[ $option['id'] ] = $option['options']; + if ( in_array( $option['type'], array( 'textarea', 'css', 'html', 'text', 'url', 'csv', 'color', 'numbercsv', 'postids', 'posttypes', 'number', 'wysiwyg', 'file', 'password' ), true ) ) { + if ( isset( $option['default'] ) ) { + $options[ $option['id'] ] = $option['default']; + } elseif ( isset( $option['options'] ) ) { + $options[ $option['id'] ] = $option['options']; + } } if ( in_array( $option['type'], array( 'multicheck', 'radio', 'select', 'radiodesc', 'thumbsizes' ), true ) && isset( $option['default'] ) ) { $options[ $option['id'] ] = $option['default']; @@ -717,6 +734,58 @@ public function settings_reset() { delete_option( $this->settings_key ); } + /** + * Get sanitization callback for given Settings key. + * + * @param string $key Settings key. + * + * @return mixed Callback function or false if callback isn't found. + */ + public function get_sanitize_callback( $key = '' ) { + if ( empty( $key ) ) { + return false; + } + + $settings_sanitize = new Settings_Sanitize( + array( + 'settings_key' => $this->settings_key, + 'prefix' => $this->prefix, + ) + ); + + // Iterate over registered fields and see if we can find proper callback. + foreach ( $this->registered_settings as $section => $settings ) { + foreach ( $settings as $setting ) { + if ( $setting['id'] !== $key ) { + continue; + } + + // Return the callback name. + $sanitize_callback = false; + + if ( isset( $setting['sanitize_callback'] ) && is_callable( $setting['sanitize_callback'] ) ) { + $sanitize_callback = $setting['sanitize_callback']; + return $sanitize_callback; + } + + if ( is_callable( array( $settings_sanitize, 'sanitize_' . $setting['type'] . '_field' ) ) ) { + // For repeater fields, create a closure to pass the field configuration. + if ( 'repeater' === $setting['type'] ) { + return function ( $value ) use ( $settings_sanitize, $setting ) { + return $settings_sanitize->sanitize_repeater_field( $value, $setting ); + }; + } + $sanitize_callback = array( $settings_sanitize, 'sanitize_' . $setting['type'] . '_field' ); + return $sanitize_callback; + } + + return $sanitize_callback; + } + } + + return false; + } + /** * Sanitize the form data being submitted. * @@ -724,7 +793,6 @@ public function settings_reset() { * @return array Sanitized array */ public function settings_sanitize( $input ) { - // This should be set if a form is submitted, so let's save it in the $referrer variable. if ( empty( $_POST['_wp_http_referer'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return $input; @@ -765,7 +833,6 @@ public function settings_sanitize( $input ) { // Loop through each setting being saved and pass it through a sanitization filter. foreach ( $settings_types as $key => $type ) { - /** * Skip settings that are not really settings. * @@ -778,12 +845,18 @@ public function settings_sanitize( $input ) { } if ( array_key_exists( $key, $output ) ) { - $sanitize_callback = $this->get_sanitize_callback( $key ); // If callback is set, call it. if ( $sanitize_callback ) { - $output[ $key ] = call_user_func( $sanitize_callback, $output[ $key ] ); + // Pass the field configuration for repeater fields. + if ( 'repeater' === $type && isset( $this->registered_settings[ $key ] ) ) { + $output[ $key ] = call_user_func( $sanitize_callback, $output[ $key ], $this->registered_settings[ $key ] ); + } elseif ( 'sensitive' === $type ) { + $output[ $key ] = call_user_func( $sanitize_callback, $output[ $key ], $key ); + } else { + $output[ $key ] = call_user_func( $sanitize_callback, $output[ $key ] ); + } continue; } } @@ -805,52 +878,11 @@ public function settings_sanitize( $input ) { * Filter the settings array before it is returned. * * @param array $output Settings array. - * @param array $input Input settings array. + * @param array $input Input settings array. */ return apply_filters( $this->prefix . '_settings_sanitize', $output, $input ); } - /** - * Get sanitization callback for given Settings key. - * - * @param string $key Settings key. - * - * @return mixed Callback function or false if callback isn't found. - */ - public function get_sanitize_callback( $key = '' ) { - if ( empty( $key ) ) { - return false; - } - - $settings_sanitize = new Settings_Sanitize(); - - // Iterate over registered fields and see if we can find proper callback. - foreach ( $this->registered_settings as $section => $settings ) { - foreach ( $settings as $setting ) { - if ( $setting['id'] !== $key ) { - continue; - } - - // Return the callback name. - $sanitize_callback = false; - - if ( isset( $setting['sanitize_callback'] ) && is_callable( $setting['sanitize_callback'] ) ) { - $sanitize_callback = $setting['sanitize_callback']; - return $sanitize_callback; - } - - if ( is_callable( array( $settings_sanitize, 'sanitize_' . $setting['type'] . '_field' ) ) ) { - $sanitize_callback = array( $settings_sanitize, 'sanitize_' . $setting['type'] . '_field' ); - return $sanitize_callback; - } - - return $sanitize_callback; - } - } - - return false; - } - /** * Render the settings page. */ @@ -858,7 +890,6 @@ public function plugin_settings() { ob_start(); ?>' . wp_kses_post( $args['desc'] ) . '
'; - } else { - $desc = ''; - } + $desc = ! empty( $args['desc'] ) ? '' . wp_kses_post( $args['desc'] ) . '
' : ''; /** * After Settings Output filter @@ -86,15 +81,16 @@ public function get_field_description( $args ) { * @param array $args Arguments array. */ $desc = apply_filters( $this->prefix . '_setting_field_description', $desc, $args ); + return $desc; } /** * Get the value of a settings field. * - * @param string $option Settings field name. - * @param string $default_value Default text if it's not found. - * @return string + * @param string $option Settings field name. + * @param mixed $default_value Default value if option is not found. + * @return mixed */ public function get_option( $option, $default_value = '' ) { @@ -107,6 +103,39 @@ public function get_option( $option, $default_value = '' ) { return $default_value; } + /** + * Get field ID and name attributes. + * + * @param array $args Field arguments. + * @return array Array containing field_id and field_name. + */ + protected function get_field_attributes( $args ) { + $id = sanitize_key( $args['id'] ); + if ( isset( $args['_repeater_id'] ) && isset( $args['_index'] ) ) { + $field_id = sprintf( + '%s-%s-%s-fields-%s', + $this->settings_key, + $args['_repeater_id'], + $args['_index'], + $id + ); + $field_name = sprintf( + '%s[%s][%s][fields][%s]', + $this->settings_key, + $args['_repeater_id'], + $args['_index'], + $id + ); + } else { + $field_id = $this->settings_key . '-' . $id; + $field_name = $this->settings_key . '[' . $id . ']'; + } + + return array( + 'field_id' => $field_id, + 'field_name' => $field_name, + ); + } /** * Miscellaneous callback funcion * @@ -156,23 +185,25 @@ public function callback_descriptive_text( $args ) { * @param array $args Array of arguments. */ public function callback_text( $args ) { - $value = isset( $args['value'] ) ? $args['value'] : $this->get_option( $args['id'], $args['options'] ); $size = sanitize_html_class( isset( $args['size'] ) ? $args['size'] : 'regular' ); $class = sanitize_html_class( $args['field_class'] ); $placeholder = empty( $args['placeholder'] ) ? '' : ' placeholder="' . $args['placeholder'] . '"'; $disabled = ( ! empty( $args['disabled'] ) || $args['pro'] ) ? ' disabled="disabled"' : ''; $readonly = ( isset( $args['readonly'] ) && true === $args['readonly'] ) ? ' readonly="readonly"' : ''; - $attributes = $disabled . $readonly; + $required = ( isset( $args['required'] ) && true === $args['required'] ) ? ' required' : ''; + $attributes = $disabled . $readonly . $required; foreach ( (array) $args['field_attributes'] as $attribute => $val ) { $attributes .= sprintf( ' %1$s="%2$s"', $attribute, esc_attr( $val ) ); } + $field_attributes = $this->get_field_attributes( $args ); + $html = sprintf( - '', - $this->settings_key, - sanitize_key( $args['id'] ), + '', + $field_attributes['field_id'], + $field_attributes['field_name'], $class . ' ' . $size . '-text', esc_attr( stripslashes( $value ) ), $attributes, @@ -242,7 +273,8 @@ public function callback_textarea( $args ) { $placeholder = empty( $args['placeholder'] ) ? '' : ' placeholder="' . $args['placeholder'] . '"'; $disabled = ( ! empty( $args['disabled'] ) || $args['pro'] ) ? ' disabled="disabled"' : ''; $readonly = ( isset( $args['readonly'] ) && true === $args['readonly'] ) ? ' readonly="readonly"' : ''; - $attributes = $disabled . $readonly; + $required = ( isset( $args['required'] ) && true === $args['required'] ) ? ' required' : ''; + $attributes = $disabled . $readonly . $required; $html = sprintf( '', @@ -327,7 +359,11 @@ public function callback_multicheck( $args ) { $disabled = ( ! empty( $args['disabled'] ) || $args['pro'] ) ? ' disabled="disabled"' : ''; if ( ! empty( $args['options'] ) ) { - $html .= sprintf( '', $this->settings_key, sanitize_key( $args['id'] ) ); + $html .= sprintf( + '', + $this->settings_key, + sanitize_key( $args['id'] ) + ); foreach ( $args['options'] as $key => $option ) { if ( in_array( $key, $value_array, true ) ) { @@ -504,6 +540,9 @@ public function callback_number( $args ) { $size = isset( $args['size'] ) ? $args['size'] : 'regular'; $placeholder = empty( $args['placeholder'] ) ? '' : ' placeholder="' . esc_attr( $args['placeholder'] ) . '"'; $disabled = ( ! empty( $args['disabled'] ) || $args['pro'] ) ? ' disabled="disabled"' : ''; + $readonly = ( isset( $args['readonly'] ) && true === $args['readonly'] ) ? ' readonly="readonly"' : ''; + $required = ( isset( $args['required'] ) && true === $args['required'] ) ? ' required' : ''; + $attributes = $disabled . $readonly . $required; $html = sprintf( '', @@ -515,7 +554,7 @@ public function callback_number( $args ) { esc_attr( stripslashes( $value ) ), $placeholder, $this->settings_key, - $disabled + $attributes ); $html .= $this->get_field_description( $args ); @@ -532,24 +571,29 @@ public function callback_number( $args ) { * @return void */ public function callback_select( $args ) { - $value = isset( $args['value'] ) ? $args['value'] : $this->get_option( $args['id'], $args['default'] ); - $disabled = ( ! empty( $args['disabled'] ) || $args['pro'] ) ? ' disabled="disabled"' : ''; + $value = isset( $args['value'] ) ? $args['value'] : $this->get_option( $args['id'], $args['default'] ); + $class = sanitize_html_class( $args['field_class'] ); + $disabled = ( ! empty( $args['disabled'] ) || $args['pro'] ) ? ' disabled="disabled"' : ''; + $required = ( isset( $args['required'] ) && true === $args['required'] ) ? ' required' : ''; + $attributes = $disabled . $required; + + foreach ( (array) $args['field_attributes'] as $attribute => $val ) { + $attributes .= sprintf( ' %1$s="%2$s"', $attribute, esc_attr( $val ) ); + } if ( isset( $args['chosen'] ) ) { - $chosen = 'class="chosen"'; - } else { - $chosen = ''; + $class .= ' chosen'; } $html = sprintf( - '', + '', $this->settings_key, sanitize_key( $args['id'] ), - $chosen, - $disabled + $class, + $attributes ); - foreach ( $args['options'] as $option => $name ) { + foreach ( (array) $args['options'] as $option => $name ) { $html .= sprintf( '', sanitize_key( $option ), selected( $option, $value, false ), $name ); } @@ -675,7 +719,7 @@ public function callback_wysiwyg( $args ) { $value = isset( $args['value'] ) ? $args['value'] : $this->get_option( $args['id'], $args['options'] ); $size = isset( $args['size'] ) ? $args['size'] : '500px'; - echo '' . esc_html__( 'Invalid product ID.', 'knowledgebase' ) . '
'; + } + + $sections = self::fetch_terms( + 'wzkb_category', + array( + 'parent' => 0, + 'hide_empty' => $args['show_empty_sections'] ? 0 : 1, + ), + array( + array( + 'key' => 'product_id', + 'value' => $product_id, + 'compare' => '=', + ), + ) + ); + + $output = ''; + if ( ! empty( $sections ) && ! is_wp_error( $sections ) ) { + // Add section wrapper if category_level is 1. + $category_level = (int) \wzkb_get_option( 'category_level' ); + if ( 1 === $category_level ) { + $output .= '' . esc_html__( 'No sections found for this product.', 'knowledgebase' ) . '
'; + } + + return $output; + } + /** * Creates the knowledge base loop. * @@ -130,44 +225,64 @@ public static function get_knowledge_base( $args = array() ) { * @return string Formatted output. */ public static function get_knowledge_base_loop( $term_id, $level, $nested = true, $args = array() ) { - $divclasses = array( 'wzkb_section', 'wzkb-section-level-' . $level ); - $category_level = (int) \wzkb_get_option( 'category_level' ); + // Special handling for root level (term_id = 0) in single product mode. + if ( 0 === $term_id && 0 === $level ) { + $output = ''; - if ( ( $category_level - 1 ) === $level ) { - $divclasses[] = 'section group'; - } elseif ( $category_level === $level ) { - $divclasses[] = 'col span_1_of_' . $args['columns']; - } + // Get top-level sections. + $sections = self::fetch_terms( + 'wzkb_category', + array( + 'parent' => 0, + 'hide_empty' => $args['show_empty_sections'] ? 0 : 1, + ) + ); - /** - * Filter to add more classes if needed. - * - * @since 1.1.0 - * - * @param array $divclasses Current array of classes. - * @param int $level Level of the loop. - * @param int $term_id Term ID. - */ - $divclasses = apply_filters( 'wzkb_loop_div_class', $divclasses, $level, $term_id ); + if ( ! empty( $sections ) && ! is_wp_error( $sections ) ) { + // Add section wrapper if category_level is 2. + $category_level = (int) \wzkb_get_option( 'category_level' ); + if ( 2 === $category_level ) { + $output .= '