diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml index 39f0dc9..70a1d30 100644 --- a/.github/workflows/cs.yml +++ b/.github/workflows/cs.yml @@ -30,24 +30,15 @@ jobs: tools: cs2pr coverage: none - - name: 'Composer: set up PHPCS dependencies' - run: | - composer require --no-update squizlabs/php_codesniffer wp-coding-standards/wpcs phpcompatibility/phpcompatibility-wp dealerdirect/phpcodesniffer-composer-installer - composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true - # Install dependencies and handle caching in one go. # @link https://github.com/marketplace/actions/install-composer-dependencies - name: Install Composer dependencies uses: "ramsey/composer-install@v3" - - name: 'Run Composer Update' - run: | - composer update - # Check the code-style consistency of the PHP files. - name: Check PHP code style continue-on-error: true run: vendor/bin/phpcs --report-full --report-checkstyle=./phpcs-report.xml - name: Show PHPCS results in PR - run: cs2pr --graceful-warnings ./phpcs-report.xml \ No newline at end of file + run: cs2pr --graceful-warnings ./phpcs-report.xml diff --git a/.github/workflows/phpcompat.yml b/.github/workflows/phpcompat.yml new file mode 100644 index 0000000..df1634b --- /dev/null +++ b/.github/workflows/phpcompat.yml @@ -0,0 +1,54 @@ +name: PHP Compatibility + +on: + push: + paths-ignore: + - '**.md' + - '**.txt' + pull_request: + paths-ignore: + - '**.md' + - '**.txt' + workflow_dispatch: {} + +jobs: + phpcompat: + name: 'PHP Compatibility Check' + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + coverage: none + + # Create isolated environment for PHPCompatibility with PHPCS 4.x + - name: Setup PHPCompatibility + run: | + mkdir phpcompat-tools + cd phpcompat-tools + composer init --name="temp/phpcompat" --type=project --no-interaction + composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true + composer require --dev \ + squizlabs/php_codesniffer:"^4.0" \ + phpcompatibility/php-compatibility:"dev-develop" \ + dealerdirect/phpcodesniffer-composer-installer:"^1.0" \ + --no-interaction + + # Check PHP 7.4 through 8.5 compatibility + - name: Check PHP Compatibility (7.4-8.5) + run: | + cd phpcompat-tools + ./vendor/bin/phpcs -p \ + --standard=PHPCompatibility \ + --runtime-set testVersion 7.4-8.5 \ + --extensions=php \ + --ignore=*/vendor/*,*/node_modules/*,*/tests/*,*/phpunit/*,*/freemius/* \ + ../ || exit_code=$? + + # Exit with the phpcs exit code (if set) + exit ${exit_code:-0} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ce0e304..8d6dec0 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1,8 +1,6 @@ name: Unit Tests on: - # Run on all pushes and on all pull requests. - # Prevent the build from running when there are only irrelevant changes. push: paths-ignore: - '**.md' @@ -11,7 +9,6 @@ on: paths-ignore: - '**.md' - '**.txt' - # Allow manually triggering the workflow. workflow_dispatch: jobs: @@ -21,63 +18,43 @@ jobs: strategy: fail-fast: false matrix: - # Notes regarding supported versions in WP: - # The base matrix only contains the PHP versions which are supported on all supported WP versions. - php: ['8.0', '8.1', '7.4'] + php: ['7.4', '8.1', '8.2', '8.3'] wp: ['latest'] - experimental: [false] + mysql: ['8.0'] include: - # Complement the builds run via the matrix with high/low WP builds for PHP 7.4 and 8.0. - # PHP 8.0 is sort of supported since WP 5.6. - # PHP 7.4 is supported since WP 5.3. - php: '8.3' + wp: '6.6' + mysql: '8.0' + - php: '8.4' wp: 'latest' + mysql: '8.0' experimental: true - - php: '8.2' + - php: '8.5' wp: 'latest' + mysql: '8.0' experimental: true - - php: '8.2' - wp: '6.3' - experimental: true - - php: '8.0' - wp: '5.9' - experimental: true - - name: "PHP ${{ matrix.php }} - WP ${{ matrix.wp }}" - continue-on-error: ${{ matrix.experimental }} - - services: - mysql: - # WP 5.4 is the first WP version which largely supports MySQL 8.0. - # See: https://core.trac.wordpress.org/ticket/49344 - # During the setting up of these tests, it became clear that MySQL 8.0 - # in combination with PHP < 7.4 is not properly/sufficiently supported - # within WP Core. - # See: https://core.trac.wordpress.org/ticket/52496 - image: mysql:${{ ( matrix.wp == 5.3 && '5.6' ) || ( (matrix.wp < 5.4 || matrix.php < 7.4) && '5.7' ) || '8.0' }} - env: - MYSQL_ALLOW_EMPTY_PASSWORD: false - ports: - - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=10s --health-retries=10 + name: "PHP ${{ matrix.php }} - WP ${{ matrix.wp }} - MySQL ${{ matrix.mysql }}" + continue-on-error: ${{ matrix.experimental == true }} steps: - name: Checkout code uses: actions/checkout@v4 + - name: Setup MySQL + uses: ankane/setup-mysql@v1 + with: + mysql-version: ${{ matrix.mysql }} + - name: Install PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: mysqli, mysql + extensions: mysqli coverage: none - # On WP 5.2, PHPUnit 5.x, 6.x and 7.x are supported. - # On PHP >= 8.0, PHPUnit 7.5+ is needed, no matter what. - - name: Determine supported PHPUnit version - id: set_phpunit + - name: Set PHPUnit version run: | if [[ "${{ matrix.php }}" > "8.0" ]]; then echo "PHPUNIT=9.*" >> $GITHUB_ENV @@ -85,41 +62,28 @@ jobs: echo "PHPUNIT=5.7.*||6.*||7.5.*||8.5.*" >> $GITHUB_ENV fi - - name: 'Composer: set up PHPUnit' - env: - PHPUNIT: ${{ env.PHPUNIT }} - run: composer require --no-update phpunit/phpunit:"${{ env.PHPUNIT }}" + - name: Set up PHPUnit + run: composer require --no-update phpunit/phpunit:"$PHPUNIT" - # Install dependencies and handle caching in one go. - # @link https://github.com/marketplace/actions/install-composer-dependencies - - name: Install Composer dependencies for PHP < 8.0 + - name: Install dependencies for PHP < 8.0 if: ${{ matrix.php < 8.0 }} - uses: "ramsey/composer-install@v2" + uses: ramsey/composer-install@v3 - # For the PHP 8.0 and above, we need to install with ignore platform reqs as not all dependencies allow it yet. - - name: Install Composer dependencies for PHP >= 8.0 + - name: Install dependencies for PHP >= 8.0 if: ${{ matrix.php >= 8.0 }} - uses: "ramsey/composer-install@v2" + uses: ramsey/composer-install@v3 with: composer-options: --ignore-platform-reqs - - name: Install Subversion - run: sudo apt-get install subversion - - - name: Set up WordPress - run: phpunit/install.sh wordpress_test root '' 127.0.0.1:3306 ${{ matrix.wp }} - - - name: Tool versions + - name: Set up WordPress test environment run: | - php --version - composer --version - ./vendor/bin/phpunit --version - which ./vendor/bin/phpunit + sudo apt-get install -y subversion + bash phpunit/install.sh wordpress_test root '' 127.0.0.1:3306 ${{ matrix.wp }} - - name: Run the unit tests - single site - run: ./vendor/bin/phpunit + - name: Run tests (single site) + run: vendor/bin/phpunit - - name: Run the unit tests - multisite + - name: Run tests (multisite) env: WP_MULTISITE: 1 - run: ./vendor/bin/phpunit \ No newline at end of file + run: vendor/bin/phpunit \ No newline at end of file diff --git a/.github/workflows/zipitup.yml b/.github/workflows/zipitup.yml index e4b9355..64b83a6 100644 --- a/.github/workflows/zipitup.yml +++ b/.github/workflows/zipitup.yml @@ -23,13 +23,16 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Build project run: | mkdir build + - name: Create artifact uses: montudor/action-zip@v1 with: - args: zip -X -r build/${{ github.event.repository.name }}.zip . -x *.git* node_modules/\* .* "*/\.*" CODE_OF_CONDUCT.md CONTRIBUTING.md ISSUE_TEMPLATE.md PULL_REQUEST_TEMPLATE.md *.dist *.yml *.neon composer.* package.json dev-helpers** build** wporg-assets** phpunit** + args: zip -X -r build/${{ github.event.repository.name }}.zip . -x *.git* node_modules/\* .* "*/\.*" "*/.git*" "*/.DS_Store" CODE_OF_CONDUCT.md CONTRIBUTING.md ISSUE_TEMPLATE.md PULL_REQUEST_TEMPLATE.md CLAUDE.md *.dist *.yml *.neon composer.* package.json package-lock.json "dev-helpers/*" "build/*" "wporg-assets/*" "docs/*" "phpunit/*" phpstan-bootstrap.php build-assets.js + - name: Upload artifact uses: actions/upload-artifact@v4 with: @@ -40,4 +43,4 @@ jobs: with: args: build/${{ github.event.repository.name }}.zip application/zip env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04e75fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Ignore entire vendor except Freemius +/vendor/* +!/vendor/freemius/ + +# Tooling +/phpcompat-tools/ +/node_modules/ + +# Lock files +package-lock.json +composer.lock + +# Claude AI +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..603c49f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Plugin Overview + +WebberZone Knowledge Base is a free WordPress plugin (namespace `WebberZone\Knowledge_Base`) that creates a multi-product knowledge base system. This is the standalone free version — there is no `includes/pro/` directory here. The companion premium plugin (knowledgebase-pro) is a separate repository that extends this codebase with pro features. + +- **Plugin entry**: `knowledgebase.php` (defines constants, loads Freemius, triggers autoloader) +- **PHP**: 7.4+ | **WordPress**: 6.7+ +- **Custom post type**: `wz_knowledgebase` | **Taxonomies**: `wzkb_category`, `wzkb_product`, `wzkb_tag` +- **Constants**: `WZKB_VERSION`, `WZKB_PLUGIN_DIR`, `WZKB_PLUGIN_URL`, `WZKB_PLUGIN_FILE`, `WZKB_DEFAULT_THUMBNAIL_URL` + +## Build & Development Commands + +### PHP + +```bash +composer install # Install dependencies +composer test # Run phpcs + phpcompat + phpstan +composer phpcs # WordPress coding standards check +composer phpcbf # Auto-fix coding standards +composer phpstan # Static analysis (Level 5) +composer phpcompat # PHP 7.4–8.5 compatibility check +vendor/bin/phpunit # Run unit tests +vendor/bin/phpunit --filter TestName # Run a single test by name +WP_MULTISITE=1 vendor/bin/phpunit # Run multisite unit tests +``` + +### JavaScript / Blocks + +```bash +npm run build # Build all blocks (runs build:free + build:pro) +npm run build:free # Build all free blocks +npm run build:assets # Minify CSS/JS and generate RTL +npm run start # Watch mode for free blocks +npm run lint:js # Lint JavaScript +npm run lint:css # Lint CSS +npm run format # Auto-format JS and CSS +``` + +Individual block builds: `npm run build:[kb|articles|sections|products|search|breadcrumb|related|alerts]` + +Note: `package.json` also contains `build:pro` and `build:rating` scripts that reference `includes/pro/` — these are irrelevant in this repository and will fail if run here. + +### Distribution + +```bash +composer zip # Create PHP distribution zip +npm run zip # Create full plugin zip (wp-scripts plugin-zip) +``` + +## Architecture + +### Main Bootstrap Flow + +1. `plugins_loaded` hook → `Main::get_instance()` (singleton) +2. `Main::init()` instantiates all component handlers and registers their hooks +3. Admin components only load on `is_admin()` (deferred to `init` action for translation readiness) +4. `Main` has `$pro` and `$is_pro_enabled` properties defined but they are never set in this repository — those are only used by the pro plugin + +### Key Patterns + +**Autoloader** (`includes/autoloader.php`): PSR-4 style. Converts `WebberZone\Knowledge_Base\Admin\Settings` → `includes/admin/class-settings.php`. + +**Hook Registry** (`includes/util/class-hook-registry.php`): Custom wrapper around WordPress actions/filters with duplicate prevention and closure support. All components register hooks through this instead of calling `add_action()`/`add_filter()` directly. + +**Settings**: Global `$wzkb_settings` populated at plugin load. Read via `wzkb_get_option( $key )` or `wzkb_get_settings()`. Settings page in `includes/admin/class-settings.php`. Stored as a single serialized array under option key `wzkb_settings`. All settings filters use the prefix `wzkb_` (e.g. `wzkb_get_option_{$key}`). + +**Caching** (`includes/util/class-cache.php`): Term meta-based caching (not transients) with expiry timestamps. AJAX endpoint for admin cache clearing. + +**Free/Pro coexistence**: The plugin includes deactivation logic in `knowledgebase.php` — activating either the free or pro plugin automatically deactivates the other and shows an admin notice. + +### Component Map + +| Directory | Responsibility | +|---|---| +| `includes/admin/` | Settings UI, columns, wizard, notices, activation | +| `includes/frontend/` | Templates, display, shortcodes, styles, search, breadcrumbs, related articles, feeds, patterns | +| `includes/blocks/` | 8 free Gutenberg blocks (React in `src/`, compiled to `build/`) | +| `includes/rest/` | REST API under `/wzkb/v1/` namespace | +| `includes/widgets/` | 4 classic WordPress widgets (Articles, Sections, Breadcrumb, Products) | +| `includes/util/` | Hook registry, caching utilities, helpers | + +### Block Development + +Blocks are in `includes/blocks/src/[block-name]/`. Each block has its own `block.json`, React `edit.js`, and server-side render via PHP. After editing block source, run `npm run build:[block-name]` — never edit files in `build/` directly. + +### Public Helper Functions + +`includes/functions.php` exposes the plugin's public API. Key functions: +- `wzkb_knowledge()` — render the full KB output +- `wzkb_get_option( $key )` / `wzkb_get_settings()` — read settings (prefer over `get_option()` directly) +- `wzkb_get_breadcrumb()`, `wzkb_get_search_form()`, `wzkb_get_alert()`, `wzkb_related_articles()` — frontend rendering helpers +- `wzkb_get_the_post_thumbnail()` — thumbnail retrieval (supports ACF image fields) +- `wzkb_get_kb_url()`, `wzkb_get_product_sections_list()`, `wzkb_get_term_hierarchy_path()` — URL and taxonomy helpers + +### REST API + +Endpoints under `/wzkb/v1/`: `/sections` (product sections), `/knowledgebase` (list), `/knowledgebase/{id}` (single). Responses are object-cached under group `wzkb_rest` (300 s TTL); cache is invalidated on post save/delete and term changes. + +## Code Quality Configuration + +- **PHPCS**: `phpcs.xml.dist` — WordPress coding standards +- **PHPStan**: `phpstan.neon.dist` — Level 5 strict analysis; baseline in `phpstan-baseline.neon` +- **PHPUnit**: `phpunit.xml.dist` — test configuration, tests in `phpunit/tests/` diff --git a/LICENSE.txt b/LICENSE.txt old mode 100755 new mode 100644 diff --git a/README.md b/README.md index f2aa951..536a883 100644 --- a/README.md +++ b/README.md @@ -8,44 +8,56 @@ [![Required PHP](https://img.shields.io/wordpress/plugin/required-php/knowledgebase?style=flat-square)](https://wordpress.org/plugins/knowledgebase/) [![Active installs](https://img.shields.io/wordpress/plugin/installs/knowledgebase?style=flat-square)](https://wordpress.org/plugins/knowledgebase/) -__Requires:__ 6.3 +__Requires:__ 6.6 -__Tested up to:__ 6.8 +__Tested up to:__ 6.9 __License:__ [GPL-2.0+](http://www.gnu.org/licenses/gpl-2.0.html) __Plugin page:__ [Knowledge Base](https://webberzone.com/plugins/knowledgebase/) | [WordPress.org Plugin page](https://wordpress.org/plugins/knowledgebase/) -Effortlessly build a comprehensive knowledge base for unlimited products on your WordPress site and elevate your customer support experience. +Effortlessly create a powerful, multi-product knowledge base. Boost your support, reduce tickets, scale your documentation and make customers happy! ## Description -[Knowledge Base](https://webberzone.com/plugins/knowledgebase/) is an easy-to-use WordPress plugin that allows you to create a knowledge base / FAQ section on your site. +[Knowledge Base](https://webberzone.com/plugins/knowledgebase/) makes building a knowledge base or FAQ for your WordPress site easy, fast, and scalable. -You can use it to create a single or multi-product knowledge base with little effort. +🎯 Perfect for: +✅ Multi-product companies +✅ SaaS platforms +✅ Ecommerce support centres +✅ Documentation hubs -The plugin was born after I tried several free plugins and themes that didn't fit my purpose. It's designed to be very easy to install and use out of the box. +🔎 [Live Demo](https://webberzone.com/support/knowledgebase/). -You can view a [live demo of my knowledge base](https://webberzone.com/support/knowledgebase/). +### Main features -### Terminology +- 🚀 __Unlimited Knowledge Bases__ — Support as many products as you like, with unlimited sections and sub-sections. +- 🎨 __Beautiful, Responsive Layouts__ — Ships with clean templates powered by the Responsive Grid System. +- 🔗 __Customisable Permalinks__ — View your KB at /knowledgebase/ by default or change it easily. +- ✨ __Shortcodes + Gutenberg Blocks__ — Add KB listings anywhere using [knowledgebase] or use the Knowledge Base block. +- 🧭 __Built-in Breadcrumbs__ — Improve UX and SEO with breadcrumb navigation. +- 🧩 __Widgets Included__ — WZKB Articles, WZKB Sections, and WZKB Breadcrumbs widgets. +- ⚡ __Built-in Caching__ — Speed up your Knowledge Base without extra plugins. -* __Articles__: A custom post type `wz_knowledgebase` is used to store all the knowledge base articles -* __Sections__: A custom taxonomy ( `kbcategory` ) used to create the knowledge base. You will need *at least one category* to display the knowledge base. Add these categories under *Knowledge Base > Sections* -* __Tags__: Additionally you can use tags ( `kbtags` ) can also be used for each knowledge base article. +### Pro features -### Main features +[Knowledge Base Pro](https://webberzone.com/plugins/knowledgebase/#pro) enhances the plugin with advanced features for larger documentation sites, including ratings and feedback, a help widget, a powerful custom permalinks engine, premium layouts, and additional admin tools. + +- ⭐ __Article Rating & Feedback System__ — Collect binary or 5-star feedback with optional follow-up questions, admin alerts, Bayesian sorting, and GDPR-friendly tracking modes. +- 💬 __Help Widget__ — Offer an in-app support hub with live search, suggested articles, and a contact form inside a floating assistant. +- 🧭 __Custom Permalinks Engine__ — Craft advanced URL structures for articles, sections, tags, and products using dynamic placeholders. +- 🎨 __Premium Layout Pack__ — Unlock seven additional frontend styles (Card, Minimal, Boxed, Gradient, Compact, Magazine, Professional). +- 🛠️ __Advanced Admin Tools__ — Control knowledge base caching with expiry settings, on-demand cache clearing, and other productivity enhancements. + +### Key Concepts -* Supports unlimited knowledge bases using different sections with unlimited nested levels -* Inbuilt styles that display the Knowledge Base beautifully and are fully responsive - Uses the [Responsive Grid System](http://www.responsivegridsystem.com/) -* Customizable permalinks: Archives are enabled so your knowledge base can be viewed automatically at `/knowledgebase/` upon activation. You can change this on the Settings page -* Shortcode: `[knowledgebase]` will allow you to display the knowledge base on any page you choose. For other shortcodes, check the FAQ -* Gutenberg block: You can display the knowledge base using a block. Find it by typing `kb` or `knowledge base` when adding a new block -* Breadcrumbs: Default templates include breadcrumbs. Alternatively, use the function or shortcode to display this where you want -* Widgets: WZKB Articles, WZKB Sections and WZKB Breadcrumbs -* Inbuilt cache to speed up the display of your knowledge base articles +- __Articles:__ Custom post type `wz_knowledgebase` — your FAQs, how-to guides, and documentation. +- __Products:__ Custom taxonomy `wzkb_product` — link articles to one or more products. +- __Sections:__ Custom taxonomy `wzkb_category` — organize content neatly into categories. +- __Tags:__ Optional `wzkb_tag` taxonomy — make finding content even easier. -## Contribute +### Contribute If you have an idea, I'd love to hear it. WebberZone Knowledge Base is also available on [Github](https://github.com/WebberZone/knowledgebase). You can [create an issue on the Github page](https://github.com/WebberZone/knowledgebase/issues) or, better yet, fork the plugin, add a new feature and send me a pull request. @@ -60,9 +72,9 @@ For more screenshots, visit the [WordPress plugin page](http://wordpress.org/plu ### WordPress install (The easy way) -1. Navigate to "Plugins" within your WordPress Admin Area -2. Click "Add new" and in the search box enter "Knowledgebase" or "Knowledge Base" -3. Find the plugin in the list (usually the first result) and click "Install Now" +1. Navigate to “Plugins” within your WordPress Admin Area +2. Click “Add new” and in the search box enter “Knowledgebase” or "Knowledge Base" +3. Find the plugin in the list (usually the first result) and click “Install Now” 4. Activate or Network activate the Plugin in WP-Admin under the Plugins screen ### Manual install @@ -71,48 +83,31 @@ For more screenshots, visit the [WordPress plugin page](http://wordpress.org/plu 2. Extract the contents of knowledgebase.zip to wp-content/plugins/ folder. You should get a folder called knowledgebase. 3. Activate or Network activate the Plugin in WP-Admin under the Plugins screen -### Usage +### Quick Start -1. Visit `Knowledge Base » Sections` to add new categories to the knowledge base -2. Visit `Knowledge Base » Add New` to add new Articles to the knowledge base. You can select a section from there while adding -3. Optionally, create a new page or edit an existing one and add the shortcode `[knowledgebase]` or use the block to set up this page to display the knowledgebase +When you Activate the plugin for the first time, you will be taken to the Setup Wizard. Follow the instructions to set up your knowledge base. -The plugin supports unlimited levels of category hierarchy. To build a multi-product knowledge base: +After the Setup Wizard, you can: + +1. Go to __Knowledge Base » Products__ — add your first Products if you've selected Multi-Product mode. +2. Go to __Knowledge Base » Sections__ — add your first categories. +3. Go to __Knowledge Base » Add New__— create articles and assign them to sections. + +__Want a multi-product Knowledge Base only with Sections?__ 1. Set the *First section level* under the Output tab to 2 2. Create a set of top-level sections for each product 3. Create sub-sections for each of the products -[This live demo](https://webberzone.com/support/knowledgebase/) is a working example of a multi-product knowledge base. +See a live example: [WebberZone Knowledge Base Demo](https://webberzone.com/support/knowledgebase/). ## Frequently Asked Questions Check out the [FAQ on the plugin page](http://wordpress.org/plugins/knowledgebase/faq/) and the [Knowledge Base](https://webberzone.com/support/section/knowledgebase/). -If your question is not listed below, please create a new post at the [WordPress.org support forum](http://wordpress.org/support/plugin/knowledgebase). It is the fastest way to get support, as I monitor the forums regularly. I also provide [premium *paid* support via email](https://webberzone.com/support/). - -### 404 errors on the knowledge base - -This is usually due to outdated permalinks. To flush the existing permalink rules, visit Settings > Permalinks in your WordPress admin area. - -### Shortcodes - -For details on all the shortcodes included in the plugin, refer to [this Knowledge Base article](https://webberzone.com/support/knowledgebase/knowledge-base-shortcodes/). - -### Using your templates for archives and search - -WebberZone Knowledge Base comes built with custom templates to display archives of the articles, category archives, and search results. You can easily override any of these templates by creating your template in your theme's folder or in `wp-content/knowledgebase/templates` - -1. Article view: single-wz_knowledgebase.php or single-wz_knowledgebase.html -2. Articles archive: archive-wz_knowledgebase.php or archive-wz_knowledgebase.html -3. Category archive: taxonomy-wzkb_category.php or taxonomy-wzkb_category.html -4. Search results: wzkb-search.php or wzkb-search.html - -### How do I sort the posts or sections - -The plugin doesn't have an inbuilt feature to sort posts or sections. You will need an external plugin like [Intuitive Custom Post Order](https://wordpress.org/plugins/intuitive-custom-post-order/) which allows you to easily drag and drop posts, sections or tags to display them in a custom order. +If you don't see your question answered in the FAQ and Knowledge Base, please post it on the [WordPress.org support forum](http://wordpress.org/support/plugin/knowledgebase). This is the quickest way to get help, as I check the forums daily. For more personalized assistance, I also offer [premium *paid* support via email](https://webberzone.com/support/). -### How can I report security bugs? +## How can I report security bugs? You can report security bugs through the Patchstack Vulnerability Disclosure Program. The Patchstack team help validate, triage and handle any security vulnerabilities. [Report a security vulnerability.](https://patchstack.com/database/vdp/knowledgebase) diff --git a/build-assets.js b/build-assets.js index 342bb88..6b0d48c 100644 --- a/build-assets.js +++ b/build-assets.js @@ -1,13 +1,36 @@ -const { execSync } = require( 'child_process' ); -const { readdirSync, statSync, readFileSync, writeFileSync, existsSync, unlinkSync } = require( 'fs' ); -const { join, dirname, resolve, extname } = require( 'path' ); -const { mkdirSync } = require( 'fs' ); +const { execSync } = require('child_process'); +const { readdirSync, statSync, readFileSync, writeFileSync, existsSync, unlinkSync } = require('fs'); +const { join, dirname, resolve, extname, relative } = require('path'); +const { mkdirSync } = require('fs'); // Parse command line arguments. -const args = process.argv.slice( 2 ); -const specificFiles = args.filter( arg => ! arg.startsWith( '--' ) ); +const args = process.argv.slice(2); +const specificFiles = args.filter(arg => !arg.startsWith('--')); const processSpecificFiles = specificFiles.length > 0; +/** + * Normalise file paths to use forward slashes. + * + * @param {string} filePath File path to normalise. + * @return {string} Normalised file path. + */ +function normalizePath(filePath) { + const normalized = (filePath || '').split('\\').join('/'); + return normalized.startsWith('./') ? normalized.slice(2) : normalized; +} + +/** + * Get path relative to process cwd with normalised separators. + * + * @param {string} filePath Absolute or relative path. + * @return {string} Normalised relative path. + */ +function getRelativePath(filePath) { + const absolutePath = resolve(filePath); + const relPath = relative(process.cwd(), absolutePath); + return normalizePath('' === relPath ? absolutePath : relPath); +} + /** * Configuration for asset processing. */ @@ -23,7 +46,7 @@ const config = { 'includes/frontend/blocks', 'includes/pro/blocks', ], - + // File patterns to exclude from discovery. // Note: We exclude -rtl.css but not .min files to allow re-minification. excludePatterns: [ @@ -62,10 +85,10 @@ const binaries = { * * @param {string} filePath File path to check. */ -function ensureDirectoryExists( filePath ) { - const dir = dirname( filePath ); - if ( ! existsSync( dir ) ) { - mkdirSync( dir, { recursive: true } ); +function ensureDirectoryExists(filePath) { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); } } @@ -75,17 +98,25 @@ function ensureDirectoryExists( filePath ) { * @param {string} filePath File path to check. * @return {boolean} True if should be excluded. */ -function shouldExclude( filePath ) { +function shouldExclude(filePath) { + const normalizedPath = normalizePath(filePath); + // Check directory exclusions. - for ( const excludeDir of config.excludeDirs ) { - if ( filePath.includes( `/${excludeDir}/` ) || filePath.startsWith( `${excludeDir}/` ) ) { + for (const excludeDir of config.excludeDirs) { + const needle = `/${excludeDir}`; + if ( + normalizedPath.includes(`${needle}/`) || + normalizedPath.endsWith(needle) || + normalizedPath.startsWith(`${excludeDir}/`) || + normalizedPath === excludeDir + ) { return true; } } // Check pattern exclusions. - for ( const pattern of config.excludePatterns ) { - if ( pattern.test( filePath ) ) { + for (const pattern of config.excludePatterns) { + if (pattern.test(normalizedPath)) { return true; } } @@ -101,28 +132,29 @@ function shouldExclude( filePath ) { * @param {Array} fileList Accumulated file list. * @return {Array} List of file paths. */ -function findFiles( dir, extension, fileList = [] ) { - if ( ! existsSync( dir ) ) { +function findFiles(dir, extension, fileList = []) { + if (!existsSync(dir)) { return fileList; } - const files = readdirSync( dir ); + const files = readdirSync(dir); - files.forEach( ( file ) => { - const filePath = join( dir, file ); + files.forEach((file) => { + const filePath = join(dir, file); + const relativePath = getRelativePath(filePath); - if ( shouldExclude( filePath ) ) { + if (shouldExclude(relativePath)) { return; } - const stat = statSync( filePath ); + const stat = statSync(filePath); - if ( stat.isDirectory() ) { - findFiles( filePath, extension, fileList ); - } else if ( file.endsWith( extension ) && ! shouldExclude( filePath ) ) { - fileList.push( filePath ); + if (stat.isDirectory()) { + findFiles(filePath, extension, fileList); + } else if (file.endsWith(extension)) { + fileList.push(relativePath); } - } ); + }); return fileList; } @@ -134,13 +166,13 @@ function findFiles( dir, extension, fileList = [] ) { * @param {string} outputFile Output file path. * @return {boolean} True if successful. */ -function minifyCss( inputFile, outputFile ) { +function minifyCss(inputFile, outputFile) { try { - ensureDirectoryExists( outputFile ); - execSync( `${binaries.cleancss} -o ${outputFile} ${inputFile}`, { stdio: 'pipe' } ); + ensureDirectoryExists(outputFile); + execSync(`${binaries.cleancss} -o ${outputFile} ${inputFile}`, { stdio: 'pipe' }); return true; - } catch ( error ) { - console.error( ` ✗ Error minifying ${inputFile}:`, error.message ); + } catch (error) { + console.error(` ✗ Error minifying ${inputFile}:`, error.message); errorCount++; return false; } @@ -153,13 +185,13 @@ function minifyCss( inputFile, outputFile ) { * @param {string} outputFile Output file path. * @return {boolean} True if successful. */ -function minifyJs( inputFile, outputFile ) { +function minifyJs(inputFile, outputFile) { try { - ensureDirectoryExists( outputFile ); - execSync( `${binaries.terser} ${inputFile} -o ${outputFile} -c -m`, { stdio: 'pipe' } ); + ensureDirectoryExists(outputFile); + execSync(`${binaries.terser} ${inputFile} -o ${outputFile} -c -m`, { stdio: 'pipe' }); return true; - } catch ( error ) { - console.error( ` ✗ Error minifying ${inputFile}:`, error.message ); + } catch (error) { + console.error(` ✗ Error minifying ${inputFile}:`, error.message); errorCount++; return false; } @@ -172,13 +204,13 @@ function minifyJs( inputFile, outputFile ) { * @param {string} outputFile Output file path. * @return {boolean} True if successful. */ -function generateRtl( inputFile, outputFile ) { +function generateRtl(inputFile, outputFile) { try { - ensureDirectoryExists( outputFile ); - execSync( `${binaries.rtlcss} ${inputFile} ${outputFile}`, { stdio: 'pipe' } ); + ensureDirectoryExists(outputFile); + execSync(`${binaries.rtlcss} ${inputFile} ${outputFile}`, { stdio: 'pipe' }); return true; - } catch ( error ) { - console.error( ` ✗ Error creating RTL for ${inputFile}:`, error.message ); + } catch (error) { + console.error(` ✗ Error creating RTL for ${inputFile}:`, error.message); errorCount++; return false; } @@ -190,12 +222,12 @@ function generateRtl( inputFile, outputFile ) { * @param {string} file File path to format. * @return {boolean} True if successful. */ -function formatCss( file ) { +function formatCss(file) { try { - execSync( `${binaries.wpscripts} format ${file}`, { stdio: 'pipe' } ); + execSync(`${binaries.wpscripts} format ${file}`, { stdio: 'pipe' }); return true; - } catch ( error ) { - console.error( ` ✗ Error formatting ${file}:`, error.message ); + } catch (error) { + console.error(` ✗ Error formatting ${file}:`, error.message); errorCount++; return false; } @@ -208,216 +240,254 @@ function formatCss( file ) { * @param {Array} inputFiles Array of input file paths. * @param {boolean} isJs Whether files are JavaScript. */ -function combineFiles( outputFile, inputFiles, isJs = false ) { - console.log( `\nCombining files into: ${outputFile}` ); - +function combineFiles(outputFile, inputFiles, isJs = false) { + console.log(`\nCombining files into: ${outputFile}`); + let combinedContent = ''; const tempFiles = []; - - inputFiles.forEach( ( file ) => { + + inputFiles.forEach((file) => { try { let content; // Minify before combining if configured. - if ( config.minifyBeforeCombine ) { - const tempMinFile = file.replace( /\.(css|js)$/, '.temp.min.$1' ); - tempFiles.push( tempMinFile ); + if (config.minifyBeforeCombine) { + const tempMinFile = file.replace(/\.(css|js)$/, '.temp.min.$1'); + tempFiles.push(tempMinFile); - if ( isJs ) { - if ( ! minifyJs( file, tempMinFile ) ) { + if (isJs) { + if (!minifyJs(file, tempMinFile)) { return; } } else { - if ( ! minifyCss( file, tempMinFile ) ) { + if (!minifyCss(file, tempMinFile)) { return; } } - content = readFileSync( tempMinFile, 'utf8' ); + content = readFileSync(tempMinFile, 'utf8'); } else { - content = readFileSync( file, 'utf8' ); + content = readFileSync(file, 'utf8'); } combinedContent += `\n/* Source: ${file} */\n`; combinedContent += content; combinedContent += '\n'; - console.log( ` ✓ Added: ${file}` ); - } catch ( error ) { - console.error( ` ✗ Error reading ${file}:`, error.message ); + console.log(` ✓ Added: ${file}`); + } catch (error) { + console.error(` ✗ Error reading ${file}:`, error.message); errorCount++; } - } ); + }); try { - ensureDirectoryExists( outputFile ); - writeFileSync( outputFile, combinedContent ); - console.log( ` ✓ Created: ${outputFile}` ); + ensureDirectoryExists(outputFile); + writeFileSync(outputFile, combinedContent); + console.log(` ✓ Created: ${outputFile}`); // Clean up temp files (cross-platform). - tempFiles.forEach( ( tempFile ) => { + tempFiles.forEach((tempFile) => { try { - if ( existsSync( tempFile ) ) { - unlinkSync( tempFile ); + if (existsSync(tempFile)) { + unlinkSync(tempFile); } - } catch ( error ) { + } catch (error) { // Silently ignore cleanup errors. } - } ); - } catch ( error ) { - console.error( ` ✗ Error writing ${outputFile}:`, error.message ); + }); + } catch (error) { + console.error(` ✗ Error writing ${outputFile}:`, error.message); errorCount++; } } // Display usage information if specific files are being processed. -if ( processSpecificFiles ) { - console.log( '==================================' ); - console.log( 'Processing specific files:' ); - specificFiles.forEach( file => console.log( ` - ${file}` ) ); - console.log( '==================================' ); +if (processSpecificFiles) { + console.log('=================================='); + console.log('Processing specific files:'); + specificFiles.forEach(file => console.log(` - ${file}`)); + console.log('=================================='); } else { - console.log( '==================================' ); - console.log( 'Processing all assets in project' ); - console.log( 'Tip: Pass specific files to process only those:' ); - console.log( ' node build-assets.js path/to/file.js path/to/file.css' ); - console.log( '==================================' ); + console.log('=================================='); + console.log('Processing all assets in project'); + console.log('Tip: Pass specific files to process only those:'); + console.log(' node build-assets.js path/to/file.js path/to/file.css'); + console.log('=================================='); } // Step 1: Combine CSS files. -console.log( '\n=== Combining CSS Files ===' ); -Object.entries( config.combineCss ).forEach( ( [output, inputs] ) => { - combineFiles( output, inputs, false ); -} ); +console.log('\n=== Combining CSS Files ==='); +Object.entries(config.combineCss).forEach(([output, inputs]) => { + combineFiles(output, inputs, false); +}); // Step 2: Combine JS files. -console.log( '\n=== Combining JS Files ===' ); -Object.entries( config.combineJs ).forEach( ( [output, inputs] ) => { - combineFiles( output, inputs, true ); -} ); +console.log('\n=== Combining JS Files ==='); +Object.entries(config.combineJs).forEach(([output, inputs]) => { + combineFiles(output, inputs, true); +}); // Step 3: Build exclusion lists. const combinedSourceFiles = [ - ...Object.values( config.combineCss ).flat(), - ...Object.values( config.combineJs ).flat(), + ...Object.values(config.combineCss).flat(), + ...Object.values(config.combineJs).flat(), ]; const combinedOutputFiles = [ - ...Object.keys( config.combineCss ), - ...Object.keys( config.combineJs ), + ...Object.keys(config.combineCss), + ...Object.keys(config.combineJs), ]; // Step 4: Find all CSS and JS files. let allCssFiles = []; let allJsFiles = []; -if ( processSpecificFiles ) { - console.log( '\n=== Processing Specific Files ===' ); - specificFiles.forEach( file => { - const resolvedPath = resolve( file ); - const ext = extname( file ); - if ( existsSync( resolvedPath ) ) { - if ( ext === '.css' ) { - allCssFiles.push( file ); - console.log( ` ✓ Found CSS: ${file}` ); - } else if ( ext === '.js' ) { - allJsFiles.push( file ); - console.log( ` ✓ Found JS: ${file}` ); - } else { - console.log( ` ✗ Skipped (not CSS/JS): ${file}` ); +if (processSpecificFiles) { + console.log('\n=== Processing Specific Files ==='); + specificFiles.forEach(file => { + const resolvedPath = resolve(file); + if (!existsSync(resolvedPath)) { + console.log(` ✗ File not found: ${file}`); + return; + } + + const stat = statSync(resolvedPath); + const relativePath = getRelativePath(resolvedPath); + + if (shouldExclude(relativePath)) { + console.log(` ✗ Skipped (excluded): ${file}`); + return; + } + + if (stat.isDirectory()) { + console.log(` ✓ Scanning directory: ${file}`); + const cssFromDir = findFiles(resolvedPath, '.css', []); + const jsFromDir = findFiles(resolvedPath, '.js', []); + + if (cssFromDir.length === 0 && jsFromDir.length === 0) { + console.log(` ✗ No CSS/JS files found in directory: ${file}`); + } + + cssFromDir.forEach((cssFile) => { + if (!allCssFiles.includes(cssFile)) { + allCssFiles.push(cssFile); + console.log(` ✓ Found CSS: ${cssFile}`); + } + }); + + jsFromDir.forEach((jsFile) => { + if (!allJsFiles.includes(jsFile)) { + allJsFiles.push(jsFile); + console.log(` ✓ Found JS: ${jsFile}`); + } + }); + return; + } + + const ext = extname(resolvedPath); + if (ext === '.css') { + if (!allCssFiles.includes(relativePath)) { + allCssFiles.push(relativePath); + } + console.log(` ✓ Found CSS: ${relativePath}`); + } else if (ext === '.js') { + if (!allJsFiles.includes(relativePath)) { + allJsFiles.push(relativePath); } + console.log(` ✓ Found JS: ${relativePath}`); } else { - console.log( ` ✗ File not found: ${file}` ); + console.log(` ✗ Skipped (not CSS/JS): ${file}`); } - } ); + }); } else { - allCssFiles = findFiles( '.', '.css' ); - allJsFiles = findFiles( '.', '.js' ); + allCssFiles = findFiles('.', '.css'); + allJsFiles = findFiles('.', '.js'); } // Filter out source files used in combinations. -const cssFilesToMinify = allCssFiles.filter( ( file ) => { - if ( combinedSourceFiles.includes( file ) ) { +const cssFilesToMinify = allCssFiles.filter((file) => { + if (combinedSourceFiles.includes(file)) { return false; } - if ( file.endsWith( '.min.css' ) ) { + if (file.endsWith('.min.css')) { return false; } - if ( ! config.createMinifiedCombinedFiles && combinedOutputFiles.includes( file ) ) { + if (!config.createMinifiedCombinedFiles && combinedOutputFiles.includes(file)) { return false; } return true; -} ); +}); -const jsFilesToMinify = allJsFiles.filter( ( file ) => { - if ( combinedSourceFiles.includes( file ) ) { +const jsFilesToMinify = allJsFiles.filter((file) => { + if (combinedSourceFiles.includes(file)) { return false; } - if ( file.endsWith( '.min.js' ) ) { + if (file.endsWith('.min.js')) { return false; } - if ( ! config.createMinifiedCombinedFiles && combinedOutputFiles.includes( file ) ) { + if (!config.createMinifiedCombinedFiles && combinedOutputFiles.includes(file)) { return false; } return true; -} ); +}); -console.log( `\n=== Processing Assets ===` ); -console.log( `Found ${cssFilesToMinify.length} CSS files and ${jsFilesToMinify.length} JS files to minify.\n` ); +console.log(`\n=== Processing Assets ===`); +console.log(`Found ${cssFilesToMinify.length} CSS files and ${jsFilesToMinify.length} JS files to minify.\n`); // Step 5: Minify CSS files. -console.log( '=== Minifying CSS ===' ); -cssFilesToMinify.forEach( ( file ) => { - const minFile = file.replace( '.css', '.min.css' ); - console.log( ` ${file} → ${minFile}` ); - minifyCss( file, minFile ); -} ); +console.log('=== Minifying CSS ==='); +cssFilesToMinify.forEach((file) => { + const minFile = file.replace('.css', '.min.css'); + console.log(` ${file} → ${minFile}`); + minifyCss(file, minFile); +}); // Step 6: Minify JS files. -console.log( '\n=== Minifying JS ===' ); -jsFilesToMinify.forEach( ( file ) => { - const minFile = file.replace( '.js', '.min.js' ); - console.log( ` ${file} → ${minFile}` ); - minifyJs( file, minFile ); -} ); +console.log('\n=== Minifying JS ==='); +jsFilesToMinify.forEach((file) => { + const minFile = file.replace('.js', '.min.js'); + console.log(` ${file} → ${minFile}`); + minifyJs(file, minFile); +}); // Step 7: Generate RTL versions for CSS files. -console.log( '\n=== Generating RTL CSS ===' ); -const cssFilesForRtl = allCssFiles.filter( ( file ) => ! file.includes( '-rtl.' ) && ! file.endsWith( '-rtl.css' ) ); +console.log('\n=== Generating RTL CSS ==='); +const cssFilesForRtl = allCssFiles.filter((file) => !file.includes('-rtl.') && !file.endsWith('-rtl.css')); -if ( processSpecificFiles && cssFilesForRtl.length === 0 ) { - console.log( ' No CSS files to process for RTL.' ); +if (processSpecificFiles && cssFilesForRtl.length === 0) { + console.log(' No CSS files to process for RTL.'); } const rtlFilesGenerated = []; -cssFilesForRtl.forEach( ( file ) => { +cssFilesForRtl.forEach((file) => { // Handle .min.css files correctly: name.min.css → name-rtl.min.css - const rtlFile = file.endsWith( '.min.css' ) - ? file.replace( '.min.css', '-rtl.min.css' ) - : file.replace( '.css', '-rtl.css' ); - console.log( ` ${file} → ${rtlFile}` ); - if ( generateRtl( file, rtlFile ) ) { + const rtlFile = file.endsWith('.min.css') + ? file.replace('.min.css', '-rtl.min.css') + : file.replace('.css', '-rtl.css'); + console.log(` ${file} → ${rtlFile}`); + if (generateRtl(file, rtlFile)) { // Only format non-minified RTL files. - if ( ! rtlFile.endsWith( '.min.css' ) ) { - rtlFilesGenerated.push( rtlFile ); + if (!rtlFile.endsWith('.min.css')) { + rtlFilesGenerated.push(rtlFile); } } -} ); +}); // Step 8: Format RTL CSS files for better readability. -if ( rtlFilesGenerated.length > 0 ) { - console.log( '\n=== Formatting RTL CSS ===' ); - rtlFilesGenerated.forEach( ( file ) => { - console.log( ` Formatting: ${file}` ); - formatCss( file ); - } ); +if (rtlFilesGenerated.length > 0) { + console.log('\n=== Formatting RTL CSS ==='); + rtlFilesGenerated.forEach((file) => { + console.log(` Formatting: ${file}`); + formatCss(file); + }); } // Final summary. -console.log( '\n==================================' ); -if ( errorCount > 0 ) { - console.error( `✗ Completed with ${errorCount} error(s).` ); - process.exit( 1 ); +console.log('\n=================================='); +if (errorCount > 0) { + console.error(`✗ Completed with ${errorCount} error(s).`); + process.exit(1); } else { - console.log( '✓ All assets processed successfully!' ); - process.exit( 0 ); + console.log('✓ All assets processed successfully!'); + process.exit(0); } \ No newline at end of file diff --git a/changelog.txt b/changelog.txt index 78a203c..bc1de25 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,6 +2,52 @@ This is an archive of older changelog entries. Most recent entries are maintained in readme.txt += 2.3.2 = + +* Bug fixes: + * Fixed security issue where Knowledge Base slug in settings was not sanitized. + += 2.3.1 = + +* Bug fixes: + * Fixed security issue where arguments passed to the shortcodes were not properly sanitized. + += 2.3.0 = + +Release post: [https://webberzone.com/blog/knowledge-base-v2-3-0/](https://webberzone.com/blog/knowledge-base-v2-3-0/) + +The plugin has been completely rewritten to use classes and autoloading. + +* Features: + * New block: Knowledge Base Articles. + * New block: Knowledge Base Breadcrumbs. + * New block: Knowledge Base Sections. + +* Modifications: + * Enhanced breadcrumb navigation with semantic HTML5 markup and improved accessibility + * Added Schema.org BreadcrumbList markup for better SEO + * Added support for custom Unicode separators in breadcrumbs + += 2.2.1 = + +* Enhancements: + * The plugin will now load RTL styles if your site is in RTL mode. + * Only load CSS on the frontend if the option is enabled in the Settings page. + +* Bug fixes: + * Fixed a security issue in the alerts block that impacted edge cases of stored data from contributors. Now the alert block content is passed through `wp_kses_post` before being displayed. + * Fixed a bug where the block would not render correctly in the editor + += 2.2.0 = + +Release post: [https://webberzone.com/blog/knowledge-base-v2-2-0/](https://webberzone.com/blog/knowledge-base-v2-2-0/) + +* Enhancements: + * The plugin will now look for templates within `wp-content/knowledgebase/templates` folder if it is not found within the existing theme before using the plugin's included templates + * Alerts block now shows a preview and the Default style is inserted correctly + * Upgrade settings handling to use the WebberZone Settings_API class + * Knowledge Base block is wrapped in the `` component which prevent any accidental clicking when you're using it in the block editor (Gutenberg) + = 2.1.2 = * Security fix in block diff --git a/composer.json b/composer.json index 00601eb..fff93f8 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "webberzone/knowledgebase", "description": "Fastest way to create a highly-flexible multi-product knowledge base.", + "version": "3.0.0", "type": "wordpress-plugin", "keywords": [ "knowledge base", @@ -10,7 +11,8 @@ "support", "documentation" ], - "license": "GPL-2.0-or-later", + "license": "gpl-2.0-or-later", + "homepage": "https://webberzone.com/plugins/knowledgebase/", "authors": [ { "name": "WebberZone", @@ -27,9 +29,8 @@ "php-stubs/wordpress-stubs": "^6", "wp-coding-standards/wpcs": "^3", "dealerdirect/phpcodesniffer-composer-installer": "^1", - "phpcompatibility/phpcompatibility-wp": "^2", "yoast/phpunit-polyfills": "^3", - "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + "phpunit/phpunit": "^9 || ^10 || ^11 || ^12" }, "config": { "allow-plugins": { @@ -37,10 +38,29 @@ "dealerdirect/phpcodesniffer-composer-installer": true } }, + "minimum-stability": "dev", + "prefer-stable": true, "scripts": { + "build:vendor": "composer install --no-dev --optimize-autoloader --classmap-authoritative", "phpstan": "vendor/bin/phpstan analyse --memory-limit=2048M", - "phpstan-baseline": "vendor/bin/phpstan analyse --generate-baseline --memory-limit=2048M", + "phpstan-baseline": "vendor/bin/phpstan analyse --memory-limit=2048M --generate-baseline", "phpcs": "vendor/bin/phpcs -p -v -s --standard=phpcs.xml.dist $(find . -name '*.php')", - "phpcbf": "vendor/bin/phpcbf -p -v -s --standard=phpcs.xml.dist $(find . -name '*.php')" + "phpcbf": "vendor/bin/phpcbf -p -v -s --standard=phpcs.xml.dist $(find . -name '*.php')", + "phpcompat:setup": [ + "@php -r \"if (!is_dir('phpcompat-tools')) { mkdir('phpcompat-tools'); }\"", + "@composer config --working-dir=phpcompat-tools allow-plugins.dealerdirect/phpcodesniffer-composer-installer true 2>/dev/null || true", + "@composer require --working-dir=phpcompat-tools --dev --quiet --no-interaction squizlabs/php_codesniffer:^4.0 phpcompatibility/php-compatibility:dev-develop dealerdirect/phpcodesniffer-composer-installer:^1.0 2>/dev/null || true" + ], + "phpcompat": [ + "@phpcompat:setup", + "phpcompat-tools/vendor/bin/phpcs -p --standard=PHPCompatibility --runtime-set testVersion 7.4-8.5 --extensions=php --ignore=*/vendor/*,*/node_modules/*,*/tests/*,*/phpunit/*,*/freemius/*,*/phpcompat-tools/* ." + ], + "phpcompat:clean": "rm -rf phpcompat-tools", + "test": [ + "@phpcs", + "@phpcompat", + "@phpstan" + ], + "zip": "mkdir -p build && zip -X -r build/$(basename $(pwd)).zip . -x '*.git*' 'node_modules/*' '.*' '*/.git*' '*/.DS_Store' 'vendor/**/.DS_Store' 'vendor/bin/*' 'CODE_OF_CONDUCT.md' 'CONTRIBUTING.md' 'ISSUE_TEMPLATE.md' 'PULL_REQUEST_TEMPLATE.md' 'CLAUDE.md' '*.dist' '*.yml' '*.neon' 'composer.*' 'package.json' 'package-lock.json' 'dev-helpers**' 'build**' 'wporg-assets**' 'test-tools**' 'docs/*' 'phpunit**' 'phpstan-bootstrap.php' 'phpcompat-tools**'" } } \ No newline at end of file diff --git a/includes/admin/class-activator.php b/includes/admin/class-activator.php index c749fbc..0c7103f 100644 --- a/includes/admin/class-activator.php +++ b/includes/admin/class-activator.php @@ -12,6 +12,7 @@ namespace WebberZone\Knowledge_Base\Admin; use WebberZone\Knowledge_Base\CPT; +use WebberZone\Knowledge_Base\Util\Hook_Registry; /** * Class Activator @@ -26,7 +27,7 @@ class Activator { * @since 3.3.0 */ public function __construct() { - add_action( 'wp_initialize_site', array( $this, 'activate_new_site' ) ); + Hook_Registry::add_action( 'wp_initialize_site', array( $this, 'activate_new_site' ) ); } /** @@ -96,7 +97,10 @@ public static function single_activate() { // Then flush them. global $wp_rewrite; $wp_rewrite->init(); - flush_rewrite_rules( false ); + flush_rewrite_rules(); + + // Set a transient to trigger wizard redirect (30-second expiry). + set_transient( 'wzkb_activation_redirect', true, 30 ); } /** diff --git a/includes/admin/class-admin-banner.php b/includes/admin/class-admin-banner.php new file mode 100644 index 0000000..8188409 --- /dev/null +++ b/includes/admin/class-admin-banner.php @@ -0,0 +1,463 @@ + + */ + public array $config = array(); + + /** + * Derived class names keyed by component. + * + * @var array> + */ + public array $class_names = array(); + + /** + * Localized strings. + * + * @var array + */ + public array $strings = array(); + + /** + * Style configuration. + * + * @var array + */ + public array $style = array(); + + /** + * Base class prefix shared by all banners. + * + * @var string + */ + public string $base_prefix = 'wz-admin-banner'; + + /** + * Unique class prefix derived from the provided prefix. + * + * @var string + */ + public string $unique_prefix = 'admin-banner'; + + /** + * Constructor. + * + * @param array $config Configuration arguments for the banner. + */ + public function __construct( array $config ) { + $defaults = array( + 'capability' => 'manage_options', + 'allow_network' => false, + 'prefix' => '', + 'screen_ids' => array(), + 'page_slugs' => array(), + 'sections' => array(), + 'exclude_screen_bases' => array( 'post', 'post-new' ), + 'strings' => array(), + 'link_target' => '_self', + 'style' => array(), + ); + + $this->config = wp_parse_args( $config, $defaults ); + $this->strings = $this->prepare_strings( $this->config['strings'] ?? array() ); + + $this->config['sections'] = $this->sanitize_sections( $this->config['sections'] ); + + $this->unique_prefix = $this->resolve_wrapper_prefix( (string) $this->config['prefix'] ); + $this->class_names = $this->derive_class_names(); + $this->style = $this->prepare_style_config( $this->config['style'] ?? array() ); + + if ( empty( $this->config['screen_ids'] ) ) { + $this->config['screen_ids'] = $this->collect_targets_from_sections( 'screen_ids' ); + } + + if ( empty( $this->config['page_slugs'] ) ) { + $this->config['page_slugs'] = $this->collect_targets_from_sections( 'page_slugs' ); + } + + $this->hooks(); + } + + /** + * Register hooks. + */ + public function hooks(): void { + Hook_Registry::add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_styles' ) ); + Hook_Registry::add_action( 'in_admin_header', array( $this, 'render' ) ); + } + + /** + * Enqueue banner styles if required on the current screen or page slug. + */ + public function maybe_enqueue_styles(): void { + if ( empty( $this->style['url'] ) ) { + return; + } + + $screen = ! is_network_admin() ? get_current_screen() : null; + $page_slug = $this->get_request_page_slug(); + + if ( $screen instanceof \WP_Screen && $this->should_render_on_screen( $screen, $page_slug ) ) { + $this->enqueue_style(); + return; + } + + if ( '' !== $page_slug && in_array( $page_slug, $this->config['page_slugs'], true ) ) { + $this->enqueue_style(); + } + } + + /** + * Render the admin banner markup when conditions are met. + */ + public function render(): void { + if ( is_network_admin() && ! $this->config['allow_network'] ) { + return; + } + + $screen = get_current_screen(); + if ( ! ( $screen instanceof \WP_Screen ) || ! current_user_can( $this->config['capability'] ) ) { + return; + } + + $page_slug = $this->get_request_page_slug(); + + if ( ! $this->should_render_on_screen( $screen, $page_slug ) ) { + return; + } + + $current_section = $this->resolve_current_section( $screen, $page_slug ); + + ?> +
+
+ strings['eyebrow'] ) ) : ?> + strings['eyebrow'] ); ?> + + strings['title'] ) ) : ?> +

strings['title'] ); ?>

+ + strings['text'] ) ) : ?> +

strings['text'] ); ?>

+ +
+ +
+ style['handle'], + $this->style['url'], + (array) $this->style['deps'], + $this->style['version'] + ); + wp_enqueue_style( $this->style['handle'] ); + } + + /** + * Determine whether the banner should display on the current screen. + * + * @param \WP_Screen $screen Current admin screen. + * @param string $page_slug Current request page slug. + */ + public function should_render_on_screen( \WP_Screen $screen, string $page_slug ): bool { + $screen_base = (string) $screen->base; + if ( '' !== $screen_base && in_array( $screen_base, (array) $this->config['exclude_screen_bases'], true ) ) { + return false; + } + + $screen_id = (string) $screen->id; + if ( '' !== $screen_id && in_array( $screen_id, (array) $this->config['screen_ids'], true ) ) { + return true; + } + + if ( '' !== $page_slug && in_array( $page_slug, (array) $this->config['page_slugs'], true ) ) { + return true; + } + + return false; + } + + /** + * Resolve the banner section to highlight based on current screen or page slug. + * + * @param \WP_Screen $screen Current admin screen. + * @param string $page_slug Current request page slug. + */ + public function resolve_current_section( \WP_Screen $screen, string $page_slug ): string { + $screen_id = (string) $screen->id; + + foreach ( $this->config['sections'] as $section_key => $section ) { + if ( ! empty( $section['screen_ids'] ) && in_array( $screen_id, (array) $section['screen_ids'], true ) ) { + return $section_key; + } + } + + foreach ( $this->config['sections'] as $section_key => $section ) { + if ( ! empty( $section['page_slugs'] ) && in_array( $page_slug, (array) $section['page_slugs'], true ) ) { + return $section_key; + } + } + + return ''; + } + + /** + * Prepare localized strings. + * + * @param array $strings Raw strings array. + */ + public function prepare_strings( array $strings ): array { + $defaults = array( + 'region_label' => '', + 'nav_label' => '', + 'eyebrow' => '', + 'title' => '', + 'text' => '', + ); + + return wp_parse_args( $strings, $defaults ); + } + + /** + * Resolve the wrapper prefix based on base prefix provided. + * + * @param string $prefix Base prefix. + */ + public function resolve_wrapper_prefix( string $prefix ): string { + $prefix = sanitize_key( $prefix ); + + if ( '' === $prefix ) { + return $this->base_prefix; + } + + return false === strpos( $prefix, $this->base_prefix ) ? "{$prefix}-admin-banner" : $prefix; + } + + /** + * Prepare style configuration. + * + * @param array $style Style configuration. + */ + public function prepare_style_config( array $style ): array { + $defaults = array( + 'handle' => $this->sanitize_handle( "{$this->unique_prefix}-styles" ), + 'deps' => array(), + 'version' => self::DEFAULT_STYLE_VERSION, + 'filename' => 'admin-banner', + 'url' => '', + ); + + $style_config = wp_parse_args( $style, $defaults ); + + if ( empty( $style_config['url'] ) ) { + $assets_base = trailingslashit( plugin_dir_url( __FILE__ ) ) . 'css/'; + $min_suffix = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; + $rtl_suffix = is_rtl() ? '-rtl' : ''; + $style_config['url'] = $assets_base . $style_config['filename'] . $rtl_suffix . $min_suffix . '.css'; + } + + return $style_config; + } + + /** + * Sanitize the sections configuration. + * + * @param array $sections Sections configuration. + * + * @return array + */ + public function sanitize_sections( array $sections ): array { + $sanitized = array(); + + foreach ( $sections as $key => $section ) { + if ( empty( $section['label'] ) || empty( $section['url'] ) ) { + continue; + } + + $section_key = sanitize_key( $key ); + + $sanitized[ $section_key ] = array( + 'label' => $section['label'], + 'url' => $section['url'], + 'type' => isset( $section['type'] ) ? sanitize_key( $section['type'] ) : 'secondary', + 'target' => isset( $section['target'] ) ? $section['target'] : '_self', + 'rel' => isset( $section['rel'] ) ? $section['rel'] : '', + 'screen_ids' => isset( $section['screen_ids'] ) ? (array) $section['screen_ids'] : array(), + 'page_slugs' => isset( $section['page_slugs'] ) ? array_map( 'sanitize_key', (array) $section['page_slugs'] ) : array(), + ); + } + + return $sanitized; + } + + /** + * Derive class names following the provided prefix alongside the base prefix. + * + * @return array> + */ + public function derive_class_names(): array { + $build = function ( string $suffix = '' ): array { + $classes = array( $this->base_prefix . $suffix ); + + if ( $this->unique_prefix !== $this->base_prefix ) { + $classes[] = $this->unique_prefix . $suffix; + } + + return $classes; + }; + + return array( + 'wrapper' => $build(), + 'intro' => $build( '__intro' ), + 'eyebrow' => $build( '__eyebrow' ), + 'title' => $build( '__title' ), + 'text' => $build( '__text' ), + 'links_wrapper' => $build( '__links' ), + 'link' => $build( '__link' ), + 'link_primary' => $build( '__link--primary' ), + 'link_secondary' => $build( '__link--secondary' ), + 'link_current' => $build( '__link--current' ), + 'link_new' => $build( '__link--new' ), + ); + } + + /** + * Collect screen IDs or page slugs from the sections configuration. + * + * @param string $target_key screen_ids|page_slugs key. + * + * @return array + */ + public function collect_targets_from_sections( string $target_key ): array { + $values = array(); + + foreach ( $this->config['sections'] as $section ) { + if ( empty( $section[ $target_key ] ) ) { + continue; + } + foreach ( (array) $section[ $target_key ] as $value ) { + $values[] = (string) $value; + } + } + + return array_values( array_unique( array_filter( $values ) ) ); + } + + /** + * Retrieve the CSS classes for a section link. + * + * @param array $section Section configuration. + */ + public function get_section_link_classes( array $section ): array { + $classes = $this->class_names['link'] ?? array(); + $type = isset( $section['type'] ) ? sanitize_key( $section['type'] ) : 'secondary'; + $type = '' !== $type ? $type : 'secondary'; + $type_key = "link_{$type}"; + + if ( isset( $this->class_names[ $type_key ] ) ) { + $classes = array_merge( $classes, (array) $this->class_names[ $type_key ] ); + } elseif ( isset( $this->class_names['link_secondary'] ) ) { + $classes = array_merge( $classes, (array) $this->class_names['link_secondary'] ); + } + + return array_values( array_unique( array_filter( $classes ) ) ); + } + + /** + * Implode a class array into a string. + * + * @param array $classes Class list. + * @return string Class attribute string. + */ + public function implode_classes( array $classes ): string { + return implode( ' ', array_unique( array_filter( $classes ) ) ); + } + + /** + * Retrieve a flattened class attribute by key. + * + * @param string $key Classes array key. + * @return string Class attribute string. + */ + public function class_attr( string $key ): string { + return $this->implode_classes( $this->class_names[ $key ] ?? array() ); + } + + /** + * Sanitize a style handle. + * + * @param string $handle Raw handle. + */ + public function sanitize_handle( string $handle ): string { + return sanitize_title_with_dashes( $handle ); + } + + /** + * Get the current page slug from the request. + */ + public function get_request_page_slug(): string { + if ( isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $page_raw = sanitize_text_field( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } else { + return ''; + } + + $page_slug = strtolower( (string) strtok( $page_raw, '&' ) ); + + return sanitize_key( $page_slug ); + } +} diff --git a/includes/admin/class-admin-columns.php b/includes/admin/class-admin-columns.php index fa85579..b88c1d3 100644 --- a/includes/admin/class-admin-columns.php +++ b/includes/admin/class-admin-columns.php @@ -1,21 +1,21 @@ '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 +97,346 @@ 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 = wzkb_get_section_product( $id ); + if ( $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. + * @param array $args Additional term query arguments. + * @return array Modified clauses. + */ + public function sort_terms_by_product( $pieces, $taxonomies, $args ) { + 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( $args['orderby'] ) ? sanitize_key( $args['orderby'] ) : ''; + if ( 'product' !== $orderby ) { + return $pieces; + } + + // Get sort order. + $order = isset( $args['order'] ) ? strtoupper( sanitize_text_field( $args['order'] ) ) : 'ASC'; + $order = in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'ASC'; + + $meta_alias = 'tm_product_sort'; + $product_alias = 'pt_product_sort'; + $taxonomy_alias = 'ptt_product_sort'; + + // Join with termmeta to get product_id while avoiding alias collisions. + $pieces['join'] .= " LEFT JOIN {$wpdb->termmeta} AS {$meta_alias} ON t.term_id = {$meta_alias}.term_id AND {$meta_alias}.meta_key = 'product_id'"; + + // Join with terms and term_taxonomy to get wzkb_product name. + $pieces['join'] .= " LEFT JOIN {$wpdb->terms} AS {$product_alias} ON {$meta_alias}.meta_value = {$product_alias}.term_id"; + $pieces['join'] .= " LEFT JOIN {$wpdb->term_taxonomy} AS {$taxonomy_alias} ON {$product_alias}.term_id = {$taxonomy_alias}.term_id AND {$taxonomy_alias}.taxonomy = 'wzkb_product'"; + + // Set the ORDER BY clause with the "ORDER BY" prefix. + $pieces['orderby'] = "ORDER BY COALESCE({$product_alias}.name, '') $order, t.name $order"; + + // Prevent WordPress from appending the order. + $pieces['order'] = ''; + + return $pieces; + } + + /** + * Ensure Sections screen defaults to sorting by Product. + * + * @since 3.0.0 + * + * @param \WP_Term_Query $query Term query instance. + */ + public function set_default_sections_order( $query ) { + if ( ! is_admin() || ! $query instanceof \WP_Term_Query ) { + return; + } + + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen || 'edit-tags' !== $screen->base || 'wzkb_category' !== $screen->taxonomy ) { + return; + } + + if ( ! current_user_can( 'manage_categories' ) ) { + return; + } + + // Respect explicit user choice. + if ( isset( $_GET['orderby'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + $orderby = isset( $query->query_vars['orderby'] ) ? $query->query_vars['orderby'] : ''; + if ( ! empty( $orderby ) && 'name' !== $orderby ) { + return; + } + + $query->query_vars['orderby'] = 'product'; + if ( empty( $query->query_vars['order'] ) ) { + $query->query_vars['order'] = 'ASC'; + } + } + + /** + * 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 ); + } + + /** + * Enqueue script for product filter on Sections taxonomy screen. + * + * @since 3.0.0 + */ + public function enqueue_sections_filter_script() { + $screen = get_current_screen(); + if ( ! $screen || 'edit-tags' !== $screen->base || 'wzkb_category' !== $screen->taxonomy ) { + return; + } + + if ( ! current_user_can( 'manage_categories' ) ) { + return; + } + + $products = get_terms( + array( + 'taxonomy' => 'wzkb_product', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + if ( empty( $products ) || is_wp_error( $products ) ) { + return; + } + + // Format products data for JS. + $products_data = array(); + foreach ( $products as $product ) { + $products_data[] = array( + 'term_id' => $product->term_id, + 'name' => $product->name, + ); + } + + // Get current query parameters to preserve them. + $query_params = array(); + // phpcs:disable WordPress.Security.NonceVerification.Recommended + if ( ! empty( $_GET ) ) { + foreach ( $_GET as $key => $value ) { + if ( 'wzkb_product' !== $key ) { + // Properly sanitize GET parameters based on expected types. + $sanitized_key = sanitize_key( $key ); + + // Handle common WordPress admin parameters appropriately. + switch ( $sanitized_key ) { + case 'taxonomy': + case 'post_type': + case 'orderby': + case 'order': + $query_params[ $sanitized_key ] = sanitize_text_field( $value ); + break; + + case 'page': + case 'paged': + $query_params[ $sanitized_key ] = absint( $value ); + break; + + case 's': // Search term. + $query_params[ $sanitized_key ] = sanitize_text_field( $value ); + break; + + default: + // For any other parameters, apply general sanitization. + if ( is_array( $value ) ) { + $query_params[ $sanitized_key ] = array_map( 'sanitize_text_field', $value ); + } else { + $query_params[ $sanitized_key ] = sanitize_text_field( $value ); + } + break; + } + } + } + } + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $selected = isset( $_GET['wzkb_product'] ) ? absint( $_GET['wzkb_product'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + // Register and enqueue script. + $minimize = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min'; + wp_register_script( + 'wzkb-sections-product-filter', + plugins_url( 'js/sections-product-filter' . $minimize . '.js', __FILE__ ), + array( 'jquery' ), + WZKB_VERSION, + true + ); + + wp_localize_script( + 'wzkb-sections-product-filter', + 'knowledgebaseProductFilter', + array( + 'products' => $products_data, + 'currentProduct' => $selected, + 'queryParams' => $query_params, + 'currentScreen' => $screen->id, + 'nonces' => array( + 'filter' => wp_create_nonce( 'wzkb_sections_filter' ), + ), + 'strings' => array( + 'filterInstruction' => __( 'Filter sections by product:', 'knowledgebase' ), + 'productLabel' => __( 'Product:', 'knowledgebase' ), + 'searchPlaceholder' => __( 'Search sections...', 'knowledgebase' ), + ), + ) + ); + + wp_enqueue_script( 'wzkb-sections-product-filter' ); + } + + /** + * Filter Sections by selected Product. + * + * @since 3.0.0 + * + * @param array $pieces Array of query SQL clauses. + * @param array $taxonomies Array of taxonomy names. + * @return array Modified query SQL clauses. + */ + public function filter_sections_by_product( $pieces, $taxonomies ) { + global $wpdb; + + // Only run for wzkb_category taxonomy queries. + if ( ! in_array( 'wzkb_category', $taxonomies, true ) ) { + return $pieces; + } + + $screen = get_current_screen(); + if ( ! $screen || 'edit-tags' !== $screen->base || 'wzkb_category' !== $screen->taxonomy ) { + return $pieces; + } + + if ( ! current_user_can( 'manage_categories' ) ) { + return $pieces; + } + + if ( empty( $_GET['wzkb_product'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $pieces; + } + + $product_id = absint( $_GET['wzkb_product'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( $product_id ) { + $pieces['join'] .= " INNER JOIN {$wpdb->termmeta} AS tm ON t.term_id = tm.term_id "; + $pieces['where'] .= $wpdb->prepare( " AND tm.meta_key = 'product_id' AND tm.meta_value = %d ", $product_id ); + } + + return $pieces; + } +} \ No newline at end of file diff --git a/includes/admin/class-admin-notices-api.php b/includes/admin/class-admin-notices-api.php new file mode 100644 index 0000000..31e61c1 --- /dev/null +++ b/includes/admin/class-admin-notices-api.php @@ -0,0 +1,216 @@ +prefix = $prefix; + + Hook_Registry::add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); + Hook_Registry::add_action( 'admin_notices', array( $this, 'display_notices' ) ); + Hook_Registry::add_action( "wp_ajax_{$this->prefix}_dismiss_notice", array( $this, 'handle_notice_dismissal' ) ); + } + + /** + * Register and enqueue the dismiss script, pushing this instance's config + * into the shared window.adminNoticesConfigs array. + */ + public function enqueue_scripts() { + $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; + $handle = "{$this->prefix}-admin-notices"; + + wp_register_script( + $handle, + plugins_url( "js/admin-notices{$minimize}.js", __FILE__ ), + array( 'jquery' ), + WZKB_VERSION, + true + ); + + $config = wp_json_encode( + array( + 'prefix' => $this->prefix, + 'action' => "{$this->prefix}_dismiss_notice", + 'nonce' => wp_create_nonce( "{$this->prefix}_dismiss_notice" ), + ) + ); + + wp_add_inline_script( + $handle, + 'window.adminNoticesConfigs = window.adminNoticesConfigs || []; window.adminNoticesConfigs.push(' . $config . ');', + 'before' + ); + + wp_enqueue_script( $handle ); + } + + /** + * Register a new notice. + * + * @param array $notice { + * Notice arguments. + * + * @type string $id Unique notice ID. + * @type string $message Notice message. + * @type string $type Notice type. Either 'error', 'warning', 'success' or 'info'. + * @type bool $dismissible Whether the notice is dismissible. + * @type int $dismiss_time Dismiss time in seconds. Default 0 (permanent). + * @type array $screens Array of screens to show notice on. Empty means all screens. + * @type string $capability Capability required to see the notice. + * @type array $conditions Array of callbacks to determine if notice should show. + * } + */ + public function register_notice( array $notice ) { + $default_notice = array( + 'id' => '', + 'message' => '', + 'type' => 'info', + 'dismissible' => true, + 'dismiss_time' => 0, + 'screens' => array(), + 'capability' => 'manage_options', + 'conditions' => array(), + ); + + $notice = wp_parse_args( $notice, $default_notice ); + + if ( empty( $notice['id'] ) || empty( $notice['message'] ) ) { + return; + } + + $this->notices[ $notice['id'] ] = $notice; + } + + /** + * Display registered notices. + */ + public function display_notices() { + $screen = get_current_screen(); + if ( null === $screen ) { + return; + } + + foreach ( $this->notices as $notice ) { + // Skip if user doesn't have capability. + if ( ! current_user_can( $notice['capability'] ) ) { + continue; + } + + // Skip if not on correct screen. + if ( ! empty( $notice['screens'] ) && ! in_array( $screen->id, $notice['screens'], true ) ) { + continue; + } + + // Check conditions. + foreach ( $notice['conditions'] as $condition ) { + if ( is_callable( $condition ) && ! call_user_func( $condition ) ) { + continue 2; + } + } + + // Skip if notice is dismissed. + if ( $this->is_notice_dismissed( $notice['id'] ) ) { + continue; + } + + $class = 'notice notice-' . $notice['type']; + if ( $notice['dismissible'] ) { + $class .= ' is-dismissible'; + } + + printf( + '
%5$s
', + esc_attr( $class ), + esc_attr( $notice['id'] ), + esc_attr( $notice['dismiss_time'] ), + esc_attr( $this->prefix ), + wp_kses_post( $notice['message'] ) + ); + } + } + + /** + * Handle notice dismissal via AJAX. + */ + public function handle_notice_dismissal() { + check_ajax_referer( "{$this->prefix}_dismiss_notice", 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_die(); + } + + $notice_id = isset( $_POST['notice_id'] ) ? sanitize_key( $_POST['notice_id'] ) : ''; + $dismiss_time = isset( $_POST['dismiss_time'] ) ? absint( $_POST['dismiss_time'] ) : 0; + + if ( ! $notice_id ) { + wp_die(); + } + + $key = "{$this->prefix}_notice_dismissed_{$notice_id}"; + + if ( $dismiss_time ) { + set_transient( $key, true, $dismiss_time ); + } else { + update_user_meta( get_current_user_id(), $key, true ); + } + + wp_die(); + } + + /** + * Check if a notice has been dismissed. + * + * @param string $notice_id Notice ID. + * @return bool Whether the notice has been dismissed. + */ + private function is_notice_dismissed( $notice_id ) { + $notice = $this->notices[ $notice_id ] ?? null; + + if ( ! $notice ) { + return false; + } + + $key = "{$this->prefix}_notice_dismissed_{$notice_id}"; + + if ( $notice['dismiss_time'] ) { + return (bool) get_transient( $key ); + } + + return (bool) get_user_meta( get_current_user_id(), $key, true ); + } +} diff --git a/includes/admin/class-admin.php b/includes/admin/class-admin.php index 5098217..f163295 100644 --- a/includes/admin/class-admin.php +++ b/includes/admin/class-admin.php @@ -10,7 +10,7 @@ namespace WebberZone\Knowledge_Base\Admin; use WebberZone\Knowledge_Base\Util\Cache; -use WebberZone\Knowledge_Base\Admin\Activator; +use WebberZone\Knowledge_Base\Util\Hook_Registry; // If this file is called directly, abort. if ( ! defined( 'WPINC' ) ) { @@ -18,7 +18,7 @@ } /** - * Class to register the Better Search Admin Area. + * Class to register the Knowledge Base Admin Area. * * @since 2.3.0 */ @@ -29,36 +29,90 @@ class Admin { * * @since 2.3.0 * - * @var object Settings API. + * @var Settings Settings API. */ - public $settings; + public Settings $settings; /** * Activator class. * * @since 2.3.0 * - * @var object Activator class. + * @var Activator Activator class. */ - public $activator; + public Activator $activator; + + /** + * Settings wizard. + * + * @since 3.0.0 + * + * @var Settings_Wizard|null Settings wizard instance. + */ + public ?Settings_Wizard $settings_wizard = null; /** * Cache. * * @since 2.3.0 * - * @var object Cache. + * @var Cache Cache. */ - public $cache; + public Cache $cache; /** * Admin columns. * * @since 2.3.0 * - * @var object Admin columns. + * @var Admin_Columns Admin columns. + */ + public Admin_Columns $admin_columns; + + /** + * Product Migrator class. + * + * @since 3.0.0 + * + * @var Product_Migrator Product Migrator class. + */ + public Product_Migrator $product_migrator; + + /** + * Admin Notices API. + * + * @since 4.1.0 + * + * @var Admin_Notices_API Admin notices API. + */ + public Admin_Notices_API $admin_notices_api; + + /** + * Section Product Meta class. + * + * @since 3.0.0 + * + * @var Section_Product_Meta Section Product Meta class. + */ + public Section_Product_Meta $section_product_meta; + + /** + * Tools Page class. + * + * @since 3.0.0 + * + * @var Tools_Page Tools Page class. + */ + public Tools_Page $tools_page; + + /** + * Admin banner helper instance. + * + * @since 3.0.0 + * + * @var Admin_Banner */ - public $admin_columns; + public Admin_Banner $admin_banner; /** * Main constructor class. @@ -69,10 +123,210 @@ 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->section_product_meta = new Section_Product_Meta(); + $this->product_migrator = new Product_Migrator(); + $this->admin_notices_api = new Admin_Notices_API(); + $this->settings_wizard = new Settings_Wizard(); + $this->tools_page = new Tools_Page(); + $this->admin_banner = new Admin_Banner( $this->get_admin_banner_config() ); + } + + /** + * Retrieve the configuration array for the admin banner. + * + * @since 3.0.0 + * + * @return array + */ + private function get_admin_banner_config(): array { + $kb_url = wzkb_get_kb_url(); + $products_url = admin_url( 'edit-tags.php?taxonomy=wzkb_product&post_type=wz_knowledgebase' ); + $sections_url = admin_url( 'edit-tags.php?taxonomy=wzkb_category&post_type=wz_knowledgebase' ); + $tags_url = admin_url( 'edit-tags.php?taxonomy=wzkb_tag&post_type=wz_knowledgebase' ); + $tools_url = admin_url( 'edit.php?post_type=wz_knowledgebase&page=wzkb_tools_page' ); + + return array( + 'capability' => 'edit_posts', + 'prefix' => 'wzkb', + 'screen_ids' => array( + 'edit-wz_knowledgebase', + 'wz_knowledgebase', + 'wz_knowledgebase_page_wzkb-settings', + 'knowledgebase_page_wzkb-settings', + 'wz_knowledgebase_page_wzkb_tools_page', + 'knowledgebase_page_wzkb_tools_page', + 'edit-wzkb_category', + 'term-wzkb_category', + 'edit-wzkb_product', + 'term-wzkb_product', + 'edit-wzkb_tag', + 'term-wzkb_tag', + ), + 'page_slugs' => array( + 'wzkb-settings', + 'wzkb_tools_page', + ), + 'strings' => array( + 'region_label' => esc_html__( 'Knowledge Base quick links', 'knowledgebase' ), + 'nav_label' => esc_html__( 'Knowledge Base admin shortcuts', 'knowledgebase' ), + 'eyebrow' => esc_html__( 'WebberZone Knowledge Base', 'knowledgebase' ), + 'title' => esc_html__( 'Shape a helpful support hub your users will love.', 'knowledgebase' ), + 'text' => esc_html__( 'Jump to your most-used Knowledge Base tools, manage content faster, and explore more WebberZone plugins.', 'knowledgebase' ), + ), + 'sections' => array( + 'archive' => array( + 'label' => esc_html__( 'View Knowledge Base', 'knowledgebase' ), + 'url' => $kb_url, + 'type' => 'primary', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ), + 'products' => array( + 'label' => esc_html__( 'Products', 'knowledgebase' ), + 'url' => $products_url, + 'screen_ids' => array( 'edit-wzkb_product', 'term-wzkb_product' ), + 'page_slugs' => array( 'edit-tags.php?taxonomy=wzkb_product' ), + ), + 'sections' => array( + 'label' => esc_html__( 'Sections', 'knowledgebase' ), + 'url' => $sections_url, + 'screen_ids' => array( 'edit-wzkb_category', 'term-wzkb_category' ), + ), + 'tags' => array( + 'label' => esc_html__( 'Tags', 'knowledgebase' ), + 'url' => $tags_url, + 'screen_ids' => array( 'edit-wzkb_tag', 'term-wzkb_tag' ), + ), + 'tools' => array( + 'label' => esc_html__( 'Tools', 'knowledgebase' ), + 'url' => $tools_url, + 'screen_ids' => array( 'wz_knowledgebase_page_wzkb_tools_page', 'knowledgebase_page_wzkb_tools_page' ), + 'page_slugs' => array( 'wzkb_tools_page' ), + ), + 'plugins' => array( + 'label' => esc_html__( 'WebberZone Plugins', 'knowledgebase' ), + 'url' => 'https://webberzone.com/plugins/', + 'type' => 'secondary', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ), + ), + ); + } + + /** + * Determine if the banner should be rendered on the current screen. + * + * @since 3.0.0 + * + * @param \WP_Screen $screen Current screen. + * + * @return bool + */ + private function is_knowledge_base_screen( \WP_Screen $screen ): bool { + $page_param = $this->get_request_page_param(); + + if ( 'wz_knowledgebase' === (string) $screen->post_type ) { + return true; + } + + $screen_taxonomy = (string) $screen->taxonomy; + if ( '' !== $screen_taxonomy && in_array( $screen_taxonomy, array( 'wzkb_category', 'wzkb_product', 'wzkb_tag' ), true ) ) { + return true; + } + + $screen_id = (string) $screen->id; + if ( '' !== $screen_id && in_array( $screen_id, array( 'wz_knowledgebase_page_wzkb-settings', 'knowledgebase_page_wzkb-settings' ), true ) ) { + return true; + } + + if ( $this->is_tools_screen( $screen, $page_param ) ) { + return true; + } + + if ( '' !== $page_param && in_array( $page_param, array( 'wzkb-settings', 'wzkb_tools_page' ), true ) ) { + return true; + } + + return false; + } + + /** + * Retrieve the current admin page query parameter in a sanitised form. + * + * @since 3.0.0 + * + * @return string Sanitised page identifier. + */ + private function get_request_page_param(): string { + $page_param_raw = filter_input( INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); + + if ( is_string( $page_param_raw ) && '' !== $page_param_raw ) { + return sanitize_key( $page_param_raw ); + } + + if ( isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + return ''; + } + + /** + * Retrieve a sanitized request variable intended for use as a key/slug. + * + * @since 3.0.0 + * + * @param string $key Request key to fetch. + * + * @return string Sanitised key value. + */ + private function get_request_key_param( string $key ): string { + $value_raw = filter_input( INPUT_GET, $key, FILTER_UNSAFE_RAW ); + + if ( is_string( $value_raw ) && '' !== $value_raw ) { + return sanitize_key( $value_raw ); + } + + if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return sanitize_key( wp_unslash( (string) $_GET[ $key ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + return ''; + } + + /** + * Determine whether the current screen represents the Tools page. + * + * @since 3.0.0 + * + * @param \WP_Screen $screen Current screen instance. + * @param string $page_param Sanitised page query parameter. + * + * @return bool + */ + private function is_tools_screen( \WP_Screen $screen, string $page_param ): bool { + $candidates = array_filter( + array( + (string) $screen->id, + (string) $screen->base, + (string) $screen->parent_base, + (string) $screen->parent_file, + $page_param, + ) + ); + + foreach ( $candidates as $candidate ) { + if ( false !== strpos( $candidate, 'wzkb_tools_page' ) ) { + return true; + } + } + + return false; } /** @@ -81,11 +335,66 @@ public function __construct() { * @since 2.3.0 */ public function hooks() { - add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) ); - add_action( 'admin_notices', array( $this, 'admin_notices' ) ); - add_filter( 'dashboard_glance_items', array( $this, 'dashboard_glance_items' ), 10, 1 ); - add_filter( 'admin_head', array( $this, 'admin_head' ) ); - add_action( 'admin_footer', array( $this, 'maybe_add_button_to_post_list' ) ); + Hook_Registry::add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) ); + Hook_Registry::add_action( 'admin_init', array( $this, 'register_notices' ) ); + Hook_Registry::add_filter( 'dashboard_glance_items', array( $this, 'dashboard_glance_items' ), 10, 1 ); + Hook_Registry::add_filter( 'admin_head', array( $this, 'admin_head' ) ); + } + + /** + * Register admin notices. + * + * @since 3.0.0 + */ + public function register_notices() { + $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' ); + + // Notice for missing settings. + $page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + $is_setup_page = 'wzkb_wizard' === $page; + $slugs_not_set = 'not-set-random-string' === $kb_slug + || 'not-set-random-string' === $product_slug + || 'not-set-random-string' === $cat_slug + || 'not-set-random-string' === $tag_slug; + + if ( ! $is_setup_page && $slugs_not_set ) { + $this->admin_notices_api->register_notice( + array( + 'id' => 'wzkb_settings_not_registered', + 'type' => 'error', + 'dismissible' => false, + 'message' => sprintf( + /* translators: 1. Link to admin page. */ + esc_html__( 'Knowledge Base settings for the slug have not been registered. Please visit the %s to update and save the options.', 'knowledgebase' ), + '' . esc_html__( 'admin page', 'knowledgebase' ) . '' + ), + ) + ); + } + + // Notice for Products taxonomy when multi-product mode is disabled. + $this->admin_notices_api->register_notice( + array( + 'id' => 'wzkb_multi_product_disabled', + 'type' => 'warning', + 'dismissible' => false, + 'screens' => array( 'edit-wzkb_product' ), + 'conditions' => array( + function () { + return ! (int) \wzkb_get_option( 'multi_product', 0 ); + }, + ), + 'message' => sprintf( + /* translators: %s: HTML link to the plugin settings page. */ + esc_html__( 'The Products taxonomy is only available in multi-product mode. Please enable multi-product mode in the %s.', 'knowledgebase' ), + sprintf( '%s', esc_url( admin_url( 'edit.php?post_type=wz_knowledgebase&page=wzkb-settings' ) ), esc_html__( 'plugin settings', 'knowledgebase' ) ) + ), + ) + ); } /** @@ -98,58 +407,53 @@ 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' ), + 'flush_confirm_message' => esc_html__( 'Are you sure you want to flush the permalinks?', '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' ); - } - } + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + $should_enqueue = false; - /** - * Display admin notices. - * - * @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' ); - - // Only add the notice if the user is an admin. - if ( ! current_user_can( 'manage_options' ) ) { - return; + if ( $screen && $this->is_knowledge_base_screen( $screen ) ) { + $should_enqueue = true; + } else { + $post_type = $this->get_request_key_param( 'post_type' ); + $taxonomy = $this->get_request_key_param( 'taxonomy' ); + $page = $this->get_request_page_param(); + + if ( 'wz_knowledgebase' === $post_type || in_array( $taxonomy, array( 'wzkb_category', 'wzkb_product', 'wzkb_tag' ), true ) || in_array( $page, array( 'wzkb-settings', 'wzkb_tools_page' ), true ) ) { + $should_enqueue = true; + } } - // 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 ) { - ?> -
-

- 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 - ?> -

-
- post_type && 'wzkb_category' !== $screen->taxonomy ) || - ( 'edit' !== $screen->base && 'wz_knowledgebase' !== $screen->post_type ) ) { - return; - } - - $this->render_custom_button( 'Visit Knowledge Base' ); - } - - /** - * Render the custom button + * Display Pro upgrade banner. * - * @since 2.3.0 + * @since 3.0.0 * - * @param string $button_text Text to display on the button. + * @param bool $donate Whether to show the donate banner. + * @param string $custom_text Custom text to show in the banner. */ - private function render_custom_button( $button_text = 'Custom Action' ) { + public static function pro_upgrade_banner( $donate = true, $custom_text = '' ) { ?> - +
+
+ +

+ + + +

<?php esc_html_e( 'Support the development - Send us a donation today.', 'knowledgebase' ); ?>

+ +
+
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' ), + ); + Hook_Registry::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(); + ?> +
+

+ +

+ +

+ + +

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+ +
+
+

+ +
+
+
+
+
+
    +
    +

    + +

    +
    +
    +
    + false, + 'progress' => 0, + 'message' => '', + 'next_step' => $step, + 'state' => $state, + 'errors' => array(), + 'dry_run' => $dry_run, + 'log' => array(), + ); + + switch ( $step ) { + case 0: + if ( ! empty( $state['initialization_complete'] ) ) { + $response_data['message'] = '' . __( 'Migration already initialized. Resuming...', 'knowledgebase' ) . ''; + $log[] = $response_data['message']; + $response_data['progress'] = ! empty( $state['products_created'] ) ? 20 : 0; + $response_data['next_step'] = ! empty( $state['products_created'] ) ? 2 : 1; + $response_data['log'] = array_slice( $log, $last_log_index ); + $state['last_log_index'] = count( $log ); + $response_data['state'] = $state; + set_transient( 'wzkb_migration_log', $log, DAY_IN_SECONDS ); + break; + } + + delete_transient( 'wzkb_migration_log' ); + delete_transient( 'wzkb_migration_assigned_articles' ); + delete_transient( 'wzkb_migration_article_counts' ); + + $log = array(); + + $response_data['message'] = '' . __( 'Initializing migration...', 'knowledgebase' ) . ''; + + if ( $dry_run ) { + $log[] = '' . __( 'Dry run mode: No changes will be made.', 'knowledgebase' ) . ''; + } + + $top_sections = get_terms( + array( + 'taxonomy' => $this->taxonomy_section, + 'hide_empty' => false, + 'parent' => 0, + 'fields' => 'all', + ) + ); + + if ( is_wp_error( $top_sections ) ) { + wp_send_json_error( + sprintf( + /* translators: %s: Error message */ + __( 'Error fetching sections: %s', 'knowledgebase' ), + $top_sections->get_error_message() + ) + ); + } + + $top_section_ids = array(); + $section_article_counts = array(); + $unique_article_ids = array(); + $total_articles = 0; + $total_descendant_count = 0; + + foreach ( $top_sections as $section ) { + $section_id = (int) $section->term_id; + $top_section_ids[] = $section_id; + $descendant_ids = $this->get_all_descendant_section_ids( $section_id, $this->taxonomy_section ); + $total_descendant_count += count( $descendant_ids ); + $all_section_ids = array_merge( array( $section_id ), $descendant_ids ); + + foreach ( $all_section_ids as $sid ) { + $articles = get_posts( + array( + 'post_type' => $this->post_type_article, + 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), + 'posts_per_page' => -1, + 'fields' => 'ids', + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + array( + 'taxonomy' => $this->taxonomy_section, + 'field' => 'term_id', + 'terms' => $sid, + ), + ), + 'suppress_filters' => true, + ) + ); + $section_article_counts[ $sid ] = count( $articles ); + foreach ( $articles as $article_id ) { + if ( ! in_array( $article_id, $unique_article_ids, true ) ) { + $unique_article_ids[] = $article_id; + ++$total_articles; + } + } + } + } + + $state['top_section_ids'] = $top_section_ids; + $state['section_to_product_map'] = array(); + $state['total_articles'] = $total_articles; + $state['articles_processed'] = 0; + $state['sections_mapped'] = 0; + $state['top_sections_mapped'] = 0; + $state['total_descendant_count'] = $total_descendant_count; + $state['sections_deleted'] = 0; + $state['processed_section_ids'] = array(); + $state['processed_article_ids'] = array(); + $state['last_logged_section'] = array(); + $state['initialization_complete'] = true; + $state['last_log_index'] = 0; + + set_transient( 'wzkb_migration_article_counts', $section_article_counts, DAY_IN_SECONDS ); + delete_transient( 'wzkb_migration_assigned_articles' ); + + $response_data['progress'] = 20; + $response_data['next_step'] = 1; + $response_data['state'] = $state; + + if ( empty( $top_section_ids ) ) { + $response_data['done'] = true; + $response_data['progress'] = 100; + $response_data['message'] = __( 'No top-level sections found. Nothing to migrate.', 'knowledgebase' ); + $log[] = $response_data['message']; + } else { + $response_data['state'] = $state; + } + + $response_data['log'] = array_slice( $log, $last_log_index ); + $state['last_log_index'] = count( $log ); + $response_data['state'] = $state; + set_transient( 'wzkb_migration_log', $log, DAY_IN_SECONDS ); + break; + + case 1: + if ( ! empty( $state['products_created'] ) ) { + $response_data['message'] = '' . __( 'Products already created. Skipping product creation step...', 'knowledgebase' ) . ''; + $log[] = $response_data['message']; + $response_data['progress'] = 20; + $response_data['next_step'] = 2; + $response_data['log'] = array_slice( $log, $last_log_index ); + $state['last_log_index'] = count( $log ); + $response_data['state'] = $state; + set_transient( 'wzkb_migration_log', $log, DAY_IN_SECONDS ); + break; + } + + $response_data['message'] = '' . __( 'Creating products from top-level sections...', 'knowledgebase' ) . ''; + $log[] = $response_data['message']; + $top_section_ids = $state['top_section_ids'] ?? array(); + $section_to_product_map = $state['section_to_product_map'] ?? array(); + $simulated_product_ids = $state['simulated_product_ids'] ?? array(); + + foreach ( $top_section_ids as $section_id ) { + $section = get_term( $section_id, $this->taxonomy_section ); + if ( ! $section || is_wp_error( $section ) ) { + $log[] = sprintf( + /* translators: %d: Section ID */ + __( 'Skipped invalid section ID: %d.', 'knowledgebase' ), + $section_id + ); + continue; + } + $existing_product = get_term_by( 'slug', $section->slug, $this->taxonomy_product ); + if ( $existing_product ) { + $product_id = (int) $existing_product->term_id; + $log[] = sprintf( + /* translators: 1: Section name, 2: Section ID, 3: Product ID */ + __( 'Used existing product for section "%1$s" (ID: %2$d) with product ID: %3$d.', 'knowledgebase' ), + $section->name, + $section_id, + $product_id + ); + } else { + $product = wp_insert_term( + $section->name, + $this->taxonomy_product, + array( + 'description' => $section->description, + 'slug' => $section->slug, + ) + ); + if ( is_wp_error( $product ) ) { + continue; + } + $product_id = (int) $product['term_id']; + if ( $dry_run ) { + $simulated_product_ids[] = $product_id; + } + $log[] = sprintf( + /* translators: 1: Product name, 2: Product ID, 3: Section name, 4: Section ID */ + __( 'Created product "%1$s" (ID: %2$d) for section "%3$s" (ID: %4$d).', 'knowledgebase' ), + $section->name, + $product_id, + $section->name, + $section_id + ); + } + $section_to_product_map[ $section_id ] = $product_id; + $state['top_sections_mapped'] = isset( $state['top_sections_mapped'] ) ? $state['top_sections_mapped'] + 1 : 1; + } + + $state['section_to_product_map'] = $section_to_product_map; + $state['simulated_product_ids'] = $simulated_product_ids; + $state['products_created'] = true; + $response_data['log'] = array_slice( $log, $last_log_index ); + $response_data['progress'] = 20; + $response_data['next_step'] = 2; + $state['last_log_index'] = count( $log ); + $response_data['state'] = $state; + set_transient( 'wzkb_migration_log', $log, DAY_IN_SECONDS ); + break; + + case 2: + /** + * Filters the maximum number of sections processed per migration batch. + * + * Allows customization of migration batch section limit for server performance tuning. + * + * @since 3.0.0 + * @param int $max_sections_per_batch Number of sections processed per batch. Default 3. + * @return int Filtered number of sections per batch. + */ + $max_sections_per_batch = apply_filters( 'wzkb_migration_max_sections_per_batch', 3 ); + + /** + * Filters the maximum number of articles processed per migration batch. + * + * Allows customization of migration batch article limit for server performance tuning. + * + * @since 3.0.0 + * @param int $max_articles_per_batch Number of articles processed per batch. Default 50. + * @return int Filtered number of articles per batch. + */ + $max_articles_per_batch = apply_filters( 'wzkb_migration_max_articles_per_batch', 50 ); + + $top_section_ids = $state['top_section_ids'] ?? array(); + $section_to_product_map = $state['section_to_product_map'] ?? array(); + $section_article_counts = is_array( get_transient( 'wzkb_migration_article_counts' ) ) ? get_transient( 'wzkb_migration_article_counts' ) : array(); + $total_articles = isset( $state['total_articles'] ) ? (int) $state['total_articles'] : 0; + $articles_processed = $state['articles_processed'] ?? 0; + $sections_mapped = $state['sections_mapped'] ?? 0; + $top_sections_mapped = $state['top_sections_mapped'] ?? 0; + $already_assigned_articles = is_array( get_transient( 'wzkb_migration_assigned_articles' ) ) ? get_transient( 'wzkb_migration_assigned_articles' ) : array(); + $current_top_section_index = isset( $state['current_top_section_index'] ) ? (int) $state['current_top_section_index'] : 0; + $current_desc_section_index = isset( $state['current_desc_section_index'] ) ? (int) $state['current_desc_section_index'] : 0; + $current_article_offset = isset( $state['current_article_offset'] ) ? (int) $state['current_article_offset'] : 0; + $descendant_ids = $state['descendant_ids'] ?? array(); + $last_logged_section = $state['last_logged_section'] ?? array(); + + if ( empty( $top_section_ids ) || $current_top_section_index >= count( $top_section_ids ) ) { + $response_data['progress'] = 80; + $response_data['next_step'] = 3; + $response_data['message'] = __( 'No more top-level sections to process. Moving to next step.', 'knowledgebase' ); + $log[] = $response_data['message']; + unset( $state['current_top_section_index'], $state['descendant_ids'], $state['current_desc_section_index'], $state['current_article_offset'] ); + } else { + $section_id = $top_section_ids[ $current_top_section_index ]; + $section_term = get_term( $section_id, $this->taxonomy_section ); + $section_name = $section_term->name ?? $section_id; + $total_top_sections = count( $top_section_ids ); + $log_key = (string) $current_top_section_index; + + $is_new_section = ( empty( $descendant_ids ) && 0 === $current_desc_section_index && 0 === $current_article_offset ); + + if ( $is_new_section ) { + $stage_message = sprintf( + /* translators: 1: Section name, 2: Section ID, 3: Current index, 4: Total count */ + __( 'Starting sub-section mapping for "%1$s" (ID: %2$d) (%3$d of %4$d)...', 'knowledgebase' ), + $section_name, + $section_id, + $current_top_section_index + 1, + $total_top_sections + ); + $current_stage = 'start'; + } else { + $stage_message = sprintf( + /* translators: 1: Section name, 2: Section ID, 3: Current index, 4: Total count */ + __( 'Continuing sub-section mapping for "%1$s" (ID: %2$d) (%3$d of %4$d)...', 'knowledgebase' ), + $section_name, + $section_id, + $current_top_section_index + 1, + $total_top_sections + ); + $current_stage = 'continue'; + } + + $response_data['message'] = '' . $stage_message . ''; + + $last_entry = $last_logged_section[ $log_key ] ?? array(); + if ( ( $last_entry['section_id'] ?? null ) !== $section_id || ( $last_entry['stage'] ?? '' ) !== $current_stage ) { + $log[] = $response_data['message']; + $last_logged_section[ $log_key ] = array( + 'section_id' => $section_id, + 'stage' => $current_stage, + ); + } + + $log[] = sprintf( + /* translators: 1: Top index, 2: Descendant index, 3: Offset, 4: Descendant count, 5: Top sections count, 6: Articles processed, 7: Total articles */ + __( 'Incoming State: top_index=%1$d, desc_index=%2$d, offset=%3$d, desc_count=%4$d, top_sections_count=%5$d, articles_processed=%6$d/%7$d', 'knowledgebase' ), + $current_top_section_index, + $current_desc_section_index, + $current_article_offset, + count( $descendant_ids ), + count( $top_section_ids ), + $articles_processed, + $total_articles + ); + if ( ! isset( $section_to_product_map[ $section_id ] ) ) { + $log[] = sprintf( + /* translators: %d: Section ID */ + __( 'Skipping section ID: %d (No product mapping found).', 'knowledgebase' ), + $section_id + ); + $state['current_top_section_index'] = $current_top_section_index + 1; + $state['descendant_ids'] = array(); + $state['current_desc_section_index'] = 0; + $state['current_article_offset'] = 0; + $response_data['progress'] = round( max( 20, min( 80, ( $current_top_section_index / max( 1, count( $top_section_ids ) ) ) * 60 + 20 ) ), 1 ); + $response_data['next_step'] = 2; + } else { + $product_id = (int) $section_to_product_map[ $section_id ]; + + if ( empty( $descendant_ids ) ) { + $log[] = sprintf( + /* translators: %d: Section ID */ + __( 'Loading descendants for section ID: %d', 'knowledgebase' ), + $section_id + ); + $descendant_ids = $this->get_all_descendant_section_ids( $section_id, $this->taxonomy_section ); + array_unshift( $descendant_ids, $section_id ); + $log[] = sprintf( + /* translators: 1: Section ID, 2: Count */ + __( 'Found %2$d sections (including top-level) for section ID: %1$d.', 'knowledgebase' ), + $section_id, + count( $descendant_ids ) + ); + $log[] = 'Descendant IDs: ' . implode( ', ', array_slice( $descendant_ids, 1 ) ); + $current_desc_section_index = 0; + $current_article_offset = 0; + } + + $sections_processed = 0; + $articles_in_batch = 0; + $top_sections_count = count( $top_section_ids ); + while ( $sections_processed < $max_sections_per_batch && $articles_in_batch < $max_articles_per_batch && $current_top_section_index < $top_sections_count ) { + if ( $current_desc_section_index >= count( $descendant_ids ) ) { + $log[] = sprintf( + /* translators: %d: Section ID */ + __( 'Completed all sections for top section ID: %d. Moving to next top section.', 'knowledgebase' ), + $section_id + ); + $last_logged_section[ $log_key ] = array( + 'section_id' => $section_id, + 'stage' => 'complete', + ); + ++$current_top_section_index; + $descendant_ids = array(); + $current_desc_section_index = 0; + $current_article_offset = 0; + break; + } + + $current_section_id = $descendant_ids[ $current_desc_section_index ]; + $section_term = get_term( $current_section_id, $this->taxonomy_section ); + $log[] = sprintf( + /* translators: 1: Section name, 2: Section ID */ + __( 'Processing section "%1$s" (ID: %2$d)', 'knowledgebase' ), + $section_term->name ?? $current_section_id, + $current_section_id + ); + + // Only count as a descendant section if it's not the top-level section itself. + if ( $current_section_id !== $section_id ) { + if ( ! $dry_run ) { + update_term_meta( $current_section_id, 'product_id', $product_id ); + } + + // Check if we've already counted this section before incrementing. + if ( ! isset( $state['processed_section_ids'][ $current_section_id ] ) ) { + ++$sections_mapped; + $state['processed_section_ids'][ $current_section_id ] = true; + } + + $log[] = sprintf( + /* translators: 1: Section name, 2: Section ID, 3: Product ID */ + __( 'Linked section "%1$s" (ID: %2$d) to product ID: %3$d.', 'knowledgebase' ), + $section_term->name ?? $current_section_id, + $current_section_id, + $product_id + ); + } + + $articles_remaining = isset( $section_article_counts[ $current_section_id ] ) ? $section_article_counts[ $current_section_id ] - $current_article_offset : $max_articles_per_batch; + $articles_to_fetch = min( $max_articles_per_batch, $max_articles_per_batch - $articles_in_batch, $articles_remaining ); + + if ( $articles_to_fetch <= 0 ) { + $log[] = sprintf( + /* translators: %d: Section ID */ + __( 'No articles remaining for section ID: %d.', 'knowledgebase' ), + $current_section_id + ); + ++$current_desc_section_index; + $current_article_offset = 0; + ++$sections_processed; + continue; + } + + $articles = get_posts( + array( + 'post_type' => $this->post_type_article, + 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), + 'posts_per_page' => $articles_to_fetch, + 'offset' => $current_article_offset, + 'fields' => 'ids', + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + array( + 'taxonomy' => $this->taxonomy_section, + 'field' => 'term_id', + 'terms' => $current_section_id, + ), + ), + 'suppress_filters' => true, + ) + ); + + $assigned_articles = array(); + foreach ( $articles as $article_id ) { + $article_id = absint( $article_id ); + if ( isset( $already_assigned_articles[ $product_id ][ $article_id ] ) ) { + $log[] = sprintf( + /* translators: 1: Article ID, 2: Product ID */ + __( 'Skipped article ID: %1$d (already assigned to product ID: %2$d).', 'knowledgebase' ), + $article_id, + $product_id + ); + continue; + } + if ( ! $dry_run ) { + wp_set_object_terms( $article_id, $product_id, $this->taxonomy_product, true ); + } + $assigned_articles[] = sprintf( '%s (ID: %d)', get_the_title( $article_id ), $article_id ); + $already_assigned_articles[ $product_id ][ $article_id ] = true; + ++$articles_processed; + + if ( ! isset( $state['processed_article_ids'][ $article_id ] ) ) { + $state['processed_article_ids'][ $article_id ] = true; + } + } + + if ( ! empty( $assigned_articles ) ) { + $product_term = get_term( $product_id, $this->taxonomy_product ); + $product_name = $product_term->name ?? __( 'Unknown Product', 'knowledgebase' ); + $log[] = sprintf( + /* translators: 1: Product name, 2: Product ID, 3: List of articles */ + __( 'Assigned articles to product "%1$s" (ID: %2$d): %3$s', 'knowledgebase' ), + $product_name, + $product_id, + implode( ', ', $assigned_articles ) + ); + } else { + $log[] = sprintf( + /* translators: %d: Section ID */ + __( 'No new articles assigned for section ID: %d.', 'knowledgebase' ), + $current_section_id + ); + } + + $articles_count = count( $articles ); + $articles_in_batch += $articles_count; + $current_article_offset += $articles_count; + + if ( $articles_count < $articles_to_fetch || $current_article_offset >= ( $section_article_counts[ $current_section_id ] ?? 0 ) ) { + $log[] = sprintf( + /* translators: 1: Section ID, 2: Article count */ + __( 'Finished processing articles for section ID: %1$d (found %2$d articles).', 'knowledgebase' ), + $current_section_id, + $articles_count + ); + ++$current_desc_section_index; + $current_article_offset = 0; + ++$sections_processed; + } + } + + $processed_count = count( $state['processed_article_ids'] ?? array() ); + $effective_total_articles = max( $total_articles, $processed_count ); + + $response_data['progress'] = $total_articles > 0 + ? round( max( 20, min( 80, ( $effective_total_articles > 0 ? ( $processed_count / $effective_total_articles ) : 0 ) * 60 + 20 ) ), 1 ) + : round( max( 20, min( 80, ( $current_top_section_index / max( 1, count( $top_section_ids ) ) ) * 60 + 20 ) ), 1 ); + + if ( $current_top_section_index < count( $top_section_ids ) ) { + $response_data['next_step'] = 2; + } + } + } + + set_transient( 'wzkb_migration_assigned_articles', $already_assigned_articles, DAY_IN_SECONDS ); + set_transient( 'wzkb_migration_log', $log, DAY_IN_SECONDS ); + + $state['current_top_section_index'] = $current_top_section_index; + $state['descendant_ids'] = $descendant_ids; + $state['current_desc_section_index'] = $current_desc_section_index; + $state['current_article_offset'] = $current_article_offset; + $processed_count = count( $state['processed_article_ids'] ?? array() ); + $effective_total_articles = max( $total_articles, $processed_count ); + $state['articles_processed'] = $processed_count; + $state['total_articles'] = $effective_total_articles; + $state['sections_mapped'] = $sections_mapped; + $state['top_sections_mapped'] = $top_sections_mapped; + $state['last_logged_section'] = $last_logged_section; + + $log[] = sprintf( + /* translators: 1: Top index, 2: Descendant index, 3: Offset, 4: Descendant count, 5: Sections mapped, 6: Top sections mapped, 7: Articles processed, 8: Total articles */ + __( 'Outgoing State: top_index=%1$d, desc_index=%2$d, offset=%3$d, desc_count=%4$d, sections_mapped=%5$d, top_sections_mapped=%6$d, articles_processed=%7$d/%8$d', 'knowledgebase' ), + $current_top_section_index, + $current_desc_section_index, + $current_article_offset, + count( $descendant_ids ), + $sections_mapped, + $top_sections_mapped, + $state['articles_processed'], + $total_articles + ); + + $response_data['log'] = array_slice( $log, $last_log_index ); + $state['last_log_index'] = count( $log ); + $response_data['state'] = $state; + set_transient( 'wzkb_migration_log', $log, DAY_IN_SECONDS ); + + break; + + case 3: + $last_log_index = isset( $state['last_log_index'] ) ? (int) $state['last_log_index'] : 0; + $response_data['message'] = '' . __( 'Deleting old top-level sections...', 'knowledgebase' ) . ''; + $log[] = $response_data['message']; + $top_section_ids = $state['top_section_ids'] ?? array(); + $sections_deleted = 0; + foreach ( $top_section_ids as $section_id ) { + if ( ! $dry_run ) { + $result = wp_delete_term( $section_id, $this->taxonomy_section ); + if ( ! is_wp_error( $result ) ) { + ++$sections_deleted; + } + } else { + ++$sections_deleted; + } + $log[] = sprintf( + /* translators: %d: Section ID */ + __( 'Deleting top-level section ID: %d.', 'knowledgebase' ), + $section_id + ); + } + $state['sections_deleted'] = $sections_deleted; + + if ( $dry_run ) { + $simulated_product_ids = $state['simulated_product_ids'] ?? array(); + if ( ! empty( $simulated_product_ids ) ) { + foreach ( $simulated_product_ids as $sim_product_id ) { + wp_delete_term( $sim_product_id, $this->taxonomy_product ); + $log[] = sprintf( + /* translators: 1: Product ID */ + __( 'Deleted simulated product with ID: %1$d after dry run.', 'knowledgebase' ), + $sim_product_id + ); + } + $state['simulated_product_ids'] = array(); + } + } + + $log[] = sprintf( + /* translators: 1: Top index, 2: Descendant index, 3: Offset, 4: Descendant count, 5: Sections mapped, 6: Top sections mapped, 7: Articles processed, 8: Total articles */ + __( 'Outgoing State: top_index=%1$d, desc_index=%2$d, offset=%3$d, desc_count=%4$d, sections_mapped=%5$d, top_sections_mapped=%6$d, articles_processed=%7$d/%8$d', 'knowledgebase' ), + $state['current_top_section_index'] ?? 0, + $state['current_desc_section_index'] ?? 0, + $state['current_article_offset'] ?? 0, + count( $state['descendant_ids'] ?? array() ), + $state['sections_mapped'] ?? 0, + $state['top_sections_mapped'] ?? 0, + $state['articles_processed'] ?? 0, + $state['total_articles'] ?? 0 + ); + + $log[] = '' . sprintf( + /* translators: 1: Products created, 2: Sub-sections mapped, 3: Articles processed, 4: Sections deleted */ + __( 'Migration Summary: Created %1$d products from top-level sections, mapped %2$d sub-sections to products, processed %3$d articles, and deleted %4$d old top-level sections.', 'knowledgebase' ), + $state['top_sections_mapped'], + $state['sections_mapped'], + $state['articles_processed'] ?? 0, + $sections_deleted + ) . ''; + + if ( ! $dry_run ) { + update_option( 'wzkb_product_migration_complete', time() ); + } + + $response_data['progress'] = 100; + $response_data['done'] = true; + $response_data['state'] = $state; + $response_data['message'] = '' . ( $dry_run ? __( 'Dry run complete! No changes were made.', 'knowledgebase' ) : __( 'Migration complete!', 'knowledgebase' ) ) . ''; + $response_data['log'] = array_slice( $log, $last_log_index ); + $state['last_log_index'] = count( $log ); + $response_data['state'] = $state; + + delete_transient( 'wzkb_migration_assigned_articles' ); + delete_transient( 'wzkb_migration_article_counts' ); + delete_transient( 'wzkb_migration_log' ); + + break; + + default: + $response_data['message'] = __( 'Unknown migration step.', 'knowledgebase' ); + $response_data['errors'][] = $response_data['message']; + $response_data['done'] = true; + $response_data['log'] = array_slice( $log, $state['last_log_index'] ?? 0 ); + $state['last_log_index'] = count( $log ); + $response_data['state'] = $state; + + break; + } + + wp_send_json_success( $response_data ); + } + + /** + * Helper function: Recursively get all descendant section IDs for a given section. + * + * @param int $parent_id The parent section ID. + * @param string $taxonomy The taxonomy name. + * + * @return int[] Array of descendant section IDs. + */ + private function get_all_descendant_section_ids( $parent_id, $taxonomy ) { + $descendants = array(); + $children = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'parent' => $parent_id, + 'fields' => 'ids', + ) + ); + if ( is_wp_error( $children ) || empty( $children ) ) { + return $descendants; + } + foreach ( $children as $child_id ) { + $descendants[] = (int) $child_id; + $descendants = array_merge( $descendants, $this->get_all_descendant_section_ids( (int) $child_id, $taxonomy ) ); + } + return $descendants; + } + + /** + * Check if the notice has been dismissed for 90 days. + * + * @return bool True if the notice has been dismissed, false otherwise. + */ + public static function is_dismissed() { + $dismissed = get_user_meta( get_current_user_id(), 'wzkb_product_notice_dismissed', true ); + if ( empty( $dismissed ) ) { + return false; + } + return ( time() - (int) $dismissed ) < 90 * DAY_IN_SECONDS; + } + + /** + * Check if we are on a KB admin screen. + * + * @return bool True if we are on a KB admin screen, false otherwise. + */ + public static function is_kb_admin_screen() { + $screen = get_current_screen(); + return isset( $screen->post_type ) && 'wz_knowledgebase' === $screen->post_type; + } + + /** + * AJAX handler for dismissing the product notice. + * + * @return void + */ + public function dismiss_product_notice() { + check_ajax_referer( 'wzkb_dismiss_product_notice', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error(); + } + update_user_meta( get_current_user_id(), 'wzkb_product_notice_dismissed', time() ); + wp_send_json_success(); + } + + /** + * Display an error message in a consistent format. + * + * @param string $message The error message. + */ + public function display_error( $message ) { + echo '

    ' . esc_html( $message ) . '

    '; + } + + /** + * Display a success message in a consistent format. + * + * @param string $message The success message. + */ + public function display_success( $message ) { + echo '

    ' . esc_html( $message ) . '

    '; + } + + /** + * Display a warning message in a consistent format. + * + * @param string $message The warning message. + */ + public function display_warning( $message ) { + echo '

    ' . esc_html( $message ) . '

    '; + } +} diff --git a/includes/admin/class-product-section-selector.php b/includes/admin/class-product-section-selector.php new file mode 100644 index 0000000..7433fb0 --- /dev/null +++ b/includes/admin/class-product-section-selector.php @@ -0,0 +1,589 @@ + 'array', + 'single' => true, + 'sanitize_callback' => array( $this, 'sanitize_id_array' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ), + ), + 'auth_callback' => static function () { + return current_user_can( 'edit_posts' ); + }, + 'default' => array(), + ) + ); + + register_post_meta( + 'wz_knowledgebase', + '_wzkb_section_ids', + array( + 'type' => 'array', + 'single' => true, + 'sanitize_callback' => array( $this, 'sanitize_id_array' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ), + ), + 'auth_callback' => static function () { + return current_user_can( 'edit_posts' ); + }, + 'default' => array(), + ) + ); + } + + /** + * Sanitize array meta values. + * + * @param mixed $value Raw value. + * @return array + */ + public function sanitize_id_array( $value ) { + if ( empty( $value ) ) { + return array(); + } + + if ( ! is_array( $value ) ) { + $value = array( $value ); + } + + return array_values( + array_filter( + array_map( 'absint', $value ), + static function ( $maybe_id ) { + return $maybe_id > 0; + } + ) + ); + } + + /** + * Remove classic taxonomy metaboxes replaced by custom UI. + */ + public function remove_core_taxonomy_metaboxes() { + remove_meta_box( 'wzkb_categorydiv', 'wz_knowledgebase', 'side' ); + + if ( ! $this->is_block_editor_enabled() ) { + remove_meta_box( 'wzkb_productdiv', 'wz_knowledgebase', 'side' ); + remove_meta_box( 'tagsdiv-wzkb_product', 'wz_knowledgebase', 'side' ); + } + } + + /** + * Register replacement metabox for the classic editor. + */ + public function register_classic_sections_metabox() { + if ( $this->is_block_editor_enabled() ) { + return; + } + + add_meta_box( + 'wzkb-classic-sections', + esc_html__( 'Product-aware Sections', 'knowledgebase' ), + array( $this, 'render_classic_sections_metabox' ), + 'wz_knowledgebase', + 'side', + 'high' + ); + } + + /** + * Render the custom metabox contents. + * + * @param \WP_Post $post Current post. + */ + public function render_classic_sections_metabox( \WP_Post $post ) { + wp_nonce_field( 'wzkb_classic_sections', 'wzkb_classic_sections_nonce' ); + + $product_ids = $this->sanitize_id_array( get_post_meta( $post->ID, '_wzkb_product_ids', true ) ); + $section_ids = $this->sanitize_id_array( get_post_meta( $post->ID, '_wzkb_section_ids', true ) ); + ?> + + + +
    + +
    +

    + +

    +
    +
    +

    + +

    +
    +
    + + post_type || $this->is_block_editor_enabled() ) { + return; + } + + $asset_url = plugin_dir_url( __FILE__ ); + $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; + + wp_enqueue_script( + 'wzkb-classic-sections-metabox', + $asset_url . 'js/classic-sections-metabox' . $minimize . '.js', + array( 'jquery', 'wp-api-fetch' ), + WZKB_VERSION, + true + ); + + $post_id = $this->get_current_post_id(); + $products = $this->sanitize_id_array( $post_id ? get_post_meta( $post_id, '_wzkb_product_ids', true ) : array() ); + $sections = $this->sanitize_id_array( $post_id ? get_post_meta( $post_id, '_wzkb_section_ids', true ) : array() ); + $strings = $this->get_ui_strings(); + $product_map = $this->get_product_map(); + $localization = array( + 'endpoint' => esc_url_raw( rest_url( 'wzkb/v1/sections' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'products' => $product_map, + 'meta' => array( + 'products' => $products, + 'sections' => $sections, + ), + 'strings' => $strings, + ); + + wp_localize_script( 'wzkb-classic-sections-metabox', 'WZKBClassicSections', $localization ); + + wp_enqueue_style( + 'wzkb-classic-sections-metabox', + $asset_url . 'css/classic-sections-metabox' . $minimize . '.css', + array(), + WZKB_VERSION + ); + } + + /** + * Strip the section metabox from stored meta-box order. + * + * @param array|string $order Saved order. + * @return array|string + */ + public function filter_meta_box_order( $order ) { + if ( empty( $order ) || ! is_array( $order ) ) { + return $order; + } + + $hidden_boxes = array( 'wzkb_categorydiv' ); + if ( ! $this->is_block_editor_enabled() ) { + $hidden_boxes[] = 'wzkb_productdiv'; + $hidden_boxes[] = 'tagsdiv-wzkb_product'; + } + + foreach ( $order as $context => $boxes ) { + if ( empty( $boxes ) ) { + continue; + } + + $order[ $context ] = implode( + ',', + array_filter( + array_map( 'trim', explode( ',', $boxes ) ), + static function ( $box_id ) use ( $hidden_boxes ) { + return ! in_array( $box_id, $hidden_boxes, true ); + } + ) + ); + } + + return $order; + } + + /** + * Enqueue the Gutenberg panel assets. + */ + public function enqueue_editor_assets() { + if ( ! function_exists( 'get_current_screen' ) ) { + return; + } + + $screen = get_current_screen(); + if ( ! $screen || 'wz_knowledgebase' !== $screen->post_type ) { + return; + } + + $asset_url = plugin_dir_url( __FILE__ ); + $version = defined( 'WZKB_VERSION' ) ? WZKB_VERSION : '1.0.0'; + $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; + + wp_enqueue_script( + 'wzkb-editor-sections-panel', + $asset_url . 'js/editor-sections-panel' . $minimize . '.js', + array( 'wp-plugins', 'wp-edit-post', 'wp-element', 'wp-data', 'wp-components', 'wp-api-fetch' ), + $version, + true + ); + + $product_map = $this->get_product_map(); + $strings = $this->get_ui_strings(); + + wp_localize_script( + 'wzkb-editor-sections-panel', + 'WZKBEditorSections', + array( + 'endpoint' => esc_url_raw( rest_url( 'wzkb/v1/sections' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'products' => $product_map, + 'strings' => $strings, + ) + ); + + wp_enqueue_style( + 'wzkb-editor-sections-panel', + $asset_url . 'css/editor-sections-panel' . $minimize . '.css', + array( 'wp-components' ), + $version + ); + } + + /** + * Sync taxonomy assignments after a REST save. + * + * @param \WP_Post $post Post object. + * @param \WP_REST_Request $request Request. + * @param bool $creating Whether this is a create operation. + */ + public function sync_sections_after_rest_save( \WP_Post $post, \WP_REST_Request $request, $creating ) { + unset( $creating ); + + if ( 'wz_knowledgebase' !== $post->post_type ) { + return; + } + + if ( isset( $request['meta']['_wzkb_section_ids'] ) && is_array( $request['meta']['_wzkb_section_ids'] ) ) { + $this->assign_section_terms( $post->ID, $request['meta']['_wzkb_section_ids'] ); + } + + $this->sync_product_meta( $post->ID ); + } + + /** + * Ensure taxonomy stays in sync when posts are saved outside REST. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + */ + public function maybe_sync_sections_during_save( $post_id, $post ) { + if ( 'wz_knowledgebase' !== $post->post_type || wp_is_post_revision( $post ) || wp_is_post_autosave( $post_id ) ) { + return; + } + + if ( true === $this->is_syncing ) { + return; + } + + $this->is_syncing = true; + + $section_ids = get_post_meta( $post_id, '_wzkb_section_ids', true ); + + if ( empty( $section_ids ) ) { + $current_terms = get_the_terms( $post_id, 'wzkb_category' ); + if ( ! empty( $current_terms ) && ! is_wp_error( $current_terms ) ) { + $section_ids = array_map( 'absint', wp_list_pluck( $current_terms, 'term_id' ) ); + update_post_meta( $post_id, '_wzkb_section_ids', $section_ids ); + } + } + + try { + $this->assign_section_terms( $post_id, $section_ids ); + $this->sync_product_meta( $post_id ); + } finally { + $this->is_syncing = false; + } + } + + /** + * Capture classic editor submissions and persist meta. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + */ + public function maybe_capture_classic_submission( $post_id, $post ) { + if ( 'wz_knowledgebase' !== $post->post_type || $this->is_block_editor_enabled() ) { + return; + } + + if ( ! isset( $_POST['wzkb_classic_sections_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['wzkb_classic_sections_nonce'] ) ), 'wzkb_classic_sections' ) ) { + return; + } + + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + + $product_ids = $this->get_sanitized_ids_from_input( '_wzkb_product_ids' ); + $section_ids = $this->get_sanitized_ids_from_input( '_wzkb_section_ids' ); + + update_post_meta( $post_id, '_wzkb_product_ids', $product_ids ); + update_post_meta( $post_id, '_wzkb_section_ids', $section_ids ); + + wp_set_post_terms( $post_id, $product_ids, 'wzkb_product', false ); + } + + /** + * Assign taxonomy terms based on meta values. + * + * @param int $post_id Post ID. + * @param mixed $section_ids Raw section IDs. + */ + private function assign_section_terms( $post_id, $section_ids ) { + if ( empty( $section_ids ) ) { + wp_set_post_terms( $post_id, array(), 'wzkb_category', false ); + return; + } + + if ( ! is_array( $section_ids ) ) { + $section_ids = array( $section_ids ); + } + + $section_ids = array_values( + array_filter( + array_map( 'absint', $section_ids ), + static function ( $maybe_id ) { + return $maybe_id > 0; + } + ) + ); + + wp_set_post_terms( $post_id, $section_ids, 'wzkb_category', false ); + } + + /** + * Keep `_wzkb_product_ids` meta aligned with taxonomy selections. + * + * @param int $post_id Post ID. + */ + private function sync_product_meta( $post_id ) { + $product_terms = get_the_terms( $post_id, 'wzkb_product' ); + if ( empty( $product_terms ) || is_wp_error( $product_terms ) ) { + delete_post_meta( $post_id, '_wzkb_product_ids' ); + return; + } + + $product_ids = array_map( 'absint', wp_list_pluck( $product_terms, 'term_id' ) ); + update_post_meta( $post_id, '_wzkb_product_ids', array_values( $product_ids ) ); + } + + /** + * Retrieve sanitized IDs from POST input. + * + * @param string $key Field key. + * @return array + */ + private function get_sanitized_ids_from_input( string $key ): array { + $raw_value = filter_input( INPUT_POST, $key, FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY ); + + if ( null === $raw_value ) { + $string_value = filter_input( INPUT_POST, $key, FILTER_UNSAFE_RAW ); + + if ( null === $string_value || '' === $string_value ) { + return array(); + } + + $raw_value = explode( ',', $string_value ); + } + + $raw_value = (array) wp_unslash( $raw_value ); + $raw_value = array_filter( + array_map( + static function ( $item ) { + return sanitize_text_field( $item ); + }, + $raw_value + ), + static function ( $item ) { + return '' !== $item; + } + ); + + return $this->sanitize_id_array( $raw_value ); + } + + /** + * Determine if the block editor is active for Knowledge Base posts. + * + * @return bool + */ + private function is_block_editor_enabled(): bool { + return (bool) use_block_editor_for_post_type( 'wz_knowledgebase' ); + } + + /** + * Get the current editing post ID. + * + * @return int + */ + private function get_current_post_id(): int { + global $post; + + if ( $post instanceof \WP_Post && 'wz_knowledgebase' === $post->post_type ) { + return (int) $post->ID; + } + + $post_id = filter_input( INPUT_GET, 'post', FILTER_SANITIZE_NUMBER_INT ); + if ( $post_id ) { + return absint( $post_id ); + } + + $post_id = filter_input( INPUT_POST, 'post_ID', FILTER_SANITIZE_NUMBER_INT ); + return $post_id ? absint( $post_id ) : 0; + } + + /** + * Retrieve a cached map of product IDs to names. + * + * @return array + */ + private function get_product_map(): array { + if ( null !== $this->product_map ) { + return $this->product_map; + } + + $terms = get_terms( + array( + 'taxonomy' => 'wzkb_product', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + $this->product_map = array(); + + if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) { + foreach ( $terms as $term ) { + $this->product_map[ (int) $term->term_id ] = $term->name; + } + } + + return $this->product_map; + } + + /** + * Shared UI strings for selectors. + * + * @return array + */ + private function get_ui_strings(): array { + return array( + 'panelTitle' => esc_html__( 'Knowledge Base Sections', 'knowledgebase' ), + 'selectProducts' => esc_html__( 'Select a product to load its sections.', 'knowledgebase' ), + 'noSections' => esc_html__( 'No sections match the selected products.', 'knowledgebase' ), + 'loading' => esc_html__( 'Loading sections…', 'knowledgebase' ), + 'unassigned' => esc_html__( 'Sections without a product', 'knowledgebase' ), + /* translators: %s: Product name. */ + 'productHeading' => esc_html__( '%s sections', 'knowledgebase' ), + 'searchPlaceholder' => esc_html__( 'Search products…', 'knowledgebase' ), + 'noProductMatches' => esc_html__( 'No products match your search.', 'knowledgebase' ), + /* translators: 1: shown count. 2: total count. */ + 'productOverflow' => esc_html__( 'Showing first %1$s products out of %2$s. Refine your search.', 'knowledgebase' ), + ); + } +} diff --git a/includes/admin/class-section-product-meta.php b/includes/admin/class-section-product-meta.php new file mode 100644 index 0000000..e3dabb2 --- /dev/null +++ b/includes/admin/class-section-product-meta.php @@ -0,0 +1,186 @@ + 'wzkb_product', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + if ( empty( $products ) || is_wp_error( $products ) ) { + return; + } + ?> +
    + + +

    +
    + 'wzkb_product', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + if ( empty( $products ) || is_wp_error( $products ) ) { + return; + } + + // Get current product assignment. + $product = wzkb_get_section_product( $term ); + $product_id = $product ? $product->term_id : 0; + ?> + + + + +

    + + + settings_page_url = admin_url( 'edit.php?post_type=wz_knowledgebase&page=wzkb-settings' ); + + $args = array( + 'steps' => $this->get_wizard_steps(), + 'translation_strings' => $this->get_translation_strings(), + 'page_slug' => 'wzkb_wizard', + 'menu_args' => array( + 'parent' => 'edit.php?post_type=wz_knowledgebase', + 'capability' => 'manage_options', + ), + ); + + parent::__construct( $settings_key, $prefix, $args ); + + $this->additional_hooks(); + } + + /** + * Additional hooks specific to Knowledge Base. + * + * @since 3.0.0 + */ + protected function additional_hooks() { + Hook_Registry::add_action( 'wzkb_activate', array( $this, 'trigger_wizard_on_activation' ) ); + Hook_Registry::add_action( 'admin_init', array( $this, 'register_wizard_notice' ) ); + Hook_Registry::add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_custom_scripts' ) ); + Hook_Registry::add_action( 'wp_ajax_wzkb_flush_permalinks', array( $this, 'flush_permalinks' ) ); + + // Register Tom Select AJAX handlers for wizard taxonomy fields. + Hook_Registry::add_action( 'wp_ajax_' . $this->prefix . '_taxonomy_search_tom_select', array( Settings::class, 'taxonomy_search_tom_select' ) ); + } + + /** + * Get the skip wizard link URL. + * + * @since 3.0.0 + * + * @return string Skip wizard link URL. + */ + protected function get_skip_link_url() { + return $this->settings_page_url; + } + + /** + * Get wizard steps configuration. + * + * @since 3.0.0 + * + * @return array Wizard steps. + */ + public function get_wizard_steps() { + $all_settings_grouped = Settings::get_registered_settings(); + $all_settings = array(); + foreach ( $all_settings_grouped as $section_settings ) { + $all_settings = array_merge( $all_settings, $section_settings ); + } + + $multi_product = (int) \wzkb_get_option( 'multi_product', 0 ); + + $mode_keys = array( + 'multi_product', + 'category_level', + ); + $permalink_keys = array( + 'permalink_header', + 'kb_slug', + 'product_slug', + 'category_slug', + 'tag_slug', + 'article_permalink', + ); + $performance_keys = array( + 'cache', + 'cache_expiry', + ); + $display_settings_keys = array( + 'kb_title', + 'show_article_count', + 'show_excerpt', + 'clickable_section', + 'show_empty_sections', + 'limit', + 'show_related_articles', + 'show_sidebar', + ); + $style_settings_keys = array( + 'include_styles', + 'product_archive_layout', + 'kb_style', + 'columns', + 'custom_css', + ); + $pro_features_keys = array( + 'rating_system', + 'rating_tracking_method', + 'show_rating_stats', + 'help_widget_enabled', + 'help_widget_display_location', + 'help_widget_position', + 'help_widget_color', + 'help_widget_greeting', + 'help_widget_contact_enabled', + ); + + $steps = array( + 'welcome' => array( + 'title' => __( 'Knowledge Base Setup', 'knowledgebase' ), + 'description' => __( 'Thank you for installing Knowledge Base! This wizard will help you configure the essential settings to get your knowledge base working perfectly.', 'knowledgebase' ), + 'settings' => array(), + ), + 'mode_settings' => array( + 'title' => __( 'Mode Settings', 'knowledgebase' ), + 'description' => __( 'Enable multi-product mode and determine which section level is displayed first.', 'knowledgebase' ), + 'settings' => $this->build_step_settings( $mode_keys, $all_settings ), + ), + 'permalink_performance' => array( + 'title' => __( 'Permalinks & Performance', 'knowledgebase' ), + 'description' => __( 'Define slugs, permalink structure, and caching settings for the knowledge base.', 'knowledgebase' ), + 'settings' => $this->build_step_settings( array_merge( $permalink_keys, $performance_keys ), $all_settings ), + ), + 'display_options' => array( + 'title' => __( 'Display Options', 'knowledgebase' ), + 'description' => __( 'Customize how the knowledge base archive looks and which metadata is visible.', 'knowledgebase' ), + 'settings' => $this->build_step_settings( array_merge( $display_settings_keys, $style_settings_keys ), $all_settings ), + ), + 'pro_features' => array( + 'title' => __( 'Pro Features', 'knowledgebase' ), + 'description' => __( 'Unlock premium features like ratings and the help widget. Configure the essentials here before diving deeper.', 'knowledgebase' ), + 'settings' => $this->build_step_settings( $pro_features_keys, $all_settings ), + ), + 'products_setup' => array( + 'title' => __( 'Create Products', 'knowledgebase' ), + 'description' => __( 'Add one or more products to organize your knowledge base content.', 'knowledgebase' ), + 'settings' => array(), + 'custom_step' => 'products', + ), + 'sections_setup' => array( + 'title' => __( 'Create Sections', 'knowledgebase' ), + 'description' => __( 'Add sections to organize your articles. You can add more later.', 'knowledgebase' ), + 'settings' => array(), + 'custom_step' => 'sections', + ), + 'subsections_setup' => array( + 'title' => __( 'Create Subsections', 'knowledgebase' ), + 'description' => __( 'Add subsections and assign them to a parent section. You can add more later.', 'knowledgebase' ), + 'settings' => array(), + 'custom_step' => 'subsections', + ), + ); + + /** + * Filter wizard steps. + * + * @param array $steps Wizard steps. + */ + return apply_filters( 'wzkb_wizard_steps', $steps ); + } + + /** + * Build settings array for a wizard step from keys. + * + * @since 3.0.0 + * + * @param array $keys Setting keys for this step. + * @param array $all_settings All settings array. + * @return array + */ + protected function build_step_settings( $keys, $all_settings ) { + $step_settings = array(); + + foreach ( $keys as $key ) { + if ( isset( $all_settings[ $key ] ) ) { + $step_settings[ $key ] = $all_settings[ $key ]; + } + } + + return $step_settings; + } + + /** + * Get translation strings for the wizard. + * + * @since 3.0.0 + * + * @return array Translation strings. + */ + public function get_translation_strings() { + return array( + 'page_title' => __( 'Knowledge Base Setup Wizard', 'knowledgebase' ), + 'menu_title' => __( 'Setup Wizard', 'knowledgebase' ), + 'next_step' => __( 'Next Step', 'knowledgebase' ), + 'previous_step' => __( 'Previous Step', 'knowledgebase' ), + 'finish_setup' => __( 'Finish Setup', 'knowledgebase' ), + 'skip_wizard' => __( 'Skip Wizard', 'knowledgebase' ), + /* translators: %s: Search query. */ + 'tom_select_no_results' => __( 'No results found for "%s"', 'knowledgebase' ), + 'steps_nav_aria_label' => __( 'Setup Wizard Steps', 'knowledgebase' ), + /* translators: %1$d: Current step number, %2$d: Total number of steps */ + 'step_of' => __( 'Step %1$d of %2$d', 'knowledgebase' ), + 'wizard_complete' => __( 'Setup Complete!', 'knowledgebase' ), + 'setup_complete' => __( 'Your Knowledge Base has been configured successfully. You can now start organizing your documentation.', 'knowledgebase' ), + 'go_to_settings' => __( 'Go to Settings', 'knowledgebase' ), + ); + } + + /** + * Trigger wizard on plugin activation. + * + * @since 3.0.0 + */ + public function trigger_wizard_on_activation() { + // Set a transient that will trigger the wizard on first admin page visit. + // This works better than an option because it's temporary and won't persist + // if the wizard is never accessed. + set_transient( 'wzkb_show_wizard_activation_redirect', true, HOUR_IN_SECONDS ); + + // Also set an option for more persistent storage in multisite environments. + update_option( 'wzkb_show_wizard', true ); + } + + /** + * Register the wizard notice with the Admin_Notices_API. + * + * @since 3.0.0 + */ + public function register_wizard_notice() { + // Get the Admin_Notices_API instance. + $admin_notices_api = wzkb()->admin->admin_notices_api; + if ( ! $admin_notices_api ) { + return; + } + + $admin_notices_api->register_notice( + array( + 'id' => 'wzkb_wizard_notice', + 'message' => sprintf( + '

    %s

    %s

    ', + esc_html__( 'Welcome to Knowledge Base! Would you like to run the setup wizard to configure the plugin?', 'knowledgebase' ), + esc_url( admin_url( 'admin.php?page=wzkb_wizard' ) ), + esc_html__( 'Run Setup Wizard', 'knowledgebase' ) + ), + 'type' => 'info', + 'dismissible' => true, + 'capability' => 'manage_options', + 'conditions' => array( + function () { + $page = sanitize_key( (string) filter_input( INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS ) ); + + // Only show if wizard is not completed, not dismissed, and activation flag is set. + // Check both transient and option to ensure it works in multisite environments. + return ! $this->is_wizard_completed() && + ! get_option( 'wzkb_wizard_notice_dismissed', false ) && + ( get_transient( 'wzkb_show_wizard_activation_redirect' ) || get_option( 'wzkb_show_wizard', false ) ) && + 'wzkb_wizard' !== $page; + }, + ), + ) + ); + } + + /** + * Get the URL to redirect to after wizard completion. + * + * @since 3.0.0 + * + * @return string Redirect URL. + */ + protected function get_completion_redirect_url() { + return $this->settings_page_url; + } + + /** + * Enqueue custom scripts for the wizard. + * + * @since 3.0.0 + * + * @param string $hook Current admin page hook. + */ + public function enqueue_custom_scripts( $hook ) { + if ( false === strpos( $hook, $this->page_slug ) ) { + return; + } + + $step_config = $this->get_current_step_config(); + $custom_step = $step_config['custom_step'] ?? ''; + $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; + + if ( in_array( $custom_step, array( 'products', 'sections', 'subsections' ), true ) ) { + wp_enqueue_style( + 'wzkb-wizard-content', + plugins_url( 'css/wizard-content' . $minimize . '.css', __FILE__ ), + array(), + WZKB_VERSION + ); + wp_enqueue_script( + 'wzkb-wizard-content', + plugins_url( 'js/wizard-content' . $minimize . '.js', __FILE__ ), + array( 'jquery' ), + WZKB_VERSION, + true + ); + } + } + + /** + * Process the current step's form data. + * + * Custom taxonomy setup steps don't use the settings table, so we need to + * handle them explicitly and still fire the step processed action. + */ + protected function process_current_step() { + $current_step_config = $this->get_current_step_config(); + $custom_step = $current_step_config['custom_step'] ?? ''; + + if ( in_array( $custom_step, array( 'products', 'sections', 'subsections' ), true ) ) { + switch ( $custom_step ) { + case 'products': + $this->process_products_submission(); + break; + case 'sections': + $this->process_sections_submission(); + break; + case 'subsections': + $this->process_subsections_submission(); + break; + } + + do_action( $this->prefix . '_wizard_step_processed', $this->current_step, array() ); + return; + } + + parent::process_current_step(); + } + + /** + * Override render_wizard_page to handle custom steps. + * + * @since 3.0.0 + */ + public function render_wizard_page() { + $this->current_step = $this->get_current_step(); + $step_config = $this->get_current_step_config(); + + if ( empty( $step_config ) ) { + $this->render_completion_page(); + return; + } + + $custom_step = $step_config['custom_step'] ?? ''; + if ( in_array( $custom_step, array( 'products', 'sections', 'subsections' ), true ) ) { + $this->render_taxonomy_setup_step( $custom_step, $step_config ); + return; + } + + parent::render_wizard_page(); + } + + /** + * Render custom taxonomy setup steps inside the wizard. + * + * @since 3.0.0 + * + * @param string $custom_step Custom step identifier. + * @param array $step_config Current step configuration. + * @return void + */ + protected function render_taxonomy_setup_step( string $custom_step, array $step_config ) { + $this->maybe_clamp_current_step(); + $multi_product = (int) \wzkb_get_option( 'multi_product', 0 ); + ?> +
    +

    translation_strings['wizard_title'] ); ?>

    + + render_wizard_steps_navigation(); ?> + +
    +
    +
    +
    +

    + translation_strings['step_of']; + printf( + esc_html( $step_pattern ), + esc_html( $current_step_name ), + esc_html( (string) $this->current_step ), + esc_html( (string) $this->total_steps ) + ); + ?> +

    +
    + +
    +
    +

    + +

    + + +
    + prefix}_wizard_nonce", "{$this->prefix}_wizard_nonce" ); ?> +
    + render_products_fields(); + break; + case 'sections': + $this->render_sections_fields( $multi_product ); + break; + case 'subsections': + $this->render_subsections_fields(); + break; + } + ?> +
    + + prefix}_wizard_before_actions", $this->current_step, $this->total_steps ); + ?> +
    + render_wizard_buttons(); ?> +
    +
    +
    +
    +
    + 'wzkb_product', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + if ( is_wp_error( $existing_products ) ) { + $existing_products = array(); + } + ?> +
    + + + + + + + + + + + + render_empty_repeater_row( 'wzkb_wizard_products', $existing_products ); ?> + +
    +

    + +

    +
    + 'wzkb_product', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + if ( is_wp_error( $products ) ) { + $products = array(); + } + } + $existing_sections = get_terms( + array( + 'taxonomy' => 'wzkb_category', + 'hide_empty' => false, + 'parent' => 0, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + if ( is_wp_error( $existing_sections ) ) { + $existing_sections = array(); + } + ?> +
    + + + + + + + + + + + + + + + render_empty_repeater_row( 'wzkb_wizard_sections', $existing_sections, $products, ( 1 === $multi_product ) ); ?> + +
    +

    + +

    +
    + 'wzkb_category', + 'hide_empty' => false, + 'parent' => 0, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + if ( is_wp_error( $sections ) ) { + $sections = array(); + } + $all_sections = get_terms( + array( + 'taxonomy' => 'wzkb_category', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + if ( is_wp_error( $all_sections ) ) { + $all_sections = array(); + } + $existing_subsections = array_values( + array_filter( + (array) $all_sections, + static function ( $term ) { + return is_numeric( $term->parent ) && (int) $term->parent > 0; + } + ) + ); + ?> +
    + + + + + + + + + + + + + render_empty_repeater_row( 'wzkb_wizard_subsections', $existing_subsections, $sections, true, true ); ?> + +
    +

    + +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + current_step < 1 ) { + $this->current_step = 1; + } + if ( $this->current_step > $this->total_steps ) { + $this->current_step = $this->total_steps; + update_option( "{$this->prefix}_wizard_current_step", $this->current_step ); + } + } + + /** + * Create products submitted from the products wizard step. + * + * @since 3.0.0 + * + * @return void + */ + protected function process_products_submission() { + $rows = filter_input( INPUT_POST, 'wzkb_wizard_products', FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY ); + if ( empty( $rows ) || ! is_array( $rows ) ) { + return; + } + foreach ( $rows as $row ) { + $this->insert_or_update_term_id_from_row( $row, 'wzkb_product' ); + } + } + + /** + * Create sections submitted from the sections wizard step. + * + * @since 3.0.0 + * + * @return void + */ + protected function process_sections_submission() { + $rows = filter_input( INPUT_POST, 'wzkb_wizard_sections', FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY ); + if ( empty( $rows ) || ! is_array( $rows ) ) { + return; + } + $multi_product = (int) \wzkb_get_option( 'multi_product', 0 ); + foreach ( $rows as $row ) { + $term_id = $this->insert_or_update_term_id_from_row( $row, 'wzkb_category' ); + if ( $term_id <= 0 ) { + continue; + } + if ( 1 === $multi_product ) { + $product_id = isset( $row['product_id'] ) ? absint( $row['product_id'] ) : 0; + if ( $product_id > 0 ) { + update_term_meta( $term_id, 'product_id', $product_id ); + } + } + } + } + + /** + * Create subsections submitted from the subsections wizard step. + * + * @since 3.0.0 + * + * @return void + */ + protected function process_subsections_submission() { + $rows = filter_input( INPUT_POST, 'wzkb_wizard_subsections', FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY ); + if ( empty( $rows ) || ! is_array( $rows ) ) { + return; + } + $multi_product = (int) \wzkb_get_option( 'multi_product', 0 ); + foreach ( $rows as $row ) { + $parent_id = isset( $row['parent_section_id'] ) ? absint( $row['parent_section_id'] ) : 0; + $existing_id = isset( $row['existing_id'] ) ? absint( $row['existing_id'] ) : 0; + if ( $parent_id <= 0 && $existing_id <= 0 ) { + continue; + } + if ( $existing_id > 0 && $parent_id <= 0 ) { + $existing_term = get_term( $existing_id, 'wzkb_category' ); + if ( $existing_term && ! is_wp_error( $existing_term ) ) { + $parent_id = (int) $existing_term->parent; + } + } + + $args = array(); + if ( $parent_id > 0 ) { + $args['parent'] = $parent_id; + } + $term_id = $this->insert_or_update_term_id_from_row( $row, 'wzkb_category', $args ); + if ( $term_id <= 0 ) { + continue; + } + if ( 1 === $multi_product ) { + $inherited_product_id = $parent_id > 0 ? (int) get_term_meta( $parent_id, 'product_id', true ) : 0; + if ( $inherited_product_id > 0 ) { + update_term_meta( $term_id, 'product_id', $inherited_product_id ); + } + } + } + } + + /** + * Insert a term from a repeater row, or return existing term ID when it exists. + * + * @since 3.0.0 + * + * @param mixed $row Raw row data. + * @param string $taxonomy Taxonomy name. + * @param array $extra_args Extra arguments passed to wp_insert_term. + * @return int Term ID on success, 0 on failure. + */ + protected function insert_or_update_term_id_from_row( $row, string $taxonomy, array $extra_args = array() ): int { + if ( ! is_array( $row ) ) { + return 0; + } + $existing_id = isset( $row['existing_id'] ) ? absint( $row['existing_id'] ) : 0; + $name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( $row['name'] ) ) : ''; + $slug = isset( $row['slug'] ) ? sanitize_title( wp_unslash( $row['slug'] ) ) : ''; + $description = isset( $row['description'] ) ? wp_kses_post( wp_unslash( $row['description'] ) ) : ''; + + if ( $existing_id > 0 ) { + $args = array(); + if ( '' !== $name ) { + $args['name'] = $name; + } + if ( '' !== $slug ) { + $args['slug'] = $slug; + } + if ( '' !== $description ) { + $args['description'] = $description; + } + $args = array_merge( $args, $extra_args ); + + if ( empty( $args ) ) { + return $existing_id; + } + $updated = wp_update_term( $existing_id, $taxonomy, $args ); + if ( is_wp_error( $updated ) ) { + return 0; + } + return (int) ( $updated['term_id'] ?? $existing_id ); + } + + if ( '' === $name ) { + return 0; + } + if ( '' === $slug ) { + $slug = sanitize_title( $name ); + } + + $existing = get_term_by( 'slug', $slug, $taxonomy ); + if ( $existing instanceof \WP_Term ) { + return (int) $existing->term_id; + } + + $args = array_merge( + array( + 'slug' => $slug, + 'description' => $description, + ), + $extra_args + ); + + $inserted = wp_insert_term( $name, $taxonomy, $args ); + if ( is_wp_error( $inserted ) ) { + if ( 'term_exists' === $inserted->get_error_code() ) { + $term_id = (int) $inserted->get_error_data(); + return $term_id; + } + return 0; + } + return isset( $inserted['term_id'] ) ? (int) $inserted['term_id'] : 0; + } + + /** + * Handle AJAX request to flush permalinks. + * + * @since 3.0.0 + */ + public function flush_permalinks() { + check_ajax_referer( 'wzkb_flush_permalinks', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Insufficient permissions.', 'knowledgebase' ) ) ); + } + + flush_rewrite_rules(); + + wp_send_json_success( array( 'message' => esc_html__( 'Permalinks flushed successfully.', 'knowledgebase' ) ) ); + } + + /** + * Override the render completion page to show CRP specific content. + * + * @since 3.0.0 + */ + protected function render_completion_page() { + $multi_product = \wzkb_get_option( 'multi_product' ); + $pro_active = ! empty( wzkb()->pro ); + ?> +
    +
    +

    translation_strings['wizard_complete'] ); ?>

    +

    + translation_strings['setup_complete'] ); ?> +

    +
    + +
    +
    + + + + + +
    + +
    +
    +

    +
    + + + + +
    +

    + +
    + + +
    +

    + +
    +
    +
    +
    + settings_key = 'wzkb_settings'; + self::$prefix = 'wzkb'; + $this->menu_slug = 'wzkb-settings'; + + Hook_Registry::add_action( 'admin_menu', array( $this, 'initialise_settings' ) ); + Hook_Registry::add_action( 'admin_head', array( $this, 'admin_head' ), 11 ); + Hook_Registry::add_filter( 'plugin_row_meta', array( $this, 'plugin_row_meta' ), 11, 2 ); + Hook_Registry::add_filter( 'plugin_action_links_' . plugin_basename( WZKB_PLUGIN_FILE ), array( $this, 'plugin_actions_links' ) ); + Hook_Registry::add_filter( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ), 99 ); + + Hook_Registry::add_filter( self::$prefix . '_settings_sanitize', array( $this, 'change_settings_on_save' ), 99 ); + Hook_Registry::add_action( self::$prefix . '_settings_form_buttons', array( $this, 'render_wizard_button' ), 20 ); + } + + /** + * AJAX handler for Tom Select taxonomy searches. + * + * Used by Settings API and Setup Wizard taxonomy autocomplete fields. + * + * @since 3.0.0 + * + * @return void + */ + public static function taxonomy_search_tom_select(): void { + // Verify nonce. + if ( ! isset( $_REQUEST['nonce'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + wp_send_json_error(); + } + + $nonce_valid = wp_verify_nonce( sanitize_key( $_REQUEST['nonce'] ), self::$prefix . '_taxonomy_search_tom_select' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! $nonce_valid ) { + wp_send_json_error(); + } + + if ( ! isset( $_REQUEST['endpoint'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + wp_send_json_error(); + } + + $endpoint = sanitize_key( $_REQUEST['endpoint'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $term = isset( $_REQUEST['q'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['q'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + $comma = _x( ',', 'tag delimiter', 'knowledgebase' ); + if ( ',' !== $comma ) { + $term = str_replace( $comma, ',', $term ); + } + if ( false !== strpos( $term, ',' ) ) { + $term_parts = explode( ',', $term ); + $term = $term_parts[ count( $term_parts ) - 1 ]; + } + $term = trim( $term ); + + $allowed_endpoints = array( + 'category' => 'wzkb_category', + 'product' => 'wzkb_product', + 'tag' => 'wzkb_tag', + 'post_tag' => 'wzkb_tag', + ); + + $allowed_taxonomies = array_values( $allowed_endpoints ); + if ( isset( $allowed_endpoints[ $endpoint ] ) ) { + $taxonomy = $allowed_endpoints[ $endpoint ]; + } elseif ( in_array( $endpoint, $allowed_taxonomies, true ) ) { + $taxonomy = $endpoint; + } else { + wp_send_json_success( array() ); + } + $tax = get_taxonomy( $taxonomy ); + if ( ! $tax ) { + wp_send_json_success( array() ); + } + + if ( empty( $tax->cap->assign_terms ) || ! current_user_can( $tax->cap->assign_terms ) ) { + wp_send_json_error(); + } + + /** This filter has been defined in /wp-admin/includes/ajax-actions.php */ + $term_search_min_chars = (int) apply_filters( 'term_search_min_chars', 2, $tax, $term ); + if ( ( 0 === $term_search_min_chars ) || ( strlen( $term ) < $term_search_min_chars ) ) { + wp_send_json_success( array() ); + } + + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'name__like' => $term, + 'hide_empty' => false, + 'number' => 20, + ) + ); + + $results = array(); + foreach ( (array) $terms as $found_term ) { + if ( ! ( $found_term instanceof \WP_Term ) ) { + continue; + } + + $results[] = array( + 'value' => sprintf( '%1$s (%2$s:%3$d)', $found_term->name, $found_term->taxonomy, (int) $found_term->term_taxonomy_id ), + 'text' => $found_term->name, + ); + } + + wp_send_json_success( $results ); + } + + /** + * Initialise the settings API. + * + * @since 2.3.0 + */ + public function initialise_settings() { + $props = array( + 'default_tab' => 'general', + 'help_sidebar' => $this->get_help_sidebar(), + 'help_tabs' => $this->get_help_tabs(), + 'admin_footer_text' => $this->get_admin_footer_text(), + 'menus' => $this->get_menus(), + ); + + $args = array( + 'props' => $props, + 'translation_strings' => $this->get_translation_strings(), + 'settings_sections' => $this->get_settings_sections(), + 'registered_settings' => $this->get_registered_settings(), + 'upgraded_settings' => array(), + ); + + $this->settings_api = new Settings\Settings_API( $this->settings_key, self::$prefix, $args ); + } + + /** + * Get settings defaults. + * + * @since 3.0.0 + * + * @return array Default settings. + */ + public static function settings_defaults() { + $defaults = array(); + + // Get all registered settings. + $settings = self::get_registered_settings(); + $default_types = array( + 'color', + 'css', + 'csv', + 'file', + 'html', + 'multicheck', + 'number', + 'numbercsv', + 'password', + 'postids', + 'posttypes', + 'radio', + 'radiodesc', + 'repeater', + 'select', + 'sensitive', + 'taxonomies', + 'text', + 'textarea', + 'thumbsizes', + 'url', + 'wysiwyg', + ); + + // Loop through each section. + foreach ( $settings as $section_settings ) { + // Loop through each setting in the section. + foreach ( $section_settings as $setting ) { + if ( ! isset( $setting['id'] ) ) { + continue; + } + + $setting_id = $setting['id']; + $setting_type = $setting['type'] ?? ''; + $default_value = ''; + + // When checkbox is set to true, set this to 1. + if ( 'checkbox' === $setting_type ) { + $default_value = isset( $setting['default'] ) ? (int) (bool) $setting['default'] : 0; + } elseif ( isset( $setting['default'] ) && in_array( $setting_type, $default_types, true ) ) { + $default_value = $setting['default']; + } + + $defaults[ $setting_id ] = $default_value; + } + } + + /** + * Filter the default settings array. + * + * @since 1.0.0 + * + * @param array $defaults Default settings. + */ + return apply_filters( self::$prefix . '_settings_defaults', $defaults ); + } + + /** + * Array containing the translation strings. + * + * @since 1.8.0 + * + * @return array Translation strings. + */ + public function get_translation_strings() { + $strings = array( + 'page_title' => esc_html__( 'Knowledge Base Settings', 'knowledgebase' ), + 'menu_title' => esc_html__( 'Settings', 'knowledgebase' ), + 'page_header' => esc_html__( 'Knowledge Base Settings', 'knowledgebase' ), + 'reset_message' => esc_html__( 'Settings have been reset to their default values. Reload this page to view the updated settings.', 'knowledgebase' ), + 'success_message' => esc_html__( 'Settings updated.', 'knowledgebase' ), + 'save_changes' => esc_html__( 'Save Changes', 'knowledgebase' ), + 'reset_settings' => esc_html__( 'Reset all settings', 'knowledgebase' ), + 'reset_button_confirm' => esc_html__( 'Do you really want to reset all these settings to their default values?', 'knowledgebase' ), + 'checkbox_modified' => esc_html__( 'Modified from default setting', 'knowledgebase' ), + 'button_label' => esc_html__( 'Choose File', 'knowledgebase' ), + 'previous_saved' => esc_html__( 'Previously saved', 'knowledgebase' ), + ); + + /** + * Filter the array containing the settings' sections. + * + * @since 2.2.0 + * + * @param array $strings Translation strings. + */ + return apply_filters( self::$prefix . '_translation_strings', $strings ); + } + + /** + * Get the admin menus. + * + * @return array Admin menus. + */ + public function get_menus() { + $menus = array(); + + // Settings menu. + $menus[] = array( + 'settings_page' => true, + 'type' => 'submenu', + 'parent_slug' => 'edit.php?post_type=wz_knowledgebase', + 'page_title' => esc_html__( 'Knowledge Base Settings', 'knowledgebase' ), + 'menu_title' => esc_html__( 'Settings', 'knowledgebase' ), + 'menu_slug' => $this->menu_slug, + ); + + return $menus; + } + + /** + * Array containing the settings' sections. + * + * @since 2.3.0 + * + * @return array Settings array + */ + public static function get_settings_sections() { + $sections = array( + 'general' => __( 'General', 'knowledgebase' ), + 'output' => __( 'Output', 'knowledgebase' ), + 'styles' => __( 'Styles', 'knowledgebase' ), + 'pro' => __( 'Pro', 'knowledgebase' ), + ); + + /** + * Filter the array containing the settings' sections. + * + * @since 2.2.0 + * + * @param array $sections Array of settings' sections + */ + return apply_filters( self::$prefix . '_settings_sections', $sections ); + } + + + /** + * Retrieve the array of plugin settings + * + * @since 2.3.0 + * + * @return array Settings array + */ + public static function get_registered_settings() { + $settings = array(); + $sections = self::get_settings_sections(); + + foreach ( $sections as $section => $value ) { + $method_name = 'settings_' . $section; + if ( method_exists( __CLASS__, $method_name ) ) { + $settings[ $section ] = self::$method_name(); + } + } + + /** + * Filters the settings array + * + * @since 2.2.0 + * + * @param array $Knowledgebase_setings Settings array + */ + return apply_filters( self::$prefix . '_registered_settings', $settings ); + } + + /** + * Retrieve the array of General settings + * + * @since 2.3.0 + * + * @return array General settings array + */ + public static function settings_general() { + $settings = array( + 'multi_product' => array( + 'id' => 'multi_product', + 'name' => esc_html__( 'Enable Multi-Product Mode', 'knowledgebase' ), + 'desc' => esc_html__( + 'Enable this option to use a dedicated “Products” menu to organize your knowledge base articles and sections by product. This system allows you to assign each article or section to one or more products, making it easier to manage documentation for different software, hardware, or service lines. If your knowledge base does not need this level of organization, you can leave this option disabled.', + 'knowledgebase' + ), + 'type' => 'checkbox', + 'default' => false, + ), + 'kb_homepage_mode' => array( + 'id' => 'kb_homepage_mode', + 'name' => esc_html__( 'Use Knowledge Base as Homepage', 'knowledgebase' ), + 'desc' => esc_html__( 'Enable this option to display the Knowledge Base on the site homepage. The Knowledge Base URL will serve as the homepage, and the Knowledge Base archive URL will redirect to it.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => false, + 'pro' => true, + ), + 'permalink_header' => array( + 'id' => 'permalink_header', + 'name' => '

    ' . esc_html__( 'Permalinks', 'knowledgebase' ) . '

    ', + 'desc' => esc_html__( 'The following settings affect the permalinks of the knowledge base. These are set when registering the custom post type and taxonomy. Please visit the Permalinks page in the Settings menu to refresh permalinks if you get 404 errors.', 'knowledgebase' ), + 'type' => 'header', + ), + 'kb_slug' => array( + 'id' => 'kb_slug', + 'name' => esc_html__( 'Knowledge Base slug', 'knowledgebase' ), + 'desc' => esc_html__( 'This will set the opening path of the URL of the knowledge base and is set when registering the custom post type', 'knowledgebase' ), + 'type' => 'text', + 'default' => 'knowledgebase', + 'field_class' => 'large-text', + ), + 'product_slug' => array( + 'id' => 'product_slug', + 'name' => esc_html__( 'Product slug', 'knowledgebase' ), + 'desc' => esc_html__( 'This slug forms part of the URL for product pages when Multi-Product Mode is enabled. The value is used when registering the custom taxonomy.', 'knowledgebase' ), + 'type' => 'text', + 'default' => 'kb/product', + 'field_class' => 'large-text', + ), + 'category_slug' => array( + 'id' => 'category_slug', + 'name' => esc_html__( 'Section slug', 'knowledgebase' ), + 'desc' => esc_html__( 'Each section is a section of the knowledge base. This setting is used when registering the custom section and forms a part of the URL when browsing section archives', 'knowledgebase' ), + 'type' => 'text', + 'default' => 'kb/section', + 'field_class' => 'large-text', + ), + 'tag_slug' => array( + 'id' => 'tag_slug', + 'name' => esc_html__( 'Tags slug', 'knowledgebase' ), + 'desc' => esc_html__( 'Each article can have multiple tags. This setting is used when registering the custom tag and forms a part of the URL when browsing tag archives', 'knowledgebase' ), + 'type' => 'text', + 'default' => 'kb/tags', + 'field_class' => 'large-text', + ), + 'article_permalink' => array( + 'id' => 'article_permalink', + 'name' => esc_html__( 'Article Permalink Structure', 'knowledgebase' ), + 'desc' => esc_html__( 'Structure for article URLs. Leave empty to use default which is the "Knowledge Base slug/%postname%".', 'knowledgebase' ), + 'type' => 'text', + 'default' => '', + 'field_class' => 'large-text', + 'pro' => true, + ), + 'performance_header' => array( + 'id' => 'performance_header', + 'name' => '

    ' . esc_html__( 'Performance', 'knowledgebase' ) . '

    ', + 'desc' => '', + 'type' => 'header', + ), + 'cache' => array( + 'id' => 'cache', + 'name' => esc_html__( 'Enable cache', 'knowledgebase' ), + 'desc' => esc_html__( 'Cache the output of the queries to speed up retrieval of the knowledgebase. Recommended for large knowledge bases', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => false, + ), + 'cache_expiry' => array( + 'id' => 'cache_expiry', + 'name' => esc_html__( 'Cache Time', 'knowledgebase' ), + 'desc' => esc_html__( 'How long should the knowledge base be cached for. Default is 1 day.', 'knowledgebase' ), + 'type' => 'select', + 'default' => DAY_IN_SECONDS, + 'options' => array( + 0 => esc_html__( 'No expiry', 'knowledgebase' ), + HOUR_IN_SECONDS => esc_html__( '1 Hour', 'knowledgebase' ), + 6 * HOUR_IN_SECONDS => esc_html__( '6 Hours', 'knowledgebase' ), + 12 * HOUR_IN_SECONDS => esc_html__( '12 Hours', 'knowledgebase' ), + DAY_IN_SECONDS => esc_html__( '1 Day', 'knowledgebase' ), + 3 * DAY_IN_SECONDS => esc_html__( '3 Days', 'knowledgebase' ), + WEEK_IN_SECONDS => esc_html__( '1 Week', 'knowledgebase' ), + 2 * WEEK_IN_SECONDS => esc_html__( '2 Weeks', 'knowledgebase' ), + MONTH_IN_SECONDS => esc_html__( '30 Days', 'knowledgebase' ), + 2 * MONTH_IN_SECONDS => esc_html__( '60 Days', 'knowledgebase' ), + 3 * MONTH_IN_SECONDS => esc_html__( '90 Days', 'knowledgebase' ), + YEAR_IN_SECONDS => esc_html__( '1 Year', 'knowledgebase' ), + ), + 'pro' => true, + ), + 'uninstall_header' => array( + 'id' => 'uninstall_header', + 'name' => '

    ' . esc_html__( 'Uninstall options', 'knowledgebase' ) . '

    ', + 'desc' => '', + 'type' => 'header', + 'default' => '', + ), + 'uninstall_options' => array( + 'id' => 'uninstall_options', + 'name' => esc_html__( 'Delete options on uninstall', 'knowledgebase' ), + 'desc' => esc_html__( 'Check this box to delete the settings on this page when the plugin is deleted via the Plugins page in your WordPress Admin', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + ), + 'uninstall_data' => array( + 'id' => 'uninstall_data', + 'name' => esc_html__( 'Delete all content on uninstall', 'knowledgebase' ), + 'desc' => esc_html__( 'Check this box to delete all the posts, categories and tags created by the plugin. There is no way to restore the data if you choose this option', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => false, + ), + 'feed_header' => array( + 'id' => 'feed_header', + 'name' => '

    ' . esc_html__( 'Feed options', 'knowledgebase' ) . '

    ', + 'desc' => '', + 'type' => 'header', + 'default' => '', + ), + 'include_in_feed' => array( + 'id' => 'include_in_feed', + 'name' => esc_html__( 'Include in feed', 'knowledgebase' ), + 'desc' => esc_html__( 'Adds the knowledge base articles to the main RSS feed for your site', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + ), + 'disable_kb_feed' => array( + 'id' => 'disable_kb_feed', + 'name' => esc_html__( 'Disable KB feed', 'knowledgebase' ), + /* translators: 1: Opening link tag, 2: Closing link tag. */ + 'desc' => sprintf( esc_html__( 'The knowledge base articles have a default feed. This option will disable the feed. You might need to %1$srefresh your permalinks%2$s when changing this option.', 'knowledgebase' ), '', '' ), + 'type' => 'checkbox', + 'default' => false, + ), + ); + + /** + * Filters the General settings array + * + * @since 2.2.0 + * + * @param array $settings General Settings array + */ + return apply_filters( self::$prefix . '_settings_general', $settings ); + } + + + /** + * Retrieve the array of Output settings + * + * @since 2.3.0 + * + * @return array Output settings array + */ + public static function settings_output() { + + $settings = array( + 'kb_title' => array( + 'id' => 'kb_title', + 'name' => esc_html__( 'Knowledge base title', 'knowledgebase' ), + 'desc' => esc_html__( 'This will be displayed as the archive title and in other relevant places.', 'knowledgebase' ), + 'type' => 'text', + 'default' => 'Knowledge Base', + 'field_class' => 'large-text', + ), + 'category_level' => array( + 'id' => 'category_level', + 'name' => esc_html__( 'First section level', 'knowledgebase' ), + 'desc' => esc_html__( 'Knowledge Base supports an unlimited hierarchy of sections. Set to 1 if using multi-product mode (with sections as the first level for each product). Set to 2 for traditional mode (top-level sections as product categories). This determines which section level is displayed in the grid layout. The default is 2, which was the behavior before version 3.0.', 'knowledgebase' ), + 'type' => 'number', + 'default' => '2', + 'size' => 'small', + 'min' => '1', + 'max' => '5', + ), + 'show_article_count' => array( + 'id' => 'show_article_count', + 'name' => esc_html__( 'Show article count', 'knowledgebase' ), + 'desc' => esc_html__( 'If selected, the number of articles will be displayed in an orange circle next to the header. You can override the color by styling wzkb_section_count', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + ), + 'show_excerpt' => array( + 'id' => 'show_excerpt', + 'name' => esc_html__( 'Show excerpt', 'knowledgebase' ), + 'desc' => esc_html__( 'Select to include the post excerpt after the article link', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => false, + ), + 'clickable_section' => array( + 'id' => 'clickable_section', + 'name' => esc_html__( 'Link section title', 'knowledgebase' ), + 'desc' => esc_html__( 'If selected, the title of each knowledge base section will link to its own page.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + ), + 'show_empty_sections' => array( + 'id' => 'show_empty_sections', + 'name' => esc_html__( 'Show empty sections', 'knowledgebase' ), + 'desc' => esc_html__( 'If selected, sections with no articles will also be displayed', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => false, + ), + 'limit' => array( + 'id' => 'limit', + 'name' => esc_html__( 'Max articles per section', 'knowledgebase' ), + 'desc' => esc_html__( 'Enter the number of articles that should be displayed in each section when viewing the knowledge base. Use -1 to display all articles (no limit). Once this limit is reached, the footer displays a "more link" to view the category.', 'knowledgebase' ), + 'type' => 'number', + 'default' => 5, + 'size' => 'small', + 'min' => -1, + 'max' => 500, + ), + 'show_sidebar' => array( + 'id' => 'show_sidebar', + 'name' => esc_html__( 'Show sidebar', 'knowledgebase' ), + 'desc' => esc_html__( 'Add the sidebar of your theme to the built-in templates for archives, sections, and search. This will not work with Block Themes. You will need to select an appropriate block template if you are using a block theme.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => false, + ), + 'show_related_articles' => array( + 'id' => 'show_related_articles', + 'name' => esc_html__( 'Show related articles', 'knowledgebase' ), + 'desc' => esc_html__( 'Add related articles at the bottom of the knowledge base article. Only works when using the inbuilt template.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + ), + ); + + /** + * Filters the Output settings array + * + * @since 2.2.0 + * + * @param array $settings Output Settings array + */ + return apply_filters( self::$prefix . '_settings_output', $settings ); + } + + + /** + * Retrieve the array of Styles settings + * + * @since 2.3.0 + * + * @return array Styles settings array + */ + public static function settings_styles() { + $settings = array( + 'product_archive_layout' => array( + 'id' => 'product_archive_layout', + 'name' => esc_html__( 'Product archive layout', 'knowledgebase' ), + 'desc' => esc_html__( 'Choose how products are displayed on the main Knowledge Base archive when Multi-Product Mode is enabled. “Sections list” shows each product with its sections listed below. The “Product cards grid” displays products as a grid of cards, allowing visitors to click through to a product page.', 'knowledgebase' ), + 'type' => 'select', + 'options' => array( + 'sections' => esc_html__( 'Sections list (current behavior)', 'knowledgebase' ), + 'grid' => esc_html__( 'Product cards grid', 'knowledgebase' ), + ), + 'default' => 'sections', + ), + 'include_styles' => array( + 'id' => 'include_styles', + 'name' => esc_html__( 'Include inbuilt styles', 'knowledgebase' ), + 'desc' => esc_html__( 'Uncheck this to disable this plugin from adding the inbuilt styles. You will need to add your own CSS styles if you disable this option', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + ), + 'kb_style' => array( + 'id' => 'kb_style', + 'name' => esc_html__( 'Knowledge Base Style', 'knowledgebase' ), + 'desc' => esc_html__( 'Select a visual style for your knowledge base display. Premium styles are available in the Pro version.', 'knowledgebase' ), + 'type' => 'select', + 'options' => self::get_kb_styles(), + 'default' => 'classic', + ), + 'columns' => array( + 'id' => 'columns', + 'name' => esc_html__( 'Number of columns', 'knowledgebase' ), + 'desc' => esc_html__( 'Set the number of columns to display the knowledge base archives. This will be overridden on smaller screens to optimize display.', 'knowledgebase' ), + 'type' => 'number', + 'default' => '2', + 'size' => 'small', + 'min' => '1', + 'max' => '5', + ), + 'custom_css' => array( + 'id' => 'custom_css', + 'name' => esc_html__( 'Custom CSS', 'knowledgebase' ), + 'desc' => esc_html__( 'Enter any custom valid CSS without any wrapping <style> tags', 'knowledgebase' ), + 'type' => 'css', + 'options' => '', + 'field_class' => 'codemirror_css', + ), + ); + + /** + * Filters the Styles settings array + * + * @since 2.2.0 + * + * @param array $settings Styles settings array + */ + return apply_filters( self::$prefix . '_settings_styles', $settings ); + } + + /** + * Retrieve the array of Pro settings + * + * @since 3.0.0 + * + * @return array Pro settings array + */ + public static function settings_pro() { + $settings = array( + 'rating_header' => array( + 'id' => 'rating_header', + 'name' => '

    ' . esc_html__( 'Article Rating', 'knowledgebase' ) . '

    ', + 'desc' => '', + 'type' => 'header', + ), + 'rating_system' => array( + 'id' => 'rating_system', + 'name' => esc_html__( 'Enable Rating System', 'knowledgebase' ), + 'desc' => esc_html__( 'Allow visitors to rate the quality of knowledge base articles.', 'knowledgebase' ), + 'type' => 'select', + 'default' => 'disabled', + 'options' => array( + 'disabled' => esc_html__( 'Disabled', 'knowledgebase' ), + 'binary' => esc_html__( 'Useful / Not Useful', 'knowledgebase' ), + 'scale' => esc_html__( '1-5 Star Rating', 'knowledgebase' ), + ), + 'pro' => true, + ), + 'rating_tracking_method' => array( + 'id' => 'rating_tracking_method', + 'name' => esc_html__( 'Vote Tracking Method', 'knowledgebase' ), + /* translators: %s: URL to rating system documentation */ + 'desc' => sprintf( + /* translators: %1$s: Opening link tag, %2$s: Closing link tag. */ + esc_html__( 'Choose how to prevent duplicate votes. Each method has different privacy implications. %1$sLearn more about tracking methods and GDPR compliance%2$s.', 'knowledgebase' ), + '', + '' + ), + 'type' => 'select', + 'default' => 'cookie', + 'options' => array( + 'none' => esc_html__( 'No Tracking (allows multiple votes)', 'knowledgebase' ), + 'cookie' => esc_html__( 'Cookie Only (requires consent)', 'knowledgebase' ), + 'ip' => esc_html__( 'IP Address Only (stores personal data)', 'knowledgebase' ), + 'cookie_ip' => esc_html__( 'Cookie + IP Address (requires both)', 'knowledgebase' ), + 'logged_in_only' => esc_html__( 'Logged-in Users Only (best for authenticated sites)', 'knowledgebase' ), + ), + 'pro' => true, + ), + 'show_rating_stats' => array( + 'id' => 'show_rating_stats', + 'name' => esc_html__( 'Show Rating Statistics', 'knowledgebase' ), + 'desc' => esc_html__( 'Display the average rating and vote count below the rating buttons.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + 'pro' => true, + ), + 'help_widget_header' => array( + 'id' => 'help_widget_header', + 'name' => '

    ' . esc_html__( 'Help Widget', 'knowledgebase' ) . '

    ', + 'desc' => esc_html__( 'A floating help widget that provides self-service support with search, suggested articles, and contact form.', 'knowledgebase' ), + 'type' => 'header', + ), + 'help_widget_enabled' => array( + 'id' => 'help_widget_enabled', + 'name' => esc_html__( 'Enable Help Widget', 'knowledgebase' ), + 'desc' => esc_html__( 'Display a floating help widget on your site for self-service support.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => false, + 'pro' => true, + ), + 'help_widget_display_location' => array( + 'id' => 'help_widget_display_location', + 'name' => esc_html__( 'Display Location', 'knowledgebase' ), + 'desc' => esc_html__( 'Choose where the help widget appears on your site.', 'knowledgebase' ), + 'type' => 'select', + 'default' => 'kb_only', + 'options' => array( + 'kb_only' => esc_html__( 'Knowledge Base Only', 'knowledgebase' ), + 'sitewide' => esc_html__( 'Entire Site', 'knowledgebase' ), + ), + 'pro' => true, + ), + 'help_widget_position' => array( + 'id' => 'help_widget_position', + 'name' => esc_html__( 'Button Position', 'knowledgebase' ), + 'desc' => esc_html__( 'Choose where the help widget button appears on the screen.', 'knowledgebase' ), + 'type' => 'select', + 'default' => 'right', + 'options' => array( + 'right' => esc_html__( 'Bottom Right', 'knowledgebase' ), + 'left' => esc_html__( 'Bottom Left', 'knowledgebase' ), + ), + 'pro' => true, + ), + 'help_widget_button_style' => array( + 'id' => 'help_widget_button_style', + 'name' => esc_html__( 'Button Style', 'knowledgebase' ), + 'desc' => esc_html__( 'Choose how the help widget button is displayed.', 'knowledgebase' ), + 'type' => 'select', + 'default' => 'icon', + 'options' => array( + 'icon' => esc_html__( 'Icon Only', 'knowledgebase' ), + 'text' => esc_html__( 'Text Only', 'knowledgebase' ), + 'icon_and_text' => esc_html__( 'Icon and Text', 'knowledgebase' ), + ), + 'pro' => true, + ), + 'help_widget_button_text' => array( + 'id' => 'help_widget_button_text', + 'name' => esc_html__( 'Button Text', 'knowledgebase' ), + 'desc' => esc_html__( 'Text to display on the help widget button (when text style is selected).', 'knowledgebase' ), + 'type' => 'text', + 'default' => __( 'Help', 'knowledgebase' ), + 'field_class' => 'regular-text', + 'pro' => true, + ), + 'help_widget_color' => array( + 'id' => 'help_widget_color', + 'name' => esc_html__( 'Help Widget Color', 'knowledgebase' ), + 'desc' => esc_html__( 'Primary color for the help widget button and interface elements.', 'knowledgebase' ), + 'type' => 'color', + 'default' => '#617DEC', + 'field_class' => 'color-field', + 'pro' => true, + ), + 'help_widget_hover_color' => array( + 'id' => 'help_widget_hover_color', + 'name' => esc_html__( 'Help Widget Hover Color', 'knowledgebase' ), + 'desc' => esc_html__( 'Hover color for buttons and interactive elements.', 'knowledgebase' ), + 'type' => 'color', + 'default' => '#4c63d2', + 'field_class' => 'color-field', + 'pro' => true, + ), + 'help_widget_text_color' => array( + 'id' => 'help_widget_text_color', + 'name' => esc_html__( 'Help Widget Text Color', 'knowledgebase' ), + 'desc' => esc_html__( 'Text color for the help widget button and interface elements.', 'knowledgebase' ), + 'type' => 'color', + 'default' => '#ffffff', + 'field_class' => 'color-field', + 'pro' => true, + ), + 'help_widget_hover_text_color' => array( + 'id' => 'help_widget_hover_text_color', + 'name' => esc_html__( 'Help Widget Hover Text Color', 'knowledgebase' ), + 'desc' => esc_html__( 'Text color for the help widget button on hover.', 'knowledgebase' ), + 'type' => 'color', + 'default' => '#ffffff', + 'field_class' => 'color-field', + 'pro' => true, + ), + 'help_widget_panel_bg_color' => array( + 'id' => 'help_widget_panel_bg_color', + 'name' => esc_html__( 'Panel Background Color', 'knowledgebase' ), + 'desc' => esc_html__( 'Background color for the help widget panel.', 'knowledgebase' ), + 'type' => 'color', + 'default' => '#ffffff', + 'field_class' => 'color-field', + 'pro' => true, + ), + 'help_widget_panel_text_color' => array( + 'id' => 'help_widget_panel_text_color', + 'name' => esc_html__( 'Panel Text Color', 'knowledgebase' ), + 'desc' => esc_html__( 'Default text color within the help widget panel.', 'knowledgebase' ), + 'type' => 'color', + 'default' => '#1a1a1a', + 'field_class' => 'color-field', + 'pro' => true, + ), + 'help_widget_link_hover_color' => array( + 'id' => 'help_widget_link_hover_color', + 'name' => esc_html__( 'Link Hover Background', 'knowledgebase' ), + 'desc' => esc_html__( 'Background color when hovering over help widget links and list items.', 'knowledgebase' ), + 'type' => 'color', + 'default' => '#f3f4f6', + 'field_class' => 'color-field', + 'pro' => true, + ), + 'help_widget_greeting' => array( + 'id' => 'help_widget_greeting', + 'name' => esc_html__( 'Greeting Message', 'knowledgebase' ), + 'desc' => esc_html__( 'Welcome message shown when the help widget opens.', 'knowledgebase' ), + 'type' => 'text', + 'default' => __( 'Hi! How can we help you?', 'knowledgebase' ), + 'field_class' => 'large-text', + 'pro' => true, + ), + 'help_widget_search_placeholder' => array( + 'id' => 'help_widget_search_placeholder', + 'name' => esc_html__( 'Search Placeholder', 'knowledgebase' ), + 'desc' => esc_html__( 'Placeholder text for the search input field.', 'knowledgebase' ), + 'type' => 'text', + 'default' => __( 'Search for answers...', 'knowledgebase' ), + 'field_class' => 'large-text', + 'pro' => true, + ), + 'help_widget_contact_enabled' => array( + 'id' => 'help_widget_contact_enabled', + 'name' => esc_html__( 'Enable Contact Form', 'knowledgebase' ), + 'desc' => esc_html__( 'Allow visitors to send messages through the help widget.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + 'pro' => true, + ), + 'help_widget_contact_email' => array( + 'id' => 'help_widget_contact_email', + 'name' => esc_html__( 'Contact Email', 'knowledgebase' ), + 'desc' => esc_html__( 'Email address where help widget contact form submissions will be sent.', 'knowledgebase' ), + 'type' => 'text', + 'default' => get_option( 'admin_email' ), + 'field_class' => 'regular-text', + 'pro' => true, + ), + 'help_widget_show_on_mobile' => array( + 'id' => 'help_widget_show_on_mobile', + 'name' => esc_html__( 'Show on Mobile', 'knowledgebase' ), + 'desc' => esc_html__( 'Display the help widget on mobile devices.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + 'pro' => true, + ), + 'help_widget_enable_animation' => array( + 'id' => 'help_widget_enable_animation', + 'name' => esc_html__( 'Enable button pulse', 'knowledgebase' ), + 'desc' => esc_html__( 'Enable a subtle pulsing animation on the help widget button to draw attention. Disable to keep the button static.', 'knowledgebase' ), + 'type' => 'checkbox', + 'default' => true, + 'pro' => true, + ), + ); + + /** + * Filters the Pro settings array + * + * @since 3.0.0 + * + * @param array $settings Pro Settings array + */ + return apply_filters( self::$prefix . '_settings_pro', $settings ); + } + + /** + * Get available KB styles. + * + * Returns free styles by default. Pro and other extensions can add their styles via filter. + * + * @since 3.0.0 + * + * @return array Array of style options. + */ + public static function get_kb_styles() { + // Free styles only. + $styles = array( + 'classic' => esc_html__( 'Classic', 'knowledgebase' ), + 'vibrant' => esc_html__( 'Vibrant', 'knowledgebase' ), + ); + + /** + * Filter available KB styles. + * + * Allows Pro or other extensions to add their styles to the dropdown. + * + * @since 3.0.0 + * + * @param array $styles Array of style options (key => label). + */ + return apply_filters( 'wzkb_kb_styles', $styles ); + } + + /** + * Adding WordPress plugin action links. + * + * @since 3.0.0 + * + * @param array $links Array of links. + * @return array + */ + public function plugin_actions_links( $links ) { + + return array_merge( + array( + 'settings' => '' . esc_html__( 'Settings', 'knowledgebase' ) . '', + ), + $links + ); + } + + /** + * Add meta links on Plugins page. + * + * @since 3.0.0 + * + * @param array $links Array of Links. + * @param string $file Current file. + * @return array + */ + public function plugin_row_meta( $links, $file ) { + + if ( false !== strpos( $file, 'knowledgebase.php' ) ) { + $new_links = array( + 'support' => '' . esc_html__( 'Support', 'knowledgebase' ) . '', + 'donate' => '' . esc_html__( 'Donate', 'knowledgebase' ) . '', + 'contribute' => '' . esc_html__( 'Contribute', 'knowledgebase' ) . '', + ); + + $links = array_merge( $links, $new_links ); + } + return $links; + } + + /** + * Get the help sidebar content to display on the plugin settings page. + * + * @since 1.8.0 + */ + public function get_help_sidebar() { + $help_sidebar = + /* translators: 1: Plugin support site link. */ + '

    ' . sprintf( __( 'For more information or how to get support visit the support site.', 'knowledgebase' ), esc_url( 'https://webberzone.com/support/' ) ) . '

    ' . + /* translators: 1: WordPress.org support forums link. */ + '

    ' . sprintf( __( 'Please report bugs, contribute or request features on GitHub.', 'knowledgebase' ), esc_url( 'https://github.com/WebberZone/knowledgebase' ) ) . '

    ' . + '

    ' . sprintf( + /* translators: 1: Github issues link, 2: Github plugin page link. */ + __( 'Post an issue on GitHub (bug reports only).', 'knowledgebase' ), + esc_url( 'https://github.com/WebberZone/knowledgebase/issues' ), + esc_url( 'https://github.com/WebberZone/knowledgebase' ) + ) . '

    '; + + /** + * Filter to modify the help sidebar content. + * + * @since 2.3.0 + * + * @param string $help_sidebar Help sidebar content. + */ + return apply_filters( self::$prefix . '_settings_help', $help_sidebar ); + } + + /** + * Get the help tabs to display on the plugin settings page. + * + * @since 2.3.0 + */ + public function get_help_tabs() { + $help_tabs = array( + array( + 'id' => 'wzkb-settings-overview', + 'title' => __( 'Knowledge Base Settings', 'knowledgebase' ), + 'content' => + '

    ' . __( 'Configure every part of your docs experience from this screen. Use the sections on the left to move between General, Output, Styles, and Pro options.', 'knowledgebase' ) . '

    ' . + '

    ' . __( 'General covers Multi-Product Mode, URL slugs, caching, uninstall cleanup, and feed behaviour so links stay consistent and data is removed safely when required.', 'knowledgebase' ) . '

    ' . + '

    ' . __( 'Output controls how archives render—titles, hierarchy depth, article counts, excerpts, limits per section, sidebars, and related articles.', 'knowledgebase' ) . '

    ' . + '

    ' . __( 'Styles lets you switch layouts, set columns, and add custom CSS. Disable inbuilt styles if your theme already provides the styling you need.', 'knowledgebase' ) . '

    ' . + '

    ' . __( 'Pro features unlock article ratings, the floating help widget, and advanced layouts.', 'knowledgebase' ) . '

    ' . + '

    ' . sprintf( + /* translators: 1: Opening link tag, 2: Closing link tag. */ + __( '%1$sRead the full settings guide%2$s', 'knowledgebase' ), + '', + '' + ) . '

    ', + ), + ); + + /** + * Filter to add more help tabs. + * + * @since 2.2.0 + * + * @param array $help_tabs Associative array of help tabs. + */ + return apply_filters( self::$prefix . '_settings_help_tabs', $help_tabs ); + } + + /** + * Add CSS to admin head. + * + * @since 2.2.0 + */ + public function admin_head() { + if ( ! is_customize_preview() ) { + $css = ' + '; + + echo $css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + } + + /** + * Add footer text on the plugin page. + * + * @since 2.0.0 + */ + public static function get_admin_footer_text() { + return sprintf( + /* translators: 1: Opening achor tag with Plugin page link, 2: Closing anchor tag, 3: Opening anchor tag with review link. */ + __( 'Thank you for using %1$sWebberZone Knowledge_Base%2$s! Please %3$srate us%2$s on %3$sWordPress.org%2$s', 'knowledgebase' ), + '', + '', + '' + ); + } + + /** + * Enqueue scripts and styles. + * + * @since 2.3.0 + * + * @param string $hook Current hook. + */ + public function admin_enqueue_scripts( $hook ) { + + if ( ! isset( $this->settings_api->settings_page ) || $hook !== $this->settings_api->settings_page ) { + return; + } + wp_enqueue_script( 'wzkb-admin' ); + wp_enqueue_style( 'wzkb-admin-ui' ); + } + + /** + * Modify settings when they are being saved. + * + * @since 2.3.0 + * + * @param array $settings Settings array. + * @return array Sanitized settings array. + */ + public function change_settings_on_save( $settings ) { + + // Delete the cache. + \WebberZone\Knowledge_Base\Util\Cache::delete(); + + flush_rewrite_rules( true ); + + return $settings; + } + + /** + * Add Setup Wizard button on the settings page. + * + * @since 3.0.0 + * + * @return void + */ + public function render_wizard_button() { + printf( + '
    %s', + esc_attr__( 'Start Settings Wizard', 'knowledgebase' ), + esc_url( + add_query_arg( + array( + 'post_type' => 'wz_knowledgebase', + 'page' => 'wzkb_wizard', + ), + admin_url( 'edit.php' ) + ) + ), + esc_attr__( 'Start Settings Wizard', 'knowledgebase' ), + esc_html__( 'Start Settings Wizard', 'knowledgebase' ) + ); + } +} diff --git a/includes/admin/class-tools-page.php b/includes/admin/class-tools-page.php new file mode 100644 index 0000000..2277182 --- /dev/null +++ b/includes/admin/class-tools-page.php @@ -0,0 +1,172 @@ +parent_id = add_submenu_page( + 'edit.php?post_type=wz_knowledgebase', + esc_html__( 'Knowledge Base Tools', 'knowledgebase' ), + esc_html__( 'Tools', 'knowledgebase' ), + 'manage_options', + 'wzkb_tools_page', + array( $this, 'render_page' ) + ); + + Hook_Registry::add_action( 'load-' . $this->parent_id, array( $this, 'help_tabs' ) ); + } + + /** + * Enqueue scripts in admin area. + * + * @since 3.0.0 + * + * @param string $hook The current admin page. + */ + public function admin_enqueue_scripts( $hook ) { + if ( $hook === $this->parent_id ) { + wp_enqueue_style( 'wp-spinner' ); + } + } + + /** + * Render the tools page. + * + * @since 3.0.0 + * + * @return void + */ + public function render_page() { + ob_start(); + ?> +
    +

    + + + +
    +
    +
    + + + +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    + + set_help_sidebar( + '

    ' . sprintf( + /* translators: 1: Support link. */ + __( 'For more information visit the WebberZone support site.', 'knowledgebase' ), + esc_url( 'https://webberzone.com/support/' ) + ) . '

    ' + ); + + $screen->add_help_tab( + array( + 'id' => 'wzkb-tools-general', + 'title' => __( 'General', 'knowledgebase' ), + 'content' => + '

    ' . __( 'This screen provides tools for managing the Knowledge Base.', 'knowledgebase' ) . '

    ' . + '

    ' . __( 'Different features can add their own tool sections to this page.', 'knowledgebase' ) . '

    ', + ) + ); + + /** + * Action hook to add additional help tabs. + * + * @since 3.0.0 + * + * @param \WP_Screen $screen Current screen object. + */ + do_action( 'wzkb_tools_help_tabs', $screen ); + } +} diff --git a/includes/admin/class-walker-category-dropdown.php b/includes/admin/class-walker-category-dropdown.php new file mode 100644 index 0000000..f40e303 --- /dev/null +++ b/includes/admin/class-walker-category-dropdown.php @@ -0,0 +1,54 @@ + ' ); + + $label = trim( + sprintf( + '%1$s (ID: %2$d)', + $path, + $category->term_id + ) + ); + + $output .= "\t\n"; + } +} diff --git a/includes/admin/css/admin-banner-rtl.css b/includes/admin/css/admin-banner-rtl.css new file mode 100644 index 0000000..cb4521a --- /dev/null +++ b/includes/admin/css/admin-banner-rtl.css @@ -0,0 +1,169 @@ +/* Admin banner base styles +----------------------------------*/ +.wz-admin-banner { + margin: -20px -20px 0 -20px; + padding: 26px 36px; + background: linear-gradient(-180deg, #041f4e 0%, #031337 55%, #010713 100%); + color: #f6f8ff; + display: flex; + align-items: center; + gap: 28px; + box-sizing: border-box; +} + +.wz-admin-banner__intro { + flex: 1 1 auto; + min-width: 220px; + color: #f6f8ff; +} + +.wz-admin-banner__eyebrow { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background-color: rgba(127, 195, 255, 0.2); + color: #7fc3ff; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.wz-admin-banner__title { + margin: 10px 0 6px; + font-size: 1rem; + font-weight: 700; + line-height: 1.24; + color: #ffffff; +} + +.wz-admin-banner__text { + margin: 0; + font-size: 0.9rem; + line-height: 1.6; + color: #dfe6ff; + max-width: 540px; +} + +.wz-admin-banner__links { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.wz-admin-banner__link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + color: inherit; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease, + outline 0.2s ease; + outline: none; +} + +.wz-admin-banner__link:focus, +.wz-admin-banner__link:focus-visible, +.wz-admin-banner__link:active { + outline: 2px solid rgba(255, 255, 255, 0.7); + outline-offset: 2px; +} + +.wz-admin-banner__link--primary { + background-color: #ffbd59; + color: #0a0a0a; + box-shadow: 0 8px 24px rgba(255, 189, 89, 0.45); +} + +.wz-admin-banner__link--primary:hover, +.wz-admin-banner__link--primary:focus, +.wz-admin-banner__link--primary:focus-visible, +.wz-admin-banner__link--primary:active { + background-color: #f08c00; + transform: translateY(-1px); + outline: 2px solid #ffffff; + outline-offset: 2px; + color: #0a0a0a; +} + +.wz-admin-banner__link--secondary { + background-color: rgba(3, 32, 117, 0.18); + color: #f6f8ff; + box-shadow: inset 0 0 0 1px rgba(3, 32, 117, 0.45); +} + +.wz-admin-banner__link--secondary:hover, +.wz-admin-banner__link--secondary:focus, +.wz-admin-banner__link--secondary:focus-visible, +.wz-admin-banner__link--secondary:active { + background-color: #0d2f8d; + transform: translateY(-1px); + outline: 2px solid #ffbd59; + outline-offset: 2px; + color: #ffffff; +} + +.wz-admin-banner__link--current { + background-color: #c8302e; + color: #ffffff; + box-shadow: 0 8px 24px rgba(200, 48, 46, 0.4); +} + +.wz-admin-banner__link--current:hover, +.wz-admin-banner__link--current:focus, +.wz-admin-banner__link--current:focus-visible, +.wz-admin-banner__link--current:active { + background-color: #e04841; + color: #ffffff; + outline: 2px solid #ffffff; + outline-offset: 2px; + transform: translateY(-1px); +} + +@media screen and (max-width: 782px) { + .wz-admin-banner { + flex-direction: column; + align-items: flex-start; + gap: 18px; + margin: -16px -16px 12px -16px; + padding: 20px 20px; + } + + .wz-admin-banner__title { + font-size: 1.125rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .wz-admin-banner__link { + transition: none; + } +} + +@media screen and (max-width: 600px) { + body.wp-admin #wpbody { + padding-top: 0; + } + + body.wp-admin .wz-admin-banner { + margin: 0px -16px 0 -16px; + padding: 48px 20px 28px 20px; + } + + .wz-admin-banner__link { + font-size: 0.875rem; + } + + body.wp-admin .wz-admin-banner__title, + body.wp-admin .wz-admin-banner__text { + display: none; + } +} diff --git a/includes/admin/css/admin-banner-rtl.min.css b/includes/admin/css/admin-banner-rtl.min.css new file mode 100644 index 0000000..a015bcb --- /dev/null +++ b/includes/admin/css/admin-banner-rtl.min.css @@ -0,0 +1 @@ +.wz-admin-banner{margin:-20px -20px 0 -20px;padding:26px 36px;background:linear-gradient(-180deg,#041f4e 0,#031337 55%,#010713 100%);color:#f6f8ff;display:flex;align-items:center;gap:28px;box-sizing:border-box}.wz-admin-banner__intro{flex:1 1 auto;min-width:220px;color:#f6f8ff}.wz-admin-banner__eyebrow{display:inline-block;padding:2px 10px;border-radius:999px;background-color:rgba(127,195,255,.2);color:#7fc3ff;font-size:.9rem;text-transform:uppercase;letter-spacing:.06em;font-weight:600}.wz-admin-banner__title{margin:10px 0 6px;font-size:1rem;font-weight:700;line-height:1.24;color:#fff}.wz-admin-banner__text{margin:0;font-size:.9rem;line-height:1.6;color:#dfe6ff;max-width:540px}.wz-admin-banner__links{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.wz-admin-banner__link{display:inline-flex;align-items:center;gap:8px;padding:9px 16px;border-radius:999px;font-size:.9rem;font-weight:600;text-decoration:none;color:inherit;transition:transform .2s ease,box-shadow .2s ease,background-color .2s ease,outline .2s ease;outline:0}.wz-admin-banner__link:active,.wz-admin-banner__link:focus,.wz-admin-banner__link:focus-visible{outline:2px solid rgba(255,255,255,.7);outline-offset:2px}.wz-admin-banner__link--primary{background-color:#ffbd59;color:#0a0a0a;box-shadow:0 8px 24px rgba(255,189,89,.45)}.wz-admin-banner__link--primary:active,.wz-admin-banner__link--primary:focus,.wz-admin-banner__link--primary:focus-visible,.wz-admin-banner__link--primary:hover{background-color:#f08c00;transform:translateY(-1px);outline:2px solid #ffffff;outline-offset:2px;color:#0a0a0a}.wz-admin-banner__link--secondary{background-color:rgba(3,32,117,.18);color:#f6f8ff;box-shadow:inset 0 0 0 1px rgba(3,32,117,.45)}.wz-admin-banner__link--secondary:active,.wz-admin-banner__link--secondary:focus,.wz-admin-banner__link--secondary:focus-visible,.wz-admin-banner__link--secondary:hover{background-color:#0d2f8d;transform:translateY(-1px);outline:2px solid #ffbd59;outline-offset:2px;color:#fff}.wz-admin-banner__link--current{background-color:#c8302e;color:#fff;box-shadow:0 8px 24px rgba(200,48,46,.4)}.wz-admin-banner__link--current:active,.wz-admin-banner__link--current:focus,.wz-admin-banner__link--current:focus-visible,.wz-admin-banner__link--current:hover{background-color:#e04841;color:#fff;outline:2px solid #ffffff;outline-offset:2px;transform:translateY(-1px)}@media screen and (max-width:782px){.wz-admin-banner{flex-direction:column;align-items:flex-start;gap:18px;margin:-16px -16px 12px -16px;padding:20px 20px}.wz-admin-banner__title{font-size:1.125rem}}@media (prefers-reduced-motion:reduce){.wz-admin-banner__link{transition:none}}@media screen and (max-width:600px){body.wp-admin #wpbody{padding-top:0}body.wp-admin .wz-admin-banner{margin:0 -16px 0 -16px;padding:48px 20px 28px 20px}.wz-admin-banner__link{font-size:.875rem}body.wp-admin .wz-admin-banner__text,body.wp-admin .wz-admin-banner__title{display:none}} \ No newline at end of file diff --git a/includes/admin/css/admin-banner.css b/includes/admin/css/admin-banner.css new file mode 100644 index 0000000..bb6a57e --- /dev/null +++ b/includes/admin/css/admin-banner.css @@ -0,0 +1,165 @@ +/* Admin banner base styles +----------------------------------*/ +.wz-admin-banner { + margin: -20px -20px 0 -20px; + padding: 26px 36px; + background: linear-gradient(180deg, #041f4e 0%, #031337 55%, #010713 100%); + color: #f6f8ff; + display: flex; + align-items: center; + gap: 28px; + box-sizing: border-box; +} + +.wz-admin-banner__intro { + flex: 1 1 auto; + min-width: 220px; + color: #f6f8ff; +} + +.wz-admin-banner__eyebrow { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background-color: rgba(127, 195, 255, 0.2); + color: #7fc3ff; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.wz-admin-banner__title { + margin: 10px 0 6px; + font-size: 1rem; + font-weight: 700; + line-height: 1.24; + color: #ffffff; +} + +.wz-admin-banner__text { + margin: 0; + font-size: 0.9rem; + line-height: 1.6; + color: #dfe6ff; + max-width: 540px; +} + +.wz-admin-banner__links { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.wz-admin-banner__link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + color: inherit; + transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, outline 0.2s ease; + outline: none; +} + +.wz-admin-banner__link:focus, +.wz-admin-banner__link:focus-visible, +.wz-admin-banner__link:active { + outline: 2px solid rgba(255, 255, 255, 0.7); + outline-offset: 2px; +} + +.wz-admin-banner__link--primary { + background-color: #ffbd59; + color: #0a0a0a; + box-shadow: 0 8px 24px rgba(255, 189, 89, 0.45); +} + +.wz-admin-banner__link--primary:hover, +.wz-admin-banner__link--primary:focus, +.wz-admin-banner__link--primary:focus-visible, +.wz-admin-banner__link--primary:active { + background-color: #f08c00; + transform: translateY(-1px); + outline: 2px solid #ffffff; + outline-offset: 2px; + color: #0a0a0a; +} + +.wz-admin-banner__link--secondary { + background-color: rgba(3, 32, 117, 0.18); + color: #f6f8ff; + box-shadow: inset 0 0 0 1px rgba(3, 32, 117, 0.45); +} + +.wz-admin-banner__link--secondary:hover, +.wz-admin-banner__link--secondary:focus, +.wz-admin-banner__link--secondary:focus-visible, +.wz-admin-banner__link--secondary:active { + background-color: #0d2f8d; + transform: translateY(-1px); + outline: 2px solid #ffbd59; + outline-offset: 2px; + color: #ffffff; +} + +.wz-admin-banner__link--current { + background-color: #c8302e; + color: #ffffff; + box-shadow: 0 8px 24px rgba(200, 48, 46, 0.4); +} + +.wz-admin-banner__link--current:hover, +.wz-admin-banner__link--current:focus, +.wz-admin-banner__link--current:focus-visible, +.wz-admin-banner__link--current:active { + background-color: #e04841; + color: #ffffff; + outline: 2px solid #ffffff; + outline-offset: 2px; + transform: translateY(-1px); +} + +@media screen and (max-width: 782px) { + .wz-admin-banner { + flex-direction: column; + align-items: flex-start; + gap: 18px; + margin: -16px -16px 12px -16px; + padding: 20px 20px; + } + + .wz-admin-banner__title { + font-size: 1.125rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .wz-admin-banner__link { + transition: none; + } +} + +@media screen and (max-width: 600px) { + body.wp-admin #wpbody { + padding-top: 0; + } + + body.wp-admin .wz-admin-banner { + margin: 0px -16px 0 -16px; + padding: 48px 20px 28px 20px; + } + + .wz-admin-banner__link { + font-size: 0.875rem; + } + + body.wp-admin .wz-admin-banner__title, + body.wp-admin .wz-admin-banner__text { + display: none; + } +} \ No newline at end of file diff --git a/includes/admin/css/admin-banner.min.css b/includes/admin/css/admin-banner.min.css new file mode 100644 index 0000000..9aa6f3d --- /dev/null +++ b/includes/admin/css/admin-banner.min.css @@ -0,0 +1 @@ +.wz-admin-banner{margin:-20px -20px 0 -20px;padding:26px 36px;background:linear-gradient(180deg,#041f4e 0,#031337 55%,#010713 100%);color:#f6f8ff;display:flex;align-items:center;gap:28px;box-sizing:border-box}.wz-admin-banner__intro{flex:1 1 auto;min-width:220px;color:#f6f8ff}.wz-admin-banner__eyebrow{display:inline-block;padding:2px 10px;border-radius:999px;background-color:rgba(127,195,255,.2);color:#7fc3ff;font-size:.9rem;text-transform:uppercase;letter-spacing:.06em;font-weight:600}.wz-admin-banner__title{margin:10px 0 6px;font-size:1rem;font-weight:700;line-height:1.24;color:#fff}.wz-admin-banner__text{margin:0;font-size:.9rem;line-height:1.6;color:#dfe6ff;max-width:540px}.wz-admin-banner__links{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.wz-admin-banner__link{display:inline-flex;align-items:center;gap:8px;padding:9px 16px;border-radius:999px;font-size:.9rem;font-weight:600;text-decoration:none;color:inherit;transition:transform .2s ease,box-shadow .2s ease,background-color .2s ease,outline .2s ease;outline:0}.wz-admin-banner__link:active,.wz-admin-banner__link:focus,.wz-admin-banner__link:focus-visible{outline:2px solid rgba(255,255,255,.7);outline-offset:2px}.wz-admin-banner__link--primary{background-color:#ffbd59;color:#0a0a0a;box-shadow:0 8px 24px rgba(255,189,89,.45)}.wz-admin-banner__link--primary:active,.wz-admin-banner__link--primary:focus,.wz-admin-banner__link--primary:focus-visible,.wz-admin-banner__link--primary:hover{background-color:#f08c00;transform:translateY(-1px);outline:2px solid #ffffff;outline-offset:2px;color:#0a0a0a}.wz-admin-banner__link--secondary{background-color:rgba(3,32,117,.18);color:#f6f8ff;box-shadow:inset 0 0 0 1px rgba(3,32,117,.45)}.wz-admin-banner__link--secondary:active,.wz-admin-banner__link--secondary:focus,.wz-admin-banner__link--secondary:focus-visible,.wz-admin-banner__link--secondary:hover{background-color:#0d2f8d;transform:translateY(-1px);outline:2px solid #ffbd59;outline-offset:2px;color:#fff}.wz-admin-banner__link--current{background-color:#c8302e;color:#fff;box-shadow:0 8px 24px rgba(200,48,46,.4)}.wz-admin-banner__link--current:active,.wz-admin-banner__link--current:focus,.wz-admin-banner__link--current:focus-visible,.wz-admin-banner__link--current:hover{background-color:#e04841;color:#fff;outline:2px solid #ffffff;outline-offset:2px;transform:translateY(-1px)}@media screen and (max-width:782px){.wz-admin-banner{flex-direction:column;align-items:flex-start;gap:18px;margin:-16px -16px 12px -16px;padding:20px 20px}.wz-admin-banner__title{font-size:1.125rem}}@media (prefers-reduced-motion:reduce){.wz-admin-banner__link{transition:none}}@media screen and (max-width:600px){body.wp-admin #wpbody{padding-top:0}body.wp-admin .wz-admin-banner{margin:0 -16px 0 -16px;padding:48px 20px 28px 20px}.wz-admin-banner__link{font-size:.875rem}body.wp-admin .wz-admin-banner__text,body.wp-admin .wz-admin-banner__title{display:none}} \ No newline at end of file diff --git a/includes/admin/css/admin-rtl.css b/includes/admin/css/admin-rtl.css new file mode 100644 index 0000000..906d067 --- /dev/null +++ b/includes/admin/css/admin-rtl.css @@ -0,0 +1,246 @@ +/* Buttons +----------------------------------*/ +a.wzkb_button { + padding: 10px; + text-decoration: none; + text-shadow: none; + border-radius: 3px; + transition: all 0.3s ease 0s; + margin-left: 10px; + display: inline-block; +} + +a.wzkb_button:hover { + box-shadow: -3px 3px 10px #666; +} + +a.wzkb_button.wzkb_button_green, +a.wzkb_button.wzkb_button_green:focus { + color: #fff; + background: #008000; + border: 1px solid #003400; +} + +a.wzkb_button.wzkb_button_green:hover { + color: #fff; + background: #006400; +} + +a.wzkb_button.wzkb_button_red, +a.wzkb_button.wzkb_button_red:focus { + color: #fff; + background: #d63638; + border: 1px solid #b92c2e; +} + +a.wzkb_button.wzkb_button_red:hover { + color: #fff; + background: #b92c2e; +} + +a.wzkb_button.wzkb_button_blue, +a.wzkb_button.wzkb_button_blue:focus { + color: #fff; + background: #032075; + border: 1px solid #001f5b; +} + +a.wzkb_button.wzkb_button_blue:hover { + color: #fff; + background: #001f5b; +} + +a.wzkb_button.wzkb_button_gold, +a.wzkb_button.wzkb_button_gold:focus { + color: #000; + background: #ffbd59; + border: 1px solid #ffa500; +} + +a.wzkb_button.wzkb_button_gold:hover { + color: #000; + background: #ffa500; +} + +/* Knowledge Base admin banner +----------------------------------*/ +.wzkb-admin-banner { + margin: -20px -20px 0 -20px; + padding: 26px 36px; + background: linear-gradient(-180deg, #032075 0%, #04104a 55%, #0a0a0a 100%); + color: #f6f8ff; + display: flex; + align-items: center; + gap: 28px; + box-sizing: border-box; +} + +.wzkb-admin-banner__intro { + flex: 1 1 auto; + min-width: 220px; + color: #f6f8ff; +} + +.wzkb-admin-banner__eyebrow { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background-color: rgba(255, 189, 89, 0.24); + color: #ffbd59; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.wzkb-admin-banner__title { + margin: 10px 0 6px; + font-size: 1rem; + font-weight: 700; + line-height: 1.24; + color: #ffffff; +} + +.wzkb-admin-banner__text { + margin: 0; + font-size: 0.9rem; + line-height: 1.6; + color: #e9efff; + max-width: 540px; +} + +.wzkb-admin-banner__links { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.wzkb-admin-banner__link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + color: inherit; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease, + outline 0.2s ease; + outline: none; +} + +.wzkb-admin-banner__link--primary { + background-color: #ffbd59; + color: #0a0a0a; + box-shadow: 0 8px 24px rgba(255, 189, 89, 0.35); +} + +.wzkb-admin-banner__link--primary:hover, +.wzkb-admin-banner__link--primary:focus-visible { + background-color: #ffd37f; + transform: translateY(-1px); + outline: 2px solid #0a0a0a; + outline-offset: 2px; + color: #0a0a0a; +} + +.wzkb-admin-banner__link--secondary { + background: linear-gradient( + -180deg, + rgba(255, 255, 255, 0.22) 0%, + rgba(255, 255, 255, 0.1) 100% + ); + color: #f6f8ff; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32); +} + +.wzkb-admin-banner__link--secondary:hover, +.wzkb-admin-banner__link--secondary:focus-visible { + background-color: rgba(255, 255, 255, 0.65); + transform: translateY(-1px); + outline: 2px solid #ffbd59; + outline-offset: 2px; + color: #032075; +} + +.wzkb-admin-banner__link--accent { + background-color: #ffffff; + color: #032075; + box-shadow: 0 8px 24px rgba(3, 32, 117, 0.3); +} + +.wzkb-admin-banner__link--accent:hover, +.wzkb-admin-banner__link--accent:focus-visible { + background-color: #f0f3ff; + outline: 2px solid #032075; + outline-offset: 2px; + color: #032075; +} + +.wzkb-admin-banner__links .wzkb-admin-banner__link:focus-visible { + box-shadow: none; +} + +.wzkb-admin-banner__link--current { + background-color: #ffe2a3; + color: #0a0a0a; + box-shadow: inset 0 0 0 1px rgba(255, 189, 89, 0.75); +} + +.wzkb-admin-banner__link--current:hover, +.wzkb-admin-banner__link--current:focus-visible { + background-color: #ffd37f; + color: #0a0a0a; + outline: 2px solid #0a0a0a; + outline-offset: 2px; + transform: translateY(-1px); +} + +@media screen and (max-width: 782px) { + .wzkb-admin-banner { + flex-direction: column; + align-items: flex-start; + gap: 18px; + margin: -16px -16px 12px -16px; + padding: 20px 20px; + } + + .wzkb-admin-banner__title { + font-size: 1.125rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .wzkb-admin-banner__link { + transition: none; + } +} + +@media screen and (max-width: 600px) { + body.wp-admin #wpbody { + padding-top: 0; + } + + body.wp-admin .wzkb-admin-banner { + margin: 0px -16px 0 -16px; + padding: 50px 20px 30px 20px; + } + + .wzkb-admin-banner__link--plugins { + display: none; + } + + .wzkb-admin-banner__link { + font-size: 0.875rem; + } + + body.wp-admin .wzkb-admin-banner__title, + body.wp-admin .wzkb-admin-banner__text { + display: none; + } +} diff --git a/includes/admin/css/admin-rtl.min.css b/includes/admin/css/admin-rtl.min.css new file mode 100644 index 0000000..58f7140 --- /dev/null +++ b/includes/admin/css/admin-rtl.min.css @@ -0,0 +1 @@ +a.wzkb_button{padding:10px;text-decoration:none;text-shadow:none;border-radius:3px;transition:all .3s ease 0s;margin-left:10px;display:inline-block}a.wzkb_button:hover{box-shadow:-3px 3px 10px #666}a.wzkb_button.wzkb_button_green,a.wzkb_button.wzkb_button_green:focus{color:#fff;background:green;border:1px solid #003400}a.wzkb_button.wzkb_button_green:hover{color:#fff;background:#006400}a.wzkb_button.wzkb_button_red,a.wzkb_button.wzkb_button_red:focus{color:#fff;background:#d63638;border:1px solid #b92c2e}a.wzkb_button.wzkb_button_red:hover{color:#fff;background:#b92c2e}a.wzkb_button.wzkb_button_blue,a.wzkb_button.wzkb_button_blue:focus{color:#fff;background:#032075;border:1px solid #001f5b}a.wzkb_button.wzkb_button_blue:hover{color:#fff;background:#001f5b}a.wzkb_button.wzkb_button_gold,a.wzkb_button.wzkb_button_gold:focus{color:#000;background:#ffbd59;border:1px solid orange}a.wzkb_button.wzkb_button_gold:hover{color:#000;background:orange}.wzkb-admin-banner{margin:-20px -20px 0 -20px;padding:26px 36px;background:linear-gradient(-180deg,#032075 0,#04104a 55%,#0a0a0a 100%);color:#f6f8ff;display:flex;align-items:center;gap:28px;box-sizing:border-box}.wzkb-admin-banner__intro{flex:1 1 auto;min-width:220px;color:#f6f8ff}.wzkb-admin-banner__eyebrow{display:inline-block;padding:2px 10px;border-radius:999px;background-color:rgba(255,189,89,.24);color:#ffbd59;font-size:.9rem;text-transform:uppercase;letter-spacing:.06em;font-weight:600}.wzkb-admin-banner__title{margin:10px 0 6px;font-size:1rem;font-weight:700;line-height:1.24;color:#fff}.wzkb-admin-banner__text{margin:0;font-size:.9rem;line-height:1.6;color:#e9efff;max-width:540px}.wzkb-admin-banner__links{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.wzkb-admin-banner__link{display:inline-flex;align-items:center;gap:8px;padding:9px 16px;border-radius:999px;font-size:.9rem;font-weight:600;text-decoration:none;color:inherit;transition:transform .2s ease,box-shadow .2s ease,background-color .2s ease,outline .2s ease;outline:0}.wzkb-admin-banner__link--primary{background-color:#ffbd59;color:#0a0a0a;box-shadow:0 8px 24px rgba(255,189,89,.35)}.wzkb-admin-banner__link--primary:focus-visible,.wzkb-admin-banner__link--primary:hover{background-color:#ffd37f;transform:translateY(-1px);outline:2px solid #0a0a0a;outline-offset:2px;color:#0a0a0a}.wzkb-admin-banner__link--secondary{background:linear-gradient(-180deg,rgba(255,255,255,.22) 0,rgba(255,255,255,.1) 100%);color:#f6f8ff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.32)}.wzkb-admin-banner__link--secondary:focus-visible,.wzkb-admin-banner__link--secondary:hover{background-color:rgba(255,255,255,.65);transform:translateY(-1px);outline:2px solid #ffbd59;outline-offset:2px;color:#032075}.wzkb-admin-banner__link--accent{background-color:#fff;color:#032075;box-shadow:0 8px 24px rgba(3,32,117,.3)}.wzkb-admin-banner__link--accent:focus-visible,.wzkb-admin-banner__link--accent:hover{background-color:#f0f3ff;outline:2px solid #032075;outline-offset:2px;color:#032075}.wzkb-admin-banner__links .wzkb-admin-banner__link:focus-visible{box-shadow:none}.wzkb-admin-banner__link--current{background-color:#ffe2a3;color:#0a0a0a;box-shadow:inset 0 0 0 1px rgba(255,189,89,.75)}.wzkb-admin-banner__link--current:focus-visible,.wzkb-admin-banner__link--current:hover{background-color:#ffd37f;color:#0a0a0a;outline:2px solid #0a0a0a;outline-offset:2px;transform:translateY(-1px)}@media screen and (max-width:782px){.wzkb-admin-banner{flex-direction:column;align-items:flex-start;gap:18px;margin:-16px -16px 12px -16px;padding:20px 20px}.wzkb-admin-banner__title{font-size:1.125rem}}@media (prefers-reduced-motion:reduce){.wzkb-admin-banner__link{transition:none}}@media screen and (max-width:600px){body.wp-admin #wpbody{padding-top:0}body.wp-admin .wzkb-admin-banner{margin:0 -16px 0 -16px;padding:50px 20px 30px 20px}.wzkb-admin-banner__link--plugins{display:none}.wzkb-admin-banner__link{font-size:.875rem}body.wp-admin .wzkb-admin-banner__text,body.wp-admin .wzkb-admin-banner__title{display:none}} \ No newline at end of file diff --git a/includes/admin/css/admin.css b/includes/admin/css/admin.css index 8aed19b..e5081d0 100644 --- a/includes/admin/css/admin.css +++ b/includes/admin/css/admin.css @@ -14,7 +14,8 @@ a.wzkb_button:hover { box-shadow: 3px 3px 10px #666; } -a.wzkb_button.wzkb_button_green { +a.wzkb_button.wzkb_button_green, +a.wzkb_button.wzkb_button_green:focus { color: #fff; background: #008000; border: 1px solid #003400; @@ -25,7 +26,8 @@ a.wzkb_button.wzkb_button_green:hover { background: #006400; } -a.wzkb_button.wzkb_button_red { +a.wzkb_button.wzkb_button_red, +a.wzkb_button.wzkb_button_red:focus { color: #fff; background: #d63638; border: 1px solid #b92c2e; @@ -36,7 +38,8 @@ a.wzkb_button.wzkb_button_red:hover { background: #b92c2e; } -a.wzkb_button.wzkb_button_blue { +a.wzkb_button.wzkb_button_blue, +a.wzkb_button.wzkb_button_blue:focus { color: #fff; background: #032075; border: 1px solid #001f5b; @@ -47,13 +50,189 @@ a.wzkb_button.wzkb_button_blue:hover { background: #001f5b; } -a.wzkb_button.wzkb_button_gold { +a.wzkb_button.wzkb_button_gold, +a.wzkb_button.wzkb_button_gold:focus { color: #000; - background: #FFBD59; - border: 1px solid #FFA500; + background: #ffbd59; + border: 1px solid #ffa500; } a.wzkb_button.wzkb_button_gold:hover { color: #000; - background: #FFA500; -} \ No newline at end of file + background: #ffa500; +} + +/* Knowledge Base admin banner +----------------------------------*/ +.wzkb-admin-banner { + margin: -20px -20px 0 -20px; + padding: 26px 36px; + background: linear-gradient(180deg, #032075 0%, #04104a 55%, #0a0a0a 100%); + color: #f6f8ff; + display: flex; + align-items: center; + gap: 28px; + box-sizing: border-box; +} + +.wzkb-admin-banner__intro { + flex: 1 1 auto; + min-width: 220px; + color: #f6f8ff; +} + +.wzkb-admin-banner__eyebrow { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background-color: rgba(255, 189, 89, 0.24); + color: #ffbd59; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.wzkb-admin-banner__title { + margin: 10px 0 6px; + font-size: 1rem; + font-weight: 700; + line-height: 1.24; + color: #ffffff; +} + +.wzkb-admin-banner__text { + margin: 0; + font-size: 0.9rem; + line-height: 1.6; + color: #e9efff; + max-width: 540px; +} + +.wzkb-admin-banner__links { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.wzkb-admin-banner__link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + color: inherit; + transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, outline 0.2s ease; + outline: none; +} + +.wzkb-admin-banner__link--primary { + background-color: #ffbd59; + color: #0a0a0a; + box-shadow: 0 8px 24px rgba(255, 189, 89, 0.35); +} + +.wzkb-admin-banner__link--primary:hover, +.wzkb-admin-banner__link--primary:focus-visible { + background-color: #ffd37f; + transform: translateY(-1px); + outline: 2px solid #0a0a0a; + outline-offset: 2px; + color: #0a0a0a; +} + +.wzkb-admin-banner__link--secondary { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.1) 100%); + color: #f6f8ff; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32); +} + +.wzkb-admin-banner__link--secondary:hover, +.wzkb-admin-banner__link--secondary:focus-visible { + background-color: rgba(255, 255, 255, 0.65); + transform: translateY(-1px); + outline: 2px solid #ffbd59; + outline-offset: 2px; + color: #032075; +} + +.wzkb-admin-banner__link--accent { + background-color: #ffffff; + color: #032075; + box-shadow: 0 8px 24px rgba(3, 32, 117, 0.3); +} + +.wzkb-admin-banner__link--accent:hover, +.wzkb-admin-banner__link--accent:focus-visible { + background-color: #f0f3ff; + outline: 2px solid #032075; + outline-offset: 2px; + color: #032075; +} + +.wzkb-admin-banner__links .wzkb-admin-banner__link:focus-visible { + box-shadow: none; +} + +.wzkb-admin-banner__link--current { + background-color: #ffe2a3; + color: #0a0a0a; + box-shadow: inset 0 0 0 1px rgba(255, 189, 89, 0.75); +} + +.wzkb-admin-banner__link--current:hover, +.wzkb-admin-banner__link--current:focus-visible { + background-color: #ffd37f; + color: #0a0a0a; + outline: 2px solid #0a0a0a; + outline-offset: 2px; + transform: translateY(-1px); +} + +@media screen and (max-width: 782px) { + .wzkb-admin-banner { + flex-direction: column; + align-items: flex-start; + gap: 18px; + margin: -16px -16px 12px -16px; + padding: 20px 20px; + } + + .wzkb-admin-banner__title { + font-size: 1.125rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .wzkb-admin-banner__link { + transition: none; + } +} + +@media screen and (max-width: 600px) { + body.wp-admin #wpbody { + padding-top: 0; + } + + body.wp-admin .wzkb-admin-banner { + margin: 0px -16px 0 -16px; + padding: 50px 20px 30px 20px; + } + + .wzkb-admin-banner__link--plugins { + display: none; + } + + .wzkb-admin-banner__link { + font-size: 0.875rem; + } + + body.wp-admin .wzkb-admin-banner__title, + body.wp-admin .wzkb-admin-banner__text { + display: none; + } +} diff --git a/includes/admin/css/admin.min.css b/includes/admin/css/admin.min.css index fb5170a..e8b3c9c 100644 --- a/includes/admin/css/admin.min.css +++ b/includes/admin/css/admin.min.css @@ -1 +1 @@ -a.wzkb_button{padding:10px;text-decoration:none;text-shadow:none;border-radius:3px;transition:all .3s ease 0s;margin-right:10px;display:inline-block;}a.wzkb_button:hover{box-shadow:3px 3px 10px #666;}a.wzkb_button.wzkb_button_green{color:#fff;background:#008000;border:1px solid #003400;}a.wzkb_button.wzkb_button_green:hover{color:#fff;background:#006400;}a.wzkb_button.wzkb_button_red{color:#fff;background:#d63638;border:1px solid #b92c2e;}a.wzkb_button.wzkb_button_red:hover{color:#fff;background:#b92c2e;}a.wzkb_button.wzkb_button_blue{color:#fff;background:#032075;border:1px solid #001f5b;}a.wzkb_button.wzkb_button_blue:hover{color:#fff;background:#001f5b;}a.wzkb_button.wzkb_button_gold{color:#000;background:#FFBD59;border:1px solid #FFA500;}a.wzkb_button.wzkb_button_gold:hover{color:#000;background:#FFA500;} \ No newline at end of file +a.wzkb_button{padding:10px;text-decoration:none;text-shadow:none;border-radius:3px;transition:all .3s ease 0s;margin-right:10px;display:inline-block}a.wzkb_button:hover{box-shadow:3px 3px 10px #666}a.wzkb_button.wzkb_button_green,a.wzkb_button.wzkb_button_green:focus{color:#fff;background:green;border:1px solid #003400}a.wzkb_button.wzkb_button_green:hover{color:#fff;background:#006400}a.wzkb_button.wzkb_button_red,a.wzkb_button.wzkb_button_red:focus{color:#fff;background:#d63638;border:1px solid #b92c2e}a.wzkb_button.wzkb_button_red:hover{color:#fff;background:#b92c2e}a.wzkb_button.wzkb_button_blue,a.wzkb_button.wzkb_button_blue:focus{color:#fff;background:#032075;border:1px solid #001f5b}a.wzkb_button.wzkb_button_blue:hover{color:#fff;background:#001f5b}a.wzkb_button.wzkb_button_gold,a.wzkb_button.wzkb_button_gold:focus{color:#000;background:#ffbd59;border:1px solid orange}a.wzkb_button.wzkb_button_gold:hover{color:#000;background:orange}.wzkb-admin-banner{margin:-20px -20px 0 -20px;padding:26px 36px;background:linear-gradient(180deg,#032075 0,#04104a 55%,#0a0a0a 100%);color:#f6f8ff;display:flex;align-items:center;gap:28px;box-sizing:border-box}.wzkb-admin-banner__intro{flex:1 1 auto;min-width:220px;color:#f6f8ff}.wzkb-admin-banner__eyebrow{display:inline-block;padding:2px 10px;border-radius:999px;background-color:rgba(255,189,89,.24);color:#ffbd59;font-size:.9rem;text-transform:uppercase;letter-spacing:.06em;font-weight:600}.wzkb-admin-banner__title{margin:10px 0 6px;font-size:1rem;font-weight:700;line-height:1.24;color:#fff}.wzkb-admin-banner__text{margin:0;font-size:.9rem;line-height:1.6;color:#e9efff;max-width:540px}.wzkb-admin-banner__links{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.wzkb-admin-banner__link{display:inline-flex;align-items:center;gap:8px;padding:9px 16px;border-radius:999px;font-size:.9rem;font-weight:600;text-decoration:none;color:inherit;transition:transform .2s ease,box-shadow .2s ease,background-color .2s ease,outline .2s ease;outline:0}.wzkb-admin-banner__link--primary{background-color:#ffbd59;color:#0a0a0a;box-shadow:0 8px 24px rgba(255,189,89,.35)}.wzkb-admin-banner__link--primary:focus-visible,.wzkb-admin-banner__link--primary:hover{background-color:#ffd37f;transform:translateY(-1px);outline:2px solid #0a0a0a;outline-offset:2px;color:#0a0a0a}.wzkb-admin-banner__link--secondary{background:linear-gradient(180deg,rgba(255,255,255,.22) 0,rgba(255,255,255,.1) 100%);color:#f6f8ff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.32)}.wzkb-admin-banner__link--secondary:focus-visible,.wzkb-admin-banner__link--secondary:hover{background-color:rgba(255,255,255,.65);transform:translateY(-1px);outline:2px solid #ffbd59;outline-offset:2px;color:#032075}.wzkb-admin-banner__link--accent{background-color:#fff;color:#032075;box-shadow:0 8px 24px rgba(3,32,117,.3)}.wzkb-admin-banner__link--accent:focus-visible,.wzkb-admin-banner__link--accent:hover{background-color:#f0f3ff;outline:2px solid #032075;outline-offset:2px;color:#032075}.wzkb-admin-banner__links .wzkb-admin-banner__link:focus-visible{box-shadow:none}.wzkb-admin-banner__link--current{background-color:#ffe2a3;color:#0a0a0a;box-shadow:inset 0 0 0 1px rgba(255,189,89,.75)}.wzkb-admin-banner__link--current:focus-visible,.wzkb-admin-banner__link--current:hover{background-color:#ffd37f;color:#0a0a0a;outline:2px solid #0a0a0a;outline-offset:2px;transform:translateY(-1px)}@media screen and (max-width:782px){.wzkb-admin-banner{flex-direction:column;align-items:flex-start;gap:18px;margin:-16px -16px 12px -16px;padding:20px 20px}.wzkb-admin-banner__title{font-size:1.125rem}}@media (prefers-reduced-motion:reduce){.wzkb-admin-banner__link{transition:none}}@media screen and (max-width:600px){body.wp-admin #wpbody{padding-top:0}body.wp-admin .wzkb-admin-banner{margin:0 -16px 0 -16px;padding:50px 20px 30px 20px}.wzkb-admin-banner__link--plugins{display:none}.wzkb-admin-banner__link{font-size:.875rem}body.wp-admin .wzkb-admin-banner__text,body.wp-admin .wzkb-admin-banner__title{display:none}} \ No newline at end of file diff --git a/includes/admin/css/classic-sections-metabox-rtl.css b/includes/admin/css/classic-sections-metabox-rtl.css new file mode 100644 index 0000000..e374de4 --- /dev/null +++ b/includes/admin/css/classic-sections-metabox-rtl.css @@ -0,0 +1,55 @@ +.wzkb-classic-sections__message { + margin: 0; + font-style: italic; + color: #555d66; +} + +.wzkb-classic-sections__products, +.wzkb-classic-sections__sections { + padding: 8px; + border: 1px solid #dcdcde; + border-radius: 4px; + background-color: #fff; + margin-bottom: 12px; +} + +.wzkb-classic-sections__product-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.wzkb-classic-sections__product-item { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; +} + +.wzkb-classic-sections__group { + margin-bottom: 12px; +} + +.wzkb-classic-sections__group-title { + margin: 0 0 6px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #2c3338; +} + +.wzkb-classic-sections__node { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.wzkb-classic-sections__node-label { + font-size: 13px; +} + +.wzkb-classic-sections__note { + margin-top: 8px; + color: #b32d2e; +} diff --git a/includes/admin/css/classic-sections-metabox-rtl.min.css b/includes/admin/css/classic-sections-metabox-rtl.min.css new file mode 100644 index 0000000..b5ab24e --- /dev/null +++ b/includes/admin/css/classic-sections-metabox-rtl.min.css @@ -0,0 +1 @@ +.wzkb-classic-sections__message{margin:0;font-style:italic;color:#555d66}.wzkb-classic-sections__products,.wzkb-classic-sections__sections{padding:8px;border:1px solid #dcdcde;border-radius:4px;background-color:#fff;margin-bottom:12px}.wzkb-classic-sections__product-list{display:flex;flex-direction:column;gap:6px}.wzkb-classic-sections__product-item{display:flex;align-items:center;gap:6px;font-weight:500}.wzkb-classic-sections__group{margin-bottom:12px}.wzkb-classic-sections__group-title{margin:0 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.4px;color:#2c3338}.wzkb-classic-sections__node{display:flex;align-items:center;gap:6px;margin-bottom:4px}.wzkb-classic-sections__node-label{font-size:13px}.wzkb-classic-sections__note{margin-top:8px;color:#b32d2e} \ No newline at end of file diff --git a/includes/admin/css/classic-sections-metabox.css b/includes/admin/css/classic-sections-metabox.css new file mode 100644 index 0000000..e374de4 --- /dev/null +++ b/includes/admin/css/classic-sections-metabox.css @@ -0,0 +1,55 @@ +.wzkb-classic-sections__message { + margin: 0; + font-style: italic; + color: #555d66; +} + +.wzkb-classic-sections__products, +.wzkb-classic-sections__sections { + padding: 8px; + border: 1px solid #dcdcde; + border-radius: 4px; + background-color: #fff; + margin-bottom: 12px; +} + +.wzkb-classic-sections__product-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.wzkb-classic-sections__product-item { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; +} + +.wzkb-classic-sections__group { + margin-bottom: 12px; +} + +.wzkb-classic-sections__group-title { + margin: 0 0 6px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #2c3338; +} + +.wzkb-classic-sections__node { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.wzkb-classic-sections__node-label { + font-size: 13px; +} + +.wzkb-classic-sections__note { + margin-top: 8px; + color: #b32d2e; +} diff --git a/includes/admin/css/classic-sections-metabox.min.css b/includes/admin/css/classic-sections-metabox.min.css new file mode 100644 index 0000000..b5ab24e --- /dev/null +++ b/includes/admin/css/classic-sections-metabox.min.css @@ -0,0 +1 @@ +.wzkb-classic-sections__message{margin:0;font-style:italic;color:#555d66}.wzkb-classic-sections__products,.wzkb-classic-sections__sections{padding:8px;border:1px solid #dcdcde;border-radius:4px;background-color:#fff;margin-bottom:12px}.wzkb-classic-sections__product-list{display:flex;flex-direction:column;gap:6px}.wzkb-classic-sections__product-item{display:flex;align-items:center;gap:6px;font-weight:500}.wzkb-classic-sections__group{margin-bottom:12px}.wzkb-classic-sections__group-title{margin:0 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.4px;color:#2c3338}.wzkb-classic-sections__node{display:flex;align-items:center;gap:6px;margin-bottom:4px}.wzkb-classic-sections__node-label{font-size:13px}.wzkb-classic-sections__note{margin-top:8px;color:#b32d2e} \ No newline at end of file diff --git a/includes/admin/css/editor-sections-panel-rtl.css b/includes/admin/css/editor-sections-panel-rtl.css new file mode 100644 index 0000000..a2dfd42 --- /dev/null +++ b/includes/admin/css/editor-sections-panel-rtl.css @@ -0,0 +1,65 @@ +.wzkb-editor-sections__loading { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.wzkb-editor-sections__container { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0; +} + +.wzkb-editor-sections__message { + margin: 0; + font-size: 13px; + line-height: 1.4; +} + +.wzkb-editor-sections__notice { + margin: 0; +} + +.wzkb-editor-sections__group { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 0 12px 0; + border-bottom: 1px solid #ccc; +} + +.wzkb-editor-sections__group-title { + margin: 0; + font-size: 14px; + line-height: 1.4; + font-weight: 600; +} + +.wzkb-editor-sections__tree { + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 2px; +} + +.wzkb-editor-sections__node .components-base-control__field { + margin-bottom: 0; +} + +.wzkb-editor-sections__node { + font-size: 13px; + line-height: 1.4; + margin: 3px 0; +} + +.wzkb-editor-sections__node--child { + margin-top: 6px; +} + +.wzkb-editor-sections__empty { + font-style: italic; + margin: 0; + font-size: 13px; +} diff --git a/includes/admin/css/editor-sections-panel-rtl.min.css b/includes/admin/css/editor-sections-panel-rtl.min.css new file mode 100644 index 0000000..95c7bed --- /dev/null +++ b/includes/admin/css/editor-sections-panel-rtl.min.css @@ -0,0 +1 @@ +.wzkb-editor-sections__loading{display:flex;flex-direction:column;gap:8px;align-items:flex-start}.wzkb-editor-sections__container{display:flex;flex-direction:column;gap:10px;padding:4px 0}.wzkb-editor-sections__message{margin:0;font-size:13px;line-height:1.4}.wzkb-editor-sections__notice{margin:0}.wzkb-editor-sections__group{display:flex;flex-direction:column;gap:6px;padding:6px 0 12px 0;border-bottom:1px solid #ccc}.wzkb-editor-sections__group-title{margin:0;font-size:14px;line-height:1.4;font-weight:600}.wzkb-editor-sections__tree{display:flex;flex-direction:column;gap:4px;padding-right:2px}.wzkb-editor-sections__node .components-base-control__field{margin-bottom:0}.wzkb-editor-sections__node{font-size:13px;line-height:1.4;margin:3px 0}.wzkb-editor-sections__node--child{margin-top:6px}.wzkb-editor-sections__empty{font-style:italic;margin:0;font-size:13px} \ No newline at end of file diff --git a/includes/admin/css/editor-sections-panel.css b/includes/admin/css/editor-sections-panel.css new file mode 100644 index 0000000..b68c64c --- /dev/null +++ b/includes/admin/css/editor-sections-panel.css @@ -0,0 +1,65 @@ +.wzkb-editor-sections__loading { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.wzkb-editor-sections__container { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0; +} + +.wzkb-editor-sections__message { + margin: 0; + font-size: 13px; + line-height: 1.4; +} + +.wzkb-editor-sections__notice { + margin: 0; +} + +.wzkb-editor-sections__group { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 0 12px 0; + border-bottom: 1px solid #ccc; +} + +.wzkb-editor-sections__group-title { + margin: 0; + font-size: 14px; + line-height: 1.4; + font-weight: 600; +} + +.wzkb-editor-sections__tree { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 2px; +} + +.wzkb-editor-sections__node .components-base-control__field { + margin-bottom: 0; +} + +.wzkb-editor-sections__node { + font-size: 13px; + line-height: 1.4; + margin: 3px 0; +} + +.wzkb-editor-sections__node--child { + margin-top: 6px; +} + +.wzkb-editor-sections__empty { + font-style: italic; + margin: 0; + font-size: 13px; +} diff --git a/includes/admin/css/editor-sections-panel.min.css b/includes/admin/css/editor-sections-panel.min.css new file mode 100644 index 0000000..0ae4aa0 --- /dev/null +++ b/includes/admin/css/editor-sections-panel.min.css @@ -0,0 +1 @@ +.wzkb-editor-sections__loading{display:flex;flex-direction:column;gap:8px;align-items:flex-start}.wzkb-editor-sections__container{display:flex;flex-direction:column;gap:10px;padding:4px 0}.wzkb-editor-sections__message{margin:0;font-size:13px;line-height:1.4}.wzkb-editor-sections__notice{margin:0}.wzkb-editor-sections__group{display:flex;flex-direction:column;gap:6px;padding:6px 0 12px 0;border-bottom:1px solid #ccc}.wzkb-editor-sections__group-title{margin:0;font-size:14px;line-height:1.4;font-weight:600}.wzkb-editor-sections__tree{display:flex;flex-direction:column;gap:4px;padding-left:2px}.wzkb-editor-sections__node .components-base-control__field{margin-bottom:0}.wzkb-editor-sections__node{font-size:13px;line-height:1.4;margin:3px 0}.wzkb-editor-sections__node--child{margin-top:6px}.wzkb-editor-sections__empty{font-style:italic;margin:0;font-size:13px} \ No newline at end of file diff --git a/includes/admin/css/wizard-content-rtl.css b/includes/admin/css/wizard-content-rtl.css new file mode 100644 index 0000000..172ff7d --- /dev/null +++ b/includes/admin/css/wizard-content-rtl.css @@ -0,0 +1,23 @@ +.wzkb-wizard-repeater-table th, +.wzkb-wizard-repeater-table td { + vertical-align: top; +} + +.wzkb-wizard-repeater-table .wzkb-wizard-col-actions { + width: 120px; +} + +.wzkb-wizard-repeater-table input.regular-text, +.wzkb-wizard-repeater-table textarea.large-text, +.wzkb-wizard-repeater-table select { + width: 100%; + max-width: 100%; +} + +.wzkb-wizard-repeater-table textarea { + resize: vertical; +} + +.wzkb-wizard-repeater-table .button-link-delete { + color: #b32d2e; +} diff --git a/includes/admin/css/wizard-content-rtl.min.css b/includes/admin/css/wizard-content-rtl.min.css new file mode 100644 index 0000000..aab6710 --- /dev/null +++ b/includes/admin/css/wizard-content-rtl.min.css @@ -0,0 +1 @@ +.wzkb-wizard-repeater-table td,.wzkb-wizard-repeater-table th{vertical-align:top}.wzkb-wizard-repeater-table .wzkb-wizard-col-actions{width:120px}.wzkb-wizard-repeater-table input.regular-text,.wzkb-wizard-repeater-table select,.wzkb-wizard-repeater-table textarea.large-text{width:100%;max-width:100%}.wzkb-wizard-repeater-table textarea{resize:vertical}.wzkb-wizard-repeater-table .button-link-delete{color:#b32d2e} \ No newline at end of file diff --git a/includes/admin/css/wizard-content.css b/includes/admin/css/wizard-content.css new file mode 100644 index 0000000..1fb0703 --- /dev/null +++ b/includes/admin/css/wizard-content.css @@ -0,0 +1,23 @@ +.wzkb-wizard-repeater-table th, +.wzkb-wizard-repeater-table td { + vertical-align: top; +} + +.wzkb-wizard-repeater-table .wzkb-wizard-col-actions { + width: 120px; +} + +.wzkb-wizard-repeater-table input.regular-text, +.wzkb-wizard-repeater-table textarea.large-text, +.wzkb-wizard-repeater-table select { + width: 100%; + max-width: 100%; +} + +.wzkb-wizard-repeater-table textarea { + resize: vertical; +} + +.wzkb-wizard-repeater-table .button-link-delete { + color: #b32d2e; +} \ No newline at end of file diff --git a/includes/admin/css/wizard-content.min.css b/includes/admin/css/wizard-content.min.css new file mode 100644 index 0000000..aab6710 --- /dev/null +++ b/includes/admin/css/wizard-content.min.css @@ -0,0 +1 @@ +.wzkb-wizard-repeater-table td,.wzkb-wizard-repeater-table th{vertical-align:top}.wzkb-wizard-repeater-table .wzkb-wizard-col-actions{width:120px}.wzkb-wizard-repeater-table input.regular-text,.wzkb-wizard-repeater-table select,.wzkb-wizard-repeater-table textarea.large-text{width:100%;max-width:100%}.wzkb-wizard-repeater-table textarea{resize:vertical}.wzkb-wizard-repeater-table .button-link-delete{color:#b32d2e} \ No newline at end of file diff --git a/includes/admin/images/wzkb-icon.jpg b/includes/admin/images/wzkb-icon.jpg new file mode 100644 index 0000000..02137f7 Binary files /dev/null and b/includes/admin/images/wzkb-icon.jpg differ diff --git a/includes/admin/js/admin-notices.js b/includes/admin/js/admin-notices.js new file mode 100644 index 0000000..e2e5952 --- /dev/null +++ b/includes/admin/js/admin-notices.js @@ -0,0 +1,25 @@ +/** + * Admin notice dismissal handler. + * + * Reads configs pushed into window.adminNoticesConfigs by each plugin instance + * via wp_add_inline_script. Each config object contains: + * - prefix {string} Plugin prefix (matches data-notice-prefix attribute). + * - action {string} AJAX action name. + * - nonce {string} Nonce for the AJAX request. + */ +jQuery(document).ready(function ($) { + var configs = window.adminNoticesConfigs || []; + + configs.forEach(function (config) { + $('.notice[data-notice-prefix="' + config.prefix + '"]').on('click', '.notice-dismiss', function () { + var $notice = $(this).closest('.notice'); + + $.post(ajaxurl, { + action: config.action, + notice_id: $notice.data('notice-id'), + dismiss_time: $notice.data('dismiss-time'), + nonce: config.nonce + }); + }); + }); +}); diff --git a/includes/admin/js/admin-notices.min.js b/includes/admin/js/admin-notices.min.js new file mode 100644 index 0000000..a3959d5 --- /dev/null +++ b/includes/admin/js/admin-notices.min.js @@ -0,0 +1 @@ +jQuery(document).ready(function(i){(window.adminNoticesConfigs||[]).forEach(function(n){i('.notice[data-notice-prefix="'+n.prefix+'"]').on("click",".notice-dismiss",function(){var o=i(this).closest(".notice");i.post(ajaxurl,{action:n.action,notice_id:o.data("notice-id"),dismiss_time:o.data("dismiss-time"),nonce:n.nonce})})})}); \ No newline at end of file diff --git a/includes/admin/js/admin-scripts.js b/includes/admin/js/admin-scripts.js index 35421f1..ad8e6fc 100644 --- a/includes/admin/js/admin-scripts.js +++ b/includes/admin/js/admin-scripts.js @@ -1,57 +1,82 @@ jQuery(document).ready( function ($) { $('button[name="wzkb_cache_clear"]').on('click', function () { - if (confirm(wzkb_admin_data.confirm_message)) { + if (confirm(WZKBAdminData.strings.confirm_message)) { var $button = $(this); $button.prop('disabled', true).append(' '); clearCache($button); } }); + $('button[name="wzkb_flush_permalinks"]').on('click', function (e) { + e.preventDefault(); + var $button = $(this); + var nonce = $button.data('nonce'); + $button.prop('disabled', true).append(' '); + flushPermalinks($button, nonce); + }); + // Function to clear the cache. function clearCache($button) { - $.post(wzkb_admin_data.ajax_url, { + $.post(WZKBAdminData.ajax_url, { action: 'wzkb_clear_cache', - security: wzkb_admin_data.security + security: WZKBAdminData.security }, function (response) { if (response.success) { - alert(response.data.message); + // Use WordPress admin notice instead of alert(). + showAdminNotice(WZKBAdminData.strings.success_message, 'success'); } else { - alert(wzkb_admin_data.fail_message); + showAdminNotice(WZKBAdminData.strings.fail_message, 'error'); } }).fail(function (jqXHR, textStatus) { - alert(wzkb_admin_data.request_fail_message + textStatus); + showAdminNotice(WZKBAdminData.strings.fail_message, 'error'); + console.log(WZKBAdminData.strings.request_fail_message + textStatus); }).always(function () { $button.prop('disabled', false).find('.spinner').remove(); }); } - // Prompt the user when they leave the page without saving the form. - var formmodified = 0; - - function confirmFormChange() { - formmodified = 1; - } - - function confirmExit() { - if (formmodified == 1) { - return true; - } + // Function to flush permalinks. + function flushPermalinks($button, nonce) { + $.post(WZKBAdminData.ajax_url, { + action: 'wzkb_flush_permalinks', + nonce: nonce + }, function (response) { + if (response.success) { + showAdminNotice(response.data.message, 'success'); + } else { + showAdminNotice(WZKBAdminData.strings.fail_message, 'error'); + } + }).fail(function (jqXHR, textStatus) { + showAdminNotice(WZKBAdminData.strings.fail_message, 'error'); + console.log(WZKBAdminData.strings.request_fail_message + textStatus); + }).always(function () { + $button.prop('disabled', false).text(WZKBAdminData.strings.flush_permalinks_text || 'Flush Permalinks'); + }); } - function formNotModified() { - formmodified = 0; - } + // Function to show WordPress admin notices. + function showAdminNotice(message, type) { + var noticeClass = type === 'success' ? 'notice-success' : 'notice-error'; + var $notice = $('

    ' + message + '

    '); - $('form *').change(confirmFormChange); + // Insert notice after the first h1 or h2 in the page. + if ($('.wrap > h1, .wrap > h2').length) { + $('.wrap > h1, .wrap > h2').first().after($notice); + } else { + $('.wrap').prepend($notice); + } - window.onbeforeunload = confirmExit; + // Scroll to notice. + $('html, body').animate({ scrollTop: $notice.offset().top - 100 }, 300); - $("input[name='submit']").click(formNotModified); - $("input[id='search-submit']").click(formNotModified); - $("input[id='doaction']").click(formNotModified); - $("input[id='doaction2']").click(formNotModified); - $("input[name='filter_action']").click(formNotModified); + // Auto-dismiss after 5 seconds. + setTimeout(function () { + $notice.fadeOut(300, function () { + $(this).remove(); + }); + }, 5000); + } $( function () { diff --git a/includes/admin/js/admin-scripts.min.js b/includes/admin/js/admin-scripts.min.js index 204169a..03e1cf9 100644 --- a/includes/admin/js/admin-scripts.min.js +++ b/includes/admin/js/admin-scripts.min.js @@ -1 +1 @@ -jQuery(document).ready((function(a){a('button[name="wzkb_cache_clear"]').on("click",(function(){if(confirm(wzkb_admin_data.confirm_message)){var n=a(this);n.prop("disabled",!0).append(' '),function(n){a.post(wzkb_admin_data.ajax_url,{action:"wzkb_clear_cache",security:wzkb_admin_data.security},(function(a){a.success?alert(a.data.message):alert(wzkb_admin_data.fail_message)})).fail((function(a,n){alert(wzkb_admin_data.request_fail_message+n)})).always((function(){n.prop("disabled",!1).find(".spinner").remove()}))}(n)}}));var n=0;function i(){n=0}a("form *").change((function(){n=1})),window.onbeforeunload=function(){if(1==n)return!0},a("input[name='submit']").click(i),a("input[id='search-submit']").click(i),a("input[id='doaction']").click(i),a("input[id='doaction2']").click(i),a("input[name='filter_action']").click(i),a((function(){a("#post-body-content").tabs({create:function(n,i){a(i.tab.find("a")).addClass("nav-tab-active")},activate:function(n,i){a(i.oldTab.find("a")).removeClass("nav-tab-active"),a(i.newTab.find("a")).addClass("nav-tab-active")}})}))})); \ No newline at end of file +jQuery(document).ready(function(a){function s(s,n){var e=a('

    '+s+"

    ");a(".wrap > h1, .wrap > h2").length?a(".wrap > h1, .wrap > h2").first().after(e):a(".wrap").prepend(e),a("html, body").animate({scrollTop:e.offset().top-100},300),setTimeout(function(){e.fadeOut(300,function(){a(this).remove()})},5e3)}a('button[name="wzkb_cache_clear"]').on("click",function(){if(confirm(WZKBAdminData.strings.confirm_message)){var n=a(this);n.prop("disabled",!0).append(' '),function(n){a.post(WZKBAdminData.ajax_url,{action:"wzkb_clear_cache",security:WZKBAdminData.security},function(a){a.success?s(WZKBAdminData.strings.success_message,"success"):s(WZKBAdminData.strings.fail_message,"error")}).fail(function(a,n){s(WZKBAdminData.strings.fail_message,"error"),console.log(WZKBAdminData.strings.request_fail_message+n)}).always(function(){n.prop("disabled",!1).find(".spinner").remove()})}(n)}}),a('button[name="wzkb_flush_permalinks"]').on("click",function(n){n.preventDefault();var e=a(this),i=e.data("nonce");e.prop("disabled",!0).append(' '),function(n,e){a.post(WZKBAdminData.ajax_url,{action:"wzkb_flush_permalinks",nonce:e},function(a){a.success?s(a.data.message,"success"):s(WZKBAdminData.strings.fail_message,"error")}).fail(function(a,n){s(WZKBAdminData.strings.fail_message,"error"),console.log(WZKBAdminData.strings.request_fail_message+n)}).always(function(){n.prop("disabled",!1).text(WZKBAdminData.strings.flush_permalinks_text||"Flush Permalinks")})}(e,i)}),a(function(){a("#post-body-content").tabs({create:function(s,n){a(n.tab.find("a")).addClass("nav-tab-active")},activate:function(s,n){a(n.oldTab.find("a")).removeClass("nav-tab-active"),a(n.newTab.find("a")).addClass("nav-tab-active")}})})}); \ No newline at end of file diff --git a/includes/admin/js/classic-sections-metabox.js b/includes/admin/js/classic-sections-metabox.js new file mode 100644 index 0000000..e7a845b --- /dev/null +++ b/includes/admin/js/classic-sections-metabox.js @@ -0,0 +1,379 @@ +/* global WZKBClassicSections */ +(function (window, document) { + if (!window.wp || !window.wp.apiFetch || !document) { + return; + } + + const config = window.WZKBClassicSections || {}; + if (!config.endpoint) { + return; + } + + const apiFetch = window.wp.apiFetch; + if (config.nonce && apiFetch.createNonceMiddleware) { + if (!window.wzkbClassicNonceMiddlewareAdded) { + apiFetch.use(apiFetch.createNonceMiddleware(config.nonce)); + window.wzkbClassicNonceMiddlewareAdded = true; + } + } + + const root = document.querySelector('.wzkb-classic-sections[data-role="root"]'); + if (!root) { + return; + } + + const productsContainer = root.querySelector('[data-role="products"]'); + const sectionsContainer = root.querySelector('[data-role="sections"]'); + const productInput = document.getElementById('wzkb_classic_product_ids'); + const sectionInput = document.getElementById('wzkb_classic_section_ids'); + const searchInput = root.querySelector('[data-role="product-search"]'); + + const strings = config.strings || {}; + const productMap = config.products || {}; + + const selectedProducts = new Set(Array.isArray(config.meta?.products) ? config.meta.products : []); + const selectedSections = new Set(Array.isArray(config.meta?.sections) ? config.meta.sections : []); + + const MAX_RENDERED_PRODUCTS = 15; + const cachedResponses = new Map(); + let isLoading = false; + let lastError = null; + let inflightKey = null; + let productFilter = ''; + + const normalizeProductEntries = () => { + return Object.keys(productMap) + .map((id) => { + const termId = parseInt(id, 10); + return { + id: termId, + label: productMap[id], + }; + }) + .filter((entry) => Number.isFinite(entry.id) && entry.id > 0) + .sort((a, b) => (a.label || '').localeCompare(b.label || '')); + }; + + const allProducts = normalizeProductEntries(); + + const normalize = (value) => { + const items = Array.isArray(value) ? value : []; + return items + .map((item) => parseInt(item, 10)) + .filter((item) => Number.isFinite(item) && item > 0); + }; + + const serialize = (set) => Array.from(set).sort((a, b) => a - b).join(','); + + const updateHiddenInputs = () => { + if (productInput) { + productInput.value = serialize(selectedProducts); + } + if (sectionInput) { + sectionInput.value = serialize(selectedSections); + } + }; + + const createElement = (tag, className, text) => { + const el = document.createElement(tag); + if (className) { + el.className = className; + } + if (text) { + el.textContent = text; + } + return el; + }; + + const renderMessage = (container, message) => { + container.innerHTML = ''; + container.appendChild(createElement('p', 'wzkb-classic-sections__message', message)); + }; + + const buildTree = (terms) => { + if (!Array.isArray(terms)) { + return []; + } + const byId = new Map(); + terms.forEach((term) => { + byId.set(term.id, { + id: term.id, + name: term.name, + parent: term.parent, + children: [], + }); + }); + + const roots = []; + byId.forEach((node) => { + if (node.parent && byId.has(node.parent)) { + byId.get(node.parent).children.push(node); + } else { + roots.push(node); + } + }); + + const sortNodes = (nodes) => + nodes + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ + ...node, + children: sortNodes(node.children), + })); + + return sortNodes(roots); + }; + + const groupByProduct = (terms, activeProducts) => { + if (!Array.isArray(terms) || !activeProducts.length) { + return []; + } + + const groups = new Map(); + terms.forEach((term) => { + const productId = Number.isFinite(parseInt(term.product, 10)) ? parseInt(term.product, 10) : 0; + const bucket = productId > 0 ? productId : 0; + if (productId > 0 && !activeProducts.includes(productId)) { + return; + } + if (!groups.has(bucket)) { + groups.set(bucket, []); + } + groups.get(bucket).push(term); + }); + + const ordered = []; + activeProducts.forEach((productId) => { + if (groups.has(productId)) { + ordered.push({ + productId, + label: (strings.productHeading || '%s sections').replace('%s', productMap[productId] || productId), + nodes: buildTree(groups.get(productId)), + }); + groups.delete(productId); + } + }); + + if (groups.has(0)) { + ordered.push({ + productId: 0, + label: strings.unassigned || 'Sections without a product', + nodes: buildTree(groups.get(0)), + }); + groups.delete(0); + } + + groups.forEach((nodes, productId) => { + ordered.push({ + productId, + label: (strings.productHeading || '%s sections').replace('%s', productMap[productId] || productId), + nodes: buildTree(nodes), + }); + }); + + return ordered.filter((group) => group.nodes.length); + }; + + const getFilteredProducts = () => { + if (!productFilter) { + return allProducts; + } + + const needle = productFilter.toLowerCase(); + return allProducts.filter((entry) => { + const label = entry.label || ''; + return label.toLowerCase().indexOf(needle) !== -1 || String(entry.id).indexOf(needle) !== -1; + }); + }; + + const renderProducts = () => { + productsContainer.innerHTML = ''; + + const entries = getFilteredProducts(); + if (!entries.length) { + renderMessage(productsContainer, strings.noProductMatches || 'No products match your search.'); + return; + } + + const list = createElement('div', 'wzkb-classic-sections__product-list'); + const limitedEntries = entries.slice(0, MAX_RENDERED_PRODUCTS); + + limitedEntries.forEach((entry) => { + const wrapper = createElement('label', 'wzkb-classic-sections__product-item'); + const checkbox = createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = entry.id; + checkbox.checked = selectedProducts.has(entry.id); + checkbox.addEventListener('change', (event) => { + if (event.target.checked) { + selectedProducts.add(entry.id); + } else { + selectedProducts.delete(entry.id); + } + updateHiddenInputs(); + loadSections(); + }); + + const label = createElement('span', 'wzkb-classic-sections__product-label', entry.label || `Product ${entry.id}`); + wrapper.appendChild(checkbox); + wrapper.appendChild(label); + list.appendChild(wrapper); + }); + + if (entries.length > MAX_RENDERED_PRODUCTS) { + const note = createElement( + 'p', + 'wzkb-classic-sections__product-note', + (strings.productOverflow || 'Showing first %1$s products out of %2$s. Refine your search.') + .replace('%1$s', MAX_RENDERED_PRODUCTS) + .replace('%2$s', entries.length) + ); + list.appendChild(note); + } + + productsContainer.appendChild(list); + }; + + const bindSearch = () => { + if (!searchInput) { + return; + } + + if (strings.searchPlaceholder) { + searchInput.placeholder = strings.searchPlaceholder; + } + + searchInput.addEventListener( + 'input', + (() => { + let timeout = null; + return function (event) { + window.clearTimeout(timeout); + timeout = window.setTimeout(() => { + productFilter = (event.target.value || '').trim(); + renderProducts(); + }, 150); + }; + })() + ); + }; + + const renderSections = (terms) => { + sectionsContainer.innerHTML = ''; + + if (!selectedProducts.size) { + renderMessage(sectionsContainer, strings.selectProducts || 'Select a product to load its sections.'); + return; + } + + if (isLoading) { + renderMessage(sectionsContainer, strings.loading || 'Loading sections…'); + return; + } + + if (lastError) { + renderMessage(sectionsContainer, strings.error || 'Unable to load sections.'); + return; + } + + const grouped = groupByProduct(terms, Array.from(selectedProducts)); + if (!grouped.length) { + renderMessage(sectionsContainer, strings.noSections || 'No sections match the selected products.'); + return; + } + + grouped.forEach((group) => { + const groupEl = createElement('div', 'wzkb-classic-sections__group'); + groupEl.appendChild(createElement('h4', 'wzkb-classic-sections__group-title', group.label)); + const treeEl = createElement('div', 'wzkb-classic-sections__tree'); + appendTree(treeEl, group.nodes, 0); + groupEl.appendChild(treeEl); + sectionsContainer.appendChild(groupEl); + }); + }; + + const appendTree = (container, nodes, level) => { + nodes.forEach((node) => { + const row = createElement('div', 'wzkb-classic-sections__node'); + row.style.marginLeft = `${level * 16}px`; + + const checkbox = createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = node.id; + checkbox.checked = selectedSections.has(node.id); + checkbox.addEventListener('change', (event) => { + if (event.target.checked) { + selectedSections.add(node.id); + } else { + selectedSections.delete(node.id); + } + updateHiddenInputs(); + }); + + const label = createElement('span', 'wzkb-classic-sections__node-label', node.name); + row.appendChild(checkbox); + row.appendChild(label); + container.appendChild(row); + + if (node.children && node.children.length) { + appendTree(container, node.children, level + 1); + } + }); + }; + + const loadSections = () => { + const ids = Array.from(selectedProducts); + if (!ids.length) { + lastError = null; + renderSections([]); + return; + } + + const key = ids.sort((a, b) => a - b).join(','); + if (cachedResponses.has(key)) { + lastError = null; + renderSections(cachedResponses.get(key)); + return; + } + + isLoading = true; + lastError = null; + inflightKey = key; + renderSections([]); + + apiFetch({ url: `${config.endpoint}?products=${encodeURIComponent(key)}` }) + .then((terms) => { + if (inflightKey !== key) { + return; + } + isLoading = false; + cachedResponses.set(key, Array.isArray(terms) ? terms : []); + renderSections(cachedResponses.get(key)); + }) + .catch((error) => { + if (inflightKey !== key) { + return; + } + isLoading = false; + lastError = error; + renderSections([]); + }); + }; + + const initialize = () => { + const initialProducts = normalize(config.meta?.products || []); + const initialSections = normalize(config.meta?.sections || []); + + selectedProducts.clear(); + initialProducts.forEach((id) => selectedProducts.add(id)); + selectedSections.clear(); + initialSections.forEach((id) => selectedSections.add(id)); + + updateHiddenInputs(); + bindSearch(); + renderProducts(); + loadSections(); + }; + + initialize(); +})(window, document); diff --git a/includes/admin/js/classic-sections-metabox.min.js b/includes/admin/js/classic-sections-metabox.min.js new file mode 100644 index 0000000..e8d5887 --- /dev/null +++ b/includes/admin/js/classic-sections-metabox.min.js @@ -0,0 +1 @@ +!function(e,t){if(!e.wp||!e.wp.apiFetch||!t)return;const n=e.WZKBClassicSections||{};if(!n.endpoint)return;const s=e.wp.apiFetch;n.nonce&&s.createNonceMiddleware&&(e.wzkbClassicNonceMiddlewareAdded||(s.use(s.createNonceMiddleware(n.nonce)),e.wzkbClassicNonceMiddlewareAdded=!0));const r=t.querySelector('.wzkb-classic-sections[data-role="root"]');if(!r)return;const c=r.querySelector('[data-role="products"]'),o=r.querySelector('[data-role="sections"]'),a=t.getElementById("wzkb_classic_product_ids"),i=t.getElementById("wzkb_classic_section_ids"),d=r.querySelector('[data-role="product-search"]'),l=n.strings||{},p=n.products||{},u=new Set(Array.isArray(n.meta?.products)?n.meta.products:[]),h=new Set(Array.isArray(n.meta?.sections)?n.meta.sections:[]),f=new Map;let m=!1,b=null,w=null,g="";const y=Object.keys(p).map(e=>({id:parseInt(e,10),label:p[e]})).filter(e=>Number.isFinite(e.id)&&e.id>0).sort((e,t)=>(e.label||"").localeCompare(t.label||"")),_=e=>(Array.isArray(e)?e:[]).map(e=>parseInt(e,10)).filter(e=>Number.isFinite(e)&&e>0),k=e=>Array.from(e).sort((e,t)=>e-t).join(","),C=()=>{a&&(a.value=k(u)),i&&(i.value=k(h))},v=(e,n,s)=>{const r=t.createElement(e);return n&&(r.className=n),s&&(r.textContent=s),r},A=(e,t)=>{e.innerHTML="",e.appendChild(v("p","wzkb-classic-sections__message",t))},z=e=>{if(!Array.isArray(e))return[];const t=new Map;e.forEach(e=>{t.set(e.id,{id:e.id,name:e.name,parent:e.parent,children:[]})});const n=[];t.forEach(e=>{e.parent&&t.has(e.parent)?t.get(e.parent).children.push(e):n.push(e)});const s=e=>e.sort((e,t)=>e.name.localeCompare(t.name)).map(e=>({...e,children:s(e.children)}));return s(n)},E=()=>{c.innerHTML="";const e=(()=>{if(!g)return y;const e=g.toLowerCase();return y.filter(t=>-1!==(t.label||"").toLowerCase().indexOf(e)||-1!==String(t.id).indexOf(e))})();if(!e.length)return void A(c,l.noProductMatches||"No products match your search.");const t=v("div","wzkb-classic-sections__product-list");if(e.slice(0,15).forEach(e=>{const n=v("label","wzkb-classic-sections__product-item"),s=v("input");s.type="checkbox",s.value=e.id,s.checked=u.has(e.id),s.addEventListener("change",t=>{t.target.checked?u.add(e.id):u.delete(e.id),C(),I()});const r=v("span","wzkb-classic-sections__product-label",e.label||`Product ${e.id}`);n.appendChild(s),n.appendChild(r),t.appendChild(n)}),e.length>15){const n=v("p","wzkb-classic-sections__product-note",(l.productOverflow||"Showing first %1$s products out of %2$s. Refine your search.").replace("%1$s",15).replace("%2$s",e.length));t.appendChild(n)}c.appendChild(t)},S=e=>{if(o.innerHTML="",!u.size)return void A(o,l.selectProducts||"Select a product to load its sections.");if(m)return void A(o,l.loading||"Loading sections…");if(b)return void A(o,l.error||"Unable to load sections.");const t=((e,t)=>{if(!Array.isArray(e)||!t.length)return[];const n=new Map;e.forEach(e=>{const s=Number.isFinite(parseInt(e.product,10))?parseInt(e.product,10):0,r=s>0?s:0;s>0&&!t.includes(s)||(n.has(r)||n.set(r,[]),n.get(r).push(e))});const s=[];return t.forEach(e=>{n.has(e)&&(s.push({productId:e,label:(l.productHeading||"%s sections").replace("%s",p[e]||e),nodes:z(n.get(e))}),n.delete(e))}),n.has(0)&&(s.push({productId:0,label:l.unassigned||"Sections without a product",nodes:z(n.get(0))}),n.delete(0)),n.forEach((e,t)=>{s.push({productId:t,label:(l.productHeading||"%s sections").replace("%s",p[t]||t),nodes:z(e)})}),s.filter(e=>e.nodes.length)})(e,Array.from(u));t.length?t.forEach(e=>{const t=v("div","wzkb-classic-sections__group");t.appendChild(v("h4","wzkb-classic-sections__group-title",e.label));const n=v("div","wzkb-classic-sections__tree");M(n,e.nodes,0),t.appendChild(n),o.appendChild(t)}):A(o,l.noSections||"No sections match the selected products.")},M=(e,t,n)=>{t.forEach(t=>{const s=v("div","wzkb-classic-sections__node");s.style.marginLeft=16*n+"px";const r=v("input");r.type="checkbox",r.value=t.id,r.checked=h.has(t.id),r.addEventListener("change",e=>{e.target.checked?h.add(t.id):h.delete(t.id),C()});const c=v("span","wzkb-classic-sections__node-label",t.name);s.appendChild(r),s.appendChild(c),e.appendChild(s),t.children&&t.children.length&&M(e,t.children,n+1)})},I=()=>{const e=Array.from(u);if(!e.length)return b=null,void S([]);const t=e.sort((e,t)=>e-t).join(",");if(f.has(t))return b=null,void S(f.get(t));m=!0,b=null,w=t,S([]),s({url:`${n.endpoint}?products=${encodeURIComponent(t)}`}).then(e=>{w===t&&(m=!1,f.set(t,Array.isArray(e)?e:[]),S(f.get(t)))}).catch(e=>{w===t&&(m=!1,b=e,S([]))})};(()=>{const t=_(n.meta?.products||[]),s=_(n.meta?.sections||[]);u.clear(),t.forEach(e=>u.add(e)),h.clear(),s.forEach(e=>h.add(e)),C(),d&&(l.searchPlaceholder&&(d.placeholder=l.searchPlaceholder),d.addEventListener("input",(()=>{let t=null;return function(n){e.clearTimeout(t),t=e.setTimeout(()=>{g=(n.target.value||"").trim(),E()},150)}})())),E(),I()})()}(window,document); \ No newline at end of file diff --git a/includes/admin/js/editor-sections-panel.js b/includes/admin/js/editor-sections-panel.js new file mode 100644 index 0000000..a55f2dd --- /dev/null +++ b/includes/admin/js/editor-sections-panel.js @@ -0,0 +1,403 @@ +/** + * Gutenberg product-aware sections panel. + * + * @package WebberZone\Knowledge_Base + */ + +/* global WZKBEditorSections */ + +let wzkbSectionsNonceMiddlewareAdded = false; + +(function () { + if ( + !window.wp || + !window.wp.data || + !window.wp.components || + !window.wp.element || + !window.wp.hooks + ) { + return; + } + + const config = window.WZKBEditorSections || {}; + if (!config.endpoint) { + return; + } + + const strings = config.strings || {}; + const { + components: { Spinner, Notice, CheckboxControl }, + element: { createElement: el, useEffect, useMemo, useState }, + data: { useSelect, useDispatch }, + hooks: { addFilter }, + } = window.wp; + const apiFetch = window.wp.apiFetch; + + if (!addFilter || !CheckboxControl || !useSelect || !apiFetch) { + return; + } + + if (config.nonce && apiFetch.createNonceMiddleware && !wzkbSectionsNonceMiddlewareAdded) { + apiFetch.use(apiFetch.createNonceMiddleware(config.nonce)); + wzkbSectionsNonceMiddlewareAdded = true; + } + + const normalizeIds = (values) => { + if (!Array.isArray(values)) { + return []; + } + + return values + .map((value) => parseInt(value, 10)) + .filter((value) => Number.isFinite(value) && value > 0); + }; + + const sortNumeric = (values) => { + return values.slice().sort((a, b) => a - b); + }; + + const arraysEqual = (a, b) => { + if (a.length !== b.length) { + return false; + } + + const sortedA = sortNumeric(a); + const sortedB = sortNumeric(b); + + for (let index = 0; index < sortedA.length; index += 1) { + if (sortedA[index] !== sortedB[index]) { + return false; + } + } + + return true; + }; + + const buildTree = (terms) => { + if (!Array.isArray(terms) || !terms.length) { + return []; + } + + const byId = new Map(); + + terms.forEach((term) => { + const node = { + id: term.id, + name: term.name, + parent: term.parent, + children: [], + }; + byId.set(node.id, node); + }); + + const roots = []; + byId.forEach((node) => { + if (node.parent && byId.has(node.parent)) { + byId.get(node.parent).children.push(node); + } else { + roots.push(node); + } + }); + + const sortRecursive = (nodes) => { + return nodes + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ + ...node, + children: sortRecursive(node.children), + })); + }; + + return sortRecursive(roots); + }; + + const useSections = (products) => { + const [state, setState] = useState({ + items: [], + isLoading: false, + error: null, + }); + + const productKey = sortNumeric(products).join(','); + + useEffect(() => { + if (!products.length) { + setState({ items: [], isLoading: false, error: null }); + return; + } + + let isCancelled = false; + + setState((prev) => ({ + ...prev, + isLoading: true, + error: null, + })); + + const query = encodeURIComponent(productKey); + + apiFetch({ url: config.endpoint + '?products=' + query }) + .then((terms) => { + if (isCancelled) { + return; + } + + setState({ + items: Array.isArray(terms) ? terms : [], + isLoading: false, + error: null, + }); + }) + .catch((error) => { + if (isCancelled) { + return; + } + + setState({ + items: [], + isLoading: false, + error, + }); + }); + + return () => { + isCancelled = true; + }; + }, [productKey]); + + return state; + }; + + const getLatestMeta = () => { + if (!window.wp || !window.wp.data) { + return {}; + } + + return window.wp.data.select('core/editor').getEditedPostAttribute('meta') || {}; + }; + + const formatProductTitle = (productId) => { + const productName = + (productId && config.products && config.products[productId]) || `Product ${productId}`; + const template = strings.productHeading || '%s sections'; + return template.replace('%s', productName); + }; + + const groupSectionsByProduct = (terms, selectedProducts) => { + if (!Array.isArray(terms) || !terms.length) { + return []; + } + + const groups = new Map(); + + terms.forEach((term) => { + const productId = Number.isFinite(parseInt(term.product, 10)) + ? parseInt(term.product, 10) + : 0; + + if (productId > 0 && !selectedProducts.includes(productId)) { + return; + } + + const bucket = productId > 0 ? productId : 0; + + if (!groups.has(bucket)) { + groups.set(bucket, []); + } + + groups.get(bucket).push(term); + }); + + const orderedGroups = []; + + selectedProducts.forEach((productId) => { + if (groups.has(productId)) { + orderedGroups.push({ + productId, + label: formatProductTitle(productId), + nodes: buildTree(groups.get(productId)), + }); + groups.delete(productId); + } + }); + + if (groups.has(0)) { + orderedGroups.push({ + productId: 0, + label: strings.unassigned || 'Sections without a product', + nodes: buildTree(groups.get(0)), + }); + groups.delete(0); + } + + groups.forEach((bucketTerms, productId) => { + orderedGroups.push({ + productId, + label: formatProductTitle(productId), + nodes: buildTree(bucketTerms), + }); + }); + + return orderedGroups.filter((group) => group.nodes.length); + }; + + const SectionTree = ({ nodes, selectedIds, onToggle, level }) => { + if (!nodes || !nodes.length) { + return null; + } + + return nodes.map((node) => { + const key = 'section-node-' + node.id; + const indentation = level > 0 ? level * 24 : 0; + const className = + 'wzkb-editor-sections__node' + (level > 0 ? ' wzkb-editor-sections__node--child' : ''); + + return el( + 'div', + { key, className, style: { marginLeft: indentation } }, + el(CheckboxControl, { + label: node.name, + checked: selectedIds.has(node.id), + onChange: (checked) => onToggle(node.id, checked), + }), + node.children && node.children.length + ? el(SectionTree, { + nodes: node.children, + selectedIds, + onToggle, + level: level + 1, + }) + : null + ); + }); + }; + + const SectionsPanel = () => { + const meta = useSelect((select) => select('core/editor').getEditedPostAttribute('meta') || {}, []); + const productMeta = normalizeIds(meta._wzkb_product_ids || []); + const sectionMeta = normalizeIds(meta._wzkb_section_ids || []); + + const taxonomyProducts = normalizeIds(useSelect((select) => select('core/editor').getEditedPostAttribute('wzkb_product') || [], [])); + const taxonomySections = normalizeIds(useSelect((select) => select('core/editor').getEditedPostAttribute('wzkb_category') || [], [])); + + const { editPost } = useDispatch('core/editor'); + + useEffect(() => { + if (arraysEqual(productMeta, taxonomyProducts)) { + return; + } + + const latestMeta = getLatestMeta(); + editPost({ + meta: { + ...latestMeta, + _wzkb_product_ids: taxonomyProducts, + }, + }); + }, [productMeta.join(','), taxonomyProducts.join(','), editPost]); + + useEffect(() => { + if (arraysEqual(sectionMeta, taxonomySections)) { + return; + } + + const latestMeta = getLatestMeta(); + editPost({ + meta: { + ...latestMeta, + _wzkb_section_ids: taxonomySections, + }, + }); + }, [sectionMeta.join(','), taxonomySections.join(','), editPost]); + + const { items: sectionTerms, isLoading, error } = useSections(productMeta); + const groupedSections = useMemo( + () => groupSectionsByProduct(sectionTerms, productMeta), + [sectionTerms, productMeta.join(',')] + ); + const selectedIds = useMemo(() => new Set(sectionMeta), [sectionMeta.join(',')]); + + const toggleSection = (sectionId, isChecked) => { + const updated = new Set(selectedIds); + if (isChecked) { + updated.add(sectionId); + } else { + updated.delete(sectionId); + } + + const nextSections = Array.from(updated).sort((a, b) => a - b); + const latestMeta = getLatestMeta(); + + editPost({ + meta: { + ...latestMeta, + _wzkb_section_ids: nextSections, + }, + wzkb_category: nextSections, + }); + }; + + const panelContents = () => { + if (!productMeta.length) { + return el('p', { className: 'wzkb-editor-sections__message' }, strings.selectProducts || 'Select a product to load its sections.'); + } + + if (error) { + return el( + Notice, + { status: 'error', isDismissible: false, className: 'wzkb-editor-sections__notice' }, + strings.error || 'Unable to load sections. Please try again.' + ); + } + + if (isLoading) { + return el( + 'div', + { className: 'wzkb-editor-sections__loading' }, + el(Spinner, null), + el('p', null, strings.loading || 'Loading sections…') + ); + } + + if (!groupedSections.length) { + return el( + 'p', + { className: 'wzkb-editor-sections__empty' }, + strings.noSections || 'No sections match the selected products.' + ); + } + + return groupedSections.map((group) => + el( + 'div', + { key: 'group-' + group.productId, className: 'wzkb-editor-sections__group' }, + el('h4', { className: 'wzkb-editor-sections__group-title' }, group.label), + el( + 'div', + { className: 'wzkb-editor-sections__tree' }, + el(SectionTree, { + nodes: group.nodes, + selectedIds, + onToggle: toggleSection, + level: 0, + }) + ) + ) + ); + }; + + const contents = panelContents(); + + return el('div', { className: 'wzkb-editor-sections__container' }, contents); + }; + + addFilter('editor.PostTaxonomyType', 'wzkb/custom-sections-panel', (OriginalComponent) => { + return function WrappedComponent(props) { + if (!props || props.slug !== 'wzkb_category') { + return el(OriginalComponent, props); + } + + return el(SectionsPanel, { label: props.label }); + }; + }); +})(); diff --git a/includes/admin/js/editor-sections-panel.min.js b/includes/admin/js/editor-sections-panel.min.js new file mode 100644 index 0000000..03c395f --- /dev/null +++ b/includes/admin/js/editor-sections-panel.min.js @@ -0,0 +1 @@ +let wzkbSectionsNonceMiddlewareAdded=!1;!function(){if(!(window.wp&&window.wp.data&&window.wp.components&&window.wp.element&&window.wp.hooks))return;const e=window.WZKBEditorSections||{};if(!e.endpoint)return;const t=e.strings||{},{components:{Spinner:o,Notice:n,CheckboxControl:s},element:{createElement:r,useEffect:i,useMemo:d,useState:c},data:{useSelect:a,useDispatch:l},hooks:{addFilter:u}}=window.wp,p=window.wp.apiFetch;if(!(u&&s&&a&&p))return;e.nonce&&p.createNonceMiddleware&&!wzkbSectionsNonceMiddlewareAdded&&(p.use(p.createNonceMiddleware(e.nonce)),wzkbSectionsNonceMiddlewareAdded=!0);const w=e=>Array.isArray(e)?e.map(e=>parseInt(e,10)).filter(e=>Number.isFinite(e)&&e>0):[],g=e=>e.slice().sort((e,t)=>e-t),h=(e,t)=>{if(e.length!==t.length)return!1;const o=g(e),n=g(t);for(let e=0;e{if(!Array.isArray(e)||!e.length)return[];const t=new Map;e.forEach(e=>{const o={id:e.id,name:e.name,parent:e.parent,children:[]};t.set(o.id,o)});const o=[];t.forEach(e=>{e.parent&&t.has(e.parent)?t.get(e.parent).children.push(e):o.push(e)});const n=e=>e.sort((e,t)=>e.name.localeCompare(t.name)).map(e=>({...e,children:n(e.children)}));return n(o)},b=()=>window.wp&&window.wp.data&&window.wp.data.select("core/editor").getEditedPostAttribute("meta")||{},_=o=>{const n=o&&e.products&&e.products[o]||`Product ${o}`;return(t.productHeading||"%s sections").replace("%s",n)},k=({nodes:e,selectedIds:t,onToggle:o,level:n})=>e&&e.length?e.map(e=>{const i="section-node-"+e.id;return r("div",{key:i,className:"wzkb-editor-sections__node"+(n>0?" wzkb-editor-sections__node--child":""),style:{marginLeft:n>0?24*n:0}},r(s,{label:e.name,checked:t.has(e.id),onChange:t=>o(e.id,t)}),e.children&&e.children.length?r(k,{nodes:e.children,selectedIds:t,onToggle:o,level:n+1}):null)}):null,f=()=>{const s=a(e=>e("core/editor").getEditedPostAttribute("meta")||{},[]),u=w(s._wzkb_product_ids||[]),f=w(s._wzkb_section_ids||[]),z=w(a(e=>e("core/editor").getEditedPostAttribute("wzkb_product")||[],[])),y=w(a(e=>e("core/editor").getEditedPostAttribute("wzkb_category")||[],[])),{editPost:N}=l("core/editor");i(()=>{if(h(u,z))return;const e=b();N({meta:{...e,_wzkb_product_ids:z}})},[u.join(","),z.join(","),N]),i(()=>{if(h(f,y))return;const e=b();N({meta:{...e,_wzkb_section_ids:y}})},[f.join(","),y.join(","),N]);const{items:A,isLoading:E,error:S}=(t=>{const[o,n]=c({items:[],isLoading:!1,error:null}),s=g(t).join(",");return i(()=>{if(!t.length)return void n({items:[],isLoading:!1,error:null});let o=!1;n(e=>({...e,isLoading:!0,error:null}));const r=encodeURIComponent(s);return p({url:e.endpoint+"?products="+r}).then(e=>{o||n({items:Array.isArray(e)?e:[],isLoading:!1,error:null})}).catch(e=>{o||n({items:[],isLoading:!1,error:e})}),()=>{o=!0}},[s]),o})(u),I=d(()=>((e,o)=>{if(!Array.isArray(e)||!e.length)return[];const n=new Map;e.forEach(e=>{const t=Number.isFinite(parseInt(e.product,10))?parseInt(e.product,10):0;if(t>0&&!o.includes(t))return;const s=t>0?t:0;n.has(s)||n.set(s,[]),n.get(s).push(e)});const s=[];return o.forEach(e=>{n.has(e)&&(s.push({productId:e,label:_(e),nodes:m(n.get(e))}),n.delete(e))}),n.has(0)&&(s.push({productId:0,label:t.unassigned||"Sections without a product",nodes:m(n.get(0))}),n.delete(0)),n.forEach((e,t)=>{s.push({productId:t,label:_(t),nodes:m(e)})}),s.filter(e=>e.nodes.length)})(A,u),[A,u.join(",")]),v=d(()=>new Set(f),[f.join(",")]),P=(e,t)=>{const o=new Set(v);t?o.add(e):o.delete(e);const n=Array.from(o).sort((e,t)=>e-t),s=b();N({meta:{...s,_wzkb_section_ids:n},wzkb_category:n})},L=u.length?S?r(n,{status:"error",isDismissible:!1,className:"wzkb-editor-sections__notice"},t.error||"Unable to load sections. Please try again."):E?r("div",{className:"wzkb-editor-sections__loading"},r(o,null),r("p",null,t.loading||"Loading sections…")):I.length?I.map(e=>r("div",{key:"group-"+e.productId,className:"wzkb-editor-sections__group"},r("h4",{className:"wzkb-editor-sections__group-title"},e.label),r("div",{className:"wzkb-editor-sections__tree"},r(k,{nodes:e.nodes,selectedIds:v,onToggle:P,level:0})))):r("p",{className:"wzkb-editor-sections__empty"},t.noSections||"No sections match the selected products."):r("p",{className:"wzkb-editor-sections__message"},t.selectProducts||"Select a product to load its sections.");return r("div",{className:"wzkb-editor-sections__container"},L)};u("editor.PostTaxonomyType","wzkb/custom-sections-panel",e=>function(t){return t&&"wzkb_category"===t.slug?r(f,{label:t.label}):r(e,t)})}(); \ No newline at end of file diff --git a/includes/admin/js/product-migrator.js b/includes/admin/js/product-migrator.js new file mode 100644 index 0000000..d111328 --- /dev/null +++ b/includes/admin/js/product-migrator.js @@ -0,0 +1,188 @@ +/* global ajaxurl, wzkbProductMigrator */ +(function ($) { + 'use strict'; + + var step = 0; + + var migrationState = {}; + var lastTopSectionIndex = -1; + + /** + * Updates the migration progress bar. + * + * @param {number} percent - The percentage of completion. + * @param {string} message - The message to display. + */ + function updateProgressBar(percent, message) { + $('#wzkb-migration-progress-bar').css('width', percent + '%'); + $('#wzkb-migration-progress-bar').html(percent + '%'); + $('#wzkb-migration-progress-text').html(message); + } + + /** + * Displays an error message. + * + * @param {string} message - The error message. + */ + function showError(message) { + $('#wzkb-migration-errors').append('
  • ' + message + '
  • '); + } + + /** + * Appends a log message. + * + * @param {string} html - The log message HTML. + */ + function appendToLog(html) { + if (html) { + var timestamp = new Date().toLocaleString(); + var $log = $('#wzkb-migration-log'); + $log.append('

    [' + 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; + } + + var hasLogEntries = response.data.log && Array.isArray(response.data.log) && response.data.log.length; + + if (hasLogEntries) { + response.data.log.forEach(function (line) { appendToLog(line); }); + } else 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..9ff5efb --- /dev/null +++ b/includes/admin/js/product-migrator.min.js @@ -0,0 +1 @@ +!function(t){"use strict";var o=0,r={},a=-1;function i(o,r){t("#wzkb-migration-progress-bar").css("width",o+"%"),t("#wzkb-migration-progress-bar").html(o+"%"),t("#wzkb-migration-progress-text").html(r)}function n(o){t("#wzkb-migration-errors").append("
  • "+o+"
  • ")}function s(o){if(o){var r=(new Date).toLocaleString(),a=t("#wzkb-migration-log");a.append("

    ["+r+"] "+o+"

    "),a.scrollTop(a[0].scrollHeight),setTimeout(function(){a[0].offsetHeight},0)}}function e(){var c=JSON.parse(JSON.stringify(r));void 0!==c.current_top_section_index&&c.current_top_section_index===a?(window.wzkbMigrationLoopCount||(window.wzkbMigrationLoopCount=0),window.wzkbMigrationLoopCount++,window.wzkbMigrationLoopCount>=5&&(console.warn("Warning: current_top_section_index has not changed after 5 attempts:",c.current_top_section_index),window.wzkbMigrationLoopCount=0)):window.wzkbMigrationLoopCount=0,a=c.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:c},success:function(a){return a.success?(a.data.log&&Array.isArray(a.data.log)&&a.data.log.length?a.data.log.forEach(function(t){s(t)}):a.data.message&&s(a.data.message),a.data.progress&&i(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?(i(100,a.data.message),void t("#wzkb-migration-start").prop("disabled",!1)):a.data.done?(i(100,a.data.message||wzkbProductMigrator.strings.migration_complete),void t("#wzkb-migration-start").prop("disabled",!1)):(o=a.data.next_step,r=JSON.parse(JSON.stringify(a.data.state)),void setTimeout(e,100))):(n(a.data||wzkbProductMigrator.strings.unknown_error),i(100,wzkbProductMigrator.strings.migration_failed),void t("#wzkb-migration-start").prop("disabled",!1))},error:function(o,r,a){console.error("AJAX error:",r,a),n(wzkbProductMigrator.strings.ajax_error+": "+a),i(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,r={},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),i(0,wzkbProductMigrator.strings.starting_migration),e()}),t("#wzkb-copy-log").on("click",function(){var o=t(this),r=t("#wzkb-migration-log"),a="";if(r.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){console.error("Failed to copy: ",t),o.html(' Failed!'),setTimeout(function(){o.html(' Copy Log')},2e3)})})})}(jQuery); \ No newline at end of file diff --git a/includes/admin/js/sections-product-filter.js b/includes/admin/js/sections-product-filter.js new file mode 100644 index 0000000..a6029c5 --- /dev/null +++ b/includes/admin/js/sections-product-filter.js @@ -0,0 +1,145 @@ +/** + * Sections product filter for Knowledge Base plugin. + * + * @package WebberZone\Knowledge_Base + */ + +/* global knowledgebaseProductFilter */ + +(function ($) { + 'use strict'; + + var DATA = window.knowledgebaseProductFilter || {}; + + /** + * Build and insert the product filter form. + */ + function renderProductFilter() { + if ($('.wzkb-sections-product-filter').length) { + return; + } + + var products = DATA.products || []; + if (!products.length) { + return; + } + + var strings = DATA.strings || {}; + var selected = DATA.selectedProduct; + if (selected === undefined || selected === null || '' === String(selected)) { + selected = DATA.currentProduct || ''; + } + var queryParams = $.extend({}, DATA.queryParams || {}); + + if (!queryParams.taxonomy) { + queryParams.taxonomy = 'wzkb_category'; + } + + var $form = $('
    ', { + class: 'wzkb-sections-product-filter', + method: 'get' + }).css({ + marginTop: '12px' + }); + + $.each(queryParams, function (key, value) { + if ('wzkb_product' === key) { + return; + } + + if (value === undefined || value === null || '' === String(value)) { + return; + } + + $form.append( + $('', { + type: 'hidden', + name: key, + value: value + }) + ); + }); + + var $label = $('