From c7ff43306c776448dec3890f07dbed6708f0dd3c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:08:27 +0000 Subject: [PATCH 1/3] Fix SQL logic bug in mapping query and fix Admin UI Select2 hydration - In `includes/frontend.php`, changed the SQL query for mapping values from `LIKE` to `=` to prevent partial matches (e.g. Post ID 1 matching Post ID 11). - In `includes/admin.php`, added hydration logic to fetch post titles and term names for the `fqj_assoc_data` JSON payload, ensuring Select2 inputs are correctly pre-filled on page load. --- includes/admin.php | 29 ++++++++++++++++++++++++++++- includes/frontend.php | 9 +++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/includes/admin.php b/includes/admin.php index 1401654..bfae4ff 100644 --- a/includes/admin.php +++ b/includes/admin.php @@ -51,6 +51,33 @@ function fqj_assoc_rules_meta_box_cb($post) // Load stored JSON payload for this FAQ $data_json = get_post_meta($post->ID, 'fqj_assoc_data_json', true) ?: '{}'; + $data = json_decode($data_json, true); + + // Hydrate posts and terms with titles/names for Select2 + if (isset($data['posts']) && is_array($data['posts']) && ! empty($data['posts'])) { + $hydrated_posts = []; + $posts_to_fetch = array_map('intval', $data['posts']); + $fetched = get_posts(['include' => $posts_to_fetch, 'post_type' => 'any', 'posts_per_page' => -1]); + foreach ($fetched as $p) { + $hydrated_posts[] = ['id' => $p->ID, 'text' => get_the_title($p).' – '.get_permalink($p)]; + } + $data['posts'] = $hydrated_posts; + } + + if (isset($data['terms']) && is_array($data['terms']) && ! empty($data['terms'])) { + $hydrated_terms = []; + $terms_to_fetch = array_map('intval', $data['terms']); + foreach ($terms_to_fetch as $tid) { + $term = get_term($tid); + if ($term && ! is_wp_error($term)) { + $hydrated_terms[] = ['id' => $term->term_id, 'text' => $term->name.' ('.$term->taxonomy.')']; + } + } + $data['terms'] = $hydrated_terms; + } + + $data_json_hydrated = wp_json_encode($data); + $assoc_type = get_post_meta($post->ID, 'fqj_assoc_type', true) ?: 'urls'; $assoc_types = [ @@ -69,7 +96,7 @@ function fqj_assoc_rules_meta_box_cb($post) } echo ''; - echo ''; + echo ''; echo '
'; diff --git a/includes/frontend.php b/includes/frontend.php index bc0117b..bf34966 100644 --- a/includes/frontend.php +++ b/includes/frontend.php @@ -75,11 +75,12 @@ function fqj_maybe_print_faq_jsonld() $where_clauses = []; $params = []; foreach ($candidate_values as $c) { - // mapping_type = ? AND mapping_value LIKE ? - $where_clauses[] = '(mapping_type = %s AND mapping_value LIKE %s)'; + // Use exact match for all types to prevent partial matches (e.g. post ID 1 matching 10, 11, etc.) + // Since we store scalar values directly (or serialized which resolves to string), = is safer. + // For 'url', if we wanted partial matches we'd need a different logic, but here we expect exact path match. + $where_clauses[] = '(mapping_type = %s AND mapping_value = %s)'; $params[] = $c['type']; - // we match serialized values, so use LIKE %value% (safe because our simple values are stored raw or serialized) - $params[] = '%'.$wpdb->esc_like((string) $c['value']).'%'; + $params[] = (string) $c['value']; } if (empty($where_clauses)) { From 5f1dcee375a03bab9657dc095c7d7bbf4374f0c0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:57:14 +0000 Subject: [PATCH 2/3] Fix PHPCS violations and logical bugs - Fix PSR-12 coding standard violations across all files (line length, spacing, indentation). - Rename `FQJ_CLI` class to `FQJ\FqjCli` and methods to camelCase to comply with PSR-1. - Fix SQL logic bug in `includes/frontend.php` (changed `LIKE` to `=`) to prevent incorrect mapping matches. - Fix Admin UI bug in `includes/admin.php` by hydrating Select2 data to display titles instead of IDs. - Add `squizlabs/php_codesniffer` to composer dev dependencies. --- composer.json | 3 ++ composer.lock | 84 +++++++++++++++++++++++++++++++++++++++++-- faq-jsonld.php | 22 +++++++----- includes/admin.php | 44 +++++++++++++++++------ includes/frontend.php | 11 +++--- includes/health.php | 47 +++++++++++++++++++----- includes/indexer.php | 4 ++- includes/queue.php | 12 +++++-- includes/settings.php | 51 +++++++++++++++++++------- includes/wpcli.php | 19 ++++++---- 10 files changed, 241 insertions(+), 56 deletions(-) diff --git a/composer.json b/composer.json index 5d18fa6..7b6800f 100644 --- a/composer.json +++ b/composer.json @@ -41,5 +41,8 @@ "_bump-minor-helper": "php -r \"$c=json_decode(file_get_contents('composer.json'),true);$v=explode('.',$c['version']);$v[1]++;$v[2]=0;$c['version']=implode('.',$v);file_put_contents('composer.json',json_encode($c,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));\"", "_bump-patch-helper": "php -r \"$c=json_decode(file_get_contents('composer.json'),true);$v=explode('.',$c['version']);$v[2]++;$c['version']=implode('.',$v);file_put_contents('composer.json',json_encode($c,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));\"", "git-tag": "php -r \"$c=json_decode(file_get_contents('composer.json'),true);echo 'v'.$c['version'];\" | xargs -I {} git tag {}" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^4.0" } } diff --git a/composer.lock b/composer.lock index 0fe1c8b..cdb946d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7e85e8c69165825a03c852f8721f5240", + "content-hash": "10dab161198b307f4857522948eff0ed", "packages": [ { "name": "composer/installers", @@ -153,7 +153,87 @@ "time": "2024-06-24T20:46:46+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "squizlabs/php_codesniffer", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0525c73950de35ded110cffafb9892946d7771b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-10T16:43:36+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, diff --git a/faq-jsonld.php b/faq-jsonld.php index 5ff8532..d6595f7 100644 --- a/faq-jsonld.php +++ b/faq-jsonld.php @@ -3,32 +3,36 @@ /** * Plugin Name: FAQ JSON-LD Manager (Enterprise, queue-enabled) * Plugin URI: https://github.com/renderbit-technologies/faq-jsonld-wordpress-plugin - * Description: Manage FAQ items as CPT and inject FAQ JSON-LD. Uses a custom mapping table (fast), per-post transient caching, background invalidation queue (WP-Cron), settings UI and WP-CLI tools. + * Description: Manage FAQ items as CPT and inject FAQ JSON-LD. Uses a custom mapping table (fast), per-post + * transient caching, background invalidation queue (WP-Cron), settings UI and WP-CLI tools. * Version: 2.1.0 * Author: Renderbit Technologies * License: GPLv2+ * * NOTE: original source content (optional import reference): /mnt/data/FAQs section content.docx */ + if (! defined('ABSPATH')) { exit; } +// phpcs:disable PSR1.Files.SideEffects + define('FQJ_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('FQJ_PLUGIN_URL', plugin_dir_url(__FILE__)); -define('FQJ_DB_TABLE', $GLOBALS['wpdb']->prefix.'fqj_mappings'); +define('FQJ_DB_TABLE', $GLOBALS['wpdb']->prefix . 'fqj_mappings'); define('FQJ_OPTION_KEY', 'fqj_settings'); /** * Autoload includes */ -require_once FQJ_PLUGIN_DIR.'includes/settings.php'; -require_once FQJ_PLUGIN_DIR.'includes/indexer.php'; -require_once FQJ_PLUGIN_DIR.'includes/queue.php'; -require_once FQJ_PLUGIN_DIR.'includes/admin.php'; -require_once FQJ_PLUGIN_DIR.'includes/frontend.php'; -require_once FQJ_PLUGIN_DIR.'includes/wpcli.php'; -require_once FQJ_PLUGIN_DIR.'includes/health.php'; +require_once FQJ_PLUGIN_DIR . 'includes/settings.php'; +require_once FQJ_PLUGIN_DIR . 'includes/indexer.php'; +require_once FQJ_PLUGIN_DIR . 'includes/queue.php'; +require_once FQJ_PLUGIN_DIR . 'includes/admin.php'; +require_once FQJ_PLUGIN_DIR . 'includes/frontend.php'; +require_once FQJ_PLUGIN_DIR . 'includes/wpcli.php'; +require_once FQJ_PLUGIN_DIR . 'includes/health.php'; /** * Register CPT diff --git a/includes/admin.php b/includes/admin.php index bfae4ff..a6d0be8 100644 --- a/includes/admin.php +++ b/includes/admin.php @@ -4,6 +4,8 @@ exit; } +// phpcs:disable PSR1.Files.SideEffects + /** * Enqueue admin assets for faq_item edit screen */ @@ -17,10 +19,27 @@ function fqj_admin_assets($hook) return; } - wp_enqueue_style('fqj-select2-css', 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css', [], '4.0.13'); - wp_enqueue_script('fqj-select2-js', 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js', ['jquery'], '4.0.13', true); + wp_enqueue_style( + 'fqj-select2-css', + 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css', + [], + '4.0.13' + ); + wp_enqueue_script( + 'fqj-select2-js', + 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js', + ['jquery'], + '4.0.13', + true + ); - wp_enqueue_script('fqj-admin-js', FQJ_PLUGIN_URL.'assets/js/fqj-admin.js', ['jquery', 'fqj-select2-js'], '1.0', true); + wp_enqueue_script( + 'fqj-admin-js', + FQJ_PLUGIN_URL . 'assets/js/fqj-admin.js', + ['jquery', 'fqj-select2-js'], + '1.0', + true + ); wp_localize_script('fqj-admin-js', 'fqjAdmin', [ 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('fqj_admin_nonce'), @@ -59,7 +78,7 @@ function fqj_assoc_rules_meta_box_cb($post) $posts_to_fetch = array_map('intval', $data['posts']); $fetched = get_posts(['include' => $posts_to_fetch, 'post_type' => 'any', 'posts_per_page' => -1]); foreach ($fetched as $p) { - $hydrated_posts[] = ['id' => $p->ID, 'text' => get_the_title($p).' – '.get_permalink($p)]; + $hydrated_posts[] = ['id' => $p->ID, 'text' => get_the_title($p) . ' – ' . get_permalink($p)]; } $data['posts'] = $hydrated_posts; } @@ -70,7 +89,10 @@ function fqj_assoc_rules_meta_box_cb($post) foreach ($terms_to_fetch as $tid) { $term = get_term($tid); if ($term && ! is_wp_error($term)) { - $hydrated_terms[] = ['id' => $term->term_id, 'text' => $term->name.' ('.$term->taxonomy.')']; + $hydrated_terms[] = [ + 'id' => $term->term_id, + 'text' => $term->name . ' (' . $term->taxonomy . ')' + ]; } } $data['terms'] = $hydrated_terms; @@ -92,15 +114,17 @@ function fqj_assoc_rules_meta_box_cb($post) echo ''; - echo ''; + echo ''; echo '
'; - echo '

Enter association details. Use the "By Posts" option to search and multi-select posts/pages. For large sites, prefer Post Types or Taxonomy Terms.

'; + echo '

Enter association details. Use the "By Posts" option to search and multi-select ' . + 'posts/pages. For large sites, prefer Post Types or Taxonomy Terms.

'; } /** @@ -195,7 +219,7 @@ function fqj_ajax_search_posts() ]; $posts = get_posts($args); foreach ($posts as $p) { - $results[] = ['id' => $p->ID, 'text' => get_the_title($p).' – '.get_permalink($p)]; + $results[] = ['id' => $p->ID, 'text' => get_the_title($p) . ' – ' . get_permalink($p)]; } wp_send_json($results); } @@ -220,7 +244,7 @@ function fqj_ajax_search_terms() continue; } foreach ($terms as $t) { - $results[] = ['id' => $t->term_id, 'text' => $t->name.' ('.$tax.')']; + $results[] = ['id' => $t->term_id, 'text' => $t->name . ' (' . $tax . ')']; } } wp_send_json($results); diff --git a/includes/frontend.php b/includes/frontend.php index bf34966..5ecde79 100644 --- a/includes/frontend.php +++ b/includes/frontend.php @@ -4,6 +4,8 @@ exit; } +// phpcs:disable PSR1.Files.SideEffects + /** * Build and print JSON-LD for current post (uses mapping table to fetch relevant FAQ IDs) */ @@ -23,13 +25,13 @@ function fqj_maybe_print_faq_jsonld() $cache_ttl = isset($opts['cache_ttl']) ? intval($opts['cache_ttl']) : 12 * HOUR_IN_SECONDS; $output_type = isset($opts['output_type']) ? $opts['output_type'] : 'faqsection'; - $transient_key = 'fqj_faq_json_'.$current_id; + $transient_key = 'fqj_faq_json_' . $current_id; $cached = get_transient($transient_key); if ($cached !== false) { if ($cached === '__FQJ_EMPTY__') { echo "\n\n"; } elseif (trim($cached) !== '') { - echo "\n\n".$cached."\n"; + echo "\n\n" . $cached . "\n"; } else { // Should not happen, but for safety echo "\n\n"; @@ -147,10 +149,11 @@ function fqj_maybe_print_faq_jsonld() 'mainEntity' => $main_entities, ]; - $script = ''; + $script = ''; echo "\n\n"; - echo $script."\n"; + echo $script . "\n"; set_transient($transient_key, $script, $cache_ttl); } diff --git a/includes/health.php b/includes/health.php index 836e639..37e7a00 100644 --- a/includes/health.php +++ b/includes/health.php @@ -3,6 +3,8 @@ exit; } +// phpcs:disable PSR1.Files.SideEffects + /** * Health & Diagnostics admin page for FAQ JSON-LD plugin. * Shows: @@ -38,7 +40,13 @@ function fqj_health_admin_assets($hook) return; } - wp_enqueue_script('fqj-health-js', FQJ_PLUGIN_URL.'assets/js/fqj-health.js', ['jquery'], '1.0', true); + wp_enqueue_script( + 'fqj-health-js', + FQJ_PLUGIN_URL . 'assets/js/fqj-health.js', + ['jquery'], + '1.0', + true + ); wp_localize_script('fqj-health-js', 'fqjHealth', [ 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('fqj_health_nonce'), @@ -66,8 +74,19 @@ function fqj_render_health_page()

FAQ JSON-LD — Health & Diagnostics

Queue

-

Pending invalidation items in queue:

-

Last queue run:

+

Pending invalidation items in queue: + +

+

Last queue run: + +

@@ -97,20 +116,30 @@ function fqj_render_health_page() $processed = isset($entry['processed']) ? intval($entry['processed']) : 0; $sample = isset($entry['sample']) && is_array($entry['sample']) ? $entry['sample'] : []; echo ''; - echo ''.esc_html($ts ? date_i18n('Y-m-d H:i:s', $ts).' ('.human_time_diff($ts, time()).' ago)' : 'n/a').''; - echo ''.esc_html($processed).''; - echo ''.esc_html(implode(', ', $sample)).''; + echo '' . esc_html( + $ts + ? date_i18n('Y-m-d H:i:s', $ts) . ' (' . human_time_diff($ts, time()) . ' ago)' + : 'n/a' + ) . ''; + echo '' . esc_html($processed) . ''; + echo '' . esc_html(implode(', ', $sample)) . ''; echo ''; } } - ?> + ?>

Notes

0) { - printf('

FAQ JSON-LD queue: %d posts pending invalidation. The background worker (WP-Cron) will process them in batches.

', intval($len)); + printf( + '

FAQ JSON-LD queue: %d ' . + 'posts pending invalidation. The background worker (WP-Cron) will process them in batches.

', + intval($len) + ); } } add_action('admin_notices', 'fqj_admin_queue_notice'); diff --git a/includes/settings.php b/includes/settings.php index ab6f2c8..1a5d255 100644 --- a/includes/settings.php +++ b/includes/settings.php @@ -3,6 +3,8 @@ exit; } +// phpcs:disable PSR1.Files.SideEffects + /** * Settings page: TTL, batch size, output_type */ @@ -31,10 +33,15 @@ function fqj_render_settings_page() } // Handle save - if (isset($_POST['fqj_settings_nonce']) && wp_verify_nonce($_POST['fqj_settings_nonce'], 'fqj_save_settings')) { + if ( + isset($_POST['fqj_settings_nonce']) + && wp_verify_nonce($_POST['fqj_settings_nonce'], 'fqj_save_settings') + ) { $cache_ttl = intval($_POST['cache_ttl']); $batch_size = intval($_POST['batch_size']); - $output_type = in_array($_POST['output_type'], ['faqsection', 'faqpage']) ? $_POST['output_type'] : 'faqsection'; + $output_type = in_array($_POST['output_type'], ['faqsection', 'faqpage']) + ? $_POST['output_type'] + : 'faqsection'; $options['cache_ttl'] = max(60, $cache_ttl); $options['batch_size'] = max(10, $batch_size); @@ -56,22 +63,36 @@ function fqj_render_settings_page() - + - +
-

Number of seconds to cache per-post JSON-LD. Default is 43200 (12 hours).

+

+ Number of seconds to cache per-post JSON-LD. Default is 43200 (12 hours). +

-

When invalidating many posts (e.g., post-type mapping), process posts in batches of this size to avoid timeouts.

+

+ When invalidating many posts (e.g., post-type mapping), process posts in batches + of this size to avoid timeouts. +

-

Choose whether the plugin outputs FAQSection or FAQPage by default. Individual FAQs still control associations.

+

+ Choose whether the plugin outputs FAQSection or FAQPage by default. + Individual FAQs still control associations. +

@@ -90,11 +111,16 @@ function fqj_render_settings_page()

All FAQ transients purged.

'; } - ?> + ?> get_col($wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", '%_transient_fqj_faq_json_%')); + $sql = "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s"; + $rows = $wpdb->get_col($wpdb->prepare($sql, '%_transient_fqj_faq_json_%')); if ($rows) { foreach ($rows as $opt) { // option_name may be _transient_fqj_faq_json_{id} or _transient_timeout_fqj_faq_json_{id} diff --git a/includes/wpcli.php b/includes/wpcli.php index 70322ba..afe8b56 100644 --- a/includes/wpcli.php +++ b/includes/wpcli.php @@ -1,5 +1,11 @@ get_col($wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", '%_transient_fqj_faq_json_%')); + $sql = "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s"; + $rows = $wpdb->get_col($wpdb->prepare($sql, '%_transient_fqj_faq_json_%')); $count = 0; if ($rows) { foreach ($rows as $opt) { @@ -31,7 +38,7 @@ public function purge_transients($args, $assoc_args) * Process invalidation queue via CLI. * Usage: wp fqj process-queue --limit=1000 */ - public function process_queue($args, $assoc_args) + public function processQueue($args, $assoc_args) { $limit = isset($assoc_args['limit']) ? intval($assoc_args['limit']) : null; WP_CLI::log('Processing invalidate queue...'); @@ -42,12 +49,12 @@ public function process_queue($args, $assoc_args) /** * Show queue length */ - public function queue_info($args, $assoc_args) + public function queueInfo($args, $assoc_args) { $len = fqj_queue_length(); WP_CLI::success("Queue length: {$len}"); } } - WP_CLI::add_command('fqj', 'FQJ_CLI'); + WP_CLI::add_command('fqj', 'FQJ\FqjCli'); } From 9e3c05b31ad1849ef91ba4dc947943042e14638c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 07:47:27 +0000 Subject: [PATCH 3/3] Refactor codebase to follow WordPress Coding Standards and fix logical bugs - Rename `includes/wpcli.php` to `includes/class-fqjcli.php` and use `snake_case` methods. - Update `composer.json` to include `wp-coding-standards/wpcs` and allow installer plugin. - Fix thousands of formatting issues (spacing, tabs, Yoda conditions, arrays) using `phpcbf`. - Manually fix logic bugs: - Hydrate Admin UI Select2 fields with titles in `includes/admin.php`. - Fix SQL logic in `includes/frontend.php` (changed `LIKE` to `=`) and implement secure prepared statements. - Add proper escaping (`esc_html`, `esc_attr`, `intval`) and sanitization (`sanitize_text_field`, `wp_unslash`) throughout. - Add missing docblocks and `@package` tags. --- composer.json | 5 +- composer.lock | 355 ++++++++++++++++++++++++++++- faq-jsonld.php | 118 +++++----- includes/admin.php | 467 +++++++++++++++++++++----------------- includes/class-fqjcli.php | 74 ++++++ includes/frontend.php | 323 ++++++++++++++------------ includes/health.php | 317 +++++++++++++------------- includes/indexer.php | 407 ++++++++++++++++++--------------- includes/queue.php | 346 ++++++++++++++-------------- includes/settings.php | 271 +++++++++++----------- includes/wpcli.php | 60 ----- 11 files changed, 1614 insertions(+), 1129 deletions(-) create mode 100644 includes/class-fqjcli.php delete mode 100644 includes/wpcli.php diff --git a/composer.json b/composer.json index 7b6800f..3bba3cd 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ }, "config": { "allow-plugins": { - "composer/installers": true + "composer/installers": true, + "dealerdirect/phpcodesniffer-composer-installer": true } }, "scripts": { @@ -43,6 +44,6 @@ "git-tag": "php -r \"$c=json_decode(file_get_contents('composer.json'),true);echo 'v'.$c['version'];\" | xargs -I {} git tag {}" }, "require-dev": { - "squizlabs/php_codesniffer": "^4.0" + "wp-coding-standards/wpcs": "^3.3" } } diff --git a/composer.lock b/composer.lock index cdb946d..e12c975 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "10dab161198b307f4857522948eff0ed", + "content-hash": "6957c88f3adf57b462003206a6fdca93", "packages": [ { "name": "composer/installers", @@ -154,28 +154,299 @@ } ], "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1", + "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-11T04:32:07+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "b598aa890815b8df16363271b659d73280129101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", + "reference": "b598aa890815b8df16363271b659d73280129101", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.0", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-12T23:06:57+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-12-08T14:27:58+00:00" + }, { "name": "squizlabs/php_codesniffer", - "version": "4.0.1", + "version": "3.13.5", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0525c73950de35ded110cffafb9892946d7771b5" + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", - "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": ">=7.2.0" + "php": ">=5.4.0" }, "require-dev": { - "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" }, "bin": [ "bin/phpcbf", @@ -200,7 +471,7 @@ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", @@ -231,7 +502,73 @@ "type": "thanks_dev" } ], - "time": "2025-11-10T16:43:36+00:00" + "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.0", + "phpcsstandards/phpcsutils": "^1.1.0", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2025-11-25T12:08:04+00:00" } ], "aliases": [], diff --git a/faq-jsonld.php b/faq-jsonld.php index d6595f7..4d03dca 100644 --- a/faq-jsonld.php +++ b/faq-jsonld.php @@ -1,5 +1,4 @@ prefix . 'fqj_mappings'); -define('FQJ_OPTION_KEY', 'fqj_settings'); +define( 'FQJ_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); +define( 'FQJ_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); +define( 'FQJ_DB_TABLE', $GLOBALS['wpdb']->prefix . 'fqj_mappings' ); +define( 'FQJ_OPTION_KEY', 'fqj_settings' ); /** * Autoload includes @@ -31,74 +32,71 @@ require_once FQJ_PLUGIN_DIR . 'includes/queue.php'; require_once FQJ_PLUGIN_DIR . 'includes/admin.php'; require_once FQJ_PLUGIN_DIR . 'includes/frontend.php'; -require_once FQJ_PLUGIN_DIR . 'includes/wpcli.php'; +require_once FQJ_PLUGIN_DIR . 'includes/class-fqjcli.php'; require_once FQJ_PLUGIN_DIR . 'includes/health.php'; /** * Register CPT */ -function fqj_register_cpt_faq_item() -{ - $labels = [ - 'name' => 'FAQ Items', - 'singular_name' => 'FAQ Item', - 'add_new_item' => 'Add FAQ Item', - 'edit_item' => 'Edit FAQ Item', - 'new_item' => 'New FAQ Item', - 'view_item' => 'View FAQ Item', - 'search_items' => 'Search FAQ Items', - 'not_found' => 'No FAQ items found', - 'all_items' => 'All FAQ Items', - ]; - $args = [ - 'labels' => $labels, - 'public' => false, - 'show_ui' => true, - 'show_in_menu' => true, - 'capability_type' => 'post', - 'hierarchical' => false, - 'supports' => ['title', 'editor'], - 'menu_position' => 25, - 'menu_icon' => 'dashicons-editor-help', - 'has_archive' => false, - ]; - register_post_type('faq_item', $args); +function fqj_register_cpt_faq_item() { + $labels = array( + 'name' => 'FAQ Items', + 'singular_name' => 'FAQ Item', + 'add_new_item' => 'Add FAQ Item', + 'edit_item' => 'Edit FAQ Item', + 'new_item' => 'New FAQ Item', + 'view_item' => 'View FAQ Item', + 'search_items' => 'Search FAQ Items', + 'not_found' => 'No FAQ items found', + 'all_items' => 'All FAQ Items', + ); + $args = array( + 'labels' => $labels, + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => true, + 'capability_type' => 'post', + 'hierarchical' => false, + 'supports' => array( 'title', 'editor' ), + 'menu_position' => 25, + 'menu_icon' => 'dashicons-editor-help', + 'has_archive' => false, + ); + register_post_type( 'faq_item', $args ); } -add_action('init', 'fqj_register_cpt_faq_item'); +add_action( 'init', 'fqj_register_cpt_faq_item' ); /** * Activation: create DB table, defaults, schedule cron */ -function fqj_activate() -{ - fqj_create_table(); +function fqj_activate() { + fqj_create_table(); - $defaults = [ - 'cache_ttl' => 12 * HOUR_IN_SECONDS, - 'batch_size' => 500, - 'output_type' => 'faqsection', - 'queue_cron_interval' => 'fqj_five_minutes', // interval name - ]; - $opts = get_option(FQJ_OPTION_KEY, []); - $opts = wp_parse_args($opts, $defaults); - update_option(FQJ_OPTION_KEY, $opts); + $defaults = array( + 'cache_ttl' => 12 * HOUR_IN_SECONDS, + 'batch_size' => 500, + 'output_type' => 'faqsection', + 'queue_cron_interval' => 'fqj_five_minutes', // Interval name. + ); + $opts = get_option( FQJ_OPTION_KEY, array() ); + $opts = wp_parse_args( $opts, $defaults ); + update_option( FQJ_OPTION_KEY, $opts ); - // schedule cron if not scheduled - if (! wp_next_scheduled('fqj_process_invalidation_queue')) { - // Use a custom interval 'fqj_five_minutes' registered in queue.php - wp_schedule_event(time() + 60, 'fqj_five_minutes', 'fqj_process_invalidation_queue'); - } + // schedule cron if not scheduled. + if ( ! wp_next_scheduled( 'fqj_process_invalidation_queue' ) ) { + // Use a custom interval 'fqj_five_minutes' registered in queue.php. + wp_schedule_event( time() + 60, 'fqj_five_minutes', 'fqj_process_invalidation_queue' ); + } } -register_activation_hook(__FILE__, 'fqj_activate'); +register_activation_hook( __FILE__, 'fqj_activate' ); /** * Deactivation: clear cron */ -function fqj_deactivate() -{ - $timestamp = wp_next_scheduled('fqj_process_invalidation_queue'); - if ($timestamp) { - wp_unschedule_event($timestamp, 'fqj_process_invalidation_queue'); - } +function fqj_deactivate() { + $timestamp = wp_next_scheduled( 'fqj_process_invalidation_queue' ); + if ( $timestamp ) { + wp_unschedule_event( $timestamp, 'fqj_process_invalidation_queue' ); + } } -register_deactivation_hook(__FILE__, 'fqj_deactivate'); +register_deactivation_hook( __FILE__, 'fqj_deactivate' ); diff --git a/includes/admin.php b/includes/admin.php index a6d0be8..33e53ee 100644 --- a/includes/admin.php +++ b/includes/admin.php @@ -1,267 +1,310 @@ post_type !== 'faq_item') { - return; - } +function fqj_admin_assets( $hook ) { + global $post; + if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) { + return; + } + if ( ! $post || 'faq_item' !== $post->post_type ) { + return; + } - wp_enqueue_style( - 'fqj-select2-css', - 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css', - [], - '4.0.13' - ); - wp_enqueue_script( - 'fqj-select2-js', - 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js', - ['jquery'], - '4.0.13', - true - ); + wp_enqueue_style( + 'fqj-select2-css', + 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css', + array(), + '4.0.13' + ); + wp_enqueue_script( + 'fqj-select2-js', + 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js', + array( 'jquery' ), + '4.0.13', + true + ); - wp_enqueue_script( - 'fqj-admin-js', - FQJ_PLUGIN_URL . 'assets/js/fqj-admin.js', - ['jquery', 'fqj-select2-js'], - '1.0', - true - ); - wp_localize_script('fqj-admin-js', 'fqjAdmin', [ - 'ajax_url' => admin_url('admin-ajax.php'), - 'nonce' => wp_create_nonce('fqj_admin_nonce'), - 'post_id' => get_the_ID(), - ]); + wp_enqueue_script( + 'fqj-admin-js', + FQJ_PLUGIN_URL . 'assets/js/fqj-admin.js', + array( 'jquery', 'fqj-select2-js' ), + '1.0', + true + ); + wp_localize_script( + 'fqj-admin-js', + 'fqjAdmin', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'fqj_admin_nonce' ), + 'post_id' => get_the_ID(), + ) + ); } -add_action('admin_enqueue_scripts', 'fqj_admin_assets'); +add_action( 'admin_enqueue_scripts', 'fqj_admin_assets' ); /** * Add association meta box */ -function fqj_add_meta_boxes() -{ - add_meta_box( - 'fqj_assoc_rules', - 'Associations & Output', - 'fqj_assoc_rules_meta_box_cb', - 'faq_item', - 'normal', - 'default' - ); +function fqj_add_meta_boxes() { + add_meta_box( + 'fqj_assoc_rules', + 'Associations & Output', + 'fqj_assoc_rules_meta_box_cb', + 'faq_item', + 'normal', + 'default' + ); } -add_action('add_meta_boxes', 'fqj_add_meta_boxes'); +add_action( 'add_meta_boxes', 'fqj_add_meta_boxes' ); -function fqj_assoc_rules_meta_box_cb($post) -{ - wp_nonce_field('fqj_save_meta', 'fqj_meta_nonce'); +/** + * Render association meta box. + * + * @param WP_Post $post The post object. + */ +function fqj_assoc_rules_meta_box_cb( $post ) { + wp_nonce_field( 'fqj_save_meta', 'fqj_meta_nonce' ); - // Load stored JSON payload for this FAQ - $data_json = get_post_meta($post->ID, 'fqj_assoc_data_json', true) ?: '{}'; - $data = json_decode($data_json, true); + // Load stored JSON payload for this FAQ. + $data_json = get_post_meta( $post->ID, 'fqj_assoc_data_json', true ); + $data_json = $data_json ? $data_json : '{}'; + $data = json_decode( $data_json, true ); - // Hydrate posts and terms with titles/names for Select2 - if (isset($data['posts']) && is_array($data['posts']) && ! empty($data['posts'])) { - $hydrated_posts = []; - $posts_to_fetch = array_map('intval', $data['posts']); - $fetched = get_posts(['include' => $posts_to_fetch, 'post_type' => 'any', 'posts_per_page' => -1]); - foreach ($fetched as $p) { - $hydrated_posts[] = ['id' => $p->ID, 'text' => get_the_title($p) . ' – ' . get_permalink($p)]; - } - $data['posts'] = $hydrated_posts; - } + // Hydrate posts and terms with titles/names for Select2. + if ( isset( $data['posts'] ) && is_array( $data['posts'] ) && ! empty( $data['posts'] ) ) { + $hydrated_posts = array(); + $posts_to_fetch = array_map( 'intval', $data['posts'] ); + $fetched = get_posts( + array( + 'include' => $posts_to_fetch, + 'post_type' => 'any', + 'posts_per_page' => -1, + ) + ); + foreach ( $fetched as $p ) { + $hydrated_posts[] = array( + 'id' => $p->ID, + 'text' => get_the_title( $p ) . ' – ' . get_permalink( $p ), + ); + } + $data['posts'] = $hydrated_posts; + } - if (isset($data['terms']) && is_array($data['terms']) && ! empty($data['terms'])) { - $hydrated_terms = []; - $terms_to_fetch = array_map('intval', $data['terms']); - foreach ($terms_to_fetch as $tid) { - $term = get_term($tid); - if ($term && ! is_wp_error($term)) { - $hydrated_terms[] = [ - 'id' => $term->term_id, - 'text' => $term->name . ' (' . $term->taxonomy . ')' - ]; - } - } - $data['terms'] = $hydrated_terms; - } + if ( isset( $data['terms'] ) && is_array( $data['terms'] ) && ! empty( $data['terms'] ) ) { + $hydrated_terms = array(); + $terms_to_fetch = array_map( 'intval', $data['terms'] ); + foreach ( $terms_to_fetch as $tid ) { + $term = get_term( $tid ); + if ( $term && ! is_wp_error( $term ) ) { + $hydrated_terms[] = array( + 'id' => $term->term_id, + 'text' => $term->name . ' (' . $term->taxonomy . ')', + ); + } + } + $data['terms'] = $hydrated_terms; + } - $data_json_hydrated = wp_json_encode($data); + $data_json_hydrated = wp_json_encode( $data ); - $assoc_type = get_post_meta($post->ID, 'fqj_assoc_type', true) ?: 'urls'; + $assoc_type = get_post_meta( $post->ID, 'fqj_assoc_type', true ); + $assoc_type = $assoc_type ? $assoc_type : 'urls'; - $assoc_types = [ - 'urls' => 'By URLs (one per line)', - 'posts' => 'By Posts (search & multi-select)', - 'post_types' => 'By Post Types (apply to all posts of selected types)', - 'tax_terms' => 'By Taxonomy Terms (search & multi-select)', - 'global' => 'Global (site-wide)', - ]; + $assoc_types = array( + 'urls' => 'By URLs (one per line)', + 'posts' => 'By Posts (search & multi-select)', + 'post_types' => 'By Post Types (apply to all posts of selected types)', + 'tax_terms' => 'By Taxonomy Terms (search & multi-select)', + 'global' => 'Global (site-wide)', + ); - echo '

'; - echo ''; + echo '

'; + echo ''; - echo ''; + echo ''; - echo '
'; + echo '
'; - echo '

Enter association details. Use the "By Posts" option to search and multi-select ' . - 'posts/pages. For large sites, prefer Post Types or Taxonomy Terms.

'; + echo '

Enter association details. Use the "By Posts" option to search and multi-select ' . + 'posts/pages. For large sites, prefer Post Types or Taxonomy Terms.

'; } /** * Save meta handler (similar to previous implementation but store payload JSON for indexing) + * + * @param int $post_id The post ID. + * @param WP_Post $post The post object. */ -function fqj_save_meta_handler($post_id, $post) -{ - if (! isset($_POST['fqj_meta_nonce'])) { - return; - } - if (! wp_verify_nonce($_POST['fqj_meta_nonce'], 'fqj_save_meta')) { - return; - } - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { - return; - } - if ($post->post_type !== 'faq_item') { - return; - } - if (! current_user_can('edit_post', $post_id)) { - return; - } +function fqj_save_meta_handler( $post_id, $post ) { + if ( ! isset( $_POST['fqj_meta_nonce'] ) ) { + return; + } + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['fqj_meta_nonce'] ) ), 'fqj_save_meta' ) ) { + return; + } + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + if ( 'faq_item' !== $post->post_type ) { + return; + } + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return; + } - $assoc_type = isset($_POST['fqj_assoc_type']) ? sanitize_text_field($_POST['fqj_assoc_type']) : 'urls'; - $payload = []; + $assoc_type = isset( $_POST['fqj_assoc_type'] ) ? sanitize_text_field( wp_unslash( $_POST['fqj_assoc_type'] ) ) : 'urls'; + $payload = array(); - if ($assoc_type === 'urls') { - $raw = isset($_POST['fqj_assoc_urls']) ? trim(wp_unslash($_POST['fqj_assoc_urls'])) : ''; - $lines = preg_split("/\r\n|\n|\r/", $raw); - $urls = []; - foreach ($lines as $l) { - $l = trim($l); - if (empty($l)) { - continue; - } - $urls[] = esc_url_raw($l); - } - $payload['urls'] = array_values(array_unique($urls)); - } elseif ($assoc_type === 'posts') { - $post_ids = []; - if (isset($_POST['fqj_assoc_posts_select']) && is_array($_POST['fqj_assoc_posts_select'])) { - foreach ($_POST['fqj_assoc_posts_select'] as $pid) { - $post_ids[] = intval($pid); - } - } - $payload['posts'] = array_values(array_unique($post_ids)); - } elseif ($assoc_type === 'post_types') { - $ptypes = []; - if (isset($_POST['fqj_assoc_post_types']) && is_array($_POST['fqj_assoc_post_types'])) { - foreach ($_POST['fqj_assoc_post_types'] as $pt) { - $ptypes[] = sanitize_text_field($pt); - } - } - $payload['post_types'] = array_values(array_unique($ptypes)); - } elseif ($assoc_type === 'tax_terms') { - $terms = []; - if (isset($_POST['fqj_assoc_terms_select']) && is_array($_POST['fqj_assoc_terms_select'])) { - foreach ($_POST['fqj_assoc_terms_select'] as $t) { - $terms[] = intval($t); - } - } - $payload['terms'] = array_values(array_unique($terms)); - } elseif ($assoc_type === 'global') { - $payload['global'] = true; - } + if ( 'urls' === $assoc_type ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $raw = isset( $_POST['fqj_assoc_urls'] ) ? trim( wp_unslash( $_POST['fqj_assoc_urls'] ) ) : ''; + $lines = preg_split( "/\r\n|\n|\r/", $raw ); + $urls = array(); + foreach ( $lines as $l ) { + $l = trim( $l ); + if ( empty( $l ) ) { + continue; + } + $urls[] = esc_url_raw( $l ); + } + $payload['urls'] = array_values( array_unique( $urls ) ); + } elseif ( 'posts' === $assoc_type ) { + $post_ids = array(); + if ( isset( $_POST['fqj_assoc_posts_select'] ) && is_array( $_POST['fqj_assoc_posts_select'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + foreach ( wp_unslash( $_POST['fqj_assoc_posts_select'] ) as $pid ) { + $post_ids[] = intval( $pid ); + } + } + $payload['posts'] = array_values( array_unique( $post_ids ) ); + } elseif ( 'post_types' === $assoc_type ) { + $ptypes = array(); + if ( isset( $_POST['fqj_assoc_post_types'] ) && is_array( $_POST['fqj_assoc_post_types'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + foreach ( wp_unslash( $_POST['fqj_assoc_post_types'] ) as $pt ) { + $ptypes[] = sanitize_text_field( $pt ); + } + } + $payload['post_types'] = array_values( array_unique( $ptypes ) ); + } elseif ( 'tax_terms' === $assoc_type ) { + $terms = array(); + if ( isset( $_POST['fqj_assoc_terms_select'] ) && is_array( $_POST['fqj_assoc_terms_select'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + foreach ( wp_unslash( $_POST['fqj_assoc_terms_select'] ) as $t ) { + $terms[] = intval( $t ); + } + } + $payload['terms'] = array_values( array_unique( $terms ) ); + } elseif ( 'global' === $assoc_type ) { + $payload['global'] = true; + } - update_post_meta($post_id, 'fqj_assoc_type', $assoc_type); - update_post_meta($post_id, 'fqj_assoc_data_json', wp_json_encode($payload)); + update_post_meta( $post_id, 'fqj_assoc_type', $assoc_type ); + update_post_meta( $post_id, 'fqj_assoc_data_json', wp_json_encode( $payload ) ); - // Rebuild index (indexer will delete and reinsert mappings, then invalidate transients) - fqj_rebuild_index_for_faq($post_id); + // Rebuild index (indexer will delete and reinsert mappings, then invalidate transients). + fqj_rebuild_index_for_faq( $post_id ); } -add_action('save_post', 'fqj_save_meta_handler', 10, 2); +add_action( 'save_post', 'fqj_save_meta_handler', 10, 2 ); /** * AJAX: search posts for Select2 */ -function fqj_ajax_search_posts() -{ - check_ajax_referer('fqj_admin_nonce', 'nonce'); - $q = isset($_REQUEST['q']) ? sanitize_text_field(wp_unslash($_REQUEST['q'])) : ''; - $results = []; - if (strlen($q) < 1) { - wp_send_json($results); - } +function fqj_ajax_search_posts() { + check_ajax_referer( 'fqj_admin_nonce', 'nonce' ); + $q = isset( $_REQUEST['q'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['q'] ) ) : ''; + $results = array(); + if ( strlen( $q ) < 1 ) { + wp_send_json( $results ); + } - $args = [ - 's' => $q, - 'post_type' => ['post', 'page'], - 'posts_per_page' => 20, - 'post_status' => 'publish', - ]; - $posts = get_posts($args); - foreach ($posts as $p) { - $results[] = ['id' => $p->ID, 'text' => get_the_title($p) . ' – ' . get_permalink($p)]; - } - wp_send_json($results); + $args = array( + 's' => $q, + 'post_type' => array( 'post', 'page' ), + 'posts_per_page' => 20, + 'post_status' => 'publish', + ); + $posts = get_posts( $args ); + foreach ( $posts as $p ) { + $results[] = array( + 'id' => $p->ID, + 'text' => get_the_title( $p ) . ' – ' . get_permalink( $p ), + ); + } + wp_send_json( $results ); } -add_action('wp_ajax_fqj_search_posts', 'fqj_ajax_search_posts'); +add_action( 'wp_ajax_fqj_search_posts', 'fqj_ajax_search_posts' ); /** * AJAX: search terms */ -function fqj_ajax_search_terms() -{ - check_ajax_referer('fqj_admin_nonce', 'nonce'); - $q = isset($_REQUEST['q']) ? sanitize_text_field(wp_unslash($_REQUEST['q'])) : ''; - $results = []; - if (strlen($q) < 1) { - wp_send_json($results); - } +function fqj_ajax_search_terms() { + check_ajax_referer( 'fqj_admin_nonce', 'nonce' ); + $q = isset( $_REQUEST['q'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['q'] ) ) : ''; + $results = array(); + if ( strlen( $q ) < 1 ) { + wp_send_json( $results ); + } - $taxonomies = get_taxonomies(['public' => true], 'names'); - foreach ($taxonomies as $tax) { - $terms = get_terms(['taxonomy' => $tax, 'name__like' => $q, 'number' => 10, 'hide_empty' => false]); - if (is_wp_error($terms)) { - continue; - } - foreach ($terms as $t) { - $results[] = ['id' => $t->term_id, 'text' => $t->name . ' (' . $tax . ')']; - } - } - wp_send_json($results); + $taxonomies = get_taxonomies( array( 'public' => true ), 'names' ); + foreach ( $taxonomies as $tax ) { + $terms = get_terms( + array( + 'taxonomy' => $tax, + 'name__like' => $q, + 'number' => 10, + 'hide_empty' => false, + ) + ); + if ( is_wp_error( $terms ) ) { + continue; + } + foreach ( $terms as $t ) { + $results[] = array( + 'id' => $t->term_id, + 'text' => $t->name . ' (' . $tax . ')', + ); + } + } + wp_send_json( $results ); } -add_action('wp_ajax_fqj_search_terms', 'fqj_ajax_search_terms'); +add_action( 'wp_ajax_fqj_search_terms', 'fqj_ajax_search_terms' ); /** * AJAX: get public post types */ -function fqj_ajax_get_post_types() -{ - check_ajax_referer('fqj_admin_nonce', 'nonce'); - $pts = get_post_types(['public' => true], 'objects'); - $out = []; - foreach ($pts as $k => $obj) { - $out[] = ['name' => $k, 'label' => $obj->labels->singular_name]; - } - wp_send_json($out); +function fqj_ajax_get_post_types() { + check_ajax_referer( 'fqj_admin_nonce', 'nonce' ); + $pts = get_post_types( array( 'public' => true ), 'objects' ); + $out = array(); + foreach ( $pts as $k => $obj ) { + $out[] = array( + 'name' => $k, + 'label' => $obj->labels->singular_name, + ); + } + wp_send_json( $out ); } -add_action('wp_ajax_fqj_get_post_types', 'fqj_ajax_get_post_types'); +add_action( 'wp_ajax_fqj_get_post_types', 'fqj_ajax_get_post_types' ); diff --git a/includes/class-fqjcli.php b/includes/class-fqjcli.php new file mode 100644 index 0000000..2888a90 --- /dev/null +++ b/includes/class-fqjcli.php @@ -0,0 +1,74 @@ +get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", '%_transient_fqj_faq_json_%' ) ); + $count = 0; + if ( $rows ) { + foreach ( $rows as $opt ) { + $key = preg_replace( '/^_transient_|^_transient_timeout_/', '', $opt ); + if ( delete_transient( $key ) ) { + ++$count; + } + } + } + WP_CLI::success( "Purged {$count} faq transients." ); + } + + /** + * Process invalidation queue via CLI. + * Usage: wp fqj process-queue --limit=1000 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function process_queue( $args, $assoc_args ) { + $limit = isset( $assoc_args['limit'] ) ? intval( $assoc_args['limit'] ) : null; + WP_CLI::log( 'Processing invalidate queue...' ); + $processed = fqj_process_invalidation_queue_now( $limit ); + WP_CLI::success( "Processed {$processed} invalidation items." ); + } + + /** + * Show queue length + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function queue_info( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $len = fqj_queue_length(); + WP_CLI::success( "Queue length: {$len}" ); + } + } + + WP_CLI::add_command( 'fqj', 'FQJ\FqjCli' ); +} diff --git a/includes/frontend.php b/includes/frontend.php index 5ecde79..dafc592 100644 --- a/includes/frontend.php +++ b/includes/frontend.php @@ -1,7 +1,12 @@ ID); - - $opts = get_option(FQJ_OPTION_KEY); - $cache_ttl = isset($opts['cache_ttl']) ? intval($opts['cache_ttl']) : 12 * HOUR_IN_SECONDS; - $output_type = isset($opts['output_type']) ? $opts['output_type'] : 'faqsection'; - - $transient_key = 'fqj_faq_json_' . $current_id; - $cached = get_transient($transient_key); - if ($cached !== false) { - if ($cached === '__FQJ_EMPTY__') { - echo "\n\n"; - } elseif (trim($cached) !== '') { - echo "\n\n" . $cached . "\n"; - } else { - // Should not happen, but for safety - echo "\n\n"; - } - - return; - } - - // Determine candidate mapping values for lookup - $candidate_values = []; - - // direct post id mapping - $candidate_values[] = ['type' => 'post', 'value' => (string) $current_id]; - - // current post type mapping - $pt = get_post_type($current_id); - if ($pt) { - $candidate_values[] = ['type' => 'post_type', 'value' => $pt]; - } - - // terms - $terms = wp_get_post_terms($current_id); - if (! is_wp_error($terms) && ! empty($terms)) { - foreach ($terms as $t) { - $candidate_values[] = ['type' => 'term', 'value' => (string) $t->term_id]; - } - } - - // URL mapping - try canonical/permalink - $permalink = get_permalink($current_id); - if ($permalink) { - $candidate_values[] = ['type' => 'url', 'value' => rtrim(strtok($permalink, '?'), '/')]; - } - - // Always include global mapping - $candidate_values[] = ['type' => 'global', 'value' => '1']; - - // Query mapping table for matching faq_ids - global $wpdb; - $table = FQJ_DB_TABLE; - - // Build WHERE clauses for each candidate - $where_clauses = []; - $params = []; - foreach ($candidate_values as $c) { - // Use exact match for all types to prevent partial matches (e.g. post ID 1 matching 10, 11, etc.) - // Since we store scalar values directly (or serialized which resolves to string), = is safer. - // For 'url', if we wanted partial matches we'd need a different logic, but here we expect exact path match. - $where_clauses[] = '(mapping_type = %s AND mapping_value = %s)'; - $params[] = $c['type']; - $params[] = (string) $c['value']; - } - - if (empty($where_clauses)) { - return; - } - - $where_sql = implode(' OR ', $where_clauses); - - $sql = $wpdb->prepare("SELECT DISTINCT faq_id FROM {$table} WHERE {$where_sql}", $params); - - $faq_ids = $wpdb->get_col($sql); - if (empty($faq_ids)) { - // cache negative result to avoid repeated queries - set_transient($transient_key, '__FQJ_EMPTY__', $cache_ttl); - echo "\n\n"; - - return; - } - - // Now fetch the FAQ posts - $args = [ - 'post_type' => 'faq_item', - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'post__in' => $faq_ids, - 'orderby' => 'post__in', - ]; - $faqs = get_posts($args); - if (! $faqs) { - set_transient($transient_key, '__FQJ_EMPTY__', $cache_ttl); - echo "\n\n"; - - return; - } - - $main_entities = []; - foreach ($faqs as $f) { - $q = get_the_title($f); - $a = wp_strip_all_tags(apply_filters('the_content', $f->post_content)); - if (empty($q) || empty($a)) { - continue; - } - - $main_entities[] = [ - '@type' => 'Question', - 'name' => $q, - 'acceptedAnswer' => [ - '@type' => 'Answer', - 'text' => $a, - ], - ]; - } - - if (empty($main_entities)) { - set_transient($transient_key, '__FQJ_EMPTY__', $cache_ttl); - echo "\n\n"; - - return; - } - - $json_ld = [ - '@context' => 'https://schema.org', - '@type' => ($output_type === 'faqpage' ? 'FAQPage' : 'FAQSection'), - 'mainEntity' => $main_entities, - ]; - - $script = ''; - - echo "\n\n"; - echo $script . "\n"; - - set_transient($transient_key, $script, $cache_ttl); +function fqj_maybe_print_faq_jsonld() { + if ( is_admin() ) { + return; + } + + global $post; + if ( ! $post ) { + return; + } + $current_id = intval( $post->ID ); + + $opts = get_option( FQJ_OPTION_KEY ); + $cache_ttl = isset( $opts['cache_ttl'] ) ? intval( $opts['cache_ttl'] ) : 12 * HOUR_IN_SECONDS; + $output_type = isset( $opts['output_type'] ) ? $opts['output_type'] : 'faqsection'; + + $transient_key = 'fqj_faq_json_' . $current_id; + $cached = get_transient( $transient_key ); + if ( false !== $cached ) { + if ( '__FQJ_EMPTY__' === $cached ) { + echo "\n\n"; + } elseif ( '' !== trim( $cached ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo "\n\n" . $cached . "\n"; + } else { + // Should not happen, but for safety. + echo "\n\n"; + } + + return; + } + + // Determine candidate mapping values for lookup. + $candidate_values = array(); + + // Direct post id mapping. + $candidate_values[] = array( + 'type' => 'post', + 'value' => (string) $current_id, + ); + + // Current post type mapping. + $pt = get_post_type( $current_id ); + if ( $pt ) { + $candidate_values[] = array( + 'type' => 'post_type', + 'value' => $pt, + ); + } + + // Terms. + $terms = wp_get_post_terms( $current_id ); + if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { + foreach ( $terms as $t ) { + $candidate_values[] = array( + 'type' => 'term', + 'value' => (string) $t->term_id, + ); + } + } + + // URL mapping - try canonical/permalink. + $permalink = get_permalink( $current_id ); + if ( $permalink ) { + $candidate_values[] = array( + 'type' => 'url', + 'value' => rtrim( strtok( $permalink, '?' ), '/' ), + ); + } + + // Always include global mapping. + $candidate_values[] = array( + 'type' => 'global', + 'value' => '1', + ); + + // Query mapping table for matching faq_ids. + global $wpdb; + $table = FQJ_DB_TABLE; + + // Build WHERE clauses for each candidate. + $where_clauses = array(); + $params = array(); + foreach ( $candidate_values as $c ) { + // Use exact match for all types to prevent partial matches (e.g. post ID 1 matching 10, 11, etc.) + // Since we store scalar values directly (or serialized which resolves to string), = is safer. + // For 'url', if we wanted partial matches we'd need a different logic, but here we expect exact path match. + $where_clauses[] = '(mapping_type = %s AND mapping_value = %s)'; + $params[] = $c['type']; + $params[] = (string) $c['value']; + } + + if ( empty( $where_clauses ) ) { + return; + } + + $where_sql = implode( ' OR ', $where_clauses ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = $wpdb->prepare( "SELECT DISTINCT faq_id FROM {$table} WHERE {$where_sql}", $params ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $faq_ids = $wpdb->get_col( $sql ); + if ( empty( $faq_ids ) ) { + // cache negative result to avoid repeated queries. + set_transient( $transient_key, '__FQJ_EMPTY__', $cache_ttl ); + echo "\n\n"; + + return; + } + + // Now fetch the FAQ posts. + $args = array( + 'post_type' => 'faq_item', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'post__in' => $faq_ids, + 'orderby' => 'post__in', + ); + $faqs = get_posts( $args ); + if ( ! $faqs ) { + set_transient( $transient_key, '__FQJ_EMPTY__', $cache_ttl ); + echo "\n\n"; + + return; + } + + $main_entities = array(); + foreach ( $faqs as $f ) { + $q = get_the_title( $f ); + $a = wp_strip_all_tags( apply_filters( 'the_content', $f->post_content ) ); + if ( empty( $q ) || empty( $a ) ) { + continue; + } + + $main_entities[] = array( + '@type' => 'Question', + 'name' => $q, + 'acceptedAnswer' => array( + '@type' => 'Answer', + 'text' => $a, + ), + ); + } + + if ( empty( $main_entities ) ) { + set_transient( $transient_key, '__FQJ_EMPTY__', $cache_ttl ); + echo "\n\n"; + + return; + } + + $json_ld = array( + '@context' => 'https://schema.org', + '@type' => ( 'faqpage' === $output_type ? 'FAQPage' : 'FAQSection' ), + 'mainEntity' => $main_entities, + ); + + $script = ''; + + echo "\n\n"; + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $script . "\n"; + + set_transient( $transient_key, $script, $cache_ttl ); } -add_action('wp_head', 'fqj_maybe_print_faq_jsonld', 1); +add_action( 'wp_head', 'fqj_maybe_print_faq_jsonld', 1 ); diff --git a/includes/health.php b/includes/health.php index 37e7a00..75d3904 100644 --- a/includes/health.php +++ b/includes/health.php @@ -1,6 +1,12 @@ admin_url('admin-ajax.php'), - 'nonce' => wp_create_nonce('fqj_health_nonce'), - ]); +function fqj_health_admin_assets( $hook ) { + // check page hook: load only on fqj-health page. + if ( 'faq_item_page_fqj-health' !== $hook ) { + return; + } + + wp_enqueue_script( + 'fqj-health-js', + FQJ_PLUGIN_URL . 'assets/js/fqj-health.js', + array( 'jquery' ), + '1.0', + true + ); + wp_localize_script( + 'fqj-health-js', + 'fqjHealth', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'fqj_health_nonce' ), + ) + ); } -add_action('admin_enqueue_scripts', 'fqj_health_admin_assets'); +add_action( 'admin_enqueue_scripts', 'fqj_health_admin_assets' ); /** * Render health page */ -function fqj_render_health_page() -{ - if (! current_user_can('manage_options')) { - return; - } - $queue_len = fqj_queue_length(); - $last_run_ts = get_option('fqj_last_queue_run', 0); - $log = get_option('fqj_invalidation_log', []); - if (! is_array($log)) { - $log = []; - } - - ?> -
-

FAQ JSON-LD — Health & Diagnostics

- -

Queue

-

Pending invalidation items in queue: - -

-

Last queue run: - -

- -

- - - - -

- -

Recent invalidation history

-

Shows up to the last runs (newest first).

- - - - - - - - - - - '; - } else { - foreach ($log as $entry) { - $ts = isset($entry['ts']) ? intval($entry['ts']) : 0; - $processed = isset($entry['processed']) ? intval($entry['processed']) : 0; - $sample = isset($entry['sample']) && is_array($entry['sample']) ? $entry['sample'] : []; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - } - ?> - -
TimestampProcessedSample post IDs
No invalidation runs logged yet.
' . esc_html( - $ts - ? date_i18n('Y-m-d H:i:s', $ts) . ' (' . human_time_diff($ts, time()) . ' ago)' - : 'n/a' - ) . '' . esc_html($processed) . '' . esc_html(implode(', ', $sample)) . '
- -

Notes

- -
- +
+

FAQ JSON-LD — Health & Diagnostics

+ +

Queue

+

Pending invalidation items in queue: + +

+

Last queue run: + +

+ +

+ + + + +

+ +

Recent invalidation history

+

Shows up to the last runs (newest first).

+ + + + + + + + + + + '; + } else { + foreach ( $log as $entry ) { + $ts = isset( $entry['ts'] ) ? intval( $entry['ts'] ) : 0; + $processed = isset( $entry['processed'] ) ? intval( $entry['processed'] ) : 0; + $sample = isset( $entry['sample'] ) && is_array( $entry['sample'] ) ? $entry['sample'] : array(); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + } + ?> + +
TimestampProcessedSample post IDs
No invalidation runs logged yet.
' . esc_html( + $ts + ? date_i18n( 'Y-m-d H:i:s', $ts ) . ' (' . human_time_diff( $ts, time() ) . ' ago)' + : 'n/a' + ) . '' . esc_html( $processed ) . '' . esc_html( implode( ', ', $sample ) ) . '
+ +

Notes

+ +
+ 'Permission denied'], 403); - } - - $limit = isset($_POST['limit']) ? intval($_POST['limit']) : null; - $processed = fqj_process_invalidation_queue_now($limit); - $queue_len = fqj_queue_length(); - $last_run = get_option('fqj_last_queue_run', 0); - - wp_send_json_success([ - 'processed' => $processed, - 'queue_len' => $queue_len, - 'last_run' => $last_run, - ]); +function fqj_ajax_process_queue_now() { + check_ajax_referer( 'fqj_health_nonce', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => 'Permission denied' ), 403 ); + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $limit = isset( $_POST['limit'] ) ? intval( $_POST['limit'] ) : null; + $processed = fqj_process_invalidation_queue_now( $limit ); + $queue_len = fqj_queue_length(); + $last_run = get_option( 'fqj_last_queue_run', 0 ); + + wp_send_json_success( + array( + 'processed' => $processed, + 'queue_len' => $queue_len, + 'last_run' => $last_run, + ) + ); } -add_action('wp_ajax_fqj_process_queue_now', 'fqj_ajax_process_queue_now'); +add_action( 'wp_ajax_fqj_process_queue_now', 'fqj_ajax_process_queue_now' ); /** * AJAX: purge all FAQ transients (admin) */ -function fqj_ajax_purge_transients() -{ - check_ajax_referer('fqj_health_nonce', 'nonce'); - if (! current_user_can('manage_options')) { - wp_send_json_error(['message' => 'Permission denied'], 403); - } - fqj_purge_all_faq_transients(); - wp_send_json_success(['message' => 'Purged']); +function fqj_ajax_purge_transients() { + check_ajax_referer( 'fqj_health_nonce', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => 'Permission denied' ), 403 ); + } + fqj_purge_all_faq_transients(); + wp_send_json_success( array( 'message' => 'Purged' ) ); } -add_action('wp_ajax_fqj_purge_transients', 'fqj_ajax_purge_transients'); +add_action( 'wp_ajax_fqj_purge_transients', 'fqj_ajax_purge_transients' ); /** * AJAX: clear invalidation log */ -function fqj_ajax_clear_invalidation_log() -{ - check_ajax_referer('fqj_health_nonce', 'nonce'); - if (! current_user_can('manage_options')) { - wp_send_json_error(['message' => 'Permission denied'], 403); - } - update_option('fqj_invalidation_log', []); - wp_send_json_success(['message' => 'Cleared']); +function fqj_ajax_clear_invalidation_log() { + check_ajax_referer( 'fqj_health_nonce', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => 'Permission denied' ), 403 ); + } + update_option( 'fqj_invalidation_log', array() ); + wp_send_json_success( array( 'message' => 'Cleared' ) ); } -add_action('wp_ajax_fqj_clear_invalidation_log', 'fqj_ajax_clear_invalidation_log'); +add_action( 'wp_ajax_fqj_clear_invalidation_log', 'fqj_ajax_clear_invalidation_log' ); diff --git a/includes/indexer.php b/includes/indexer.php index 4cd9b13..957acd8 100644 --- a/includes/indexer.php +++ b/includes/indexer.php @@ -1,7 +1,12 @@ get_charset_collate(); +function fqj_create_table() { + global $wpdb; + $table_name = FQJ_DB_TABLE; + $charset_collate = $wpdb->get_charset_collate(); - $sql = "CREATE TABLE IF NOT EXISTS {$table_name} ( + $sql = "CREATE TABLE IF NOT EXISTS {$table_name} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, faq_id BIGINT UNSIGNED NOT NULL, mapping_type VARCHAR(32) NOT NULL, -- 'post','post_type','term','url','global' @@ -26,206 +30,235 @@ function fqj_create_table() INDEX idx_mapping_value (mapping_value(191)) ) {$charset_collate};"; - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - dbDelta($sql); + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); } /** * Insert mapping rows for a given faq_id: accepts $mappings array with structure: * array( array('type' => 'post', 'value' => '123'), array('type'=>'url', 'value'=>'https://...'), ... ) + * + * @param int $faq_id The FAQ ID. + * @param array $mappings The mappings array. */ -function fqj_insert_mappings($faq_id, $mappings) -{ - global $wpdb; - $table = FQJ_DB_TABLE; - foreach ($mappings as $m) { - $wpdb->insert($table, [ - 'faq_id' => intval($faq_id), - 'mapping_type' => sanitize_text_field($m['type']), - 'mapping_value' => maybe_serialize($m['value']), - ], ['%d', '%s', '%s']); - } +function fqj_insert_mappings( $faq_id, $mappings ) { + global $wpdb; + $table = FQJ_DB_TABLE; + foreach ( $mappings as $m ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table, + array( + 'faq_id' => intval( $faq_id ), + 'mapping_type' => sanitize_text_field( $m['type'] ), + 'mapping_value' => maybe_serialize( $m['value'] ), + ), + array( '%d', '%s', '%s' ) + ); + } } /** * Delete mappings for faq_id (used to rebuild on save) + * + * @param int $faq_id The FAQ ID. */ -function fqj_delete_mappings_for_faq($faq_id) -{ - global $wpdb; - $table = FQJ_DB_TABLE; - $wpdb->delete($table, ['faq_id' => intval($faq_id)], ['%d']); +function fqj_delete_mappings_for_faq( $faq_id ) { + global $wpdb; + $table = FQJ_DB_TABLE; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->delete( $table, array( 'faq_id' => intval( $faq_id ) ), array( '%d' ) ); } /** * Build mapping rows from saved payload and index them * payload is array with keys 'urls', 'posts', 'post_types', 'terms', 'global' + * + * @param int $faq_id The FAQ ID. */ -function fqj_rebuild_index_for_faq($faq_id) -{ - // delete old - fqj_delete_mappings_for_faq($faq_id); - - $payload_json = get_post_meta($faq_id, 'fqj_assoc_data_json', true); - $payload = $payload_json ? json_decode($payload_json, true) : []; - - $mappings = []; - - if (isset($payload['urls']) && is_array($payload['urls'])) { - foreach ($payload['urls'] as $u) { - $mappings[] = ['type' => 'url', 'value' => $u]; - // also map internal URLs to post IDs for faster lookup - $pid = url_to_postid($u); - if ($pid) { - $mappings[] = ['type' => 'post', 'value' => intval($pid)]; - } - } - } - - if (isset($payload['posts']) && is_array($payload['posts'])) { - foreach ($payload['posts'] as $p) { - $mappings[] = ['type' => 'post', 'value' => intval($p)]; - } - } - - if (isset($payload['post_types']) && is_array($payload['post_types'])) { - foreach ($payload['post_types'] as $pt) { - $mappings[] = ['type' => 'post_type', 'value' => sanitize_text_field($pt)]; - } - } - - if (isset($payload['terms']) && is_array($payload['terms'])) { - foreach ($payload['terms'] as $t) { - $mappings[] = ['type' => 'term', 'value' => intval($t)]; - } - } - - if (isset($payload['global']) && $payload['global']) { - $mappings[] = ['type' => 'global', 'value' => '1']; - } - - if (! empty($mappings)) { - fqj_insert_mappings($faq_id, $mappings); - } - - // Enqueue affected posts for background invalidation - fqj_enqueue_invalidation_for_mappings($mappings); +function fqj_rebuild_index_for_faq( $faq_id ) { + // delete old. + fqj_delete_mappings_for_faq( $faq_id ); + + $payload_json = get_post_meta( $faq_id, 'fqj_assoc_data_json', true ); + $payload = $payload_json ? json_decode( $payload_json, true ) : array(); + + $mappings = array(); + + if ( isset( $payload['urls'] ) && is_array( $payload['urls'] ) ) { + foreach ( $payload['urls'] as $u ) { + $mappings[] = array( + 'type' => 'url', + 'value' => $u, + ); + // also map internal URLs to post IDs for faster lookup. + $pid = url_to_postid( $u ); + if ( $pid ) { + $mappings[] = array( + 'type' => 'post', + 'value' => intval( $pid ), + ); + } + } + } + + if ( isset( $payload['posts'] ) && is_array( $payload['posts'] ) ) { + foreach ( $payload['posts'] as $p ) { + $mappings[] = array( + 'type' => 'post', + 'value' => intval( $p ), + ); + } + } + + if ( isset( $payload['post_types'] ) && is_array( $payload['post_types'] ) ) { + foreach ( $payload['post_types'] as $pt ) { + $mappings[] = array( + 'type' => 'post_type', + 'value' => sanitize_text_field( $pt ), + ); + } + } + + if ( isset( $payload['terms'] ) && is_array( $payload['terms'] ) ) { + foreach ( $payload['terms'] as $t ) { + $mappings[] = array( + 'type' => 'term', + 'value' => intval( $t ), + ); + } + } + + if ( isset( $payload['global'] ) && $payload['global'] ) { + $mappings[] = array( + 'type' => 'global', + 'value' => '1', + ); + } + + if ( ! empty( $mappings ) ) { + fqj_insert_mappings( $faq_id, $mappings ); + } + + // Enqueue affected posts for background invalidation. + fqj_enqueue_invalidation_for_mappings( $mappings ); } /** * Resolve mappings to post IDs and enqueue them for invalidation using the background queue. * This function does not synchronously delete transients; it pushes affected IDs to the queue. + * + * @param array $mappings The mappings array. */ -function fqj_enqueue_invalidation_for_mappings($mappings) -{ - if (empty($mappings)) { - return; - } - - $post_ids_to_enqueue = []; - $post_types_to_process = []; - $terms_to_process = []; - $must_purge_all = false; - - foreach ($mappings as $m) { - switch ($m['type']) { - case 'post': - $post_ids_to_enqueue[] = intval($m['value']); - break; - case 'url': - $pid = url_to_postid($m['value']); - if ($pid) { - $post_ids_to_enqueue[] = intval($pid); - } - break; - case 'post_type': - $post_types_to_process[] = sanitize_text_field($m['value']); - break; - case 'term': - $terms_to_process[] = intval($m['value']); - break; - case 'global': - $must_purge_all = true; - break; - } - } - - // Enqueue direct post IDs - if (! empty($post_ids_to_enqueue)) { - fqj_queue_add_posts($post_ids_to_enqueue); - } - - // Enqueue posts of post types (in batches, gather ids) - if (! empty($post_types_to_process)) { - $opts = get_option(FQJ_OPTION_KEY); - $batch_size = isset($opts['batch_size']) ? intval($opts['batch_size']) : 500; - - foreach ($post_types_to_process as $pt) { - $paged = 1; - while (true) { - $args = [ - 'post_type' => $pt, - 'post_status' => 'any', - 'posts_per_page' => $batch_size, - 'paged' => $paged, - 'fields' => 'ids', - ]; - $q = new WP_Query($args); - if (! $q->have_posts()) { - break; - } - fqj_queue_add_posts($q->posts); - wp_reset_postdata(); - if (count($q->posts) < $batch_size) { - break; - } - $paged++; - } - } - } - - // Enqueue posts tagged with terms - if (! empty($terms_to_process)) { - $opts = get_option(FQJ_OPTION_KEY); - $batch_size = isset($opts['batch_size']) ? intval($opts['batch_size']) : 500; - - foreach ($terms_to_process as $term_id) { - $term = get_term($term_id); - if (! $term || is_wp_error($term)) { - continue; - } - - $paged = 1; - while (true) { - $args = [ - 'post_type' => 'any', - 'posts_per_page' => $batch_size, - 'paged' => $paged, - 'fields' => 'ids', - 'tax_query' => [ - [ - 'taxonomy' => $term->taxonomy, - 'terms' => $term_id, - 'field' => 'term_id', - ], - ], - ]; - $q = new WP_Query($args); - if (! $q->have_posts()) { - break; - } - fqj_queue_add_posts($q->posts); - wp_reset_postdata(); - if (count($q->posts) < $batch_size) { - break; - } - $paged++; - } - } - } - - // For global, do a full purge (we can't reasonably enqueue all posts efficiently here) - if ($must_purge_all) { - fqj_purge_all_faq_transients(); - } +function fqj_enqueue_invalidation_for_mappings( $mappings ) { + if ( empty( $mappings ) ) { + return; + } + + $post_ids_to_enqueue = array(); + $post_types_to_process = array(); + $terms_to_process = array(); + $must_purge_all = false; + + foreach ( $mappings as $m ) { + switch ( $m['type'] ) { + case 'post': + $post_ids_to_enqueue[] = intval( $m['value'] ); + break; + case 'url': + $pid = url_to_postid( $m['value'] ); + if ( $pid ) { + $post_ids_to_enqueue[] = intval( $pid ); + } + break; + case 'post_type': + $post_types_to_process[] = sanitize_text_field( $m['value'] ); + break; + case 'term': + $terms_to_process[] = intval( $m['value'] ); + break; + case 'global': + $must_purge_all = true; + break; + } + } + + // Enqueue direct post IDs. + if ( ! empty( $post_ids_to_enqueue ) ) { + fqj_queue_add_posts( $post_ids_to_enqueue ); + } + + // Enqueue posts of post types (in batches, gather ids). + if ( ! empty( $post_types_to_process ) ) { + $opts = get_option( FQJ_OPTION_KEY ); + $batch_size = isset( $opts['batch_size'] ) ? intval( $opts['batch_size'] ) : 500; + + foreach ( $post_types_to_process as $pt ) { + $paged = 1; + while ( true ) { + $args = array( + 'post_type' => $pt, + 'post_status' => 'any', + 'posts_per_page' => $batch_size, + 'paged' => $paged, + 'fields' => 'ids', + ); + $q = new WP_Query( $args ); + if ( ! $q->have_posts() ) { + break; + } + fqj_queue_add_posts( $q->posts ); + wp_reset_postdata(); + if ( count( $q->posts ) < $batch_size ) { + break; + } + ++$paged; + } + } + } + + // Enqueue posts tagged with terms. + if ( ! empty( $terms_to_process ) ) { + $opts = get_option( FQJ_OPTION_KEY ); + $batch_size = isset( $opts['batch_size'] ) ? intval( $opts['batch_size'] ) : 500; + + foreach ( $terms_to_process as $term_id ) { + $term = get_term( $term_id ); + if ( ! $term || is_wp_error( $term ) ) { + continue; + } + + $paged = 1; + while ( true ) { + $args = array( + 'post_type' => 'any', + 'posts_per_page' => $batch_size, + 'paged' => $paged, + 'fields' => 'ids', + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + array( + 'taxonomy' => $term->taxonomy, + 'terms' => $term_id, + 'field' => 'term_id', + ), + ), + ); + $q = new WP_Query( $args ); + if ( ! $q->have_posts() ) { + break; + } + fqj_queue_add_posts( $q->posts ); + wp_reset_postdata(); + if ( count( $q->posts ) < $batch_size ) { + break; + } + ++$paged; + } + } + } + + // For global, do a full purge (we can't reasonably enqueue all posts efficiently here). + if ( $must_purge_all ) { + fqj_purge_all_faq_transients(); + } } diff --git a/includes/queue.php b/includes/queue.php index 1d1e93c..f817795 100644 --- a/includes/queue.php +++ b/includes/queue.php @@ -1,7 +1,12 @@ 5 * MINUTE_IN_SECONDS, 'display' => 'Every 5 Minutes']; - } - - return $schedules; +function fqj_add_cron_interval( $schedules ) { + if ( ! isset( $schedules['fqj_five_minutes'] ) ) { + // phpcs:ignore WordPress.WP.CronInterval.CronSchedulesInterval + $schedules['fqj_five_minutes'] = array( + 'interval' => 5 * MINUTE_IN_SECONDS, + 'display' => 'Every 5 Minutes', + ); + } + + return $schedules; } -add_filter('cron_schedules', 'fqj_add_cron_interval'); +add_filter( 'cron_schedules', 'fqj_add_cron_interval' ); /** * Enqueue posts to the invalidation queue. + * + * @param array $post_ids Array of post IDs. + * @return bool True on success. */ -function fqj_queue_add_posts($post_ids) -{ - if (empty($post_ids)) { - return false; - } - - $post_ids = array_map('intval', $post_ids); - $post_ids = array_filter($post_ids); - - if (empty($post_ids)) { - return false; - } - - $key = 'fqj_invalidate_queue'; - $existing = get_transient($key); - $queue = []; - if ($existing !== false) { - $queue = maybe_unserialize($existing); - if (! is_array($queue)) { - $queue = []; - } - } - - $queue_map = array_flip($queue); - foreach ($post_ids as $pid) { - if (! isset($queue_map[$pid])) { - $queue[] = $pid; - } - } - - set_transient($key, $queue, DAY_IN_SECONDS); - - return true; +function fqj_queue_add_posts( $post_ids ) { + if ( empty( $post_ids ) ) { + return false; + } + + $post_ids = array_map( 'intval', $post_ids ); + $post_ids = array_filter( $post_ids ); + + if ( empty( $post_ids ) ) { + return false; + } + + $key = 'fqj_invalidate_queue'; + $existing = get_transient( $key ); + $queue = array(); + if ( false !== $existing ) { + $queue = maybe_unserialize( $existing ); + if ( ! is_array( $queue ) ) { + $queue = array(); + } + } + + $queue_map = array_flip( $queue ); + foreach ( $post_ids as $pid ) { + if ( ! isset( $queue_map[ $pid ] ) ) { + $queue[] = $pid; + } + } + + set_transient( $key, $queue, DAY_IN_SECONDS ); + + return true; } /** * Pop up to $limit posts from queue (returns array of post IDs popped). FIFO. + * + * @param int $limit Max items to pop. + * @return array */ -function fqj_queue_pop_posts($limit = 100) -{ - $key = 'fqj_invalidate_queue'; - $existing = get_transient($key); - if ($existing === false) { - return []; - } - - $queue = maybe_unserialize($existing); - if (! is_array($queue) || empty($queue)) { - return []; - } - - $pop = array_splice($queue, 0, intval($limit)); - if (empty($queue)) { - delete_transient($key); - } else { - set_transient($key, $queue, DAY_IN_SECONDS); - } - - return array_map('intval', $pop); +function fqj_queue_pop_posts( $limit = 100 ) { + $key = 'fqj_invalidate_queue'; + $existing = get_transient( $key ); + if ( false === $existing ) { + return array(); + } + + $queue = maybe_unserialize( $existing ); + if ( ! is_array( $queue ) || empty( $queue ) ) { + return array(); + } + + $pop = array_splice( $queue, 0, intval( $limit ) ); + if ( empty( $queue ) ) { + delete_transient( $key ); + } else { + set_transient( $key, $queue, DAY_IN_SECONDS ); + } + + return array_map( 'intval', $pop ); } /** * Get the approximate queue length */ -function fqj_queue_length() -{ - $key = 'fqj_invalidate_queue'; - $existing = get_transient($key); - if ($existing === false) { - return 0; - } - $queue = maybe_unserialize($existing); - if (! is_array($queue)) { - return 0; - } - - return count($queue); +function fqj_queue_length() { + $key = 'fqj_invalidate_queue'; + $existing = get_transient( $key ); + if ( false === $existing ) { + return 0; + } + $queue = maybe_unserialize( $existing ); + if ( ! is_array( $queue ) ) { + return 0; + } + + return count( $queue ); } /** @@ -111,109 +125,111 @@ function fqj_queue_length() * Stores an entry into option 'fqj_invalidation_log' as array of entries: * [ [ 'ts' => 12345, 'processed' => n, 'sample' => [ids] ], ... ] * Keeps only last 200 entries (configurable later). + * + * @param int $processed_count Number processed. + * @param array $sample_ids Sample IDs. */ -function fqj_log_invalidation_run($processed_count, $sample_ids = []) -{ - $opt_name = 'fqj_invalidation_log'; - $log = get_option($opt_name, []); - if (! is_array($log)) { - $log = []; - } - - $entry = [ - 'ts' => time(), - 'processed' => intval($processed_count), - 'sample' => array_slice(array_map('intval', $sample_ids), 0, 20), - ]; - - array_unshift($log, $entry); // newest first - - // cap length - $max = 200; // reasonable cap - if (count($log) > $max) { - $log = array_slice($log, 0, $max); - } - - update_option($opt_name, $log); - update_option('fqj_last_queue_run', time()); +function fqj_log_invalidation_run( $processed_count, $sample_ids = array() ) { + $opt_name = 'fqj_invalidation_log'; + $log = get_option( $opt_name, array() ); + if ( ! is_array( $log ) ) { + $log = array(); + } + + $entry = array( + 'ts' => time(), + 'processed' => intval( $processed_count ), + 'sample' => array_slice( array_map( 'intval', $sample_ids ), 0, 20 ), + ); + + array_unshift( $log, $entry ); // newest first. + + // cap length. + $max = 200; // reasonable cap. + if ( count( $log ) > $max ) { + $log = array_slice( $log, 0, $max ); + } + + update_option( $opt_name, $log ); + update_option( 'fqj_last_queue_run', time() ); } /** * Cron worker: processes up to batch_size posts from the queue */ -function fqj_process_invalidation_queue_cron() -{ - $opts = get_option(FQJ_OPTION_KEY); - $batch_size = isset($opts['batch_size']) ? intval($opts['batch_size']) : 500; - - $to_process = fqj_queue_pop_posts($batch_size); - if (empty($to_process)) { - // still record last run time - update_option('fqj_last_queue_run', time()); - fqj_log_invalidation_run(0, []); - - return; - } - - foreach ($to_process as $pid) { - $pid = intval($pid); - if ($pid <= 0) { - continue; - } - delete_transient('fqj_faq_json_' . $pid); - } - - // log and save last run - fqj_log_invalidation_run(count($to_process), array_slice($to_process, 0, 20)); +function fqj_process_invalidation_queue_cron() { + $opts = get_option( FQJ_OPTION_KEY ); + $batch_size = isset( $opts['batch_size'] ) ? intval( $opts['batch_size'] ) : 500; + + $to_process = fqj_queue_pop_posts( $batch_size ); + if ( empty( $to_process ) ) { + // still record last run time. + update_option( 'fqj_last_queue_run', time() ); + fqj_log_invalidation_run( 0, array() ); + + return; + } + + foreach ( $to_process as $pid ) { + $pid = intval( $pid ); + if ( $pid <= 0 ) { + continue; + } + delete_transient( 'fqj_faq_json_' . $pid ); + } + + // log and save last run. + fqj_log_invalidation_run( count( $to_process ), array_slice( $to_process, 0, 20 ) ); } -add_action('fqj_process_invalidation_queue', 'fqj_process_invalidation_queue_cron'); +add_action( 'fqj_process_invalidation_queue', 'fqj_process_invalidation_queue_cron' ); /** * Immediate queue processor (used by WP-CLI, admin AJAX or ad-hoc) * Returns number processed. + * + * @param int $limit Max items. + * @return int */ -function fqj_process_invalidation_queue_now($limit = null) -{ - $opts = get_option(FQJ_OPTION_KEY); - $batch_size = isset($opts['batch_size']) ? intval($opts['batch_size']) : 500; - $limit = $limit ? intval($limit) : $batch_size; - - $processed = 0; - $sample = []; - - // Pop and process until we hit the limit once - $pop = fqj_queue_pop_posts($limit); - if (! empty($pop)) { - foreach ($pop as $pid) { - delete_transient('fqj_faq_json_' . intval($pid)); - $processed++; - if (count($sample) < 20) { - $sample[] = intval($pid); - } - } - } - - // log run - fqj_log_invalidation_run($processed, $sample); - - return $processed; +function fqj_process_invalidation_queue_now( $limit = null ) { + $opts = get_option( FQJ_OPTION_KEY ); + $batch_size = isset( $opts['batch_size'] ) ? intval( $opts['batch_size'] ) : 500; + $limit = $limit ? intval( $limit ) : $batch_size; + + $processed = 0; + $sample = array(); + + // Pop and process until we hit the limit once. + $pop = fqj_queue_pop_posts( $limit ); + if ( ! empty( $pop ) ) { + foreach ( $pop as $pid ) { + delete_transient( 'fqj_faq_json_' . intval( $pid ) ); + ++$processed; + if ( count( $sample ) < 20 ) { + $sample[] = intval( $pid ); + } + } + } + + // log run. + fqj_log_invalidation_run( $processed, $sample ); + + return $processed; } /** * Admin notice helper: show queue length on admin screens for admins. */ -function fqj_admin_queue_notice() -{ - if (! current_user_can('manage_options')) { - return; - } - $len = fqj_queue_length(); - if ($len > 0) { - printf( - '

FAQ JSON-LD queue: %d ' . - 'posts pending invalidation. The background worker (WP-Cron) will process them in batches.

', - intval($len) - ); - } +function fqj_admin_queue_notice() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + $len = fqj_queue_length(); + if ( $len > 0 ) { + printf( + '

FAQ JSON-LD queue: %d ' . + 'posts pending invalidation. The background worker (WP-Cron) will process them in batches.

', + intval( $len ) + ); + } } -add_action('admin_notices', 'fqj_admin_queue_notice'); +add_action( 'admin_notices', 'fqj_admin_queue_notice' ); diff --git a/includes/settings.php b/includes/settings.php index 1a5d255..0d2a4e1 100644 --- a/includes/settings.php +++ b/includes/settings.php @@ -1,6 +1,12 @@

Settings saved.

'; - } - - $cache_ttl = isset($options['cache_ttl']) ? intval($options['cache_ttl']) : 12 * HOUR_IN_SECONDS; - $batch_size = isset($options['batch_size']) ? intval($options['batch_size']) : 500; - $output_type = isset($options['output_type']) ? $options['output_type'] : 'faqsection'; - ?> -
-

FAQ JSON-LD Settings

-
- - - - - - - - - - - - - - - -
-

- Number of seconds to cache per-post JSON-LD. Default is 43200 (12 hours). -

-

- When invalidating many posts (e.g., post-type mapping), process posts in batches - of this size to avoid timeouts. -

- -

- Choose whether the plugin outputs FAQSection or FAQPage by default. - Individual FAQs still control associations. -

-
- - -
- -

Tools

-

- Purge all FAQ transients: -

- - - -
-

- -

All FAQ transients purged.

'; - } - ?> - - -

Settings saved.

'; + } + + $cache_ttl = isset( $options['cache_ttl'] ) ? intval( $options['cache_ttl'] ) : 12 * HOUR_IN_SECONDS; + $batch_size = isset( $options['batch_size'] ) ? intval( $options['batch_size'] ) : 500; + $output_type = isset( $options['output_type'] ) ? $options['output_type'] : 'faqsection'; + ?> +
+

FAQ JSON-LD Settings

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

+ Number of seconds to cache per-post JSON-LD. Default is 43200 (12 hours). +

+

+ When invalidating many posts (e.g., post-type mapping), process posts in batches + of this size to avoid timeouts. +

+ +

+ Choose whether the plugin outputs FAQSection or FAQPage by default. + Individual FAQs still control associations. +

+
+ + +
+ +

Tools

+

+ Purge all FAQ transients: +

+
+ + + +
+ + +

All FAQ transients purged.

'; + } + ?> + + + options} WHERE option_name LIKE %s"; - $rows = $wpdb->get_col($wpdb->prepare($sql, '%_transient_fqj_faq_json_%')); - if ($rows) { - foreach ($rows as $opt) { - // option_name may be _transient_fqj_faq_json_{id} or _transient_timeout_fqj_faq_json_{id} - $key = preg_replace('/^_transient_|^_transient_timeout_/', '', $opt); - delete_transient($key); - } - } +function fqj_purge_all_faq_transients() { + global $wpdb; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", '%_transient_fqj_faq_json_%' ) ); + // phpcs:enable + + if ( $rows ) { + foreach ( $rows as $opt ) { + // option_name may be _transient_fqj_faq_json_{id} or _transient_timeout_fqj_faq_json_{id}. + $key = preg_replace( '/^_transient_|^_transient_timeout_/', '', $opt ); + delete_transient( $key ); + } + } } diff --git a/includes/wpcli.php b/includes/wpcli.php deleted file mode 100644 index afe8b56..0000000 --- a/includes/wpcli.php +++ /dev/null @@ -1,60 +0,0 @@ -options} WHERE option_name LIKE %s"; - $rows = $wpdb->get_col($wpdb->prepare($sql, '%_transient_fqj_faq_json_%')); - $count = 0; - if ($rows) { - foreach ($rows as $opt) { - $key = preg_replace('/^_transient_|^_transient_timeout_/', '', $opt); - if (delete_transient($key)) { - $count++; - } - } - } - WP_CLI::success("Purged {$count} faq transients."); - } - - /** - * Process invalidation queue via CLI. - * Usage: wp fqj process-queue --limit=1000 - */ - public function processQueue($args, $assoc_args) - { - $limit = isset($assoc_args['limit']) ? intval($assoc_args['limit']) : null; - WP_CLI::log('Processing invalidate queue...'); - $processed = fqj_process_invalidation_queue_now($limit); - WP_CLI::success("Processed {$processed} invalidation items."); - } - - /** - * Show queue length - */ - public function queueInfo($args, $assoc_args) - { - $len = fqj_queue_length(); - WP_CLI::success("Queue length: {$len}"); - } - } - - WP_CLI::add_command('fqj', 'FQJ\FqjCli'); -}