From 1880fdd8e4e6fe5f02b012b0f3fe4a48d9c7787b Mon Sep 17 00:00:00 2001 From: Girish Panchal <79647963+girishpanchal30@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:31:04 +0530 Subject: [PATCH 01/15] Guarded action scheduler (#1376) * fix: guard action scheduler * fix: update PHP version * fix: enhance action scheduler checks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pirate-bot <58979018+pirate-bot@users.noreply.github.com> --- classes/Visualizer/Module/Setup.php | 30 ++++++++++++++++++++++++++++- index.php | 17 +++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/classes/Visualizer/Module/Setup.php b/classes/Visualizer/Module/Setup.php index 2ef61c60..6ce00659 100644 --- a/classes/Visualizer/Module/Setup.php +++ b/classes/Visualizer/Module/Setup.php @@ -45,6 +45,7 @@ public function __construct( Visualizer_Plugin $plugin ) { register_activation_hook( VISUALIZER_BASEFILE, array( $this, 'activate' ) ); register_deactivation_hook( VISUALIZER_BASEFILE, array( $this, 'deactivate' ) ); $this->_addAction( 'visualizer_schedule_refresh_db', 'refreshDbChart' ); + $this->_addAction( 'init', 'maybe_reschedule_refresh_db' ); $this->_addFilter( 'visualizer_schedule_refresh_chart', 'refresh_db_for_chart', 10, 3 ); $this->_addAction( 'admin_init', 'adminInit' ); @@ -490,7 +491,11 @@ private function schedule_refresh_db_action(): void { $interval = $this->get_schedule_interval_seconds( $interval_key ); $timestamp = strtotime( 'midnight' ) - get_option( 'gmt_offset' ) * HOUR_IN_SECONDS; - if ( function_exists( 'as_next_scheduled_action' ) && function_exists( 'as_schedule_recurring_action' ) ) { + if ( + visualizer_can_use_action_scheduler() + && function_exists( 'as_next_scheduled_action' ) + && function_exists( 'as_schedule_recurring_action' ) + ) { $next = as_next_scheduled_action( $hook, array(), $group ); if ( false === $next ) { as_schedule_recurring_action( $timestamp, $interval, $hook, array(), $group ); @@ -503,6 +508,29 @@ private function schedule_refresh_db_action(): void { wp_schedule_event( $timestamp, $interval_key, $hook ); } + /** + * Keep the DB refresh scheduled when Action Scheduler is not available. + * + * The migration to Action Scheduler clears the WP-Cron event, so a site that + * already migrated and then lost the library would have nothing left running + * the refresh. Re-arms WP-Cron in that case; no-op whenever the library is up. + */ + public function maybe_reschedule_refresh_db(): void { + if ( + visualizer_can_use_action_scheduler() + && function_exists( 'as_next_scheduled_action' ) + && function_exists( 'as_schedule_recurring_action' ) + ) { + return; + } + + if ( wp_next_scheduled( 'visualizer_schedule_refresh_db' ) ) { + return; + } + + $this->schedule_refresh_db_action(); + } + /** * Unschedule the recurring DB refresh action. */ diff --git a/index.php b/index.php index dd010a6e..b25f24fb 100644 --- a/index.php +++ b/index.php @@ -152,7 +152,7 @@ function () { $action_scheduler_file = VISUALIZER_ABSPATH . '/vendor/woocommerce/action-scheduler/action-scheduler.php'; - if ( is_readable( $action_scheduler_file ) ) { + if ( visualizer_can_use_action_scheduler() && is_readable( $action_scheduler_file ) ) { require_once $action_scheduler_file; } @@ -217,6 +217,21 @@ function ( $products ) { } } +/** + * Whether the bundled Action Scheduler can run against this wpdb. + * + * The bundled library calls wpdb::db_server_info() unguarded when it claims a + * queue batch, and core only added that method in WordPress 5.5. Loading it + * without the method available fatals, so those sites stay on WP-Cron instead. + * + * @return bool + */ +function visualizer_can_use_action_scheduler() { + global $wpdb; + + return isset( $wpdb ) && is_callable( array( $wpdb, 'db_server_info' ) ); +} + /** * Registers with the SDK * From ac87eb4d62ce87961026acd5bf962885f42a2b69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:55:57 +0000 Subject: [PATCH 02/15] Initial plan From 5e1dc5df72569b7264f9976854c2d99f5763e62c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:04:28 +0000 Subject: [PATCH 03/15] Update readme stable tag and shorten changelog Co-authored-by: HardeepAsrani <2649903+HardeepAsrani@users.noreply.github.com> --- Gruntfile.js | 6 + readme.txt | 1078 +------------------------------------------------- 2 files changed, 8 insertions(+), 1076 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index e6fb298e..56775154 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -18,6 +18,12 @@ module.exports = function (grunt) { 'package.json' ] }, + readmetxt: { + options: { + prefix: 'Stable tag:\\s*', + }, + src: [ 'readme.txt' ], + }, style: { options: { prefix: 'Version\\:\\s' diff --git a/readme.txt b/readme.txt index a1c6e364..ef7599fc 100755 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: charts, tables, datatable, data-visualization, ai-charts Requires at least: 5.2 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: trunk +Stable tag: 4.0.8 License: GPL v2.0 or later License URI: http://www.opensource.org/licenses/gpl-license.php @@ -253,18 +253,12 @@ Please follow the reporting protocols outlined on our [Security Page](https://th - Fixed script execution from saved JSON chart credentials. Thanks Yaswanth Reddy Sunkara for the report. - Updated dependencies - - - ##### [Version 4.0.7](https://github.com/Codeinwp/visualizer/compare/v4.0.6...v4.0.7) (2026-07-30) - Fixed charts using a database source saving the results of the previous query, so a saved chart no longer differs from the data shown in the editor. - Updated dependencies - Enhanced security — fixed unauthorized chart_data REST access. - - - ##### [Version 4.0.6](https://github.com/Codeinwp/visualizer/compare/v4.0.5...v4.0.6) (2026-07-22) - Fixed scheduled imports from remote CSV files so charts continue to refresh automatically in the background. @@ -272,1072 +266,4 @@ Please follow the reporting protocols outlined on our [Security Page](https://th - Improved the overall security of the product. Thanks to **creeper_kirby** for responsibly reporting the issue. - Updated dependencies. - - - -##### [Version 4.0.5](https://github.com/Codeinwp/visualizer/compare/v4.0.4...v4.0.5) (2026-07-10) - -- Enhanced security - - - - -##### [Version 4.0.4](https://github.com/Codeinwp/visualizer/compare/v4.0.3...v4.0.4) (2026-06-30) - -- Enhanced security - - - - -##### [Version 4.0.3](https://github.com/Codeinwp/visualizer/compare/v4.0.2...v4.0.3) (2026-06-09) - -- Improved layout compatibility with WP 7.0 - - - - -##### [Version 4.0.2](https://github.com/Codeinwp/visualizer/compare/v4.0.1...v4.0.2) (2026-05-19) - -- Fixed broken candlestick chart -- Updated dependencies -- Enhanced security (CVE-2026-65526 fixed) - - - - -##### [Version 4.0.1](https://github.com/Codeinwp/visualizer/compare/v4.0.0...v4.0.1) (2026-03-31) - -- Internal update to ensure compatibility and improve system consistency. - - - - -#### [Version 4.0.0](https://github.com/Codeinwp/visualizer/compare/v3.11.15...v4.0.0) (2026-03-31) - -### New Features -- Introducing AI chart creation powered by D3.js -- You can now import XLSX files natively -- The charts library now includes a grid and list view for easier browsing -- Added a new Elementor widget for displaying Visualizer charts -- Improved auto-sync reliability by replacing WP-Cron with Action Scheduler - -### Improvements -- Cleaned up legacy block controls in the Block Editor - -### Bug Fixes -- Resolved an issue where lazy charts didn’t render when already in the viewport on page load -- Fixed a fatal error on servers with broken FTP filesystem configuration -- Addressed errors occurring in the getCSV method -- Fixed an intermittent sidebar scroll issue in Chrome -- Prevented a double clipboard alert when copying -- Fixed a bubble chart crash on window resize when using named series in manual configuration - - - - -##### [Version 3.11.15](https://github.com/Codeinwp/visualizer/compare/v3.11.14...v3.11.15) (2026-03-04) - -- Enhanced security - - - - -##### [Version 3.11.14](https://github.com/Codeinwp/visualizer/compare/v3.11.13...v3.11.14) (2025-11-28) - -- Enhanced security - - - - -##### [Version 3.11.13](https://github.com/Codeinwp/visualizer/compare/v3.11.12...v3.11.13) (2025-11-06) - -- Enhanced security - - - - -##### [Version 3.11.12](https://github.com/Codeinwp/visualizer/compare/v3.11.11...v3.11.12) (2025-09-04) - -- Updated dependencies -- Fixed issue with the number format not being applied properly to the last value of the Google chart - - - - -##### [Version 3.11.11](https://github.com/Codeinwp/visualizer/compare/v3.11.10...v3.11.11) (2025-05-27) - -- Fixed chart rendering on page load with multiple charts -- Updated dependencies - - - - -##### [Version 3.11.10](https://github.com/Codeinwp/visualizer/compare/v3.11.9...v3.11.10) (2025-04-16) - -- Fixed special characters not rendering correctly in Legend labels -- Updated dependencies - - - - -##### [Version 3.11.9](https://github.com/Codeinwp/visualizer/compare/v3.11.8...v3.11.9) (2025-02-13) - -- Enhanced security -- Fixed extra column issue with a simple editor - - - - -##### [Version 3.11.8](https://github.com/Codeinwp/visualizer/compare/v3.11.7...v3.11.8) (2024-11-13) - -- Updated WordPress core tested up to version - - - - -##### [Version 3.11.7](https://github.com/Codeinwp/visualizer/compare/v3.11.6...v3.11.7) (2024-11-07) - -- Updated dependencies -- Enhanced security - - - - -##### [Version 3.11.6](https://github.com/Codeinwp/visualizer/compare/v3.11.5...v3.11.6) (2024-08-30) - -- Fixed chart overlapping issue with the Gutenberg editor - - - - -##### [Version 3.11.5](https://github.com/Codeinwp/visualizer/compare/v3.11.4...v3.11.5) (2024-07-17) - -- Updated promotional links and buttons - - - - -##### [Version 3.11.4](https://github.com/Codeinwp/visualizer/compare/v3.11.3...v3.11.4) (2024-07-09) - -- Fixed broken link in the Map settings of the Geo Chart -- Fixed the default cron job sync time for data synchronization [PRO] - - - - -##### [Version 3.11.3](https://github.com/Codeinwp/visualizer/compare/v3.11.2...v3.11.3) (2024-05-27) - -- Fixed the rendering of Visualizer widgets in the Customizer - - - - -##### [Version 3.11.2](https://github.com/Codeinwp/visualizer/compare/v3.11.1...v3.11.2) (2024-05-23) - -- Fixed Visualizer block widget not loading -- Improved the popup rendering -- Enhanced security - - - - -##### [Version 3.11.1](https://github.com/Codeinwp/visualizer/compare/v3.11.0...v3.11.1) (2024-05-14) - -- Fixed the permissions of the plans - - - - -#### [Version 3.11.0](https://github.com/Codeinwp/visualizer/compare/v3.10.15...v3.11.0) (2024-05-13) - -#### Enhancements -- Added a manual data edit feature to the free version of the plugin -- Improved the flow of chart creation -- Added a chart information panel: backend chart title & quick shortcode access -- Line and Pie charts from the ChartJS library are now available for free users, along with library selection and relative images -- Improved wording in onboarding -- Improved workflow for creating charts via the Visualizer Gutenberg block -- Moved chart library selection to the top of the page and showing charts from the available library only -- Unified chart edition (removed duplicated chart settings in block editor) -- Added optional feature usage data -- Enhanced security -- Improved the UI of the database query option (visible database prefix, sample query, autocompleting table names) [PRO] - - - - -##### [Version 3.10.15](https://github.com/Codeinwp/visualizer/compare/v3.10.14...v3.10.15) (2024-05-10) - -- Updated internal dependencies - - - - -##### [Version 3.10.14](https://github.com/Codeinwp/visualizer/compare/v3.10.13...v3.10.14) (2024-05-10) - -- Updated internal dependencies - - - - -##### [Version 3.10.13](https://github.com/Codeinwp/visualizer/compare/v3.10.12...v3.10.13) (2024-04-17) - -### Fixes -- **Updated internal dependencies:** Enhanced performance and security. - - - - -##### [Version 3.10.12](https://github.com/Codeinwp/visualizer/compare/v3.10.11...v3.10.12) (2024-04-15) - -### Fixes -- **Updated internal dependencies:** Enhanced performance and security. - - - - -##### [Version 3.10.11](https://github.com/Codeinwp/visualizer/compare/v3.10.10...v3.10.11) (2024-04-01) - -### Fixes -- Updated internal dependencies -- Improved E2E testing - - - - -##### [Version 3.10.10](https://github.com/Codeinwp/visualizer/compare/v3.10.9...v3.10.10) (2024-03-28) - -### Fixes -- Fixed blueprint schema for Live Preview - - - - -##### [Version 3.10.9](https://github.com/Codeinwp/visualizer/compare/v3.10.8...v3.10.9) (2024-03-27) - -### Fixes -- Visualizer live preview fixed -- Updated internal dependencies - - - - -##### [Version 3.10.8](https://github.com/Codeinwp/visualizer/compare/v3.10.7...v3.10.8) (2024-03-14) - -### Bug Fixes -- Fix Google chart rendering condition for Classic Plugin -- Changes the speed and position of the loading bar - -### Improvements -- Add a Live Preview button for the plugin page on wordpress.org -- Added NPS survey - - - - -##### [Version 3.10.7](https://github.com/Codeinwp/visualizer/compare/v3.10.6...v3.10.7) (2024-02-06) - -### Fixes -- Rendering charts that are under tabs/hidden containers -- Updated dependencies -- Updated CTA buttons -- Updated UTM campaign links - - - - -##### [Version 3.10.6](https://github.com/Codeinwp/visualizer/compare/v3.10.5...v3.10.6) (2024-01-18) - -### Fixes -- Enhanced security - - - - -##### [Version 3.10.5](https://github.com/Codeinwp/visualizer/compare/v3.10.4...v3.10.5) (2023-12-19) - -### Improvements -- **Added chart responsive support** -- **SDK Updates** - -### Bug Fixes -- **Fixed chart data filter issue** -- **Fixed animation issue fusion builder** -- **Fixed live import issue with Gutenberg block** -- **Fixed tooltip text rendering issue in ChartJS** -- **Fixed onboarding chart import issue** -- **Removed PHP deprecated function** -- **Fixed compatibility issue with WP VIP** -- **Fixed PHP 8.1 deprecated error** - - - - -##### [Version 3.10.4](https://github.com/Codeinwp/visualizer/compare/v3.10.3...v3.10.4) (2023-08-17) - -- Updated dependencies -- Updated WordPress version required to 5.2 or higher - - - - -##### [Version 3.10.3](https://github.com/Codeinwp/visualizer/compare/v3.10.2...v3.10.3) (2023-06-13) - -- Removed branding label - - - - -##### [Version 3.10.2](https://github.com/Codeinwp/visualizer/compare/v3.10.1...v3.10.2) (2023-06-05) - -- Added About Us page integration -- Updated dependencies - - - - -##### [Version 3.10.1](https://github.com/Codeinwp/visualizer/compare/v3.10.0...v3.10.1) (2023-05-24) - -- Fixed data tables horizontal scrolling issue -- Added legend position default label text -- Added tooltip default label text -- Fixed Gutenberg block error -- Fixed table chart rendering issue in Gutenberg block -- Fixed compatibility issue with the FSE editor -- Fixed compatibility issue with WP 5.6 -- Fixed console error when importing charts from others -- Fixed pagination enable/disable issue -- Fixed editor rows limitation issue - - - - -#### [Version 3.10.0](https://github.com/Codeinwp/visualizer/compare/v3.9.7...v3.10.0) (2023-04-04) - -- Added a setup wizard onboarding page -- Fixed empty chart issue - - - - -##### [Version 3.9.7](https://github.com/Codeinwp/visualizer/compare/v3.9.6...v3.9.7) (2023-03-30) - -- Improved chart actions icons -- Updated WordPress core tested up to version - - - - -##### [Version 3.9.6](https://github.com/Codeinwp/visualizer/compare/v3.9.5...v3.9.6) (2023-03-20) - -- Fixed custom format issue in ChartJS - - - - -##### [Version 3.9.5](https://github.com/Codeinwp/visualizer/compare/v3.9.4...v3.9.5) (2023-01-30) - -- Improved security by escaping shortcode attribute before render -- Compatibility with PHP 8.2 - - - - -##### [Version 3.9.4](https://github.com/Codeinwp/visualizer/compare/v3.9.3...v3.9.4) (2023-01-12) - -- Fixed the lock of PRO features bypass - - - - -##### [Version 3.9.3](https://github.com/Codeinwp/visualizer/compare/v3.9.2...v3.9.3) (2023-01-05) - -- Fixed the chart refresh problem when importing from CSV [#970](https://github.com/Codeinwp/visualizer/issues/970) - - - - -##### [Version 3.9.2](https://github.com/Codeinwp/visualizer/compare/v3.9.1...v3.9.2) (2022-12-21) - -- Fixed Geo chart [PRO] flickering issue -- Added disable legend option for ChartJS chart -- Enhanced security - - - - -##### [Version 3.9.1](https://github.com/Codeinwp/visualizer/compare/v3.9.0...v3.9.1) (2022-11-17) - -- Fixed PHP error when Polylang plugin is activated [#960](https://github.com/Codeinwp/visualizer/issues/960) - - - - -#### [Version 3.9.0](https://github.com/Codeinwp/visualizer/compare/v3.8.1...v3.9.0) (2022-11-10) - -- Fixed table chart decimal number issue Decimal Numbers are ignored and not displayed [#955](https://github.com/Codeinwp/visualizer/issues/955) -- Fix the scrollbar issue that happens on WP 6.1 version [WP 6.1] Chart permissions selector is not scrollable [#950](https://github.com/Codeinwp/visualizer/issues/950) -- Fix PHP Warning Warning when copy (frontend action) is enabled [#949](https://github.com/Codeinwp/visualizer/issues/949) -- Compatibility with the WPML translation plugin for translating charts [PRO feature] add support for string translations [#731](https://github.com/Codeinwp/visualizer/issues/731) -- Integration with Woocommerce Data endpoints for creating charts [PRO feature] -- Show new features on chart library page - - - - -##### [Version 3.8.1](https://github.com/Codeinwp/visualizer/compare/v3.8.0...v3.8.1) (2022-10-12) - - -- Change copy shortcode icon to clone a chart We can change copy Short Code icon [#945](https://github.com/Codeinwp/visualizer/issues/945) -- Fix temp file creation issue on instawp selecting the line chart is not working on instawp [#940](https://github.com/Codeinwp/visualizer/issues/940) -- Fix control Type error -- Fixed simple editor scrolling issue -- Fix filter broken layout -- Fix chartJs javascript error when rendering multiple charts - - - - -#### [Version 3.8.0](https://github.com/Codeinwp/visualizer/compare/v3.7.12...v3.8.0) (2022-09-21) - -- Add data filter support for the charts - - - - -##### [Version 3.7.12](https://github.com/Codeinwp/visualizer/compare/v3.7.11...v3.7.12) (2022-09-07) - -- Fixed compatibility issue with some themes and plugins [#933](https://github.com/Codeinwp/visualizer/issues/933) - - - - -##### [Version 3.7.11](https://github.com/Codeinwp/visualizer/compare/v3.7.10...v3.7.11) (2022-08-30) - -- Fix update axis baseline color [#925](https://github.com/Codeinwp/visualizer/issues/925) -- Update more features images [#926](https://github.com/Codeinwp/visualizer/issues/926) -- Fix update minor grid line field [#924](https://github.com/Codeinwp/visualizer/issues/924) -- Add new filter for container attributes [#922](https://github.com/Codeinwp/visualizer/issues/922) -- Fix axis data format issue [#920](https://github.com/Codeinwp/visualizer/issues/920) -- Chart unique title [#915](https://github.com/Codeinwp/visualizer/issues/915) -- Fix block back button issue [#930](https://github.com/Codeinwp/visualizer/issues/930) -- Userguide broken documentation link [#918](https://github.com/Codeinwp/visualizer/issues/918) - - - - -##### [Version 3.7.10](https://github.com/Codeinwp/visualizer/compare/v3.7.9...v3.7.10) (2022-07-05) - - -- Fix watermark/copyright opacity wrong value [#910](https://github.com/Codeinwp/visualizer/issues/910) - - - - -##### [Version 3.7.9](https://github.com/Codeinwp/visualizer/compare/v3.7.8...v3.7.9) (2022-06-22) - -* Fix branding bad HTML - - - - -##### [Version 3.7.8](https://github.com/Codeinwp/visualizer/compare/v3.7.7...v3.7.8) (2022-06-21) - -- Add the description field in the pie chart -- Fix Label other is not visible on the pie chart -- Fix the pagination issue in the chart library -- Update dependencies - - - - -##### [Version 3.7.7](https://github.com/Codeinwp/visualizer/compare/v3.7.6...v3.7.7) (2022-05-31) - -* Harden security by enforcing escaped urls. - - - - -##### [Version 3.7.6](https://github.com/Codeinwp/visualizer/compare/v3.7.5...v3.7.6) (2022-05-26) - -- Fix PHP 8.0 or above compatibility issue -- Fix the space missing between the id and class attributes of the charts -- Fix compatibility issue with Ultimate Coupons for WooCommerce Free plugin -- Fix charts placed in accordion (tabs) that do not load properly -- Fix conflict with translation plugins - - - - -##### [Version 3.7.5](https://github.com/Codeinwp/visualizer/compare/v3.7.4...v3.7.5) (2022-03-17) - -- Fix display long hAxis text issue -- Improve features discoverability. - - - - -##### [Version 3.7.4](https://github.com/Codeinwp/visualizer/compare/v3.7.3...v3.7.4) (2022-02-07) - -- Fix multiple lazy load charts issue -- Tested up to WordPress 5.9 version - - - - -##### [Version 3.7.3](https://github.com/Codeinwp/visualizer/compare/v3.7.2...v3.7.3) (2022-01-19) - -- Fix multiple charts lazy rendering issue -- Upgrade jQuery UI core library CDN -- Fix console error when rendering multiple charts -- Keep only Line / Pie / bar / Table charts in free version - - - - -##### [Version 3.7.2](https://github.com/Codeinwp/visualizer/compare/v3.7.1...v3.7.2) (2021-12-20) - -- Add latest chartJS library support in the bar chart -- Fix horizontal axis showing date format in Bar chart -- Fix orientation rotate issue in mobile -- CSS performance improvements -- Improve JS performance - - - - -##### [Version 3.7.1](https://github.com/Codeinwp/visualizer/compare/v3.7.0...v3.7.1) (2021-11-17) - -- [Feat] Allow charts to render in dashboard areas via 3rd party plugins like Dashboard Widgets Suite -- [Fix] Changing Line Chart series style to dot on some series changes the order of tooltip information -- [Fix] Problem with X-Axis of Line chart on Safari if date/datetime is used as values -- [Fix] Multiple Visualizer charts causing rendering issues (blank page) -- [Fix] Charts are not resizing on landscape view on mobile -- [Fix] Notices/Warnings appear on the front-end if the user is not logged in for some charts -- [Fix] Radar/Spider Chart Tooltip in PRO does not show the data value - - - - -#### [Version 3.7.0](https://github.com/Codeinwp/visualizer/compare/v3.6.1...v3.7.0) (2021-10-07) - -#### Features -- Add option to enable/disable save the chart image -- Enhanced caching - -#### Fixes -- Fix undefined variable errors when using Gutenberg blocks -- Fix broken chart in iOS and Safari -- Fix custom refresh time support for DB chart -- Fix table placed inside accordion cannot be scrolled down (refreshes all the time) -- Fix pagination doesn't work in the library -- Fix HTML entities in JSON break Visualizer Table Chart -- Fix string type columns affect the target of the formatting applied to a number column - - - - -##### [Version 3.6.1](https://github.com/Codeinwp/visualizer/compare/v3.6.0...v3.6.1) (2021-08-04) - -- Fix broken dropdown style issue -- Add block support in widget area -- Add wp alpha color picker support -- Fix broken chart layout issue when add long title - - - - -#### [Version 3.6.0](https://github.com/Codeinwp/visualizer/compare/v3.5.1...v3.6.0) (2021-07-08) - -### Features -* Adds option to render chart as image -* Automatically render chart as image on AMP - -### Fixes -* [Chart Type: TABLE] When adding a blank header, the last column of the table disappears -* Styling is not applied to Chart Area for Table Chart ( Margin, Width, Height) -* Improve compatibility with Site Kit by Google -* Loading icon doesn't disappear until you edit the chart again [Line Chart] -* Broken chart if used inside an accordion (expand/collapse) - - - - -##### [Version 3.5.1](https://github.com/Codeinwp/visualizer/compare/v3.5.0...v3.5.1) (2021-05-25) - - -- Fix compatibility with 3rd party plugins which use the same js library as Visualizer [#801](https://github.com/Codeinwp/visualizer/issues/801) -- Fix fatal error when a new chart is created on PHP 8 [#795](https://github.com/Codeinwp/visualizer/issues/795) -- Chart doesn't get refreshed automatically when the JSON endpoint is used as the data source - -#### Features: -- Adds option to add schema.org Dataset for license and creator [#794](https://github.com/Codeinwp/visualizer/issues/794) - - - - -#### [Version 3.5.0](https://github.com/Codeinwp/visualizer/compare/v3.4.11...v3.5.0) (2021-04-28) - -#### Fixes -- Permissions tab disappears after a user modifies a chart's data -- Conflict with Edit Flow plugin -- Improve compatibility with Ninja Forms -- Fix for color for Minor Grid Lines not working - -#### Features -- Add support for key:value header to access JSON endpoints -- Option to download CSV data of the chart without the row with series type - - - -= 3.4.11 - 2021-02-16 = - -[Feat] Tested up to WP 5.6 - - -= 3.4.10 - 2020-12-09 = - -* [Fix] Compatibility with Composer 2.0 - - -= 3.4.9 - 2020-11-26 = - -* [Fix] Pie chart slices offset problem in the Gutenberg editor -* [Fix] Warning is thrown when remote file is used as source -* [Fix] Bubble chart legend position not working in the Gutenberg editor - - -= 3.4.8 - 2020-09-29 = - -* [Fix] Option to edit charts imported from JSON - - -= 3.4.7 - 2020-09-21 = - -* - [Fix] Import from JSON: Reordering columns only reorders the label, not the associated data -* - [Fix] numberFormat option doesn't apply in Bar charts when using annotations -* - [Fix] Google GEO chart is not rendering if 3rd data column is added - - -= 3.4.6 - 2020-08-11 = - -* - [Feat] Show chart ID in the chart library -* - [Fix] Compatibility with WP 5.5 -* - [Fix] Google charts: Series number format not applying in the Gutenberg editor -* - [Fix] Google Table chart does not display chart if boolean values are specified -* - [Fix] Duplicated enque for jsapi loader - - -= 3.4.5 - 2020-07-08 = - -* [Feat] New Google Table Charts -* [Feat] Option for lazy loading Google Charts -* [Feat] Option to easily copy chart shortcode code -* [Fix] Remove Inside the Chart option for the legend position for Google Pie charts - - -= 3.4.4 - 2020-06-16 = - -* [Feat] Option to download charts as .png images -* [Fix] Make UI more intuitive when a chart is missing in the editor -* [Fix] Clicking Copy chart shows the Copied message multiple times -* [Fix] Conflict with Modern Events Calendar plugin -* [Fix] Chart size ( width and height ) options not working for ChartJS charts -* [Fix] Resizing the window causes annotation-based charts to throw an error -* [Fix] Remove Inside the Chart option as legend position for Google Pie charts -* [Fix] Clicking Advanced Options panel breaks Bubble chart -* [Fix] Missing posts revisions on chart update - - -= 3.4.3 - 2020-05-04 = - -* [Feat] Improved chart creation UX -* [Feat] New option to control the number of charts per page in the Charts Library -* [Feat] New option for filtering/ordering charts in the Charts Library -* [Feat] Support for custom codes for the boolean data type in Table charts -* [Fix] Support for displaying apostrophes in charts -* [Fix] Visualizer button layout in the Classic block -* [Fix] Bulk activation of plugin aborts activation of subsequent plugins - - -= 3.4.2 - 2020-02-17 = - -* New Cypress tests for the Gutenberg block - - -= 3.4.1 - 2020-02-14 = - -* [Fix] Insert chart button in the classic block -* [Fix for Pro version] Import from chart did not work - - -= 3.4.0 - 2020-02-13 = - -* [Feat] Support for authentication for JSON import -* [Feat] New chart type: Bubble -* [Feat] Combine one-time import and schedule import into a single control for an online .csv file import -* [Feat] Add support for annotations and other roles -* [Feat] For every chart show the last updated date and any error that exists -* [Feat] Tested up to WP 5.3 -* [Fix] When new data is imported using csv/url, the manual editor still show old data -* [Fix] Having SCRIPT_DEBUG on causes issues in real time update of charts -* [Fix] Table chart: Error appears when trying to import from JSON -* [Fix] PHP Fatal error: Uncaught Error: Cannot unset string offsets -* [Fix] Long responsive table can overflow on smaller screens - - -= 3.3.4 - 2019-11-15 = - -* Fix issue with table chart not loading in the block editor - - -= 3.3.3 - 2019-11-12 = - -* Tested upto WordPress 5.3 - - -= 3.3.2 - 2019-10-03 = - -* Add support for Dataset schema -* Horizontal Axis formatting should apply to tooltips - - -### 3.3.1 - 2019-09-28 ### - -* Increase minimum requirement to PHP 5.6 -* Fixed issue with loading customization.js on multisites -* Fixed issue with manually editing a remotely loaded chart -* Fixed issues with cloning -* Fixed issues with ChartJS assigning default colors -* Fix security issues in block editor - - -### 3.3.0 - 2019-08-14 ### - -* Add support for ChartJS -* Add alpha color picker for supported charts -* Fix issue with some options of DataTable -* Include DataTable charts in block editor -* Fix issue with import from JSON not working with some sources -* Add menu and onboarding page -* Fix issue with frontend action checkboxes -* Improve UX in advanced settings - - -### 3.2.1 - 2019-05-05 ### - -* Fix issue with async loading of scripts - - -### 3.2.0 - 2019-05-03 ### - -* Add support for charts in AMP requests -* Add support to show charts from JSON/REST endpoints -* Fix loading of Google Visualization javascript files -* Add simple editors for editing chart data -* Tested up to WP 5.2 - - -### 3.1.3 - 2019-02-24 ### - -* Fix issue with changing column settings of the last column in table chart -* Add support for query language to get subset of data from Google Spreadsheet -* Fix conflict with jquery 3.3.x -* Migrated PHPExcel to PhpSpreadsheet -* Front end action 'print' should print the chart and fall back to printing the data -* Fix issue with table chart not showing in IE -* Fix issue with multiple instances of same chart not showing -* Fix issue with date type column does not work with Combo charts -* Tested with WP 5.1 - - -### 3.1.2 - 2018-12-06 ### - -* Fix bug "Warning: A non-numeric value encountered" -* Tested with WP 5.0 - - -### 3.1.1 - 2018-12-05 ### - -* Fix issue with Gutenberg support -* Fix issue with loading new Table chart -* Fix options that don't work correctly with some charts - - -### 3.1.0 - 2018-12-03 ### - -* Add Table chart -* Fix date format in sample files - - -### 3.0.12 - 2018-10-11 ### - -* Added filter to enable users to change schedule of charts. -* Fixed bug with line chart with timeofday column. -* Fixed bug with scheduled charts that sometimes did not show updated data. -* Javascript can be customized on a per user basis that will not be wiped out on update. - - -### 3.0.11 - 2018-08-15 ### - -* Fixed issue with the Series Settings options for the Table Chart -* Fixed issue with chart showing "Table has no columns" with remote sources - - -### 3.0.10 - 2018-07-20 ### - -* Fixed problem with chart reverting to the default values -* Fixed problem with Boolean column type -* Fixed problem with the Geo chart type not saving colors options - - -### 3.0.9 - 2018-07-12 ### - -* New chart title option for the back-end of the charts that don't allow a title on the front-end -* Store the png images of the charts in a global array that can be used in JS -* Added options for charts animations - - -### 3.0.8 - 2018-06-27 ### - -* Added revision support for the chart post type -* Added both % and Value to the Pie Slice -* Use the blog locale for Visualizer's options -* Fixed issue with data being fetched from the remote source every single time the chart was shown -* Fixed issue with scheduled charts not being updated if one of the scheduled charts is deleted - - -### 3.0.7 - 2018-03-26 ### - -* Adds insert button in chart library. -* Remove frontend assets where they are not needed. -* Improve non-English charts compatibility. -* Adds a filter to change charts locale. - - -### 3.0.6 - 2018-02-27 ### - -* Fix UTF-8 support while saving the data. -* Improve editing experience. -* Improves compatibility with Premium version. -* Adds chart button into TinyMCE editor. - - -### 3.0.5 - 2018-01-05 ### - -* Fix chart rendering bug in firefox. -* Fix review notification. - - -### 3.0.4 - 2017-11-27 ### - -* Fix for review message notification. - - -### 3.0.3 - 2017-11-16 ### - -* Adds compatibility with WordPress 4.9. - - -### 3.0.2 - 2017-10-10 ### - -* Fix dependency for composer dependencies. - - -### 3.0.1 - 2017-10-06 ### - -* Improved compatibility with various theme and plugins. -* Fix for chart type selection when creation from media popup. - - -### 3.0.0 - 2017-09-05 ### - -* Adds support manual configuration according to Google Visualization API. -* Improves compatibility with more features from the pro version. - - -### 2.2.0 - 2017-08-16 ### - -* Added custom number format for pie chart. -* Added frontend actions buttons ( Print, Export to CSV, Export to Excel, Copy) - - -### 2.1.9 - 2017-07-10 ### - -* Fixed display error with hex color. - - -### 2.1.8 - 2017-07-03 ### - -* Added chart title into library. -* Fixed SDK issues with dashboard widget. - - -### 2.1.7 ### - -* Updated sdk loading logic. - - - -### 2.1.4 ### -* Fixed issues with non-latin chars on CSV files to import. - -### 2.1.2 ### -* Fixed priority issue with wp_enqueue_media -* Added latest version of sdk - -### 2.1.1 ### -* Fixed charts bliking on some themes. - -### 2.1.0 ### -* Fixed geomap issue with apikey. -* Fixed responsive issues on tabbed interface and page builders. -* Added compatibility with premium import from posts/page feature. - -### 2.0.4 ### -* Fixed resize issue in the library page. - -### 2.0.0 ### -* Improved design and layout to support multiple datasources. -* Added new integrations in the Pro version. -* Added opt-in for tracking. - -### 1.7.6 ### -* Fixed issue when using the same shortcode multiple times on the same page. - -### 1.7.5 ### -* Removed footer banner upsell -* Fixed series settings issue -* Fixed issue with comas being used in numbers - -### 1.7.2 ### -* Improved charts responsive system - -### 1.7.1 ### -* Fixed grid lines error links - -### 1.7.0 ### -* Fixed responsive issues -* Fixed issues with zero margin values -* Fixed import issue - -### 1.6.6 ### -* Fixed charts resizing on tabbed content - -### 1.6.5 ### -* Fixed responsive issue -* Fixed no axis text color for line and bar charts - - -### 1.6.0 ### -* Fixed security issue when importing charts -* Removed pointer for the pro version -* Fixed charts import from media library -* Added support to show legend on the left side - - -### 1.5.6 ### -* Added support for 3 more chart types -* Fixed issue with charts not saving - - -### 1.5.5 ### -* Added export for charts -* Enable default value for focus target. Fixed issue with hover which was not working on some machines. - - -### 1.5.4 ### -* Added free search text over graphs - -### 1.5.2 ### -* Added step2 and 3 into step 1 - -### 1.5.1 ### -* Fixed bug with from web button - -### 1.5 ### -* Added support for live editor -* Added support for importing data from other charts -* Added filter for chart settings -* Fixed bug when zero was not working on the charts - -### 1.4.2.3 ### -* Implemented ability to edit horizontal and vertical axis number format for bar and column charts - -### 1.4.2.2 ### -* Added ability to pass a class for chart wrapper div -* Added proper label for custom post type - -### 1.4.2.1 ### -* Fixed issue with download_url function which not exists at front end -* Added functionality which prevents direct access to the plugin folder - -### 1.4.2 ### -* Fixed remote CSV uploading issue when allow_url_fopen option is disabled in php.ini -* Replaced flattr image on widget and added donate link to plugin meta data -* Added notification message at library page when allow_url_fopen option is disabled - -### 1.4.1.1 ### -* Removed CSV parser escape constant to prevent warnings which appears when PHP 5.2.x or less is used - -### 1.4.1 ### -* Fixed issue which prevents the plugin working on SSL backend -* Fixed issue with CSV file uploading in IE and other browsers -* Fixed issue with empty series, which appears due to leading space in a source file -* Added ability to define custom delimiter, enclosure and escape variables for CSV parsing - -### 1.4 ### -* Implemented aggregation target and selection mode options for candlestick chart -* Implemented focus target and data opacity for columnar chars -* Implemented data opacity and interpolate nulls settings for line chart -* Implemented ability to edit tooltip settings -* Implemented new settings for linear charts like selection mode and aggregation target -* Implemented area and point opacity settings for area chart -* Implemented new settings for pie chart like pie hole, start angle and slice offset -* Implemented ability to select a color for chart title and legend items -* Fixed number formatting settings for linear charts, from now it works only for axis labels -* Reworked general settings section by moving title and font settings into separate groups - -### 1.3.0.2 ### -* Replaced links to github wiki - -### 1.3.0.1 ### -* Added Flattr button - -### 1.3.0 ### -* Implemented ability to set number and date formatters -* Implemented ability to select transparent background for a chart -* Fixed JS bugs which appear when post type editor support is disabled -* Fixed issue with NULL values for numeric series -* Fixed invalid charts rendering at "Add Media" library -* Fixed compatibility issue with another Google API related plugins -* Added "rate the plugin" box - -### 1.2.0 ### -* Implemented minor grid lines settings. -* Implemented view window settings. -* Horizontal and vertical axes settings were split into separate groups. - -### 1.1.4 ### -* Bug with float values has been fixed. - -### 1.1.3 ### -* Issue with "fseek warning" for Google Spreadsheet document source, was fixed. - -### 1.1.2 ### -* Compatibility issues with WordPress version 3.6 has been fixed. - -### 1.1.1 ### -* Active type tab in the charts library was fixed. -* Library styles were updated. - -### 1.1.0 ### -* Auto population was added for remote CSV file source. -* Ability to hook chart series and data was implemented. -* Ability to upload CSV files from web was implemented. - -### 1.0.1 ### -* The bug with CSV file uploading was fixed. - -### 1.0.0 ### -* The first version of what wil be the best wp charts plugin. +For the full changelog, please see [CHANGELOG.md](https://github.com/Codeinwp/visualizer/blob/development/CHANGELOG.md). From 54b9615d54b51e94f440cb26325c9f0ae066d436 Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 18 Sep 2026 12:32:22 +0300 Subject: [PATCH 04/15] feat: register abilities with the Abilities API Co-Authored-By: Claude Fable 5.1 --- classes/Visualizer/Module/Abilities.php | 1684 +++++++++++++++++++++++ index.php | 3 + 2 files changed, 1687 insertions(+) create mode 100644 classes/Visualizer/Module/Abilities.php diff --git a/classes/Visualizer/Module/Abilities.php b/classes/Visualizer/Module/Abilities.php new file mode 100644 index 00000000..dff875bb --- /dev/null +++ b/classes/Visualizer/Module/Abilities.php @@ -0,0 +1,1684 @@ +_addAction( 'wp_abilities_api_categories_init', 'registerCategory' ); + $this->_addAction( 'wp_abilities_api_init', 'registerAbilities' ); + } + + /** + * Registers the ability category. + * + * @access public + * @return void + */ + public function registerCategory() { + if ( ! function_exists( 'wp_register_ability_category' ) ) { + return; + } + + wp_register_ability_category( + self::CATEGORY, + array( + 'label' => __( 'Visualizer', 'visualizer' ), + 'description' => __( 'Create, read and refresh Visualizer charts and tables.', 'visualizer' ), + ) + ); + } + + /** + * Registers the abilities. + * + * @access public + * @return void + */ + public function registerAbilities() { + if ( ! function_exists( 'wp_register_ability' ) ) { + return; + } + + $column_schema = array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => __( 'Column label.', 'visualizer' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Column data type.', 'visualizer' ), + 'enum' => Visualizer_Source::getAllowedTypes(), + ), + ), + 'required' => array( 'name', 'type' ), + ); + + $embed_schema = array( + 'type' => 'object', + 'properties' => array( + 'shortcode' => array( 'type' => 'string' ), + 'block' => array( 'type' => 'string' ), + ), + ); + + $rows_schema = array( + 'type' => 'array', + 'description' => __( 'Data rows. Each row is an array of cell values in column order.', 'visualizer' ), + 'items' => array( + 'type' => 'array', + ), + ); + + $preview_schema = array( + 'type' => 'object', + 'properties' => array( + 'columns' => array( + 'type' => 'array', + 'items' => $column_schema, + ), + 'rows' => $rows_schema, + 'row_count' => array( 'type' => 'integer' ), + ), + ); + + wp_register_ability( + 'visualizer/list-charts', + array( + 'label' => __( 'List charts', 'visualizer' ), + 'description' => __( 'Lists the Visualizer charts the current user can manage, with their type, library, data source and shortcode.', 'visualizer' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'default' => array(), + 'properties' => array( + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Page of results.', 'visualizer' ), + 'default' => 1, + 'minimum' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => __( 'Charts per page.', 'visualizer' ), + 'default' => 20, + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Only return charts of this type (for example pie, line, bar, tabular).', 'visualizer' ), + ), + 'search' => array( + 'type' => 'string', + 'description' => __( 'Search term matched against the chart settings (title), like the chart library search.', 'visualizer' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'charts' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'title' => array( 'type' => 'string' ), + 'type' => array( 'type' => 'string' ), + 'library' => array( 'type' => 'string' ), + 'source' => array( 'type' => 'string' ), + 'modified' => array( 'type' => 'string' ), + 'shortcode' => array( 'type' => 'string' ), + ), + ), + ), + 'total' => array( 'type' => 'integer' ), + 'total_pages' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => array( $this, 'executeListCharts' ), + 'permission_callback' => array( $this, 'canManageCharts' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + wp_register_ability( + 'visualizer/get-chart', + array( + 'label' => __( 'Get chart', 'visualizer' ), + 'description' => __( 'Returns the type, columns, rows, options, data source and embed markup (shortcode and block) of a chart. Source credentials are never returned.', 'visualizer' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'chart_id' => array( + 'type' => 'integer', + 'description' => __( 'The chart ID.', 'visualizer' ), + 'minimum' => 1, + ), + ), + 'required' => array( 'chart_id' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'title' => array( 'type' => 'string' ), + 'type' => array( 'type' => 'string' ), + 'library' => array( 'type' => 'string' ), + 'columns' => array( + 'type' => 'array', + 'items' => $column_schema, + ), + 'rows' => $rows_schema, + 'options' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + 'source' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + 'embed' => $embed_schema, + ), + ), + 'execute_callback' => array( $this, 'executeGetChart' ), + 'permission_callback' => array( $this, 'canEditChartInput' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + wp_register_ability( + 'visualizer/upsert-chart', + array( + 'label' => __( 'Create or update chart', 'visualizer' ), + 'description' => __( 'Creates a chart (no chart_id) or updates one (chart_id given) from typed columns, rows and options. With dry_run=true nothing is saved and the parsed data preview is returned. Chart types that need a higher plan are rejected.', 'visualizer' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'chart_id' => array( + 'type' => 'integer', + 'description' => __( 'The chart to update. Omit to create a new chart.', 'visualizer' ), + 'minimum' => 1, + ), + 'title' => array( + 'type' => 'string', + 'description' => __( 'Chart name shown in the chart library.', 'visualizer' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Chart type, for example pie, line, bar, column, area, tabular. Required when creating.', 'visualizer' ), + ), + 'library' => array( + 'type' => 'string', + 'description' => __( 'Rendering library. Defaults to the first library the chart type supports.', 'visualizer' ), + 'enum' => array( 'GoogleCharts', 'ChartJS', 'DataTable' ), + ), + 'columns' => array( + 'type' => 'array', + 'description' => __( 'Typed columns. Required when creating; when updating, send together with rows to replace the chart data.', 'visualizer' ), + 'items' => $column_schema, + ), + 'rows' => $rows_schema, + 'options' => array( + 'type' => 'object', + 'description' => __( 'Chart settings merged over the existing ones (for example title, legend, colors).', 'visualizer' ), + 'additionalProperties' => true, + ), + 'dry_run' => array( + 'type' => 'boolean', + 'description' => __( 'Validate and return the parsed data preview without saving.', 'visualizer' ), + 'default' => false, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'created' => array( 'type' => 'boolean' ), + 'embed' => $embed_schema, + 'dry_run' => array( 'type' => 'boolean' ), + 'valid' => array( 'type' => 'boolean' ), + 'parsed_preview' => $preview_schema, + 'errors' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ), + 'execute_callback' => array( $this, 'executeUpsertChart' ), + 'permission_callback' => array( $this, 'canUpsertChart' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + wp_register_ability( + 'visualizer/set-data-source', + array( + 'label' => __( 'Set chart data source', 'visualizer' ), + 'description' => __( 'Points a chart at a remote CSV/XLSX file, a JSON endpoint or a WordPress database query, imports the data and optionally saves a refresh interval. Requires a plan that includes the chosen source. With dry_run=true nothing is saved and the fetched preview is returned.', 'visualizer' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'chart_id' => array( + 'type' => 'integer', + 'description' => __( 'The chart ID.', 'visualizer' ), + 'minimum' => 1, + ), + 'source' => array( + 'type' => 'string', + 'description' => __( 'Source kind.', 'visualizer' ), + 'enum' => array( 'csv_url', 'json', 'db_query' ), + ), + 'url' => array( + 'type' => 'string', + 'description' => __( 'URL of the CSV/XLSX file (csv_url) or of the JSON endpoint (json). The CSV must have the labels in row 1 and the data types in row 2.', 'visualizer' ), + ), + 'json_root' => array( + 'type' => 'string', + 'description' => __( 'Root node that holds the rows, in the format the chart editor uses: "root" for the top level, "root>data>results" for nested nodes. Run with dry_run=true and no mapping to list the available roots.', 'visualizer' ), + ), + 'json_method' => array( + 'type' => 'string', + 'enum' => array( 'GET', 'POST' ), + 'default' => 'GET', + ), + 'json_paging' => array( + 'type' => 'string', + 'description' => __( 'Node that holds the next page URL, same format as json_root.', 'visualizer' ), + ), + 'mapping' => array( + 'type' => 'array', + 'description' => __( 'JSON sources only: the JSON keys to use as columns, in order, with their data type. Run with dry_run=true first to see the available keys.', 'visualizer' ), + 'items' => $column_schema, + ), + 'query' => array( + 'type' => 'string', + 'description' => __( 'db_query sources only: a single SELECT statement run against the WordPress database.', 'visualizer' ), + ), + 'refresh_interval' => array( + 'type' => 'number', + 'description' => __( 'Hours between automatic refreshes. -1 imports once. Only the intervals offered by the chart editor for the active plan are accepted.', 'visualizer' ), + 'default' => -1, + ), + 'dry_run' => array( + 'type' => 'boolean', + 'default' => false, + ), + ), + 'required' => array( 'chart_id', 'source' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'source' => array( 'type' => 'string' ), + 'refresh_interval' => array( 'type' => 'number' ), + 'dry_run' => array( 'type' => 'boolean' ), + 'available_roots' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'available_keys' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'parsed_preview' => $preview_schema, + 'embed' => $embed_schema, + ), + ), + 'execute_callback' => array( $this, 'executeSetDataSource' ), + 'permission_callback' => array( $this, 'canSetDataSource' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + wp_register_ability( + 'visualizer/refresh-chart-data', + array( + 'label' => __( 'Refresh chart data', 'visualizer' ), + 'description' => __( 'Fetches the current data for a chart from its saved remote CSV/XLSX, JSON or database source and stores it.', 'visualizer' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'chart_id' => array( + 'type' => 'integer', + 'description' => __( 'The chart ID.', 'visualizer' ), + 'minimum' => 1, + ), + ), + 'required' => array( 'chart_id' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'source' => array( 'type' => 'string' ), + 'refreshed' => array( 'type' => 'boolean' ), + 'row_count' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => array( $this, 'executeRefreshChartData' ), + 'permission_callback' => array( $this, 'canEditChartInput' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + // ------------------------------------------------------------------------- + // Permissions + // ------------------------------------------------------------------------- + + /** + * Same capability as the chart library page and the charts list endpoint. + * + * @access public + * @return bool + */ + public function canManageCharts() { + return current_user_can( 'edit_posts' ); + } + + /** + * Same checks as the chart editor: edit_posts plus Visualizer_Module::can_edit_chart(). + * + * @access public + * + * @param mixed $input The ability input. + * @return bool + */ + public function canEditChartInput( $input = array() ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return false; + } + + $chart_id = $this->getChartId( $input ); + + // A missing or invalid ID is reported by the execute callback. + return ! $chart_id || ! $this->getChart( $chart_id ) || self::can_edit_chart( $chart_id ); + } + + /** + * Creating needs edit_posts; updating additionally needs can_edit_chart(). + * + * @access public + * + * @param mixed $input The ability input. + * @return bool + */ + public function canUpsertChart( $input = array() ) { + return $this->canEditChartInput( $input ); + } + + /** + * Chart editor checks, plus the database import checks for SQL sources. + * + * @access public + * + * @param mixed $input The ability input. + * @return bool + */ + public function canSetDataSource( $input = array() ) { + if ( ! $this->canEditChartInput( $input ) ) { + return false; + } + + if ( is_array( $input ) && isset( $input['source'] ) && 'db_query' === $input['source'] ) { + // Same as Visualizer_Module_Chart::saveQuery(). + return current_user_can( 'administrator' ) && is_super_admin(); + } + + return true; + } + + // ------------------------------------------------------------------------- + // Execute callbacks + // ------------------------------------------------------------------------- + + /** + * Lists charts. + * + * @access public + * + * @param mixed $input The ability input. + * @return array|WP_Error + */ + public function executeListCharts( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $page = isset( $input['page'] ) ? max( 1, absint( $input['page'] ) ) : 1; + $per_page = isset( $input['per_page'] ) ? absint( $input['per_page'] ) : 20; + $per_page = min( self::MAX_PER_PAGE, max( 1, $per_page ) ); + + $query_args = array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $page, + ); + + // Same restriction as Visualizer_Module_Chart::getCharts(). + if ( ! current_user_can( 'edit_others_posts' ) ) { + $query_args['author'] = get_current_user_id(); + } + + $meta_query = array(); + if ( ! empty( $input['type'] ) ) { + $type = sanitize_text_field( $input['type'] ); + if ( ! in_array( $type, Visualizer_Plugin::getChartTypes(), true ) ) { + return new WP_Error( 'visualizer_invalid_type', __( 'Unknown chart type.', 'visualizer' ) ); + } + $meta_query[] = array( + 'key' => Visualizer_Plugin::CF_CHART_TYPE, + 'value' => $type, + 'compare' => '=', + ); + } + if ( ! empty( $input['search'] ) ) { + $meta_query[] = array( + 'key' => Visualizer_Plugin::CF_SETTINGS, + 'value' => sanitize_text_field( $input['search'] ), + 'compare' => 'LIKE', + ); + } + if ( $meta_query ) { + $query_args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + } + + $query = new WP_Query( apply_filters( 'visualizer_query_args', $query_args ) ); + $charts = array(); + foreach ( $query->posts as $chart ) { + if ( ! $chart instanceof WP_Post ) { + continue; + } + $charts[] = array( + 'id' => $chart->ID, + 'title' => $this->getChartTitle( $chart ), + 'type' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true ), + 'library' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ), + 'source' => $this->getSourceKind( $chart->ID ), + 'modified' => (string) $chart->post_modified_gmt, + 'shortcode' => $this->getShortcode( $chart->ID ), + ); + } + + return array( + 'charts' => $charts, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ); + } + + /** + * Returns one chart. + * + * @access public + * + * @param mixed $input The ability input. + * @return array|WP_Error + */ + public function executeGetChart( $input = array() ) { + $chart = $this->getEditableChart( $input ); + if ( is_wp_error( $chart ) ) { + return $chart; + } + + $type = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true ); + $series = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SERIES, get_post_meta( $chart->ID, Visualizer_Plugin::CF_SERIES, true ), $chart->ID, $type ); + $data = self::get_chart_data( $chart, $type ); + $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true ); + $settings = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SETTINGS, $settings, $chart->ID, $type ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } + // The chart preview image is a large data URI, not an option. + unset( $settings['chart-img'] ); + + return array( + 'id' => $chart->ID, + 'title' => $this->getChartTitle( $chart ), + 'type' => $type, + 'library' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ), + 'columns' => $this->seriesToColumns( $series ), + 'rows' => $this->normalizeRows( $data ), + 'options' => $settings, + 'source' => $this->describeSource( $chart->ID ), + 'embed' => $this->getEmbed( $chart->ID ), + ); + } + + /** + * Creates or updates a chart from typed columns and rows. + * + * @access public + * + * @param mixed $input The ability input. + * @return array|WP_Error + */ + public function executeUpsertChart( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $dry_run = ! empty( $input['dry_run'] ); + $chart = null; + + if ( ! empty( $input['chart_id'] ) ) { + $chart = $this->getEditableChart( $input ); + if ( is_wp_error( $chart ) ) { + return $chart; + } + } + + $has_data = isset( $input['columns'] ) || isset( $input['rows'] ); + if ( ! $chart && ! $has_data ) { + return new WP_Error( 'visualizer_missing_data', __( 'columns and rows are required to create a chart.', 'visualizer' ) ); + } + + // Chart type and library, gated like the chart type page. + $type = $chart ? (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true ) : ''; + if ( isset( $input['type'] ) && '' !== $input['type'] ) { + $type = sanitize_text_field( $input['type'] ); + } + if ( '' === $type ) { + return new WP_Error( 'visualizer_missing_type', __( 'type is required to create a chart.', 'visualizer' ) ); + } + + $type_changed = ! $chart || isset( $input['type'] ) || isset( $input['library'] ); + $library = $chart ? (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ) : ''; + if ( $type_changed ) { + $library = $this->resolveLibrary( $type, isset( $input['library'] ) ? (string) $input['library'] : ( isset( $input['type'] ) ? '' : $library ) ); + if ( is_wp_error( $library ) ) { + return $library; + } + } + + $source = null; + if ( $has_data ) { + $source = $this->buildManualSource( $input ); + if ( is_wp_error( $source ) ) { + if ( $dry_run ) { + return array( + 'dry_run' => true, + 'valid' => false, + 'errors' => $source->get_error_messages(), + ); + } + return $source; + } + } + + $options = array(); + if ( isset( $input['options'] ) ) { + if ( ! is_array( $input['options'] ) ) { + return new WP_Error( 'visualizer_invalid_options', __( 'options must be an object.', 'visualizer' ) ); + } + // Same sanitization as the settings form (Visualizer_Module_Chart::sanitizeSettings()). + $options = map_deep( $input['options'], 'sanitize_textarea_field' ); + unset( $options['chart-img'] ); + } + + if ( $dry_run ) { + $result = array( + 'dry_run' => true, + 'valid' => true, + 'errors' => array(), + ); + if ( $source ) { + $result['parsed_preview'] = $this->getPreview( $source->getSeries(), $source->getRawData() ); + } + return $result; + } + + $created = false; + $this->disableRevisionsTemporarily(); + + if ( ! $chart ) { + // Same defaults as the chart creation flow in Visualizer_Module_Chart::renderChartPages(). + $chart_id = wp_insert_post( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_title' => 'Visualization', + 'post_author' => get_current_user_id(), + 'post_status' => 'auto-draft', + 'post_content' => $source->getData(), + ), + true + ); + if ( is_wp_error( $chart_id ) ) { + return $chart_id; + } + + add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, $type ); + add_post_meta( $chart_id, Visualizer_Plugin::CF_DEFAULT_DATA, 0 ); + add_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, $source->getSourceName() ); + add_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, $source->getSeries() ); + add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, $library ); + add_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, array( 'focusTarget' => 'datum' ) ); + + do_action( 'visualizer_pro_new_chart_defaults', $chart_id ); + + $chart = get_post( $chart_id ); + if ( ! $chart instanceof WP_Post ) { + return new WP_Error( 'visualizer_create_failed', __( 'The chart could not be created.', 'visualizer' ) ); + } + Visualizer_Module_Utility::set_defaults( $chart ); + $created = true; + } else { + if ( $type_changed ) { + update_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, $type ); + update_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, $library ); + } + if ( $source ) { + $this->clearSourceMeta( $chart->ID, true ); + $this->persistSource( $chart->ID, $source ); + } + } + + // Settings, stored the way the settings form stores them. + $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true ); + if ( ! is_array( $settings ) ) { + $settings = array(); + } + $settings = array_merge( $settings, $options ); + + $post_update = array( 'ID' => $chart->ID ); + if ( isset( $input['title'] ) && '' !== trim( (string) $input['title'] ) ) { + $title = sanitize_text_field( $input['title'] ); + $settings['backend-title'] = $title; + $post_update['post_title'] = $title; + } + + $internal_title = ''; + if ( ! empty( $settings['title'] ) ) { + $internal_title = is_array( $settings['title'] ) ? ( isset( $settings['title']['text'] ) ? $settings['title']['text'] : '' ) : $settings['title']; + } + $settings['internal_title'] = '' !== (string) $internal_title ? $internal_title : $chart->ID; + if ( empty( $settings['pieResidueSliceLabel'] ) ) { + $settings['pieResidueSliceLabel'] = esc_html__( 'Other', 'visualizer' ); + } + update_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, $settings ); + + if ( $created ) { + $post_update['post_status'] = 'publish'; + } + if ( count( $post_update ) > 1 ) { + wp_update_post( $post_update ); + } + + $this->clearChartCache( $chart->ID ); + + return array( + 'id' => $chart->ID, + 'created' => $created, + 'embed' => $this->getEmbed( $chart->ID ), + ); + } + + /** + * Configures a remote CSV/XLSX, JSON or database source for a chart. + * + * @access public + * + * @param mixed $input The ability input. + * @return array|WP_Error + */ + public function executeSetDataSource( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $chart = $this->getEditableChart( $input ); + if ( is_wp_error( $chart ) ) { + return $chart; + } + + $kind = isset( $input['source'] ) ? sanitize_key( $input['source'] ) : ''; + $dry_run = ! empty( $input['dry_run'] ); + + $features = array( + 'csv_url' => 'import-url', + 'json' => 'import-url', + 'db_query' => 'db-query', + ); + if ( ! isset( $features[ $kind ] ) ) { + return new WP_Error( 'visualizer_invalid_source', __( 'source must be one of csv_url, json or db_query.', 'visualizer' ) ); + } + if ( ! $this->isFeatureAvailable( $features[ $kind ] ) ) { + return new WP_Error( 'visualizer_feature_unavailable', __( 'This data source is not available on the current Visualizer plan.', 'visualizer' ) ); + } + + $schedule_types = array( + 'csv_url' => 'csv', + 'json' => 'json', + 'db_query' => 'db', + ); + $hours = $this->resolveInterval( $input, $schedule_types[ $kind ], $chart->ID ); + if ( is_wp_error( $hours ) ) { + return $hours; + } + + switch ( $kind ) { + case 'csv_url': + return $this->setCsvUrlSource( $chart, $input, $hours, $dry_run ); + case 'json': + return $this->setJsonSource( $chart, $input, $hours, $dry_run ); + default: + return $this->setQuerySource( $chart, $input, $hours, $dry_run ); + } + } + + /** + * Re-fetches the data of a chart from its saved source. + * + * @access public + * + * @param mixed $input The ability input. + * @return array|WP_Error + */ + public function executeRefreshChartData( $input = array() ) { + $chart = $this->getEditableChart( $input ); + if ( is_wp_error( $chart ) ) { + return $chart; + } + + $source_class = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_SOURCE, true ); + + switch ( $source_class ) { + case 'Visualizer_Source_Query': + case 'Visualizer_Source_Json': + // Handled by Visualizer_Module_Setup::refresh_db_for_chart(). + delete_post_meta( $chart->ID, Visualizer_Plugin::CF_ERROR ); + apply_filters( 'visualizer_schedule_refresh_chart', $chart, $chart->ID, true ); + $error = get_post_meta( $chart->ID, Visualizer_Plugin::CF_ERROR, true ); + if ( ! empty( $error ) ) { + return new WP_Error( 'visualizer_refresh_failed', sanitize_text_field( $error ) ); + } + break; + case 'Visualizer_Source_Csv_Remote': + case 'Visualizer_Source_Xlsx_Remote': + $url = $this->getRemoteFileUrl( $chart ); + if ( '' === $url ) { + return new WP_Error( 'visualizer_no_source', __( 'The chart has no saved source URL.', 'visualizer' ) ); + } + $source = $this->fetchSource( new $source_class( $url ) ); + if ( is_wp_error( $source ) ) { + return $source; + } + $this->disableRevisionsTemporarily(); + $this->persistSource( $chart->ID, $source ); + break; + default: + return new WP_Error( 'visualizer_not_refreshable', __( 'The chart data was entered manually or uploaded as a file, so there is no source to refresh from.', 'visualizer' ) ); + } + + $this->clearChartCache( $chart->ID ); + + $chart = get_post( $chart->ID ); + $rows = $chart instanceof WP_Post ? self::get_chart_data( $chart, '' ) : array(); + + return array( + 'id' => (int) $input['chart_id'], + 'source' => $this->getSourceKind( (int) $input['chart_id'] ), + 'refreshed' => true, + 'row_count' => is_array( $rows ) ? count( $rows ) : 0, + ); + } + + // ------------------------------------------------------------------------- + // Source helpers + // ------------------------------------------------------------------------- + + /** + * Remote CSV/XLSX source, same flow as the "Import from URL" form in Visualizer_Module_Chart::uploadData(). + * + * @param WP_Post $chart The chart. + * @param array $input The ability input. + * @param float $hours The refresh interval. + * @param bool $dry_run Whether to skip saving. + * @return array|WP_Error + */ + private function setCsvUrlSource( $chart, $input, $hours, $dry_run ) { + $url = $this->validateUrl( $input ); + if ( is_wp_error( $url ) ) { + return $url; + } + + $extension = strtolower( (string) pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) ); + $source = 'xlsx' === $extension ? new Visualizer_Source_Xlsx_Remote( $url ) : new Visualizer_Source_Csv_Remote( $url ); + $source = $this->fetchSource( $source ); + if ( is_wp_error( $source ) ) { + return $source; + } + + if ( $dry_run ) { + return array( + 'id' => $chart->ID, + 'source' => 'csv_url', + 'dry_run' => true, + 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ), + ); + } + + $this->disableRevisionsTemporarily(); + $this->clearSourceMeta( $chart->ID, $hours < 0 ); + $this->persistSource( $chart->ID, $source ); + if ( $hours >= 0 ) { + apply_filters( 'visualizer_pro_chart_schedule', $chart->ID, $url, $hours ); + } + $this->clearChartCache( $chart->ID ); + + return array( + 'id' => $chart->ID, + 'source' => 'csv_url', + 'refresh_interval' => $hours, + 'dry_run' => false, + 'embed' => $this->getEmbed( $chart->ID ), + ); + } + + /** + * JSON source, same flow as Visualizer_Module_Chart::setJsonData(). + * + * @param WP_Post $chart The chart. + * @param array $input The ability input. + * @param float $hours The refresh interval. + * @param bool $dry_run Whether to skip saving. + * @return array|WP_Error + */ + private function setJsonSource( $chart, $input, $hours, $dry_run ) { + $url = $this->validateUrl( $input ); + if ( is_wp_error( $url ) ) { + return $url; + } + + $params = array( + 'url' => $url, + 'root' => ( isset( $input['json_root'] ) && is_string( $input['json_root'] ) && '' !== trim( $input['json_root'] ) ) ? sanitize_text_field( $input['json_root'] ) : 'root', + 'method' => ( isset( $input['json_method'] ) && 'POST' === strtoupper( (string) $input['json_method'] ) ) ? 'POST' : 'GET', + ); + if ( ! empty( $input['json_paging'] ) && is_string( $input['json_paging'] ) ) { + $params['paging'] = sanitize_text_field( $input['json_paging'] ); + } + + // Credentials saved from the chart editor are reused for the same host only; they are never accepted or returned here. + $saved_headers = get_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_HEADERS, true ); + $saved_url = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_URL, true ); + $auth = array(); + if ( is_array( $saved_headers ) && ! empty( $saved_headers['auth'] ) && '' !== $saved_url && wp_parse_url( $saved_url, PHP_URL_HOST ) === wp_parse_url( $url, PHP_URL_HOST ) && wp_parse_url( $saved_url, PHP_URL_SCHEME ) === wp_parse_url( $url, PHP_URL_SCHEME ) ) { + $auth = $saved_headers['auth']; + if ( is_array( $auth ) && isset( $auth['username'], $auth['password'] ) ) { + $params['username'] = $auth['username']; + $params['password'] = $auth['password']; + } elseif ( is_string( $auth ) ) { + $params['auth'] = $auth; + } + } + + // First pass: discover the keys available under the chosen root. + $probe = $this->fetchSource( new Visualizer_Source_Json( $params ) ); + if ( is_wp_error( $probe ) ) { + return $probe; + } + $raw = $probe->getRawData(); + $first = $raw ? reset( $raw ) : null; + $keys = is_array( $first ) ? array_map( 'strval', array_keys( $first ) ) : array(); + $mapping = isset( $input['mapping'] ) ? $input['mapping'] : array(); + if ( ! $keys && ( $mapping || ! $dry_run ) ) { + return new WP_Error( 'visualizer_fetch_failed', __( 'Unable to fetch data from the endpoint. Check the URL and the root.', 'visualizer' ) ); + } + + if ( ! $mapping ) { + if ( $dry_run ) { + $roots = ( new Visualizer_Source_Json( $params ) )->fetchRoots(); + return array( + 'id' => $chart->ID, + 'source' => 'json', + 'dry_run' => true, + 'available_roots' => is_array( $roots ) ? array_values( array_map( 'strval', $roots ) ) : array(), + 'available_keys' => $keys, + ); + } + return new WP_Error( 'visualizer_missing_mapping', __( 'mapping is required for JSON sources. Run with dry_run=true to list the available keys.', 'visualizer' ) ); + } + + $columns = $this->validateColumns( $mapping ); + if ( is_wp_error( $columns ) ) { + return $columns; + } + $params['header'] = array(); + $params['type'] = array(); + foreach ( $columns as $column ) { + if ( ! in_array( $column['name'], $keys, true ) ) { + /* translators: %s: JSON key. */ + return new WP_Error( 'visualizer_invalid_mapping', sprintf( __( 'The key "%s" does not exist in the JSON data.', 'visualizer' ), $column['name'] ) ); + } + $params['header'][] = $column['name']; + $params['type'][ $column['name'] ] = $column['type']; + } + + $source = new Visualizer_Source_Json( $params ); + try { + $source->fetchFromEditableTable(); + } catch ( Exception $e ) { + return new WP_Error( 'visualizer_fetch_failed', __( 'The data could not be parsed with the given column types.', 'visualizer' ) ); + } + if ( ! $source->getSeries() ) { + return new WP_Error( 'visualizer_invalid_mapping', __( 'No columns could be mapped.', 'visualizer' ) ); + } + + if ( $dry_run ) { + return array( + 'id' => $chart->ID, + 'source' => 'json', + 'dry_run' => true, + 'available_keys' => $keys, + 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ), + ); + } + + $this->disableRevisionsTemporarily(); + $this->clearSourceMeta( $chart->ID, true ); + update_post_meta( $chart->ID, Visualizer_Plugin::CF_EDITABLE_TABLE, true ); + $this->persistSource( $chart->ID, $source ); + update_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_URL, $params['url'] ); + update_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_ROOT, $params['root'] ); + + $headers = array( 'method' => $params['method'] ); + if ( $auth ) { + $headers['auth'] = $auth; + } + add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_HEADERS, $headers ); + if ( ! empty( $params['paging'] ) ) { + add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_PAGING, $params['paging'] ); + } + + // Same as Visualizer_Module_Chart::setJsonSchedule(). + $schedules = get_option( Visualizer_Plugin::CF_JSON_SCHEDULE, array() ); + $schedules = is_array( $schedules ) ? $schedules : array(); + if ( $hours >= 0 ) { + add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_SCHEDULE, $hours ); + $schedules[ $chart->ID ] = time() + $hours * HOUR_IN_SECONDS; + } else { + unset( $schedules[ $chart->ID ] ); + } + update_option( Visualizer_Plugin::CF_JSON_SCHEDULE, $schedules ); + + $this->clearChartCache( $chart->ID ); + + return array( + 'id' => $chart->ID, + 'source' => 'json', + 'refresh_interval' => $hours, + 'dry_run' => false, + 'embed' => $this->getEmbed( $chart->ID ), + ); + } + + /** + * WordPress database query source, same flow as Visualizer_Module_Chart::saveQuery(). + * + * @param WP_Post $chart The chart. + * @param array $input The ability input. + * @param float $hours The refresh interval. + * @param bool $dry_run Whether to skip saving. + * @return array|WP_Error + */ + private function setQuerySource( $chart, $input, $hours, $dry_run ) { + // Repeated here because the permission callback only sees the raw input. + if ( ! current_user_can( 'administrator' ) || ! is_super_admin() ) { + return new WP_Error( 'visualizer_forbidden', __( 'Action not allowed for this user.', 'visualizer' ) ); + } + + $query = isset( $input['query'] ) && is_string( $input['query'] ) ? trim( trim( $input['query'] ), ';' ) : ''; + if ( '' === $query ) { + return new WP_Error( 'visualizer_missing_query', __( 'query is required for db_query sources.', 'visualizer' ) ); + } + + // No connection parameters: the query always runs against the WordPress database. + $source = new Visualizer_Source_Query( $query, $chart->ID, array( 'db_type' => Visualizer_Plugin::WP_DB_NAME ) ); + $source->fetch( false ); + $error = $source->get_error(); + if ( ! empty( $error ) ) { + return new WP_Error( 'visualizer_query_failed', sanitize_text_field( $error ) ); + } + if ( ! $source->getSeries() ) { + return new WP_Error( 'visualizer_query_failed', __( 'The query returned no rows.', 'visualizer' ) ); + } + + if ( $dry_run ) { + return array( + 'id' => $chart->ID, + 'source' => 'db_query', + 'dry_run' => true, + 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ), + ); + } + + $this->disableRevisionsTemporarily(); + $this->clearSourceMeta( $chart->ID, true ); + update_post_meta( $chart->ID, Visualizer_Plugin::CF_DB_QUERY, $query ); + update_post_meta( $chart->ID, Visualizer_Plugin::CF_DB_SCHEDULE, $hours ); + $this->persistSource( $chart->ID, $source ); + + $schedules = get_option( Visualizer_Plugin::CF_DB_SCHEDULE, array() ); + $schedules = is_array( $schedules ) ? $schedules : array(); + $schedules[ $chart->ID ] = time() + $hours * HOUR_IN_SECONDS; + update_option( Visualizer_Plugin::CF_DB_SCHEDULE, $schedules ); + + $this->clearChartCache( $chart->ID ); + + return array( + 'id' => $chart->ID, + 'source' => 'db_query', + 'refresh_interval' => $hours, + 'dry_run' => false, + 'embed' => $this->getEmbed( $chart->ID ), + ); + } + + /** + * Builds a CSV source from typed columns and rows, the format the manual data editor submits. + * + * @param array $input The ability input. + * @return Visualizer_Source|WP_Error + */ + private function buildManualSource( $input ) { + $columns = $this->validateColumns( isset( $input['columns'] ) ? $input['columns'] : null ); + if ( is_wp_error( $columns ) ) { + return $columns; + } + + $rows = isset( $input['rows'] ) ? $input['rows'] : null; + if ( ! is_array( $rows ) || ! $rows ) { + return new WP_Error( 'visualizer_invalid_rows', __( 'rows must be a non-empty array of arrays.', 'visualizer' ) ); + } + if ( count( $rows ) > self::MAX_ROWS ) { + /* translators: %d: maximum number of rows. */ + return new WP_Error( 'visualizer_too_many_rows', sprintf( __( 'A maximum of %d rows is accepted.', 'visualizer' ), self::MAX_ROWS ) ); + } + + $errors = new WP_Error(); + $lines = array( wp_list_pluck( $columns, 'name' ), wp_list_pluck( $columns, 'type' ) ); + foreach ( array_values( $rows ) as $index => $row ) { + if ( ! is_array( $row ) || count( $row ) > count( $columns ) ) { + /* translators: %d: row number. */ + $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %d is not an array or has more cells than columns.', 'visualizer' ), $index + 1 ) ); + continue; + } + $line = array(); + foreach ( array_values( $row ) as $position => $cell ) { + if ( is_bool( $cell ) ) { + $cell = $cell ? 'true' : 'false'; + } elseif ( null === $cell ) { + $cell = ''; + } elseif ( ! is_scalar( $cell ) ) { + /* translators: %d: row number. */ + $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %d contains a value that is not a string, number or boolean.', 'visualizer' ), $index + 1 ) ); + continue 2; + } + $cell = sanitize_text_field( (string) $cell ); + if ( 'number' === $columns[ $position ]['type'] && '' !== $cell && ! is_numeric( str_replace( ',', '', $cell ) ) ) { + /* translators: 1: row number, 2: column label. */ + $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %1$d: "%2$s" expects a number.', 'visualizer' ), $index + 1, $columns[ $position ]['name'] ) ); + continue 2; + } + $line[] = $cell; + } + $lines[] = array_pad( $line, count( $columns ), '' ); + + if ( count( $errors->get_error_messages() ) >= 20 ) { + break; + } + } + if ( $errors->has_errors() ) { + return $errors; + } + + $tmpfile = wp_tempnam( Visualizer_Plugin::NAME ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen + $handle = $tmpfile ? fopen( $tmpfile, 'w' ) : false; + if ( ! $handle ) { + return new WP_Error( 'visualizer_tmp_file', __( 'A temporary file could not be created.', 'visualizer' ) ); + } + foreach ( $lines as $line ) { + fputcsv( $handle, $line, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE ); + } + fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + + $source = $this->fetchSource( new Visualizer_Source_Csv( $tmpfile ) ); + wp_delete_file( $tmpfile ); + + if ( ! is_wp_error( $source ) && count( $source->getSeries() ) !== count( $columns ) ) { + return new WP_Error( 'visualizer_invalid_columns', __( 'The columns could not be parsed. Check that every column has a label and a type.', 'visualizer' ) ); + } + + return $source; + } + + /** + * Runs fetch() on a source and converts failures to WP_Error. + * + * @param Visualizer_Source $source The source. + * @return Visualizer_Source|WP_Error + */ + private function fetchSource( $source ) { + try { + $fetched = $source->fetch(); + } catch ( Exception $e ) { + $fetched = false; + } + + $error = $source->get_error(); + if ( ! $fetched || ! empty( $error ) ) { + return new WP_Error( 'visualizer_fetch_failed', ! empty( $error ) ? sanitize_text_field( $error ) : __( 'Could not parse data. Check the format and try again.', 'visualizer' ) ); + } + + return $source; + } + + /** + * Stores the series and data of a fetched source, as the editors do after an import. + * + * @param int $chart_id The chart ID. + * @param Visualizer_Source $source The fetched source. + * @return void + */ + private function persistSource( $chart_id, $source ) { + wp_update_post( + array( + 'ID' => $chart_id, + 'post_content' => $source->getData(), + ) + ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, $source->getSeries() ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, $source->getSourceName() ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_DEFAULT_DATA, 0 ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_ERROR ); + } + + /** + * Removes the parameters of the previous source, as Visualizer_Module_Chart::uploadData() does before an import. + * + * @param int $chart_id The chart ID. + * @param bool $remove_schedule Whether to remove the remote file schedule too. + * @return void + */ + private function clearSourceMeta( $chart_id, $remove_schedule ) { + if ( $remove_schedule ) { + apply_filters( 'visualizer_pro_remove_schedule', $chart_id ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_URL ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_SCHEDULE ); + } + + delete_post_meta( $chart_id, Visualizer_Plugin::CF_FILTER_CONFIG ); + delete_post_meta( $chart_id, '__transient-' . Visualizer_Plugin::CF_FILTER_CONFIG ); + delete_post_meta( $chart_id, '__transient-' . Visualizer_Plugin::CF_DB_QUERY ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_DB_QUERY ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_DB_SCHEDULE ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_REMOTE_DB_PARAMS ); + + delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_ROOT ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_PAGING ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_SCHEDULE ); + + delete_post_meta( $chart_id, Visualizer_Plugin::CF_ERROR ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_EDITOR ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_EDITABLE_TABLE ); + } + + /** + * Clears the cached chart output. + * + * @param int $chart_id The chart ID. + * @return void + */ + private function clearChartCache( $chart_id ) { + delete_transient( Visualizer_Plugin::CF_CHART_CACHE . '_' . $chart_id ); + } + + // ------------------------------------------------------------------------- + // Validation helpers + // ------------------------------------------------------------------------- + + /** + * Extracts the chart ID from the input. + * + * @param mixed $input The ability input. + * @return int + */ + private function getChartId( $input ) { + return ( is_array( $input ) && isset( $input['chart_id'] ) ) ? absint( $input['chart_id'] ) : 0; + } + + /** + * Returns the chart post, or null when the ID is not a chart. + * + * @param int $chart_id The chart ID. + * @return WP_Post|null + */ + private function getChart( $chart_id ) { + $chart = $chart_id ? get_post( $chart_id ) : null; + + return ( $chart instanceof WP_Post && Visualizer_Plugin::CPT_VISUALIZER === $chart->post_type ) ? $chart : null; + } + + /** + * Returns the chart from the input when the current user can edit it. + * + * @param mixed $input The ability input. + * @return WP_Post|WP_Error + */ + private function getEditableChart( $input ) { + $chart = $this->getChart( $this->getChartId( $input ) ); + if ( ! $chart ) { + return new WP_Error( 'visualizer_chart_not_found', __( 'Chart not found.', 'visualizer' ) ); + } + if ( ! self::can_edit_chart( $chart->ID ) ) { + return new WP_Error( 'visualizer_forbidden', __( 'You do not have permission to perform this action.', 'visualizer' ) ); + } + + return $chart; + } + + /** + * Validates a list of {name, type} columns. + * + * @param mixed $columns The columns input. + * @return array|WP_Error + */ + private function validateColumns( $columns ) { + if ( ! is_array( $columns ) || ! $columns ) { + return new WP_Error( 'visualizer_invalid_columns', __( 'columns must be a non-empty array of {name, type} objects.', 'visualizer' ) ); + } + if ( count( $columns ) > self::MAX_COLUMNS ) { + /* translators: %d: maximum number of columns. */ + return new WP_Error( 'visualizer_too_many_columns', sprintf( __( 'A maximum of %d columns is accepted.', 'visualizer' ), self::MAX_COLUMNS ) ); + } + + $valid = array(); + $names = array(); + foreach ( $columns as $column ) { + $name = ( is_array( $column ) && isset( $column['name'] ) && is_scalar( $column['name'] ) ) ? sanitize_text_field( wp_strip_all_tags( (string) $column['name'] ) ) : ''; + $type = ( is_array( $column ) && isset( $column['type'] ) && is_string( $column['type'] ) ) ? $column['type'] : ''; + if ( '' === $name || '0' === $name ) { + return new WP_Error( 'visualizer_invalid_columns', __( 'Every column needs a non-empty name.', 'visualizer' ) ); + } + if ( in_array( $name, $names, true ) ) { + /* translators: %s: column label. */ + return new WP_Error( 'visualizer_invalid_columns', sprintf( __( 'Duplicate column name "%s".', 'visualizer' ), $name ) ); + } + if ( ! in_array( $type, Visualizer_Source::getAllowedTypes(), true ) ) { + /* translators: 1: column label, 2: list of types. */ + return new WP_Error( 'visualizer_invalid_columns', sprintf( __( 'Column "%1$s" has an invalid type. Allowed types: %2$s.', 'visualizer' ), $name, implode( ', ', Visualizer_Source::getAllowedTypes() ) ) ); + } + $names[] = $name; + $valid[] = array( + 'name' => $name, + 'type' => $type, + ); + } + + return $valid; + } + + /** + * Validates the chart type against the active plan and resolves the library. + * + * @param string $type The chart type. + * @param string $library The requested library, or empty for the default. + * @return string|WP_Error + */ + private function resolveLibrary( $type, $library ) { + $types = Visualizer_Module_Admin::_getChartTypesLocalized(); + if ( ! isset( $types[ $type ] ) || ! is_array( $types[ $type ] ) ) { + /* translators: %s: list of chart types. */ + return new WP_Error( 'visualizer_invalid_type', sprintf( __( 'Unknown chart type. Available types: %s.', 'visualizer' ), implode( ', ', array_keys( $types ) ) ) ); + } + if ( ! Visualizer_Module_Admin::checkChartStatus( $type ) ) { + return new WP_Error( 'visualizer_type_unavailable', __( 'This chart type is not available on the current Visualizer plan.', 'visualizer' ) ); + } + + $supported = isset( $types[ $type ]['supports'] ) ? (array) $types[ $type ]['supports'] : array( 'Google Charts' ); + $supported = array_values( array_map( array( $this, 'removeSpaces' ), $supported ) ); + if ( '' === $library ) { + return $supported[0]; + } + if ( ! in_array( $library, $supported, true ) ) { + /* translators: %s: list of libraries. */ + return new WP_Error( 'visualizer_invalid_library', sprintf( __( 'This chart type supports these libraries on the current plan: %s.', 'visualizer' ), implode( ', ', $supported ) ) ); + } + + return $library; + } + + /** + * Removes the spaces of a library label ("Google Charts" is stored as "GoogleCharts"). + * + * @access public + * + * @param string $label The library label. + * @return string + */ + public function removeSpaces( $label ) { + return str_replace( ' ', '', (string) $label ); + } + + /** + * Same plan check as the upsell overlays in the chart editor (Visualizer_Module_Sources::addProUpsell()). + * + * @param string $feature The feature key. + * @return bool + */ + private function isFeatureAvailable( $feature ) { + if ( in_array( $feature, (array) self::get_features_for_license( 2 ), true ) ) { + return (bool) apply_filters( 'visualizer_is_business', false ); + } + if ( in_array( $feature, (array) self::get_features_for_license( 1 ), true ) ) { + return (bool) self::is_pro(); + } + + return true; + } + + /** + * Validates refresh_interval against the intervals the chart editor offers for the active plan. + * + * @param array $input The ability input. + * @param string $type The schedule type (csv, json, db). + * @param int $chart_id The chart ID. + * @return float|WP_Error + */ + private function resolveInterval( $input, $type, $chart_id ) { + if ( ! isset( $input['refresh_interval'] ) || ! is_numeric( $input['refresh_interval'] ) || (float) $input['refresh_interval'] < 0 ) { + return -1.0; + } + + $hours = (float) $input['refresh_interval']; + $allowed = apply_filters( 'visualizer_chart_schedules', array( '-1' => __( 'One-time', 'visualizer' ) ), $type, $chart_id ); + $allowed = is_array( $allowed ) ? array_keys( $allowed ) : array(); + foreach ( $allowed as $option ) { + if ( is_numeric( $option ) && abs( (float) $option - $hours ) < 0.00001 ) { + return $hours; + } + } + + return new WP_Error( + 'visualizer_interval_unavailable', + /* translators: %s: list of intervals in hours. */ + sprintf( __( 'This refresh interval is not available on the current Visualizer plan. Allowed values (hours): %s.', 'visualizer' ), implode( ', ', $allowed ) ) + ); + } + + /** + * Validates the url input. + * + * @param array $input The ability input. + * @return string|WP_Error + */ + private function validateUrl( $input ) { + $url = ( isset( $input['url'] ) && is_string( $input['url'] ) ) ? wp_http_validate_url( esc_url_raw( trim( $input['url'] ) ) ) : false; + if ( ! $url ) { + return new WP_Error( 'visualizer_invalid_url', __( 'The URL you entered is invalid. Please enter a valid URL.', 'visualizer' ) ); + } + + return (string) $url; + } + + // ------------------------------------------------------------------------- + // Output helpers + // ------------------------------------------------------------------------- + + /** + * Returns the chart name as shown in the chart library. + * + * @param WP_Post $chart The chart. + * @return string + */ + private function getChartTitle( $chart ) { + $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true ); + if ( is_array( $settings ) && ! empty( $settings['backend-title'] ) && is_string( $settings['backend-title'] ) ) { + return $settings['backend-title']; + } + + return '#' . $chart->ID; + } + + /** + * Returns the shortcode of a chart. + * + * @param int $chart_id The chart ID. + * @return string + */ + private function getShortcode( $chart_id ) { + return sprintf( '[visualizer id="%d"]', $chart_id ); + } + + /** + * Returns the embed descriptor of a chart. + * + * @param int $chart_id The chart ID. + * @return array{shortcode: string, block: string} + */ + private function getEmbed( $chart_id ) { + return array( + 'shortcode' => $this->getShortcode( $chart_id ), + 'block' => sprintf( '', $chart_id ), + ); + } + + /** + * Maps a source class to the source kind used by the abilities. + * + * @param int $chart_id The chart ID. + * @return string + */ + private function getSourceKind( $chart_id ) { + $kinds = array( + 'Visualizer_Source_Csv' => 'manual', + 'Visualizer_Source_Xlsx' => 'manual', + 'Visualizer_Source_Csv_Remote' => 'csv_url', + 'Visualizer_Source_Xlsx_Remote' => 'csv_url', + 'Visualizer_Source_Json' => 'json', + 'Visualizer_Source_Query' => 'db_query', + ); + $class = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, true ); + + return isset( $kinds[ $class ] ) ? $kinds[ $class ] : $class; + } + + /** + * Describes the saved source of a chart without any credentials. + * + * @param int $chart_id The chart ID. + * @return array + */ + private function describeSource( $chart_id ) { + $kind = $this->getSourceKind( $chart_id ); + $source = array( 'kind' => $kind ); + + switch ( $kind ) { + case 'csv_url': + $chart = get_post( $chart_id ); + $source['url'] = $chart instanceof WP_Post ? $this->getRemoteFileUrl( $chart ) : ''; + $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_CHART_SCHEDULE ); + break; + case 'json': + $headers = get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, true ); + $source['url'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL, true ); + $source['json_root'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_ROOT, true ); + $source['json_method'] = ( is_array( $headers ) && ! empty( $headers['method'] ) ) ? (string) $headers['method'] : 'GET'; + $source['has_saved_auth'] = is_array( $headers ) && ! empty( $headers['auth'] ); + $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_JSON_SCHEDULE ); + break; + case 'db_query': + $source['is_remote_db'] = ! empty( get_post_meta( $chart_id, Visualizer_Plugin::CF_REMOTE_DB_PARAMS, true ) ); + $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_DB_SCHEDULE ); + // The SQL is only shown to users who may edit it in the chart editor. + if ( current_user_can( 'administrator' ) && is_super_admin() ) { + $source['query'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_DB_QUERY, true ); + } + break; + } + + return $source; + } + + /** + * Returns a saved refresh interval in hours, -1 when none is saved. + * + * @param int $chart_id The chart ID. + * @param string $meta_key The schedule meta key. + * @return float + */ + private function getInterval( $chart_id, $meta_key ) { + $hours = get_post_meta( $chart_id, $meta_key, true ); + + return is_numeric( $hours ) ? (float) $hours : -1.0; + } + + /** + * Returns the URL of a remote CSV/XLSX chart. + * + * @param WP_Post $chart The chart. + * @return string + */ + private function getRemoteFileUrl( $chart ) { + $url = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_URL, true ); + if ( '' === $url ) { + // Same fallback as Visualizer_Source_Csv_Remote::_repopulate(). + $content = self::decode_content( html_entity_decode( $chart->post_content ) ); + $url = ( is_array( $content ) && isset( $content['source'] ) && is_string( $content['source'] ) ) ? $content['source'] : ''; + } + + return wp_http_validate_url( $url ) ? $url : ''; + } + + /** + * Converts stored series to columns. + * + * @param mixed $series The series. + * @return array + */ + private function seriesToColumns( $series ) { + $columns = array(); + if ( is_array( $series ) ) { + foreach ( $series as $serie ) { + if ( ! is_array( $serie ) ) { + continue; + } + $columns[] = array( + 'name' => isset( $serie['label'] ) ? (string) $serie['label'] : '', + 'type' => isset( $serie['type'] ) ? (string) $serie['type'] : 'string', + ); + } + } + + return $columns; + } + + /** + * Returns data rows as a list of lists. + * + * @param mixed $data The chart data. + * @return array> + */ + private function normalizeRows( $data ) { + $rows = array(); + if ( is_array( $data ) ) { + foreach ( $data as $row ) { + if ( is_array( $row ) ) { + $rows[] = array_values( $row ); + } + } + } + + return $rows; + } + + /** + * Returns the parsed preview of a fetched source. + * + * @param mixed $series The parsed series. + * @param mixed $data The parsed data. + * @return array + */ + private function getPreview( $series, $data ) { + $rows = $this->normalizeRows( $data ); + + return array( + 'columns' => $this->seriesToColumns( $series ), + 'rows' => array_slice( $rows, 0, self::PREVIEW_ROWS ), + 'row_count' => count( $rows ), + ); + } +} diff --git a/index.php b/index.php index b25f24fb..4d021d32 100644 --- a/index.php +++ b/index.php @@ -145,6 +145,9 @@ function () { // AI Chart Builder module (AJAX endpoints for the React wizard). $plugin->setModule( Visualizer_Module_AIBuilder::NAME ); + // Abilities API integration (no-op when the API is not available). + $plugin->setModule( Visualizer_Module_Abilities::NAME ); + $vendor_file = VISUALIZER_ABSPATH . '/vendor/autoload.php'; if ( is_readable( $vendor_file ) ) { include_once $vendor_file; From ae930dd648c84b947a182b39bad8418d398afcc6 Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 18 Sep 2026 17:15:31 +0300 Subject: [PATCH 05/15] feat: opt in to the AI Connect module Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- index.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/index.php b/index.php index 4d021d32..59a9c872 100644 --- a/index.php +++ b/index.php @@ -183,6 +183,31 @@ function () { ); } ); + add_filter( + 'visualizer_ai_connect_metadata', + function () { + return array( + 'name' => 'Visualizer', + 'notice_cases' => array( + __( 'build a chart from your numbers', 'visualizer' ), + __( 'update a table with new data', 'visualizer' ), + __( 'refresh charts from their data source', 'visualizer' ), + ), + 'prompts' => array( + __( 'List my Visualizer charts and tell me the type, data source and shortcode of each one.', 'visualizer' ), + __( 'Create a pie chart called "Traffic by channel" with Organic 5400, Direct 2100 and Social 1300, then give me its shortcode.', 'visualizer' ), + __( 'Go through all my charts and refresh the data of every one that pulls from a remote CSV, JSON or database source.', 'visualizer' ), + ), + 'abilities' => array( + 'visualizer/list-charts', + 'visualizer/get-chart', + 'visualizer/upsert-chart', + 'visualizer/set-data-source', + 'visualizer/refresh-chart-data', + ), + ); + } + ); if ( ! defined( 'TI_E2E_TESTING' ) && 'yes' === get_option( 'visualizer_logger_flag', 'no' ) ) { add_filter( 'themeisle_sdk_enable_telemetry', '__return_true' ); From 21f5952acc789520f840b411914bc63a259e55bc Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 18 Sep 2026 21:10:29 +0300 Subject: [PATCH 06/15] feat: add the upgrade link to Pro-required ability errors Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- classes/Visualizer/Module/Abilities.php | 33 +++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/classes/Visualizer/Module/Abilities.php b/classes/Visualizer/Module/Abilities.php index dff875bb..54bb221a 100644 --- a/classes/Visualizer/Module/Abilities.php +++ b/classes/Visualizer/Module/Abilities.php @@ -812,7 +812,7 @@ public function executeSetDataSource( $input = array() ) { return new WP_Error( 'visualizer_invalid_source', __( 'source must be one of csv_url, json or db_query.', 'visualizer' ) ); } if ( ! $this->isFeatureAvailable( $features[ $kind ] ) ) { - return new WP_Error( 'visualizer_feature_unavailable', __( 'This data source is not available on the current Visualizer plan.', 'visualizer' ) ); + return $this->planError( 'visualizer_feature_unavailable', __( 'This data source is not available on the current Visualizer plan.', 'visualizer' ), $features[ $kind ] ); } $schedule_types = array( @@ -1405,7 +1405,7 @@ private function resolveLibrary( $type, $library ) { return new WP_Error( 'visualizer_invalid_type', sprintf( __( 'Unknown chart type. Available types: %s.', 'visualizer' ), implode( ', ', array_keys( $types ) ) ) ); } if ( ! Visualizer_Module_Admin::checkChartStatus( $type ) ) { - return new WP_Error( 'visualizer_type_unavailable', __( 'This chart type is not available on the current Visualizer plan.', 'visualizer' ) ); + return $this->planError( 'visualizer_type_unavailable', __( 'This chart type is not available on the current Visualizer plan.', 'visualizer' ), 'pro-chart-types' ); } $supported = isset( $types[ $type ]['supports'] ) ? (array) $types[ $type ]['supports'] : array( 'Google Charts' ); @@ -1472,10 +1472,33 @@ private function resolveInterval( $input, $type, $chart_id ) { } } + /* translators: %s: list of intervals in hours. */ + $message = sprintf( __( 'This refresh interval is not available on the current Visualizer plan. Allowed values (hours): %s.', 'visualizer' ), implode( ', ', $allowed ) ); + + // The plan is the limit only when it offers no recurring interval; otherwise the value itself is wrong. + if ( count( $allowed ) > 1 ) { + return new WP_Error( 'visualizer_interval_unavailable', $message ); + } + + return $this->planError( 'visualizer_interval_unavailable', $message, 'refresh-interval' ); + } + + /** + * Builds the error for a request the active plan does not cover, with the upgrade link. + * + * @param string $code The error code. + * @param string $message The error message. + * @param string $area The gated feature, used as the campaign of the upgrade link. + * @return WP_Error + */ + private function planError( $code, $message, $area ) { + $upgrade_url = tsdk_translate_link( tsdk_utmify( Visualizer_Plugin::PRO_TEASER_URL, $area, 'mcp' ) ); + return new WP_Error( - 'visualizer_interval_unavailable', - /* translators: %s: list of intervals in hours. */ - sprintf( __( 'This refresh interval is not available on the current Visualizer plan. Allowed values (hours): %s.', 'visualizer' ), implode( ', ', $allowed ) ) + $code, + /* translators: 1: the error message, 2: the upgrade URL. */ + sprintf( __( '%1$s Upgrade: %2$s', 'visualizer' ), $message, $upgrade_url ), + array( 'upgrade_url' => $upgrade_url ) ); } From 4d996ef4aa03af418f703603f182475514f1a044 Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 18 Sep 2026 21:26:42 +0300 Subject: [PATCH 07/15] chore: make the AI Connect prompts task-driven Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.php b/index.php index 59a9c872..c01be2d8 100644 --- a/index.php +++ b/index.php @@ -194,9 +194,9 @@ function () { __( 'refresh charts from their data source', 'visualizer' ), ), 'prompts' => array( - __( 'List my Visualizer charts and tell me the type, data source and shortcode of each one.', 'visualizer' ), - __( 'Create a pie chart called "Traffic by channel" with Organic 5400, Direct 2100 and Social 1300, then give me its shortcode.', 'visualizer' ), + __( 'Create a Visualizer pie chart called "Traffic by channel" with Organic 5400, Direct 2100 and Social 1300, then give me its shortcode.', 'visualizer' ), __( 'Go through all my charts and refresh the data of every one that pulls from a remote CSV, JSON or database source.', 'visualizer' ), + __( 'List my charts and tell me the type, data source and shortcode of each one.', 'visualizer' ), ), 'abilities' => array( 'visualizer/list-charts', From 98a869f9268f43e57eefa61b1aede307bf122727 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:01:17 +0300 Subject: [PATCH 08/15] chore(deps): bump codeinwp/themeisle-sdk from 3.3.61 to 3.3.62 (#1383) Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.61 to 3.3.62. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Changelog](https://github.com/Codeinwp/themeisle-sdk/blob/v3.3.62/CHANGELOG.md) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.61...v3.3.62) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.62 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 79c20cde..046e426b 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.61", + "version": "3.3.62", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99" + "reference": "8363c9cab1a233095a76cd48e96fb64ce1b29ef8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9fe698b52dec768a0dd8b500fb51efe40962ee99", - "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/8363c9cab1a233095a76cd48e96fb64ce1b29ef8", + "reference": "8363c9cab1a233095a76cd48e96fb64ce1b29ef8", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.61" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.62" }, - "time": "2026-08-24T15:59:27+00:00" + "time": "2026-09-17T17:08:21+00:00" }, { "name": "neitanod/forceutf8", From feaf1ca55d9826339ba5b00b4616e464cb8d85f3 Mon Sep 17 00:00:00 2001 From: selul Date: Mon, 21 Sep 2026 10:33:51 +0300 Subject: [PATCH 09/15] chore: let AI Connect collect the abilities by prefix Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- index.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/index.php b/index.php index c01be2d8..e148c094 100644 --- a/index.php +++ b/index.php @@ -198,13 +198,7 @@ function () { __( 'Go through all my charts and refresh the data of every one that pulls from a remote CSV, JSON or database source.', 'visualizer' ), __( 'List my charts and tell me the type, data source and shortcode of each one.', 'visualizer' ), ), - 'abilities' => array( - 'visualizer/list-charts', - 'visualizer/get-chart', - 'visualizer/upsert-chart', - 'visualizer/set-data-source', - 'visualizer/refresh-chart-data', - ), + 'ability_prefix' => 'visualizer', ); } ); From 08ecec1d7a3d293cc6a91c5fdb1e17b8b67a4feb Mon Sep 17 00:00:00 2001 From: selul Date: Mon, 21 Sep 2026 13:11:40 +0300 Subject: [PATCH 10/15] chore: make the AI Connect prompts value-driven Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- index.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/index.php b/index.php index e148c094..f99c6d50 100644 --- a/index.php +++ b/index.php @@ -194,9 +194,9 @@ function () { __( 'refresh charts from their data source', 'visualizer' ), ), 'prompts' => array( - __( 'Create a Visualizer pie chart called "Traffic by channel" with Organic 5400, Direct 2100 and Social 1300, then give me its shortcode.', 'visualizer' ), - __( 'Go through all my charts and refresh the data of every one that pulls from a remote CSV, JSON or database source.', 'visualizer' ), - __( 'List my charts and tell me the type, data source and shortcode of each one.', 'visualizer' ), + __( 'Create a Visualizer pie chart "Traffic by channel" with Organic 5400, Direct 2100 and Social 1300, and add it to my Analytics page.', 'visualizer' ), + __( 'Refresh every chart that pulls from a remote CSV, JSON or database source.', 'visualizer' ), + __( 'Change my "Monthly sales" chart to a bar chart and add September: 12,400.', 'visualizer' ), ), 'ability_prefix' => 'visualizer', ); From 7b2f6435d19c07f74ab59b4c58d1243bbd718849 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:01:44 +0300 Subject: [PATCH 11/15] chore(deps-dev): bump phpstan/phpstan from 2.2.13 to 2.2.14 (#1386) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.13 to 2.2.14. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.14 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 046e426b..e61b682c 100644 --- a/composer.lock +++ b/composer.lock @@ -1066,11 +1066,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.13", + "version": "2.2.14", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", - "reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "shasum": "" }, "require": { @@ -1126,7 +1126,7 @@ "type": "github" } ], - "time": "2026-09-03T20:38:19+00:00" + "time": "2026-09-12T21:39:33+00:00" }, { "name": "phpunit/php-code-coverage", From 269d800984929bd8a5e1be482615d56e283fb734 Mon Sep 17 00:00:00 2001 From: selul Date: Tue, 29 Sep 2026 11:58:41 +0300 Subject: [PATCH 12/15] chore: require Themeisle SDK 3.3.64 The first SDK release that carries the AI Connect module this PR opts into. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 1153b42d..41d36e15 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "source": "https://github.com/Codeinwp/visualizer" }, "require": { - "codeinwp/themeisle-sdk": "^3.3", + "codeinwp/themeisle-sdk": "^3.3.64", "neitanod/forceutf8": "~2.0", "openspout/openspout": "^3.7", "woocommerce/action-scheduler": "^3.8" diff --git a/composer.lock b/composer.lock index 046e426b..c883cb71 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "881b5f99c0b72f47e79eb09bdac7fbea", + "content-hash": "1be7e10e95d4282417350cbad70deb7b", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.62", + "version": "3.3.64", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "8363c9cab1a233095a76cd48e96fb64ce1b29ef8" + "reference": "53c6f4faca201298748d6319f25a1de2767b5254" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/8363c9cab1a233095a76cd48e96fb64ce1b29ef8", - "reference": "8363c9cab1a233095a76cd48e96fb64ce1b29ef8", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/53c6f4faca201298748d6319f25a1de2767b5254", + "reference": "53c6f4faca201298748d6319f25a1de2767b5254", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.62" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.64" }, - "time": "2026-09-17T17:08:21+00:00" + "time": "2026-09-29T08:42:21+00:00" }, { "name": "neitanod/forceutf8", From c23d6c844ec10cafd8b27354ce4461ef596d0577 Mon Sep 17 00:00:00 2001 From: selul Date: Tue, 29 Sep 2026 12:44:26 +0300 Subject: [PATCH 13/15] chore: require Themeisle SDK 3.3.65 Picks up the AI Connect fixes since 3.3.64: the Enable button hides once the connector is active, the notice matches core's height, and the "enabled" event for products with their own notification UI. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 41d36e15..fc50bfe0 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "source": "https://github.com/Codeinwp/visualizer" }, "require": { - "codeinwp/themeisle-sdk": "^3.3.64", + "codeinwp/themeisle-sdk": "^3.3.65", "neitanod/forceutf8": "~2.0", "openspout/openspout": "^3.7", "woocommerce/action-scheduler": "^3.8" diff --git a/composer.lock b/composer.lock index c883cb71..dfee0252 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1be7e10e95d4282417350cbad70deb7b", + "content-hash": "78fe9ab724950bb3c973311378d483e5", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.64", + "version": "3.3.65", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "53c6f4faca201298748d6319f25a1de2767b5254" + "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/53c6f4faca201298748d6319f25a1de2767b5254", - "reference": "53c6f4faca201298748d6319f25a1de2767b5254", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/f650fe856d52ce4e5754557d89ba2f3127ad54d8", + "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.64" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.65" }, - "time": "2026-09-29T08:42:21+00:00" + "time": "2026-09-29T09:35:47+00:00" }, { "name": "neitanod/forceutf8", From 256bc23d19d0f549ef8cd5461fa3d60d4cfaa7c4 Mon Sep 17 00:00:00 2001 From: selul Date: Tue, 29 Sep 2026 13:56:31 +0300 Subject: [PATCH 14/15] release: AI agent support - Added AI agent support: let AI assistants read and change your Visualizer charts and settings. From 93eb5252d4299c200f6dbda869917f6bb29820dc Mon Sep 17 00:00:00 2001 From: Alexia-Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:02:07 +0300 Subject: [PATCH 15/15] fix: recover the database refresh chain a killed run ends (#1385) * fix: keep the DB refresh scheduled when Action Scheduler creation fails as_schedule_recurring_action() returns 0 instead of throwing when it cannot store an action. The activation path ignored that return, cleared the WP-Cron fallback anyway, and left the refresh hook with no trigger at all. Nothing recovered it: the recovery hook stood down whenever Action Scheduler was usable, and the migration was gated on the WP-Cron event that had just been deleted, so even a reactivation could fail the same way. Clear the fallback only after re-querying Action Scheduler for the action. Turn the recovery hook into a plain "is anything going to fire this hook" check, which also covers the legacy WP-Cron migration, so the duplicate scheduling code in Visualizer_Module_Upgrade is no longer needed. Two consequences of running that check on every request: - Re-arm WP-Cron only when the event is missing or its interval changed. Re-arming unconditionally pinned it to a past timestamp on every request, so the refresh became due on every cron spawn. - Schedule the action as unique. Action Scheduler creates the next recurrence only after the current one completes, so a concurrent request can arrive while nothing is pending and add a duplicate. Co-Authored-By: Claude Opus 5 (1M context) * fix: clear a WP-Cron event left beside the Action Scheduler action Both schedulers fire visualizer_schedule_refresh_db, so a site holding an action and an event refreshes twice per interval. The recovery check counted the action alone as settled and left the event in place. Treat the refresh as settled only when Action Scheduler holds the action and no event fires the same hook beside it. Also from review: reuse the first lookup instead of querying Action Scheduler again when the action already exists, and name the hook and the group once instead of repeating the literals. Co-Authored-By: Claude Opus 5 (1M context) * test: skip instead of fatal when Action Scheduler is not loaded index.php loads Action Scheduler only when visualizer_can_use_action_scheduler() passes, so a host without wpdb::db_server_info() has none. set_up() called the library unconditionally, which ended the whole suite with a fatal before any test could report. Skip the file instead. Also corrects the WP-Cron comment: the check is missing or different interval, not a stale timestamp. A timestamp criterion would pin the event to the past again, which is what the check exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) * fix: recover the refresh chain a killed run ends, and throttle the check Hands-on investigation on the reporting site corrected the premise of #1384. Reactivation did restore the action; the customer was reading WP Crontrol, where the hook correctly no longer appears after the Action Scheduler migration. The real defect is that a run killed mid flight ends the recurring chain for good: Action Scheduler creates the next occurrence inside schedule_next_instance(), which a host kill, fatal or timeout never reaches, and the queue cleaner then marks the action failed with no successor. The per-request check already recovers this. Add the two pieces it was missing: - Hook action_scheduler_ensure_recurring_actions, Action Scheduler's own daily assurance hook, as a floor under the per-request check for sites that serve few requests. - Throttle the per-request check to one run per 300s, recorded in an autoloaded option so a settled site spends no query on it. Action Scheduler needs the same 300s to mark a killed run failed, so a shorter window cannot recover one any sooner. The daily hook ignores the window. Co-Authored-By: Claude Opus 5 (1M context) * fix: start a recovered run now, and stop rewriting an autoloaded option Two findings from review. The start time is local midnight derived from gmt_offset. West of UTC that midnight has not arrived yet, so a recovered run was parked up to twelve hours ahead and the charts stayed stale for the rest of the day. Fall back to the previous midnight when the computed one is still in the future. The throttle recorded its timestamp in an autoloaded option, so every window rewrote the alloptions blob and invalidated it for every process. A transient with the window as its expiry is not autoloaded, expires on its own, and keeps the read cached. Co-Authored-By: Claude Opus 5 (1M context) * test: prove the filter argument order instead of assuming it Review has now questioned the pre_as_schedule_recurring_action argument order twice. The racer records the two arguments it receives and the test asserts their types, so a swap fails loudly instead of silently forwarding the wrong value. Types separate them whatever value the code under test passes. Also from review: guard set_up() on the ActionScheduler classes the tests call statics on, not only the functions, and scope the gmt_offset change to a filter the framework restores. Co-Authored-By: Claude Opus 5 (1M context) * test: do not depend on plugin_basename() for the lifecycle hook name plugin_basename() resolves differently depending on where the plugin directory is loaded from, so the hook name register_activation_hook() used is not stable across environments. Loading the plugin from outside WP_PLUGIN_DIR registers the full path as the hook name while the test computes the short one, and the activation test then fires a hook nothing listens to and passes vacuously or fails. Call the callback the hook points at instead. Co-Authored-By: Claude Opus 5 (1M context) * test: say why the Action Scheduler precondition fails instead of skipping A red build on this assertion needs to point somewhere. Absent is skipped in set_up() because some hosts cannot run Action Scheduler; loaded but not initialized means the load order broke, and every other test in the class would then assert nothing. Co-Authored-By: Claude Opus 5 (1M context) * fix: do not drop the refresh on an unknown interval or a fractional offset Two findings from review, both on the WP-Cron fallback. wp_schedule_event() refuses a schedule WP-Cron does not know, and the fallback cleared the existing event before asking, so a visualizer_chart_schedule_interval filter returning an unregistered key left the refresh with nothing and the throttle then held the retry off. Fall back to the plugin's own schedule when the filtered key is not registered, which is what get_schedule_interval_seconds() already does for the interval itself. gmt_offset is a number rather than an integer, so the start time could carry a fraction. WP-Cron keys its array by that value, and PHP reports losing precision from wp-includes/cron.php. Cast the start time to an integer. Co-Authored-By: Claude Opus 5 (1M context) * docs: note that tear_down restores hooks, so test filters need no removal Reviewers have read the missing remove_filter() calls as a leak four times across this PR and its pro counterpart. WP_UnitTestCase_Base backs up $wp_filter in set_up() and restores it wholesale in tear_down(). Co-Authored-By: Claude Opus 5 (1M context) * fix: record the check window only when a trigger exists The window skips work that is already done, so recording it after an attempt that established no trigger left the site without one until the window expired, with the daily assurance hook a day away. ensure_refresh_db_action() now reports whether the refresh ended up scheduled, and the caller caches only that. Co-Authored-By: Claude Opus 5 (1M context) * fix: treat a live WP-Cron fallback as scheduled for the check window Where Action Scheduler is present but refuses, the fallback is the trigger, and the previous commit reported that as unscheduled. The window was then never recorded, so every request repeated the whole check and the refused insert. Split the two questions: settled means the refresh sits on the scheduler this site should use, which is what decides whether to act; having a trigger means something will fire the hook at all, which is what decides whether to cache. ensure_refresh_db_action() goes back to returning nothing. Co-Authored-By: Claude Opus 5 (1M context) * refactor: read the cron schedules once when scheduling the refresh The key check and the interval lookup each called wp_get_schedules(). Read it once and derive both from the same array, which also makes the one-line helper that did the second lookup unnecessary. Co-Authored-By: Claude Fable 5.1 * fix: keep the old WP-Cron event until its replacement is scheduled Changing interval cleared the old event and then scheduled the new one, so a refused schedule left the refresh with nothing. That is the bug this PR fixes on the Action Scheduler path, repeated on the WP-Cron one. Schedule first and remove the old event by its own timestamp afterwards. wp_clear_scheduled_hook() would remove the new event too, since WP-Cron does not dedupe recurring events for a hook. A matching timestamp means the write already replaced the old entry in place, so nothing is removed in that case. Co-Authored-By: Claude Fable 5.1 * fix: unschedule the old refresh event without passing its arguments The refresh is scheduled without arguments, so the old event has none to match. Keeps this in step with the Pro plugin, where PHPStan objected to passing the typed-as-array field where a list is expected. Co-Authored-By: Claude Fable 5.1 * docs: trim the comments Requested in review: keep the one line that says why, drop the paragraphs. Co-Authored-By: Claude Fable 5.1 * test: type the pending action IDs as numeric strings Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- classes/Visualizer/Module/Setup.php | 152 ++++++++--- classes/Visualizer/Module/Upgrade.php | 42 --- tests/test-schedule-refresh-db.php | 378 ++++++++++++++++++++++++++ uninstall.php | 1 + 4 files changed, 496 insertions(+), 77 deletions(-) create mode 100644 tests/test-schedule-refresh-db.php diff --git a/classes/Visualizer/Module/Setup.php b/classes/Visualizer/Module/Setup.php index 6ce00659..00a3fbca 100644 --- a/classes/Visualizer/Module/Setup.php +++ b/classes/Visualizer/Module/Setup.php @@ -31,6 +31,26 @@ class Visualizer_Module_Setup extends Visualizer_Module { const NAME = __CLASS__; + /** + * Hook that refreshes database charts. + */ + const REFRESH_DB_HOOK = 'visualizer_schedule_refresh_db'; + + /** + * Action Scheduler group that owns the refresh. + */ + const REFRESH_DB_GROUP = 'visualizer'; + + /** + * Marks the refresh trigger as checked recently. + */ + const REFRESH_DB_CHECK_TRANSIENT = 'visualizer-refresh-db-checked'; + + /** + * Seconds a check stays valid; matches Action Scheduler's timeout for a killed run. + */ + const REFRESH_DB_CHECK_WINDOW = 300; + /** * Constructor. * @@ -44,8 +64,9 @@ public function __construct( Visualizer_Plugin $plugin ) { register_activation_hook( VISUALIZER_BASEFILE, array( $this, 'activate' ) ); register_deactivation_hook( VISUALIZER_BASEFILE, array( $this, 'deactivate' ) ); - $this->_addAction( 'visualizer_schedule_refresh_db', 'refreshDbChart' ); + $this->_addAction( self::REFRESH_DB_HOOK, 'refreshDbChart' ); $this->_addAction( 'init', 'maybe_reschedule_refresh_db' ); + $this->_addAction( 'action_scheduler_ensure_recurring_actions', 'ensure_refresh_db_action' ); $this->_addFilter( 'visualizer_schedule_refresh_chart', 'refresh_db_for_chart', 10, 3 ); $this->_addAction( 'admin_init', 'adminInit' ); @@ -485,11 +506,24 @@ public function custom_cron_schedules( $schedules ) { * Schedule the recurring DB refresh action. */ private function schedule_refresh_db_action(): void { - $hook = 'visualizer_schedule_refresh_db'; - $group = 'visualizer'; + $hook = self::REFRESH_DB_HOOK; + $group = self::REFRESH_DB_GROUP; + $schedules = wp_get_schedules(); $interval_key = apply_filters( 'visualizer_chart_schedule_interval', 'visualizer_ten_minutes' ); - $interval = $this->get_schedule_interval_seconds( $interval_key ); - $timestamp = strtotime( 'midnight' ) - get_option( 'gmt_offset' ) * HOUR_IN_SECONDS; + + // wp_schedule_event() refuses an unregistered schedule. + if ( ! isset( $schedules[ $interval_key ]['interval'] ) ) { + $interval_key = 'visualizer_ten_minutes'; + } + + $interval = isset( $schedules[ $interval_key ]['interval'] ) ? (int) $schedules[ $interval_key ]['interval'] : 600; + // gmt_offset can be fractional, and WP-Cron keys its array by this value. + $timestamp = (int) ( strtotime( 'midnight' ) - get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); + + // West of UTC that midnight is still ahead; start from the previous one. + if ( $timestamp > time() ) { + $timestamp -= DAY_IN_SECONDS; + } if ( visualizer_can_use_action_scheduler() @@ -498,63 +532,111 @@ private function schedule_refresh_db_action(): void { ) { $next = as_next_scheduled_action( $hook, array(), $group ); if ( false === $next ) { - as_schedule_recurring_action( $timestamp, $interval, $hook, array(), $group ); + // Unique: a concurrent request can arrive while nothing is pending. + as_schedule_recurring_action( $timestamp, $interval, $hook, array(), $group, true ); + + // Returns 0 on failure, so ask the store. + $next = as_next_scheduled_action( $hook, array(), $group ); + } + + // Drop the WP-Cron fallback only once the action exists. + if ( false !== $next ) { + wp_clear_scheduled_hook( $hook ); + return; } - wp_clear_scheduled_hook( $hook ); + } + + // Re-arming a live event would pin it to a past timestamp and keep it due. + $event = wp_get_scheduled_event( $hook ); + if ( $event && $event->schedule === $interval_key ) { return; } - wp_clear_scheduled_hook( $hook ); - wp_schedule_event( $timestamp, $interval_key, $hook ); + // Schedule first so a refused replacement keeps the old event, then remove the old one + // by its timestamp: wp_clear_scheduled_hook() would take the new one too. + if ( false === wp_schedule_event( $timestamp, $interval_key, $hook ) ) { + return; + } + + // A matching timestamp was already overwritten in place. + if ( $event && $event->timestamp !== $timestamp ) { + wp_unschedule_event( $event->timestamp, $hook ); + } } /** - * Keep the DB refresh scheduled when Action Scheduler is not available. - * - * The migration to Action Scheduler clears the WP-Cron event, so a site that - * already migrated and then lost the library would have nothing left running - * the refresh. Re-arms WP-Cron in that case; no-op whenever the library is up. + * Check once per window, on init, that something still fires the refresh. */ public function maybe_reschedule_refresh_db(): void { + if ( get_transient( self::REFRESH_DB_CHECK_TRANSIENT ) ) { + return; + } + + $this->ensure_refresh_db_action(); + + // Cache only a check that left a trigger; a failed one retries next request. + if ( $this->has_refresh_db_trigger() ) { + set_transient( self::REFRESH_DB_CHECK_TRANSIENT, 1, self::REFRESH_DB_CHECK_WINDOW ); + } + } + + /** + * Keep the DB refresh scheduled. + * + * A killed run never reaches schedule_next_instance(), so Action Scheduler's chain ends there. + */ + public function ensure_refresh_db_action(): void { + if ( ! $this->refresh_db_is_settled() ) { + $this->schedule_refresh_db_action(); + } + } + + /** + * Whether the refresh is on Action Scheduler with no WP-Cron event beside it. + * + * @return bool + */ + private function refresh_db_is_settled(): bool { + $hook = self::REFRESH_DB_HOOK; + if ( visualizer_can_use_action_scheduler() && function_exists( 'as_next_scheduled_action' ) && function_exists( 'as_schedule_recurring_action' ) ) { - return; + return false !== as_next_scheduled_action( $hook, array(), self::REFRESH_DB_GROUP ) + && ! wp_next_scheduled( $hook ); } - if ( wp_next_scheduled( 'visualizer_schedule_refresh_db' ) ) { - return; + return (bool) wp_next_scheduled( $hook ); + } + + /** + * Whether anything will fire the refresh hook again. + * + * @return bool + */ + private function has_refresh_db_trigger(): bool { + $hook = self::REFRESH_DB_HOOK; + + if ( visualizer_can_use_action_scheduler() && function_exists( 'as_next_scheduled_action' ) ) { + if ( false !== as_next_scheduled_action( $hook, array(), self::REFRESH_DB_GROUP ) ) { + return true; + } } - $this->schedule_refresh_db_action(); + return (bool) wp_next_scheduled( $hook ); } /** * Unschedule the recurring DB refresh action. */ private function unschedule_refresh_db_action(): void { - $hook = 'visualizer_schedule_refresh_db'; - $group = 'visualizer'; + $hook = self::REFRESH_DB_HOOK; + $group = self::REFRESH_DB_GROUP; if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( $hook, array(), $group ); } wp_clear_scheduled_hook( $hook ); } - - /** - * Resolve a cron schedule key to seconds. - * - * @param string $interval_key Cron schedule key. - * @return int Interval in seconds. - */ - private function get_schedule_interval_seconds( $interval_key ) { - $schedules = wp_get_schedules(); - if ( isset( $schedules[ $interval_key ]['interval'] ) ) { - return (int) $schedules[ $interval_key ]['interval']; - } - - return 600; - } } diff --git a/classes/Visualizer/Module/Upgrade.php b/classes/Visualizer/Module/Upgrade.php index 1989d63b..1dcf3b9e 100644 --- a/classes/Visualizer/Module/Upgrade.php +++ b/classes/Visualizer/Module/Upgrade.php @@ -24,11 +24,6 @@ public static function upgrade() { $upgraded = true; } - if ( wp_next_scheduled( 'visualizer_schedule_refresh_db' ) ) { - self::migrate_action_scheduler(); - $upgraded = true; - } - if ( ! $upgraded ) { return; } @@ -79,41 +74,4 @@ private static function makeAllTableChartsTabular() { ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared } - - /** - * Migrate recurring WP-Cron jobs to Action Scheduler. - */ - private static function migrate_action_scheduler(): void { - if ( ! function_exists( 'as_schedule_recurring_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) { - return; - } - - $hook = 'visualizer_schedule_refresh_db'; - $group = 'visualizer'; - $interval_key = apply_filters( 'visualizer_chart_schedule_interval', 'visualizer_ten_minutes' ); - $interval = self::get_schedule_interval_seconds( $interval_key ); - $timestamp = strtotime( 'midnight' ) - get_option( 'gmt_offset' ) * HOUR_IN_SECONDS; - - $next = as_next_scheduled_action( $hook, array(), $group ); - if ( false === $next ) { - as_schedule_recurring_action( $timestamp, $interval, $hook, array(), $group ); - } - - wp_clear_scheduled_hook( $hook ); - } - - /** - * Resolve a cron schedule key to seconds. - * - * @param string $interval_key Cron schedule key. - * @return int Interval in seconds. - */ - private static function get_schedule_interval_seconds( $interval_key ) { - $schedules = wp_get_schedules(); - if ( isset( $schedules[ $interval_key ]['interval'] ) ) { - return (int) $schedules[ $interval_key ]['interval']; - } - - return 600; - } } diff --git a/tests/test-schedule-refresh-db.php b/tests/test-schedule-refresh-db.php new file mode 100644 index 00000000..d7fc8e03 --- /dev/null +++ b/tests/test-schedule-refresh-db.php @@ -0,0 +1,378 @@ +markTestSkipped( 'Action Scheduler is not loaded on this environment.' ); + } + + as_unschedule_all_actions( self::HOOK, array(), self::GROUP ); + wp_clear_scheduled_hook( self::HOOK ); + + // the bootstrap's init already opened a check window. + delete_transient( Visualizer_Module_Setup::REFRESH_DB_CHECK_TRANSIENT ); + } + + /** + * Whether anything will fire the refresh hook again. + * + * @return bool + */ + private function has_trigger(): bool { + return false !== as_next_scheduled_action( self::HOOK, array(), self::GROUP ) + || false !== wp_next_scheduled( self::HOOK ); + } + + /** + * Call the lifecycle callback directly; plugin_basename() is not stable across environments. + * + * @param string $action Either `activate` or `deactivate`. + */ + private function lifecycle( string $action ) { + $this->setup_module()->$action( false ); + } + + /** + * Precondition. Fails rather than skips: loaded but uninitialized means the load order broke. + */ + public function test_action_scheduler_is_available() { + $this->assertTrue( function_exists( 'as_schedule_recurring_action' ), 'Action Scheduler must be loaded' ); + $this->assertTrue( + ActionScheduler::is_initialized(), + 'Action Scheduler is loaded but not initialized; the other tests would assert nothing.' + ); + } + + /** + * A refused Action Scheduler creation must leave the WP-Cron fallback in place. + */ + public function test_activation_keeps_a_trigger_when_action_scheduler_creation_fails() { + add_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + $this->lifecycle( 'activate' ); + remove_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + + $this->assertTrue( $this->has_trigger(), 'activation must not leave the refresh hook with no trigger at all' ); + } + + /** + * A site that already lost both schedulers must recover on an ordinary request. + */ + public function test_recovery_restores_a_trigger_when_both_schedulers_are_empty() { + $this->assertFalse( $this->has_trigger(), 'precondition: nothing is scheduled' ); + + $this->setup_module()->maybe_reschedule_refresh_db(); + + $this->assertTrue( $this->has_trigger(), 'a missing refresh trigger must be restored without another activation' ); + } + + /** + * A legacy WP-Cron event must move onto Action Scheduler and stop firing twice. + */ + public function test_legacy_wp_cron_event_migrates_to_action_scheduler() { + wp_schedule_event( time(), 'visualizer_ten_minutes', self::HOOK ); + $this->assertNotFalse( wp_next_scheduled( self::HOOK ), 'precondition: a legacy WP-Cron event exists' ); + + $this->setup_module()->maybe_reschedule_refresh_db(); + + $this->assertNotFalse( as_next_scheduled_action( self::HOOK, array(), self::GROUP ), 'the refresh must move onto Action Scheduler' ); + $this->assertFalse( wp_next_scheduled( self::HOOK ), 'the superseded WP-Cron event must not survive the migration' ); + } + + /** + * While Action Scheduler keeps refusing, later requests must leave the fallback alone. + */ + public function test_recovery_does_not_drag_a_live_wp_cron_event_back_into_the_past() { + add_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + + $this->setup_module()->ensure_refresh_db_action(); + $this->assertNotFalse( wp_next_scheduled( self::HOOK ), 'precondition: the fallback is armed' ); + + // mimic WP-Cron having run the event and rescheduled it forward. + wp_clear_scheduled_hook( self::HOOK ); + $future = time() + 600; + wp_schedule_event( $future, 'visualizer_ten_minutes', self::HOOK ); + + $this->setup_module()->ensure_refresh_db_action(); + remove_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + + $this->assertSame( $future, wp_next_scheduled( self::HOOK ), 'a later request must not make the refresh due again' ); + } + + /** + * A concurrent request must not be able to create a second recurring action. + */ + public function test_recovery_does_not_create_a_second_action_when_a_concurrent_request_wins_the_race() { + $done = false; + $seen = array(); + // Another request scheduling between our lookup and our write. Action Scheduler + // passes $priority before $unique, see its functions.php:165. + $racer = function ( $pre, $timestamp, $interval, $hook, $args, $group, $priority, $unique ) use ( &$done, &$seen ) { + if ( ! $done ) { + $done = true; + $seen = array( + 'priority' => $priority, + 'unique' => $unique, + ); + as_schedule_recurring_action( $timestamp, $interval, $hook, $args, $group, $unique, $priority ); + } + return null; + }; + add_filter( 'pre_as_schedule_recurring_action', $racer, 10, 8 ); + + $this->setup_module()->maybe_reschedule_refresh_db(); + remove_filter( 'pre_as_schedule_recurring_action', $racer, 10 ); + + // Pin the argument order by type, whatever value the code passes for $unique. + $this->assertIsInt( $seen['priority'], 'the filter must pass $priority before $unique' ); + $this->assertIsBool( $seen['unique'], 'the filter must pass $priority before $unique' ); + $this->assertTrue( $done, 'precondition: the race was actually simulated' ); + $this->assertCount( 1, $this->pending_actions(), 'a lost race must not leave the refresh scheduled twice' ); + } + + /** + * A WP-Cron event left beside an Action Scheduler action must go; both fire the hook. + */ + public function test_recovery_removes_a_wp_cron_event_left_beside_an_action_scheduler_action() { + as_schedule_recurring_action( time(), 600, self::HOOK, array(), self::GROUP, true ); + wp_schedule_event( time(), 'visualizer_ten_minutes', self::HOOK ); + $this->assertNotFalse( as_next_scheduled_action( self::HOOK, array(), self::GROUP ), 'precondition: Action Scheduler owns the refresh' ); + $this->assertNotFalse( wp_next_scheduled( self::HOOK ), 'precondition: a WP-Cron event sits beside it' ); + + $this->setup_module()->maybe_reschedule_refresh_db(); + + $this->assertFalse( wp_next_scheduled( self::HOOK ), 'the refresh must not stay scheduled on both systems' ); + $this->assertNotFalse( as_next_scheduled_action( self::HOOK, array(), self::GROUP ), 'the Action Scheduler action must survive' ); + } + + /** + * A run killed mid flight must not end the recurring chain (the scenario on the reporting site). + */ + public function test_a_killed_run_does_not_end_the_recurring_chain() { + as_schedule_recurring_action( time(), 600, self::HOOK, array(), self::GROUP, true ); + $pending = $this->pending_actions(); + $action_id = reset( $pending ); + + // what the queue cleaner does to a run that never returned. + $store = ActionScheduler::store(); + $store->log_execution( $action_id ); + $store->mark_failure( $action_id ); + + $this->assertSame( ActionScheduler_Store::STATUS_FAILED, $store->get_status( $action_id ), 'precondition: the run was killed' ); + $this->assertFalse( $this->has_trigger(), 'precondition: nothing succeeds the killed run' ); + + $this->setup_module()->maybe_reschedule_refresh_db(); + + $this->assertCount( 1, $this->pending_actions(), 'a killed run must get a successor' ); + } + + /** + * Action Scheduler's daily assurance hook must restore a missing action. + */ + public function test_the_daily_action_scheduler_hook_restores_a_missing_action() { + $this->assertFalse( $this->has_trigger(), 'precondition: nothing is scheduled' ); + + do_action( 'action_scheduler_ensure_recurring_actions' ); + + $this->assertTrue( $this->has_trigger(), 'the daily assurance hook must restore the refresh' ); + } + + /** + * The per-request check stands down inside its window; the daily hook does not. + */ + public function test_the_per_request_check_is_throttled_and_the_daily_hook_is_the_floor() { + $module = $this->setup_module(); + + $module->maybe_reschedule_refresh_db(); + $this->assertTrue( $this->has_trigger(), 'precondition: the first request scheduled the refresh' ); + + // the chain dies again, inside the window the first request opened. + as_unschedule_all_actions( self::HOOK, array(), self::GROUP ); + $module->maybe_reschedule_refresh_db(); + + $this->assertFalse( $this->has_trigger(), 'inside the window the per-request check must stand down' ); + + do_action( 'action_scheduler_ensure_recurring_actions' ); + + $this->assertTrue( $this->has_trigger(), 'the daily assurance hook must repair it whatever the window says' ); + } + + /** + * Recovery must make the refresh due now, not at a midnight that has not happened yet. + */ + public function test_recovery_does_not_park_the_next_run_in_the_future() { + // Far enough west that the computed midnight is always ahead of now. + $hours_into_utc_day = ( time() - strtotime( 'midnight' ) ) / HOUR_IN_SECONDS; + add_filter( + 'pre_option_gmt_offset', + function () use ( $hours_into_utc_day ) { + return - ( $hours_into_utc_day + 1 ); + } + ); + + $this->setup_module()->ensure_refresh_db_action(); + + $next = as_next_scheduled_action( self::HOOK, array(), self::GROUP ); + $this->assertLessThanOrEqual( time(), $next, 'the recovered refresh must be due now, not hours from now' ); + } + + /** + * A filtered interval key WP-Cron does not know must not drop the trigger. + */ + public function test_an_unknown_interval_key_still_leaves_a_trigger() { + add_filter( + 'visualizer_chart_schedule_interval', + static function () { + return 'not_a_registered_schedule'; + } + ); + // force the WP-Cron fallback. + add_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + + $this->setup_module()->ensure_refresh_db_action(); + + $this->assertTrue( $this->has_trigger(), 'an unknown interval key must not leave the refresh with no trigger' ); + } + + /** + * The start time must be a whole second, whatever gmt_offset holds. + */ + public function test_a_fractional_offset_does_not_schedule_a_fractional_timestamp() { + add_filter( + 'pre_option_gmt_offset', + static function () { + return 5.0001; + } + ); + // force the WP-Cron fallback. + add_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + + $lost = array(); + set_error_handler( + static function ( $errno, $errstr ) use ( &$lost ) { + if ( false !== strpos( $errstr, 'loses precision' ) ) { + $lost[] = $errstr; + } + return true; + }, + E_DEPRECATED + ); + + try { + $this->setup_module()->ensure_refresh_db_action(); + } finally { + restore_error_handler(); + } + + $this->assertSame( array(), $lost, 'the start time must be a whole second' ); + } + + /** + * A check that scheduled nothing must be retried, not cached. + */ + public function test_a_failed_check_is_retried_on_the_next_request() { + $module = $this->setup_module(); + + // refuse both schedulers. + add_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + add_filter( 'schedule_event', '__return_false' ); + + $module->maybe_reschedule_refresh_db(); + $this->assertFalse( $this->has_trigger(), 'precondition: nothing could be scheduled' ); + + remove_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + remove_filter( 'schedule_event', '__return_false' ); + + $module->maybe_reschedule_refresh_db(); + + $this->assertTrue( $this->has_trigger(), 'a check that scheduled nothing must be retried on the next request' ); + } + + /** + * A live WP-Cron fallback counts as scheduled, so the window still applies. + */ + public function test_a_wp_cron_fallback_is_not_re_attempted_on_every_request() { + $attempts = 0; + $refuse = function () use ( &$attempts ) { + ++$attempts; + return 0; + }; + add_filter( 'pre_as_schedule_recurring_action', $refuse ); + + $module = $this->setup_module(); + $module->maybe_reschedule_refresh_db(); + + $this->assertNotFalse( wp_next_scheduled( self::HOOK ), 'precondition: the fallback is armed' ); + $after_first = $attempts; + + $module->maybe_reschedule_refresh_db(); + remove_filter( 'pre_as_schedule_recurring_action', $refuse ); + + $this->assertSame( $after_first, $attempts, 'a live fallback must not be re-attempted on the next request' ); + } + + /** + * A refused replacement must not take the old WP-Cron event with it. + */ + public function test_a_refused_reschedule_keeps_the_old_wp_cron_event() { + // an event on another interval. + wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::HOOK ); + add_filter( 'pre_as_schedule_recurring_action', '__return_zero' ); + // WP-Cron refuses the replacement. + add_filter( 'schedule_event', '__return_false' ); + + $this->setup_module()->ensure_refresh_db_action(); + + $this->assertNotFalse( wp_next_scheduled( self::HOOK ), 'the old event must survive a refused replacement' ); + } + + /** + * Every pending refresh action. + * + * @return list + */ + private function pending_actions(): array { + return as_get_scheduled_actions( + array( + 'hook' => self::HOOK, + 'group' => self::GROUP, + 'status' => ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + } + + /** + * The registered setup module. + * + * @return Visualizer_Module_Setup + */ + private function setup_module(): Visualizer_Module_Setup { + return Visualizer_Plugin::instance()->getModule( Visualizer_Module_Setup::NAME ); + } +} diff --git a/uninstall.php b/uninstall.php index 5da4a28f..f9c8b7e2 100644 --- a/uninstall.php +++ b/uninstall.php @@ -27,3 +27,4 @@ // clean up after ourselves, that's a good plugin! delete_option( 'visualizer_fresh_install' ); delete_option( 'visualizer_wizard_data' ); +delete_transient( 'visualizer-refresh-db-checked' );