From 839d3fe9698ea43cf40df982a393c5b02f713de1 Mon Sep 17 00:00:00 2001 From: Fernando Morgenstern Date: Thu, 5 Jun 2025 09:07:18 -0300 Subject: [PATCH 1/2] Squashed 'libraries/action-scheduler/' content from commit efbb795 git-subtree-dir: libraries/action-scheduler git-subtree-split: efbb7953f72a433086335b249292f280dd43ddfe --- .editorconfig | 24 + .gitattributes | 14 + .github/release-drafter.yml | 15 + .github/workflows/pr-unit-tests.yml | 97 + .gitignore | 25 + Gruntfile.js | 57 + README.md | 35 + action-scheduler.php | 70 + changelog.txt | 187 + classes/ActionScheduler_ActionClaim.php | 45 + classes/ActionScheduler_ActionFactory.php | 378 ++ classes/ActionScheduler_AdminView.php | 311 + ...tionScheduler_AsyncRequest_QueueRunner.php | 93 + classes/ActionScheduler_Compatibility.php | 111 + classes/ActionScheduler_DataController.php | 201 + classes/ActionScheduler_DateTime.php | 79 + classes/ActionScheduler_Exception.php | 11 + classes/ActionScheduler_FatalErrorMonitor.php | 98 + ...ActionScheduler_InvalidActionException.php | 47 + classes/ActionScheduler_ListTable.php | 675 +++ classes/ActionScheduler_LogEntry.php | 78 + classes/ActionScheduler_NullLogEntry.php | 18 + classes/ActionScheduler_OptionLock.php | 136 + classes/ActionScheduler_QueueCleaner.php | 254 + classes/ActionScheduler_QueueRunner.php | 254 + classes/ActionScheduler_SystemInformation.php | 93 + classes/ActionScheduler_Versions.php | 151 + classes/ActionScheduler_WPCommentCleaner.php | 133 + classes/ActionScheduler_wcSystemStatus.php | 166 + classes/WP_CLI/Action/Cancel_Command.php | 120 + classes/WP_CLI/Action/Create_Command.php | 151 + classes/WP_CLI/Action/Delete_Command.php | 108 + classes/WP_CLI/Action/Generate_Command.php | 121 + classes/WP_CLI/Action/Get_Command.php | 75 + classes/WP_CLI/Action/List_Command.php | 133 + classes/WP_CLI/Action/Next_Command.php | 71 + classes/WP_CLI/Action/Run_Command.php | 194 + .../ActionScheduler_WPCLI_Clean_Command.php | 123 + .../ActionScheduler_WPCLI_QueueRunner.php | 195 + ...ctionScheduler_WPCLI_Scheduler_command.php | 202 + classes/WP_CLI/Action_Command.php | 353 ++ classes/WP_CLI/Migration_Command.php | 190 + classes/WP_CLI/ProgressBar.php | 139 + classes/WP_CLI/System_Command.php | 283 + classes/abstracts/ActionScheduler.php | 400 ++ .../ActionScheduler_Abstract_ListTable.php | 797 +++ .../ActionScheduler_Abstract_QueueRunner.php | 383 ++ ...onScheduler_Abstract_RecurringSchedule.php | 112 + .../ActionScheduler_Abstract_Schedule.php | 89 + .../ActionScheduler_Abstract_Schema.php | 187 + classes/abstracts/ActionScheduler_Lock.php | 74 + classes/abstracts/ActionScheduler_Logger.php | 258 + classes/abstracts/ActionScheduler_Store.php | 506 ++ .../ActionScheduler_TimezoneHelper.php | 162 + .../ActionScheduler_WPCLI_Command.php | 83 + classes/actions/ActionScheduler_Action.php | 191 + .../ActionScheduler_CanceledAction.php | 25 + .../ActionScheduler_FinishedAction.php | 21 + .../actions/ActionScheduler_NullAction.php | 25 + .../data-stores/ActionScheduler_DBLogger.php | 154 + .../data-stores/ActionScheduler_DBStore.php | 1295 +++++ .../ActionScheduler_HybridStore.php | 460 ++ .../ActionScheduler_wpCommentLogger.php | 282 + .../ActionScheduler_wpPostStore.php | 1088 ++++ ...eduler_wpPostStore_PostStatusRegistrar.php | 63 + ...cheduler_wpPostStore_PostTypeRegistrar.php | 53 + ...cheduler_wpPostStore_TaxonomyRegistrar.php | 33 + classes/migration/ActionMigrator.php | 126 + .../ActionScheduler_DBStoreMigrator.php | 52 + classes/migration/BatchFetcher.php | 95 + classes/migration/Config.php | 196 + classes/migration/Controller.php | 245 + classes/migration/DryRun_ActionMigrator.php | 28 + classes/migration/DryRun_LogMigrator.php | 23 + classes/migration/LogMigrator.php | 58 + classes/migration/Runner.php | 171 + classes/migration/Scheduler.php | 128 + .../ActionScheduler_CanceledSchedule.php | 63 + .../ActionScheduler_CronSchedule.php | 111 + .../ActionScheduler_IntervalSchedule.php | 90 + .../ActionScheduler_NullSchedule.php | 39 + .../schedules/ActionScheduler_Schedule.php | 22 + .../ActionScheduler_SimpleSchedule.php | 81 + .../schema/ActionScheduler_LoggerSchema.php | 101 + .../schema/ActionScheduler_StoreSchema.php | 143 + codecov.yml | 13 + composer.json | 52 + composer.lock | 2561 +++++++++ ...eduler_Abstract_QueueRunner_Deprecated.php | 27 + .../ActionScheduler_AdminView_Deprecated.php | 153 + .../ActionScheduler_Schedule_Deprecated.php | 29 + .../ActionScheduler_Store_Deprecated.php | 50 + deprecated/functions.php | 129 + docs/CNAME | 1 + docs/_config.yml | 7 + docs/_layouts/default.html | 59 + docs/admin.md | 22 + docs/android-chrome-192x192.png | Bin 0 -> 6933 bytes docs/android-chrome-256x256.png | Bin 0 -> 2997 bytes docs/api.md | 253 + docs/apple-touch-icon.png | Bin 0 -> 6933 bytes docs/assets/css/style.scss | 57 + docs/browserconfig.xml | 9 + docs/faq.md | 129 + docs/favicon-16x16.png | Bin 0 -> 398 bytes docs/favicon-32x32.png | Bin 0 -> 1189 bytes docs/favicon.ico | Bin 0 -> 184010 bytes docs/google14ef723abb376cd3.html | 1 + docs/index.md | 66 + docs/mstile-150x150.png | Bin 0 -> 4246 bytes docs/perf.md | 149 + docs/safari-pinned-tab.svg | 40 + docs/site.webmanifest | 19 + docs/usage.md | 154 + docs/version3-0.md | 68 + docs/wp-cli.md | 100 + functions.php | 495 ++ lib/WP_Async_Request.php | 188 + lib/cron-expression/CronExpression.php | 318 ++ .../CronExpression_AbstractField.php | 100 + .../CronExpression_DayOfMonthField.php | 110 + .../CronExpression_DayOfWeekField.php | 124 + .../CronExpression_FieldFactory.php | 55 + .../CronExpression_FieldInterface.php | 39 + .../CronExpression_HoursField.php | 47 + .../CronExpression_MinutesField.php | 39 + .../CronExpression_MonthField.php | 55 + .../CronExpression_YearField.php | 43 + lib/cron-expression/LICENSE | 19 + lib/cron-expression/README.md | 92 + license.txt | 674 +++ package-lock.json | 4995 +++++++++++++++++ package.json | 43 + phpcs.xml | 63 + readme.txt | 231 + tests/ActionScheduler_UnitTestCase.php | 70 + tests/README.md | 20 + tests/bin/install.sh | 181 + tests/bootstrap.php | 37 + tests/phpunit.xml.dist | 48 + ...heduler_Mock_Async_Request_QueueRunner.php | 16 + tests/phpunit/ActionScheduler_Mocker.php | 32 + .../ActionScheduler_UnitTestCase.php | 71 + .../helpers/ActionScheduler_Callbacks.php | 34 + .../ActionScheduler_Compatibility_Test.php | 48 + .../ActionScheduler_TimezoneHelper_Test.php | 110 + .../jobs/ActionScheduler_Action_Test.php | 54 + .../jobs/ActionScheduler_NullAction_Test.php | 15 + tests/phpunit/jobstore/AbstractStoreTest.php | 163 + .../ActionScheduler_DBStoreMigrator_Test.php | 26 + .../jobstore/ActionScheduler_DBStore_Test.php | 722 +++ .../ActionScheduler_HybridStore_Test.php | 277 + .../ActionScheduler_wpPostStore_Test.php | 474 ++ .../lock/ActionScheduler_OptionLock_Test.php | 77 + .../logging/ActionScheduler_DBLogger_Test.php | 134 + .../ActionScheduler_wpCommentLogger_Test.php | 241 + .../phpunit/migration/ActionMigrator_Test.php | 144 + tests/phpunit/migration/BatchFetcher_Test.php | 75 + tests/phpunit/migration/Config_Test.php | 33 + tests/phpunit/migration/Controller_Test.php | 76 + tests/phpunit/migration/LogMigrator_Test.php | 52 + tests/phpunit/migration/Runner_Test.php | 95 + tests/phpunit/migration/Scheduler_Test.php | 138 + .../procedural_api/procedural_api_Test.php | 484 ++ .../wc_get_scheduled_actions_Test.php | 131 + .../ActionScheduler_QueueCleaner_Test.php | 173 + .../ActionScheduler_QueueRunner_Test.php | 613 ++ .../ActionScheduler_CronSchedule_Test.php | 75 + .../ActionScheduler_IntervalSchedule_Test.php | 37 + .../ActionScheduler_NullSchedule_Test.php | 17 + .../ActionScheduler_SimpleSchedule_Test.php | 36 + .../ActionScheduler_Versions_Test.php | 42 + 172 files changed, 31776 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/pr-unit-tests.yml create mode 100644 .gitignore create mode 100644 Gruntfile.js create mode 100644 README.md create mode 100644 action-scheduler.php create mode 100644 changelog.txt create mode 100644 classes/ActionScheduler_ActionClaim.php create mode 100644 classes/ActionScheduler_ActionFactory.php create mode 100644 classes/ActionScheduler_AdminView.php create mode 100644 classes/ActionScheduler_AsyncRequest_QueueRunner.php create mode 100644 classes/ActionScheduler_Compatibility.php create mode 100644 classes/ActionScheduler_DataController.php create mode 100644 classes/ActionScheduler_DateTime.php create mode 100644 classes/ActionScheduler_Exception.php create mode 100644 classes/ActionScheduler_FatalErrorMonitor.php create mode 100644 classes/ActionScheduler_InvalidActionException.php create mode 100644 classes/ActionScheduler_ListTable.php create mode 100644 classes/ActionScheduler_LogEntry.php create mode 100644 classes/ActionScheduler_NullLogEntry.php create mode 100644 classes/ActionScheduler_OptionLock.php create mode 100644 classes/ActionScheduler_QueueCleaner.php create mode 100644 classes/ActionScheduler_QueueRunner.php create mode 100644 classes/ActionScheduler_SystemInformation.php create mode 100644 classes/ActionScheduler_Versions.php create mode 100644 classes/ActionScheduler_WPCommentCleaner.php create mode 100644 classes/ActionScheduler_wcSystemStatus.php create mode 100644 classes/WP_CLI/Action/Cancel_Command.php create mode 100644 classes/WP_CLI/Action/Create_Command.php create mode 100644 classes/WP_CLI/Action/Delete_Command.php create mode 100644 classes/WP_CLI/Action/Generate_Command.php create mode 100644 classes/WP_CLI/Action/Get_Command.php create mode 100644 classes/WP_CLI/Action/List_Command.php create mode 100644 classes/WP_CLI/Action/Next_Command.php create mode 100644 classes/WP_CLI/Action/Run_Command.php create mode 100644 classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php create mode 100644 classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php create mode 100644 classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php create mode 100644 classes/WP_CLI/Action_Command.php create mode 100644 classes/WP_CLI/Migration_Command.php create mode 100644 classes/WP_CLI/ProgressBar.php create mode 100644 classes/WP_CLI/System_Command.php create mode 100644 classes/abstracts/ActionScheduler.php create mode 100644 classes/abstracts/ActionScheduler_Abstract_ListTable.php create mode 100644 classes/abstracts/ActionScheduler_Abstract_QueueRunner.php create mode 100644 classes/abstracts/ActionScheduler_Abstract_RecurringSchedule.php create mode 100644 classes/abstracts/ActionScheduler_Abstract_Schedule.php create mode 100644 classes/abstracts/ActionScheduler_Abstract_Schema.php create mode 100644 classes/abstracts/ActionScheduler_Lock.php create mode 100644 classes/abstracts/ActionScheduler_Logger.php create mode 100644 classes/abstracts/ActionScheduler_Store.php create mode 100644 classes/abstracts/ActionScheduler_TimezoneHelper.php create mode 100644 classes/abstracts/ActionScheduler_WPCLI_Command.php create mode 100644 classes/actions/ActionScheduler_Action.php create mode 100644 classes/actions/ActionScheduler_CanceledAction.php create mode 100644 classes/actions/ActionScheduler_FinishedAction.php create mode 100644 classes/actions/ActionScheduler_NullAction.php create mode 100644 classes/data-stores/ActionScheduler_DBLogger.php create mode 100644 classes/data-stores/ActionScheduler_DBStore.php create mode 100644 classes/data-stores/ActionScheduler_HybridStore.php create mode 100644 classes/data-stores/ActionScheduler_wpCommentLogger.php create mode 100644 classes/data-stores/ActionScheduler_wpPostStore.php create mode 100644 classes/data-stores/ActionScheduler_wpPostStore_PostStatusRegistrar.php create mode 100644 classes/data-stores/ActionScheduler_wpPostStore_PostTypeRegistrar.php create mode 100644 classes/data-stores/ActionScheduler_wpPostStore_TaxonomyRegistrar.php create mode 100644 classes/migration/ActionMigrator.php create mode 100644 classes/migration/ActionScheduler_DBStoreMigrator.php create mode 100644 classes/migration/BatchFetcher.php create mode 100644 classes/migration/Config.php create mode 100644 classes/migration/Controller.php create mode 100644 classes/migration/DryRun_ActionMigrator.php create mode 100644 classes/migration/DryRun_LogMigrator.php create mode 100644 classes/migration/LogMigrator.php create mode 100644 classes/migration/Runner.php create mode 100644 classes/migration/Scheduler.php create mode 100644 classes/schedules/ActionScheduler_CanceledSchedule.php create mode 100644 classes/schedules/ActionScheduler_CronSchedule.php create mode 100644 classes/schedules/ActionScheduler_IntervalSchedule.php create mode 100644 classes/schedules/ActionScheduler_NullSchedule.php create mode 100644 classes/schedules/ActionScheduler_Schedule.php create mode 100644 classes/schedules/ActionScheduler_SimpleSchedule.php create mode 100644 classes/schema/ActionScheduler_LoggerSchema.php create mode 100644 classes/schema/ActionScheduler_StoreSchema.php create mode 100644 codecov.yml create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 deprecated/ActionScheduler_Abstract_QueueRunner_Deprecated.php create mode 100644 deprecated/ActionScheduler_AdminView_Deprecated.php create mode 100644 deprecated/ActionScheduler_Schedule_Deprecated.php create mode 100644 deprecated/ActionScheduler_Store_Deprecated.php create mode 100644 deprecated/functions.php create mode 100644 docs/CNAME create mode 100644 docs/_config.yml create mode 100644 docs/_layouts/default.html create mode 100644 docs/admin.md create mode 100644 docs/android-chrome-192x192.png create mode 100644 docs/android-chrome-256x256.png create mode 100644 docs/api.md create mode 100644 docs/apple-touch-icon.png create mode 100644 docs/assets/css/style.scss create mode 100644 docs/browserconfig.xml create mode 100644 docs/faq.md create mode 100644 docs/favicon-16x16.png create mode 100644 docs/favicon-32x32.png create mode 100644 docs/favicon.ico create mode 100644 docs/google14ef723abb376cd3.html create mode 100644 docs/index.md create mode 100644 docs/mstile-150x150.png create mode 100644 docs/perf.md create mode 100644 docs/safari-pinned-tab.svg create mode 100644 docs/site.webmanifest create mode 100644 docs/usage.md create mode 100644 docs/version3-0.md create mode 100644 docs/wp-cli.md create mode 100644 functions.php create mode 100644 lib/WP_Async_Request.php create mode 100644 lib/cron-expression/CronExpression.php create mode 100644 lib/cron-expression/CronExpression_AbstractField.php create mode 100644 lib/cron-expression/CronExpression_DayOfMonthField.php create mode 100644 lib/cron-expression/CronExpression_DayOfWeekField.php create mode 100644 lib/cron-expression/CronExpression_FieldFactory.php create mode 100644 lib/cron-expression/CronExpression_FieldInterface.php create mode 100644 lib/cron-expression/CronExpression_HoursField.php create mode 100644 lib/cron-expression/CronExpression_MinutesField.php create mode 100644 lib/cron-expression/CronExpression_MonthField.php create mode 100644 lib/cron-expression/CronExpression_YearField.php create mode 100644 lib/cron-expression/LICENSE create mode 100644 lib/cron-expression/README.md create mode 100644 license.txt create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpcs.xml create mode 100644 readme.txt create mode 100644 tests/ActionScheduler_UnitTestCase.php create mode 100644 tests/README.md create mode 100755 tests/bin/install.sh create mode 100644 tests/bootstrap.php create mode 100644 tests/phpunit.xml.dist create mode 100644 tests/phpunit/ActionScheduler_Mock_Async_Request_QueueRunner.php create mode 100644 tests/phpunit/ActionScheduler_Mocker.php create mode 100644 tests/phpunit/deprecated/ActionScheduler_UnitTestCase.php create mode 100644 tests/phpunit/helpers/ActionScheduler_Callbacks.php create mode 100644 tests/phpunit/helpers/ActionScheduler_Compatibility_Test.php create mode 100644 tests/phpunit/helpers/ActionScheduler_TimezoneHelper_Test.php create mode 100644 tests/phpunit/jobs/ActionScheduler_Action_Test.php create mode 100644 tests/phpunit/jobs/ActionScheduler_NullAction_Test.php create mode 100644 tests/phpunit/jobstore/AbstractStoreTest.php create mode 100644 tests/phpunit/jobstore/ActionScheduler_DBStoreMigrator_Test.php create mode 100644 tests/phpunit/jobstore/ActionScheduler_DBStore_Test.php create mode 100644 tests/phpunit/jobstore/ActionScheduler_HybridStore_Test.php create mode 100644 tests/phpunit/jobstore/ActionScheduler_wpPostStore_Test.php create mode 100644 tests/phpunit/lock/ActionScheduler_OptionLock_Test.php create mode 100644 tests/phpunit/logging/ActionScheduler_DBLogger_Test.php create mode 100644 tests/phpunit/logging/ActionScheduler_wpCommentLogger_Test.php create mode 100644 tests/phpunit/migration/ActionMigrator_Test.php create mode 100644 tests/phpunit/migration/BatchFetcher_Test.php create mode 100644 tests/phpunit/migration/Config_Test.php create mode 100644 tests/phpunit/migration/Controller_Test.php create mode 100644 tests/phpunit/migration/LogMigrator_Test.php create mode 100644 tests/phpunit/migration/Runner_Test.php create mode 100644 tests/phpunit/migration/Scheduler_Test.php create mode 100644 tests/phpunit/procedural_api/procedural_api_Test.php create mode 100644 tests/phpunit/procedural_api/wc_get_scheduled_actions_Test.php create mode 100644 tests/phpunit/runner/ActionScheduler_QueueCleaner_Test.php create mode 100644 tests/phpunit/runner/ActionScheduler_QueueRunner_Test.php create mode 100644 tests/phpunit/schedules/ActionScheduler_CronSchedule_Test.php create mode 100644 tests/phpunit/schedules/ActionScheduler_IntervalSchedule_Test.php create mode 100644 tests/phpunit/schedules/ActionScheduler_NullSchedule_Test.php create mode 100644 tests/phpunit/schedules/ActionScheduler_SimpleSchedule_Test.php create mode 100644 tests/phpunit/versioning/ActionScheduler_Versions_Test.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c3dfa83 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# WordPress Coding Standards +# https://make.wordpress.org/core/handbook/coding-standards/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +tab_width = 4 +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true + +[*.txt] +trim_trailing_whitespace = false + +[*.{md,json,yml}] +trim_trailing_whitespace = false +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a7666ea --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +docs export-ignore +tests export-ignore +codecov.yml export-ignore +.editorconfig export-ignore +.github export-ignore +.travis.yml export-ignore +.gitattributes export-ignore +.gitignore export-ignore +composer.* export-ignore +Gruntfile.js export-ignore +package.json export-ignore +package-lock.json export-ignore +phpcs.xml export-ignore +phpunit.* export-ignore diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..702a690 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,15 @@ +template: | + ## next release – date + + + + $CHANGES + + **Added** + **Changed** + **Deprecated** + **Removed** + **Fixed** + **Security** + +change-template: '* $TITLE (PR #$NUMBER)' diff --git a/.github/workflows/pr-unit-tests.yml b/.github/workflows/pr-unit-tests.yml new file mode 100644 index 0000000..c86ba59 --- /dev/null +++ b/.github/workflows/pr-unit-tests.yml @@ -0,0 +1,97 @@ +name: Run unit tests on PR +on: + pull_request +jobs: + test: + name: PHP ${{ matrix.php }} WP ${{ matrix.wp }} MU ${{ matrix.multisite }} + timeout-minutes: 15 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # We test against the earliest and latest PHP versions for each major supported version. + php: [ '7.1', '7.4', '8.0', '8.3' ] + wp: [ '6.4', '6.5', '6.6', 'latest', 'nightly' ] + multisite: [ '0', '1' ] + exclude: + # WordPress 6.6+ requires PHP 7.2+ + - php: 7.1 + wp: 6.6 + - php: 7.1 + wp: latest + - php: 7.1 + wp: nightly + services: + database: + image: mysql:5.6 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer + extensions: mysql + coverage: none + + - name: Tool versions + run: | + php --version + composer --version + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist + + - name: Setup PHPUnit + run: | + # PHPUnit 7.5 when using PHP 7.1 - 7.4. + if [ "$(php -r "echo version_compare( PHP_VERSION, '8.0', '<' );")" ]; then + curl -L https://phar.phpunit.de/phpunit-7.5.phar -o /tmp/phpunit + OVERWRITE=1 + # PHPUnit 7.5 (Custom Fork) when using PHP 8.0+. + else + curl -L https://github.com/woocommerce/phpunit/archive/add-compatibility-with-php8-to-phpunit-7.zip -o /tmp/phpunit-7.5-fork.zip + unzip -d /tmp/phpunit-fork /tmp/phpunit-7.5-fork.zip + composer --working-dir=/tmp/phpunit-fork/phpunit-add-compatibility-with-php8-to-phpunit-7 install + rm ./vendor/bin/phpunit + ln -sf /tmp/phpunit-fork/phpunit-add-compatibility-with-php8-to-phpunit-7/phpunit ./vendor/bin/phpunit + fi + + if [ $OVERWRITE ]; then + rm ./vendor/bin/phpunit + chmod +x /tmp/phpunit + mv /tmp/phpunit ./vendor/bin/phpunit + fi + + - name: Install Subversion + run: sudo apt-get update && sudo apt-get install -y subversion + + - name: Init DB and WP + run: ./tests/bin/install.sh woo_test root root 127.0.0.1 ${{ matrix.wp }} + + - name: Run tests + run: | + ./vendor/bin/phpunit --version + WP_MULTISITE=${{ matrix.multisite }} ./vendor/bin/phpunit -c ./tests/phpunit.xml.dist + + - name: Code Coverage + run: | + bash <(curl -s https://codecov.io/bash) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6716d1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Operating System files +.DS_Store +Thumbs.db + +# IDE files +.idea +.vscode/* +project.xml +project.properties +.project +.settings* +*.sublime-project +*.sublime-workspace +.sublimelinterrc + +# Project files +node_modules/ +vendor/ + +#PHP Unit +.phpunit.result.cache +phpunit.xml + +# Build files +action-scheduler.zip diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..4477049 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,57 @@ +module.exports = function( grunt ) { + 'use strict'; + + grunt.initConfig({ + // Check textdomain errors. + checktextdomain: { + options:{ + text_domain: 'action-scheduler', + keywords: [ + '__:1,2d', + '_e:1,2d', + '_x:1,2c,3d', + 'esc_html__:1,2d', + 'esc_html_e:1,2d', + 'esc_html_x:1,2c,3d', + 'esc_attr__:1,2d', + 'esc_attr_e:1,2d', + 'esc_attr_x:1,2c,3d', + '_ex:1,2c,3d', + '_n:1,2,4d', + '_nx:1,2,4c,5d', + '_n_noop:1,2,3d', + '_nx_noop:1,2,3c,4d' + ] + }, + files: { + src: [ + '**/*.php', + '!node_modules/**', + '!tests/**', + '!vendor/**', + '!tmp/**' + ], + expand: true + } + }, + + // PHP Code Sniffer. + phpcs: { + options: { + bin: 'vendor/bin/phpcs' + }, + dist: { + src: [ + '**/*.php', // Include all php files. + '!deprecated/**', + '!node_modules/**', + '!vendor/**' + ] + } + } + }); + + // Load NPM tasks to be used here. + grunt.loadNpmTasks( 'grunt-phpcs' ); + grunt.loadNpmTasks( 'grunt-checktextdomain' ); +}; diff --git a/README.md b/README.md new file mode 100644 index 0000000..09478b0 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Action Scheduler - Job Queue for WordPress [![Build Status](https://travis-ci.org/woocommerce/action-scheduler.png?branch=master)](https://travis-ci.org/woocommerce/action-scheduler) [![codecov](https://codecov.io/gh/woocommerce/action-scheduler/branch/master/graph/badge.svg)](https://codecov.io/gh/woocommerce/action-scheduler) + +Action Scheduler is a scalable, traceable job queue for background processing large sets of actions in WordPress. It's specially designed to be distributed in WordPress plugins. + +Action Scheduler works by triggering an action hook to run at some time in the future. Each hook can be scheduled with unique data, to allow callbacks to perform operations on that data. The hook can also be scheduled to run on one or more occasions. + +Think of it like an extension to `do_action()` which adds the ability to delay and repeat a hook. + +## Battle-Tested Background Processing + +Every month, Action Scheduler processes millions of payments for [Subscriptions](https://woocommerce.com/products/woocommerce-subscriptions/), webhooks for [WooCommerce](https://wordpress.org/plugins/woocommerce/), as well as emails and other events for a range of other plugins. + +It's been seen on live sites processing queues in excess of 50,000 jobs and doing resource intensive operations, like processing payments and creating orders, at a sustained rate of over 10,000 / hour without negatively impacting normal site operations. + +This is all on infrastructure and WordPress sites outside the control of the plugin author. + +If your plugin needs background processing, especially of large sets of tasks, Action Scheduler can help. + +## Learn More + +To learn more about how to Action Scheduler works, and how to use it in your plugin, check out the docs on [ActionScheduler.org](https://actionscheduler.org). + +There you will find: + +* [Usage guide](https://actionscheduler.org/usage/): instructions on installing and using Action Scheduler +* [WP CLI guide](https://actionscheduler.org/wp-cli/): instructions on running Action Scheduler at scale via WP CLI +* [API Reference](https://actionscheduler.org/api/): complete reference guide for all API functions +* [Administration Guide](https://actionscheduler.org/admin/): guide to managing scheduled actions via the administration screen +* [Guide to Background Processing at Scale](https://actionscheduler.org/perf/): instructions for running Action Scheduler at scale via the default WP Cron queue runner + +## Credits + +Action Scheduler is developed and maintained by [Automattic](http://automattic.com/) with significant early development completed by [Flightless](https://flightless.us/). + +Collaboration is cool. We'd love to work with you to improve Action Scheduler. [Pull Requests](https://github.com/woocommerce/action-scheduler/pulls) welcome. diff --git a/action-scheduler.php b/action-scheduler.php new file mode 100644 index 0000000..ef12ca2 --- /dev/null +++ b/action-scheduler.php @@ -0,0 +1,70 @@ +. + * + * @package ActionScheduler + */ + +if ( ! function_exists( 'action_scheduler_register_3_dot_9_dot_2' ) && function_exists( 'add_action' ) ) { // WRCS: DEFINED_VERSION. + + if ( ! class_exists( 'ActionScheduler_Versions', false ) ) { + require_once __DIR__ . '/classes/ActionScheduler_Versions.php'; + add_action( 'plugins_loaded', array( 'ActionScheduler_Versions', 'initialize_latest_version' ), 1, 0 ); + } + + add_action( 'plugins_loaded', 'action_scheduler_register_3_dot_9_dot_2', 0, 0 ); // WRCS: DEFINED_VERSION. + + // phpcs:disable Generic.Functions.OpeningFunctionBraceKernighanRitchie.ContentAfterBrace + /** + * Registers this version of Action Scheduler. + */ + function action_scheduler_register_3_dot_9_dot_2() { // WRCS: DEFINED_VERSION. + $versions = ActionScheduler_Versions::instance(); + $versions->register( '3.9.2', 'action_scheduler_initialize_3_dot_9_dot_2' ); // WRCS: DEFINED_VERSION. + } + + // phpcs:disable Generic.Functions.OpeningFunctionBraceKernighanRitchie.ContentAfterBrace + /** + * Initializes this version of Action Scheduler. + */ + function action_scheduler_initialize_3_dot_9_dot_2() { // WRCS: DEFINED_VERSION. + // A final safety check is required even here, because historic versions of Action Scheduler + // followed a different pattern (in some unusual cases, we could reach this point and the + // ActionScheduler class is already defined—so we need to guard against that). + if ( ! class_exists( 'ActionScheduler', false ) ) { + require_once __DIR__ . '/classes/abstracts/ActionScheduler.php'; + ActionScheduler::init( __FILE__ ); + } + } + + // Support usage in themes - load this version if no plugin has loaded a version yet. + if ( did_action( 'plugins_loaded' ) && ! doing_action( 'plugins_loaded' ) && ! class_exists( 'ActionScheduler', false ) ) { + action_scheduler_initialize_3_dot_9_dot_2(); // WRCS: DEFINED_VERSION. + do_action( 'action_scheduler_pre_theme_init' ); + ActionScheduler_Versions::initialize_latest_version(); + } +} diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..43ca782 --- /dev/null +++ b/changelog.txt @@ -0,0 +1,187 @@ +*** Changelog *** + += 3.9.2 - 2025-02-03 = +* Fixed fatal errors by moving version info methods to a new class and deprecating conflicting ones in ActionScheduler_Versions + += 3.9.1 - 2025-01-21 = +* A number of new WP CLI commands have been added, making it easier to manage actions in the terminal and from scripts. +* New wp action-scheduler source command to help determine how Action Scheduler is being loaded. +* Additional information about the active instance of Action Scheduler is now available in the Help pull-down drawer. +* Make some other nullable parameters explicitly nullable. +* Set option value to `no` rather than deleting. + += 3.9.0 - 2024-11-14 = +* Minimum required version of PHP is now 7.1. +* Performance improvements for the `as_pending_actions_due()` function. +* Existing filter hook `action_scheduler_claim_actions_order_by` enhanced to provide callbacks with additional information. +* Improved compatibility with PHP 8.4, specifically by making implicitly nullable parameters explicitly nullable. +* A large number of coding standards-enhancements, to help reduce friction when submitting plugins to marketplaces and plugin directories. Special props @crstauf for this effort. +* Minor documentation tweaks and improvements. + += 3.8.2 - 2024-09-12 = +* Add missing parameter to the `pre_as_enqueue_async_action` hook. +* Bump minimum PHP version to 7.0. +* Bump minimum WordPress version to 6.4. +* Make the batch size adjustable during processing. + += 3.8.1 - 2024-06-20 = +* Fix typos. +* Improve the messaging in our unidentified action exceptions. + += 3.8.0 - 2024-05-22 = +* Documentation - Fixed typos in perf.md. +* Update - We now require WordPress 6.3 or higher. +* Update - We now require PHP 7.0 or higher. + += 3.7.4 - 2024-04-05 = +* Give a clear description of how the $unique parameter works. +* Preserve the tab field if set. +* Tweak - WP 6.5 compatibility. + += 3.7.3 - 2024-03-20 = +* Do not iterate over all of GET when building form in list table. +* Fix a few issues reported by PCP (Plugin Check Plugin). +* Try to save actions as unique even when the store doesn't support it. +* Tweak - WP 6.4 compatibility. +* Update "Tested up to" tag to WordPress 6.5. +* update version in package-lock.json. + += 3.7.2 - 2024-02-14 = +* No longer user variables in `_n()` translation function. + += 3.7.1 - 2023-12-13 = +* update semver to 5.7.2 because of a security vulnerability in 5.7.1. + += 3.7.0 - 2023-11-20 = +* Important: starting with this release, Action Scheduler follows an L-2 version policy (WordPress, and consequently PHP). +* Add extended indexes for hook_status_scheduled_date_gmt and status_scheduled_date_gmt. +* Catch and log exceptions thrown when actions can't be created, e.g. under a corrupt database schema. +* Tweak - WP 6.4 compatibility. +* Update unit tests for upcoming dependency version policy. +* make sure hook action_scheduler_failed_execution can access original exception object. +* mention dependency version policy in usage.md. + += 3.6.4 - 2023-10-11 = +* Performance improvements when bulk cancelling actions. +* Dev-related fixes. + += 3.6.3 - 2023-09-13 = +* Use `_doing_it_wrong` in initialization check. + += 3.6.2 - 2023-08-09 = +* Add guidance about passing arguments. +* Atomic option locking. +* Improve bulk delete handling. +* Include database error in the exception message. +* Tweak - WP 6.3 compatibility. + += 3.6.1 - 2023-06-14 = +* Document new optional `$priority` arg for various API functions. +* Document the new `--exclude-groups` WP CLI option. +* Document the new `action_scheduler_init` hook. +* Ensure actions within each claim are executed in the expected order. +* Fix incorrect text domain. +* Remove SHOW TABLES usage when checking if tables exist. + += 3.6.0 - 2023-05-10 = +* Add $unique parameter to function signatures. +* Add a cast-to-int for extra safety before forming new DateTime object. +* Add a hook allowing exceptions for consistently failing recurring actions. +* Add action priorities. +* Add init hook. +* Always raise the time limit. +* Bump minimatch from 3.0.4 to 3.0.8. +* Bump yaml from 2.2.1 to 2.2.2. +* Defensive coding relating to gaps in declared schedule types. +* Do not process an action if it cannot be set to `in-progress`. +* Filter view labels (status names) should be translatable | #919. +* Fix WPCLI progress messages. +* Improve data-store initialization flow. +* Improve error handling across all supported PHP versions. +* Improve logic for flushing the runtime cache. +* Support exclusion of multiple groups. +* Update lint-staged and Node/NPM requirements. +* add CLI clean command. +* add CLI exclude-group filter. +* exclude past-due from list table all filter count. +* throwing an exception if as_schedule_recurring_action interval param is not of type integer. + += 3.5.4 - 2023-01-17 = +* Add pre filters during action registration. +* Async scheduling. +* Calculate timeouts based on total actions. +* Correctly order the parameters for `ActionScheduler_ActionFactory`'s calls to `single_unique`. +* Fetch action in memory first before releasing claim to avoid deadlock. +* PHP 8.2: declare property to fix creation of dynamic property warning. +* PHP 8.2: fix "Using ${var} in strings is deprecated, use {$var} instead". +* Prevent `undefined variable` warning for `$num_pastdue_actions`. + += 3.5.3 - 2022-11-09 = +* Query actions with partial match. + += 3.5.2 - 2022-09-16 = +* Fix - erroneous 3.5.1 release. + += 3.5.1 - 2022-09-13 = +* Maintenance on A/S docs. +* fix: PHP 8.2 deprecated notice. + += 3.5.0 - 2022-08-25 = +* Add - The active view link within the "Tools > Scheduled Actions" screen is now clickable. +* Add - A warning when there are past-due actions. +* Enhancement - Added the ability to schedule unique actions via an atomic operation. +* Enhancement - Improvements to cache invalidation when processing batches (when running on WordPress 6.0+). +* Enhancement - If a recurring action is found to be consistently failing, it will stop being rescheduled. +* Enhancement - Adds a new "Past Due" view to the scheduled actions list table. + += 3.4.2 - 2022-06-08 = +* Fix - Change the include for better linting. +* Fix - update: Added Action scheduler completed action hook. + += 3.4.1 - 2022-05-24 = +* Fix - Change the include for better linting. +* Fix - Fix the documented return type. + += 3.4.0 - 2021-10-29 = +* Enhancement - Number of items per page can now be set for the Scheduled Actions view (props @ovidiul). #771 +* Fix - Do not lower the max_execution_time if it is already set to 0 (unlimited) (props @barryhughes). #755 +* Fix - Avoid triggering autoloaders during the version resolution process (props @olegabr). #731 & #776 +* Dev - ActionScheduler_wcSystemStatus PHPCS fixes (props @ovidiul). #761 +* Dev - ActionScheduler_DBLogger.php PHPCS fixes (props @ovidiul). #768 +* Dev - Fixed phpcs for ActionScheduler_Schedule_Deprecated (props @ovidiul). #762 +* Dev - Improve actions table indices (props @glagonikas). #774 & #777 +* Dev - PHPCS fixes for ActionScheduler_DBStore.php (props @ovidiul). #769 & #778 +* Dev - PHPCS Fixes for ActionScheduler_Abstract_ListTable (props @ovidiul). #763 & #779 +* Dev - Adds new filter action_scheduler_claim_actions_order_by to allow tuning of the claim query (props @glagonikas). #773 +* Dev - PHPCS fixes for ActionScheduler_WpPostStore class (props @ovidiul). #780 + += 3.3.0 - 2021-09-15 = +* Enhancement - Adds as_has_scheduled_action() to provide a performant way to test for existing actions. #645 +* Fix - Improves compatibility with environments where NO_ZERO_DATE is enabled. #519 +* Fix - Adds safety checks to guard against errors when our database tables cannot be created. #645 +* Dev - Now supports queries that use multiple statuses. #649 +* Dev - Minimum requirements for WordPress and PHP bumped (to 5.2 and 5.6 respectively). #723 + += 3.2.1 - 2021-06-21 = +* Fix - Add extra safety/account for different versions of AS and different loading patterns. #714 +* Fix - Handle hidden columns (Tools → Scheduled Actions) | #600. + += 3.2.0 - 2021-06-03 = +* Fix - Add "no ordering" option to as_next_scheduled_action(). +* Fix - Add secondary scheduled date checks when claiming actions (DBStore) | #634. +* Fix - Add secondary scheduled date checks when claiming actions (wpPostStore) | #634. +* Fix - Adds a new index to the action table, reducing the potential for deadlocks (props: @glagonikas). +* Fix - Fix unit tests infrastructure and adapt tests to PHP 8. +* Fix - Identify in-use data store. +* Fix - Improve test_migration_is_scheduled. +* Fix - PHP notice on list table. +* Fix - Speed up clean up and batch selects. +* Fix - Update pending dependencies. +* Fix - [PHP 8.0] Only pass action arg values through to do_action_ref_array(). +* Fix - [PHP 8] Set the PHP version to 7.1 in composer.json for PHP 8 compatibility. +* Fix - add is_initialized() to docs. +* Fix - fix file permissions. +* Fix - fixes #664 by replacing __ with esc_html__. + += 3.1.6 - 2020-05-12 = +* Change log starts. diff --git a/classes/ActionScheduler_ActionClaim.php b/classes/ActionScheduler_ActionClaim.php new file mode 100644 index 0000000..8eb01ea --- /dev/null +++ b/classes/ActionScheduler_ActionClaim.php @@ -0,0 +1,45 @@ +id = $id; + $this->action_ids = $action_ids; + } + + /** + * Get claim ID. + */ + public function get_id() { + return $this->id; + } + + /** + * Get IDs of claimed actions. + */ + public function get_actions() { + return $this->action_ids; + } +} diff --git a/classes/ActionScheduler_ActionFactory.php b/classes/ActionScheduler_ActionFactory.php new file mode 100644 index 0000000..ea60d60 --- /dev/null +++ b/classes/ActionScheduler_ActionFactory.php @@ -0,0 +1,378 @@ += 6 ? (int) func_get_arg( 5 ) : 10; + + switch ( $status ) { + case ActionScheduler_Store::STATUS_PENDING: + $action_class = 'ActionScheduler_Action'; + break; + case ActionScheduler_Store::STATUS_CANCELED: + $action_class = 'ActionScheduler_CanceledAction'; + if ( ! is_null( $schedule ) && ! is_a( $schedule, 'ActionScheduler_CanceledSchedule' ) && ! is_a( $schedule, 'ActionScheduler_NullSchedule' ) ) { + $schedule = new ActionScheduler_CanceledSchedule( $schedule->get_date() ); + } + break; + default: + $action_class = 'ActionScheduler_FinishedAction'; + break; + } + + $action_class = apply_filters( 'action_scheduler_stored_action_class', $action_class, $status, $hook, $args, $schedule, $group ); + + $action = new $action_class( $hook, $args, $schedule, $group ); + $action->set_priority( $priority ); + + /** + * Allow 3rd party code to change the instantiated action for a given hook, args, schedule and group. + * + * @param ActionScheduler_Action $action The instantiated action. + * @param string $hook The instantiated action's hook. + * @param array $args The instantiated action's args. + * @param ActionScheduler_Schedule $schedule The instantiated action's schedule. + * @param string $group The instantiated action's group. + * @param int $priority The action priority. + */ + return apply_filters( 'action_scheduler_stored_action_instance', $action, $hook, $args, $schedule, $group, $priority ); + } + + /** + * Enqueue an action to run one time, as soon as possible (rather a specific scheduled time). + * + * This method creates a new action using the NullSchedule. In practice, this results in an action scheduled to + * execute "now". Therefore, it will generally run as soon as possible but is not prioritized ahead of other actions + * that are already past-due. + * + * @param string $hook The hook to trigger when this action runs. + * @param array $args Args to pass when the hook is triggered. + * @param string $group A group to put the action in. + * + * @return int The ID of the stored action. + */ + public function async( $hook, $args = array(), $group = '' ) { + return $this->async_unique( $hook, $args, $group, false ); + } + + /** + * Same as async, but also supports $unique param. + * + * @param string $hook The hook to trigger when this action runs. + * @param array $args Args to pass when the hook is triggered. + * @param string $group A group to put the action in. + * @param bool $unique Whether to ensure the action is unique. + * + * @return int The ID of the stored action. + */ + public function async_unique( $hook, $args = array(), $group = '', $unique = true ) { + $schedule = new ActionScheduler_NullSchedule(); + $action = new ActionScheduler_Action( $hook, $args, $schedule, $group ); + return $unique ? $this->store_unique_action( $action, $unique ) : $this->store( $action ); + } + + /** + * Create single action. + * + * @param string $hook The hook to trigger when this action runs. + * @param array $args Args to pass when the hook is triggered. + * @param int $when Unix timestamp when the action will run. + * @param string $group A group to put the action in. + * + * @return int The ID of the stored action. + */ + public function single( $hook, $args = array(), $when = null, $group = '' ) { + return $this->single_unique( $hook, $args, $when, $group, false ); + } + + /** + * Create single action only if there is no pending or running action with same name and params. + * + * @param string $hook The hook to trigger when this action runs. + * @param array $args Args to pass when the hook is triggered. + * @param int $when Unix timestamp when the action will run. + * @param string $group A group to put the action in. + * @param bool $unique Whether action scheduled should be unique. + * + * @return int The ID of the stored action. + */ + public function single_unique( $hook, $args = array(), $when = null, $group = '', $unique = true ) { + $date = as_get_datetime_object( $when ); + $schedule = new ActionScheduler_SimpleSchedule( $date ); + $action = new ActionScheduler_Action( $hook, $args, $schedule, $group ); + return $unique ? $this->store_unique_action( $action ) : $this->store( $action ); + } + + /** + * Create the first instance of an action recurring on a given interval. + * + * @param string $hook The hook to trigger when this action runs. + * @param array $args Args to pass when the hook is triggered. + * @param int $first Unix timestamp for the first run. + * @param int $interval Seconds between runs. + * @param string $group A group to put the action in. + * + * @return int The ID of the stored action. + */ + public function recurring( $hook, $args = array(), $first = null, $interval = null, $group = '' ) { + return $this->recurring_unique( $hook, $args, $first, $interval, $group, false ); + } + + /** + * Create the first instance of an action recurring on a given interval only if there is no pending or running action with same name and params. + * + * @param string $hook The hook to trigger when this action runs. + * @param array $args Args to pass when the hook is triggered. + * @param int $first Unix timestamp for the first run. + * @param int $interval Seconds between runs. + * @param string $group A group to put the action in. + * @param bool $unique Whether action scheduled should be unique. + * + * @return int The ID of the stored action. + */ + public function recurring_unique( $hook, $args = array(), $first = null, $interval = null, $group = '', $unique = true ) { + if ( empty( $interval ) ) { + return $this->single_unique( $hook, $args, $first, $group, $unique ); + } + $date = as_get_datetime_object( $first ); + $schedule = new ActionScheduler_IntervalSchedule( $date, $interval ); + $action = new ActionScheduler_Action( $hook, $args, $schedule, $group ); + return $unique ? $this->store_unique_action( $action ) : $this->store( $action ); + } + + /** + * Create the first instance of an action recurring on a Cron schedule. + * + * @param string $hook The hook to trigger when this action runs. + * @param array $args Args to pass when the hook is triggered. + * @param int $base_timestamp The first instance of the action will be scheduled + * to run at a time calculated after this timestamp matching the cron + * expression. This can be used to delay the first instance of the action. + * @param int $schedule A cron definition string. + * @param string $group A group to put the action in. + * + * @return int The ID of the stored action. + */ + public function cron( $hook, $args = array(), $base_timestamp = null, $schedule = null, $group = '' ) { + return $this->cron_unique( $hook, $args, $base_timestamp, $schedule, $group, false ); + } + + + /** + * Create the first instance of an action recurring on a Cron schedule only if there is no pending or running action with same name and params. + * + * @param string $hook The hook to trigger when this action runs. + * @param array $args Args to pass when the hook is triggered. + * @param int $base_timestamp The first instance of the action will be scheduled + * to run at a time calculated after this timestamp matching the cron + * expression. This can be used to delay the first instance of the action. + * @param int $schedule A cron definition string. + * @param string $group A group to put the action in. + * @param bool $unique Whether action scheduled should be unique. + * + * @return int The ID of the stored action. + **/ + public function cron_unique( $hook, $args = array(), $base_timestamp = null, $schedule = null, $group = '', $unique = true ) { + if ( empty( $schedule ) ) { + return $this->single_unique( $hook, $args, $base_timestamp, $group, $unique ); + } + $date = as_get_datetime_object( $base_timestamp ); + $cron = CronExpression::factory( $schedule ); + $schedule = new ActionScheduler_CronSchedule( $date, $cron ); + $action = new ActionScheduler_Action( $hook, $args, $schedule, $group ); + return $unique ? $this->store_unique_action( $action ) : $this->store( $action ); + } + + /** + * Create a successive instance of a recurring or cron action. + * + * Importantly, the action will be rescheduled to run based on the current date/time. + * That means when the action is scheduled to run in the past, the next scheduled date + * will be pushed forward. For example, if a recurring action set to run every hour + * was scheduled to run 5 seconds ago, it will be next scheduled for 1 hour in the + * future, which is 1 hour and 5 seconds from when it was last scheduled to run. + * + * Alternatively, if the action is scheduled to run in the future, and is run early, + * likely via manual intervention, then its schedule will change based on the time now. + * For example, if a recurring action set to run every day, and is run 12 hours early, + * it will run again in 24 hours, not 36 hours. + * + * This slippage is less of an issue with Cron actions, as the specific run time can + * be set for them to run, e.g. 1am each day. In those cases, and entire period would + * need to be missed before there was any change is scheduled, e.g. in the case of an + * action scheduled for 1am each day, the action would need to run an entire day late. + * + * @param ActionScheduler_Action $action The existing action. + * + * @return string The ID of the stored action + * @throws InvalidArgumentException If $action is not a recurring action. + */ + public function repeat( $action ) { + $schedule = $action->get_schedule(); + $next = $schedule->get_next( as_get_datetime_object() ); + + if ( is_null( $next ) || ! $schedule->is_recurring() ) { + throw new InvalidArgumentException( __( 'Invalid action - must be a recurring action.', 'action-scheduler' ) ); + } + + $schedule_class = get_class( $schedule ); + $new_schedule = new $schedule( $next, $schedule->get_recurrence(), $schedule->get_first_date() ); + $new_action = new ActionScheduler_Action( $action->get_hook(), $action->get_args(), $new_schedule, $action->get_group() ); + $new_action->set_priority( $action->get_priority() ); + return $this->store( $new_action ); + } + + /** + * Creates a scheduled action. + * + * This general purpose method can be used in place of specific methods such as async(), + * async_unique(), single() or single_unique(), etc. + * + * @internal Not intended for public use, should not be overridden by subclasses. + * + * @param array $options { + * Describes the action we wish to schedule. + * + * @type string $type Must be one of 'async', 'cron', 'recurring', or 'single'. + * @type string $hook The hook to be executed. + * @type array $arguments Arguments to be passed to the callback. + * @type string $group The action group. + * @type bool $unique If the action should be unique. + * @type int $when Timestamp. Indicates when the action, or first instance of the action in the case + * of recurring or cron actions, becomes due. + * @type int|string $pattern Recurrence pattern. This is either an interval in seconds for recurring actions + * or a cron expression for cron actions. + * @type int $priority Lower values means higher priority. Should be in the range 0-255. + * } + * + * @return int The action ID. Zero if there was an error scheduling the action. + */ + public function create( array $options = array() ) { + $defaults = array( + 'type' => 'single', + 'hook' => '', + 'arguments' => array(), + 'group' => '', + 'unique' => false, + 'when' => time(), + 'pattern' => null, + 'priority' => 10, + ); + + $options = array_merge( $defaults, $options ); + + // Cron/recurring actions without a pattern are treated as single actions (this gives calling code the ability + // to use functions like as_schedule_recurring_action() to schedule recurring as well as single actions). + if ( ( 'cron' === $options['type'] || 'recurring' === $options['type'] ) && empty( $options['pattern'] ) ) { + $options['type'] = 'single'; + } + + switch ( $options['type'] ) { + case 'async': + $schedule = new ActionScheduler_NullSchedule(); + break; + + case 'cron': + $date = as_get_datetime_object( $options['when'] ); + $cron = CronExpression::factory( $options['pattern'] ); + $schedule = new ActionScheduler_CronSchedule( $date, $cron ); + break; + + case 'recurring': + $date = as_get_datetime_object( $options['when'] ); + $schedule = new ActionScheduler_IntervalSchedule( $date, $options['pattern'] ); + break; + + case 'single': + $date = as_get_datetime_object( $options['when'] ); + $schedule = new ActionScheduler_SimpleSchedule( $date ); + break; + + default: + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "Unknown action type '{$options['type']}' specified when trying to create an action for '{$options['hook']}'." ); + return 0; + } + + $action = new ActionScheduler_Action( $options['hook'], $options['arguments'], $schedule, $options['group'] ); + $action->set_priority( $options['priority'] ); + + $action_id = 0; + try { + $action_id = $options['unique'] ? $this->store_unique_action( $action ) : $this->store( $action ); + } catch ( Exception $e ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( + sprintf( + /* translators: %1$s is the name of the hook to be enqueued, %2$s is the exception message. */ + __( 'Caught exception while enqueuing action "%1$s": %2$s', 'action-scheduler' ), + $options['hook'], + $e->getMessage() + ) + ); + } + return $action_id; + } + + /** + * Save action to database. + * + * @param ActionScheduler_Action $action Action object to save. + * + * @return int The ID of the stored action + */ + protected function store( ActionScheduler_Action $action ) { + $store = ActionScheduler_Store::instance(); + return $store->save_action( $action ); + } + + /** + * Store action if it's unique. + * + * @param ActionScheduler_Action $action Action object to store. + * + * @return int ID of the created action. Will be 0 if action was not created. + */ + protected function store_unique_action( ActionScheduler_Action $action ) { + $store = ActionScheduler_Store::instance(); + if ( method_exists( $store, 'save_unique_action' ) ) { + return $store->save_unique_action( $action ); + } else { + /** + * Fallback to non-unique action if the store doesn't support unique actions. + * We try to save the action as unique, accepting that there might be a race condition. + * This is likely still better than giving up on unique actions entirely. + */ + $existing_action_id = (int) $store->find_action( + $action->get_hook(), + array( + 'args' => $action->get_args(), + 'status' => ActionScheduler_Store::STATUS_PENDING, + 'group' => $action->get_group(), + ) + ); + if ( $existing_action_id > 0 ) { + return 0; + } + return $store->save_action( $action ); + } + } +} diff --git a/classes/ActionScheduler_AdminView.php b/classes/ActionScheduler_AdminView.php new file mode 100644 index 0000000..1c8306e --- /dev/null +++ b/classes/ActionScheduler_AdminView.php @@ -0,0 +1,311 @@ +render(); + } + + /** + * Registers action-scheduler into WooCommerce > System status. + * + * @param array $tabs An associative array of tab key => label. + * @return array $tabs An associative array of tab key => label, including Action Scheduler's tabs + */ + public function register_system_status_tab( array $tabs ) { + $tabs['action-scheduler'] = __( 'Scheduled Actions', 'action-scheduler' ); + + return $tabs; + } + + /** + * Include Action Scheduler's administration under the Tools menu. + * + * A menu under the Tools menu is important for backward compatibility (as that's + * where it started), and also provides more convenient access than the WooCommerce + * System Status page, and for sites where WooCommerce isn't active. + */ + public function register_menu() { + $hook_suffix = add_submenu_page( + 'tools.php', + __( 'Scheduled Actions', 'action-scheduler' ), + __( 'Scheduled Actions', 'action-scheduler' ), + 'manage_options', + 'action-scheduler', + array( $this, 'render_admin_ui' ) + ); + add_action( 'load-' . $hook_suffix, array( $this, 'process_admin_ui' ) ); + } + + /** + * Triggers processing of any pending actions. + */ + public function process_admin_ui() { + $this->get_list_table(); + } + + /** + * Renders the Admin UI + */ + public function render_admin_ui() { + $table = $this->get_list_table(); + $table->display_page(); + } + + /** + * Get the admin UI object and process any requested actions. + * + * @return ActionScheduler_ListTable + */ + protected function get_list_table() { + if ( null === $this->list_table ) { + $this->list_table = new ActionScheduler_ListTable( ActionScheduler::store(), ActionScheduler::logger(), ActionScheduler::runner() ); + $this->list_table->process_actions(); + } + + return $this->list_table; + } + + /** + * Action: admin_notices + * + * Maybe check past-due actions, and print notice. + * + * @uses $this->check_pastdue_actions() + */ + public function maybe_check_pastdue_actions() { + + // Filter to prevent checking actions (ex: inappropriate user). + if ( ! apply_filters( 'action_scheduler_check_pastdue_actions', current_user_can( 'manage_options' ) ) ) { + return; + } + + // Get last check transient. + $last_check = get_transient( 'action_scheduler_last_pastdue_actions_check' ); + + // If transient exists, we're within interval, so bail. + if ( ! empty( $last_check ) ) { + return; + } + + // Perform the check. + $this->check_pastdue_actions(); + } + + /** + * Check past-due actions, and print notice. + */ + protected function check_pastdue_actions() { + + // Set thresholds. + $threshold_seconds = (int) apply_filters( 'action_scheduler_pastdue_actions_seconds', DAY_IN_SECONDS ); + $threshold_min = (int) apply_filters( 'action_scheduler_pastdue_actions_min', 1 ); + + // Set fallback value for past-due actions count. + $num_pastdue_actions = 0; + + // Allow third-parties to preempt the default check logic. + $check = apply_filters( 'action_scheduler_pastdue_actions_check_pre', null ); + + // If no third-party preempted and there are no past-due actions, return early. + if ( ! is_null( $check ) ) { + return; + } + + // Scheduled actions query arguments. + $query_args = array( + 'date' => as_get_datetime_object( time() - $threshold_seconds ), + 'status' => ActionScheduler_Store::STATUS_PENDING, + 'per_page' => $threshold_min, + ); + + // If no third-party preempted, run default check. + if ( is_null( $check ) ) { + $store = ActionScheduler_Store::instance(); + $num_pastdue_actions = (int) $store->query_actions( $query_args, 'count' ); + + // Check if past-due actions count is greater than or equal to threshold. + $check = ( $num_pastdue_actions >= $threshold_min ); + $check = (bool) apply_filters( 'action_scheduler_pastdue_actions_check', $check, $num_pastdue_actions, $threshold_seconds, $threshold_min ); + } + + // If check failed, set transient and abort. + if ( ! boolval( $check ) ) { + $interval = apply_filters( 'action_scheduler_pastdue_actions_check_interval', round( $threshold_seconds / 4 ), $threshold_seconds ); + set_transient( 'action_scheduler_last_pastdue_actions_check', time(), $interval ); + + return; + } + + $actions_url = add_query_arg( + array( + 'page' => 'action-scheduler', + 'status' => 'past-due', + 'order' => 'asc', + ), + admin_url( 'tools.php' ) + ); + + // Print notice. + echo '

'; + printf( + wp_kses( + // translators: 1) is the number of affected actions, 2) is a link to an admin screen. + _n( + 'Action Scheduler: %1$d past-due action found; something may be wrong. Read documentation »', + 'Action Scheduler: %1$d past-due actions found; something may be wrong. Read documentation »', + $num_pastdue_actions, + 'action-scheduler' + ), + array( + 'strong' => array(), + 'a' => array( + 'href' => true, + 'target' => true, + ), + ) + ), + absint( $num_pastdue_actions ), + esc_attr( esc_url( $actions_url ) ) + ); + echo '

'; + + // Facilitate third-parties to evaluate and print notices. + do_action( 'action_scheduler_pastdue_actions_extra_notices', $query_args ); + } + + /** + * Provide more information about the screen and its data in the help tab. + */ + public function add_help_tabs() { + $screen = get_current_screen(); + + if ( ! $screen || self::$screen_id !== $screen->id ) { + return; + } + + $as_version = ActionScheduler_Versions::instance()->latest_version(); + $as_source = ActionScheduler_SystemInformation::active_source(); + $as_source_path = ActionScheduler_SystemInformation::active_source_path(); + $as_source_markup = sprintf( '%s', esc_html( $as_source_path ) ); + + if ( ! empty( $as_source ) ) { + $as_source_markup = sprintf( + '%s: %s', + ucfirst( $as_source['type'] ), + esc_attr( $as_source_path ), + esc_html( $as_source['name'] ) + ); + } + + $screen->add_help_tab( + array( + 'id' => 'action_scheduler_about', + 'title' => __( 'About', 'action-scheduler' ), + 'content' => + // translators: %s is the Action Scheduler version. + '

' . sprintf( __( 'About Action Scheduler %s', 'action-scheduler' ), $as_version ) . '

' . + '

' . + __( 'Action Scheduler is a scalable, traceable job queue for background processing large sets of actions. Action Scheduler works by triggering an action hook to run at some time in the future. Scheduled actions can also be scheduled to run on a recurring schedule.', 'action-scheduler' ) . + '

' . + '

' . esc_html__( 'Source', 'action-scheduler' ) . '

' . + '

' . + esc_html__( 'Action Scheduler is currently being loaded from the following location. This can be useful when debugging, or if requested by the support team.', 'action-scheduler' ) . + '

' . + '

' . $as_source_markup . '

' . + '

' . esc_html__( 'WP CLI', 'action-scheduler' ) . '

' . + '

' . + sprintf( + /* translators: %1$s is WP CLI command (not translatable) */ + esc_html__( 'WP CLI commands are available: execute %1$s for a list of available commands.', 'action-scheduler' ), + 'wp help action-scheduler' + ) . + '

', + ) + ); + + $screen->add_help_tab( + array( + 'id' => 'action_scheduler_columns', + 'title' => __( 'Columns', 'action-scheduler' ), + 'content' => + '

' . __( 'Scheduled Action Columns', 'action-scheduler' ) . '

' . + '', + ) + ); + } +} diff --git a/classes/ActionScheduler_AsyncRequest_QueueRunner.php b/classes/ActionScheduler_AsyncRequest_QueueRunner.php new file mode 100644 index 0000000..2eaf86d --- /dev/null +++ b/classes/ActionScheduler_AsyncRequest_QueueRunner.php @@ -0,0 +1,93 @@ +store = $store; + } + + /** + * Handle async requests + * + * Run a queue, and maybe dispatch another async request to run another queue + * if there are still pending actions after completing a queue in this request. + */ + protected function handle() { + do_action( 'action_scheduler_run_queue', 'Async Request' ); // run a queue in the same way as WP Cron, but declare the Async Request context. + + $sleep_seconds = $this->get_sleep_seconds(); + + if ( $sleep_seconds ) { + sleep( $sleep_seconds ); + } + + $this->maybe_dispatch(); + } + + /** + * If the async request runner is needed and allowed to run, dispatch a request. + */ + public function maybe_dispatch() { + if ( ! $this->allow() ) { + return; + } + + $this->dispatch(); + ActionScheduler_QueueRunner::instance()->unhook_dispatch_async_request(); + } + + /** + * Only allow async requests when needed. + * + * Also allow 3rd party code to disable running actions via async requests. + */ + protected function allow() { + + if ( ! has_action( 'action_scheduler_run_queue' ) || ActionScheduler::runner()->has_maximum_concurrent_batches() || ! $this->store->has_pending_actions_due() ) { + $allow = false; + } else { + $allow = true; + } + + return apply_filters( 'action_scheduler_allow_async_request_runner', $allow ); + } + + /** + * Chaining async requests can crash MySQL. A brief sleep call in PHP prevents that. + */ + protected function get_sleep_seconds() { + return apply_filters( 'action_scheduler_async_request_sleep_seconds', 5, $this ); + } +} diff --git a/classes/ActionScheduler_Compatibility.php b/classes/ActionScheduler_Compatibility.php new file mode 100644 index 0000000..ede48aa --- /dev/null +++ b/classes/ActionScheduler_Compatibility.php @@ -0,0 +1,111 @@ + $wp_max_limit_int && $filtered_limit_int > $current_limit_int ) ) { + if ( false !== @ini_set( 'memory_limit', $filtered_limit ) ) { + return $filtered_limit; + } else { + return false; + } + } elseif ( -1 === $wp_max_limit_int || $wp_max_limit_int > $current_limit_int ) { + if ( false !== @ini_set( 'memory_limit', $wp_max_limit ) ) { + return $wp_max_limit; + } else { + return false; + } + } + + // phpcs:enable + + return false; + } + + /** + * Attempts to raise the PHP timeout for time intensive processes. + * + * Only allows raising the existing limit and prevents lowering it. Wrapper for wc_set_time_limit(), when available. + * + * @param int $limit The time limit in seconds. + */ + public static function raise_time_limit( $limit = 0 ) { + $limit = (int) $limit; + $max_execution_time = (int) ini_get( 'max_execution_time' ); + + // If the max execution time is already set to zero (unlimited), there is no reason to make a further change. + if ( 0 === $max_execution_time ) { + return; + } + + // Whichever of $max_execution_time or $limit is higher is the amount by which we raise the time limit. + $raise_by = 0 === $limit || $limit > $max_execution_time ? $limit : $max_execution_time; + + if ( function_exists( 'wc_set_time_limit' ) ) { + wc_set_time_limit( $raise_by ); + } elseif ( function_exists( 'set_time_limit' ) && false === strpos( ini_get( 'disable_functions' ), 'set_time_limit' ) && ! ini_get( 'safe_mode' ) ) { // phpcs:ignore PHPCompatibility.IniDirectives.RemovedIniDirectives.safe_modeDeprecatedRemoved + @set_time_limit( $raise_by ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + } + } +} diff --git a/classes/ActionScheduler_DataController.php b/classes/ActionScheduler_DataController.php new file mode 100644 index 0000000..8bceba2 --- /dev/null +++ b/classes/ActionScheduler_DataController.php @@ -0,0 +1,201 @@ +=' ); + return $php_support && apply_filters( 'action_scheduler_migration_dependencies_met', true ); + } + + /** + * Get a flag indicating whether the migration is complete. + * + * @return bool Whether the flag has been set marking the migration as complete + */ + public static function is_migration_complete() { + return get_option( self::STATUS_FLAG ) === self::STATUS_COMPLETE; + } + + /** + * Mark the migration as complete. + */ + public static function mark_migration_complete() { + update_option( self::STATUS_FLAG, self::STATUS_COMPLETE ); + } + + /** + * Unmark migration when a plugin is de-activated. Will not work in case of silent activation, for example in an update. + * We do this to mitigate the bug of lost actions which happens if there was an AS 2.x to AS 3.x migration in the past, but that plugin is now + * deactivated and the site was running on AS 2.x again. + */ + public static function mark_migration_incomplete() { + delete_option( self::STATUS_FLAG ); + } + + /** + * Set the action store class name. + * + * @param string $class Classname of the store class. + * + * @return string + */ + public static function set_store_class( $class ) { + return self::DATASTORE_CLASS; + } + + /** + * Set the action logger class name. + * + * @param string $class Classname of the logger class. + * + * @return string + */ + public static function set_logger_class( $class ) { + return self::LOGGER_CLASS; + } + + /** + * Set the sleep time in seconds. + * + * @param integer $sleep_time The number of seconds to pause before resuming operation. + */ + public static function set_sleep_time( $sleep_time ) { + self::$sleep_time = (int) $sleep_time; + } + + /** + * Set the tick count required for freeing memory. + * + * @param integer $free_ticks The number of ticks to free memory on. + */ + public static function set_free_ticks( $free_ticks ) { + self::$free_ticks = (int) $free_ticks; + } + + /** + * Free memory if conditions are met. + * + * @param int $ticks Current tick count. + */ + public static function maybe_free_memory( $ticks ) { + if ( self::$free_ticks && 0 === $ticks % self::$free_ticks ) { + self::free_memory(); + } + } + + /** + * Reduce memory footprint by clearing the database query and object caches. + */ + public static function free_memory() { + if ( 0 < self::$sleep_time ) { + /* translators: %d: amount of time */ + \WP_CLI::warning( sprintf( _n( 'Stopped the insanity for %d second', 'Stopped the insanity for %d seconds', self::$sleep_time, 'action-scheduler' ), self::$sleep_time ) ); + sleep( self::$sleep_time ); + } + + \WP_CLI::warning( __( 'Attempting to reduce used memory...', 'action-scheduler' ) ); + + /** + * Globals. + * + * @var $wpdb \wpdb + * @var $wp_object_cache \WP_Object_Cache + */ + global $wpdb, $wp_object_cache; + + $wpdb->queries = array(); + + if ( ! is_a( $wp_object_cache, 'WP_Object_Cache' ) ) { + return; + } + + $wp_object_cache->group_ops = array(); + $wp_object_cache->stats = array(); + $wp_object_cache->memcache_debug = array(); + $wp_object_cache->cache = array(); + + if ( is_callable( array( $wp_object_cache, '__remoteset' ) ) ) { + call_user_func( array( $wp_object_cache, '__remoteset' ) ); // important! + } + } + + /** + * Connect to table datastores if migration is complete. + * Otherwise, proceed with the migration if the dependencies have been met. + */ + public static function init() { + if ( self::is_migration_complete() ) { + add_filter( 'action_scheduler_store_class', array( 'ActionScheduler_DataController', 'set_store_class' ), 100 ); + add_filter( 'action_scheduler_logger_class', array( 'ActionScheduler_DataController', 'set_logger_class' ), 100 ); + add_action( 'deactivate_plugin', array( 'ActionScheduler_DataController', 'mark_migration_incomplete' ) ); + } elseif ( self::dependencies_met() ) { + Controller::init(); + } + + add_action( 'action_scheduler/progress_tick', array( 'ActionScheduler_DataController', 'maybe_free_memory' ) ); + } + + /** + * Singleton factory. + */ + public static function instance() { + if ( ! isset( self::$instance ) ) { + self::$instance = new static(); + } + + return self::$instance; + } +} diff --git a/classes/ActionScheduler_DateTime.php b/classes/ActionScheduler_DateTime.php new file mode 100644 index 0000000..3d3f68b --- /dev/null +++ b/classes/ActionScheduler_DateTime.php @@ -0,0 +1,79 @@ +format( 'U' ); + } + + /** + * Set the UTC offset. + * + * This represents a fixed offset instead of a timezone setting. + * + * @param string|int $offset UTC offset value. + */ + public function setUtcOffset( $offset ) { + $this->utcOffset = intval( $offset ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + + /** + * Returns the timezone offset. + * + * @return int + * @link http://php.net/manual/en/datetime.getoffset.php + */ + #[\ReturnTypeWillChange] + public function getOffset() { + return $this->utcOffset ? $this->utcOffset : parent::getOffset(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + + /** + * Set the TimeZone associated with the DateTime + * + * @param DateTimeZone $timezone Timezone object. + * + * @return static + * @link http://php.net/manual/en/datetime.settimezone.php + */ + #[\ReturnTypeWillChange] + public function setTimezone( $timezone ) { + $this->utcOffset = 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + parent::setTimezone( $timezone ); + + return $this; + } + + /** + * Get the timestamp with the WordPress timezone offset added or subtracted. + * + * @since 3.0.0 + * @return int + */ + public function getOffsetTimestamp() { + return $this->getTimestamp() + $this->getOffset(); + } +} diff --git a/classes/ActionScheduler_Exception.php b/classes/ActionScheduler_Exception.php new file mode 100644 index 0000000..08e4fae --- /dev/null +++ b/classes/ActionScheduler_Exception.php @@ -0,0 +1,11 @@ +store = $store; + } + + /** + * Start monitoring. + * + * @param ActionScheduler_ActionClaim $claim Claimed actions. + */ + public function attach( ActionScheduler_ActionClaim $claim ) { + $this->claim = $claim; + add_action( 'shutdown', array( $this, 'handle_unexpected_shutdown' ) ); + add_action( 'action_scheduler_before_execute', array( $this, 'track_current_action' ), 0, 1 ); + add_action( 'action_scheduler_after_execute', array( $this, 'untrack_action' ), 0, 0 ); + add_action( 'action_scheduler_execution_ignored', array( $this, 'untrack_action' ), 0, 0 ); + add_action( 'action_scheduler_failed_execution', array( $this, 'untrack_action' ), 0, 0 ); + } + + /** + * Stop monitoring. + */ + public function detach() { + $this->claim = null; + $this->untrack_action(); + remove_action( 'shutdown', array( $this, 'handle_unexpected_shutdown' ) ); + remove_action( 'action_scheduler_before_execute', array( $this, 'track_current_action' ), 0 ); + remove_action( 'action_scheduler_after_execute', array( $this, 'untrack_action' ), 0 ); + remove_action( 'action_scheduler_execution_ignored', array( $this, 'untrack_action' ), 0 ); + remove_action( 'action_scheduler_failed_execution', array( $this, 'untrack_action' ), 0 ); + } + + /** + * Track specified action. + * + * @param int $action_id Action ID to track. + */ + public function track_current_action( $action_id ) { + $this->action_id = $action_id; + } + + /** + * Un-track action. + */ + public function untrack_action() { + $this->action_id = 0; + } + + /** + * Handle unexpected shutdown. + */ + public function handle_unexpected_shutdown() { + $error = error_get_last(); + + if ( $error ) { + if ( in_array( $error['type'], array( E_ERROR, E_PARSE, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ), true ) ) { + if ( ! empty( $this->action_id ) ) { + $this->store->mark_failure( $this->action_id ); + do_action( 'action_scheduler_unexpected_shutdown', $this->action_id, $error ); + } + } + + $this->store->release_claim( $this->claim ); + } + } +} diff --git a/classes/ActionScheduler_InvalidActionException.php b/classes/ActionScheduler_InvalidActionException.php new file mode 100644 index 0000000..c64e971 --- /dev/null +++ b/classes/ActionScheduler_InvalidActionException.php @@ -0,0 +1,47 @@ + label). + * + * @var array + */ + protected $columns = array(); + + /** + * Actions (name => label). + * + * @var array + */ + protected $row_actions = array(); + + /** + * The active data stores + * + * @var ActionScheduler_Store + */ + protected $store; + + /** + * A logger to use for getting action logs to display + * + * @var ActionScheduler_Logger + */ + protected $logger; + + /** + * A ActionScheduler_QueueRunner runner instance (or child class) + * + * @var ActionScheduler_QueueRunner + */ + protected $runner; + + /** + * Bulk actions. The key of the array is the method name of the implementation. + * Example: bulk_(array $ids, string $sql_in). + * + * See the comments in the parent class for further details + * + * @var array + */ + protected $bulk_actions = array(); + + /** + * Flag variable to render our notifications, if any, once. + * + * @var bool + */ + protected static $did_notification = false; + + /** + * Array of seconds for common time periods, like week or month, alongside an internationalised string representation, i.e. "Day" or "Days" + * + * @var array + */ + private static $time_periods; + + /** + * Sets the current data store object into `store->action` and initialises the object. + * + * @param ActionScheduler_Store $store Store object. + * @param ActionScheduler_Logger $logger Logger object. + * @param ActionScheduler_QueueRunner $runner Runner object. + */ + public function __construct( ActionScheduler_Store $store, ActionScheduler_Logger $logger, ActionScheduler_QueueRunner $runner ) { + + $this->store = $store; + $this->logger = $logger; + $this->runner = $runner; + + $this->table_header = __( 'Scheduled Actions', 'action-scheduler' ); + + $this->bulk_actions = array( + 'delete' => __( 'Delete', 'action-scheduler' ), + ); + + $this->columns = array( + 'hook' => __( 'Hook', 'action-scheduler' ), + 'status' => __( 'Status', 'action-scheduler' ), + 'args' => __( 'Arguments', 'action-scheduler' ), + 'group' => __( 'Group', 'action-scheduler' ), + 'recurrence' => __( 'Recurrence', 'action-scheduler' ), + 'schedule' => __( 'Scheduled Date', 'action-scheduler' ), + 'log_entries' => __( 'Log', 'action-scheduler' ), + ); + + $this->sort_by = array( + 'schedule', + 'hook', + 'group', + ); + + $this->search_by = array( + 'hook', + 'args', + 'claim_id', + ); + + $request_status = $this->get_request_status(); + + if ( empty( $request_status ) ) { + $this->sort_by[] = 'status'; + } elseif ( in_array( $request_status, array( 'in-progress', 'failed' ), true ) ) { + $this->columns += array( 'claim_id' => __( 'Claim ID', 'action-scheduler' ) ); + $this->sort_by[] = 'claim_id'; + } + + $this->row_actions = array( + 'hook' => array( + 'run' => array( + 'name' => __( 'Run', 'action-scheduler' ), + 'desc' => __( 'Process the action now as if it were run as part of a queue', 'action-scheduler' ), + ), + 'cancel' => array( + 'name' => __( 'Cancel', 'action-scheduler' ), + 'desc' => __( 'Cancel the action now to avoid it being run in future', 'action-scheduler' ), + 'class' => 'cancel trash', + ), + ), + ); + + self::$time_periods = array( + array( + 'seconds' => YEAR_IN_SECONDS, + /* translators: %s: amount of time */ + 'names' => _n_noop( '%s year', '%s years', 'action-scheduler' ), + ), + array( + 'seconds' => MONTH_IN_SECONDS, + /* translators: %s: amount of time */ + 'names' => _n_noop( '%s month', '%s months', 'action-scheduler' ), + ), + array( + 'seconds' => WEEK_IN_SECONDS, + /* translators: %s: amount of time */ + 'names' => _n_noop( '%s week', '%s weeks', 'action-scheduler' ), + ), + array( + 'seconds' => DAY_IN_SECONDS, + /* translators: %s: amount of time */ + 'names' => _n_noop( '%s day', '%s days', 'action-scheduler' ), + ), + array( + 'seconds' => HOUR_IN_SECONDS, + /* translators: %s: amount of time */ + 'names' => _n_noop( '%s hour', '%s hours', 'action-scheduler' ), + ), + array( + 'seconds' => MINUTE_IN_SECONDS, + /* translators: %s: amount of time */ + 'names' => _n_noop( '%s minute', '%s minutes', 'action-scheduler' ), + ), + array( + 'seconds' => 1, + /* translators: %s: amount of time */ + 'names' => _n_noop( '%s second', '%s seconds', 'action-scheduler' ), + ), + ); + + parent::__construct( + array( + 'singular' => 'action-scheduler', + 'plural' => 'action-scheduler', + 'ajax' => false, + ) + ); + + add_screen_option( + 'per_page', + array( + 'default' => $this->items_per_page, + ) + ); + + add_filter( 'set_screen_option_' . $this->get_per_page_option_name(), array( $this, 'set_items_per_page_option' ), 10, 3 ); + set_screen_options(); + } + + /** + * Handles setting the items_per_page option for this screen. + * + * @param mixed $status Default false (to skip saving the current option). + * @param string $option Screen option name. + * @param int $value Screen option value. + * @return int + */ + public function set_items_per_page_option( $status, $option, $value ) { + return $value; + } + /** + * Convert an interval of seconds into a two part human friendly string. + * + * The WordPress human_time_diff() function only calculates the time difference to one degree, meaning + * even if an action is 1 day and 11 hours away, it will display "1 day". This function goes one step + * further to display two degrees of accuracy. + * + * Inspired by the Crontrol::interval() function by Edward Dale: https://wordpress.org/plugins/wp-crontrol/ + * + * @param int $interval A interval in seconds. + * @param int $periods_to_include Depth of time periods to include, e.g. for an interval of 70, and $periods_to_include of 2, both minutes and seconds would be included. With a value of 1, only minutes would be included. + * @return string A human friendly string representation of the interval. + */ + private static function human_interval( $interval, $periods_to_include = 2 ) { + + if ( $interval <= 0 ) { + return __( 'Now!', 'action-scheduler' ); + } + + $output = ''; + $num_time_periods = count( self::$time_periods ); + + for ( $time_period_index = 0, $periods_included = 0, $seconds_remaining = $interval; $time_period_index < $num_time_periods && $seconds_remaining > 0 && $periods_included < $periods_to_include; $time_period_index++ ) { + + $periods_in_interval = floor( $seconds_remaining / self::$time_periods[ $time_period_index ]['seconds'] ); + + if ( $periods_in_interval > 0 ) { + if ( ! empty( $output ) ) { + $output .= ' '; + } + $output .= sprintf( translate_nooped_plural( self::$time_periods[ $time_period_index ]['names'], $periods_in_interval, 'action-scheduler' ), $periods_in_interval ); + $seconds_remaining -= $periods_in_interval * self::$time_periods[ $time_period_index ]['seconds']; + $periods_included++; + } + } + + return $output; + } + + /** + * Returns the recurrence of an action or 'Non-repeating'. The output is human readable. + * + * @param ActionScheduler_Action $action Action object. + * + * @return string + */ + protected function get_recurrence( $action ) { + $schedule = $action->get_schedule(); + if ( $schedule->is_recurring() && method_exists( $schedule, 'get_recurrence' ) ) { + $recurrence = $schedule->get_recurrence(); + + if ( is_numeric( $recurrence ) ) { + /* translators: %s: time interval */ + return sprintf( __( 'Every %s', 'action-scheduler' ), self::human_interval( $recurrence ) ); + } else { + return $recurrence; + } + } + + return __( 'Non-repeating', 'action-scheduler' ); + } + + /** + * Serializes the argument of an action to render it in a human friendly format. + * + * @param array $row The array representation of the current row of the table. + * + * @return string + */ + public function column_args( array $row ) { + if ( empty( $row['args'] ) ) { + return apply_filters( 'action_scheduler_list_table_column_args', '', $row ); + } + + $row_html = ''; + + return apply_filters( 'action_scheduler_list_table_column_args', $row_html, $row ); + } + + /** + * Prints the logs entries inline. We do so to avoid loading Javascript and other hacks to show it in a modal. + * + * @param array $row Action array. + * @return string + */ + public function column_log_entries( array $row ) { + + $log_entries_html = '
    '; + + $timezone = new DateTimezone( 'UTC' ); + + foreach ( $row['log_entries'] as $log_entry ) { + $log_entries_html .= $this->get_log_entry_html( $log_entry, $timezone ); + } + + $log_entries_html .= '
'; + + return $log_entries_html; + } + + /** + * Prints the logs entries inline. We do so to avoid loading Javascript and other hacks to show it in a modal. + * + * @param ActionScheduler_LogEntry $log_entry Log entry object. + * @param DateTimezone $timezone Timestamp. + * @return string + */ + protected function get_log_entry_html( ActionScheduler_LogEntry $log_entry, DateTimezone $timezone ) { + $date = $log_entry->get_date(); + $date->setTimezone( $timezone ); + return sprintf( '
  • %s
    %s
  • ', esc_html( $date->format( 'Y-m-d H:i:s O' ) ), esc_html( $log_entry->get_message() ) ); + } + + /** + * Only display row actions for pending actions. + * + * @param array $row Row to render. + * @param string $column_name Current row. + * + * @return string + */ + protected function maybe_render_actions( $row, $column_name ) { + if ( 'pending' === strtolower( $row['status_name'] ) ) { + return parent::maybe_render_actions( $row, $column_name ); + } + + return ''; + } + + /** + * Renders admin notifications + * + * Notifications: + * 1. When the maximum number of tasks are being executed simultaneously. + * 2. Notifications when a task is manually executed. + * 3. Tables are missing. + */ + public function display_admin_notices() { + global $wpdb; + + if ( ( is_a( $this->store, 'ActionScheduler_HybridStore' ) || is_a( $this->store, 'ActionScheduler_DBStore' ) ) && apply_filters( 'action_scheduler_enable_recreate_data_store', true ) ) { + $table_list = array( + 'actionscheduler_actions', + 'actionscheduler_logs', + 'actionscheduler_groups', + 'actionscheduler_claims', + ); + + $found_tables = $wpdb->get_col( "SHOW TABLES LIKE '{$wpdb->prefix}actionscheduler%'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + foreach ( $table_list as $table_name ) { + if ( ! in_array( $wpdb->prefix . $table_name, $found_tables, true ) ) { + $this->admin_notices[] = array( + 'class' => 'error', + 'message' => __( 'It appears one or more database tables were missing. Attempting to re-create the missing table(s).', 'action-scheduler' ), + ); + $this->recreate_tables(); + parent::display_admin_notices(); + + return; + } + } + } + + if ( $this->runner->has_maximum_concurrent_batches() ) { + $claim_count = $this->store->get_claim_count(); + $this->admin_notices[] = array( + 'class' => 'updated', + 'message' => sprintf( + /* translators: %s: amount of claims */ + _n( + 'Maximum simultaneous queues already in progress (%s queue). No additional queues will begin processing until the current queues are complete.', + 'Maximum simultaneous queues already in progress (%s queues). No additional queues will begin processing until the current queues are complete.', + $claim_count, + 'action-scheduler' + ), + $claim_count + ), + ); + } elseif ( $this->store->has_pending_actions_due() ) { + + $async_request_lock_expiration = ActionScheduler::lock()->get_expiration( 'async-request-runner' ); + + // No lock set or lock expired. + if ( false === $async_request_lock_expiration || $async_request_lock_expiration < time() ) { + $in_progress_url = add_query_arg( 'status', 'in-progress', remove_query_arg( 'status' ) ); + /* translators: %s: process URL */ + $async_request_message = sprintf( __( 'A new queue has begun processing. View actions in-progress »', 'action-scheduler' ), esc_url( $in_progress_url ) ); + } else { + /* translators: %d: seconds */ + $async_request_message = sprintf( __( 'The next queue will begin processing in approximately %d seconds.', 'action-scheduler' ), $async_request_lock_expiration - time() ); + } + + $this->admin_notices[] = array( + 'class' => 'notice notice-info', + 'message' => $async_request_message, + ); + } + + $notification = get_transient( 'action_scheduler_admin_notice' ); + + if ( is_array( $notification ) ) { + delete_transient( 'action_scheduler_admin_notice' ); + + $action = $this->store->fetch_action( $notification['action_id'] ); + $action_hook_html = '' . $action->get_hook() . ''; + + if ( 1 === absint( $notification['success'] ) ) { + $class = 'updated'; + switch ( $notification['row_action_type'] ) { + case 'run': + /* translators: %s: action HTML */ + $action_message_html = sprintf( __( 'Successfully executed action: %s', 'action-scheduler' ), $action_hook_html ); + break; + case 'cancel': + /* translators: %s: action HTML */ + $action_message_html = sprintf( __( 'Successfully canceled action: %s', 'action-scheduler' ), $action_hook_html ); + break; + default: + /* translators: %s: action HTML */ + $action_message_html = sprintf( __( 'Successfully processed change for action: %s', 'action-scheduler' ), $action_hook_html ); + break; + } + } else { + $class = 'error'; + /* translators: 1: action HTML 2: action ID 3: error message */ + $action_message_html = sprintf( __( 'Could not process change for action: "%1$s" (ID: %2$d). Error: %3$s', 'action-scheduler' ), $action_hook_html, esc_html( $notification['action_id'] ), esc_html( $notification['error_message'] ) ); + } + + $action_message_html = apply_filters( 'action_scheduler_admin_notice_html', $action_message_html, $action, $notification ); + + $this->admin_notices[] = array( + 'class' => $class, + 'message' => $action_message_html, + ); + } + + parent::display_admin_notices(); + } + + /** + * Prints the scheduled date in a human friendly format. + * + * @param array $row The array representation of the current row of the table. + * + * @return string + */ + public function column_schedule( $row ) { + return $this->get_schedule_display_string( $row['schedule'] ); + } + + /** + * Get the scheduled date in a human friendly format. + * + * @param ActionScheduler_Schedule $schedule Action's schedule. + * @return string + */ + protected function get_schedule_display_string( ActionScheduler_Schedule $schedule ) { + + $schedule_display_string = ''; + + if ( is_a( $schedule, 'ActionScheduler_NullSchedule' ) ) { + return __( 'async', 'action-scheduler' ); + } + + if ( ! method_exists( $schedule, 'get_date' ) || ! $schedule->get_date() ) { + return '0000-00-00 00:00:00'; + } + + $next_timestamp = $schedule->get_date()->getTimestamp(); + + $schedule_display_string .= $schedule->get_date()->format( 'Y-m-d H:i:s O' ); + $schedule_display_string .= '
    '; + + if ( gmdate( 'U' ) > $next_timestamp ) { + /* translators: %s: date interval */ + $schedule_display_string .= sprintf( __( ' (%s ago)', 'action-scheduler' ), self::human_interval( gmdate( 'U' ) - $next_timestamp ) ); + } else { + /* translators: %s: date interval */ + $schedule_display_string .= sprintf( __( ' (%s)', 'action-scheduler' ), self::human_interval( $next_timestamp - gmdate( 'U' ) ) ); + } + + return $schedule_display_string; + } + + /** + * Bulk delete. + * + * Deletes actions based on their ID. This is the handler for the bulk delete. It assumes the data + * properly validated by the callee and it will delete the actions without any extra validation. + * + * @param int[] $ids Action IDs. + * @param string $ids_sql Inherited and unused. + */ + protected function bulk_delete( array $ids, $ids_sql ) { + foreach ( $ids as $id ) { + try { + $this->store->delete_action( $id ); + } catch ( Exception $e ) { + // A possible reason for an exception would include a scenario where the same action is deleted by a + // concurrent request. + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( + sprintf( + /* translators: 1: action ID 2: exception message. */ + __( 'Action Scheduler was unable to delete action %1$d. Reason: %2$s', 'action-scheduler' ), + $id, + $e->getMessage() + ) + ); + } + } + } + + /** + * Implements the logic behind running an action. ActionScheduler_Abstract_ListTable validates the request and their + * parameters are valid. + * + * @param int $action_id Action ID. + */ + protected function row_action_cancel( $action_id ) { + $this->process_row_action( $action_id, 'cancel' ); + } + + /** + * Implements the logic behind running an action. ActionScheduler_Abstract_ListTable validates the request and their + * parameters are valid. + * + * @param int $action_id Action ID. + */ + protected function row_action_run( $action_id ) { + $this->process_row_action( $action_id, 'run' ); + } + + /** + * Force the data store schema updates. + */ + protected function recreate_tables() { + if ( is_a( $this->store, 'ActionScheduler_HybridStore' ) ) { + $store = $this->store; + } else { + $store = new ActionScheduler_HybridStore(); + } + add_action( 'action_scheduler/created_table', array( $store, 'set_autoincrement' ), 10, 2 ); + + $store_schema = new ActionScheduler_StoreSchema(); + $logger_schema = new ActionScheduler_LoggerSchema(); + $store_schema->register_tables( true ); + $logger_schema->register_tables( true ); + + remove_action( 'action_scheduler/created_table', array( $store, 'set_autoincrement' ), 10 ); + } + /** + * Implements the logic behind processing an action once an action link is clicked on the list table. + * + * @param int $action_id Action ID. + * @param string $row_action_type The type of action to perform on the action. + */ + protected function process_row_action( $action_id, $row_action_type ) { + try { + switch ( $row_action_type ) { + case 'run': + $this->runner->process_action( $action_id, 'Admin List Table' ); + break; + case 'cancel': + $this->store->cancel_action( $action_id ); + break; + } + $success = 1; + $error_message = ''; + } catch ( Exception $e ) { + $success = 0; + $error_message = $e->getMessage(); + } + + set_transient( 'action_scheduler_admin_notice', compact( 'action_id', 'success', 'error_message', 'row_action_type' ), 30 ); + } + + /** + * {@inheritDoc} + */ + public function prepare_items() { + $this->prepare_column_headers(); + + $per_page = $this->get_items_per_page( $this->get_per_page_option_name(), $this->items_per_page ); + + $query = array( + 'per_page' => $per_page, + 'offset' => $this->get_items_offset(), + 'status' => $this->get_request_status(), + 'orderby' => $this->get_request_orderby(), + 'order' => $this->get_request_order(), + 'search' => $this->get_request_search_query(), + ); + + /** + * Change query arguments to query for past-due actions. + * Past-due actions have the 'pending' status and are in the past. + * This is needed because registering 'past-due' as a status is overkill. + */ + if ( 'past-due' === $this->get_request_status() ) { + $query['status'] = ActionScheduler_Store::STATUS_PENDING; + $query['date'] = as_get_datetime_object(); + } + + $this->items = array(); + + $total_items = $this->store->query_actions( $query, 'count' ); + + $status_labels = $this->store->get_status_labels(); + + foreach ( $this->store->query_actions( $query ) as $action_id ) { + try { + $action = $this->store->fetch_action( $action_id ); + } catch ( Exception $e ) { + continue; + } + if ( is_a( $action, 'ActionScheduler_NullAction' ) ) { + continue; + } + $this->items[ $action_id ] = array( + 'ID' => $action_id, + 'hook' => $action->get_hook(), + 'status_name' => $this->store->get_status( $action_id ), + 'status' => $status_labels[ $this->store->get_status( $action_id ) ], + 'args' => $action->get_args(), + 'group' => $action->get_group(), + 'log_entries' => $this->logger->get_logs( $action_id ), + 'claim_id' => $this->store->get_claim_id( $action_id ), + 'recurrence' => $this->get_recurrence( $action ), + 'schedule' => $action->get_schedule(), + ); + } + + $this->set_pagination_args( + array( + 'total_items' => $total_items, + 'per_page' => $per_page, + 'total_pages' => ceil( $total_items / $per_page ), + ) + ); + } + + /** + * Prints the available statuses so the user can click to filter. + */ + protected function display_filter_by_status() { + $this->status_counts = $this->store->action_counts() + $this->store->extra_action_counts(); + parent::display_filter_by_status(); + } + + /** + * Get the text to display in the search box on the list table. + */ + protected function get_search_box_button_text() { + return __( 'Search hook, args and claim ID', 'action-scheduler' ); + } + + /** + * {@inheritDoc} + */ + protected function get_per_page_option_name() { + return str_replace( '-', '_', $this->screen->id ) . '_per_page'; + } +} diff --git a/classes/ActionScheduler_LogEntry.php b/classes/ActionScheduler_LogEntry.php new file mode 100644 index 0000000..726fea2 --- /dev/null +++ b/classes/ActionScheduler_LogEntry.php @@ -0,0 +1,78 @@ +comment_type + * to ActionScheduler_LogEntry::__construct(), goodness knows why, and the Follow-up Emails plugin + * hard-codes loading its own version of ActionScheduler_wpCommentLogger with that out-dated method, + * goodness knows why, so we need to guard against that here instead of using a DateTime type declaration + * for the constructor's 3rd param of $date and causing a fatal error with older versions of FUE. + */ + if ( null !== $date && ! is_a( $date, 'DateTime' ) ) { + _doing_it_wrong( __METHOD__, 'The third parameter must be a valid DateTime instance, or null.', '2.0.0' ); + $date = null; + } + + $this->action_id = $action_id; + $this->message = $message; + $this->date = $date ? $date : new Datetime(); + } + + /** + * Returns the date when this log entry was created + * + * @return Datetime + */ + public function get_date() { + return $this->date; + } + + /** + * Get action ID of log entry. + */ + public function get_action_id() { + return $this->action_id; + } + + /** + * Get log entry message. + */ + public function get_message() { + return $this->message; + } +} + diff --git a/classes/ActionScheduler_NullLogEntry.php b/classes/ActionScheduler_NullLogEntry.php new file mode 100644 index 0000000..4df0f05 --- /dev/null +++ b/classes/ActionScheduler_NullLogEntry.php @@ -0,0 +1,18 @@ +maybe_dispatch_async_request() uses a lock to avoid + * calling ActionScheduler_QueueRunner->has_maximum_concurrent_batches() every time the 'shutdown', + * hook is triggered, because that method calls ActionScheduler_QueueRunner->store->get_claim_count() + * to find the current number of claims in the database. + * + * @param string $lock_type A string to identify different lock types. + * @bool True if lock value has changed, false if not or if set failed. + */ + public function set( $lock_type ) { + global $wpdb; + + $lock_key = $this->get_key( $lock_type ); + $existing_lock_value = $this->get_existing_lock( $lock_type ); + $new_lock_value = $this->new_lock_value( $lock_type ); + + // The lock may not exist yet, or may have been deleted. + if ( empty( $existing_lock_value ) ) { + return (bool) $wpdb->insert( + $wpdb->options, + array( + 'option_name' => $lock_key, + 'option_value' => $new_lock_value, + 'autoload' => 'no', + ) + ); + } + + if ( $this->get_expiration_from( $existing_lock_value ) >= time() ) { + return false; + } + + // Otherwise, try to obtain the lock. + return (bool) $wpdb->update( + $wpdb->options, + array( 'option_value' => $new_lock_value ), + array( + 'option_name' => $lock_key, + 'option_value' => $existing_lock_value, + ) + ); + } + + /** + * If a lock is set, return the timestamp it was set to expiry. + * + * @param string $lock_type A string to identify different lock types. + * @return bool|int False if no lock is set, otherwise the timestamp for when the lock is set to expire. + */ + public function get_expiration( $lock_type ) { + return $this->get_expiration_from( $this->get_existing_lock( $lock_type ) ); + } + + /** + * Given the lock string, derives the lock expiration timestamp (or false if it cannot be determined). + * + * @param string $lock_value String containing a timestamp, or pipe-separated combination of unique value and timestamp. + * + * @return false|int + */ + private function get_expiration_from( $lock_value ) { + $lock_string = explode( '|', $lock_value ); + + // Old style lock? + if ( count( $lock_string ) === 1 && is_numeric( $lock_string[0] ) ) { + return (int) $lock_string[0]; + } + + // New style lock? + if ( count( $lock_string ) === 2 && is_numeric( $lock_string[1] ) ) { + return (int) $lock_string[1]; + } + + return false; + } + + /** + * Get the key to use for storing the lock in the transient + * + * @param string $lock_type A string to identify different lock types. + * @return string + */ + protected function get_key( $lock_type ) { + return sprintf( 'action_scheduler_lock_%s', $lock_type ); + } + + /** + * Supplies the existing lock value, or an empty string if not set. + * + * @param string $lock_type A string to identify different lock types. + * + * @return string + */ + private function get_existing_lock( $lock_type ) { + global $wpdb; + + // Now grab the existing lock value, if there is one. + return (string) $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM $wpdb->options WHERE option_name = %s", + $this->get_key( $lock_type ) + ) + ); + } + + /** + * Supplies a lock value consisting of a unique value and the current timestamp, which are separated by a pipe + * character. + * + * Example: (string) "649de012e6b262.09774912|1688068114" + * + * @param string $lock_type A string to identify different lock types. + * + * @return string + */ + private function new_lock_value( $lock_type ) { + return uniqid( '', true ) . '|' . ( time() + $this->get_duration( $lock_type ) ); + } +} diff --git a/classes/ActionScheduler_QueueCleaner.php b/classes/ActionScheduler_QueueCleaner.php new file mode 100644 index 0000000..7029d0b --- /dev/null +++ b/classes/ActionScheduler_QueueCleaner.php @@ -0,0 +1,254 @@ +store = $store ? $store : ActionScheduler_Store::instance(); + $this->batch_size = $batch_size; + } + + /** + * Default queue cleaner process used by queue runner. + * + * @return array + */ + public function delete_old_actions() { + /** + * Filter the minimum scheduled date age for action deletion. + * + * @param int $retention_period Minimum scheduled age in seconds of the actions to be deleted. + */ + $lifespan = apply_filters( 'action_scheduler_retention_period', $this->month_in_seconds ); + + try { + $cutoff = as_get_datetime_object( $lifespan . ' seconds ago' ); + } catch ( Exception $e ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* Translators: %s is the exception message. */ + esc_html__( 'It was not possible to determine a valid cut-off time: %s.', 'action-scheduler' ), + esc_html( $e->getMessage() ) + ), + '3.5.5' + ); + + return array(); + } + + /** + * Filter the statuses when cleaning the queue. + * + * @param string[] $default_statuses_to_purge Action statuses to clean. + */ + $statuses_to_purge = (array) apply_filters( 'action_scheduler_default_cleaner_statuses', $this->default_statuses_to_purge ); + + return $this->clean_actions( $statuses_to_purge, $cutoff, $this->get_batch_size() ); + } + + /** + * Delete selected actions limited by status and date. + * + * @param string[] $statuses_to_purge List of action statuses to purge. Defaults to canceled, complete. + * @param DateTime $cutoff_date Date limit for selecting actions. Defaults to 31 days ago. + * @param int|null $batch_size Maximum number of actions per status to delete. Defaults to 20. + * @param string $context Calling process context. Defaults to `old`. + * @return array Actions deleted. + */ + public function clean_actions( array $statuses_to_purge, DateTime $cutoff_date, $batch_size = null, $context = 'old' ) { + $batch_size = ! is_null( $batch_size ) ? $batch_size : $this->batch_size; + $cutoff = ! is_null( $cutoff_date ) ? $cutoff_date : as_get_datetime_object( $this->month_in_seconds . ' seconds ago' ); + $lifespan = time() - $cutoff->getTimestamp(); + + if ( empty( $statuses_to_purge ) ) { + $statuses_to_purge = $this->default_statuses_to_purge; + } + + $deleted_actions = array(); + + foreach ( $statuses_to_purge as $status ) { + $actions_to_delete = $this->store->query_actions( + array( + 'status' => $status, + 'modified' => $cutoff, + 'modified_compare' => '<=', + 'per_page' => $batch_size, + 'orderby' => 'none', + ) + ); + + $deleted_actions = array_merge( $deleted_actions, $this->delete_actions( $actions_to_delete, $lifespan, $context ) ); + } + + return $deleted_actions; + } + + /** + * Delete actions. + * + * @param int[] $actions_to_delete List of action IDs to delete. + * @param int $lifespan Minimum scheduled age in seconds of the actions being deleted. + * @param string $context Context of the delete request. + * @return array Deleted action IDs. + */ + private function delete_actions( array $actions_to_delete, $lifespan = null, $context = 'old' ) { + $deleted_actions = array(); + + if ( is_null( $lifespan ) ) { + $lifespan = $this->month_in_seconds; + } + + foreach ( $actions_to_delete as $action_id ) { + try { + $this->store->delete_action( $action_id ); + $deleted_actions[] = $action_id; + } catch ( Exception $e ) { + /** + * Notify 3rd party code of exceptions when deleting a completed action older than the retention period + * + * This hook provides a way for 3rd party code to log or otherwise handle exceptions relating to their + * actions. + * + * @param int $action_id The scheduled actions ID in the data store + * @param Exception $e The exception thrown when attempting to delete the action from the data store + * @param int $lifespan The retention period, in seconds, for old actions + * @param int $count_of_actions_to_delete The number of old actions being deleted in this batch + * @since 2.0.0 + */ + do_action( "action_scheduler_failed_{$context}_action_deletion", $action_id, $e, $lifespan, count( $actions_to_delete ) ); + } + } + return $deleted_actions; + } + + /** + * Unclaim pending actions that have not been run within a given time limit. + * + * When called by ActionScheduler_Abstract_QueueRunner::run_cleanup(), the time limit passed + * as a parameter is 10x the time limit used for queue processing. + * + * @param int $time_limit The number of seconds to allow a queue to run before unclaiming its pending actions. Default 300 (5 minutes). + */ + public function reset_timeouts( $time_limit = 300 ) { + $timeout = apply_filters( 'action_scheduler_timeout_period', $time_limit ); + + if ( $timeout < 0 ) { + return; + } + + $cutoff = as_get_datetime_object( $timeout . ' seconds ago' ); + $actions_to_reset = $this->store->query_actions( + array( + 'status' => ActionScheduler_Store::STATUS_PENDING, + 'modified' => $cutoff, + 'modified_compare' => '<=', + 'claimed' => true, + 'per_page' => $this->get_batch_size(), + 'orderby' => 'none', + ) + ); + + foreach ( $actions_to_reset as $action_id ) { + $this->store->unclaim_action( $action_id ); + do_action( 'action_scheduler_reset_action', $action_id ); + } + } + + /** + * Mark actions that have been running for more than a given time limit as failed, based on + * the assumption some uncatchable and unloggable fatal error occurred during processing. + * + * When called by ActionScheduler_Abstract_QueueRunner::run_cleanup(), the time limit passed + * as a parameter is 10x the time limit used for queue processing. + * + * @param int $time_limit The number of seconds to allow an action to run before it is considered to have failed. Default 300 (5 minutes). + */ + public function mark_failures( $time_limit = 300 ) { + $timeout = apply_filters( 'action_scheduler_failure_period', $time_limit ); + + if ( $timeout < 0 ) { + return; + } + + $cutoff = as_get_datetime_object( $timeout . ' seconds ago' ); + $actions_to_reset = $this->store->query_actions( + array( + 'status' => ActionScheduler_Store::STATUS_RUNNING, + 'modified' => $cutoff, + 'modified_compare' => '<=', + 'per_page' => $this->get_batch_size(), + 'orderby' => 'none', + ) + ); + + foreach ( $actions_to_reset as $action_id ) { + $this->store->mark_failure( $action_id ); + do_action( 'action_scheduler_failed_action', $action_id, $timeout ); + } + } + + /** + * Do all of the cleaning actions. + * + * @param int $time_limit The number of seconds to use as the timeout and failure period. Default 300 (5 minutes). + */ + public function clean( $time_limit = 300 ) { + $this->delete_old_actions(); + $this->reset_timeouts( $time_limit ); + $this->mark_failures( $time_limit ); + } + + /** + * Get the batch size for cleaning the queue. + * + * @return int + */ + protected function get_batch_size() { + /** + * Filter the batch size when cleaning the queue. + * + * @param int $batch_size The number of actions to clean in one batch. + */ + return absint( apply_filters( 'action_scheduler_cleanup_batch_size', $this->batch_size ) ); + } +} diff --git a/classes/ActionScheduler_QueueRunner.php b/classes/ActionScheduler_QueueRunner.php new file mode 100644 index 0000000..13a71e7 --- /dev/null +++ b/classes/ActionScheduler_QueueRunner.php @@ -0,0 +1,254 @@ +store ); + } + + $this->async_request = $async_request; + } + + /** + * Initialize. + * + * @codeCoverageIgnore + */ + public function init() { + + add_filter( 'cron_schedules', array( self::instance(), 'add_wp_cron_schedule' ) ); // phpcs:ignore WordPress.WP.CronInterval.CronSchedulesInterval + + // Check for and remove any WP Cron hook scheduled by Action Scheduler < 3.0.0, which didn't include the $context param. + $next_timestamp = wp_next_scheduled( self::WP_CRON_HOOK ); + if ( $next_timestamp ) { + wp_unschedule_event( $next_timestamp, self::WP_CRON_HOOK ); + } + + $cron_context = array( 'WP Cron' ); + + if ( ! wp_next_scheduled( self::WP_CRON_HOOK, $cron_context ) ) { + $schedule = apply_filters( 'action_scheduler_run_schedule', self::WP_CRON_SCHEDULE ); + wp_schedule_event( time(), $schedule, self::WP_CRON_HOOK, $cron_context ); + } + + add_action( self::WP_CRON_HOOK, array( self::instance(), 'run' ) ); + $this->hook_dispatch_async_request(); + } + + /** + * Hook check for dispatching an async request. + */ + public function hook_dispatch_async_request() { + add_action( 'shutdown', array( $this, 'maybe_dispatch_async_request' ) ); + } + + /** + * Unhook check for dispatching an async request. + */ + public function unhook_dispatch_async_request() { + remove_action( 'shutdown', array( $this, 'maybe_dispatch_async_request' ) ); + } + + /** + * Check if we should dispatch an async request to process actions. + * + * This method is attached to 'shutdown', so is called frequently. To avoid slowing down + * the site, it mitigates the work performed in each request by: + * 1. checking if it's in the admin context and then + * 2. haven't run on the 'shutdown' hook within the lock time (60 seconds by default) + * 3. haven't exceeded the number of allowed batches. + * + * The order of these checks is important, because they run from a check on a value: + * 1. in memory - is_admin() maps to $GLOBALS or the WP_ADMIN constant + * 2. in memory - transients use autoloaded options by default + * 3. from a database query - has_maximum_concurrent_batches() run the query + * $this->store->get_claim_count() to find the current number of claims in the DB. + * + * If all of these conditions are met, then we request an async runner check whether it + * should dispatch a request to process pending actions. + */ + public function maybe_dispatch_async_request() { + // Only start an async queue at most once every 60 seconds. + if ( + is_admin() + && ! ActionScheduler::lock()->is_locked( 'async-request-runner' ) + && ActionScheduler::lock()->set( 'async-request-runner' ) + ) { + $this->async_request->maybe_dispatch(); + } + } + + /** + * Process actions in the queue. Attached to self::WP_CRON_HOOK i.e. 'action_scheduler_run_queue' + * + * The $context param of this method defaults to 'WP Cron', because prior to Action Scheduler 3.0.0 + * that was the only context in which this method was run, and the self::WP_CRON_HOOK hook had no context + * passed along with it. New code calling this method directly, or by triggering the self::WP_CRON_HOOK, + * should set a context as the first parameter. For an example of this, refer to the code seen in + * + * @see ActionScheduler_AsyncRequest_QueueRunner::handle() + * + * @param string $context Optional identifier for the context in which this action is being processed, e.g. 'WP CLI' or 'WP Cron' + * Generally, this should be capitalised and not localised as it's a proper noun. + * @return int The number of actions processed. + */ + public function run( $context = 'WP Cron' ) { + ActionScheduler_Compatibility::raise_memory_limit(); + ActionScheduler_Compatibility::raise_time_limit( $this->get_time_limit() ); + do_action( 'action_scheduler_before_process_queue' ); + $this->run_cleanup(); + + $this->processed_actions_count = 0; + if ( false === $this->has_maximum_concurrent_batches() ) { + do { + $batch_size = apply_filters( 'action_scheduler_queue_runner_batch_size', 25 ); + $processed_actions_in_batch = $this->do_batch( $batch_size, $context ); + $this->processed_actions_count += $processed_actions_in_batch; + } while ( $processed_actions_in_batch > 0 && ! $this->batch_limits_exceeded( $this->processed_actions_count ) ); // keep going until we run out of actions, time, or memory. + } + + do_action( 'action_scheduler_after_process_queue' ); + return $this->processed_actions_count; + } + + /** + * Process a batch of actions pending in the queue. + * + * Actions are processed by claiming a set of pending actions then processing each one until either the batch + * size is completed, or memory or time limits are reached, defined by @see $this->batch_limits_exceeded(). + * + * @param int $size The maximum number of actions to process in the batch. + * @param string $context Optional identifier for the context in which this action is being processed, e.g. 'WP CLI' or 'WP Cron' + * Generally, this should be capitalised and not localised as it's a proper noun. + * @return int The number of actions processed. + */ + protected function do_batch( $size = 100, $context = '' ) { + $claim = $this->store->stake_claim( $size ); + $this->monitor->attach( $claim ); + $processed_actions = 0; + + foreach ( $claim->get_actions() as $action_id ) { + // bail if we lost the claim. + if ( ! in_array( $action_id, $this->store->find_actions_by_claim_id( $claim->get_id() ), true ) ) { + break; + } + $this->process_action( $action_id, $context ); + $processed_actions++; + + if ( $this->batch_limits_exceeded( $processed_actions + $this->processed_actions_count ) ) { + break; + } + } + $this->store->release_claim( $claim ); + $this->monitor->detach(); + $this->clear_caches(); + return $processed_actions; + } + + /** + * Flush the cache if possible (intended for use after a batch of actions has been processed). + * + * This is useful because running large batches can eat up memory and because invalid data can accrue in the + * runtime cache, which may lead to unexpected results. + */ + protected function clear_caches() { + /* + * Calling wp_cache_flush_runtime() lets us clear the runtime cache without invalidating the external object + * cache, so we will always prefer this method (as compared to calling wp_cache_flush()) when it is available. + * + * However, this function was only introduced in WordPress 6.0. Additionally, the preferred way of detecting if + * it is supported changed in WordPress 6.1 so we use two different methods to decide if we should utilize it. + */ + $flushing_runtime_cache_explicitly_supported = function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_runtime' ); + $flushing_runtime_cache_implicitly_supported = ! function_exists( 'wp_cache_supports' ) && function_exists( 'wp_cache_flush_runtime' ); + + if ( $flushing_runtime_cache_explicitly_supported || $flushing_runtime_cache_implicitly_supported ) { + wp_cache_flush_runtime(); + } elseif ( + ! wp_using_ext_object_cache() + /** + * When an external object cache is in use, and when wp_cache_flush_runtime() is not available, then + * normally the cache will not be flushed after processing a batch of actions (to avoid a performance + * penalty for other processes). + * + * This filter makes it possible to override this behavior and always flush the cache, even if an external + * object cache is in use. + * + * @since 1.0 + * + * @param bool $flush_cache If the cache should be flushed. + */ + || apply_filters( 'action_scheduler_queue_runner_flush_cache', false ) + ) { + wp_cache_flush(); + } + } + + /** + * Add schedule to WP cron. + * + * @param array> $schedules Schedules. + * @return array> + */ + public function add_wp_cron_schedule( $schedules ) { + $schedules['every_minute'] = array( + 'interval' => 60, // in seconds. + 'display' => __( 'Every minute', 'action-scheduler' ), + ); + + return $schedules; + } +} diff --git a/classes/ActionScheduler_SystemInformation.php b/classes/ActionScheduler_SystemInformation.php new file mode 100644 index 0000000..188751e --- /dev/null +++ b/classes/ActionScheduler_SystemInformation.php @@ -0,0 +1,93 @@ + 'plugin', # or 'theme' + * 'name' => 'Name', + * ] + * + * @return array + */ + public static function active_source(): array { + $plugins = get_plugins(); + $plugin_files = array_keys( $plugins ); + + foreach ( $plugin_files as $plugin_file ) { + $plugin_path = trailingslashit( WP_PLUGIN_DIR ) . dirname( $plugin_file ); + $plugin_file = trailingslashit( WP_PLUGIN_DIR ) . $plugin_file; + + if ( 0 !== strpos( dirname( __DIR__ ), $plugin_path ) ) { + continue; + } + + $plugin_data = get_plugin_data( $plugin_file ); + + if ( ! is_array( $plugin_data ) || empty( $plugin_data['Name'] ) ) { + continue; + } + + return array( + 'type' => 'plugin', + 'name' => $plugin_data['Name'], + ); + } + + $themes = (array) search_theme_directories(); + + foreach ( $themes as $slug => $data ) { + $needle = trailingslashit( $data['theme_root'] ) . $slug . '/'; + + if ( 0 !== strpos( __FILE__, $needle ) ) { + continue; + } + + $theme = wp_get_theme( $slug ); + + if ( ! is_object( $theme ) || ! is_a( $theme, \WP_Theme::class ) ) { + continue; + } + + return array( + 'type' => 'theme', + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'name' => $theme->Name, + ); + } + + return array(); + } + + /** + * Returns the directory path for the currently active installation of Action Scheduler. + * + * @return string + */ + public static function active_source_path(): string { + return trailingslashit( dirname( __DIR__ ) ); + } + + /** + * Get registered sources. + * + * It is not always possible to obtain this information. For instance, if earlier versions (<=3.9.0) of + * Action Scheduler register themselves first, then the necessary data about registered sources will + * not be available. + * + * @return array + */ + public static function get_sources() { + $versions = ActionScheduler_Versions::instance(); + return method_exists( $versions, 'get_sources' ) ? $versions->get_sources() : array(); + } +} diff --git a/classes/ActionScheduler_Versions.php b/classes/ActionScheduler_Versions.php new file mode 100644 index 0000000..c23e464 --- /dev/null +++ b/classes/ActionScheduler_Versions.php @@ -0,0 +1,151 @@ + + */ + private $versions = array(); + + /** + * Registered sources. + * + * @var array + */ + private $sources = array(); + + /** + * Register version's callback. + * + * @param string $version_string Action Scheduler version. + * @param callable $initialization_callback Callback to initialize the version. + */ + public function register( $version_string, $initialization_callback ) { + if ( isset( $this->versions[ $version_string ] ) ) { + return false; + } + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace + $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); + $source = $backtrace[0]['file']; + + $this->versions[ $version_string ] = $initialization_callback; + $this->sources[ $source ] = $version_string; + return true; + } + + /** + * Get all versions. + */ + public function get_versions() { + return $this->versions; + } + + /** + * Get registered sources. + * + * Use with caution: this method is only available as of Action Scheduler's 3.9.1 + * release and, owing to the way Action Scheduler is loaded, it's possible that the + * class definition used at runtime will belong to an earlier version. + * + * @since 3.9.1 + * + * @return array + */ + public function get_sources() { + return $this->sources; + } + + /** + * Get latest version registered. + */ + public function latest_version() { + $keys = array_keys( $this->versions ); + if ( empty( $keys ) ) { + return false; + } + uasort( $keys, 'version_compare' ); + return end( $keys ); + } + + /** + * Get callback for latest registered version. + */ + public function latest_version_callback() { + $latest = $this->latest_version(); + + if ( empty( $latest ) || ! isset( $this->versions[ $latest ] ) ) { + return '__return_null'; + } + + return $this->versions[ $latest ]; + } + + /** + * Get instance. + * + * @return ActionScheduler_Versions + * @codeCoverageIgnore + */ + public static function instance() { + if ( empty( self::$instance ) ) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Initialize. + * + * @codeCoverageIgnore + */ + public static function initialize_latest_version() { + $self = self::instance(); + call_user_func( $self->latest_version_callback() ); + } + + /** + * Returns information about the plugin or theme which contains the current active version + * of Action Scheduler. + * + * If this cannot be determined, or if Action Scheduler is being loaded via some other + * method, then it will return an empty array. Otherwise, if populated, the array will + * look like the following: + * + * [ + * 'type' => 'plugin', # or 'theme' + * 'name' => 'Name', + * ] + * + * @deprecated 3.9.2 Use ActionScheduler_SystemInformation::active_source(). + * + * @return array + */ + public function active_source(): array { + _deprecated_function( __METHOD__, '3.9.2', 'ActionScheduler_SystemInformation::active_source()' ); + return ActionScheduler_SystemInformation::active_source(); + } + + /** + * Returns the directory path for the currently active installation of Action Scheduler. + * + * @deprecated 3.9.2 Use ActionScheduler_SystemInformation::active_source_path(). + * + * @return string + */ + public function active_source_path(): string { + _deprecated_function( __METHOD__, '3.9.2', 'ActionScheduler_SystemInformation::active_source_path()' ); + return ActionScheduler_SystemInformation::active_source_path(); + } +} diff --git a/classes/ActionScheduler_WPCommentCleaner.php b/classes/ActionScheduler_WPCommentCleaner.php new file mode 100644 index 0000000..2725d1f --- /dev/null +++ b/classes/ActionScheduler_WPCommentCleaner.php @@ -0,0 +1,133 @@ + Status administration screen. + add_action( 'load-tools_page_action-scheduler', array( __CLASS__, 'register_admin_notice' ) ); + add_action( 'load-woocommerce_page_wc-status', array( __CLASS__, 'register_admin_notice' ) ); + } + + /** + * Determines if there are log entries in the wp comments table. + * + * Uses the flag set on migration completion set by @see self::maybe_schedule_cleanup(). + * + * @return boolean Whether there are scheduled action comments in the comments table. + */ + public static function has_logs() { + return 'yes' === get_option( self::$has_logs_option_key ); + } + + /** + * Schedules the WP Post comment table cleanup to run in 6 months if it's not already scheduled. + * Attached to the migration complete hook 'action_scheduler/migration_complete'. + */ + public static function maybe_schedule_cleanup() { + $has_logs = 'no'; + + $args = array( + 'type' => ActionScheduler_wpCommentLogger::TYPE, + 'number' => 1, + 'fields' => 'ids', + ); + + if ( (bool) get_comments( $args ) ) { + $has_logs = 'yes'; + + if ( ! as_next_scheduled_action( self::$cleanup_hook ) ) { + as_schedule_single_action( gmdate( 'U' ) + ( 6 * MONTH_IN_SECONDS ), self::$cleanup_hook ); + } + } + + update_option( self::$has_logs_option_key, $has_logs, true ); + } + + /** + * Delete all action comments from the WP Comments table. + */ + public static function delete_all_action_comments() { + global $wpdb; + + $wpdb->delete( + $wpdb->comments, + array( + 'comment_type' => ActionScheduler_wpCommentLogger::TYPE, + 'comment_agent' => ActionScheduler_wpCommentLogger::AGENT, + ) + ); + + update_option( self::$has_logs_option_key, 'no', true ); + } + + /** + * Registers admin notices about the orphaned action logs. + */ + public static function register_admin_notice() { + add_action( 'admin_notices', array( __CLASS__, 'print_admin_notice' ) ); + } + + /** + * Prints details about the orphaned action logs and includes information on where to learn more. + */ + public static function print_admin_notice() { + $next_cleanup_message = ''; + $next_scheduled_cleanup_hook = as_next_scheduled_action( self::$cleanup_hook ); + + if ( $next_scheduled_cleanup_hook ) { + /* translators: %s: date interval */ + $next_cleanup_message = sprintf( __( 'This data will be deleted in %s.', 'action-scheduler' ), human_time_diff( gmdate( 'U' ), $next_scheduled_cleanup_hook ) ); + } + + $notice = sprintf( + /* translators: 1: next cleanup message 2: github issue URL */ + __( 'Action Scheduler has migrated data to custom tables; however, orphaned log entries exist in the WordPress Comments table. %1$s Learn more »', 'action-scheduler' ), + $next_cleanup_message, + 'https://github.com/woocommerce/action-scheduler/issues/368' + ); + + echo '

    ' . wp_kses_post( $notice ) . '

    '; + } +} diff --git a/classes/ActionScheduler_wcSystemStatus.php b/classes/ActionScheduler_wcSystemStatus.php new file mode 100644 index 0000000..bca63e7 --- /dev/null +++ b/classes/ActionScheduler_wcSystemStatus.php @@ -0,0 +1,166 @@ +store = $store; + } + + /** + * Display action data, including number of actions grouped by status and the oldest & newest action in each status. + * + * Helpful to identify issues, like a clogged queue. + */ + public function render() { + $action_counts = $this->store->action_counts(); + $status_labels = $this->store->get_status_labels(); + $oldest_and_newest = $this->get_oldest_and_newest( array_keys( $status_labels ) ); + + $this->get_template( $status_labels, $action_counts, $oldest_and_newest ); + } + + /** + * Get oldest and newest scheduled dates for a given set of statuses. + * + * @param array $status_keys Set of statuses to find oldest & newest action for. + * @return array + */ + protected function get_oldest_and_newest( $status_keys ) { + + $oldest_and_newest = array(); + + foreach ( $status_keys as $status ) { + $oldest_and_newest[ $status ] = array( + 'oldest' => '–', + 'newest' => '–', + ); + + if ( 'in-progress' === $status ) { + continue; + } + + $oldest_and_newest[ $status ]['oldest'] = $this->get_action_status_date( $status, 'oldest' ); + $oldest_and_newest[ $status ]['newest'] = $this->get_action_status_date( $status, 'newest' ); + } + + return $oldest_and_newest; + } + + /** + * Get oldest or newest scheduled date for a given status. + * + * @param string $status Action status label/name string. + * @param string $date_type Oldest or Newest. + * @return DateTime + */ + protected function get_action_status_date( $status, $date_type = 'oldest' ) { + + $order = 'oldest' === $date_type ? 'ASC' : 'DESC'; + + $action = $this->store->query_actions( + array( + 'claimed' => false, + 'status' => $status, + 'per_page' => 1, + 'order' => $order, + ) + ); + + if ( ! empty( $action ) ) { + $date_object = $this->store->get_date( $action[0] ); + $action_date = $date_object->format( 'Y-m-d H:i:s O' ); + } else { + $action_date = '–'; + } + + return $action_date; + } + + /** + * Get oldest or newest scheduled date for a given status. + * + * @param array $status_labels Set of statuses to find oldest & newest action for. + * @param array $action_counts Number of actions grouped by status. + * @param array $oldest_and_newest Date of the oldest and newest action with each status. + */ + protected function get_template( $status_labels, $action_counts, $oldest_and_newest ) { + $as_version = ActionScheduler_Versions::instance()->latest_version(); + $as_datastore = get_class( ActionScheduler_Store::instance() ); + ?> + + + + + + + + + + + + + + + + + + + + + + + + $count ) { + // WC uses the 3rd column for export, so we need to display more data in that (hidden when viewed as part of the table) and add an empty 2nd column. + printf( + '', + esc_html( $status_labels[ $status ] ), + esc_html( number_format_i18n( $count ) ), + esc_html( $oldest_and_newest[ $status ]['oldest'] ), + esc_html( $oldest_and_newest[ $status ]['newest'] ) + ); + } + ?> + +

     
    %1$s %2$s, Oldest: %3$s, Newest: %4$s%3$s%4$s
    + + assoc_args, 'group', '' ); + $callback_args = get_flag_value( $this->assoc_args, 'args', null ); + $all = get_flag_value( $this->assoc_args, 'all', false ); + + if ( ! empty( $this->args[0] ) ) { + $hook = $this->args[0]; + } + + if ( ! empty( $callback_args ) ) { + $callback_args = json_decode( $callback_args, true ); + } + + if ( $all ) { + $this->cancel_all( $hook, $callback_args, $group ); + return; + } + + $this->cancel_single( $hook, $callback_args, $group ); + } + + /** + * Cancel single action. + * + * @param string $hook The hook that the job will trigger. + * @param array $callback_args Args that would have been passed to the job. + * @param string $group The group the job is assigned to. + * @return void + */ + protected function cancel_single( $hook, $callback_args, $group ) { + if ( empty( $hook ) ) { + \WP_CLI::error( __( 'Please specify hook of action to cancel.', 'action-scheduler' ) ); + } + + try { + $result = as_unschedule_action( $hook, $callback_args, $group ); + } catch ( \Exception $e ) { + $this->print_error( $e, false ); + } + + if ( null === $result ) { + $e = new \Exception( __( 'Unable to cancel scheduled action: check the logs.', 'action-scheduler' ) ); + $this->print_error( $e, false ); + } + + $this->print_success( false ); + } + + /** + * Cancel all actions. + * + * @param string $hook The hook that the job will trigger. + * @param array $callback_args Args that would have been passed to the job. + * @param string $group The group the job is assigned to. + * @return void + */ + protected function cancel_all( $hook, $callback_args, $group ) { + if ( empty( $hook ) && empty( $group ) ) { + \WP_CLI::error( __( 'Please specify hook and/or group of actions to cancel.', 'action-scheduler' ) ); + } + + try { + $result = as_unschedule_all_actions( $hook, $callback_args, $group ); + } catch ( \Exception $e ) { + $this->print_error( $e, $multiple ); + } + + /** + * Because as_unschedule_all_actions() does not provide a result, + * neither confirm or deny actions cancelled. + */ + \WP_CLI::success( __( 'Request to cancel scheduled actions completed.', 'action-scheduler' ) ); + } + + /** + * Print a success message. + * + * @return void + */ + protected function print_success() { + \WP_CLI::success( __( 'Scheduled action cancelled.', 'action-scheduler' ) ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param \Exception $e The error object. + * @param bool $multiple Boolean if multiple actions. + * @throws \WP_CLI\ExitException When an error occurs. + * @return void + */ + protected function print_error( \Exception $e, $multiple ) { + \WP_CLI::error( + sprintf( + /* translators: %1$s: singular or plural %2$s: refers to the exception error message. */ + __( 'There was an error cancelling the %1$s: %2$s', 'action-scheduler' ), + $multiple ? __( 'scheduled actions', 'action-scheduler' ) : __( 'scheduled action', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + +} diff --git a/classes/WP_CLI/Action/Create_Command.php b/classes/WP_CLI/Action/Create_Command.php new file mode 100644 index 0000000..fedd417 --- /dev/null +++ b/classes/WP_CLI/Action/Create_Command.php @@ -0,0 +1,151 @@ +args[0]; + $schedule_start = $this->args[1]; + $callback_args = get_flag_value( $this->assoc_args, 'args', array() ); + $group = get_flag_value( $this->assoc_args, 'group', '' ); + $interval = absint( get_flag_value( $this->assoc_args, 'interval', 0 ) ); + $cron = get_flag_value( $this->assoc_args, 'cron', '' ); + $unique = get_flag_value( $this->assoc_args, 'unique', false ); + $priority = absint( get_flag_value( $this->assoc_args, 'priority', 10 ) ); + + if ( ! empty( $callback_args ) ) { + $callback_args = json_decode( $callback_args, true ); + } + + $function_args = array( + 'start' => $schedule_start, + 'cron' => $cron, + 'interval' => $interval, + 'hook' => $hook, + 'callback_args' => $callback_args, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ); + + try { + // Generate schedule start if appropriate. + if ( ! in_array( $schedule_start, static::ASYNC_OPTS, true ) ) { + $schedule_start = as_get_datetime_object( $schedule_start ); + $function_args['start'] = $schedule_start->format( 'U' ); + } + } catch ( \Exception $e ) { + \WP_CLI::error( $e->getMessage() ); + } + + // Default to creating single action. + $action_type = 'single'; + $function = 'as_schedule_single_action'; + + if ( ! empty( $interval ) ) { // Creating recurring action. + $action_type = 'recurring'; + $function = 'as_schedule_recurring_action'; + + $function_args = array_filter( + $function_args, + static function( $key ) { + return in_array( $key, array( 'start', 'interval', 'hook', 'callback_args', 'group', 'unique', 'priority' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + } elseif ( ! empty( $cron ) ) { // Creating cron action. + $action_type = 'cron'; + $function = 'as_schedule_cron_action'; + + $function_args = array_filter( + $function_args, + static function( $key ) { + return in_array( $key, array( 'start', 'cron', 'hook', 'callback_args', 'group', 'unique', 'priority' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + } elseif ( in_array( $function_args['start'], static::ASYNC_OPTS, true ) ) { // Enqueue async action. + $action_type = 'async'; + $function = 'as_enqueue_async_action'; + + $function_args = array_filter( + $function_args, + static function( $key ) { + return in_array( $key, array( 'hook', 'callback_args', 'group', 'unique', 'priority' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + } else { // Enqueue single action. + $function_args = array_filter( + $function_args, + static function( $key ) { + return in_array( $key, array( 'start', 'hook', 'callback_args', 'group', 'unique', 'priority' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + } + + $function_args = array_values( $function_args ); + + try { + $action_id = call_user_func_array( $function, $function_args ); + } catch ( \Exception $e ) { + $this->print_error( $e ); + } + + if ( 0 === $action_id ) { + $e = new \Exception( __( 'Unable to create a scheduled action.', 'action-scheduler' ) ); + $this->print_error( $e ); + } + + $this->print_success( $action_id, $action_type ); + } + + /** + * Print a success message with the action ID. + * + * @param int $action_id Created action ID. + * @param string $action_type Type of action. + * + * @return void + */ + protected function print_success( $action_id, $action_type ) { + \WP_CLI::success( + sprintf( + /* translators: %1$s: type of action, %2$d: ID of the created action */ + __( '%1$s action (%2$d) scheduled.', 'action-scheduler' ), + ucfirst( $action_type ), + $action_id + ) + ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param \Exception $e The error object. + * @throws \WP_CLI\ExitException When an error occurs. + * @return void + */ + protected function print_error( \Exception $e ) { + \WP_CLI::error( + sprintf( + /* translators: %s refers to the exception error message. */ + __( 'There was an error creating the scheduled action: %s', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + +} diff --git a/classes/WP_CLI/Action/Delete_Command.php b/classes/WP_CLI/Action/Delete_Command.php new file mode 100644 index 0000000..a549e0b --- /dev/null +++ b/classes/WP_CLI/Action/Delete_Command.php @@ -0,0 +1,108 @@ + + */ + protected $action_counts = array( + 'deleted' => 0, + 'failed' => 0, + 'total' => 0, + ); + + /** + * Construct. + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Keyed arguments. + */ + public function __construct( array $args, array $assoc_args ) { + parent::__construct( $args, $assoc_args ); + + $this->action_ids = array_map( 'absint', $args ); + $this->action_counts['total'] = count( $this->action_ids ); + + add_action( 'action_scheduler_deleted_action', array( $this, 'on_action_deleted' ) ); + } + + /** + * Execute. + * + * @return void + */ + public function execute() { + $store = \ActionScheduler::store(); + + $progress_bar = \WP_CLI\Utils\make_progress_bar( + sprintf( + /* translators: %d: number of actions to be deleted */ + _n( 'Deleting %d action', 'Deleting %d actions', $this->action_counts['total'], 'action-scheduler' ), + number_format_i18n( $this->action_counts['total'] ) + ), + $this->action_counts['total'] + ); + + foreach ( $this->action_ids as $action_id ) { + try { + $store->delete_action( $action_id ); + } catch ( \Exception $e ) { + $this->action_counts['failed']++; + \WP_CLI::warning( $e->getMessage() ); + } + + $progress_bar->tick(); + } + + $progress_bar->finish(); + + /* translators: %1$d: number of actions deleted */ + $format = _n( 'Deleted %1$d action', 'Deleted %1$d actions', $this->action_counts['deleted'], 'action-scheduler' ) . ', '; + /* translators: %2$d: number of actions deletions failed */ + $format .= _n( '%2$d failure.', '%2$d failures.', $this->action_counts['failed'], 'action-scheduler' ); + + \WP_CLI::success( + sprintf( + $format, + number_format_i18n( $this->action_counts['deleted'] ), + number_format_i18n( $this->action_counts['failed'] ) + ) + ); + } + + /** + * Action: action_scheduler_deleted_action + * + * @param int $action_id Action ID. + * @return void + */ + public function on_action_deleted( $action_id ) { + if ( 'action_scheduler_deleted_action' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['deleted']++; + \WP_CLI::debug( sprintf( 'Action %d was deleted.', $action_id ) ); + } + +} diff --git a/classes/WP_CLI/Action/Generate_Command.php b/classes/WP_CLI/Action/Generate_Command.php new file mode 100644 index 0000000..6e6e8c7 --- /dev/null +++ b/classes/WP_CLI/Action/Generate_Command.php @@ -0,0 +1,121 @@ +args[0]; + $schedule_start = $this->args[1]; + $callback_args = get_flag_value( $this->assoc_args, 'args', array() ); + $group = get_flag_value( $this->assoc_args, 'group', '' ); + $interval = (int) get_flag_value( $this->assoc_args, 'interval', 0 ); // avoid absint() to support negative intervals + $count = absint( get_flag_value( $this->assoc_args, 'count', 1 ) ); + + if ( ! empty( $callback_args ) ) { + $callback_args = json_decode( $callback_args, true ); + } + + $schedule_start = as_get_datetime_object( $schedule_start ); + + $function_args = array( + 'start' => absint( $schedule_start->format( 'U' ) ), + 'interval' => $interval, + 'count' => $count, + 'hook' => $hook, + 'callback_args' => $callback_args, + 'group' => $group, + ); + + $function_args = array_values( $function_args ); + + try { + $actions_added = $this->generate( ...$function_args ); + } catch ( \Exception $e ) { + $this->print_error( $e ); + } + + $num_actions_added = count( (array) $actions_added ); + + $this->print_success( $num_actions_added, 'single' ); + } + + /** + * Schedule multiple single actions. + * + * @param int $schedule_start Starting timestamp of first action. + * @param int $interval How long to wait between runs. + * @param int $count Limit number of actions to schedule. + * @param string $hook The hook to trigger. + * @param array $args Arguments to pass when the hook triggers. + * @param string $group The group to assign this job to. + * @return int[] IDs of actions added. + */ + protected function generate( $schedule_start, $interval, $count, $hook, array $args = array(), $group = '' ) { + $actions_added = array(); + + $progress_bar = \WP_CLI\Utils\make_progress_bar( + sprintf( + /* translators: %d is number of actions to create */ + _n( 'Creating %d action', 'Creating %d actions', $count, 'action-scheduler' ), + number_format_i18n( $count ) + ), + $count + ); + + for ( $i = 0; $i < $count; $i++ ) { + $actions_added[] = as_schedule_single_action( $schedule_start + ( $i * $interval ), $hook, $args, $group ); + $progress_bar->tick(); + } + + $progress_bar->finish(); + + return $actions_added; + } + + /** + * Print a success message with the action ID. + * + * @param int $actions_added Number of actions generated. + * @param string $action_type Type of actions scheduled. + * @return void + */ + protected function print_success( $actions_added, $action_type ) { + \WP_CLI::success( + sprintf( + /* translators: %1$d refers to the total number of tasks added, %2$s is the action type */ + _n( '%1$d %2$s action scheduled.', '%1$d %2$s actions scheduled.', $actions_added, 'action-scheduler' ), + number_format_i18n( $actions_added ), + $action_type + ) + ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param \Exception $e The error object. + * @throws \WP_CLI\ExitException When an error occurs. + * @return void + */ + protected function print_error( \Exception $e ) { + \WP_CLI::error( + sprintf( + /* translators: %s refers to the exception error message. */ + __( 'There was an error creating the scheduled action: %s', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + +} diff --git a/classes/WP_CLI/Action/Get_Command.php b/classes/WP_CLI/Action/Get_Command.php new file mode 100644 index 0000000..95df595 --- /dev/null +++ b/classes/WP_CLI/Action/Get_Command.php @@ -0,0 +1,75 @@ +args[0]; + $store = \ActionScheduler::store(); + $logger = \ActionScheduler::logger(); + $action = $store->fetch_action( $action_id ); + + if ( is_a( $action, ActionScheduler_NullAction::class ) ) { + /* translators: %d is action ID. */ + \WP_CLI::error( sprintf( esc_html__( 'Unable to retrieve action %d.', 'action-scheduler' ), $action_id ) ); + } + + $only_logs = ! empty( $this->assoc_args['field'] ) && 'log_entries' === $this->assoc_args['field']; + $only_logs = $only_logs || ( ! empty( $this->assoc_args['fields'] && 'log_entries' === $this->assoc_args['fields'] ) ); + $log_entries = array(); + + foreach ( $logger->get_logs( $action_id ) as $log_entry ) { + $log_entries[] = array( + 'date' => $log_entry->get_date()->format( static::DATE_FORMAT ), + 'message' => $log_entry->get_message(), + ); + } + + if ( $only_logs ) { + $args = array( + 'format' => \WP_CLI\Utils\get_flag_value( $this->assoc_args, 'format', 'table' ), + ); + + $formatter = new \WP_CLI\Formatter( $args, array( 'date', 'message' ) ); + $formatter->display_items( $log_entries ); + + return; + } + + try { + $status = $store->get_status( $action_id ); + } catch ( \Exception $e ) { + \WP_CLI::error( $e->getMessage() ); + } + + $action_arr = array( + 'id' => $this->args[0], + 'hook' => $action->get_hook(), + 'status' => $status, + 'args' => $action->get_args(), + 'group' => $action->get_group(), + 'recurring' => $action->get_schedule()->is_recurring() ? 'yes' : 'no', + 'scheduled_date' => $this->get_schedule_display_string( $action->get_schedule() ), + 'log_entries' => $log_entries, + ); + + $fields = array_keys( $action_arr ); + + if ( ! empty( $this->assoc_args['fields'] ) ) { + $fields = explode( ',', $this->assoc_args['fields'] ); + } + + $formatter = new \WP_CLI\Formatter( $this->assoc_args, $fields ); + $formatter->display_item( $action_arr ); + } + +} diff --git a/classes/WP_CLI/Action/List_Command.php b/classes/WP_CLI/Action/List_Command.php new file mode 100644 index 0000000..4a3e083 --- /dev/null +++ b/classes/WP_CLI/Action/List_Command.php @@ -0,0 +1,133 @@ +process_csv_arguments_to_arrays(); + + if ( ! empty( $this->assoc_args['fields'] ) ) { + $fields = $this->assoc_args['fields']; + } + + $formatter = new \WP_CLI\Formatter( $this->assoc_args, $fields ); + $query_args = $this->assoc_args; + + /** + * The `claimed` parameter expects a boolean or integer: + * check for string 'false', and set explicitly to `false` boolean. + */ + if ( array_key_exists( 'claimed', $query_args ) && 'false' === strtolower( $query_args['claimed'] ) ) { + $query_args['claimed'] = false; + } + + $return_format = 'OBJECT'; + + if ( in_array( $formatter->format, array( 'ids', 'count' ), true ) ) { + $return_format = '\'ids\''; + } + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + $params = var_export( $query_args, true ); + + if ( empty( $query_args ) ) { + $params = 'array()'; + } + + \WP_CLI::debug( + sprintf( + 'as_get_scheduled_actions( %s, %s )', + $params, + $return_format + ) + ); + + if ( ! empty( $query_args['args'] ) ) { + $query_args['args'] = json_decode( $query_args['args'], true ); + } + + switch ( $formatter->format ) { + + case 'ids': + $actions = as_get_scheduled_actions( $query_args, 'ids' ); + echo implode( ' ', $actions ); + break; + + case 'count': + $actions = as_get_scheduled_actions( $query_args, 'ids' ); + $formatter->display_items( $actions ); + break; + + default: + $actions = as_get_scheduled_actions( $query_args, OBJECT ); + + $actions_arr = array(); + + foreach ( $actions as $action_id => $action ) { + $action_arr = array( + 'id' => $action_id, + 'hook' => $action->get_hook(), + 'status' => $store->get_status( $action_id ), + 'args' => $action->get_args(), + 'group' => $action->get_group(), + 'recurring' => $action->get_schedule()->is_recurring() ? 'yes' : 'no', + 'scheduled_date' => $this->get_schedule_display_string( $action->get_schedule() ), + 'log_entries' => array(), + ); + + foreach ( $logger->get_logs( $action_id ) as $log_entry ) { + $action_arr['log_entries'][] = array( + 'date' => $log_entry->get_date()->format( static::DATE_FORMAT ), + 'message' => $log_entry->get_message(), + ); + } + + $actions_arr[] = $action_arr; + } + + $formatter->display_items( $actions_arr ); + break; + + } + } + +} diff --git a/classes/WP_CLI/Action/Next_Command.php b/classes/WP_CLI/Action/Next_Command.php new file mode 100644 index 0000000..b717445 --- /dev/null +++ b/classes/WP_CLI/Action/Next_Command.php @@ -0,0 +1,71 @@ +args[0]; + $group = get_flag_value( $this->assoc_args, 'group', '' ); + $callback_args = get_flag_value( $this->assoc_args, 'args', null ); + $raw = (bool) get_flag_value( $this->assoc_args, 'raw', false ); + + if ( ! empty( $callback_args ) ) { + $callback_args = json_decode( $callback_args, true ); + } + + if ( $raw ) { + \WP_CLI::line( as_next_scheduled_action( $hook, $callback_args, $group ) ); + return; + } + + $params = array( + 'hook' => $hook, + 'orderby' => 'date', + 'order' => 'ASC', + 'group' => $group, + ); + + if ( is_array( $callback_args ) ) { + $params['args'] = $callback_args; + } + + $params['status'] = \ActionScheduler_Store::STATUS_RUNNING; + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + \WP_CLI::debug( 'ActionScheduler()::store()->query_action( ' . var_export( $params, true ) . ' )' ); + + $store = \ActionScheduler::store(); + $action_id = $store->query_action( $params ); + + if ( $action_id ) { + echo $action_id; + return; + } + + $params['status'] = \ActionScheduler_Store::STATUS_PENDING; + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + \WP_CLI::debug( 'ActionScheduler()::store()->query_action( ' . var_export( $params, true ) . ' )' ); + + $action_id = $store->query_action( $params ); + + if ( $action_id ) { + echo $action_id; + return; + } + + \WP_CLI::warning( 'No matching next action.' ); + } + +} diff --git a/classes/WP_CLI/Action/Run_Command.php b/classes/WP_CLI/Action/Run_Command.php new file mode 100644 index 0000000..efff37d --- /dev/null +++ b/classes/WP_CLI/Action/Run_Command.php @@ -0,0 +1,194 @@ + + */ + protected $action_counts = array( + 'executed' => 0, + 'failed' => 0, + 'ignored' => 0, + 'invalid' => 0, + 'total' => 0, + ); + + /** + * Construct. + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Keyed arguments. + */ + public function __construct( array $args, array $assoc_args ) { + parent::__construct( $args, $assoc_args ); + + $this->action_ids = array_map( 'absint', $args ); + $this->action_counts['total'] = count( $this->action_ids ); + + add_action( 'action_scheduler_execution_ignored', array( $this, 'on_action_ignored' ) ); + add_action( 'action_scheduler_after_execute', array( $this, 'on_action_executed' ) ); + add_action( 'action_scheduler_failed_execution', array( $this, 'on_action_failed' ), 10, 2 ); + add_action( 'action_scheduler_failed_validation', array( $this, 'on_action_invalid' ), 10, 2 ); + } + + /** + * Execute. + * + * @return void + */ + public function execute() { + $runner = \ActionScheduler::runner(); + + $progress_bar = \WP_CLI\Utils\make_progress_bar( + sprintf( + /* translators: %d: number of actions */ + _n( 'Executing %d action', 'Executing %d actions', $this->action_counts['total'], 'action-scheduler' ), + number_format_i18n( $this->action_counts['total'] ) + ), + $this->action_counts['total'] + ); + + foreach ( $this->action_ids as $action_id ) { + $runner->process_action( $action_id, 'Action Scheduler CLI' ); + $progress_bar->tick(); + } + + $progress_bar->finish(); + + foreach ( array( + 'ignored', + 'invalid', + 'failed', + ) as $type ) { + $count = $this->action_counts[ $type ]; + + if ( empty( $count ) ) { + continue; + } + + /* + * translators: + * %1$d: count of actions evaluated. + * %2$s: type of action evaluated. + */ + $format = _n( '%1$d action %2$s.', '%1$d actions %2$s.', $count, 'action-scheduler' ); + + \WP_CLI::warning( + sprintf( + $format, + number_format_i18n( $count ), + $type + ) + ); + } + + \WP_CLI::success( + sprintf( + /* translators: %d: number of executed actions */ + _n( 'Executed %d action.', 'Executed %d actions.', $this->action_counts['executed'], 'action-scheduler' ), + number_format_i18n( $this->action_counts['executed'] ) + ) + ); + } + + /** + * Action: action_scheduler_execution_ignored + * + * @param int $action_id Action ID. + * @return void + */ + public function on_action_ignored( $action_id ) { + if ( 'action_scheduler_execution_ignored' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['ignored']++; + \WP_CLI::debug( sprintf( 'Action %d was ignored.', $action_id ) ); + } + + /** + * Action: action_scheduler_after_execute + * + * @param int $action_id Action ID. + * @return void + */ + public function on_action_executed( $action_id ) { + if ( 'action_scheduler_after_execute' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['executed']++; + \WP_CLI::debug( sprintf( 'Action %d was executed.', $action_id ) ); + } + + /** + * Action: action_scheduler_failed_execution + * + * @param int $action_id Action ID. + * @param \Exception $e Exception. + * @return void + */ + public function on_action_failed( $action_id, \Exception $e ) { + if ( 'action_scheduler_failed_execution' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['failed']++; + \WP_CLI::debug( sprintf( 'Action %d failed execution: %s', $action_id, $e->getMessage() ) ); + } + + /** + * Action: action_scheduler_failed_validation + * + * @param int $action_id Action ID. + * @param \Exception $e Exception. + * @return void + */ + public function on_action_invalid( $action_id, \Exception $e ) { + if ( 'action_scheduler_failed_validation' !== current_action() ) { + return; + } + + $action_id = absint( $action_id ); + + if ( ! in_array( $action_id, $this->action_ids, true ) ) { + return; + } + + $this->action_counts['invalid']++; + \WP_CLI::debug( sprintf( 'Action %d failed validation: %s', $action_id, $e->getMessage() ) ); + } + +} diff --git a/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php b/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php new file mode 100644 index 0000000..b0ac4e3 --- /dev/null +++ b/classes/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php @@ -0,0 +1,123 @@ +] + * : The maximum number of actions to delete per batch. Defaults to 20. + * + * [--batches=] + * : Limit execution to a number of batches. Defaults to 0, meaning batches will continue all eligible actions are deleted. + * + * [--status=] + * : Only clean actions with the specified status. Defaults to Canceled, Completed. Define multiple statuses as a comma separated string (without spaces), e.g. `--status=complete,failed,canceled` + * + * [--before=] + * : Only delete actions with scheduled date older than this. Defaults to 31 days. e.g `--before='7 days ago'`, `--before='02-Feb-2020 20:20:20'` + * + * [--pause=] + * : The number of seconds to pause between batches. Default no pause. + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @throws \WP_CLI\ExitException When an error occurs. + * + * @subcommand clean + */ + public function clean( $args, $assoc_args ) { + // Handle passed arguments. + $batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 20 ) ); + $batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) ); + $status = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'status', '' ) ); + $status = array_filter( array_map( 'trim', $status ) ); + $before = \WP_CLI\Utils\get_flag_value( $assoc_args, 'before', '' ); + $sleep = \WP_CLI\Utils\get_flag_value( $assoc_args, 'pause', 0 ); + + $batches_completed = 0; + $actions_deleted = 0; + $unlimited = 0 === $batches; + try { + $lifespan = as_get_datetime_object( $before ); + } catch ( Exception $e ) { + $lifespan = null; + } + + try { + // Custom queue cleaner instance. + $cleaner = new ActionScheduler_QueueCleaner( null, $batch ); + + // Clean actions for as long as possible. + while ( $unlimited || $batches_completed < $batches ) { + if ( $sleep && $batches_completed > 0 ) { + sleep( $sleep ); + } + + $deleted = count( $cleaner->clean_actions( $status, $lifespan, null, 'CLI' ) ); + if ( $deleted <= 0 ) { + break; + } + $actions_deleted += $deleted; + $batches_completed++; + $this->print_success( $deleted ); + } + } catch ( Exception $e ) { + $this->print_error( $e ); + } + + $this->print_total_batches( $batches_completed ); + if ( $batches_completed > 1 ) { + $this->print_success( $actions_deleted ); + } + } + + /** + * Print WP CLI message about how many batches of actions were processed. + * + * @param int $batches_processed Number of batches processed. + */ + protected function print_total_batches( int $batches_processed ) { + WP_CLI::log( + sprintf( + /* translators: %d refers to the total number of batches processed */ + _n( '%d batch processed.', '%d batches processed.', $batches_processed, 'action-scheduler' ), + $batches_processed + ) + ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param Exception $e The error object. + */ + protected function print_error( Exception $e ) { + WP_CLI::error( + sprintf( + /* translators: %s refers to the exception error message */ + __( 'There was an error deleting an action: %s', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + + /** + * Print a success message with the number of completed actions. + * + * @param int $actions_deleted Number of deleted actions. + */ + protected function print_success( int $actions_deleted ) { + WP_CLI::success( + sprintf( + /* translators: %d refers to the total number of actions deleted */ + _n( '%d action deleted.', '%d actions deleted.', $actions_deleted, 'action-scheduler' ), + $actions_deleted + ) + ); + } +} diff --git a/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php b/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php new file mode 100644 index 0000000..08cb0cd --- /dev/null +++ b/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php @@ -0,0 +1,195 @@ +run_cleanup(); + $this->add_hooks(); + + // Check to make sure there aren't too many concurrent processes running. + if ( $this->has_maximum_concurrent_batches() ) { + if ( $force ) { + WP_CLI::warning( __( 'There are too many concurrent batches, but the run is forced to continue.', 'action-scheduler' ) ); + } else { + WP_CLI::error( __( 'There are too many concurrent batches.', 'action-scheduler' ) ); + } + } + + // Stake a claim and store it. + $this->claim = $this->store->stake_claim( $batch_size, null, $hooks, $group ); + $this->monitor->attach( $this->claim ); + $this->actions = $this->claim->get_actions(); + + return count( $this->actions ); + } + + /** + * Add our hooks to the appropriate actions. + */ + protected function add_hooks() { + add_action( 'action_scheduler_before_execute', array( $this, 'before_execute' ) ); + add_action( 'action_scheduler_after_execute', array( $this, 'after_execute' ), 10, 2 ); + add_action( 'action_scheduler_failed_execution', array( $this, 'action_failed' ), 10, 2 ); + } + + /** + * Set up the WP CLI progress bar. + */ + protected function setup_progress_bar() { + $count = count( $this->actions ); + $this->progress_bar = new ProgressBar( + /* translators: %d: amount of actions */ + sprintf( _n( 'Running %d action', 'Running %d actions', $count, 'action-scheduler' ), $count ), + $count + ); + } + + /** + * Process actions in the queue. + * + * @param string $context Optional runner context. Default 'WP CLI'. + * + * @return int The number of actions processed. + */ + public function run( $context = 'WP CLI' ) { + do_action( 'action_scheduler_before_process_queue' ); + $this->setup_progress_bar(); + foreach ( $this->actions as $action_id ) { + // Error if we lost the claim. + if ( ! in_array( $action_id, $this->store->find_actions_by_claim_id( $this->claim->get_id() ), true ) ) { + WP_CLI::warning( __( 'The claim has been lost. Aborting current batch.', 'action-scheduler' ) ); + break; + } + + $this->process_action( $action_id, $context ); + $this->progress_bar->tick(); + } + + $completed = $this->progress_bar->current(); + $this->progress_bar->finish(); + $this->store->release_claim( $this->claim ); + do_action( 'action_scheduler_after_process_queue' ); + + return $completed; + } + + /** + * Handle WP CLI message when the action is starting. + * + * @param int $action_id Action ID. + */ + public function before_execute( $action_id ) { + /* translators: %s refers to the action ID */ + WP_CLI::log( sprintf( __( 'Started processing action %s', 'action-scheduler' ), $action_id ) ); + } + + /** + * Handle WP CLI message when the action has completed. + * + * @param int $action_id ActionID. + * @param null|ActionScheduler_Action $action The instance of the action. Default to null for backward compatibility. + */ + public function after_execute( $action_id, $action = null ) { + // backward compatibility. + if ( null === $action ) { + $action = $this->store->fetch_action( $action_id ); + } + /* translators: 1: action ID 2: hook name */ + WP_CLI::log( sprintf( __( 'Completed processing action %1$s with hook: %2$s', 'action-scheduler' ), $action_id, $action->get_hook() ) ); + } + + /** + * Handle WP CLI message when the action has failed. + * + * @param int $action_id Action ID. + * @param Exception $exception Exception. + * @throws \WP_CLI\ExitException With failure message. + */ + public function action_failed( $action_id, $exception ) { + WP_CLI::error( + /* translators: 1: action ID 2: exception message */ + sprintf( __( 'Error processing action %1$s: %2$s', 'action-scheduler' ), $action_id, $exception->getMessage() ), + false + ); + } + + /** + * Sleep and help avoid hitting memory limit + * + * @param int $sleep_time Amount of seconds to sleep. + * @deprecated 3.0.0 + */ + protected function stop_the_insanity( $sleep_time = 0 ) { + _deprecated_function( 'ActionScheduler_WPCLI_QueueRunner::stop_the_insanity', '3.0.0', 'ActionScheduler_DataController::free_memory' ); + + ActionScheduler_DataController::free_memory(); + } + + /** + * Maybe trigger the stop_the_insanity() method to free up memory. + */ + protected function maybe_stop_the_insanity() { + // The value returned by progress_bar->current() might be padded. Remove padding, and convert to int. + $current_iteration = intval( trim( $this->progress_bar->current() ) ); + if ( 0 === $current_iteration % 50 ) { + $this->stop_the_insanity(); + } + } +} diff --git a/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php b/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php new file mode 100644 index 0000000..d252680 --- /dev/null +++ b/classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php @@ -0,0 +1,202 @@ +init(); + $obj->register_tables( true ); + + WP_CLI::success( + sprintf( + /* translators: %s refers to the schema name*/ + __( 'Registered schema for %s', 'action-scheduler' ), + $classname + ) + ); + } + } + } + + /** + * Run the Action Scheduler + * + * ## OPTIONS + * + * [--batch-size=] + * : The maximum number of actions to run. Defaults to 100. + * + * [--batches=] + * : Limit execution to a number of batches. Defaults to 0, meaning batches will continue being executed until all actions are complete. + * + * [--cleanup-batch-size=] + * : The maximum number of actions to clean up. Defaults to the value of --batch-size. + * + * [--hooks=] + * : Only run actions with the specified hook. Omitting this option runs actions with any hook. Define multiple hooks as a comma separated string (without spaces), e.g. `--hooks=hook_one,hook_two,hook_three` + * + * [--group=] + * : Only run actions from the specified group. Omitting this option runs actions from all groups. + * + * [--exclude-groups=] + * : Run actions from all groups except the specified group(s). Define multiple groups as a comma separated string (without spaces), e.g. '--group_a,group_b'. This option is ignored when `--group` is used. + * + * [--free-memory-on=] + * : The number of actions to process between freeing memory. 0 disables freeing memory. Default 50. + * + * [--pause=] + * : The number of seconds to pause when freeing memory. Default no pause. + * + * [--force] + * : Whether to force execution despite the maximum number of concurrent processes being exceeded. + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @throws \WP_CLI\ExitException When an error occurs. + * + * @subcommand run + */ + public function run( $args, $assoc_args ) { + // Handle passed arguments. + $batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 100 ) ); + $batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) ); + $clean = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'cleanup-batch-size', $batch ) ); + $hooks = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'hooks', '' ) ); + $hooks = array_filter( array_map( 'trim', $hooks ) ); + $group = \WP_CLI\Utils\get_flag_value( $assoc_args, 'group', '' ); + $exclude_groups = \WP_CLI\Utils\get_flag_value( $assoc_args, 'exclude-groups', '' ); + $free_on = \WP_CLI\Utils\get_flag_value( $assoc_args, 'free-memory-on', 50 ); + $sleep = \WP_CLI\Utils\get_flag_value( $assoc_args, 'pause', 0 ); + $force = \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false ); + + ActionScheduler_DataController::set_free_ticks( $free_on ); + ActionScheduler_DataController::set_sleep_time( $sleep ); + + $batches_completed = 0; + $actions_completed = 0; + $unlimited = 0 === $batches; + if ( is_callable( array( ActionScheduler::store(), 'set_claim_filter' ) ) ) { + $exclude_groups = $this->parse_comma_separated_string( $exclude_groups ); + + if ( ! empty( $exclude_groups ) ) { + ActionScheduler::store()->set_claim_filter( 'exclude-groups', $exclude_groups ); + } + } + + try { + // Custom queue cleaner instance. + $cleaner = new ActionScheduler_QueueCleaner( null, $clean ); + + // Get the queue runner instance. + $runner = new ActionScheduler_WPCLI_QueueRunner( null, null, $cleaner ); + + // Determine how many tasks will be run in the first batch. + $total = $runner->setup( $batch, $hooks, $group, $force ); + + // Run actions for as long as possible. + while ( $total > 0 ) { + $this->print_total_actions( $total ); + $actions_completed += $runner->run(); + $batches_completed++; + + // Maybe set up tasks for the next batch. + $total = ( $unlimited || $batches_completed < $batches ) ? $runner->setup( $batch, $hooks, $group, $force ) : 0; + } + } catch ( Exception $e ) { + $this->print_error( $e ); + } + + $this->print_total_batches( $batches_completed ); + $this->print_success( $actions_completed ); + } + + /** + * Converts a string of comma-separated values into an array of those same values. + * + * @param string $string The string of one or more comma separated values. + * + * @return array + */ + private function parse_comma_separated_string( $string ): array { + return array_filter( str_getcsv( $string ) ); + } + + /** + * Print WP CLI message about how many actions are about to be processed. + * + * @param int $total Number of actions found. + */ + protected function print_total_actions( $total ) { + WP_CLI::log( + sprintf( + /* translators: %d refers to how many scheduled tasks were found to run */ + _n( 'Found %d scheduled task', 'Found %d scheduled tasks', $total, 'action-scheduler' ), + $total + ) + ); + } + + /** + * Print WP CLI message about how many batches of actions were processed. + * + * @param int $batches_completed Number of completed batches. + */ + protected function print_total_batches( $batches_completed ) { + WP_CLI::log( + sprintf( + /* translators: %d refers to the total number of batches executed */ + _n( '%d batch executed.', '%d batches executed.', $batches_completed, 'action-scheduler' ), + $batches_completed + ) + ); + } + + /** + * Convert an exception into a WP CLI error. + * + * @param Exception $e The error object. + * + * @throws \WP_CLI\ExitException Under some conditions WP CLI may throw an exception. + */ + protected function print_error( Exception $e ) { + WP_CLI::error( + sprintf( + /* translators: %s refers to the exception error message */ + __( 'There was an error running the action scheduler: %s', 'action-scheduler' ), + $e->getMessage() + ) + ); + } + + /** + * Print a success message with the number of completed actions. + * + * @param int $actions_completed Number of completed actions. + */ + protected function print_success( $actions_completed ) { + WP_CLI::success( + sprintf( + /* translators: %d refers to the total number of tasks completed */ + _n( '%d scheduled task completed.', '%d scheduled tasks completed.', $actions_completed, 'action-scheduler' ), + $actions_completed + ) + ); + } +} diff --git a/classes/WP_CLI/Action_Command.php b/classes/WP_CLI/Action_Command.php new file mode 100644 index 0000000..b32eea3 --- /dev/null +++ b/classes/WP_CLI/Action_Command.php @@ -0,0 +1,353 @@ +] + * : Name of the action hook. + * + * [--group=] + * : The group the job is assigned to. + * + * [--args=] + * : JSON object of arguments assigned to the job. + * --- + * default: [] + * --- + * + * [--all] + * : Cancel all occurrences of a scheduled action. + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function cancel( array $args, array $assoc_args ) { + require_once 'Action/Cancel_Command.php'; + $command = new Action\Cancel_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Creates a new scheduled action. + * + * ## OPTIONS + * + * + * : Name of the action hook. + * + * + * : A unix timestamp representing the date you want the action to start. Also 'async' or 'now' to enqueue an async action. + * + * [--args=] + * : JSON object of arguments to pass to callbacks when the hook triggers. + * --- + * default: [] + * --- + * + * [--cron=] + * : A cron-like schedule string (https://crontab.guru/). + * --- + * default: '' + * --- + * + * [--group=] + * : The group to assign this job to. + * --- + * default: '' + * --- + * + * [--interval=] + * : Number of seconds to wait between runs. + * --- + * default: 0 + * --- + * + * ## EXAMPLES + * + * wp action-scheduler action create hook_async async + * wp action-scheduler action create hook_single 1627147598 + * wp action-scheduler action create hook_recurring 1627148188 --interval=5 + * wp action-scheduler action create hook_cron 1627147655 --cron='5 4 * * *' + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function create( array $args, array $assoc_args ) { + require_once 'Action/Create_Command.php'; + $command = new Action\Create_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Delete existing scheduled action(s). + * + * ## OPTIONS + * + * ... + * : One or more IDs of actions to delete. + * --- + * default: 0 + * --- + * + * ## EXAMPLES + * + * # Delete the action with id 100 + * $ wp action-scheduler action delete 100 + * + * # Delete the actions with ids 100 and 200 + * $ wp action-scheduler action delete 100 200 + * + * # Delete the first five pending actions in 'action-scheduler' group + * $ wp action-scheduler action delete $( wp action-scheduler action list --status=pending --group=action-scheduler --format=ids ) + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function delete( array $args, array $assoc_args ) { + require_once 'Action/Delete_Command.php'; + $command = new Action\Delete_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Generates some scheduled actions. + * + * ## OPTIONS + * + * + * : Name of the action hook. + * + * + * : The Unix timestamp representing the date you want the action to start. + * + * [--count=] + * : Number of actions to create. + * --- + * default: 1 + * --- + * + * [--interval=] + * : Number of seconds to wait between runs. + * --- + * default: 0 + * --- + * + * [--args=] + * : JSON object of arguments to pass to callbacks when the hook triggers. + * --- + * default: [] + * --- + * + * [--group=] + * : The group to assign this job to. + * --- + * default: '' + * --- + * + * ## EXAMPLES + * + * wp action-scheduler action generate test_multiple 1627147598 --count=5 --interval=5 + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function generate( array $args, array $assoc_args ) { + require_once 'Action/Generate_Command.php'; + $command = new Action\Generate_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Get details about a scheduled action. + * + * ## OPTIONS + * + * + * : The ID of the action to get. + * --- + * default: 0 + * --- + * + * [--field=] + * : Instead of returning the whole action, returns the value of a single field. + * + * [--fields=] + * : Limit the output to specific fields (comma-separated). Defaults to all fields. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - yaml + * --- + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function get( array $args, array $assoc_args ) { + require_once 'Action/Get_Command.php'; + $command = new Action\Get_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Get a list of scheduled actions. + * + * Display actions based on all arguments supported by + * [as_get_scheduled_actions()](https://actionscheduler.org/api/#function-reference--as_get_scheduled_actions). + * + * ## OPTIONS + * + * [--=] + * : One or more arguments to pass to as_get_scheduled_actions(). + * + * [--field=] + * : Prints the value of a single property for each action. + * + * [--fields=] + * : Limit the output to specific object properties. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - ids + * - json + * - count + * - yaml + * --- + * + * ## AVAILABLE FIELDS + * + * These fields will be displayed by default for each action: + * + * * id + * * hook + * * status + * * group + * * recurring + * * scheduled_date + * + * These fields are optionally available: + * + * * args + * * log_entries + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + * + * @subcommand list + */ + public function subcommand_list( array $args, array $assoc_args ) { + require_once 'Action/List_Command.php'; + $command = new Action\List_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Get logs for a scheduled action. + * + * ## OPTIONS + * + * + * : The ID of the action to get. + * --- + * default: 0 + * --- + * + * @param array $args Positional arguments. + * @return void + */ + public function logs( array $args ) { + $command = sprintf( 'action-scheduler action get %d --field=log_entries', $args[0] ); + WP_CLI::runcommand( $command ); + } + + /** + * Get the ID or timestamp of the next scheduled action. + * + * ## OPTIONS + * + * + * : The hook of the next scheduled action. + * + * [--args=] + * : JSON object of arguments to search for next scheduled action. + * --- + * default: [] + * --- + * + * [--group=] + * : The group to which the next scheduled action is assigned. + * --- + * default: '' + * --- + * + * [--raw] + * : Display the raw output of as_next_scheduled_action() (timestamp or boolean). + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function next( array $args, array $assoc_args ) { + require_once 'Action/Next_Command.php'; + $command = new Action\Next_Command( $args, $assoc_args ); + $command->execute(); + } + + /** + * Run existing scheduled action(s). + * + * ## OPTIONS + * + * ... + * : One or more IDs of actions to run. + * --- + * default: 0 + * --- + * + * ## EXAMPLES + * + * # Run the action with id 100 + * $ wp action-scheduler action run 100 + * + * # Run the actions with ids 100 and 200 + * $ wp action-scheduler action run 100 200 + * + * # Run the first five pending actions in 'action-scheduler' group + * $ wp action-scheduler action run $( wp action-scheduler action list --status=pending --group=action-scheduler --format=ids ) + * + * @param array $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @return void + */ + public function run( array $args, array $assoc_args ) { + require_once 'Action/Run_Command.php'; + $command = new Action\Run_Command( $args, $assoc_args ); + $command->execute(); + } + +} diff --git a/classes/WP_CLI/Migration_Command.php b/classes/WP_CLI/Migration_Command.php new file mode 100644 index 0000000..86f750f --- /dev/null +++ b/classes/WP_CLI/Migration_Command.php @@ -0,0 +1,190 @@ + 'Migrates actions to the DB tables store', + 'synopsis' => array( + array( + 'type' => 'assoc', + 'name' => 'batch-size', + 'optional' => true, + 'default' => 100, + 'description' => 'The number of actions to process in each batch', + ), + array( + 'type' => 'assoc', + 'name' => 'free-memory-on', + 'optional' => true, + 'default' => 50, + 'description' => 'The number of actions to process between freeing memory. 0 disables freeing memory', + ), + array( + 'type' => 'assoc', + 'name' => 'pause', + 'optional' => true, + 'default' => 0, + 'description' => 'The number of seconds to pause when freeing memory', + ), + array( + 'type' => 'flag', + 'name' => 'dry-run', + 'optional' => true, + 'description' => 'Reports on the actions that would have been migrated, but does not change any data', + ), + ), + ) + ); + } + + /** + * Process the data migration. + * + * @param array $positional_args Required for WP CLI. Not used in migration. + * @param array $assoc_args Optional arguments. + * + * @return void + */ + public function migrate( $positional_args, $assoc_args ) { + $this->init_logging(); + + $config = $this->get_migration_config( $assoc_args ); + $runner = new Runner( $config ); + $runner->init_destination(); + + $batch_size = isset( $assoc_args['batch-size'] ) ? (int) $assoc_args['batch-size'] : 100; + $free_on = isset( $assoc_args['free-memory-on'] ) ? (int) $assoc_args['free-memory-on'] : 50; + $sleep = isset( $assoc_args['pause'] ) ? (int) $assoc_args['pause'] : 0; + \ActionScheduler_DataController::set_free_ticks( $free_on ); + \ActionScheduler_DataController::set_sleep_time( $sleep ); + + do { + $actions_processed = $runner->run( $batch_size ); + $this->total_processed += $actions_processed; + } while ( $actions_processed > 0 ); + + if ( ! $config->get_dry_run() ) { + // let the scheduler know that there's nothing left to do. + $scheduler = new Scheduler(); + $scheduler->mark_complete(); + } + + WP_CLI::success( sprintf( '%s complete. %d actions processed.', $config->get_dry_run() ? 'Dry run' : 'Migration', $this->total_processed ) ); + } + + /** + * Build the config object used to create the Runner + * + * @param array $args Optional arguments. + * + * @return ActionScheduler\Migration\Config + */ + private function get_migration_config( $args ) { + $args = wp_parse_args( + $args, + array( + 'dry-run' => false, + ) + ); + + $config = Controller::instance()->get_migration_config_object(); + $config->set_dry_run( ! empty( $args['dry-run'] ) ); + + return $config; + } + + /** + * Hook command line logging into migration actions. + */ + private function init_logging() { + add_action( + 'action_scheduler/migrate_action_dry_run', + function ( $action_id ) { + WP_CLI::debug( sprintf( 'Dry-run: migrated action %d', $action_id ) ); + } + ); + + add_action( + 'action_scheduler/no_action_to_migrate', + function ( $action_id ) { + WP_CLI::debug( sprintf( 'No action found to migrate for ID %d', $action_id ) ); + } + ); + + add_action( + 'action_scheduler/migrate_action_failed', + function ( $action_id ) { + WP_CLI::warning( sprintf( 'Failed migrating action with ID %d', $action_id ) ); + } + ); + + add_action( + 'action_scheduler/migrate_action_incomplete', + function ( $source_id, $destination_id ) { + WP_CLI::warning( sprintf( 'Unable to remove source action with ID %d after migrating to new ID %d', $source_id, $destination_id ) ); + }, + 10, + 2 + ); + + add_action( + 'action_scheduler/migrated_action', + function ( $source_id, $destination_id ) { + WP_CLI::debug( sprintf( 'Migrated source action with ID %d to new store with ID %d', $source_id, $destination_id ) ); + }, + 10, + 2 + ); + + add_action( + 'action_scheduler/migration_batch_starting', + function ( $batch ) { + WP_CLI::debug( 'Beginning migration of batch: ' . print_r( $batch, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r + } + ); + + add_action( + 'action_scheduler/migration_batch_complete', + function ( $batch ) { + WP_CLI::log( sprintf( 'Completed migration of %d actions', count( $batch ) ) ); + } + ); + } +} diff --git a/classes/WP_CLI/ProgressBar.php b/classes/WP_CLI/ProgressBar.php new file mode 100644 index 0000000..fb2ae8f --- /dev/null +++ b/classes/WP_CLI/ProgressBar.php @@ -0,0 +1,139 @@ +total_ticks = 0; + $this->message = $message; + $this->count = $count; + $this->interval = $interval; + } + + /** + * Increment the progress bar ticks. + */ + public function tick() { + if ( null === $this->progress_bar ) { + $this->setup_progress_bar(); + } + + $this->progress_bar->tick(); + $this->total_ticks++; + + do_action( 'action_scheduler/progress_tick', $this->total_ticks ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + } + + /** + * Get the progress bar tick count. + * + * @return int + */ + public function current() { + return $this->progress_bar ? $this->progress_bar->current() : 0; + } + + /** + * Finish the current progress bar. + */ + public function finish() { + if ( null !== $this->progress_bar ) { + $this->progress_bar->finish(); + } + + $this->progress_bar = null; + } + + /** + * Set the message used when creating the progress bar. + * + * @param string $message The message to be used when the next progress bar is created. + */ + public function set_message( $message ) { + $this->message = $message; + } + + /** + * Set the count for a new progress bar. + * + * @param integer $count The total number of ticks expected to complete. + */ + public function set_count( $count ) { + $this->count = $count; + $this->finish(); + } + + /** + * Set up the progress bar. + */ + protected function setup_progress_bar() { + $this->progress_bar = \WP_CLI\Utils\make_progress_bar( + $this->message, + $this->count, + $this->interval + ); + } +} diff --git a/classes/WP_CLI/System_Command.php b/classes/WP_CLI/System_Command.php new file mode 100644 index 0000000..a936a63 --- /dev/null +++ b/classes/WP_CLI/System_Command.php @@ -0,0 +1,283 @@ +store = \ActionScheduler::store(); + } + + /** + * Print in-use data store class. + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @return void + * + * @subcommand data-store + */ + public function datastore( array $args, array $assoc_args ) { + echo $this->get_current_datastore(); + } + + /** + * Print in-use runner class. + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @return void + */ + public function runner( array $args, array $assoc_args ) { + echo $this->get_current_runner(); + } + + /** + * Get system status. + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @return void + */ + public function status( array $args, array $assoc_args ) { + /** + * Get runner status. + * + * @link https://github.com/woocommerce/action-scheduler-disable-default-runner + */ + $runner_enabled = has_action( 'action_scheduler_run_queue', array( \ActionScheduler::runner(), 'run' ) ); + + \WP_CLI::line( sprintf( 'Data store: %s', $this->get_current_datastore() ) ); + \WP_CLI::line( sprintf( 'Runner: %s%s', $this->get_current_runner(), ( $runner_enabled ? '' : ' (disabled)' ) ) ); + \WP_CLI::line( sprintf( 'Version: %s', $this->get_latest_version() ) ); + + $rows = array(); + $action_counts = $this->store->action_counts(); + $oldest_and_newest = $this->get_oldest_and_newest( array_keys( $action_counts ) ); + + foreach ( $action_counts as $status => $count ) { + $rows[] = array( + 'status' => $status, + 'count' => $count, + 'oldest' => $oldest_and_newest[ $status ]['oldest'], + 'newest' => $oldest_and_newest[ $status ]['newest'], + ); + } + + $formatter = new \WP_CLI\Formatter( $assoc_args, array( 'status', 'count', 'oldest', 'newest' ) ); + $formatter->display_items( $rows ); + } + + /** + * Display the active version, or all registered versions. + * + * ## OPTIONS + * + * [--all] + * : List all registered versions. + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @return void + */ + public function version( array $args, array $assoc_args ) { + $all = (bool) get_flag_value( $assoc_args, 'all' ); + $latest = $this->get_latest_version(); + + if ( ! $all ) { + echo $latest; + \WP_CLI::halt( 0 ); + } + + $instance = \ActionScheduler_Versions::instance(); + $versions = $instance->get_versions(); + $rows = array(); + + foreach ( $versions as $version => $callback ) { + $active = $version === $latest; + + $rows[ $version ] = array( + 'version' => $version, + 'callback' => $callback, + 'active' => $active ? 'yes' : 'no', + ); + } + + uksort( $rows, 'version_compare' ); + + $formatter = new \WP_CLI\Formatter( $assoc_args, array( 'version', 'callback', 'active' ) ); + $formatter->display_items( $rows ); + } + + /** + * Display the current source, or all registered sources. + * + * ## OPTIONS + * + * [--all] + * : List all registered sources. + * + * [--fullpath] + * : List full path of source(s). + * + * @param array $args Positional args. + * @param array $assoc_args Keyed args. + * @uses ActionScheduler_SystemInformation::active_source_path() + * @uses \WP_CLI\Formatter::display_items() + * @uses $this->get_latest_version() + * @return void + */ + public function source( array $args, array $assoc_args ) { + $all = (bool) get_flag_value( $assoc_args, 'all' ); + $fullpath = (bool) get_flag_value( $assoc_args, 'fullpath' ); + $source = ActionScheduler_SystemInformation::active_source_path(); + $path = $source; + + if ( ! $fullpath ) { + $path = str_replace( ABSPATH, '', $path ); + } + + if ( ! $all ) { + echo $path; + \WP_CLI::halt( 0 ); + } + + $sources = ActionScheduler_SystemInformation::get_sources(); + + if ( empty( $sources ) ) { + WP_CLI::log( __( 'Detailed information about registered sources is not currently available.', 'action-scheduler' ) ); + return; + } + + $rows = array(); + + foreach ( $sources as $check_source => $version ) { + $active = dirname( $check_source ) === $source; + $path = $check_source; + + if ( ! $fullpath ) { + $path = str_replace( ABSPATH, '', $path ); + } + + $rows[ $check_source ] = array( + 'source' => $path, + 'version' => $version, + 'active' => $active ? 'yes' : 'no', + ); + } + + ksort( $rows ); + + \WP_CLI::log( PHP_EOL . 'Please note there can only be one unique registered instance of Action Scheduler per ' . PHP_EOL . 'version number, so this list may not include all the currently present copies of ' . PHP_EOL . 'Action Scheduler.' . PHP_EOL ); + + $formatter = new \WP_CLI\Formatter( $assoc_args, array( 'source', 'version', 'active' ) ); + $formatter->display_items( $rows ); + } + + /** + * Get current data store. + * + * @return string + */ + protected function get_current_datastore() { + return get_class( $this->store ); + } + + /** + * Get latest version. + * + * @param null|\ActionScheduler_Versions $instance Versions. + * @return string + */ + protected function get_latest_version( $instance = null ) { + if ( is_null( $instance ) ) { + $instance = \ActionScheduler_Versions::instance(); + } + + return $instance->latest_version(); + } + + /** + * Get current runner. + * + * @return string + */ + protected function get_current_runner() { + return get_class( \ActionScheduler::runner() ); + } + + /** + * Get oldest and newest scheduled dates for a given set of statuses. + * + * @param array $status_keys Set of statuses to find oldest & newest action for. + * @return array + */ + protected function get_oldest_and_newest( $status_keys ) { + $oldest_and_newest = array(); + + foreach ( $status_keys as $status ) { + $oldest_and_newest[ $status ] = array( + 'oldest' => '–', + 'newest' => '–', + ); + + if ( 'in-progress' === $status ) { + continue; + } + + $oldest_and_newest[ $status ]['oldest'] = $this->get_action_status_date( $status, 'oldest' ); + $oldest_and_newest[ $status ]['newest'] = $this->get_action_status_date( $status, 'newest' ); + } + + return $oldest_and_newest; + } + + /** + * Get oldest or newest scheduled date for a given status. + * + * @param string $status Action status label/name string. + * @param string $date_type Oldest or Newest. + * @return string + */ + protected function get_action_status_date( $status, $date_type = 'oldest' ) { + $order = 'oldest' === $date_type ? 'ASC' : 'DESC'; + + $args = array( + 'claimed' => false, + 'status' => $status, + 'per_page' => 1, + 'order' => $order, + ); + + $action = $this->store->query_actions( $args ); + + if ( ! empty( $action ) ) { + $date_object = $this->store->get_date( $action[0] ); + $action_date = $date_object->format( 'Y-m-d H:i:s O' ); + } else { + $action_date = '–'; + } + + return $action_date; + } + +} diff --git a/classes/abstracts/ActionScheduler.php b/classes/abstracts/ActionScheduler.php new file mode 100644 index 0000000..774abb1 --- /dev/null +++ b/classes/abstracts/ActionScheduler.php @@ -0,0 +1,400 @@ +init() (or it's parent method) set this itself, + * once it has initialized, however that would cause problems in cases where a custom data store is in + * use and it has not yet been updated to follow that same logic. + */ + function () { + self::$data_store_initialized = true; + + /** + * Fires when Action Scheduler is ready: it is safe to use the procedural API after this point. + * + * @since 3.5.5 + */ + do_action( 'action_scheduler_init' ); + }, + 1 + ); + } else { + $admin_view->init(); + $store->init(); + $logger->init(); + $runner->init(); + self::$data_store_initialized = true; + + /** + * Fires when Action Scheduler is ready: it is safe to use the procedural API after this point. + * + * @since 3.5.5 + */ + do_action( 'action_scheduler_init' ); + } + + if ( apply_filters( 'action_scheduler_load_deprecated_functions', true ) ) { + require_once self::plugin_path( 'deprecated/functions.php' ); + } + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Scheduler_command' ); + WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Clean_Command' ); + WP_CLI::add_command( 'action-scheduler action', '\Action_Scheduler\WP_CLI\Action_Command' ); + WP_CLI::add_command( 'action-scheduler', '\Action_Scheduler\WP_CLI\System_Command' ); + if ( ! ActionScheduler_DataController::is_migration_complete() && Controller::instance()->allow_migration() ) { + $command = new Migration_Command(); + $command->register(); + } + } + + /** + * Handle WP comment cleanup after migration. + */ + if ( is_a( $logger, 'ActionScheduler_DBLogger' ) && ActionScheduler_DataController::is_migration_complete() && ActionScheduler_WPCommentCleaner::has_logs() ) { + ActionScheduler_WPCommentCleaner::init(); + } + + add_action( 'action_scheduler/migration_complete', 'ActionScheduler_WPCommentCleaner::maybe_schedule_cleanup' ); + } + + /** + * Check whether the AS data store has been initialized. + * + * @param string $function_name The name of the function being called. Optional. Default `null`. + * @return bool + */ + public static function is_initialized( $function_name = null ) { + if ( ! self::$data_store_initialized && ! empty( $function_name ) ) { + $message = sprintf( + /* translators: %s function name. */ + __( '%s() was called before the Action Scheduler data store was initialized', 'action-scheduler' ), + esc_attr( $function_name ) + ); + _doing_it_wrong( esc_html( $function_name ), esc_html( $message ), '3.1.6' ); + } + + return self::$data_store_initialized; + } + + /** + * Determine if the class is one of our abstract classes. + * + * @since 3.0.0 + * + * @param string $class The class name. + * + * @return bool + */ + protected static function is_class_abstract( $class ) { + static $abstracts = array( + 'ActionScheduler' => true, + 'ActionScheduler_Abstract_ListTable' => true, + 'ActionScheduler_Abstract_QueueRunner' => true, + 'ActionScheduler_Abstract_Schedule' => true, + 'ActionScheduler_Abstract_RecurringSchedule' => true, + 'ActionScheduler_Lock' => true, + 'ActionScheduler_Logger' => true, + 'ActionScheduler_Abstract_Schema' => true, + 'ActionScheduler_Store' => true, + 'ActionScheduler_TimezoneHelper' => true, + 'ActionScheduler_WPCLI_Command' => true, + ); + + return isset( $abstracts[ $class ] ) && $abstracts[ $class ]; + } + + /** + * Determine if the class is one of our migration classes. + * + * @since 3.0.0 + * + * @param string $class The class name. + * + * @return bool + */ + protected static function is_class_migration( $class ) { + static $migration_segments = array( + 'ActionMigrator' => true, + 'BatchFetcher' => true, + 'DBStoreMigrator' => true, + 'DryRun' => true, + 'LogMigrator' => true, + 'Config' => true, + 'Controller' => true, + 'Runner' => true, + 'Scheduler' => true, + ); + + $segments = explode( '_', $class ); + $segment = isset( $segments[1] ) ? $segments[1] : $class; + + return isset( $migration_segments[ $segment ] ) && $migration_segments[ $segment ]; + } + + /** + * Determine if the class is one of our WP CLI classes. + * + * @since 3.0.0 + * + * @param string $class The class name. + * + * @return bool + */ + protected static function is_class_cli( $class ) { + static $cli_segments = array( + 'QueueRunner' => true, + 'Command' => true, + 'ProgressBar' => true, + '\Action_Scheduler\WP_CLI\Action_Command' => true, + '\Action_Scheduler\WP_CLI\System_Command' => true, + ); + + $segments = explode( '_', $class ); + $segment = isset( $segments[1] ) ? $segments[1] : $class; + + return isset( $cli_segments[ $segment ] ) && $cli_segments[ $segment ]; + } + + /** + * Clone. + */ + final public function __clone() { + trigger_error( 'Singleton. No cloning allowed!', E_USER_ERROR ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + } + + /** + * Wakeup. + */ + final public function __wakeup() { + trigger_error( 'Singleton. No serialization allowed!', E_USER_ERROR ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + } + + /** + * Construct. + */ + final private function __construct() {} + + /** Deprecated **/ + + /** + * Get DateTime object. + * + * @param null|string $when Date/time string. + * @param string $timezone Timezone string. + */ + public static function get_datetime_object( $when = null, $timezone = 'UTC' ) { + _deprecated_function( __METHOD__, '2.0', 'wcs_add_months()' ); + return as_get_datetime_object( $when, $timezone ); + } + + /** + * Issue deprecated warning if an Action Scheduler function is called in the shutdown hook. + * + * @param string $function_name The name of the function being called. + * @deprecated 3.1.6. + */ + public static function check_shutdown_hook( $function_name ) { + _deprecated_function( __FUNCTION__, '3.1.6' ); + } +} diff --git a/classes/abstracts/ActionScheduler_Abstract_ListTable.php b/classes/abstracts/ActionScheduler_Abstract_ListTable.php new file mode 100644 index 0000000..3c480e3 --- /dev/null +++ b/classes/abstracts/ActionScheduler_Abstract_ListTable.php @@ -0,0 +1,797 @@ + value pair. The + * key must much the table column name and the value is the label, which is + * automatically translated. + * + * @var array + */ + protected $columns = array(); + + /** + * Defines the row-actions. It expects an array where the key + * is the column name and the value is an array of actions. + * + * The array of actions are key => value, where key is the method name + * (with the prefix row_action_) and the value is the label + * and title. + * + * @var array + */ + protected $row_actions = array(); + + /** + * The Primary key of our table + * + * @var string + */ + protected $ID = 'ID'; + + /** + * Enables sorting, it expects an array + * of columns (the column names are the values) + * + * @var array + */ + protected $sort_by = array(); + + /** + * The default sort order + * + * @var string + */ + protected $filter_by = array(); + + /** + * The status name => count combinations for this table's items. Used to display status filters. + * + * @var array + */ + protected $status_counts = array(); + + /** + * Notices to display when loading the table. Array of arrays of form array( 'class' => {updated|error}, 'message' => 'This is the notice text display.' ). + * + * @var array + */ + protected $admin_notices = array(); + + /** + * Localised string displayed in the

    element above the able. + * + * @var string + */ + protected $table_header; + + /** + * Enables bulk actions. It must be an array where the key is the action name + * and the value is the label (which is translated automatically). It is important + * to notice that it will check that the method exists (`bulk_$name`) and will throw + * an exception if it does not exists. + * + * This class will automatically check if the current request has a bulk action, will do the + * validations and afterwards will execute the bulk method, with two arguments. The first argument + * is the array with primary keys, the second argument is a string with a list of the primary keys, + * escaped and ready to use (with `IN`). + * + * @var array + */ + protected $bulk_actions = array(); + + /** + * Makes translation easier, it basically just wraps + * `_x` with some default (the package name). + * + * @param string $text The new text to translate. + * @param string $context The context of the text. + * @return string|void The translated text. + * + * @deprecated 3.0.0 Use `_x()` instead. + */ + protected function translate( $text, $context = '' ) { + return $text; + } + + /** + * Reads `$this->bulk_actions` and returns an array that WP_List_Table understands. It + * also validates that the bulk method handler exists. It throws an exception because + * this is a library meant for developers and missing a bulk method is a development-time error. + * + * @return array + * + * @throws RuntimeException Throws RuntimeException when the bulk action does not have a callback method. + */ + protected function get_bulk_actions() { + $actions = array(); + + foreach ( $this->bulk_actions as $action => $label ) { + if ( ! is_callable( array( $this, 'bulk_' . $action ) ) ) { + throw new RuntimeException( "The bulk action $action does not have a callback method" ); + } + + $actions[ $action ] = $label; + } + + return $actions; + } + + /** + * Checks if the current request has a bulk action. If that is the case it will validate and will + * execute the bulk method handler. Regardless if the action is valid or not it will redirect to + * the previous page removing the current arguments that makes this request a bulk action. + */ + protected function process_bulk_action() { + global $wpdb; + // Detect when a bulk action is being triggered. + $action = $this->current_action(); + if ( ! $action ) { + return; + } + + check_admin_referer( 'bulk-' . $this->_args['plural'] ); + + $method = 'bulk_' . $action; + if ( array_key_exists( $action, $this->bulk_actions ) && is_callable( array( $this, $method ) ) && ! empty( $_GET['ID'] ) && is_array( $_GET['ID'] ) ) { + $ids_sql = '(' . implode( ',', array_fill( 0, count( $_GET['ID'] ), '%s' ) ) . ')'; + $id = array_map( 'absint', $_GET['ID'] ); + $this->$method( $id, $wpdb->prepare( $ids_sql, $id ) ); //phpcs:ignore WordPress.DB.PreparedSQL + } + + if ( isset( $_SERVER['REQUEST_URI'] ) ) { + wp_safe_redirect( + remove_query_arg( + array( '_wp_http_referer', '_wpnonce', 'ID', 'action', 'action2' ), + esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) + ) + ); + exit; + } + } + + /** + * Default code for deleting entries. + * validated already by process_bulk_action() + * + * @param array $ids ids of the items to delete. + * @param string $ids_sql the sql for the ids. + * @return void + */ + protected function bulk_delete( array $ids, $ids_sql ) { + $store = ActionScheduler::store(); + foreach ( $ids as $action_id ) { + $store->delete( $action_id ); + } + } + + /** + * Prepares the _column_headers property which is used by WP_Table_List at rendering. + * It merges the columns and the sortable columns. + */ + protected function prepare_column_headers() { + $this->_column_headers = array( + $this->get_columns(), + get_hidden_columns( $this->screen ), + $this->get_sortable_columns(), + ); + } + + /** + * Reads $this->sort_by and returns the columns name in a format that WP_Table_List + * expects + */ + public function get_sortable_columns() { + $sort_by = array(); + foreach ( $this->sort_by as $column ) { + $sort_by[ $column ] = array( $column, true ); + } + return $sort_by; + } + + /** + * Returns the columns names for rendering. It adds a checkbox for selecting everything + * as the first column + */ + public function get_columns() { + $columns = array_merge( + array( 'cb' => '' ), + $this->columns + ); + + return $columns; + } + + /** + * Get prepared LIMIT clause for items query + * + * @global wpdb $wpdb + * + * @return string Prepared LIMIT clause for items query. + */ + protected function get_items_query_limit() { + global $wpdb; + + $per_page = $this->get_items_per_page( $this->get_per_page_option_name(), $this->items_per_page ); + return $wpdb->prepare( 'LIMIT %d', $per_page ); + } + + /** + * Returns the number of items to offset/skip for this current view. + * + * @return int + */ + protected function get_items_offset() { + $per_page = $this->get_items_per_page( $this->get_per_page_option_name(), $this->items_per_page ); + $current_page = $this->get_pagenum(); + if ( 1 < $current_page ) { + $offset = $per_page * ( $current_page - 1 ); + } else { + $offset = 0; + } + + return $offset; + } + + /** + * Get prepared OFFSET clause for items query + * + * @global wpdb $wpdb + * + * @return string Prepared OFFSET clause for items query. + */ + protected function get_items_query_offset() { + global $wpdb; + + return $wpdb->prepare( 'OFFSET %d', $this->get_items_offset() ); + } + + /** + * Prepares the ORDER BY sql statement. It uses `$this->sort_by` to know which + * columns are sortable. This requests validates the orderby $_GET parameter is a valid + * column and sortable. It will also use order (ASC|DESC) using DESC by default. + */ + protected function get_items_query_order() { + if ( empty( $this->sort_by ) ) { + return ''; + } + + $orderby = esc_sql( $this->get_request_orderby() ); + $order = esc_sql( $this->get_request_order() ); + + return "ORDER BY {$orderby} {$order}"; + } + + /** + * Querystring arguments to persist between form submissions. + * + * @since 3.7.3 + * + * @return string[] + */ + protected function get_request_query_args_to_persist() { + return array_merge( + $this->sort_by, + array( + 'page', + 'status', + 'tab', + ) + ); + } + + /** + * Return the sortable column specified for this request to order the results by, if any. + * + * @return string + */ + protected function get_request_orderby() { + + $valid_sortable_columns = array_values( $this->sort_by ); + + if ( ! empty( $_GET['orderby'] ) && in_array( $_GET['orderby'], $valid_sortable_columns, true ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended + $orderby = sanitize_text_field( wp_unslash( $_GET['orderby'] ) ); //phpcs:ignore WordPress.Security.NonceVerification.Recommended + } else { + $orderby = $valid_sortable_columns[0]; + } + + return $orderby; + } + + /** + * Return the sortable column order specified for this request. + * + * @return string + */ + protected function get_request_order() { + + if ( ! empty( $_GET['order'] ) && 'desc' === strtolower( sanitize_text_field( wp_unslash( $_GET['order'] ) ) ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended + $order = 'DESC'; + } else { + $order = 'ASC'; + } + + return $order; + } + + /** + * Return the status filter for this request, if any. + * + * @return string + */ + protected function get_request_status() { + $status = ( ! empty( $_GET['status'] ) ) ? sanitize_text_field( wp_unslash( $_GET['status'] ) ) : ''; //phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $status; + } + + /** + * Return the search filter for this request, if any. + * + * @return string + */ + protected function get_request_search_query() { + $search_query = ( ! empty( $_GET['s'] ) ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; //phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $search_query; + } + + /** + * Process and return the columns name. This is meant for using with SQL, this means it + * always includes the primary key. + * + * @return array + */ + protected function get_table_columns() { + $columns = array_keys( $this->columns ); + if ( ! in_array( $this->ID, $columns, true ) ) { + $columns[] = $this->ID; + } + + return $columns; + } + + /** + * Check if the current request is doing a "full text" search. If that is the case + * prepares the SQL to search texts using LIKE. + * + * If the current request does not have any search or if this list table does not support + * that feature it will return an empty string. + * + * @return string + */ + protected function get_items_query_search() { + global $wpdb; + + if ( empty( $_GET['s'] ) || empty( $this->search_by ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended + return ''; + } + + $search_string = sanitize_text_field( wp_unslash( $_GET['s'] ) ); //phpcs:ignore WordPress.Security.NonceVerification.Recommended + + $filter = array(); + foreach ( $this->search_by as $column ) { + $wild = '%'; + $sql_like = $wild . $wpdb->esc_like( $search_string ) . $wild; + $filter[] = $wpdb->prepare( '`' . $column . '` LIKE %s', $sql_like ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.DB.PreparedSQL.NotPrepared + } + return implode( ' OR ', $filter ); + } + + /** + * Prepares the SQL to filter rows by the options defined at `$this->filter_by`. Before trusting + * any data sent by the user it validates that it is a valid option. + */ + protected function get_items_query_filters() { + global $wpdb; + + if ( ! $this->filter_by || empty( $_GET['filter_by'] ) || ! is_array( $_GET['filter_by'] ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended + return ''; + } + + $filter = array(); + + foreach ( $this->filter_by as $column => $options ) { + if ( empty( $_GET['filter_by'][ $column ] ) || empty( $options[ $_GET['filter_by'][ $column ] ] ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended + continue; + } + + $filter[] = $wpdb->prepare( "`$column` = %s", sanitize_text_field( wp_unslash( $_GET['filter_by'][ $column ] ) ) ); //phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + return implode( ' AND ', $filter ); + + } + + /** + * Prepares the data to feed WP_Table_List. + * + * This has the core for selecting, sorting and filtering data. To keep the code simple + * its logic is split among many methods (get_items_query_*). + * + * Beside populating the items this function will also count all the records that matches + * the filtering criteria and will do fill the pagination variables. + */ + public function prepare_items() { + global $wpdb; + + $this->process_bulk_action(); + + $this->process_row_actions(); + + if ( ! empty( $_REQUEST['_wp_http_referer'] && ! empty( $_SERVER['REQUEST_URI'] ) ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended + // _wp_http_referer is used only on bulk actions, we remove it to keep the $_GET shorter + wp_safe_redirect( remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) ); + exit; + } + + $this->prepare_column_headers(); + + $limit = $this->get_items_query_limit(); + $offset = $this->get_items_query_offset(); + $order = $this->get_items_query_order(); + $where = array_filter( + array( + $this->get_items_query_search(), + $this->get_items_query_filters(), + ) + ); + $columns = '`' . implode( '`, `', $this->get_table_columns() ) . '`'; + + if ( ! empty( $where ) ) { + $where = 'WHERE (' . implode( ') AND (', $where ) . ')'; + } else { + $where = ''; + } + + $sql = "SELECT $columns FROM {$this->table_name} {$where} {$order} {$limit} {$offset}"; + + $this->set_items( $wpdb->get_results( $sql, ARRAY_A ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + $query_count = "SELECT COUNT({$this->ID}) FROM {$this->table_name} {$where}"; + $total_items = $wpdb->get_var( $query_count ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $per_page = $this->get_items_per_page( $this->get_per_page_option_name(), $this->items_per_page ); + $this->set_pagination_args( + array( + 'total_items' => $total_items, + 'per_page' => $per_page, + 'total_pages' => ceil( $total_items / $per_page ), + ) + ); + } + + /** + * Display the table. + * + * @param string $which The name of the table. + */ + public function extra_tablenav( $which ) { + if ( ! $this->filter_by || 'top' !== $which ) { + return; + } + + echo '
    '; + + foreach ( $this->filter_by as $id => $options ) { + $default = ! empty( $_GET['filter_by'][ $id ] ) ? sanitize_text_field( wp_unslash( $_GET['filter_by'][ $id ] ) ) : ''; //phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( empty( $options[ $default ] ) ) { + $default = ''; + } + + echo ''; + } + + submit_button( esc_html__( 'Filter', 'action-scheduler' ), '', 'filter_action', false, array( 'id' => 'post-query-submit' ) ); + echo '
    '; + } + + /** + * Set the data for displaying. It will attempt to unserialize (There is a chance that some columns + * are serialized). This can be override in child classes for further data transformation. + * + * @param array $items Items array. + */ + protected function set_items( array $items ) { + $this->items = array(); + foreach ( $items as $item ) { + $this->items[ $item[ $this->ID ] ] = array_map( 'maybe_unserialize', $item ); + } + } + + /** + * Renders the checkbox for each row, this is the first column and it is named ID regardless + * of how the primary key is named (to keep the code simpler). The bulk actions will do the proper + * name transformation though using `$this->ID`. + * + * @param array $row The row to render. + */ + public function column_cb( $row ) { + return ''; + } + + /** + * Renders the row-actions. + * + * This method renders the action menu, it reads the definition from the $row_actions property, + * and it checks that the row action method exists before rendering it. + * + * @param array $row Row to be rendered. + * @param string $column_name Column name. + * @return string + */ + protected function maybe_render_actions( $row, $column_name ) { + if ( empty( $this->row_actions[ $column_name ] ) ) { + return; + } + + $row_id = $row[ $this->ID ]; + + $actions = '
    '; + $action_count = 0; + foreach ( $this->row_actions[ $column_name ] as $action_key => $action ) { + + $action_count++; + + if ( ! method_exists( $this, 'row_action_' . $action_key ) ) { + continue; + } + + $action_link = ! empty( $action['link'] ) ? $action['link'] : add_query_arg( + array( + 'row_action' => $action_key, + 'row_id' => $row_id, + 'nonce' => wp_create_nonce( $action_key . '::' . $row_id ), + ) + ); + $span_class = ! empty( $action['class'] ) ? $action['class'] : $action_key; + $separator = ( $action_count < count( $this->row_actions[ $column_name ] ) ) ? ' | ' : ''; + + $actions .= sprintf( '', esc_attr( $span_class ) ); + $actions .= sprintf( '%3$s', esc_url( $action_link ), esc_attr( $action['desc'] ), esc_html( $action['name'] ) ); + $actions .= sprintf( '%s', $separator ); + } + $actions .= '
    '; + return $actions; + } + + /** + * Process the bulk actions. + * + * @return void + */ + protected function process_row_actions() { + $parameters = array( 'row_action', 'row_id', 'nonce' ); + foreach ( $parameters as $parameter ) { + if ( empty( $_REQUEST[ $parameter ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + } + + $action = sanitize_text_field( wp_unslash( $_REQUEST['row_action'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated + $row_id = sanitize_text_field( wp_unslash( $_REQUEST['row_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated + $nonce = sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated + $method = 'row_action_' . $action; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( wp_verify_nonce( $nonce, $action . '::' . $row_id ) && method_exists( $this, $method ) ) { + $this->$method( sanitize_text_field( wp_unslash( $row_id ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + if ( isset( $_SERVER['REQUEST_URI'] ) ) { + wp_safe_redirect( + remove_query_arg( + array( 'row_id', 'row_action', 'nonce' ), + esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) + ) + ); + exit; + } + } + + /** + * Default column formatting, it will escape everything for security. + * + * @param array $item The item array. + * @param string $column_name Column name to display. + * + * @return string + */ + public function column_default( $item, $column_name ) { + $column_html = esc_html( $item[ $column_name ] ); + $column_html .= $this->maybe_render_actions( $item, $column_name ); + return $column_html; + } + + /** + * Display the table heading and search query, if any + */ + protected function display_header() { + echo '

    ' . esc_attr( $this->table_header ) . '

    '; + if ( $this->get_request_search_query() ) { + /* translators: %s: search query */ + echo '' . esc_attr( sprintf( __( 'Search results for "%s"', 'action-scheduler' ), $this->get_request_search_query() ) ) . ''; + } + echo '
    '; + } + + /** + * Display the table heading and search query, if any + */ + protected function display_admin_notices() { + foreach ( $this->admin_notices as $notice ) { + echo '
    '; + echo '

    ' . wp_kses_post( $notice['message'] ) . '

    '; + echo '
    '; + } + } + + /** + * Prints the available statuses so the user can click to filter. + */ + protected function display_filter_by_status() { + + $status_list_items = array(); + $request_status = $this->get_request_status(); + + // Helper to set 'all' filter when not set on status counts passed in. + if ( ! isset( $this->status_counts['all'] ) ) { + $all_count = array_sum( $this->status_counts ); + if ( isset( $this->status_counts['past-due'] ) ) { + $all_count -= $this->status_counts['past-due']; + } + $this->status_counts = array( 'all' => $all_count ) + $this->status_counts; + } + + // Translated status labels. + $status_labels = ActionScheduler_Store::instance()->get_status_labels(); + $status_labels['all'] = esc_html_x( 'All', 'status labels', 'action-scheduler' ); + $status_labels['past-due'] = esc_html_x( 'Past-due', 'status labels', 'action-scheduler' ); + + foreach ( $this->status_counts as $status_slug => $count ) { + + if ( 0 === $count ) { + continue; + } + + if ( $status_slug === $request_status || ( empty( $request_status ) && 'all' === $status_slug ) ) { + $status_list_item = '
  • %3$s (%4$d)
  • '; + } else { + $status_list_item = '
  • %3$s (%4$d)
  • '; + } + + $status_name = isset( $status_labels[ $status_slug ] ) ? $status_labels[ $status_slug ] : ucfirst( $status_slug ); + $status_filter_url = ( 'all' === $status_slug ) ? remove_query_arg( 'status' ) : add_query_arg( 'status', $status_slug ); + $status_filter_url = remove_query_arg( array( 'paged', 's' ), $status_filter_url ); + $status_list_items[] = sprintf( $status_list_item, esc_attr( $status_slug ), esc_url( $status_filter_url ), esc_html( $status_name ), absint( $count ) ); + } + + if ( $status_list_items ) { + echo '
      '; + echo implode( " | \n", $status_list_items ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
    '; + } + } + + /** + * Renders the table list, we override the original class to render the table inside a form + * and to render any needed HTML (like the search box). By doing so the callee of a function can simple + * forget about any extra HTML. + */ + protected function display_table() { + echo '
    '; + foreach ( $this->get_request_query_args_to_persist() as $arg ) { + $arg_value = isset( $_GET[ $arg ] ) ? sanitize_text_field( wp_unslash( $_GET[ $arg ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! $arg_value ) { + continue; + } + + echo ''; + } + + if ( ! empty( $this->search_by ) ) { + echo $this->search_box( $this->get_search_box_button_text(), 'plugin' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + parent::display(); + echo '
    '; + } + + /** + * Process any pending actions. + */ + public function process_actions() { + $this->process_bulk_action(); + $this->process_row_actions(); + + if ( ! empty( $_REQUEST['_wp_http_referer'] ) && ! empty( $_SERVER['REQUEST_URI'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + // _wp_http_referer is used only on bulk actions, we remove it to keep the $_GET shorter + wp_safe_redirect( remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) ); + exit; + } + } + + /** + * Render the list table page, including header, notices, status filters and table. + */ + public function display_page() { + $this->prepare_items(); + + echo '
    '; + $this->display_header(); + $this->display_admin_notices(); + $this->display_filter_by_status(); + $this->display_table(); + echo '
    '; + } + + /** + * Get the text to display in the search box on the list table. + */ + protected function get_search_box_placeholder() { + return esc_html__( 'Search', 'action-scheduler' ); + } + + /** + * Gets the screen per_page option name. + * + * @return string + */ + protected function get_per_page_option_name() { + return $this->package . '_items_per_page'; + } +} diff --git a/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php b/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php new file mode 100644 index 0000000..1b51c30 --- /dev/null +++ b/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php @@ -0,0 +1,383 @@ +created_time = microtime( true ); + + $this->store = $store ? $store : ActionScheduler_Store::instance(); + $this->monitor = $monitor ? $monitor : new ActionScheduler_FatalErrorMonitor( $this->store ); + $this->cleaner = $cleaner ? $cleaner : new ActionScheduler_QueueCleaner( $this->store ); + } + + /** + * Process an individual action. + * + * @param int $action_id The action ID to process. + * @param string $context Optional identifier for the context in which this action is being processed, e.g. 'WP CLI' or 'WP Cron' + * Generally, this should be capitalised and not localised as it's a proper noun. + * @throws \Exception When error running action. + */ + public function process_action( $action_id, $context = '' ) { + // Temporarily override the error handler while we process the current action. + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler + set_error_handler( + /** + * Temporary error handler which can catch errors and convert them into exceptions. This facilitates more + * robust error handling across all supported PHP versions. + * + * @throws Exception + * + * @param int $type Error level expressed as an integer. + * @param string $message Error message. + */ + function ( $type, $message ) { + throw new Exception( $message ); + }, + E_USER_ERROR | E_RECOVERABLE_ERROR + ); + + /* + * The nested try/catch structure is required because we potentially need to convert thrown errors into + * exceptions (and an exception thrown from a catch block cannot be caught by a later catch block in the *same* + * structure). + */ + try { + try { + $valid_action = false; + do_action( 'action_scheduler_before_execute', $action_id, $context ); + + if ( ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) { + do_action( 'action_scheduler_execution_ignored', $action_id, $context ); + return; + } + + $valid_action = true; + do_action( 'action_scheduler_begin_execute', $action_id, $context ); + + $action = $this->store->fetch_action( $action_id ); + $this->store->log_execution( $action_id ); + $action->execute(); + do_action( 'action_scheduler_after_execute', $action_id, $action, $context ); + $this->store->mark_complete( $action_id ); + } catch ( Throwable $e ) { + // Throwable is defined when executing under PHP 7.0 and up. We convert it to an exception, for + // compatibility with ActionScheduler_Logger. + throw new Exception( $e->getMessage(), $e->getCode(), $e ); + } + } catch ( Exception $e ) { + // This catch block exists for compatibility with PHP 5.6. + $this->handle_action_error( $action_id, $e, $context, $valid_action ); + } finally { + restore_error_handler(); + } + + if ( isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) && $action->get_schedule()->is_recurring() ) { + $this->schedule_next_instance( $action, $action_id ); + } + } + + /** + * Marks actions as either having failed execution or failed validation, as appropriate. + * + * @param int $action_id Action ID. + * @param Exception $e Exception instance. + * @param string $context Execution context. + * @param bool $valid_action If the action is valid. + * + * @return void + */ + private function handle_action_error( $action_id, $e, $context, $valid_action ) { + if ( $valid_action ) { + $this->store->mark_failure( $action_id ); + /** + * Runs when action execution fails. + * + * @param int $action_id Action ID. + * @param Exception $e Exception instance. + * @param string $context Execution context. + */ + do_action( 'action_scheduler_failed_execution', $action_id, $e, $context ); + } else { + /** + * Runs when action validation fails. + * + * @param int $action_id Action ID. + * @param Exception $e Exception instance. + * @param string $context Execution context. + */ + do_action( 'action_scheduler_failed_validation', $action_id, $e, $context ); + } + } + + /** + * Schedule the next instance of the action if necessary. + * + * @param ActionScheduler_Action $action Action. + * @param int $action_id Action ID. + */ + protected function schedule_next_instance( ActionScheduler_Action $action, $action_id ) { + // If a recurring action has been consistently failing, we may wish to stop rescheduling it. + if ( + ActionScheduler_Store::STATUS_FAILED === $this->store->get_status( $action_id ) + && $this->recurring_action_is_consistently_failing( $action, $action_id ) + ) { + ActionScheduler_Logger::instance()->log( + $action_id, + __( 'This action appears to be consistently failing. A new instance will not be scheduled.', 'action-scheduler' ) + ); + + return; + } + + try { + ActionScheduler::factory()->repeat( $action ); + } catch ( Exception $e ) { + do_action( 'action_scheduler_failed_to_schedule_next_instance', $action_id, $e, $action ); + } + } + + /** + * Determine if the specified recurring action has been consistently failing. + * + * @param ActionScheduler_Action $action The recurring action to be rescheduled. + * @param int $action_id The ID of the recurring action. + * + * @return bool + */ + private function recurring_action_is_consistently_failing( ActionScheduler_Action $action, $action_id ) { + /** + * Controls the failure threshold for recurring actions. + * + * Before rescheduling a recurring action, we look at its status. If it failed, we then check if all of the most + * recent actions (upto the threshold set by this filter) sharing the same hook have also failed: if they have, + * that is considered consistent failure and a new instance of the action will not be scheduled. + * + * @param int $failure_threshold Number of actions of the same hook to examine for failure. Defaults to 5. + */ + $consistent_failure_threshold = (int) apply_filters( 'action_scheduler_recurring_action_failure_threshold', 5 ); + + // This query should find the earliest *failing* action (for the hook we are interested in) within our threshold. + $query_args = array( + 'hook' => $action->get_hook(), + 'status' => ActionScheduler_Store::STATUS_FAILED, + 'date' => date_create( 'now', timezone_open( 'UTC' ) )->format( 'Y-m-d H:i:s' ), + 'date_compare' => '<', + 'per_page' => 1, + 'offset' => $consistent_failure_threshold - 1, + ); + + $first_failing_action_id = $this->store->query_actions( $query_args ); + + // If we didn't retrieve an action ID, then there haven't been enough failures for us to worry about. + if ( empty( $first_failing_action_id ) ) { + return false; + } + + // Now let's fetch the first action (having the same hook) of *any status* within the same window. + unset( $query_args['status'] ); + $first_action_id_with_the_same_hook = $this->store->query_actions( $query_args ); + + /** + * If a recurring action is assessed as consistently failing, it will not be rescheduled. This hook provides a + * way to observe and optionally override that assessment. + * + * @param bool $is_consistently_failing If the action is considered to be consistently failing. + * @param ActionScheduler_Action $action The action being assessed. + */ + return (bool) apply_filters( + 'action_scheduler_recurring_action_is_consistently_failing', + $first_action_id_with_the_same_hook === $first_failing_action_id, + $action + ); + } + + /** + * Run the queue cleaner. + */ + protected function run_cleanup() { + $this->cleaner->clean( 10 * $this->get_time_limit() ); + } + + /** + * Get the number of concurrent batches a runner allows. + * + * @return int + */ + public function get_allowed_concurrent_batches() { + return apply_filters( 'action_scheduler_queue_runner_concurrent_batches', 1 ); + } + + /** + * Check if the number of allowed concurrent batches is met or exceeded. + * + * @return bool + */ + public function has_maximum_concurrent_batches() { + return $this->store->get_claim_count() >= $this->get_allowed_concurrent_batches(); + } + + /** + * Get the maximum number of seconds a batch can run for. + * + * @return int The number of seconds. + */ + protected function get_time_limit() { + + $time_limit = 30; + + // Apply deprecated filter from deprecated get_maximum_execution_time() method. + if ( has_filter( 'action_scheduler_maximum_execution_time' ) ) { + _deprecated_function( 'action_scheduler_maximum_execution_time', '2.1.1', 'action_scheduler_queue_runner_time_limit' ); + $time_limit = apply_filters( 'action_scheduler_maximum_execution_time', $time_limit ); + } + + return absint( apply_filters( 'action_scheduler_queue_runner_time_limit', $time_limit ) ); + } + + /** + * Get the number of seconds the process has been running. + * + * @return int The number of seconds. + */ + protected function get_execution_time() { + $execution_time = microtime( true ) - $this->created_time; + + // Get the CPU time if the hosting environment uses it rather than wall-clock time to calculate a process's execution time. + if ( function_exists( 'getrusage' ) && apply_filters( 'action_scheduler_use_cpu_execution_time', defined( 'PANTHEON_ENVIRONMENT' ) ) ) { + $resource_usages = getrusage(); + + if ( isset( $resource_usages['ru_stime.tv_usec'], $resource_usages['ru_stime.tv_usec'] ) ) { + $execution_time = $resource_usages['ru_stime.tv_sec'] + ( $resource_usages['ru_stime.tv_usec'] / 1000000 ); + } + } + + return $execution_time; + } + + /** + * Check if the host's max execution time is (likely) to be exceeded if processing more actions. + * + * @param int $processed_actions The number of actions processed so far - used to determine the likelihood of exceeding the time limit if processing another action. + * @return bool + */ + protected function time_likely_to_be_exceeded( $processed_actions ) { + $execution_time = $this->get_execution_time(); + $max_execution_time = $this->get_time_limit(); + + // Safety against division by zero errors. + if ( 0 === $processed_actions ) { + return $execution_time >= $max_execution_time; + } + + $time_per_action = $execution_time / $processed_actions; + $estimated_time = $execution_time + ( $time_per_action * 3 ); + $likely_to_be_exceeded = $estimated_time > $max_execution_time; + + return apply_filters( 'action_scheduler_maximum_execution_time_likely_to_be_exceeded', $likely_to_be_exceeded, $this, $processed_actions, $execution_time, $max_execution_time ); + } + + /** + * Get memory limit + * + * Based on WP_Background_Process::get_memory_limit() + * + * @return int + */ + protected function get_memory_limit() { + if ( function_exists( 'ini_get' ) ) { + $memory_limit = ini_get( 'memory_limit' ); + } else { + $memory_limit = '128M'; // Sensible default, and minimum required by WooCommerce. + } + + if ( ! $memory_limit || -1 === $memory_limit || '-1' === $memory_limit ) { + // Unlimited, set to 32GB. + $memory_limit = '32G'; + } + + return ActionScheduler_Compatibility::convert_hr_to_bytes( $memory_limit ); + } + + /** + * Memory exceeded + * + * Ensures the batch process never exceeds 90% of the maximum WordPress memory. + * + * Based on WP_Background_Process::memory_exceeded() + * + * @return bool + */ + protected function memory_exceeded() { + + $memory_limit = $this->get_memory_limit() * 0.90; + $current_memory = memory_get_usage( true ); + $memory_exceeded = $current_memory >= $memory_limit; + + return apply_filters( 'action_scheduler_memory_exceeded', $memory_exceeded, $this ); + } + + /** + * See if the batch limits have been exceeded, which is when memory usage is almost at + * the maximum limit, or the time to process more actions will exceed the max time limit. + * + * Based on WC_Background_Process::batch_limits_exceeded() + * + * @param int $processed_actions The number of actions processed so far - used to determine the likelihood of exceeding the time limit if processing another action. + * @return bool + */ + protected function batch_limits_exceeded( $processed_actions ) { + return $this->memory_exceeded() || $this->time_likely_to_be_exceeded( $processed_actions ); + } + + /** + * Process actions in the queue. + * + * @param string $context Optional identifier for the context in which this action is being processed, e.g. 'WP CLI' or 'WP Cron' + * Generally, this should be capitalised and not localised as it's a proper noun. + * @return int The number of actions processed. + */ + abstract public function run( $context = '' ); +} diff --git a/classes/abstracts/ActionScheduler_Abstract_RecurringSchedule.php b/classes/abstracts/ActionScheduler_Abstract_RecurringSchedule.php new file mode 100644 index 0000000..60d09e9 --- /dev/null +++ b/classes/abstracts/ActionScheduler_Abstract_RecurringSchedule.php @@ -0,0 +1,112 @@ +start - and logic to calculate the next run date after + * that - @see $this->calculate_next(). The $first_date property also keeps a record of when the very + * first instance of this chain of schedules ran. + * + * @var DateTime + */ + private $first_date = null; + + /** + * Timestamp equivalent of @see $this->first_date + * + * @var int + */ + protected $first_timestamp = null; + + /** + * The recurrence between each time an action is run using this schedule. + * Used to calculate the start date & time. Can be a number of seconds, in the + * case of ActionScheduler_IntervalSchedule, or a cron expression, as in the + * case of ActionScheduler_CronSchedule. Or something else. + * + * @var mixed + */ + protected $recurrence; + + /** + * Construct. + * + * @param DateTime $date The date & time to run the action. + * @param mixed $recurrence The data used to determine the schedule's recurrence. + * @param DateTime|null $first (Optional) The date & time the first instance of this interval schedule ran. Default null, meaning this is the first instance. + */ + public function __construct( DateTime $date, $recurrence, ?DateTime $first = null ) { + parent::__construct( $date ); + $this->first_date = empty( $first ) ? $date : $first; + $this->recurrence = $recurrence; + } + + /** + * Schedule is recurring. + * + * @return bool + */ + public function is_recurring() { + return true; + } + + /** + * Get the date & time of the first schedule in this recurring series. + * + * @return DateTime|null + */ + public function get_first_date() { + return clone $this->first_date; + } + + /** + * Get the schedule's recurrence. + * + * @return string + */ + public function get_recurrence() { + return $this->recurrence; + } + + /** + * For PHP 5.2 compat, since DateTime objects can't be serialized + * + * @return array + */ + public function __sleep() { + $sleep_params = parent::__sleep(); + $this->first_timestamp = $this->first_date->getTimestamp(); + return array_merge( + $sleep_params, + array( + 'first_timestamp', + 'recurrence', + ) + ); + } + + /** + * Unserialize recurring schedules serialized/stored prior to AS 3.0.0 + * + * Prior to Action Scheduler 3.0.0, schedules used different property names to refer + * to equivalent data. For example, ActionScheduler_IntervalSchedule::start_timestamp + * was the same as ActionScheduler_SimpleSchedule::timestamp. This was addressed in + * Action Scheduler 3.0.0, where properties and property names were aligned for better + * inheritance. To maintain backward compatibility with scheduled serialized and stored + * prior to 3.0, we need to correctly map the old property names. + */ + public function __wakeup() { + parent::__wakeup(); + if ( $this->first_timestamp > 0 ) { + $this->first_date = as_get_datetime_object( $this->first_timestamp ); + } else { + $this->first_date = $this->get_date(); + } + } +} diff --git a/classes/abstracts/ActionScheduler_Abstract_Schedule.php b/classes/abstracts/ActionScheduler_Abstract_Schedule.php new file mode 100644 index 0000000..b7826b4 --- /dev/null +++ b/classes/abstracts/ActionScheduler_Abstract_Schedule.php @@ -0,0 +1,89 @@ +scheduled_date + * + * @var int + */ + protected $scheduled_timestamp = null; + + /** + * Construct. + * + * @param DateTime $date The date & time to run the action. + */ + public function __construct( DateTime $date ) { + $this->scheduled_date = $date; + } + + /** + * Check if a schedule should recur. + * + * @return bool + */ + abstract public function is_recurring(); + + /** + * Calculate when the next instance of this schedule would run based on a given date & time. + * + * @param DateTime $after Start timestamp. + * @return DateTime + */ + abstract protected function calculate_next( DateTime $after ); + + /** + * Get the next date & time when this schedule should run after a given date & time. + * + * @param DateTime $after Start timestamp. + * @return DateTime|null + */ + public function get_next( DateTime $after ) { + $after = clone $after; + if ( $after > $this->scheduled_date ) { + $after = $this->calculate_next( $after ); + return $after; + } + return clone $this->scheduled_date; + } + + /** + * Get the date & time the schedule is set to run. + * + * @return DateTime|null + */ + public function get_date() { + return $this->scheduled_date; + } + + /** + * For PHP 5.2 compat, because DateTime objects can't be serialized + * + * @return array + */ + public function __sleep() { + $this->scheduled_timestamp = $this->scheduled_date->getTimestamp(); + return array( + 'scheduled_timestamp', + ); + } + + /** + * Wakeup. + */ + public function __wakeup() { + $this->scheduled_date = as_get_datetime_object( $this->scheduled_timestamp ); + unset( $this->scheduled_timestamp ); + } +} diff --git a/classes/abstracts/ActionScheduler_Abstract_Schema.php b/classes/abstracts/ActionScheduler_Abstract_Schema.php new file mode 100644 index 0000000..69817fe --- /dev/null +++ b/classes/abstracts/ActionScheduler_Abstract_Schema.php @@ -0,0 +1,187 @@ +tables as $table ) { + $wpdb->tables[] = $table; + $name = $this->get_full_table_name( $table ); + $wpdb->$table = $name; + } + + // create the tables. + if ( $this->schema_update_required() || $force_update ) { + foreach ( $this->tables as $table ) { + /** + * Allow custom processing before updating a table schema. + * + * @param string $table Name of table being updated. + * @param string $db_version Existing version of the table being updated. + */ + do_action( 'action_scheduler_before_schema_update', $table, $this->db_version ); + $this->update_table( $table ); + } + $this->mark_schema_update_complete(); + } + } + + /** + * Get table definition. + * + * @param string $table The name of the table. + * + * @return string The CREATE TABLE statement, suitable for passing to dbDelta + */ + abstract protected function get_table_definition( $table ); + + /** + * Determine if the database schema is out of date + * by comparing the integer found in $this->schema_version + * with the option set in the WordPress options table + * + * @return bool + */ + private function schema_update_required() { + $option_name = 'schema-' . static::class; + $this->db_version = get_option( $option_name, 0 ); + + // Check for schema option stored by the Action Scheduler Custom Tables plugin in case site has migrated from that plugin with an older schema. + if ( 0 === $this->db_version ) { + + $plugin_option_name = 'schema-'; + + switch ( static::class ) { + case 'ActionScheduler_StoreSchema': + $plugin_option_name .= 'Action_Scheduler\Custom_Tables\DB_Store_Table_Maker'; + break; + case 'ActionScheduler_LoggerSchema': + $plugin_option_name .= 'Action_Scheduler\Custom_Tables\DB_Logger_Table_Maker'; + break; + } + + $this->db_version = get_option( $plugin_option_name, 0 ); + + delete_option( $plugin_option_name ); + } + + return version_compare( $this->db_version, $this->schema_version, '<' ); + } + + /** + * Update the option in WordPress to indicate that + * our schema is now up to date + * + * @return void + */ + private function mark_schema_update_complete() { + $option_name = 'schema-' . static::class; + + // work around race conditions and ensure that our option updates. + $value_to_save = (string) $this->schema_version . '.0.' . time(); + + update_option( $option_name, $value_to_save ); + } + + /** + * Update the schema for the given table + * + * @param string $table The name of the table to update. + * + * @return void + */ + private function update_table( $table ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + $definition = $this->get_table_definition( $table ); + if ( $definition ) { + $updated = dbDelta( $definition ); + foreach ( $updated as $updated_table => $update_description ) { + if ( strpos( $update_description, 'Created table' ) === 0 ) { + do_action( 'action_scheduler/created_table', $updated_table, $table ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + } + } + } + } + + /** + * Get full table name. + * + * @param string $table Table name. + * + * @return string The full name of the table, including the + * table prefix for the current blog + */ + protected function get_full_table_name( $table ) { + return $GLOBALS['wpdb']->prefix . $table; + } + + /** + * Confirms that all of the tables registered by this schema class have been created. + * + * @return bool + */ + public function tables_exist() { + global $wpdb; + + $tables_exist = true; + + foreach ( $this->tables as $table_name ) { + $table_name = $wpdb->prefix . $table_name; + $pattern = str_replace( '_', '\\_', $table_name ); + $existing_table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $pattern ) ); + + if ( $existing_table !== $table_name ) { + $tables_exist = false; + break; + } + } + + return $tables_exist; + } +} diff --git a/classes/abstracts/ActionScheduler_Lock.php b/classes/abstracts/ActionScheduler_Lock.php new file mode 100644 index 0000000..7280207 --- /dev/null +++ b/classes/abstracts/ActionScheduler_Lock.php @@ -0,0 +1,74 @@ +get_expiration( $lock_type ) >= time() ); + } + + /** + * Set a lock. + * + * To prevent race conditions, implementations should avoid setting the lock if the lock is already held. + * + * @param string $lock_type A string to identify different lock types. + * @return bool + */ + abstract public function set( $lock_type ); + + /** + * If a lock is set, return the timestamp it was set to expiry. + * + * @param string $lock_type A string to identify different lock types. + * @return bool|int False if no lock is set, otherwise the timestamp for when the lock is set to expire. + */ + abstract public function get_expiration( $lock_type ); + + /** + * Get the amount of time to set for a given lock. 60 seconds by default. + * + * @param string $lock_type A string to identify different lock types. + * @return int + */ + protected function get_duration( $lock_type ) { + return apply_filters( 'action_scheduler_lock_duration', self::$lock_duration, $lock_type ); + } + + /** + * Get instance. + * + * @return ActionScheduler_Lock + */ + public static function instance() { + if ( empty( self::$locker ) ) { + $class = apply_filters( 'action_scheduler_lock_class', 'ActionScheduler_OptionLock' ); + self::$locker = new $class(); + } + return self::$locker; + } +} diff --git a/classes/abstracts/ActionScheduler_Logger.php b/classes/abstracts/ActionScheduler_Logger.php new file mode 100644 index 0000000..94ee2a9 --- /dev/null +++ b/classes/abstracts/ActionScheduler_Logger.php @@ -0,0 +1,258 @@ +hook_stored_action(); + add_action( 'action_scheduler_canceled_action', array( $this, 'log_canceled_action' ), 10, 1 ); + add_action( 'action_scheduler_begin_execute', array( $this, 'log_started_action' ), 10, 2 ); + add_action( 'action_scheduler_after_execute', array( $this, 'log_completed_action' ), 10, 3 ); + add_action( 'action_scheduler_failed_execution', array( $this, 'log_failed_action' ), 10, 3 ); + add_action( 'action_scheduler_failed_action', array( $this, 'log_timed_out_action' ), 10, 2 ); + add_action( 'action_scheduler_unexpected_shutdown', array( $this, 'log_unexpected_shutdown' ), 10, 2 ); + add_action( 'action_scheduler_reset_action', array( $this, 'log_reset_action' ), 10, 1 ); + add_action( 'action_scheduler_execution_ignored', array( $this, 'log_ignored_action' ), 10, 2 ); + add_action( 'action_scheduler_failed_fetch_action', array( $this, 'log_failed_fetch_action' ), 10, 2 ); + add_action( 'action_scheduler_failed_to_schedule_next_instance', array( $this, 'log_failed_schedule_next_instance' ), 10, 2 ); + add_action( 'action_scheduler_bulk_cancel_actions', array( $this, 'bulk_log_cancel_actions' ), 10, 1 ); + } + + /** + * Register callback for storing action. + */ + public function hook_stored_action() { + add_action( 'action_scheduler_stored_action', array( $this, 'log_stored_action' ) ); + } + + /** + * Unhook callback for storing action. + */ + public function unhook_stored_action() { + remove_action( 'action_scheduler_stored_action', array( $this, 'log_stored_action' ) ); + } + + /** + * Log action stored. + * + * @param int $action_id Action ID. + */ + public function log_stored_action( $action_id ) { + $this->log( $action_id, __( 'action created', 'action-scheduler' ) ); + } + + /** + * Log action cancellation. + * + * @param int $action_id Action ID. + */ + public function log_canceled_action( $action_id ) { + $this->log( $action_id, __( 'action canceled', 'action-scheduler' ) ); + } + + /** + * Log action start. + * + * @param int $action_id Action ID. + * @param string $context Action execution context. + */ + public function log_started_action( $action_id, $context = '' ) { + if ( ! empty( $context ) ) { + /* translators: %s: context */ + $message = sprintf( __( 'action started via %s', 'action-scheduler' ), $context ); + } else { + $message = __( 'action started', 'action-scheduler' ); + } + $this->log( $action_id, $message ); + } + + /** + * Log action completion. + * + * @param int $action_id Action ID. + * @param null|ActionScheduler_Action $action Action. + * @param string $context Action execution context. + */ + public function log_completed_action( $action_id, $action = null, $context = '' ) { + if ( ! empty( $context ) ) { + /* translators: %s: context */ + $message = sprintf( __( 'action complete via %s', 'action-scheduler' ), $context ); + } else { + $message = __( 'action complete', 'action-scheduler' ); + } + $this->log( $action_id, $message ); + } + + /** + * Log action failure. + * + * @param int $action_id Action ID. + * @param Exception $exception Exception. + * @param string $context Action execution context. + */ + public function log_failed_action( $action_id, Exception $exception, $context = '' ) { + if ( ! empty( $context ) ) { + /* translators: 1: context 2: exception message */ + $message = sprintf( __( 'action failed via %1$s: %2$s', 'action-scheduler' ), $context, $exception->getMessage() ); + } else { + /* translators: %s: exception message */ + $message = sprintf( __( 'action failed: %s', 'action-scheduler' ), $exception->getMessage() ); + } + $this->log( $action_id, $message ); + } + + /** + * Log action timeout. + * + * @param int $action_id Action ID. + * @param string $timeout Timeout. + */ + public function log_timed_out_action( $action_id, $timeout ) { + /* translators: %s: amount of time */ + $this->log( $action_id, sprintf( __( 'action marked as failed after %s seconds. Unknown error occurred. Check server, PHP and database error logs to diagnose cause.', 'action-scheduler' ), $timeout ) ); + } + + /** + * Log unexpected shutdown. + * + * @param int $action_id Action ID. + * @param mixed[] $error Error. + */ + public function log_unexpected_shutdown( $action_id, $error ) { + if ( ! empty( $error ) ) { + /* translators: 1: error message 2: filename 3: line */ + $this->log( $action_id, sprintf( __( 'unexpected shutdown: PHP Fatal error %1$s in %2$s on line %3$s', 'action-scheduler' ), $error['message'], $error['file'], $error['line'] ) ); + } + } + + /** + * Log action reset. + * + * @param int $action_id Action ID. + */ + public function log_reset_action( $action_id ) { + $this->log( $action_id, __( 'action reset', 'action-scheduler' ) ); + } + + /** + * Log ignored action. + * + * @param int $action_id Action ID. + * @param string $context Action execution context. + */ + public function log_ignored_action( $action_id, $context = '' ) { + if ( ! empty( $context ) ) { + /* translators: %s: context */ + $message = sprintf( __( 'action ignored via %s', 'action-scheduler' ), $context ); + } else { + $message = __( 'action ignored', 'action-scheduler' ); + } + $this->log( $action_id, $message ); + } + + /** + * Log the failure of fetching the action. + * + * @param string $action_id Action ID. + * @param null|Exception $exception The exception which occurred when fetching the action. NULL by default for backward compatibility. + */ + public function log_failed_fetch_action( $action_id, ?Exception $exception = null ) { + + if ( ! is_null( $exception ) ) { + /* translators: %s: exception message */ + $log_message = sprintf( __( 'There was a failure fetching this action: %s', 'action-scheduler' ), $exception->getMessage() ); + } else { + $log_message = __( 'There was a failure fetching this action', 'action-scheduler' ); + } + + $this->log( $action_id, $log_message ); + } + + /** + * Log the failure of scheduling the action's next instance. + * + * @param int $action_id Action ID. + * @param Exception $exception Exception object. + */ + public function log_failed_schedule_next_instance( $action_id, Exception $exception ) { + /* translators: %s: exception message */ + $this->log( $action_id, sprintf( __( 'There was a failure scheduling the next instance of this action: %s', 'action-scheduler' ), $exception->getMessage() ) ); + } + + /** + * Bulk add cancel action log entries. + * + * Implemented here for backward compatibility. Should be implemented in parent loggers + * for more performant bulk logging. + * + * @param array $action_ids List of action ID. + */ + public function bulk_log_cancel_actions( $action_ids ) { + if ( empty( $action_ids ) ) { + return; + } + + foreach ( $action_ids as $action_id ) { + $this->log_canceled_action( $action_id ); + } + } +} diff --git a/classes/abstracts/ActionScheduler_Store.php b/classes/abstracts/ActionScheduler_Store.php new file mode 100644 index 0000000..bf6bc42 --- /dev/null +++ b/classes/abstracts/ActionScheduler_Store.php @@ -0,0 +1,506 @@ + null, + 'status' => self::STATUS_PENDING, + 'group' => '', + ) + ); + + // These params are fixed for this method. + $params['hook'] = $hook; + $params['orderby'] = 'date'; + $params['per_page'] = 1; + + if ( ! empty( $params['status'] ) ) { + if ( self::STATUS_PENDING === $params['status'] ) { + $params['order'] = 'ASC'; // Find the next action that matches. + } else { + $params['order'] = 'DESC'; // Find the most recent action that matches. + } + } + + $results = $this->query_actions( $params ); + + return empty( $results ) ? null : $results[0]; + } + + /** + * Query for action count or list of action IDs. + * + * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status. + * + * @param array $query { + * Query filtering options. + * + * @type string $hook The name of the actions. Optional. + * @type string|array $status The status or statuses of the actions. Optional. + * @type array $args The args array of the actions. Optional. + * @type DateTime $date The scheduled date of the action. Used in UTC timezone. Optional. + * @type string $date_compare Operator for selecting by $date param. Accepted values are '!=', '>', '>=', '<', '<=', '='. Defaults to '<='. + * @type DateTime $modified The last modified date of the action. Used in UTC timezone. Optional. + * @type string $modified_compare Operator for comparing $modified param. Accepted values are '!=', '>', '>=', '<', '<=', '='. Defaults to '<='. + * @type string $group The group the action belongs to. Optional. + * @type bool|int $claimed TRUE to find claimed actions, FALSE to find unclaimed actions, an int to find a specific claim ID. Optional. + * @type int $per_page Number of results to return. Defaults to 5. + * @type int $offset The query pagination offset. Defaults to 0. + * @type int $orderby Accepted values are 'hook', 'group', 'modified', 'date' or 'none'. Defaults to 'date'. + * @type string $order Accepted values are 'ASC' or 'DESC'. Defaults to 'ASC'. + * } + * @param string $query_type Whether to select or count the results. Default, select. + * + * @return string|array|null The IDs of actions matching the query. Null on failure. + */ + abstract public function query_actions( $query = array(), $query_type = 'select' ); + + /** + * Run query to get a single action ID. + * + * @since 3.3.0 + * + * @see ActionScheduler_Store::query_actions for $query arg usage but 'per_page' and 'offset' can't be used. + * + * @param array $query Query parameters. + * + * @return int|null + */ + public function query_action( $query ) { + $query['per_page'] = 1; + $query['offset'] = 0; + $results = $this->query_actions( $query ); + + if ( empty( $results ) ) { + return null; + } else { + return (int) $results[0]; + } + } + + /** + * Get a count of all actions in the store, grouped by status + * + * @return array + */ + abstract public function action_counts(); + + /** + * Get additional action counts. + * + * - add past-due actions + * + * @return array + */ + public function extra_action_counts() { + $extra_actions = array(); + + $pastdue_action_counts = (int) $this->query_actions( + array( + 'status' => self::STATUS_PENDING, + 'date' => as_get_datetime_object(), + ), + 'count' + ); + + if ( $pastdue_action_counts ) { + $extra_actions['past-due'] = $pastdue_action_counts; + } + + /** + * Allows 3rd party code to add extra action counts (used in filters in the list table). + * + * @since 3.5.0 + * @param $extra_actions array Array with format action_count_identifier => action count. + */ + return apply_filters( 'action_scheduler_extra_action_counts', $extra_actions ); + } + + /** + * Cancel action. + * + * @param string $action_id Action ID. + */ + abstract public function cancel_action( $action_id ); + + /** + * Delete action. + * + * @param string $action_id Action ID. + */ + abstract public function delete_action( $action_id ); + + /** + * Get action's schedule or run timestamp. + * + * @param string $action_id Action ID. + * + * @return DateTime The date the action is schedule to run, or the date that it ran. + */ + abstract public function get_date( $action_id ); + + + /** + * Make a claim. + * + * @param int $max_actions Maximum number of actions to claim. + * @param DateTime|null $before_date Claim only actions schedule before the given date. Defaults to now. + * @param array $hooks Claim only actions with a hook or hooks. + * @param string $group Claim only actions in the given group. + * + * @return ActionScheduler_ActionClaim + */ + abstract public function stake_claim( $max_actions = 10, ?DateTime $before_date = null, $hooks = array(), $group = '' ); + + /** + * Get claim count. + * + * @return int + */ + abstract public function get_claim_count(); + + /** + * Release the claim. + * + * @param ActionScheduler_ActionClaim $claim Claim object. + */ + abstract public function release_claim( ActionScheduler_ActionClaim $claim ); + + /** + * Un-claim the action. + * + * @param string $action_id Action ID. + */ + abstract public function unclaim_action( $action_id ); + + /** + * Mark action as failed. + * + * @param string $action_id Action ID. + */ + abstract public function mark_failure( $action_id ); + + /** + * Log action's execution. + * + * @param string $action_id Actoin ID. + */ + abstract public function log_execution( $action_id ); + + /** + * Mark action as complete. + * + * @param string $action_id Action ID. + */ + abstract public function mark_complete( $action_id ); + + /** + * Get action's status. + * + * @param string $action_id Action ID. + * @return string + */ + abstract public function get_status( $action_id ); + + /** + * Get action's claim ID. + * + * @param string $action_id Action ID. + * @return mixed + */ + abstract public function get_claim_id( $action_id ); + + /** + * Find actions by claim ID. + * + * @param string $claim_id Claim ID. + * @return array + */ + abstract public function find_actions_by_claim_id( $claim_id ); + + /** + * Validate SQL operator. + * + * @param string $comparison_operator Operator. + * @return string + */ + protected function validate_sql_comparator( $comparison_operator ) { + if ( in_array( $comparison_operator, array( '!=', '>', '>=', '<', '<=', '=' ), true ) ) { + return $comparison_operator; + } + + return '='; + } + + /** + * Get the time MySQL formatted date/time string for an action's (next) scheduled date. + * + * @param ActionScheduler_Action $action Action. + * @param null|DateTime $scheduled_date Action's schedule date (optional). + * @return string + */ + protected function get_scheduled_date_string( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) { + $next = is_null( $scheduled_date ) ? $action->get_schedule()->get_date() : $scheduled_date; + + if ( ! $next ) { + $next = date_create(); + } + + $next->setTimezone( new DateTimeZone( 'UTC' ) ); + + return $next->format( 'Y-m-d H:i:s' ); + } + + /** + * Get the time MySQL formatted date/time string for an action's (next) scheduled date. + * + * @param ActionScheduler_Action|null $action Action. + * @param null|DateTime $scheduled_date Action's scheduled date (optional). + * @return string + */ + protected function get_scheduled_date_string_local( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) { + $next = is_null( $scheduled_date ) ? $action->get_schedule()->get_date() : $scheduled_date; + + if ( ! $next ) { + $next = date_create(); + } + + ActionScheduler_TimezoneHelper::set_local_timezone( $next ); + return $next->format( 'Y-m-d H:i:s' ); + } + + /** + * Validate that we could decode action arguments. + * + * @param mixed $args The decoded arguments. + * @param int $action_id The action ID. + * + * @throws ActionScheduler_InvalidActionException When the decoded arguments are invalid. + */ + protected function validate_args( $args, $action_id ) { + // Ensure we have an array of args. + if ( ! is_array( $args ) ) { + throw ActionScheduler_InvalidActionException::from_decoding_args( $action_id ); + } + + // Validate JSON decoding if possible. + if ( function_exists( 'json_last_error' ) && JSON_ERROR_NONE !== json_last_error() ) { + throw ActionScheduler_InvalidActionException::from_decoding_args( $action_id, $args ); + } + } + + /** + * Validate a ActionScheduler_Schedule object. + * + * @param mixed $schedule The unserialized ActionScheduler_Schedule object. + * @param int $action_id The action ID. + * + * @throws ActionScheduler_InvalidActionException When the schedule is invalid. + */ + protected function validate_schedule( $schedule, $action_id ) { + if ( empty( $schedule ) || ! is_a( $schedule, 'ActionScheduler_Schedule' ) ) { + throw ActionScheduler_InvalidActionException::from_schedule( $action_id, $schedule ); + } + } + + /** + * InnoDB indexes have a maximum size of 767 bytes by default, which is only 191 characters with utf8mb4. + * + * Previously, AS wasn't concerned about args length, as we used the (unindex) post_content column. However, + * with custom tables, we use an indexed VARCHAR column instead. + * + * @param ActionScheduler_Action $action Action to be validated. + * @throws InvalidArgumentException When json encoded args is too long. + */ + protected function validate_action( ActionScheduler_Action $action ) { + if ( strlen( wp_json_encode( $action->get_args() ) ) > static::$max_args_length ) { + // translators: %d is a number (maximum length of action arguments). + throw new InvalidArgumentException( sprintf( __( 'ActionScheduler_Action::$args too long. To ensure the args column can be indexed, action args should not be more than %d characters when encoded as JSON.', 'action-scheduler' ), static::$max_args_length ) ); + } + } + + /** + * Cancel pending actions by hook. + * + * @since 3.0.0 + * + * @param string $hook Hook name. + * + * @return void + */ + public function cancel_actions_by_hook( $hook ) { + $action_ids = true; + while ( ! empty( $action_ids ) ) { + $action_ids = $this->query_actions( + array( + 'hook' => $hook, + 'status' => self::STATUS_PENDING, + 'per_page' => 1000, + 'orderby' => 'none', + ) + ); + + $this->bulk_cancel_actions( $action_ids ); + } + } + + /** + * Cancel pending actions by group. + * + * @since 3.0.0 + * + * @param string $group Group slug. + * + * @return void + */ + public function cancel_actions_by_group( $group ) { + $action_ids = true; + while ( ! empty( $action_ids ) ) { + $action_ids = $this->query_actions( + array( + 'group' => $group, + 'status' => self::STATUS_PENDING, + 'per_page' => 1000, + 'orderby' => 'none', + ) + ); + + $this->bulk_cancel_actions( $action_ids ); + } + } + + /** + * Cancel a set of action IDs. + * + * @since 3.0.0 + * + * @param int[] $action_ids List of action IDs. + * + * @return void + */ + private function bulk_cancel_actions( $action_ids ) { + foreach ( $action_ids as $action_id ) { + $this->cancel_action( $action_id ); + } + + do_action( 'action_scheduler_bulk_cancel_actions', $action_ids ); + } + + /** + * Get status labels. + * + * @return array + */ + public function get_status_labels() { + return array( + self::STATUS_COMPLETE => __( 'Complete', 'action-scheduler' ), + self::STATUS_PENDING => __( 'Pending', 'action-scheduler' ), + self::STATUS_RUNNING => __( 'In-progress', 'action-scheduler' ), + self::STATUS_FAILED => __( 'Failed', 'action-scheduler' ), + self::STATUS_CANCELED => __( 'Canceled', 'action-scheduler' ), + ); + } + + /** + * Check if there are any pending scheduled actions due to run. + * + * @return string + */ + public function has_pending_actions_due() { + $pending_actions = $this->query_actions( + array( + 'per_page' => 1, + 'date' => as_get_datetime_object(), + 'status' => self::STATUS_PENDING, + 'orderby' => 'none', + ), + 'count' + ); + + return ! empty( $pending_actions ); + } + + /** + * Callable initialization function optionally overridden in derived classes. + */ + public function init() {} + + /** + * Callable function to mark an action as migrated optionally overridden in derived classes. + * + * @param int $action_id Action ID. + */ + public function mark_migrated( $action_id ) {} + + /** + * Get instance. + * + * @return ActionScheduler_Store + */ + public static function instance() { + if ( empty( self::$store ) ) { + $class = apply_filters( 'action_scheduler_store_class', self::DEFAULT_CLASS ); + self::$store = new $class(); + } + return self::$store; + } +} diff --git a/classes/abstracts/ActionScheduler_TimezoneHelper.php b/classes/abstracts/ActionScheduler_TimezoneHelper.php new file mode 100644 index 0000000..63813eb --- /dev/null +++ b/classes/abstracts/ActionScheduler_TimezoneHelper.php @@ -0,0 +1,162 @@ +format( 'U' ) ); + } + + if ( get_option( 'timezone_string' ) ) { + $date->setTimezone( new DateTimeZone( self::get_local_timezone_string() ) ); + } else { + $date->setUtcOffset( self::get_local_timezone_offset() ); + } + + return $date; + } + + /** + * Helper to retrieve the timezone string for a site until a WP core method exists + * (see https://core.trac.wordpress.org/ticket/24730). + * + * Adapted from wc_timezone_string() and https://secure.php.net/manual/en/function.timezone-name-from-abbr.php#89155. + * + * If no timezone string is set, and its not possible to match the UTC offset set for the site to a timezone + * string, then an empty string will be returned, and the UTC offset should be used to set a DateTime's + * timezone. + * + * @since 2.1.0 + * @param bool $reset Unused. + * @return string PHP timezone string for the site or empty if no timezone string is available. + */ + protected static function get_local_timezone_string( $reset = false ) { + // If site timezone string exists, return it. + $timezone = get_option( 'timezone_string' ); + if ( $timezone ) { + return $timezone; + } + + // Get UTC offset, if it isn't set then return UTC. + $utc_offset = intval( get_option( 'gmt_offset', 0 ) ); + if ( 0 === $utc_offset ) { + return 'UTC'; + } + + // Adjust UTC offset from hours to seconds. + $utc_offset *= 3600; + + // Attempt to guess the timezone string from the UTC offset. + $timezone = timezone_name_from_abbr( '', $utc_offset ); + if ( $timezone ) { + return $timezone; + } + + // Last try, guess timezone string manually. + foreach ( timezone_abbreviations_list() as $abbr ) { + foreach ( $abbr as $city ) { + if ( (bool) date( 'I' ) === (bool) $city['dst'] && $city['timezone_id'] && intval( $city['offset'] ) === $utc_offset ) { // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- we are actually interested in the runtime timezone. + return $city['timezone_id']; + } + } + } + + // No timezone string. + return ''; + } + + /** + * Get timezone offset in seconds. + * + * @since 2.1.0 + * @return float + */ + protected static function get_local_timezone_offset() { + $timezone = get_option( 'timezone_string' ); + + if ( $timezone ) { + $timezone_object = new DateTimeZone( $timezone ); + return $timezone_object->getOffset( new DateTime( 'now' ) ); + } else { + return floatval( get_option( 'gmt_offset', 0 ) ) * HOUR_IN_SECONDS; + } + } + + /** + * Get local timezone. + * + * @param bool $reset Toggle to discard stored value. + * @deprecated 2.1.0 + */ + public static function get_local_timezone( $reset = false ) { + _deprecated_function( __FUNCTION__, '2.1.0', 'ActionScheduler_TimezoneHelper::set_local_timezone()' ); + if ( $reset ) { + self::$local_timezone = null; + } + if ( ! isset( self::$local_timezone ) ) { + $tzstring = get_option( 'timezone_string' ); + + if ( empty( $tzstring ) ) { + $gmt_offset = absint( get_option( 'gmt_offset' ) ); + if ( 0 === $gmt_offset ) { + $tzstring = 'UTC'; + } else { + $gmt_offset *= HOUR_IN_SECONDS; + $tzstring = timezone_name_from_abbr( '', $gmt_offset, 1 ); + + // If there's no timezone string, try again with no DST. + if ( false === $tzstring ) { + $tzstring = timezone_name_from_abbr( '', $gmt_offset, 0 ); + } + + // Try mapping to the first abbreviation we can find. + if ( false === $tzstring ) { + $is_dst = date( 'I' ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- we are actually interested in the runtime timezone. + foreach ( timezone_abbreviations_list() as $abbr ) { + foreach ( $abbr as $city ) { + if ( $city['dst'] === $is_dst && $city['offset'] === $gmt_offset ) { + // If there's no valid timezone ID, keep looking. + if ( is_null( $city['timezone_id'] ) ) { + continue; + } + + $tzstring = $city['timezone_id']; + break 2; + } + } + } + } + + // If we still have no valid string, then fall back to UTC. + if ( false === $tzstring ) { + $tzstring = 'UTC'; + } + } + } + + self::$local_timezone = new DateTimeZone( $tzstring ); + } + return self::$local_timezone; + } +} diff --git a/classes/abstracts/ActionScheduler_WPCLI_Command.php b/classes/abstracts/ActionScheduler_WPCLI_Command.php new file mode 100644 index 0000000..847c109 --- /dev/null +++ b/classes/abstracts/ActionScheduler_WPCLI_Command.php @@ -0,0 +1,83 @@ + + */ + protected $assoc_args; + + /** + * Construct. + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Keyed arguments. + * @throws \Exception When loading a CLI command file outside of WP CLI context. + */ + public function __construct( array $args, array $assoc_args ) { + if ( ! defined( 'WP_CLI' ) || ! constant( 'WP_CLI' ) ) { + /* translators: %s php class name */ + throw new \Exception( sprintf( __( 'The %s class can only be run within WP CLI.', 'action-scheduler' ), get_class( $this ) ) ); + } + + $this->args = $args; + $this->assoc_args = $assoc_args; + } + + /** + * Execute command. + */ + abstract public function execute(); + + /** + * Get the scheduled date in a human friendly format. + * + * @see ActionScheduler_ListTable::get_schedule_display_string() + * @param ActionScheduler_Schedule $schedule Schedule. + * @return string + */ + protected function get_schedule_display_string( ActionScheduler_Schedule $schedule ) { + + $schedule_display_string = ''; + + if ( ! $schedule->get_date() ) { + return '0000-00-00 00:00:00'; + } + + $next_timestamp = $schedule->get_date()->getTimestamp(); + + $schedule_display_string .= $schedule->get_date()->format( static::DATE_FORMAT ); + + return $schedule_display_string; + } + + /** + * Transforms arguments with '__' from CSV into expected arrays. + * + * @see \WP_CLI\CommandWithDBObject::process_csv_arguments_to_arrays() + * @link https://github.com/wp-cli/entity-command/blob/c270cc9a2367cb8f5845f26a6b5e203397c91392/src/WP_CLI/CommandWithDBObject.php#L99 + * @return void + */ + protected function process_csv_arguments_to_arrays() { + foreach ( $this->assoc_args as $k => $v ) { + if ( false !== strpos( $k, '__' ) ) { + $this->assoc_args[ $k ] = explode( ',', $v ); + } + } + } + +} diff --git a/classes/actions/ActionScheduler_Action.php b/classes/actions/ActionScheduler_Action.php new file mode 100644 index 0000000..1613efa --- /dev/null +++ b/classes/actions/ActionScheduler_Action.php @@ -0,0 +1,191 @@ + + */ + protected $args = array(); + + /** + * Action's schedule. + * + * @var ActionScheduler_Schedule + */ + protected $schedule = null; + + /** + * Action's group. + * + * @var string + */ + protected $group = ''; + + /** + * Priorities are conceptually similar to those used for regular WordPress actions. + * Like those, a lower priority takes precedence over a higher priority and the default + * is 10. + * + * Unlike regular WordPress actions, the priority of a scheduled action is strictly an + * integer and should be kept within the bounds 0-255 (anything outside the bounds will + * be brought back into the acceptable range). + * + * @var int + */ + protected $priority = 10; + + /** + * Construct. + * + * @param string $hook Action's hook. + * @param mixed[] $args Action's arguments. + * @param null|ActionScheduler_Schedule $schedule Action's schedule. + * @param string $group Action's group. + */ + public function __construct( $hook, array $args = array(), ?ActionScheduler_Schedule $schedule = null, $group = '' ) { + $schedule = empty( $schedule ) ? new ActionScheduler_NullSchedule() : $schedule; + $this->set_hook( $hook ); + $this->set_schedule( $schedule ); + $this->set_args( $args ); + $this->set_group( $group ); + } + + /** + * Executes the action. + * + * If no callbacks are registered, an exception will be thrown and the action will not be + * fired. This is useful to help detect cases where the code responsible for setting up + * a scheduled action no longer exists. + * + * @throws Exception If no callbacks are registered for this action. + */ + public function execute() { + $hook = $this->get_hook(); + + if ( ! has_action( $hook ) ) { + throw new Exception( + sprintf( + /* translators: 1: action hook. */ + __( 'Scheduled action for %1$s will not be executed as no callbacks are registered.', 'action-scheduler' ), + $hook + ) + ); + } + + do_action_ref_array( $hook, array_values( $this->get_args() ) ); + } + + /** + * Set action's hook. + * + * @param string $hook Action's hook. + */ + protected function set_hook( $hook ) { + $this->hook = $hook; + } + + /** + * Get action's hook. + */ + public function get_hook() { + return $this->hook; + } + + /** + * Set action's schedule. + * + * @param ActionScheduler_Schedule $schedule Action's schedule. + */ + protected function set_schedule( ActionScheduler_Schedule $schedule ) { + $this->schedule = $schedule; + } + + /** + * Action's schedule. + * + * @return ActionScheduler_Schedule + */ + public function get_schedule() { + return $this->schedule; + } + + /** + * Set action's args. + * + * @param mixed[] $args Action's arguments. + */ + protected function set_args( array $args ) { + $this->args = $args; + } + + /** + * Get action's args. + */ + public function get_args() { + return $this->args; + } + + /** + * Section action's group. + * + * @param string $group Action's group. + */ + protected function set_group( $group ) { + $this->group = $group; + } + + /** + * Action's group. + * + * @return string + */ + public function get_group() { + return $this->group; + } + + /** + * Action has not finished. + * + * @return bool + */ + public function is_finished() { + return false; + } + + /** + * Sets the priority of the action. + * + * @param int $priority Priority level (lower is higher priority). Should be in the range 0-255. + * + * @return void + */ + public function set_priority( $priority ) { + if ( $priority < 0 ) { + $priority = 0; + } elseif ( $priority > 255 ) { + $priority = 255; + } + + $this->priority = (int) $priority; + } + + /** + * Gets the action priority. + * + * @return int + */ + public function get_priority() { + return $this->priority; + } +} diff --git a/classes/actions/ActionScheduler_CanceledAction.php b/classes/actions/ActionScheduler_CanceledAction.php new file mode 100644 index 0000000..c600752 --- /dev/null +++ b/classes/actions/ActionScheduler_CanceledAction.php @@ -0,0 +1,25 @@ +set_schedule( new ActionScheduler_NullSchedule() ); + } + } +} diff --git a/classes/actions/ActionScheduler_FinishedAction.php b/classes/actions/ActionScheduler_FinishedAction.php new file mode 100644 index 0000000..d4e8942 --- /dev/null +++ b/classes/actions/ActionScheduler_FinishedAction.php @@ -0,0 +1,21 @@ +set_schedule( new ActionScheduler_NullSchedule() ); + } + + /** + * Execute action. + */ + public function execute() { + // don't execute. + } +} diff --git a/classes/data-stores/ActionScheduler_DBLogger.php b/classes/data-stores/ActionScheduler_DBLogger.php new file mode 100644 index 0000000..0976b06 --- /dev/null +++ b/classes/data-stores/ActionScheduler_DBLogger.php @@ -0,0 +1,154 @@ +format( 'Y-m-d H:i:s' ); + ActionScheduler_TimezoneHelper::set_local_timezone( $date ); + $date_local = $date->format( 'Y-m-d H:i:s' ); + + /** @var \wpdb $wpdb */ //phpcs:ignore Generic.Commenting.DocComment.MissingShort + global $wpdb; + $wpdb->insert( + $wpdb->actionscheduler_logs, + array( + 'action_id' => $action_id, + 'message' => $message, + 'log_date_gmt' => $date_gmt, + 'log_date_local' => $date_local, + ), + array( '%d', '%s', '%s', '%s' ) + ); + + return $wpdb->insert_id; + } + + /** + * Retrieve an action log entry. + * + * @param int $entry_id Log entry ID. + * + * @return ActionScheduler_LogEntry + */ + public function get_entry( $entry_id ) { + /** @var \wpdb $wpdb */ //phpcs:ignore Generic.Commenting.DocComment.MissingShort + global $wpdb; + $entry = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->actionscheduler_logs} WHERE log_id=%d", $entry_id ) ); + + return $this->create_entry_from_db_record( $entry ); + } + + /** + * Create an action log entry from a database record. + * + * @param object $record Log entry database record object. + * + * @return ActionScheduler_LogEntry + */ + private function create_entry_from_db_record( $record ) { + if ( empty( $record ) ) { + return new ActionScheduler_NullLogEntry(); + } + + if ( is_null( $record->log_date_gmt ) ) { + $date = as_get_datetime_object( ActionScheduler_StoreSchema::DEFAULT_DATE ); + } else { + $date = as_get_datetime_object( $record->log_date_gmt ); + } + + return new ActionScheduler_LogEntry( $record->action_id, $record->message, $date ); + } + + /** + * Retrieve an action's log entries from the database. + * + * @param int $action_id Action ID. + * + * @return ActionScheduler_LogEntry[] + */ + public function get_logs( $action_id ) { + /** @var \wpdb $wpdb */ //phpcs:ignore Generic.Commenting.DocComment.MissingShort + global $wpdb; + + $records = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->actionscheduler_logs} WHERE action_id=%d", $action_id ) ); + + return array_map( array( $this, 'create_entry_from_db_record' ), $records ); + } + + /** + * Initialize the data store. + * + * @codeCoverageIgnore + */ + public function init() { + $table_maker = new ActionScheduler_LoggerSchema(); + $table_maker->init(); + $table_maker->register_tables(); + + parent::init(); + + add_action( 'action_scheduler_deleted_action', array( $this, 'clear_deleted_action_logs' ), 10, 1 ); + } + + /** + * Delete the action logs for an action. + * + * @param int $action_id Action ID. + */ + public function clear_deleted_action_logs( $action_id ) { + /** @var \wpdb $wpdb */ //phpcs:ignore Generic.Commenting.DocComment.MissingShort + global $wpdb; + $wpdb->delete( $wpdb->actionscheduler_logs, array( 'action_id' => $action_id ), array( '%d' ) ); + } + + /** + * Bulk add cancel action log entries. + * + * @param array $action_ids List of action ID. + */ + public function bulk_log_cancel_actions( $action_ids ) { + if ( empty( $action_ids ) ) { + return; + } + + /** @var \wpdb $wpdb */ //phpcs:ignore Generic.Commenting.DocComment.MissingShort + global $wpdb; + $date = as_get_datetime_object(); + $date_gmt = $date->format( 'Y-m-d H:i:s' ); + ActionScheduler_TimezoneHelper::set_local_timezone( $date ); + $date_local = $date->format( 'Y-m-d H:i:s' ); + $message = __( 'action canceled', 'action-scheduler' ); + $format = '(%d, ' . $wpdb->prepare( '%s, %s, %s', $message, $date_gmt, $date_local ) . ')'; + $sql_query = "INSERT {$wpdb->actionscheduler_logs} (action_id, message, log_date_gmt, log_date_local) VALUES "; + $value_rows = array(); + + foreach ( $action_ids as $action_id ) { + $value_rows[] = $wpdb->prepare( $format, $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + $sql_query .= implode( ',', $value_rows ); + + $wpdb->query( $sql_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } +} diff --git a/classes/data-stores/ActionScheduler_DBStore.php b/classes/data-stores/ActionScheduler_DBStore.php new file mode 100644 index 0000000..25a883a --- /dev/null +++ b/classes/data-stores/ActionScheduler_DBStore.php @@ -0,0 +1,1295 @@ + '', + 'hooks' => '', + 'exclude-groups' => '', + ); + + /** + * Initialize the data store + * + * @codeCoverageIgnore + */ + public function init() { + $table_maker = new ActionScheduler_StoreSchema(); + $table_maker->init(); + $table_maker->register_tables(); + } + + /** + * Save an action, checks if this is a unique action before actually saving. + * + * @param ActionScheduler_Action $action Action object. + * @param DateTime|null $scheduled_date Optional schedule date. Default null. + * + * @return int Action ID. + * @throws RuntimeException Throws exception when saving the action fails. + */ + public function save_unique_action( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) { + return $this->save_action_to_db( $action, $scheduled_date, true ); + } + + /** + * Save an action. Can save duplicate action as well, prefer using `save_unique_action` instead. + * + * @param ActionScheduler_Action $action Action object. + * @param DateTime|null $scheduled_date Optional schedule date. Default null. + * + * @return int Action ID. + * @throws RuntimeException Throws exception when saving the action fails. + */ + public function save_action( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) { + return $this->save_action_to_db( $action, $scheduled_date, false ); + } + + /** + * Save an action. + * + * @param ActionScheduler_Action $action Action object. + * @param ?DateTime $date Optional schedule date. Default null. + * @param bool $unique Whether the action should be unique. + * + * @return int Action ID. + * @throws \RuntimeException Throws exception when saving the action fails. + */ + private function save_action_to_db( ActionScheduler_Action $action, ?DateTime $date = null, $unique = false ) { + global $wpdb; + + try { + $this->validate_action( $action ); + + $data = array( + 'hook' => $action->get_hook(), + 'status' => ( $action->is_finished() ? self::STATUS_COMPLETE : self::STATUS_PENDING ), + 'scheduled_date_gmt' => $this->get_scheduled_date_string( $action, $date ), + 'scheduled_date_local' => $this->get_scheduled_date_string_local( $action, $date ), + 'schedule' => serialize( $action->get_schedule() ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + 'group_id' => current( $this->get_group_ids( $action->get_group() ) ), + 'priority' => $action->get_priority(), + ); + + $args = wp_json_encode( $action->get_args() ); + if ( strlen( $args ) <= static::$max_index_length ) { + $data['args'] = $args; + } else { + $data['args'] = $this->hash_args( $args ); + $data['extended_args'] = $args; + } + + $insert_sql = $this->build_insert_sql( $data, $unique ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $insert_sql should be already prepared. + $wpdb->query( $insert_sql ); + $action_id = $wpdb->insert_id; + + if ( is_wp_error( $action_id ) ) { + throw new \RuntimeException( $action_id->get_error_message() ); + } elseif ( empty( $action_id ) ) { + if ( $unique ) { + return 0; + } + throw new \RuntimeException( $wpdb->last_error ? $wpdb->last_error : __( 'Database error.', 'action-scheduler' ) ); + } + + do_action( 'action_scheduler_stored_action', $action_id ); + + return $action_id; + } catch ( \Exception $e ) { + /* translators: %s: error message */ + throw new \RuntimeException( sprintf( __( 'Error saving action: %s', 'action-scheduler' ), $e->getMessage() ), 0 ); + } + } + + /** + * Helper function to build insert query. + * + * @param array $data Row data for action. + * @param bool $unique Whether the action should be unique. + * + * @return string Insert query. + */ + private function build_insert_sql( array $data, $unique ) { + global $wpdb; + + $columns = array_keys( $data ); + $values = array_values( $data ); + $placeholders = array_map( array( $this, 'get_placeholder_for_column' ), $columns ); + + $table_name = ! empty( $wpdb->actionscheduler_actions ) ? $wpdb->actionscheduler_actions : $wpdb->prefix . 'actionscheduler_actions'; + + $column_sql = '`' . implode( '`, `', $columns ) . '`'; + $placeholder_sql = implode( ', ', $placeholders ); + $where_clause = $this->build_where_clause_for_insert( $data, $table_name, $unique ); + + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $column_sql and $where_clause are already prepared. $placeholder_sql is hardcoded. + $insert_query = $wpdb->prepare( + " +INSERT INTO $table_name ( $column_sql ) +SELECT $placeholder_sql FROM DUAL +WHERE ( $where_clause ) IS NULL", + $values + ); + // phpcs:enable + + return $insert_query; + } + + /** + * Helper method to build where clause for action insert statement. + * + * @param array $data Row data for action. + * @param string $table_name Action table name. + * @param bool $unique Where action should be unique. + * + * @return string Where clause to be used with insert. + */ + private function build_where_clause_for_insert( $data, $table_name, $unique ) { + global $wpdb; + + if ( ! $unique ) { + return 'SELECT NULL FROM DUAL'; + } + + $pending_statuses = array( + ActionScheduler_Store::STATUS_PENDING, + ActionScheduler_Store::STATUS_RUNNING, + ); + $pending_status_placeholders = implode( ', ', array_fill( 0, count( $pending_statuses ), '%s' ) ); + + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $pending_status_placeholders is hardcoded. + $where_clause = $wpdb->prepare( + " +SELECT action_id FROM $table_name +WHERE status IN ( $pending_status_placeholders ) +AND hook = %s +AND `group_id` = %d +", + array_merge( + $pending_statuses, + array( + $data['hook'], + $data['group_id'], + ) + ) + ); + // phpcs:enable + + return "$where_clause" . ' LIMIT 1'; + } + + /** + * Helper method to get $wpdb->prepare placeholder for a given column name. + * + * @param string $column_name Name of column in actions table. + * + * @return string Placeholder to use for given column. + */ + private function get_placeholder_for_column( $column_name ) { + $string_columns = array( + 'hook', + 'status', + 'scheduled_date_gmt', + 'scheduled_date_local', + 'args', + 'schedule', + 'last_attempt_gmt', + 'last_attempt_local', + 'extended_args', + ); + + return in_array( $column_name, $string_columns, true ) ? '%s' : '%d'; + } + + /** + * Generate a hash from json_encoded $args using MD5 as this isn't for security. + * + * @param string $args JSON encoded action args. + * @return string + */ + protected function hash_args( $args ) { + return md5( $args ); + } + + /** + * Get action args query param value from action args. + * + * @param array $args Action args. + * @return string + */ + protected function get_args_for_query( $args ) { + $encoded = wp_json_encode( $args ); + if ( strlen( $encoded ) <= static::$max_index_length ) { + return $encoded; + } + return $this->hash_args( $encoded ); + } + /** + * Get a group's ID based on its name/slug. + * + * @param string|array $slugs The string name of a group, or names for several groups. + * @param bool $create_if_not_exists Whether to create the group if it does not already exist. Default, true - create the group. + * + * @return array The group IDs, if they exist or were successfully created. May be empty. + */ + protected function get_group_ids( $slugs, $create_if_not_exists = true ) { + $slugs = (array) $slugs; + $group_ids = array(); + + if ( empty( $slugs ) ) { + return array(); + } + + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + foreach ( $slugs as $slug ) { + $group_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT group_id FROM {$wpdb->actionscheduler_groups} WHERE slug=%s", $slug ) ); + + if ( empty( $group_id ) && $create_if_not_exists ) { + $group_id = $this->create_group( $slug ); + } + + if ( $group_id ) { + $group_ids[] = $group_id; + } + } + + return $group_ids; + } + + /** + * Create an action group. + * + * @param string $slug Group slug. + * + * @return int Group ID. + */ + protected function create_group( $slug ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $wpdb->insert( $wpdb->actionscheduler_groups, array( 'slug' => $slug ) ); + + return (int) $wpdb->insert_id; + } + + /** + * Retrieve an action. + * + * @param int $action_id Action ID. + * + * @return ActionScheduler_Action + */ + public function fetch_action( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $data = $wpdb->get_row( + $wpdb->prepare( + "SELECT a.*, g.slug AS `group` FROM {$wpdb->actionscheduler_actions} a LEFT JOIN {$wpdb->actionscheduler_groups} g ON a.group_id=g.group_id WHERE a.action_id=%d", + $action_id + ) + ); + + if ( empty( $data ) ) { + return $this->get_null_action(); + } + + if ( ! empty( $data->extended_args ) ) { + $data->args = $data->extended_args; + unset( $data->extended_args ); + } + + // Convert NULL dates to zero dates. + $date_fields = array( + 'scheduled_date_gmt', + 'scheduled_date_local', + 'last_attempt_gmt', + 'last_attempt_gmt', + ); + foreach ( $date_fields as $date_field ) { + if ( is_null( $data->$date_field ) ) { + $data->$date_field = ActionScheduler_StoreSchema::DEFAULT_DATE; + } + } + + try { + $action = $this->make_action_from_db_record( $data ); + } catch ( ActionScheduler_InvalidActionException $exception ) { + do_action( 'action_scheduler_failed_fetch_action', $action_id, $exception ); + return $this->get_null_action(); + } + + return $action; + } + + /** + * Create a null action. + * + * @return ActionScheduler_NullAction + */ + protected function get_null_action() { + return new ActionScheduler_NullAction(); + } + + /** + * Create an action from a database record. + * + * @param object $data Action database record. + * + * @return ActionScheduler_Action|ActionScheduler_CanceledAction|ActionScheduler_FinishedAction + */ + protected function make_action_from_db_record( $data ) { + + $hook = $data->hook; + $args = json_decode( $data->args, true ); + $schedule = unserialize( $data->schedule ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize + + $this->validate_args( $args, $data->action_id ); + $this->validate_schedule( $schedule, $data->action_id ); + + if ( empty( $schedule ) ) { + $schedule = new ActionScheduler_NullSchedule(); + } + $group = $data->group ? $data->group : ''; + + return ActionScheduler::factory()->get_stored_action( $data->status, $data->hook, $args, $schedule, $group, $data->priority ); + } + + /** + * Returns the SQL statement to query (or count) actions. + * + * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status. + * + * @param array $query Filtering options. + * @param string $select_or_count Whether the SQL should select and return the IDs or just the row count. + * + * @return string SQL statement already properly escaped. + * @throws \InvalidArgumentException If the query is invalid. + * @throws \RuntimeException When "unknown partial args matching value". + */ + protected function get_query_actions_sql( array $query, $select_or_count = 'select' ) { + + if ( ! in_array( $select_or_count, array( 'select', 'count' ), true ) ) { + throw new InvalidArgumentException( __( 'Invalid value for select or count parameter. Cannot query actions.', 'action-scheduler' ) ); + } + + $query = wp_parse_args( + $query, + array( + 'hook' => '', + 'args' => null, + 'partial_args_matching' => 'off', // can be 'like' or 'json'. + 'date' => null, + 'date_compare' => '<=', + 'modified' => null, + 'modified_compare' => '<=', + 'group' => '', + 'status' => '', + 'claimed' => null, + 'per_page' => 5, + 'offset' => 0, + 'orderby' => 'date', + 'order' => 'ASC', + ) + ); + + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $db_server_info = is_callable( array( $wpdb, 'db_server_info' ) ) ? $wpdb->db_server_info() : $wpdb->db_version(); + if ( false !== strpos( $db_server_info, 'MariaDB' ) ) { + $supports_json = version_compare( + PHP_VERSION_ID >= 80016 ? $wpdb->db_version() : preg_replace( '/[^0-9.].*/', '', str_replace( '5.5.5-', '', $db_server_info ) ), + '10.2', + '>=' + ); + } else { + $supports_json = version_compare( $wpdb->db_version(), '5.7', '>=' ); + } + + $sql = ( 'count' === $select_or_count ) ? 'SELECT count(a.action_id)' : 'SELECT a.action_id'; + $sql .= " FROM {$wpdb->actionscheduler_actions} a"; + $sql_params = array(); + + if ( ! empty( $query['group'] ) || 'group' === $query['orderby'] ) { + $sql .= " LEFT JOIN {$wpdb->actionscheduler_groups} g ON g.group_id=a.group_id"; + } + + $sql .= ' WHERE 1=1'; + + if ( ! empty( $query['group'] ) ) { + $sql .= ' AND g.slug=%s'; + $sql_params[] = $query['group']; + } + + if ( ! empty( $query['hook'] ) ) { + $sql .= ' AND a.hook=%s'; + $sql_params[] = $query['hook']; + } + + if ( ! is_null( $query['args'] ) ) { + switch ( $query['partial_args_matching'] ) { + case 'json': + if ( ! $supports_json ) { + throw new \RuntimeException( __( 'JSON partial matching not supported in your environment. Please check your MySQL/MariaDB version.', 'action-scheduler' ) ); + } + $supported_types = array( + 'integer' => '%d', + 'boolean' => '%s', + 'double' => '%f', + 'string' => '%s', + ); + foreach ( $query['args'] as $key => $value ) { + $value_type = gettype( $value ); + if ( 'boolean' === $value_type ) { + $value = $value ? 'true' : 'false'; + } + $placeholder = isset( $supported_types[ $value_type ] ) ? $supported_types[ $value_type ] : false; + if ( ! $placeholder ) { + throw new \RuntimeException( + sprintf( + /* translators: %s: provided value type */ + __( 'The value type for the JSON partial matching is not supported. Must be either integer, boolean, double or string. %s type provided.', 'action-scheduler' ), + $value_type + ) + ); + } + $sql .= ' AND JSON_EXTRACT(a.args, %s)=' . $placeholder; + $sql_params[] = '$.' . $key; + $sql_params[] = $value; + } + break; + case 'like': + foreach ( $query['args'] as $key => $value ) { + $sql .= ' AND a.args LIKE %s'; + $json_partial = $wpdb->esc_like( trim( wp_json_encode( array( $key => $value ) ), '{}' ) ); + $sql_params[] = "%{$json_partial}%"; + } + break; + case 'off': + $sql .= ' AND a.args=%s'; + $sql_params[] = $this->get_args_for_query( $query['args'] ); + break; + default: + throw new \RuntimeException( __( 'Unknown partial args matching value.', 'action-scheduler' ) ); + } + } + + if ( $query['status'] ) { + $statuses = (array) $query['status']; + $placeholders = array_fill( 0, count( $statuses ), '%s' ); + $sql .= ' AND a.status IN (' . join( ', ', $placeholders ) . ')'; + $sql_params = array_merge( $sql_params, array_values( $statuses ) ); + } + + if ( $query['date'] instanceof \DateTime ) { + $date = clone $query['date']; + $date->setTimezone( new \DateTimeZone( 'UTC' ) ); + $date_string = $date->format( 'Y-m-d H:i:s' ); + $comparator = $this->validate_sql_comparator( $query['date_compare'] ); + $sql .= " AND a.scheduled_date_gmt $comparator %s"; + $sql_params[] = $date_string; + } + + if ( $query['modified'] instanceof \DateTime ) { + $modified = clone $query['modified']; + $modified->setTimezone( new \DateTimeZone( 'UTC' ) ); + $date_string = $modified->format( 'Y-m-d H:i:s' ); + $comparator = $this->validate_sql_comparator( $query['modified_compare'] ); + $sql .= " AND a.last_attempt_gmt $comparator %s"; + $sql_params[] = $date_string; + } + + if ( true === $query['claimed'] ) { + $sql .= ' AND a.claim_id != 0'; + } elseif ( false === $query['claimed'] ) { + $sql .= ' AND a.claim_id = 0'; + } elseif ( ! is_null( $query['claimed'] ) ) { + $sql .= ' AND a.claim_id = %d'; + $sql_params[] = $query['claimed']; + } + + if ( ! empty( $query['search'] ) ) { + $sql .= ' AND (a.hook LIKE %s OR (a.extended_args IS NULL AND a.args LIKE %s) OR a.extended_args LIKE %s'; + for ( $i = 0; $i < 3; $i++ ) { + $sql_params[] = sprintf( '%%%s%%', $query['search'] ); + } + + $search_claim_id = (int) $query['search']; + if ( $search_claim_id ) { + $sql .= ' OR a.claim_id = %d'; + $sql_params[] = $search_claim_id; + } + + $sql .= ')'; + } + + if ( 'select' === $select_or_count ) { + if ( 'ASC' === strtoupper( $query['order'] ) ) { + $order = 'ASC'; + } else { + $order = 'DESC'; + } + switch ( $query['orderby'] ) { + case 'hook': + $sql .= " ORDER BY a.hook $order"; + break; + case 'group': + $sql .= " ORDER BY g.slug $order"; + break; + case 'modified': + $sql .= " ORDER BY a.last_attempt_gmt $order"; + break; + case 'none': + break; + case 'action_id': + $sql .= " ORDER BY a.action_id $order"; + break; + case 'date': + default: + $sql .= " ORDER BY a.scheduled_date_gmt $order"; + break; + } + + if ( $query['per_page'] > 0 ) { + $sql .= ' LIMIT %d, %d'; + $sql_params[] = $query['offset']; + $sql_params[] = $query['per_page']; + } + } + + if ( ! empty( $sql_params ) ) { + $sql = $wpdb->prepare( $sql, $sql_params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + return $sql; + } + + /** + * Query for action count or list of action IDs. + * + * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status. + * + * @see ActionScheduler_Store::query_actions for $query arg usage. + * + * @param array $query Query filtering options. + * @param string $query_type Whether to select or count the results. Defaults to select. + * + * @return string|array|null The IDs of actions matching the query. Null on failure. + */ + public function query_actions( $query = array(), $query_type = 'select' ) { + /** + * Global. + * + * @var wpdb $wpdb + */ + global $wpdb; + + $sql = $this->get_query_actions_sql( $query, $query_type ); + + return ( 'count' === $query_type ) ? $wpdb->get_var( $sql ) : $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoSql, WordPress.DB.DirectDatabaseQuery.NoCaching + } + + /** + * Get a count of all actions in the store, grouped by status. + * + * @return array Set of 'status' => int $count pairs for statuses with 1 or more actions of that status. + */ + public function action_counts() { + global $wpdb; + + $sql = "SELECT a.status, count(a.status) as 'count'"; + $sql .= " FROM {$wpdb->actionscheduler_actions} a"; + $sql .= ' GROUP BY a.status'; + + $actions_count_by_status = array(); + $action_stati_and_labels = $this->get_status_labels(); + + foreach ( $wpdb->get_results( $sql ) as $action_data ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + // Ignore any actions with invalid status. + if ( array_key_exists( $action_data->status, $action_stati_and_labels ) ) { + $actions_count_by_status[ $action_data->status ] = $action_data->count; + } + } + + return $actions_count_by_status; + } + + /** + * Cancel an action. + * + * @param int $action_id Action ID. + * + * @return void + * @throws \InvalidArgumentException If the action update failed. + */ + public function cancel_action( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $updated = $wpdb->update( + $wpdb->actionscheduler_actions, + array( 'status' => self::STATUS_CANCELED ), + array( 'action_id' => $action_id ), + array( '%s' ), + array( '%d' ) + ); + if ( false === $updated ) { + /* translators: %s: action ID */ + throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to cancel this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + do_action( 'action_scheduler_canceled_action', $action_id ); + } + + /** + * Cancel pending actions by hook. + * + * @since 3.0.0 + * + * @param string $hook Hook name. + * + * @return void + */ + public function cancel_actions_by_hook( $hook ) { + $this->bulk_cancel_actions( array( 'hook' => $hook ) ); + } + + /** + * Cancel pending actions by group. + * + * @param string $group Group slug. + * + * @return void + */ + public function cancel_actions_by_group( $group ) { + $this->bulk_cancel_actions( array( 'group' => $group ) ); + } + + /** + * Bulk cancel actions. + * + * @since 3.0.0 + * + * @param array $query_args Query parameters. + */ + protected function bulk_cancel_actions( $query_args ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + if ( ! is_array( $query_args ) ) { + return; + } + + // Don't cancel actions that are already canceled. + if ( isset( $query_args['status'] ) && self::STATUS_CANCELED === $query_args['status'] ) { + return; + } + + $action_ids = true; + $query_args = wp_parse_args( + $query_args, + array( + 'per_page' => 1000, + 'status' => self::STATUS_PENDING, + 'orderby' => 'none', + ) + ); + + while ( $action_ids ) { + $action_ids = $this->query_actions( $query_args ); + if ( empty( $action_ids ) ) { + break; + } + + $format = array_fill( 0, count( $action_ids ), '%d' ); + $query_in = '(' . implode( ',', $format ) . ')'; + $parameters = $action_ids; + array_unshift( $parameters, self::STATUS_CANCELED ); + + $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->actionscheduler_actions} SET status = %s WHERE action_id IN {$query_in}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $parameters + ) + ); + + do_action( 'action_scheduler_bulk_cancel_actions', $action_ids ); + } + } + + /** + * Delete an action. + * + * @param int $action_id Action ID. + * @throws \InvalidArgumentException If the action deletion failed. + */ + public function delete_action( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $deleted = $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ), array( '%d' ) ); + if ( empty( $deleted ) ) { + /* translators: %s is the action ID */ + throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to delete this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + do_action( 'action_scheduler_deleted_action', $action_id ); + } + + /** + * Get the schedule date for an action. + * + * @param string $action_id Action ID. + * + * @return \DateTime The local date the action is scheduled to run, or the date that it ran. + */ + public function get_date( $action_id ) { + $date = $this->get_date_gmt( $action_id ); + ActionScheduler_TimezoneHelper::set_local_timezone( $date ); + return $date; + } + + /** + * Get the GMT schedule date for an action. + * + * @param int $action_id Action ID. + * + * @throws \InvalidArgumentException If action cannot be identified. + * @return \DateTime The GMT date the action is scheduled to run, or the date that it ran. + */ + protected function get_date_gmt( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $record = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d", $action_id ) ); + if ( empty( $record ) ) { + /* translators: %s is the action ID */ + throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to determine the date of this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + if ( self::STATUS_PENDING === $record->status ) { + return as_get_datetime_object( $record->scheduled_date_gmt ); + } else { + return as_get_datetime_object( $record->last_attempt_gmt ); + } + } + + /** + * Stake a claim on actions. + * + * @param int $max_actions Maximum number of action to include in claim. + * @param DateTime|null $before_date Jobs must be schedule before this date. Defaults to now. + * @param array $hooks Hooks to filter for. + * @param string $group Group to filter for. + * + * @return ActionScheduler_ActionClaim + */ + public function stake_claim( $max_actions = 10, ?DateTime $before_date = null, $hooks = array(), $group = '' ) { + $claim_id = $this->generate_claim_id(); + + $this->claim_before_date = $before_date; + $this->claim_actions( $claim_id, $max_actions, $before_date, $hooks, $group ); + $action_ids = $this->find_actions_by_claim_id( $claim_id ); + $this->claim_before_date = null; + + return new ActionScheduler_ActionClaim( $claim_id, $action_ids ); + } + + /** + * Generate a new action claim. + * + * @return int Claim ID. + */ + protected function generate_claim_id() { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $now = as_get_datetime_object(); + $wpdb->insert( $wpdb->actionscheduler_claims, array( 'date_created_gmt' => $now->format( 'Y-m-d H:i:s' ) ) ); + + return $wpdb->insert_id; + } + + /** + * Set a claim filter. + * + * @param string $filter_name Claim filter name. + * @param mixed $filter_values Values to filter. + * @return void + */ + public function set_claim_filter( $filter_name, $filter_values ) { + if ( isset( $this->claim_filters[ $filter_name ] ) ) { + $this->claim_filters[ $filter_name ] = $filter_values; + } + } + + /** + * Get the claim filter value. + * + * @param string $filter_name Claim filter name. + * @return mixed + */ + public function get_claim_filter( $filter_name ) { + if ( isset( $this->claim_filters[ $filter_name ] ) ) { + return $this->claim_filters[ $filter_name ]; + } + + return ''; + } + + /** + * Mark actions claimed. + * + * @param string $claim_id Claim Id. + * @param int $limit Number of action to include in claim. + * @param DateTime|null $before_date Should use UTC timezone. + * @param array $hooks Hooks to filter for. + * @param string $group Group to filter for. + * + * @return int The number of actions that were claimed. + * @throws \InvalidArgumentException Throws InvalidArgumentException if group doesn't exist. + * @throws \RuntimeException Throws RuntimeException if unable to claim action. + */ + protected function claim_actions( $claim_id, $limit, ?DateTime $before_date = null, $hooks = array(), $group = '' ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $now = as_get_datetime_object(); + $date = is_null( $before_date ) ? $now : clone $before_date; + // can't use $wpdb->update() because of the <= condition. + $update = "UPDATE {$wpdb->actionscheduler_actions} SET claim_id=%d, last_attempt_gmt=%s, last_attempt_local=%s"; + $params = array( + $claim_id, + $now->format( 'Y-m-d H:i:s' ), + current_time( 'mysql' ), + ); + + // Set claim filters. + if ( ! empty( $hooks ) ) { + $this->set_claim_filter( 'hooks', $hooks ); + } else { + $hooks = $this->get_claim_filter( 'hooks' ); + } + if ( ! empty( $group ) ) { + $this->set_claim_filter( 'group', $group ); + } else { + $group = $this->get_claim_filter( 'group' ); + } + + $where = 'WHERE claim_id = 0 AND scheduled_date_gmt <= %s AND status=%s'; + $params[] = $date->format( 'Y-m-d H:i:s' ); + $params[] = self::STATUS_PENDING; + + if ( ! empty( $hooks ) ) { + $placeholders = array_fill( 0, count( $hooks ), '%s' ); + $where .= ' AND hook IN (' . join( ', ', $placeholders ) . ')'; + $params = array_merge( $params, array_values( $hooks ) ); + } + + $group_operator = 'IN'; + if ( empty( $group ) ) { + $group = $this->get_claim_filter( 'exclude-groups' ); + $group_operator = 'NOT IN'; + } + + if ( ! empty( $group ) ) { + $group_ids = $this->get_group_ids( $group, false ); + + // throw exception if no matching group(s) found, this matches ActionScheduler_wpPostStore's behaviour. + if ( empty( $group_ids ) ) { + throw new InvalidArgumentException( + sprintf( + /* translators: %s: group name(s) */ + _n( + 'The group "%s" does not exist.', + 'The groups "%s" do not exist.', + is_array( $group ) ? count( $group ) : 1, + 'action-scheduler' + ), + $group + ) + ); + } + + $id_list = implode( ',', array_map( 'intval', $group_ids ) ); + $where .= " AND group_id {$group_operator} ( $id_list )"; + } + + /** + * Sets the order-by clause used in the action claim query. + * + * @since 3.4.0 + * @since 3.8.3 Made $claim_id and $hooks available. + * + * @param string $order_by_sql + * @param string $claim_id Claim Id. + * @param array $hooks Hooks to filter for. + */ + $order = apply_filters( 'action_scheduler_claim_actions_order_by', 'ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC', $claim_id, $hooks ); + $params[] = $limit; + + $sql = $wpdb->prepare( "{$update} {$where} {$order} LIMIT %d", $params ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders + $rows_affected = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + if ( false === $rows_affected ) { + $error = empty( $wpdb->last_error ) + ? _x( 'unknown', 'database error', 'action-scheduler' ) + : $wpdb->last_error; + + throw new \RuntimeException( + sprintf( + /* translators: %s database error. */ + __( 'Unable to claim actions. Database error: %s.', 'action-scheduler' ), + $error + ) + ); + } + + return (int) $rows_affected; + } + + /** + * Get the number of active claims. + * + * @return int + */ + public function get_claim_count() { + global $wpdb; + + $sql = "SELECT COUNT(DISTINCT claim_id) FROM {$wpdb->actionscheduler_actions} WHERE claim_id != 0 AND status IN ( %s, %s)"; + $sql = $wpdb->prepare( $sql, array( self::STATUS_PENDING, self::STATUS_RUNNING ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Return an action's claim ID, as stored in the claim_id column. + * + * @param string $action_id Action ID. + * @return mixed + */ + public function get_claim_id( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $sql = "SELECT claim_id FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d"; + $sql = $wpdb->prepare( $sql, $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Retrieve the action IDs of action in a claim. + * + * @param int $claim_id Claim ID. + * @return int[] + */ + public function find_actions_by_claim_id( $claim_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $action_ids = array(); + $before_date = isset( $this->claim_before_date ) ? $this->claim_before_date : as_get_datetime_object(); + $cut_off = $before_date->format( 'Y-m-d H:i:s' ); + + $sql = $wpdb->prepare( + "SELECT action_id, scheduled_date_gmt FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC", + $claim_id + ); + + // Verify that the scheduled date for each action is within the expected bounds (in some unusual + // cases, we cannot depend on MySQL to honor all of the WHERE conditions we specify). + foreach ( $wpdb->get_results( $sql ) as $claimed_action ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + if ( $claimed_action->scheduled_date_gmt <= $cut_off ) { + $action_ids[] = absint( $claimed_action->action_id ); + } + } + + return $action_ids; + } + + /** + * Release actions from a claim and delete the claim. + * + * @param ActionScheduler_ActionClaim $claim Claim object. + * @throws \RuntimeException When unable to release actions from claim. + */ + public function release_claim( ActionScheduler_ActionClaim $claim ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + /** + * Deadlock warning: This function modifies actions to release them from claims that have been processed. Earlier, we used to it in a atomic query, i.e. we would update all actions belonging to a particular claim_id with claim_id = 0. + * While this was functionally correct, it would cause deadlock, since this update query will hold a lock on the claim_id_.. index on the action table. + * This allowed the possibility of a race condition, where the claimer query is also running at the same time, then the claimer query will also try to acquire a lock on the claim_id_.. index, and in this case if claim release query has already progressed to the point of acquiring the lock, but have not updated yet, it would cause a deadlock. + * + * We resolve this by getting all the actions_id that we want to release claim from in a separate query, and then releasing the claim on each of them. This way, our lock is acquired on the action_id index instead of the claim_id index. Note that the lock on claim_id will still be acquired, but it will only when we actually make the update, rather than when we select the actions. + */ + $action_ids = $wpdb->get_col( $wpdb->prepare( "SELECT action_id FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d", $claim->get_id() ) ); + + $row_updates = 0; + if ( count( $action_ids ) > 0 ) { + $action_id_string = implode( ',', array_map( 'absint', $action_ids ) ); + $row_updates = $wpdb->query( "UPDATE {$wpdb->actionscheduler_actions} SET claim_id = 0 WHERE action_id IN ({$action_id_string})" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + $wpdb->delete( $wpdb->actionscheduler_claims, array( 'claim_id' => $claim->get_id() ), array( '%d' ) ); + + if ( $row_updates < count( $action_ids ) ) { + throw new RuntimeException( + sprintf( + // translators: %d is an id. + __( 'Unable to release actions from claim id %d.', 'action-scheduler' ), + $claim->get_id() + ) + ); + } + } + + /** + * Remove the claim from an action. + * + * @param int $action_id Action ID. + * + * @return void + */ + public function unclaim_action( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $wpdb->update( + $wpdb->actionscheduler_actions, + array( 'claim_id' => 0 ), + array( 'action_id' => $action_id ), + array( '%s' ), + array( '%d' ) + ); + } + + /** + * Mark an action as failed. + * + * @param int $action_id Action ID. + * @throws \InvalidArgumentException Throw an exception if action was not updated. + */ + public function mark_failure( $action_id ) { + /** + * Global. + + * @var \wpdb $wpdb + */ + global $wpdb; + + $updated = $wpdb->update( + $wpdb->actionscheduler_actions, + array( 'status' => self::STATUS_FAILED ), + array( 'action_id' => $action_id ), + array( '%s' ), + array( '%d' ) + ); + if ( empty( $updated ) ) { + /* translators: %s is the action ID */ + throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to mark this action as having failed. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + } + + /** + * Add execution message to action log. + * + * @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress'). + * + * @param int $action_id Action ID. + * + * @return void + */ + public function log_execution( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $sql = "UPDATE {$wpdb->actionscheduler_actions} SET attempts = attempts+1, status=%s, last_attempt_gmt = %s, last_attempt_local = %s WHERE action_id = %d"; + $sql = $wpdb->prepare( $sql, self::STATUS_RUNNING, current_time( 'mysql', true ), current_time( 'mysql' ), $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $status_updated = $wpdb->query( $sql ); + + if ( ! $status_updated ) { + throw new Exception( + sprintf( + /* translators: 1: action ID. 2: status slug. */ + __( 'Unable to update the status of action %1$d to %2$s.', 'action-scheduler' ), + $action_id, + self::STATUS_RUNNING + ) + ); + } + } + + /** + * Mark an action as complete. + * + * @param int $action_id Action ID. + * + * @return void + * @throws \InvalidArgumentException Throw an exception if action was not updated. + */ + public function mark_complete( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $updated = $wpdb->update( + $wpdb->actionscheduler_actions, + array( + 'status' => self::STATUS_COMPLETE, + 'last_attempt_gmt' => current_time( 'mysql', true ), + 'last_attempt_local' => current_time( 'mysql' ), + ), + array( 'action_id' => $action_id ), + array( '%s' ), + array( '%d' ) + ); + if ( empty( $updated ) ) { + /* translators: %s is the action ID */ + throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to mark this action as having completed. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + + /** + * Fires after a scheduled action has been completed. + * + * @since 3.4.2 + * + * @param int $action_id Action ID. + */ + do_action( 'action_scheduler_completed_action', $action_id ); + } + + /** + * Get an action's status. + * + * @param int $action_id Action ID. + * + * @return string + * @throws \InvalidArgumentException Throw an exception if not status was found for action_id. + * @throws \RuntimeException Throw an exception if action status could not be retrieved. + */ + public function get_status( $action_id ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $sql = "SELECT status FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d"; + $sql = $wpdb->prepare( $sql, $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $status = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + if ( is_null( $status ) ) { + throw new \InvalidArgumentException( __( 'Invalid action ID. No status found.', 'action-scheduler' ) ); + } elseif ( empty( $status ) ) { + throw new \RuntimeException( __( 'Unknown status found for action.', 'action-scheduler' ) ); + } else { + return $status; + } + } +} diff --git a/classes/data-stores/ActionScheduler_HybridStore.php b/classes/data-stores/ActionScheduler_HybridStore.php new file mode 100644 index 0000000..c0845da --- /dev/null +++ b/classes/data-stores/ActionScheduler_HybridStore.php @@ -0,0 +1,460 @@ +demarkation_id = (int) get_option( self::DEMARKATION_OPTION, 0 ); + if ( empty( $config ) ) { + $config = Controller::instance()->get_migration_config_object(); + } + $this->primary_store = $config->get_destination_store(); + $this->secondary_store = $config->get_source_store(); + $this->migration_runner = new Runner( $config ); + } + + /** + * Initialize the table data store tables. + * + * @codeCoverageIgnore + */ + public function init() { + add_action( 'action_scheduler/created_table', array( $this, 'set_autoincrement' ), 10, 2 ); + $this->primary_store->init(); + $this->secondary_store->init(); + remove_action( 'action_scheduler/created_table', array( $this, 'set_autoincrement' ), 10 ); + } + + /** + * When the actions table is created, set its autoincrement + * value to be one higher than the posts table to ensure that + * there are no ID collisions. + * + * @param string $table_name Table name. + * @param string $table_suffix Suffix of table name. + * + * @return void + * @codeCoverageIgnore + */ + public function set_autoincrement( $table_name, $table_suffix ) { + if ( ActionScheduler_StoreSchema::ACTIONS_TABLE === $table_suffix ) { + if ( empty( $this->demarkation_id ) ) { + $this->demarkation_id = $this->set_demarkation_id(); + } + + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + /** + * A default date of '0000-00-00 00:00:00' is invalid in MySQL 5.7 when configured with + * sql_mode including both STRICT_TRANS_TABLES and NO_ZERO_DATE. + */ + $default_date = new DateTime( 'tomorrow' ); + $null_action = new ActionScheduler_NullAction(); + $date_gmt = $this->get_scheduled_date_string( $null_action, $default_date ); + $date_local = $this->get_scheduled_date_string_local( $null_action, $default_date ); + + $row_count = $wpdb->insert( + $wpdb->{ActionScheduler_StoreSchema::ACTIONS_TABLE}, + array( + 'action_id' => $this->demarkation_id, + 'hook' => '', + 'status' => '', + 'scheduled_date_gmt' => $date_gmt, + 'scheduled_date_local' => $date_local, + 'last_attempt_gmt' => $date_gmt, + 'last_attempt_local' => $date_local, + ) + ); + if ( $row_count > 0 ) { + $wpdb->delete( + $wpdb->{ActionScheduler_StoreSchema::ACTIONS_TABLE}, + array( 'action_id' => $this->demarkation_id ) + ); + } + } + } + + /** + * Store the demarkation id in WP options. + * + * @param int $id The ID to set as the demarkation point between the two stores + * Leave null to use the next ID from the WP posts table. + * + * @return int The new ID. + * + * @codeCoverageIgnore + */ + private function set_demarkation_id( $id = null ) { + if ( empty( $id ) ) { + /** + * Global. + * + * @var \wpdb $wpdb + */ + global $wpdb; + + $id = (int) $wpdb->get_var( "SELECT MAX(ID) FROM $wpdb->posts" ); + $id++; + } + update_option( self::DEMARKATION_OPTION, $id ); + + return $id; + } + + /** + * Find the first matching action from the secondary store. + * If it exists, migrate it to the primary store immediately. + * After it migrates, the secondary store will logically contain + * the next matching action, so return the result thence. + * + * @param string $hook Action's hook. + * @param array $params Action's arguments. + * + * @return string + */ + public function find_action( $hook, $params = array() ) { + $found_unmigrated_action = $this->secondary_store->find_action( $hook, $params ); + if ( ! empty( $found_unmigrated_action ) ) { + $this->migrate( array( $found_unmigrated_action ) ); + } + + return $this->primary_store->find_action( $hook, $params ); + } + + /** + * Find actions matching the query in the secondary source first. + * If any are found, migrate them immediately. Then the secondary + * store will contain the canonical results. + * + * @param array $query Query arguments. + * @param string $query_type Whether to select or count the results. Default, select. + * + * @return int[] + */ + public function query_actions( $query = array(), $query_type = 'select' ) { + $found_unmigrated_actions = $this->secondary_store->query_actions( $query, 'select' ); + if ( ! empty( $found_unmigrated_actions ) ) { + $this->migrate( $found_unmigrated_actions ); + } + + return $this->primary_store->query_actions( $query, $query_type ); + } + + /** + * Get a count of all actions in the store, grouped by status + * + * @return array Set of 'status' => int $count pairs for statuses with 1 or more actions of that status. + */ + public function action_counts() { + $unmigrated_actions_count = $this->secondary_store->action_counts(); + $migrated_actions_count = $this->primary_store->action_counts(); + $actions_count_by_status = array(); + + foreach ( $this->get_status_labels() as $status_key => $status_label ) { + + $count = 0; + + if ( isset( $unmigrated_actions_count[ $status_key ] ) ) { + $count += $unmigrated_actions_count[ $status_key ]; + } + + if ( isset( $migrated_actions_count[ $status_key ] ) ) { + $count += $migrated_actions_count[ $status_key ]; + } + + $actions_count_by_status[ $status_key ] = $count; + } + + $actions_count_by_status = array_filter( $actions_count_by_status ); + + return $actions_count_by_status; + } + + /** + * If any actions would have been claimed by the secondary store, + * migrate them immediately, then ask the primary store for the + * canonical claim. + * + * @param int $max_actions Maximum number of actions to claim. + * @param null|DateTime $before_date Latest timestamp of actions to claim. + * @param string[] $hooks Hook of actions to claim. + * @param string $group Group of actions to claim. + * + * @return ActionScheduler_ActionClaim + */ + public function stake_claim( $max_actions = 10, ?DateTime $before_date = null, $hooks = array(), $group = '' ) { + $claim = $this->secondary_store->stake_claim( $max_actions, $before_date, $hooks, $group ); + + $claimed_actions = $claim->get_actions(); + if ( ! empty( $claimed_actions ) ) { + $this->migrate( $claimed_actions ); + } + + $this->secondary_store->release_claim( $claim ); + + return $this->primary_store->stake_claim( $max_actions, $before_date, $hooks, $group ); + } + + /** + * Migrate a list of actions to the table data store. + * + * @param array $action_ids List of action IDs. + */ + private function migrate( $action_ids ) { + $this->migration_runner->migrate_actions( $action_ids ); + } + + /** + * Save an action to the primary store. + * + * @param ActionScheduler_Action $action Action object to be saved. + * @param DateTime|null $date Optional. Schedule date. Default null. + * + * @return int The action ID + */ + public function save_action( ActionScheduler_Action $action, ?DateTime $date = null ) { + return $this->primary_store->save_action( $action, $date ); + } + + /** + * Retrieve an existing action whether migrated or not. + * + * @param int $action_id Action ID. + */ + public function fetch_action( $action_id ) { + $store = $this->get_store_from_action_id( $action_id, true ); + if ( $store ) { + return $store->fetch_action( $action_id ); + } else { + return new ActionScheduler_NullAction(); + } + } + + /** + * Cancel an existing action whether migrated or not. + * + * @param int $action_id Action ID. + */ + public function cancel_action( $action_id ) { + $store = $this->get_store_from_action_id( $action_id ); + if ( $store ) { + $store->cancel_action( $action_id ); + } + } + + /** + * Delete an existing action whether migrated or not. + * + * @param int $action_id Action ID. + */ + public function delete_action( $action_id ) { + $store = $this->get_store_from_action_id( $action_id ); + if ( $store ) { + $store->delete_action( $action_id ); + } + } + + /** + * Get the schedule date an existing action whether migrated or not. + * + * @param int $action_id Action ID. + */ + public function get_date( $action_id ) { + $store = $this->get_store_from_action_id( $action_id ); + if ( $store ) { + return $store->get_date( $action_id ); + } else { + return null; + } + } + + /** + * Mark an existing action as failed whether migrated or not. + * + * @param int $action_id Action ID. + */ + public function mark_failure( $action_id ) { + $store = $this->get_store_from_action_id( $action_id ); + if ( $store ) { + $store->mark_failure( $action_id ); + } + } + + /** + * Log the execution of an existing action whether migrated or not. + * + * @param int $action_id Action ID. + */ + public function log_execution( $action_id ) { + $store = $this->get_store_from_action_id( $action_id ); + if ( $store ) { + $store->log_execution( $action_id ); + } + } + + /** + * Mark an existing action complete whether migrated or not. + * + * @param int $action_id Action ID. + */ + public function mark_complete( $action_id ) { + $store = $this->get_store_from_action_id( $action_id ); + if ( $store ) { + $store->mark_complete( $action_id ); + } + } + + /** + * Get an existing action status whether migrated or not. + * + * @param int $action_id Action ID. + */ + public function get_status( $action_id ) { + $store = $this->get_store_from_action_id( $action_id ); + if ( $store ) { + return $store->get_status( $action_id ); + } + return null; + } + + /** + * Return which store an action is stored in. + * + * @param int $action_id ID of the action. + * @param bool $primary_first Optional flag indicating search the primary store first. + * @return ActionScheduler_Store + */ + protected function get_store_from_action_id( $action_id, $primary_first = false ) { + if ( $primary_first ) { + $stores = array( + $this->primary_store, + $this->secondary_store, + ); + } elseif ( $action_id < $this->demarkation_id ) { + $stores = array( + $this->secondary_store, + $this->primary_store, + ); + } else { + $stores = array( + $this->primary_store, + ); + } + + foreach ( $stores as $store ) { + $action = $store->fetch_action( $action_id ); + if ( ! is_a( $action, 'ActionScheduler_NullAction' ) ) { + return $store; + } + } + return null; + } + + /** + * * * * * * * * * * * * * * * * * * * * * * * * * * * + * All claim-related functions should operate solely + * on the primary store. + * * * * * * * * * * * * * * * * * * * * * * * * * * * + */ + + /** + * Get the claim count from the table data store. + */ + public function get_claim_count() { + return $this->primary_store->get_claim_count(); + } + + /** + * Retrieve the claim ID for an action from the table data store. + * + * @param int $action_id Action ID. + */ + public function get_claim_id( $action_id ) { + return $this->primary_store->get_claim_id( $action_id ); + } + + /** + * Release a claim in the table data store. + * + * @param ActionScheduler_ActionClaim $claim Claim object. + */ + public function release_claim( ActionScheduler_ActionClaim $claim ) { + $this->primary_store->release_claim( $claim ); + } + + /** + * Release claims on an action in the table data store. + * + * @param int $action_id Action ID. + */ + public function unclaim_action( $action_id ) { + $this->primary_store->unclaim_action( $action_id ); + } + + /** + * Retrieve a list of action IDs by claim. + * + * @param int $claim_id Claim ID. + */ + public function find_actions_by_claim_id( $claim_id ) { + return $this->primary_store->find_actions_by_claim_id( $claim_id ); + } +} diff --git a/classes/data-stores/ActionScheduler_wpCommentLogger.php b/classes/data-stores/ActionScheduler_wpCommentLogger.php new file mode 100644 index 0000000..a9d84d3 --- /dev/null +++ b/classes/data-stores/ActionScheduler_wpCommentLogger.php @@ -0,0 +1,282 @@ +create_wp_comment( $action_id, $message, $date ); + return $comment_id; + } + + /** + * Create comment. + * + * @param int $action_id Action ID. + * @param string $message Action log's message. + * @param DateTime $date Action log entry's timestamp. + */ + protected function create_wp_comment( $action_id, $message, DateTime $date ) { + + $comment_date_gmt = $date->format( 'Y-m-d H:i:s' ); + ActionScheduler_TimezoneHelper::set_local_timezone( $date ); + $comment_data = array( + 'comment_post_ID' => $action_id, + 'comment_date' => $date->format( 'Y-m-d H:i:s' ), + 'comment_date_gmt' => $comment_date_gmt, + 'comment_author' => self::AGENT, + 'comment_content' => $message, + 'comment_agent' => self::AGENT, + 'comment_type' => self::TYPE, + ); + + return wp_insert_comment( $comment_data ); + } + + /** + * Get single log entry for action. + * + * @param string $entry_id Entry ID. + * + * @return ActionScheduler_LogEntry + */ + public function get_entry( $entry_id ) { + $comment = $this->get_comment( $entry_id ); + + if ( empty( $comment ) || self::TYPE !== $comment->comment_type ) { + return new ActionScheduler_NullLogEntry(); + } + + $date = as_get_datetime_object( $comment->comment_date_gmt ); + ActionScheduler_TimezoneHelper::set_local_timezone( $date ); + return new ActionScheduler_LogEntry( $comment->comment_post_ID, $comment->comment_content, $date ); + } + + /** + * Get action's logs. + * + * @param string $action_id Action ID. + * + * @return ActionScheduler_LogEntry[] + */ + public function get_logs( $action_id ) { + $status = 'all'; + $logs = array(); + + if ( get_post_status( $action_id ) === 'trash' ) { + $status = 'post-trashed'; + } + + $comments = get_comments( + array( + 'post_id' => $action_id, + 'orderby' => 'comment_date_gmt', + 'order' => 'ASC', + 'type' => self::TYPE, + 'status' => $status, + ) + ); + + foreach ( $comments as $c ) { + $entry = $this->get_entry( $c ); + + if ( ! empty( $entry ) ) { + $logs[] = $entry; + } + } + + return $logs; + } + + /** + * Get comment. + * + * @param int $comment_id Comment ID. + */ + protected function get_comment( $comment_id ) { + return get_comment( $comment_id ); + } + + /** + * Filter comment queries. + * + * @param WP_Comment_Query $query Comment query object. + */ + public function filter_comment_queries( $query ) { + foreach ( array( 'ID', 'parent', 'post_author', 'post_name', 'post_parent', 'type', 'post_type', 'post_id', 'post_ID' ) as $key ) { + if ( ! empty( $query->query_vars[ $key ] ) ) { + return; // don't slow down queries that wouldn't include action_log comments anyway. + } + } + $query->query_vars['action_log_filter'] = true; + add_filter( 'comments_clauses', array( $this, 'filter_comment_query_clauses' ), 10, 2 ); + } + + /** + * Filter comment queries. + * + * @param array $clauses Query's clauses. + * @param WP_Comment_Query $query Query object. + * + * @return array + */ + public function filter_comment_query_clauses( $clauses, $query ) { + if ( ! empty( $query->query_vars['action_log_filter'] ) ) { + $clauses['where'] .= $this->get_where_clause(); + } + return $clauses; + } + + /** + * Make sure Action Scheduler logs are excluded from comment feeds, which use WP_Query, not + * the WP_Comment_Query class handled by @see self::filter_comment_queries(). + * + * @param string $where Query's `where` clause. + * @param WP_Query $query Query object. + * + * @return string + */ + public function filter_comment_feed( $where, $query ) { + if ( is_comment_feed() ) { + $where .= $this->get_where_clause(); + } + return $where; + } + + /** + * Return a SQL clause to exclude Action Scheduler comments. + * + * @return string + */ + protected function get_where_clause() { + global $wpdb; + return sprintf( " AND {$wpdb->comments}.comment_type != '%s'", self::TYPE ); + } + + /** + * Remove action log entries from wp_count_comments() + * + * @param array $stats Comment count. + * @param int $post_id Post ID. + * + * @return object + */ + public function filter_comment_count( $stats, $post_id ) { + global $wpdb; + + if ( 0 === $post_id ) { + $stats = $this->get_comment_count(); + } + + return $stats; + } + + /** + * Retrieve the comment counts from our cache, or the database if the cached version isn't set. + * + * @return object + */ + protected function get_comment_count() { + global $wpdb; + + $stats = get_transient( 'as_comment_count' ); + + if ( ! $stats ) { + $stats = array(); + $count = $wpdb->get_results( "SELECT comment_approved, COUNT( * ) AS num_comments FROM {$wpdb->comments} WHERE comment_type NOT IN('order_note','action_log') GROUP BY comment_approved", ARRAY_A ); + $total = 0; + $stats = array(); + $approved = array( + '0' => 'moderated', + '1' => 'approved', + 'spam' => 'spam', + 'trash' => 'trash', + 'post-trashed' => 'post-trashed', + ); + + foreach ( (array) $count as $row ) { + // Don't count post-trashed toward totals. + if ( 'post-trashed' !== $row['comment_approved'] && 'trash' !== $row['comment_approved'] ) { + $total += $row['num_comments']; + } + if ( isset( $approved[ $row['comment_approved'] ] ) ) { + $stats[ $approved[ $row['comment_approved'] ] ] = $row['num_comments']; + } + } + + $stats['total_comments'] = $total; + $stats['all'] = $total; + + foreach ( $approved as $key ) { + if ( empty( $stats[ $key ] ) ) { + $stats[ $key ] = 0; + } + } + + $stats = (object) $stats; + set_transient( 'as_comment_count', $stats ); + } + + return $stats; + } + + /** + * Delete comment count cache whenever there is new comment or the status of a comment changes. Cache + * will be regenerated next time ActionScheduler_wpCommentLogger::filter_comment_count() is called. + */ + public function delete_comment_count_cache() { + delete_transient( 'as_comment_count' ); + } + + /** + * Initialize. + * + * @codeCoverageIgnore + */ + public function init() { + add_action( 'action_scheduler_before_process_queue', array( $this, 'disable_comment_counting' ), 10, 0 ); + add_action( 'action_scheduler_after_process_queue', array( $this, 'enable_comment_counting' ), 10, 0 ); + + parent::init(); + + add_action( 'pre_get_comments', array( $this, 'filter_comment_queries' ), 10, 1 ); + add_action( 'wp_count_comments', array( $this, 'filter_comment_count' ), 20, 2 ); // run after WC_Comments::wp_count_comments() to make sure we exclude order notes and action logs. + add_action( 'comment_feed_where', array( $this, 'filter_comment_feed' ), 10, 2 ); + + // Delete comments count cache whenever there is a new comment or a comment status changes. + add_action( 'wp_insert_comment', array( $this, 'delete_comment_count_cache' ) ); + add_action( 'wp_set_comment_status', array( $this, 'delete_comment_count_cache' ) ); + } + + /** + * Defer comment counting. + */ + public function disable_comment_counting() { + wp_defer_comment_counting( true ); + } + + /** + * Enable comment counting. + */ + public function enable_comment_counting() { + wp_defer_comment_counting( false ); + } + +} diff --git a/classes/data-stores/ActionScheduler_wpPostStore.php b/classes/data-stores/ActionScheduler_wpPostStore.php new file mode 100644 index 0000000..a503c18 --- /dev/null +++ b/classes/data-stores/ActionScheduler_wpPostStore.php @@ -0,0 +1,1088 @@ +validate_action( $action ); + $post_array = $this->create_post_array( $action, $scheduled_date ); + $post_id = $this->save_post_array( $post_array ); + $this->save_post_schedule( $post_id, $action->get_schedule() ); + $this->save_action_group( $post_id, $action->get_group() ); + do_action( 'action_scheduler_stored_action', $post_id ); + return $post_id; + } catch ( Exception $e ) { + /* translators: %s: action error message */ + throw new RuntimeException( sprintf( __( 'Error saving action: %s', 'action-scheduler' ), $e->getMessage() ), 0 ); + } + } + + /** + * Create post array. + * + * @param ActionScheduler_Action $action Scheduled Action. + * @param DateTime|null $scheduled_date Scheduled Date. + * + * @return array Returns an array of post data. + */ + protected function create_post_array( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) { + $post = array( + 'post_type' => self::POST_TYPE, + 'post_title' => $action->get_hook(), + 'post_content' => wp_json_encode( $action->get_args() ), + 'post_status' => ( $action->is_finished() ? 'publish' : 'pending' ), + 'post_date_gmt' => $this->get_scheduled_date_string( $action, $scheduled_date ), + 'post_date' => $this->get_scheduled_date_string_local( $action, $scheduled_date ), + ); + return $post; + } + + /** + * Save post array. + * + * @param array $post_array Post array. + * @return int Returns the post ID. + * @throws RuntimeException Throws an exception if the action could not be saved. + */ + protected function save_post_array( $post_array ) { + add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 ); + add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 ); + + $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); + + if ( $has_kses ) { + // Prevent KSES from corrupting JSON in post_content. + kses_remove_filters(); + } + + $post_id = wp_insert_post( $post_array ); + + if ( $has_kses ) { + kses_init_filters(); + } + + remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10 ); + remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 ); + + if ( is_wp_error( $post_id ) || empty( $post_id ) ) { + throw new RuntimeException( __( 'Unable to save action.', 'action-scheduler' ) ); + } + return $post_id; + } + + /** + * Filter insert post data. + * + * @param array $postdata Post data to filter. + * + * @return array + */ + public function filter_insert_post_data( $postdata ) { + if ( self::POST_TYPE === $postdata['post_type'] ) { + $postdata['post_author'] = 0; + if ( 'future' === $postdata['post_status'] ) { + $postdata['post_status'] = 'publish'; + } + } + return $postdata; + } + + /** + * Create a (probably unique) post name for scheduled actions in a more performant manner than wp_unique_post_slug(). + * + * When an action's post status is transitioned to something other than 'draft', 'pending' or 'auto-draft, like 'publish' + * or 'failed' or 'trash', WordPress will find a unique slug (stored in post_name column) using the wp_unique_post_slug() + * function. This is done to ensure URL uniqueness. The approach taken by wp_unique_post_slug() is to iterate over existing + * post_name values that match, and append a number 1 greater than the largest. This makes sense when manually creating a + * post from the Edit Post screen. It becomes a bottleneck when automatically processing thousands of actions, with a + * database containing thousands of related post_name values. + * + * WordPress 5.1 introduces the 'pre_wp_unique_post_slug' filter for plugins to address this issue. + * + * We can short-circuit WordPress's wp_unique_post_slug() approach using the 'pre_wp_unique_post_slug' filter. This + * method is available to be used as a callback on that filter. It provides a more scalable approach to generating a + * post_name/slug that is probably unique. Because Action Scheduler never actually uses the post_name field, or an + * action's slug, being probably unique is good enough. + * + * For more backstory on this issue, see: + * - https://github.com/woocommerce/action-scheduler/issues/44 and + * - https://core.trac.wordpress.org/ticket/21112 + * + * @param string $override_slug Short-circuit return value. + * @param string $slug The desired slug (post_name). + * @param int $post_ID Post ID. + * @param string $post_status The post status. + * @param string $post_type Post type. + * @return string + */ + public function set_unique_post_slug( $override_slug, $slug, $post_ID, $post_status, $post_type ) { + if ( self::POST_TYPE === $post_type ) { + $override_slug = uniqid( self::POST_TYPE . '-', true ) . '-' . wp_generate_password( 32, false ); + } + return $override_slug; + } + + /** + * Save post schedule. + * + * @param int $post_id Post ID of the scheduled action. + * @param string $schedule Schedule to save. + * + * @return void + */ + protected function save_post_schedule( $post_id, $schedule ) { + update_post_meta( $post_id, self::SCHEDULE_META_KEY, $schedule ); + } + + /** + * Save action group. + * + * @param int $post_id Post ID. + * @param string $group Group to save. + * @return void + */ + protected function save_action_group( $post_id, $group ) { + if ( empty( $group ) ) { + wp_set_object_terms( $post_id, array(), self::GROUP_TAXONOMY, false ); + } else { + wp_set_object_terms( $post_id, array( $group ), self::GROUP_TAXONOMY, false ); + } + } + + /** + * Fetch actions. + * + * @param int $action_id Action ID. + * @return object + */ + public function fetch_action( $action_id ) { + $post = $this->get_post( $action_id ); + if ( empty( $post ) || self::POST_TYPE !== $post->post_type ) { + return $this->get_null_action(); + } + + try { + $action = $this->make_action_from_post( $post ); + } catch ( ActionScheduler_InvalidActionException $exception ) { + do_action( 'action_scheduler_failed_fetch_action', $post->ID, $exception ); + return $this->get_null_action(); + } + + return $action; + } + + /** + * Get post. + * + * @param string $action_id - Action ID. + * @return WP_Post|null + */ + protected function get_post( $action_id ) { + if ( empty( $action_id ) ) { + return null; + } + return get_post( $action_id ); + } + + /** + * Get NULL action. + * + * @return ActionScheduler_NullAction + */ + protected function get_null_action() { + return new ActionScheduler_NullAction(); + } + + /** + * Make action from post. + * + * @param WP_Post $post Post object. + * @return WP_Post + */ + protected function make_action_from_post( $post ) { + $hook = $post->post_title; + + $args = json_decode( $post->post_content, true ); + $this->validate_args( $args, $post->ID ); + + $schedule = get_post_meta( $post->ID, self::SCHEDULE_META_KEY, true ); + $this->validate_schedule( $schedule, $post->ID ); + + $group = wp_get_object_terms( $post->ID, self::GROUP_TAXONOMY, array( 'fields' => 'names' ) ); + $group = empty( $group ) ? '' : reset( $group ); + + return ActionScheduler::factory()->get_stored_action( $this->get_action_status_by_post_status( $post->post_status ), $hook, $args, $schedule, $group ); + } + + /** + * Get action status by post status. + * + * @param string $post_status Post status. + * + * @throws InvalidArgumentException Throw InvalidArgumentException if $post_status not in known status fields returned by $this->get_status_labels(). + * @return string + */ + protected function get_action_status_by_post_status( $post_status ) { + + switch ( $post_status ) { + case 'publish': + $action_status = self::STATUS_COMPLETE; + break; + case 'trash': + $action_status = self::STATUS_CANCELED; + break; + default: + if ( ! array_key_exists( $post_status, $this->get_status_labels() ) ) { + throw new InvalidArgumentException( sprintf( 'Invalid post status: "%s". No matching action status available.', $post_status ) ); + } + $action_status = $post_status; + break; + } + + return $action_status; + } + + /** + * Get post status by action status. + * + * @param string $action_status Action status. + * + * @throws InvalidArgumentException Throws InvalidArgumentException if $post_status not in known status fields returned by $this->get_status_labels(). + * @return string + */ + protected function get_post_status_by_action_status( $action_status ) { + + switch ( $action_status ) { + case self::STATUS_COMPLETE: + $post_status = 'publish'; + break; + case self::STATUS_CANCELED: + $post_status = 'trash'; + break; + default: + if ( ! array_key_exists( $action_status, $this->get_status_labels() ) ) { + throw new InvalidArgumentException( sprintf( 'Invalid action status: "%s".', $action_status ) ); + } + $post_status = $action_status; + break; + } + + return $post_status; + } + + /** + * Returns the SQL statement to query (or count) actions. + * + * @param array $query - Filtering options. + * @param string $select_or_count - Whether the SQL should select and return the IDs or just the row count. + * + * @throws InvalidArgumentException - Throw InvalidArgumentException if $select_or_count not count or select. + * @return string SQL statement. The returned SQL is already properly escaped. + */ + protected function get_query_actions_sql( array $query, $select_or_count = 'select' ) { + + if ( ! in_array( $select_or_count, array( 'select', 'count' ), true ) ) { + throw new InvalidArgumentException( __( 'Invalid schedule. Cannot save action.', 'action-scheduler' ) ); + } + + $query = wp_parse_args( + $query, + array( + 'hook' => '', + 'args' => null, + 'date' => null, + 'date_compare' => '<=', + 'modified' => null, + 'modified_compare' => '<=', + 'group' => '', + 'status' => '', + 'claimed' => null, + 'per_page' => 5, + 'offset' => 0, + 'orderby' => 'date', + 'order' => 'ASC', + 'search' => '', + ) + ); + + /** + * Global wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + $sql = ( 'count' === $select_or_count ) ? 'SELECT count(p.ID)' : 'SELECT p.ID '; + $sql .= "FROM {$wpdb->posts} p"; + $sql_params = array(); + if ( empty( $query['group'] ) && 'group' === $query['orderby'] ) { + $sql .= " LEFT JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID"; + $sql .= " LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id"; + $sql .= " LEFT JOIN {$wpdb->terms} t ON tt.term_id=t.term_id"; + } elseif ( ! empty( $query['group'] ) ) { + $sql .= " INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id=p.ID"; + $sql .= " INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id=tt.term_taxonomy_id"; + $sql .= " INNER JOIN {$wpdb->terms} t ON tt.term_id=t.term_id"; + $sql .= ' AND t.slug=%s'; + $sql_params[] = $query['group']; + } + $sql .= ' WHERE post_type=%s'; + $sql_params[] = self::POST_TYPE; + if ( $query['hook'] ) { + $sql .= ' AND p.post_title=%s'; + $sql_params[] = $query['hook']; + } + if ( ! is_null( $query['args'] ) ) { + $sql .= ' AND p.post_content=%s'; + $sql_params[] = wp_json_encode( $query['args'] ); + } + + if ( $query['status'] ) { + $post_statuses = array_map( array( $this, 'get_post_status_by_action_status' ), (array) $query['status'] ); + $placeholders = array_fill( 0, count( $post_statuses ), '%s' ); + $sql .= ' AND p.post_status IN (' . join( ', ', $placeholders ) . ')'; + $sql_params = array_merge( $sql_params, array_values( $post_statuses ) ); + } + + if ( $query['date'] instanceof DateTime ) { + $date = clone $query['date']; + $date->setTimezone( new DateTimeZone( 'UTC' ) ); + $date_string = $date->format( 'Y-m-d H:i:s' ); + $comparator = $this->validate_sql_comparator( $query['date_compare'] ); + $sql .= " AND p.post_date_gmt $comparator %s"; + $sql_params[] = $date_string; + } + + if ( $query['modified'] instanceof DateTime ) { + $modified = clone $query['modified']; + $modified->setTimezone( new DateTimeZone( 'UTC' ) ); + $date_string = $modified->format( 'Y-m-d H:i:s' ); + $comparator = $this->validate_sql_comparator( $query['modified_compare'] ); + $sql .= " AND p.post_modified_gmt $comparator %s"; + $sql_params[] = $date_string; + } + + if ( true === $query['claimed'] ) { + $sql .= " AND p.post_password != ''"; + } elseif ( false === $query['claimed'] ) { + $sql .= " AND p.post_password = ''"; + } elseif ( ! is_null( $query['claimed'] ) ) { + $sql .= ' AND p.post_password = %s'; + $sql_params[] = $query['claimed']; + } + + if ( ! empty( $query['search'] ) ) { + $sql .= ' AND (p.post_title LIKE %s OR p.post_content LIKE %s OR p.post_password LIKE %s)'; + for ( $i = 0; $i < 3; $i++ ) { + $sql_params[] = sprintf( '%%%s%%', $query['search'] ); + } + } + + if ( 'select' === $select_or_count ) { + switch ( $query['orderby'] ) { + case 'hook': + $orderby = 'p.post_title'; + break; + case 'group': + $orderby = 't.name'; + break; + case 'status': + $orderby = 'p.post_status'; + break; + case 'modified': + $orderby = 'p.post_modified'; + break; + case 'claim_id': + $orderby = 'p.post_password'; + break; + case 'schedule': + case 'date': + default: + $orderby = 'p.post_date_gmt'; + break; + } + if ( 'ASC' === strtoupper( $query['order'] ) ) { + $order = 'ASC'; + } else { + $order = 'DESC'; + } + $sql .= " ORDER BY $orderby $order"; + if ( $query['per_page'] > 0 ) { + $sql .= ' LIMIT %d, %d'; + $sql_params[] = $query['offset']; + $sql_params[] = $query['per_page']; + } + } + + return $wpdb->prepare( $sql, $sql_params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Query for action count or list of action IDs. + * + * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status. + * + * @see ActionScheduler_Store::query_actions for $query arg usage. + * + * @param array $query Query filtering options. + * @param string $query_type Whether to select or count the results. Defaults to select. + * + * @return string|array|null The IDs of actions matching the query. Null on failure. + */ + public function query_actions( $query = array(), $query_type = 'select' ) { + /** + * Global $wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + + $sql = $this->get_query_actions_sql( $query, $query_type ); + + return ( 'count' === $query_type ) ? $wpdb->get_var( $sql ) : $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Get a count of all actions in the store, grouped by status + * + * @return array + */ + public function action_counts() { + + $action_counts_by_status = array(); + $action_stati_and_labels = $this->get_status_labels(); + $posts_count_by_status = (array) wp_count_posts( self::POST_TYPE, 'readable' ); + + foreach ( $posts_count_by_status as $post_status_name => $count ) { + + try { + $action_status_name = $this->get_action_status_by_post_status( $post_status_name ); + } catch ( Exception $e ) { + // Ignore any post statuses that aren't for actions. + continue; + } + if ( array_key_exists( $action_status_name, $action_stati_and_labels ) ) { + $action_counts_by_status[ $action_status_name ] = $count; + } + } + + return $action_counts_by_status; + } + + /** + * Cancel action. + * + * @param int $action_id Action ID. + * + * @throws InvalidArgumentException If $action_id is not identified. + */ + public function cancel_action( $action_id ) { + $post = get_post( $action_id ); + if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) { + /* translators: %s is the action ID */ + throw new InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to cancel this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + do_action( 'action_scheduler_canceled_action', $action_id ); + add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 ); + wp_trash_post( $action_id ); + remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 ); + } + + /** + * Delete action. + * + * @param int $action_id Action ID. + * @return void + * @throws InvalidArgumentException If action is not identified. + */ + public function delete_action( $action_id ) { + $post = get_post( $action_id ); + if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) { + /* translators: %s is the action ID */ + throw new InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to delete this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + do_action( 'action_scheduler_deleted_action', $action_id ); + + wp_delete_post( $action_id, true ); + } + + /** + * Get date for claim id. + * + * @param int $action_id Action ID. + * @return ActionScheduler_DateTime The date the action is schedule to run, or the date that it ran. + */ + public function get_date( $action_id ) { + $next = $this->get_date_gmt( $action_id ); + return ActionScheduler_TimezoneHelper::set_local_timezone( $next ); + } + + /** + * Get Date GMT. + * + * @param int $action_id Action ID. + * + * @throws InvalidArgumentException If $action_id is not identified. + * @return ActionScheduler_DateTime The date the action is schedule to run, or the date that it ran. + */ + public function get_date_gmt( $action_id ) { + $post = get_post( $action_id ); + if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) { + /* translators: %s is the action ID */ + throw new InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to determine the date of this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + if ( 'publish' === $post->post_status ) { + return as_get_datetime_object( $post->post_modified_gmt ); + } else { + return as_get_datetime_object( $post->post_date_gmt ); + } + } + + /** + * Stake claim. + * + * @param int $max_actions Maximum number of actions. + * @param DateTime|null $before_date Jobs must be schedule before this date. Defaults to now. + * @param array $hooks Claim only actions with a hook or hooks. + * @param string $group Claim only actions in the given group. + * + * @return ActionScheduler_ActionClaim + * @throws RuntimeException When there is an error staking a claim. + * @throws InvalidArgumentException When the given group is not valid. + */ + public function stake_claim( $max_actions = 10, ?DateTime $before_date = null, $hooks = array(), $group = '' ) { + $this->claim_before_date = $before_date; + $claim_id = $this->generate_claim_id(); + $this->claim_actions( $claim_id, $max_actions, $before_date, $hooks, $group ); + $action_ids = $this->find_actions_by_claim_id( $claim_id ); + $this->claim_before_date = null; + + return new ActionScheduler_ActionClaim( $claim_id, $action_ids ); + } + + /** + * Get claim count. + * + * @return int + */ + public function get_claim_count() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + return $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT post_password) FROM {$wpdb->posts} WHERE post_password != '' AND post_type = %s AND post_status IN ('in-progress','pending')", + array( self::POST_TYPE ) + ) + ); + } + + /** + * Generate claim id. + * + * @return string + */ + protected function generate_claim_id() { + $claim_id = md5( microtime( true ) . wp_rand( 0, 1000 ) ); + return substr( $claim_id, 0, 20 ); // to fit in db field with 20 char limit. + } + + /** + * Claim actions. + * + * @param string $claim_id Claim ID. + * @param int $limit Limit. + * @param DateTime|null $before_date Should use UTC timezone. + * @param array $hooks Claim only actions with a hook or hooks. + * @param string $group Claim only actions in the given group. + * + * @return int The number of actions that were claimed. + * @throws RuntimeException When there is a database error. + */ + protected function claim_actions( $claim_id, $limit, ?DateTime $before_date = null, $hooks = array(), $group = '' ) { + // Set up initial variables. + $date = null === $before_date ? as_get_datetime_object() : clone $before_date; + $limit_ids = ! empty( $group ); + $ids = $limit_ids ? $this->get_actions_by_group( $group, $limit, $date ) : array(); + + // If limiting by IDs and no posts found, then return early since we have nothing to update. + if ( $limit_ids && 0 === count( $ids ) ) { + return 0; + } + + /** + * Global wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + + /* + * Build up custom query to update the affected posts. Parameters are built as a separate array + * to make it easier to identify where they are in the query. + * + * We can't use $wpdb->update() here because of the "ID IN ..." clause. + */ + $update = "UPDATE {$wpdb->posts} SET post_password = %s, post_modified_gmt = %s, post_modified = %s"; + $params = array( + $claim_id, + current_time( 'mysql', true ), + current_time( 'mysql' ), + ); + + // Build initial WHERE clause. + $where = "WHERE post_type = %s AND post_status = %s AND post_password = ''"; + $params[] = self::POST_TYPE; + $params[] = ActionScheduler_Store::STATUS_PENDING; + + if ( ! empty( $hooks ) ) { + $placeholders = array_fill( 0, count( $hooks ), '%s' ); + $where .= ' AND post_title IN (' . join( ', ', $placeholders ) . ')'; + $params = array_merge( $params, array_values( $hooks ) ); + } + + /* + * Add the IDs to the WHERE clause. IDs not escaped because they came directly from a prior DB query. + * + * If we're not limiting by IDs, then include the post_date_gmt clause. + */ + if ( $limit_ids ) { + $where .= ' AND ID IN (' . join( ',', $ids ) . ')'; + } else { + $where .= ' AND post_date_gmt <= %s'; + $params[] = $date->format( 'Y-m-d H:i:s' ); + } + + // Add the ORDER BY clause and,ms limit. + $order = 'ORDER BY menu_order ASC, post_date_gmt ASC, ID ASC LIMIT %d'; + $params[] = $limit; + + // Run the query and gather results. + $rows_affected = $wpdb->query( $wpdb->prepare( "{$update} {$where} {$order}", $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + + if ( false === $rows_affected ) { + throw new RuntimeException( __( 'Unable to claim actions. Database error.', 'action-scheduler' ) ); + } + + return (int) $rows_affected; + } + + /** + * Get IDs of actions within a certain group and up to a certain date/time. + * + * @param string $group The group to use in finding actions. + * @param int $limit The number of actions to retrieve. + * @param DateTime $date DateTime object representing cutoff time for actions. Actions retrieved will be + * up to and including this DateTime. + * + * @return array IDs of actions in the appropriate group and before the appropriate time. + * @throws InvalidArgumentException When the group does not exist. + */ + protected function get_actions_by_group( $group, $limit, DateTime $date ) { + // Ensure the group exists before continuing. + if ( ! term_exists( $group, self::GROUP_TAXONOMY ) ) { + /* translators: %s is the group name */ + throw new InvalidArgumentException( sprintf( __( 'The group "%s" does not exist.', 'action-scheduler' ), $group ) ); + } + + // Set up a query for post IDs to use later. + $query = new WP_Query(); + $query_args = array( + 'fields' => 'ids', + 'post_type' => self::POST_TYPE, + 'post_status' => ActionScheduler_Store::STATUS_PENDING, + 'has_password' => false, + 'posts_per_page' => $limit * 3, + 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters + 'no_found_rows' => true, + 'orderby' => array( + 'menu_order' => 'ASC', + 'date' => 'ASC', + 'ID' => 'ASC', + ), + 'date_query' => array( + 'column' => 'post_date_gmt', + 'before' => $date->format( 'Y-m-d H:i' ), + 'inclusive' => true, + ), + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery + array( + 'taxonomy' => self::GROUP_TAXONOMY, + 'field' => 'slug', + 'terms' => $group, + 'include_children' => false, + ), + ), + ); + + return $query->query( $query_args ); + } + + /** + * Find actions by claim ID. + * + * @param string $claim_id Claim ID. + * @return array + */ + public function find_actions_by_claim_id( $claim_id ) { + /** + * Global wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + + $action_ids = array(); + $before_date = isset( $this->claim_before_date ) ? $this->claim_before_date : as_get_datetime_object(); + $cut_off = $before_date->format( 'Y-m-d H:i:s' ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE post_type = %s AND post_password = %s", + array( + self::POST_TYPE, + $claim_id, + ) + ) + ); + + // Verify that the scheduled date for each action is within the expected bounds (in some unusual + // cases, we cannot depend on MySQL to honor all of the WHERE conditions we specify). + foreach ( $results as $claimed_action ) { + if ( $claimed_action->post_date_gmt <= $cut_off ) { + $action_ids[] = absint( $claimed_action->ID ); + } + } + + return $action_ids; + } + + /** + * Release claim. + * + * @param ActionScheduler_ActionClaim $claim Claim object to release. + * @return void + * @throws RuntimeException When the claim is not unlocked. + */ + public function release_claim( ActionScheduler_ActionClaim $claim ) { + $action_ids = $this->find_actions_by_claim_id( $claim->get_id() ); + if ( empty( $action_ids ) ) { + return; // nothing to do. + } + $action_id_string = implode( ',', array_map( 'intval', $action_ids ) ); + /** + * Global wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID IN ($action_id_string) AND post_password = %s", //phpcs:ignore + array( + $claim->get_id(), + ) + ) + ); + if ( false === $result ) { + /* translators: %s: claim ID */ + throw new RuntimeException( sprintf( __( 'Unable to unlock claim %s. Database error.', 'action-scheduler' ), $claim->get_id() ) ); + } + } + + /** + * Unclaim action. + * + * @param string $action_id Action ID. + * @throws RuntimeException When unable to unlock claim on action ID. + */ + public function unclaim_action( $action_id ) { + /** + * Global wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + + //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->posts} SET post_password = '' WHERE ID = %d AND post_type = %s", + $action_id, + self::POST_TYPE + ) + ); + if ( false === $result ) { + /* translators: %s: action ID */ + throw new RuntimeException( sprintf( __( 'Unable to unlock claim on action %s. Database error.', 'action-scheduler' ), $action_id ) ); + } + } + + /** + * Mark failure on action. + * + * @param int $action_id Action ID. + * + * @return void + * @throws RuntimeException When unable to mark failure on action ID. + */ + public function mark_failure( $action_id ) { + /** + * Global wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->query( + $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_type = %s", self::STATUS_FAILED, $action_id, self::POST_TYPE ) + ); + if ( false === $result ) { + /* translators: %s: action ID */ + throw new RuntimeException( sprintf( __( 'Unable to mark failure on action %s. Database error.', 'action-scheduler' ), $action_id ) ); + } + } + + /** + * Return an action's claim ID, as stored in the post password column + * + * @param int $action_id Action ID. + * @return mixed + */ + public function get_claim_id( $action_id ) { + return $this->get_post_column( $action_id, 'post_password' ); + } + + /** + * Return an action's status, as stored in the post status column + * + * @param int $action_id Action ID. + * + * @return mixed + * @throws InvalidArgumentException When the action ID is invalid. + */ + public function get_status( $action_id ) { + $status = $this->get_post_column( $action_id, 'post_status' ); + + if ( null === $status ) { + throw new InvalidArgumentException( __( 'Invalid action ID. No status found.', 'action-scheduler' ) ); + } + + return $this->get_action_status_by_post_status( $status ); + } + + /** + * Get post column + * + * @param string $action_id Action ID. + * @param string $column_name Column Name. + * + * @return string|null + */ + private function get_post_column( $action_id, $column_name ) { + /** + * Global wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + return $wpdb->get_var( + $wpdb->prepare( + "SELECT {$column_name} FROM {$wpdb->posts} WHERE ID=%d AND post_type=%s", // phpcs:ignore + $action_id, + self::POST_TYPE + ) + ); + } + + /** + * Log Execution. + * + * @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress'). + * + * @param string $action_id Action ID. + */ + public function log_execution( $action_id ) { + /** + * Global wpdb object. + * + * @var wpdb $wpdb + */ + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $status_updated = $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->posts} SET menu_order = menu_order+1, post_status=%s, post_modified_gmt = %s, post_modified = %s WHERE ID = %d AND post_type = %s", + self::STATUS_RUNNING, + current_time( 'mysql', true ), + current_time( 'mysql' ), + $action_id, + self::POST_TYPE + ) + ); + + if ( ! $status_updated ) { + throw new Exception( + sprintf( + /* translators: 1: action ID. 2: status slug. */ + __( 'Unable to update the status of action %1$d to %2$s.', 'action-scheduler' ), + $action_id, + self::STATUS_RUNNING + ) + ); + } + } + + /** + * Record that an action was completed. + * + * @param string $action_id ID of the completed action. + * + * @throws InvalidArgumentException When the action ID is invalid. + * @throws RuntimeException When there was an error executing the action. + */ + public function mark_complete( $action_id ) { + $post = get_post( $action_id ); + if ( empty( $post ) || ( self::POST_TYPE !== $post->post_type ) ) { + /* translators: %s is the action ID */ + throw new InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to mark this action as having completed. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) ); + } + add_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10, 1 ); + add_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10, 5 ); + $result = wp_update_post( + array( + 'ID' => $action_id, + 'post_status' => 'publish', + ), + true + ); + remove_filter( 'wp_insert_post_data', array( $this, 'filter_insert_post_data' ), 10 ); + remove_filter( 'pre_wp_unique_post_slug', array( $this, 'set_unique_post_slug' ), 10 ); + if ( is_wp_error( $result ) ) { + throw new RuntimeException( $result->get_error_message() ); + } + + /** + * Fires after a scheduled action has been completed. + * + * @since 3.4.2 + * + * @param int $action_id Action ID. + */ + do_action( 'action_scheduler_completed_action', $action_id ); + } + + /** + * Mark action as migrated when there is an error deleting the action. + * + * @param int $action_id Action ID. + */ + public function mark_migrated( $action_id ) { + wp_update_post( + array( + 'ID' => $action_id, + 'post_status' => 'migrated', + ) + ); + } + + /** + * Determine whether the post store can be migrated. + * + * @param [type] $setting - Setting value. + * @return bool + */ + public function migration_dependencies_met( $setting ) { + global $wpdb; + + $dependencies_met = get_transient( self::DEPENDENCIES_MET ); + if ( empty( $dependencies_met ) ) { + $maximum_args_length = apply_filters( 'action_scheduler_maximum_args_length', 191 ); + $found_action = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND CHAR_LENGTH(post_content) > %d LIMIT 1", + $maximum_args_length, + self::POST_TYPE + ) + ); + $dependencies_met = $found_action ? 'no' : 'yes'; + set_transient( self::DEPENDENCIES_MET, $dependencies_met, DAY_IN_SECONDS ); + } + + return 'yes' === $dependencies_met ? $setting : false; + } + + /** + * InnoDB indexes have a maximum size of 767 bytes by default, which is only 191 characters with utf8mb4. + * + * Previously, AS wasn't concerned about args length, as we used the (unindex) post_content column. However, + * as we prepare to move to custom tables, and can use an indexed VARCHAR column instead, we want to warn + * developers of this impending requirement. + * + * @param ActionScheduler_Action $action Action object. + */ + protected function validate_action( ActionScheduler_Action $action ) { + try { + parent::validate_action( $action ); + } catch ( Exception $e ) { + /* translators: %s is the error message */ + $message = sprintf( __( '%s Support for strings longer than this will be removed in a future version.', 'action-scheduler' ), $e->getMessage() ); + _doing_it_wrong( 'ActionScheduler_Action::$args', esc_html( $message ), '2.1.0' ); + } + } + + /** + * (@codeCoverageIgnore) + */ + public function init() { + add_filter( 'action_scheduler_migration_dependencies_met', array( $this, 'migration_dependencies_met' ) ); + + $post_type_registrar = new ActionScheduler_wpPostStore_PostTypeRegistrar(); + $post_type_registrar->register(); + + $post_status_registrar = new ActionScheduler_wpPostStore_PostStatusRegistrar(); + $post_status_registrar->register(); + + $taxonomy_registrar = new ActionScheduler_wpPostStore_TaxonomyRegistrar(); + $taxonomy_registrar->register(); + } +} diff --git a/classes/data-stores/ActionScheduler_wpPostStore_PostStatusRegistrar.php b/classes/data-stores/ActionScheduler_wpPostStore_PostStatusRegistrar.php new file mode 100644 index 0000000..5f1f1fc --- /dev/null +++ b/classes/data-stores/ActionScheduler_wpPostStore_PostStatusRegistrar.php @@ -0,0 +1,63 @@ +post_status_args(), $this->post_status_running_labels() ) ); + register_post_status( ActionScheduler_Store::STATUS_FAILED, array_merge( $this->post_status_args(), $this->post_status_failed_labels() ) ); + } + + /** + * Build the args array for the post type definition + * + * @return array + */ + protected function post_status_args() { + $args = array( + 'public' => false, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + ); + + return apply_filters( 'action_scheduler_post_status_args', $args ); + } + + /** + * Build the args array for the post type definition + * + * @return array + */ + protected function post_status_failed_labels() { + $labels = array( + 'label' => _x( 'Failed', 'post', 'action-scheduler' ), + /* translators: %s: count */ + 'label_count' => _n_noop( 'Failed (%s)', 'Failed (%s)', 'action-scheduler' ), + ); + + return apply_filters( 'action_scheduler_post_status_failed_labels', $labels ); + } + + /** + * Build the args array for the post type definition + * + * @return array + */ + protected function post_status_running_labels() { + $labels = array( + 'label' => _x( 'In-Progress', 'post', 'action-scheduler' ), + /* translators: %s: count */ + 'label_count' => _n_noop( 'In-Progress (%s)', 'In-Progress (%s)', 'action-scheduler' ), + ); + + return apply_filters( 'action_scheduler_post_status_running_labels', $labels ); + } +} diff --git a/classes/data-stores/ActionScheduler_wpPostStore_PostTypeRegistrar.php b/classes/data-stores/ActionScheduler_wpPostStore_PostTypeRegistrar.php new file mode 100644 index 0000000..5252294 --- /dev/null +++ b/classes/data-stores/ActionScheduler_wpPostStore_PostTypeRegistrar.php @@ -0,0 +1,53 @@ +post_type_args() ); + } + + /** + * Build the args array for the post type definition + * + * @return array + */ + protected function post_type_args() { + $args = array( + 'label' => __( 'Scheduled Actions', 'action-scheduler' ), + 'description' => __( 'Scheduled actions are hooks triggered on a certain date and time.', 'action-scheduler' ), + 'public' => false, + 'map_meta_cap' => true, + 'hierarchical' => false, + 'supports' => array( 'title', 'editor', 'comments' ), + 'rewrite' => false, + 'query_var' => false, + 'can_export' => true, + 'ep_mask' => EP_NONE, + 'labels' => array( + 'name' => __( 'Scheduled Actions', 'action-scheduler' ), + 'singular_name' => __( 'Scheduled Action', 'action-scheduler' ), + 'menu_name' => _x( 'Scheduled Actions', 'Admin menu name', 'action-scheduler' ), + 'add_new' => __( 'Add', 'action-scheduler' ), + 'add_new_item' => __( 'Add New Scheduled Action', 'action-scheduler' ), + 'edit' => __( 'Edit', 'action-scheduler' ), + 'edit_item' => __( 'Edit Scheduled Action', 'action-scheduler' ), + 'new_item' => __( 'New Scheduled Action', 'action-scheduler' ), + 'view' => __( 'View Action', 'action-scheduler' ), + 'view_item' => __( 'View Action', 'action-scheduler' ), + 'search_items' => __( 'Search Scheduled Actions', 'action-scheduler' ), + 'not_found' => __( 'No actions found', 'action-scheduler' ), + 'not_found_in_trash' => __( 'No actions found in trash', 'action-scheduler' ), + ), + ); + + $args = apply_filters( 'action_scheduler_post_type_args', $args ); + return $args; + } +} diff --git a/classes/data-stores/ActionScheduler_wpPostStore_TaxonomyRegistrar.php b/classes/data-stores/ActionScheduler_wpPostStore_TaxonomyRegistrar.php new file mode 100644 index 0000000..e100fc9 --- /dev/null +++ b/classes/data-stores/ActionScheduler_wpPostStore_TaxonomyRegistrar.php @@ -0,0 +1,33 @@ +taxonomy_args() ); + } + + /** + * Get taxonomy arguments. + */ + protected function taxonomy_args() { + $args = array( + 'label' => __( 'Action Group', 'action-scheduler' ), + 'public' => false, + 'hierarchical' => false, + 'show_admin_column' => true, + 'query_var' => false, + 'rewrite' => false, + ); + + $args = apply_filters( 'action_scheduler_taxonomy_args', $args ); + return $args; + } +} diff --git a/classes/migration/ActionMigrator.php b/classes/migration/ActionMigrator.php new file mode 100644 index 0000000..f0e04fe --- /dev/null +++ b/classes/migration/ActionMigrator.php @@ -0,0 +1,126 @@ +source = $source_store; + $this->destination = $destination_store; + $this->log_migrator = $log_migrator; + } + + /** + * Migrate an action. + * + * @param int $source_action_id Action ID. + * + * @return int 0|new action ID + * @throws \RuntimeException When unable to delete action from the source store. + */ + public function migrate( $source_action_id ) { + try { + $action = $this->source->fetch_action( $source_action_id ); + $status = $this->source->get_status( $source_action_id ); + } catch ( \Exception $e ) { + $action = null; + $status = ''; + } + + if ( is_null( $action ) || empty( $status ) || ! $action->get_schedule()->get_date() ) { + // null action or empty status means the fetch operation failed or the action didn't exist. + // null schedule means it's missing vital data. + // delete it and move on. + try { + $this->source->delete_action( $source_action_id ); + } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + // nothing to do, it didn't exist in the first place. + } + do_action( 'action_scheduler/no_action_to_migrate', $source_action_id, $this->source, $this->destination ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + return 0; + } + + try { + + // Make sure the last attempt date is set correctly for completed and failed actions. + $last_attempt_date = ( \ActionScheduler_Store::STATUS_PENDING !== $status ) ? $this->source->get_date( $source_action_id ) : null; + + $destination_action_id = $this->destination->save_action( $action, null, $last_attempt_date ); + } catch ( \Exception $e ) { + do_action( 'action_scheduler/migrate_action_failed', $source_action_id, $this->source, $this->destination ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + return 0; // could not save the action in the new store. + } + + try { + switch ( $status ) { + case \ActionScheduler_Store::STATUS_FAILED: + $this->destination->mark_failure( $destination_action_id ); + break; + case \ActionScheduler_Store::STATUS_CANCELED: + $this->destination->cancel_action( $destination_action_id ); + break; + } + + $this->log_migrator->migrate( $source_action_id, $destination_action_id ); + $this->source->delete_action( $source_action_id ); + + $test_action = $this->source->fetch_action( $source_action_id ); + if ( ! is_a( $test_action, 'ActionScheduler_NullAction' ) ) { + // translators: %s is an action ID. + throw new \RuntimeException( sprintf( __( 'Unable to remove source migrated action %s', 'action-scheduler' ), $source_action_id ) ); + } + do_action( 'action_scheduler/migrated_action', $source_action_id, $destination_action_id, $this->source, $this->destination ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + return $destination_action_id; + } catch ( \Exception $e ) { + // could not delete from the old store. + $this->source->mark_migrated( $source_action_id ); + + // phpcs:disable WordPress.NamingConventions.ValidHookName.UseUnderscores + do_action( 'action_scheduler/migrate_action_incomplete', $source_action_id, $destination_action_id, $this->source, $this->destination ); + do_action( 'action_scheduler/migrated_action', $source_action_id, $destination_action_id, $this->source, $this->destination ); + // phpcs:enable + + return $destination_action_id; + } + } +} diff --git a/classes/migration/ActionScheduler_DBStoreMigrator.php b/classes/migration/ActionScheduler_DBStoreMigrator.php new file mode 100644 index 0000000..66ab997 --- /dev/null +++ b/classes/migration/ActionScheduler_DBStoreMigrator.php @@ -0,0 +1,52 @@ + $this->get_scheduled_date_string( $action, $last_attempt_date ), + 'last_attempt_local' => $this->get_scheduled_date_string_local( $action, $last_attempt_date ), + ); + + $wpdb->update( $wpdb->actionscheduler_actions, $data, array( 'action_id' => $action_id ), array( '%s', '%s' ), array( '%d' ) ); + } + + return $action_id; + } catch ( \Exception $e ) { + // translators: %s is an error message. + throw new \RuntimeException( sprintf( __( 'Error saving action: %s', 'action-scheduler' ), $e->getMessage() ), 0 ); + } + } +} diff --git a/classes/migration/BatchFetcher.php b/classes/migration/BatchFetcher.php new file mode 100644 index 0000000..20aa3d0 --- /dev/null +++ b/classes/migration/BatchFetcher.php @@ -0,0 +1,95 @@ +store = $source_store; + } + + /** + * Retrieve a list of actions. + * + * @param int $count The number of actions to retrieve. + * + * @return int[] A list of action IDs + */ + public function fetch( $count = 10 ) { + foreach ( $this->get_query_strategies( $count ) as $query ) { + $action_ids = $this->store->query_actions( $query ); + if ( ! empty( $action_ids ) ) { + return $action_ids; + } + } + + return array(); + } + + /** + * Generate a list of prioritized of action search parameters. + * + * @param int $count Number of actions to find. + * + * @return array + */ + private function get_query_strategies( $count ) { + $now = as_get_datetime_object(); + $args = array( + 'date' => $now, + 'per_page' => $count, + 'offset' => 0, + 'orderby' => 'date', + 'order' => 'ASC', + ); + + $priorities = array( + Store::STATUS_PENDING, + Store::STATUS_FAILED, + Store::STATUS_CANCELED, + Store::STATUS_COMPLETE, + Store::STATUS_RUNNING, + '', // any other unanticipated status. + ); + + foreach ( $priorities as $status ) { + yield wp_parse_args( + array( + 'status' => $status, + 'date_compare' => '<=', + ), + $args + ); + + yield wp_parse_args( + array( + 'status' => $status, + 'date_compare' => '>=', + ), + $args + ); + } + } +} diff --git a/classes/migration/Config.php b/classes/migration/Config.php new file mode 100644 index 0000000..f16b4c1 --- /dev/null +++ b/classes/migration/Config.php @@ -0,0 +1,196 @@ +source_store ) ) { + throw new \RuntimeException( __( 'Source store must be configured before running a migration', 'action-scheduler' ) ); + } + + return $this->source_store; + } + + /** + * Set the configured source store. + * + * @param ActionScheduler_Store $store Source store object. + */ + public function set_source_store( Store $store ) { + $this->source_store = $store; + } + + /** + * Get the configured source logger. + * + * @return ActionScheduler_Logger + * @throws \RuntimeException When source logger is not configured. + */ + public function get_source_logger() { + if ( empty( $this->source_logger ) ) { + throw new \RuntimeException( __( 'Source logger must be configured before running a migration', 'action-scheduler' ) ); + } + + return $this->source_logger; + } + + /** + * Set the configured source logger. + * + * @param ActionScheduler_Logger $logger Logger object. + */ + public function set_source_logger( Logger $logger ) { + $this->source_logger = $logger; + } + + /** + * Get the configured destination store. + * + * @return ActionScheduler_Store + * @throws \RuntimeException When destination store is not configured. + */ + public function get_destination_store() { + if ( empty( $this->destination_store ) ) { + throw new \RuntimeException( __( 'Destination store must be configured before running a migration', 'action-scheduler' ) ); + } + + return $this->destination_store; + } + + /** + * Set the configured destination store. + * + * @param ActionScheduler_Store $store Action store object. + */ + public function set_destination_store( Store $store ) { + $this->destination_store = $store; + } + + /** + * Get the configured destination logger. + * + * @return ActionScheduler_Logger + * @throws \RuntimeException When destination logger is not configured. + */ + public function get_destination_logger() { + if ( empty( $this->destination_logger ) ) { + throw new \RuntimeException( __( 'Destination logger must be configured before running a migration', 'action-scheduler' ) ); + } + + return $this->destination_logger; + } + + /** + * Set the configured destination logger. + * + * @param ActionScheduler_Logger $logger Logger object. + */ + public function set_destination_logger( Logger $logger ) { + $this->destination_logger = $logger; + } + + /** + * Get flag indicating whether it's a dry run. + * + * @return bool + */ + public function get_dry_run() { + return $this->dry_run; + } + + /** + * Set flag indicating whether it's a dry run. + * + * @param bool $dry_run Dry run toggle. + */ + public function set_dry_run( $dry_run ) { + $this->dry_run = (bool) $dry_run; + } + + /** + * Get progress bar object. + * + * @return ActionScheduler\WPCLI\ProgressBar + */ + public function get_progress_bar() { + return $this->progress_bar; + } + + /** + * Set progress bar object. + * + * @param ActionScheduler\WPCLI\ProgressBar $progress_bar Progress bar object. + */ + public function set_progress_bar( ProgressBar $progress_bar ) { + $this->progress_bar = $progress_bar; + } +} diff --git a/classes/migration/Controller.php b/classes/migration/Controller.php new file mode 100644 index 0000000..7c3df60 --- /dev/null +++ b/classes/migration/Controller.php @@ -0,0 +1,245 @@ +migration_scheduler = $migration_scheduler; + $this->store_classname = ''; + } + + /** + * Set the action store class name. + * + * @param string $class Classname of the store class. + * + * @return string + */ + public function get_store_class( $class ) { + if ( \ActionScheduler_DataController::is_migration_complete() ) { + return \ActionScheduler_DataController::DATASTORE_CLASS; + } elseif ( \ActionScheduler_Store::DEFAULT_CLASS !== $class ) { + $this->store_classname = $class; + return $class; + } else { + return 'ActionScheduler_HybridStore'; + } + } + + /** + * Set the action logger class name. + * + * @param string $class Classname of the logger class. + * + * @return string + */ + public function get_logger_class( $class ) { + \ActionScheduler_Store::instance(); + + if ( $this->has_custom_datastore() ) { + $this->logger_classname = $class; + return $class; + } else { + return \ActionScheduler_DataController::LOGGER_CLASS; + } + } + + /** + * Get flag indicating whether a custom datastore is in use. + * + * @return bool + */ + public function has_custom_datastore() { + return (bool) $this->store_classname; + } + + /** + * Set up the background migration process. + * + * @return void + */ + public function schedule_migration() { + $logging_tables = new ActionScheduler_LoggerSchema(); + $store_tables = new ActionScheduler_StoreSchema(); + + /* + * In some unusual cases, the expected tables may not have been created. In such cases + * we do not schedule a migration as doing so will lead to fatal error conditions. + * + * In such cases the user will likely visit the Tools > Scheduled Actions screen to + * investigate, and will see appropriate messaging (this step also triggers an attempt + * to rebuild any missing tables). + * + * @see https://github.com/woocommerce/action-scheduler/issues/653 + */ + if ( + ActionScheduler_DataController::is_migration_complete() + || $this->migration_scheduler->is_migration_scheduled() + || ! $store_tables->tables_exist() + || ! $logging_tables->tables_exist() + ) { + return; + } + + $this->migration_scheduler->schedule_migration(); + } + + /** + * Get the default migration config object + * + * @return ActionScheduler\Migration\Config + */ + public function get_migration_config_object() { + static $config = null; + + if ( ! $config ) { + $source_store = $this->store_classname ? new $this->store_classname() : new \ActionScheduler_wpPostStore(); + $source_logger = $this->logger_classname ? new $this->logger_classname() : new \ActionScheduler_wpCommentLogger(); + + $config = new Config(); + $config->set_source_store( $source_store ); + $config->set_source_logger( $source_logger ); + $config->set_destination_store( new \ActionScheduler_DBStoreMigrator() ); + $config->set_destination_logger( new \ActionScheduler_DBLogger() ); + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + $config->set_progress_bar( new ProgressBar( '', 0 ) ); + } + } + + return apply_filters( 'action_scheduler/migration_config', $config ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + } + + /** + * Hook dashboard migration notice. + */ + public function hook_admin_notices() { + if ( ! $this->allow_migration() || \ActionScheduler_DataController::is_migration_complete() ) { + return; + } + add_action( 'admin_notices', array( $this, 'display_migration_notice' ), 10, 0 ); + } + + /** + * Show a dashboard notice that migration is in progress. + */ + public function display_migration_notice() { + printf( '

    %s

    ', esc_html__( 'Action Scheduler migration in progress. The list of scheduled actions may be incomplete.', 'action-scheduler' ) ); + } + + /** + * Add store classes. Hook migration. + */ + private function hook() { + add_filter( 'action_scheduler_store_class', array( $this, 'get_store_class' ), 100, 1 ); + add_filter( 'action_scheduler_logger_class', array( $this, 'get_logger_class' ), 100, 1 ); + add_action( 'init', array( $this, 'maybe_hook_migration' ) ); + add_action( 'wp_loaded', array( $this, 'schedule_migration' ) ); + + // Action Scheduler may be displayed as a Tools screen or WooCommerce > Status administration screen. + add_action( 'load-tools_page_action-scheduler', array( $this, 'hook_admin_notices' ), 10, 0 ); + add_action( 'load-woocommerce_page_wc-status', array( $this, 'hook_admin_notices' ), 10, 0 ); + } + + /** + * Possibly hook the migration scheduler action. + */ + public function maybe_hook_migration() { + if ( ! $this->allow_migration() || \ActionScheduler_DataController::is_migration_complete() ) { + return; + } + + $this->migration_scheduler->hook(); + } + + /** + * Allow datastores to enable migration to AS tables. + */ + public function allow_migration() { + if ( ! \ActionScheduler_DataController::dependencies_met() ) { + return false; + } + + if ( null === $this->migrate_custom_store ) { + $this->migrate_custom_store = apply_filters( 'action_scheduler_migrate_data_store', false ); + } + + return ( ! $this->has_custom_datastore() ) || $this->migrate_custom_store; + } + + /** + * Proceed with the migration if the dependencies have been met. + */ + public static function init() { + if ( \ActionScheduler_DataController::dependencies_met() ) { + self::instance()->hook(); + } + } + + /** + * Singleton factory. + */ + public static function instance() { + if ( ! isset( self::$instance ) ) { + self::$instance = new static( new Scheduler() ); + } + + return self::$instance; + } +} diff --git a/classes/migration/DryRun_ActionMigrator.php b/classes/migration/DryRun_ActionMigrator.php new file mode 100644 index 0000000..ab7d670 --- /dev/null +++ b/classes/migration/DryRun_ActionMigrator.php @@ -0,0 +1,28 @@ +source = $source_logger; + $this->destination = $destination_logger; + } + + /** + * Migrate an action log. + * + * @param int $source_action_id Source logger object. + * @param int $destination_action_id Destination logger object. + */ + public function migrate( $source_action_id, $destination_action_id ) { + $logs = $this->source->get_logs( $source_action_id ); + + foreach ( $logs as $log ) { + if ( absint( $log->get_action_id() ) === absint( $source_action_id ) ) { + $this->destination->log( $destination_action_id, $log->get_message(), $log->get_date() ); + } + } + } +} diff --git a/classes/migration/Runner.php b/classes/migration/Runner.php new file mode 100644 index 0000000..85c7e04 --- /dev/null +++ b/classes/migration/Runner.php @@ -0,0 +1,171 @@ +source_store = $config->get_source_store(); + $this->destination_store = $config->get_destination_store(); + $this->source_logger = $config->get_source_logger(); + $this->destination_logger = $config->get_destination_logger(); + + $this->batch_fetcher = new BatchFetcher( $this->source_store ); + if ( $config->get_dry_run() ) { + $this->log_migrator = new DryRun_LogMigrator( $this->source_logger, $this->destination_logger ); + $this->action_migrator = new DryRun_ActionMigrator( $this->source_store, $this->destination_store, $this->log_migrator ); + } else { + $this->log_migrator = new LogMigrator( $this->source_logger, $this->destination_logger ); + $this->action_migrator = new ActionMigrator( $this->source_store, $this->destination_store, $this->log_migrator ); + } + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + $this->progress_bar = $config->get_progress_bar(); + } + } + + /** + * Run migration batch. + * + * @param int $batch_size Optional batch size. Default 10. + * + * @return int Size of batch processed. + */ + public function run( $batch_size = 10 ) { + $batch = $this->batch_fetcher->fetch( $batch_size ); + $batch_size = count( $batch ); + + if ( ! $batch_size ) { + return 0; + } + + if ( $this->progress_bar ) { + /* translators: %d: amount of actions */ + $this->progress_bar->set_message( sprintf( _n( 'Migrating %d action', 'Migrating %d actions', $batch_size, 'action-scheduler' ), $batch_size ) ); + $this->progress_bar->set_count( $batch_size ); + } + + $this->migrate_actions( $batch ); + + return $batch_size; + } + + /** + * Migration a batch of actions. + * + * @param array $action_ids List of action IDs to migrate. + */ + public function migrate_actions( array $action_ids ) { + do_action( 'action_scheduler/migration_batch_starting', $action_ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + \ActionScheduler::logger()->unhook_stored_action(); + $this->destination_logger->unhook_stored_action(); + + foreach ( $action_ids as $source_action_id ) { + $destination_action_id = $this->action_migrator->migrate( $source_action_id ); + if ( $destination_action_id ) { + $this->destination_logger->log( + $destination_action_id, + sprintf( + /* translators: 1: source action ID 2: source store class 3: destination action ID 4: destination store class */ + __( 'Migrated action with ID %1$d in %2$s to ID %3$d in %4$s', 'action-scheduler' ), + $source_action_id, + get_class( $this->source_store ), + $destination_action_id, + get_class( $this->destination_store ) + ) + ); + } + + if ( $this->progress_bar ) { + $this->progress_bar->tick(); + } + } + + if ( $this->progress_bar ) { + $this->progress_bar->finish(); + } + + \ActionScheduler::logger()->hook_stored_action(); + + do_action( 'action_scheduler/migration_batch_complete', $action_ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + } + + /** + * Initialize destination store and logger. + */ + public function init_destination() { + $this->destination_store->init(); + $this->destination_logger->init(); + } +} diff --git a/classes/migration/Scheduler.php b/classes/migration/Scheduler.php new file mode 100644 index 0000000..d6c320d --- /dev/null +++ b/classes/migration/Scheduler.php @@ -0,0 +1,128 @@ +get_migration_runner(); + $count = $migration_runner->run( $this->get_batch_size() ); + + if ( 0 === $count ) { + $this->mark_complete(); + } else { + $this->schedule_migration( time() + $this->get_schedule_interval() ); + } + } + + /** + * Mark the migration complete. + */ + public function mark_complete() { + $this->unschedule_migration(); + + \ActionScheduler_DataController::mark_migration_complete(); + do_action( 'action_scheduler/migration_complete' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + } + + /** + * Get a flag indicating whether the migration is scheduled. + * + * @return bool Whether there is a pending action in the store to handle the migration + */ + public function is_migration_scheduled() { + $next = as_next_scheduled_action( self::HOOK ); + + return ! empty( $next ); + } + + /** + * Schedule the migration. + * + * @param int $when Optional timestamp to run the next migration batch. Defaults to now. + * + * @return string The action ID + */ + public function schedule_migration( $when = 0 ) { + $next = as_next_scheduled_action( self::HOOK ); + + if ( ! empty( $next ) ) { + return $next; + } + + if ( empty( $when ) ) { + $when = time() + MINUTE_IN_SECONDS; + } + + return as_schedule_single_action( $when, self::HOOK, array(), self::GROUP ); + } + + /** + * Remove the scheduled migration action. + */ + public function unschedule_migration() { + as_unschedule_action( self::HOOK, null, self::GROUP ); + } + + /** + * Get migration batch schedule interval. + * + * @return int Seconds between migration runs. Defaults to 0 seconds to allow chaining migration via Async Runners. + */ + private function get_schedule_interval() { + return (int) apply_filters( 'action_scheduler/migration_interval', 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + } + + /** + * Get migration batch size. + * + * @return int Number of actions to migrate in each batch. Defaults to 250. + */ + private function get_batch_size() { + return (int) apply_filters( 'action_scheduler/migration_batch_size', 250 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + } + + /** + * Get migration runner object. + * + * @return Runner + */ + private function get_migration_runner() { + $config = Controller::instance()->get_migration_config_object(); + + return new Runner( $config ); + } + +} diff --git a/classes/schedules/ActionScheduler_CanceledSchedule.php b/classes/schedules/ActionScheduler_CanceledSchedule.php new file mode 100644 index 0000000..4f90c83 --- /dev/null +++ b/classes/schedules/ActionScheduler_CanceledSchedule.php @@ -0,0 +1,63 @@ +__wakeup() for details. + * + * @var null + */ + private $timestamp = null; + + /** + * Calculate when the next instance of this schedule would run based on a given date & time. + * + * @param DateTime $after Timestamp. + * + * @return DateTime|null + */ + public function calculate_next( DateTime $after ) { + return null; + } + + /** + * Cancelled actions should never have a next schedule, even if get_next() + * is called with $after < $this->scheduled_date. + * + * @param DateTime $after Timestamp. + * @return DateTime|null + */ + public function get_next( DateTime $after ) { + return null; + } + + /** + * Action is not recurring. + * + * @return bool + */ + public function is_recurring() { + return false; + } + + /** + * Unserialize recurring schedules serialized/stored prior to AS 3.0.0 + * + * Prior to Action Scheduler 3.0.0, schedules used different property names to refer + * to equivalent data. For example, ActionScheduler_IntervalSchedule::start_timestamp + * was the same as ActionScheduler_SimpleSchedule::timestamp. Action Scheduler 3.0.0 + * aligned properties and property names for better inheritance. To maintain backward + * compatibility with schedules serialized and stored prior to 3.0, we need to correctly + * map the old property names with matching visibility. + */ + public function __wakeup() { + if ( ! is_null( $this->timestamp ) ) { + $this->scheduled_timestamp = $this->timestamp; + unset( $this->timestamp ); + } + parent::__wakeup(); + } +} diff --git a/classes/schedules/ActionScheduler_CronSchedule.php b/classes/schedules/ActionScheduler_CronSchedule.php new file mode 100644 index 0000000..6ac43c9 --- /dev/null +++ b/classes/schedules/ActionScheduler_CronSchedule.php @@ -0,0 +1,111 @@ +__wakeup() for details. + * + * @var null + */ + private $start_timestamp = null; + + /** + * Deprecated property @see $this->__wakeup() for details. + * + * @var null + */ + private $cron = null; + + /** + * Wrapper for parent constructor to accept a cron expression string and map it to a CronExpression for this + * objects $recurrence property. + * + * @param DateTime $start The date & time to run the action at or after. If $start aligns with the CronSchedule passed via $recurrence, it will be used. If it does not align, the first matching date after it will be used. + * @param CronExpression|string $recurrence The CronExpression used to calculate the schedule's next instance. + * @param DateTime|null $first (Optional) The date & time the first instance of this interval schedule ran. Default null, meaning this is the first instance. + */ + public function __construct( DateTime $start, $recurrence, ?DateTime $first = null ) { + if ( ! is_a( $recurrence, 'CronExpression' ) ) { + $recurrence = CronExpression::factory( $recurrence ); + } + + // For backward compatibility, we need to make sure the date is set to the first matching cron date, not whatever date is passed in. Importantly, by passing true as the 3rd param, if $start matches the cron expression, then it will be used. This was previously handled in the now deprecated next() method. + $date = $recurrence->getNextRunDate( $start, 0, true ); + + // parent::__construct() will set this to $date by default, but that may be different to $start now. + $first = empty( $first ) ? $start : $first; + + parent::__construct( $date, $recurrence, $first ); + } + + /** + * Calculate when an instance of this schedule would start based on a given + * date & time using its the CronExpression. + * + * @param DateTime $after Timestamp. + * @return DateTime + */ + protected function calculate_next( DateTime $after ) { + return $this->recurrence->getNextRunDate( $after, 0, false ); + } + + /** + * Get the schedule's recurrence. + * + * @return string + */ + public function get_recurrence() { + return strval( $this->recurrence ); + } + + /** + * Serialize cron schedules with data required prior to AS 3.0.0 + * + * Prior to Action Scheduler 3.0.0, recurring schedules used different property names to + * refer to equivalent data. For example, ActionScheduler_IntervalSchedule::start_timestamp + * was the same as ActionScheduler_SimpleSchedule::timestamp. Action Scheduler 3.0.0 + * aligned properties and property names for better inheritance. To guard against the + * possibility of infinite loops if downgrading to Action Scheduler < 3.0.0, we need to + * also store the data with the old property names so if it's unserialized in AS < 3.0, + * the schedule doesn't end up with a null recurrence. + * + * @return array + */ + public function __sleep() { + + $sleep_params = parent::__sleep(); + + $this->start_timestamp = $this->scheduled_timestamp; + $this->cron = $this->recurrence; + + return array_merge( + $sleep_params, + array( + 'start_timestamp', + 'cron', + ) + ); + } + + /** + * Unserialize cron schedules serialized/stored prior to AS 3.0.0 + * + * For more background, @see ActionScheduler_Abstract_RecurringSchedule::__wakeup(). + */ + public function __wakeup() { + if ( is_null( $this->scheduled_timestamp ) && ! is_null( $this->start_timestamp ) ) { + $this->scheduled_timestamp = $this->start_timestamp; + unset( $this->start_timestamp ); + } + + if ( is_null( $this->recurrence ) && ! is_null( $this->cron ) ) { + $this->recurrence = $this->cron; + unset( $this->cron ); + } + parent::__wakeup(); + } +} + diff --git a/classes/schedules/ActionScheduler_IntervalSchedule.php b/classes/schedules/ActionScheduler_IntervalSchedule.php new file mode 100644 index 0000000..228ef4c --- /dev/null +++ b/classes/schedules/ActionScheduler_IntervalSchedule.php @@ -0,0 +1,90 @@ +__wakeup() for details. + * + * @var null + */ + private $start_timestamp = null; + + /** + * Deprecated property @see $this->__wakeup() for details. + * + * @var null + */ + private $interval_in_seconds = null; + + /** + * Calculate when this schedule should start after a given date & time using + * the number of seconds between recurrences. + * + * @param DateTime $after Timestamp. + * @return DateTime + */ + protected function calculate_next( DateTime $after ) { + $after->modify( '+' . (int) $this->get_recurrence() . ' seconds' ); + return $after; + } + + /** + * Schedule interval in seconds. + * + * @return int + */ + public function interval_in_seconds() { + _deprecated_function( __METHOD__, '3.0.0', '(int)ActionScheduler_Abstract_RecurringSchedule::get_recurrence()' ); + return (int) $this->get_recurrence(); + } + + /** + * Serialize interval schedules with data required prior to AS 3.0.0 + * + * Prior to Action Scheduler 3.0.0, recurring schedules used different property names to + * refer to equivalent data. For example, ActionScheduler_IntervalSchedule::start_timestamp + * was the same as ActionScheduler_SimpleSchedule::timestamp. Action Scheduler 3.0.0 + * aligned properties and property names for better inheritance. To guard against the + * possibility of infinite loops if downgrading to Action Scheduler < 3.0.0, we need to + * also store the data with the old property names so if it's unserialized in AS < 3.0, + * the schedule doesn't end up with a null/false/0 recurrence. + * + * @return array + */ + public function __sleep() { + + $sleep_params = parent::__sleep(); + + $this->start_timestamp = $this->scheduled_timestamp; + $this->interval_in_seconds = $this->recurrence; + + return array_merge( + $sleep_params, + array( + 'start_timestamp', + 'interval_in_seconds', + ) + ); + } + + /** + * Unserialize interval schedules serialized/stored prior to AS 3.0.0 + * + * For more background, @see ActionScheduler_Abstract_RecurringSchedule::__wakeup(). + */ + public function __wakeup() { + if ( is_null( $this->scheduled_timestamp ) && ! is_null( $this->start_timestamp ) ) { + $this->scheduled_timestamp = $this->start_timestamp; + unset( $this->start_timestamp ); + } + + if ( is_null( $this->recurrence ) && ! is_null( $this->interval_in_seconds ) ) { + $this->recurrence = $this->interval_in_seconds; + unset( $this->interval_in_seconds ); + } + parent::__wakeup(); + } +} diff --git a/classes/schedules/ActionScheduler_NullSchedule.php b/classes/schedules/ActionScheduler_NullSchedule.php new file mode 100644 index 0000000..77c7c4e --- /dev/null +++ b/classes/schedules/ActionScheduler_NullSchedule.php @@ -0,0 +1,39 @@ +scheduled_date = null; + } + + /** + * This schedule has no scheduled DateTime, so we need to override the parent __sleep(). + * + * @return array + */ + public function __sleep() { + return array(); + } + + /** + * Wakeup. + */ + public function __wakeup() { + $this->scheduled_date = null; + } +} diff --git a/classes/schedules/ActionScheduler_Schedule.php b/classes/schedules/ActionScheduler_Schedule.php new file mode 100644 index 0000000..e3803e1 --- /dev/null +++ b/classes/schedules/ActionScheduler_Schedule.php @@ -0,0 +1,22 @@ +__wakeup() for details. + * + * @var null|DateTime + */ + private $timestamp = null; + + /** + * Calculate when this schedule should start after a given date & time using + * the number of seconds between recurrences. + * + * @param DateTime $after Timestamp. + * + * @return DateTime|null + */ + public function calculate_next( DateTime $after ) { + return null; + } + + /** + * Schedule is not recurring. + * + * @return bool + */ + public function is_recurring() { + return false; + } + + /** + * Serialize schedule with data required prior to AS 3.0.0 + * + * Prior to Action Scheduler 3.0.0, schedules used different property names to refer + * to equivalent data. For example, ActionScheduler_IntervalSchedule::start_timestamp + * was the same as ActionScheduler_SimpleSchedule::timestamp. Action Scheduler 3.0.0 + * aligned properties and property names for better inheritance. To guard against the + * scheduled date for single actions always being seen as "now" if downgrading to + * Action Scheduler < 3.0.0, we need to also store the data with the old property names + * so if it's unserialized in AS < 3.0, the schedule doesn't end up with a null recurrence. + * + * @return array + */ + public function __sleep() { + + $sleep_params = parent::__sleep(); + + $this->timestamp = $this->scheduled_timestamp; + + return array_merge( + $sleep_params, + array( + 'timestamp', + ) + ); + } + + /** + * Unserialize recurring schedules serialized/stored prior to AS 3.0.0 + * + * Prior to Action Scheduler 3.0.0, schedules used different property names to refer + * to equivalent data. For example, ActionScheduler_IntervalSchedule::start_timestamp + * was the same as ActionScheduler_SimpleSchedule::timestamp. Action Scheduler 3.0.0 + * aligned properties and property names for better inheritance. To maintain backward + * compatibility with schedules serialized and stored prior to 3.0, we need to correctly + * map the old property names with matching visibility. + */ + public function __wakeup() { + + if ( is_null( $this->scheduled_timestamp ) && ! is_null( $this->timestamp ) ) { + $this->scheduled_timestamp = $this->timestamp; + unset( $this->timestamp ); + } + parent::__wakeup(); + } +} diff --git a/classes/schema/ActionScheduler_LoggerSchema.php b/classes/schema/ActionScheduler_LoggerSchema.php new file mode 100644 index 0000000..27e1c7f --- /dev/null +++ b/classes/schema/ActionScheduler_LoggerSchema.php @@ -0,0 +1,101 @@ +tables = array( + self::LOG_TABLE, + ); + } + + /** + * Performs additional setup work required to support this schema. + */ + public function init() { + add_action( 'action_scheduler_before_schema_update', array( $this, 'update_schema_3_0' ), 10, 2 ); + } + + /** + * Get table definition. + * + * @param string $table Table name. + */ + protected function get_table_definition( $table ) { + global $wpdb; + $table_name = $wpdb->$table; + $charset_collate = $wpdb->get_charset_collate(); + switch ( $table ) { + + case self::LOG_TABLE: + $default_date = ActionScheduler_StoreSchema::DEFAULT_DATE; + return "CREATE TABLE $table_name ( + log_id bigint(20) unsigned NOT NULL auto_increment, + action_id bigint(20) unsigned NOT NULL, + message text NOT NULL, + log_date_gmt datetime NULL default '{$default_date}', + log_date_local datetime NULL default '{$default_date}', + PRIMARY KEY (log_id), + KEY action_id (action_id), + KEY log_date_gmt (log_date_gmt) + ) $charset_collate"; + + default: + return ''; + } + } + + /** + * Update the logs table schema, allowing datetime fields to be NULL. + * + * This is needed because the NOT NULL constraint causes a conflict with some versions of MySQL + * configured with sql_mode=NO_ZERO_DATE, which can for instance lead to tables not being created. + * + * Most other schema updates happen via ActionScheduler_Abstract_Schema::update_table(), however + * that method relies on dbDelta() and this change is not possible when using that function. + * + * @param string $table Name of table being updated. + * @param string $db_version The existing schema version of the table. + */ + public function update_schema_3_0( $table, $db_version ) { + global $wpdb; + + if ( 'actionscheduler_logs' !== $table || version_compare( $db_version, '3', '>=' ) ) { + return; + } + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $table_name = $wpdb->prefix . 'actionscheduler_logs'; + $table_list = $wpdb->get_col( "SHOW TABLES LIKE '{$table_name}'" ); + $default_date = ActionScheduler_StoreSchema::DEFAULT_DATE; + + if ( ! empty( $table_list ) ) { + $query = " + ALTER TABLE {$table_name} + MODIFY COLUMN log_date_gmt datetime NULL default '{$default_date}', + MODIFY COLUMN log_date_local datetime NULL default '{$default_date}' + "; + $wpdb->query( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +} diff --git a/classes/schema/ActionScheduler_StoreSchema.php b/classes/schema/ActionScheduler_StoreSchema.php new file mode 100644 index 0000000..37df226 --- /dev/null +++ b/classes/schema/ActionScheduler_StoreSchema.php @@ -0,0 +1,143 @@ +tables = array( + self::ACTIONS_TABLE, + self::CLAIMS_TABLE, + self::GROUPS_TABLE, + ); + } + + /** + * Performs additional setup work required to support this schema. + */ + public function init() { + add_action( 'action_scheduler_before_schema_update', array( $this, 'update_schema_5_0' ), 10, 2 ); + } + + /** + * Get table definition. + * + * @param string $table Table name. + */ + protected function get_table_definition( $table ) { + global $wpdb; + $table_name = $wpdb->$table; + $charset_collate = $wpdb->get_charset_collate(); + $default_date = self::DEFAULT_DATE; + // phpcs:ignore Squiz.PHP.CommentedOutCode + $max_index_length = 191; // @see wp_get_db_schema() + + $hook_status_scheduled_date_gmt_max_index_length = $max_index_length - 20 - 8; // - status, - scheduled_date_gmt + + switch ( $table ) { + + case self::ACTIONS_TABLE: + return "CREATE TABLE {$table_name} ( + action_id bigint(20) unsigned NOT NULL auto_increment, + hook varchar(191) NOT NULL, + status varchar(20) NOT NULL, + scheduled_date_gmt datetime NULL default '{$default_date}', + scheduled_date_local datetime NULL default '{$default_date}', + priority tinyint unsigned NOT NULL default '10', + args varchar($max_index_length), + schedule longtext, + group_id bigint(20) unsigned NOT NULL default '0', + attempts int(11) NOT NULL default '0', + last_attempt_gmt datetime NULL default '{$default_date}', + last_attempt_local datetime NULL default '{$default_date}', + claim_id bigint(20) unsigned NOT NULL default '0', + extended_args varchar(8000) DEFAULT NULL, + PRIMARY KEY (action_id), + KEY hook_status_scheduled_date_gmt (hook($hook_status_scheduled_date_gmt_max_index_length), status, scheduled_date_gmt), + KEY status_scheduled_date_gmt (status, scheduled_date_gmt), + KEY scheduled_date_gmt (scheduled_date_gmt), + KEY args (args($max_index_length)), + KEY group_id (group_id), + KEY last_attempt_gmt (last_attempt_gmt), + KEY `claim_id_status_scheduled_date_gmt` (`claim_id`, `status`, `scheduled_date_gmt`) + ) $charset_collate"; + + case self::CLAIMS_TABLE: + return "CREATE TABLE {$table_name} ( + claim_id bigint(20) unsigned NOT NULL auto_increment, + date_created_gmt datetime NULL default '{$default_date}', + PRIMARY KEY (claim_id), + KEY date_created_gmt (date_created_gmt) + ) $charset_collate"; + + case self::GROUPS_TABLE: + return "CREATE TABLE {$table_name} ( + group_id bigint(20) unsigned NOT NULL auto_increment, + slug varchar(255) NOT NULL, + PRIMARY KEY (group_id), + KEY slug (slug($max_index_length)) + ) $charset_collate"; + + default: + return ''; + } + } + + /** + * Update the actions table schema, allowing datetime fields to be NULL. + * + * This is needed because the NOT NULL constraint causes a conflict with some versions of MySQL + * configured with sql_mode=NO_ZERO_DATE, which can for instance lead to tables not being created. + * + * Most other schema updates happen via ActionScheduler_Abstract_Schema::update_table(), however + * that method relies on dbDelta() and this change is not possible when using that function. + * + * @param string $table Name of table being updated. + * @param string $db_version The existing schema version of the table. + */ + public function update_schema_5_0( $table, $db_version ) { + global $wpdb; + + if ( 'actionscheduler_actions' !== $table || version_compare( $db_version, '5', '>=' ) ) { + return; + } + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $table_name = $wpdb->prefix . 'actionscheduler_actions'; + $table_list = $wpdb->get_col( "SHOW TABLES LIKE '{$table_name}'" ); + $default_date = self::DEFAULT_DATE; + + if ( ! empty( $table_list ) ) { + $query = " + ALTER TABLE {$table_name} + MODIFY COLUMN scheduled_date_gmt datetime NULL default '{$default_date}', + MODIFY COLUMN scheduled_date_local datetime NULL default '{$default_date}', + MODIFY COLUMN last_attempt_gmt datetime NULL default '{$default_date}', + MODIFY COLUMN last_attempt_local datetime NULL default '{$default_date}' + "; + $wpdb->query( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..fe69f96 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,13 @@ +codecov: + branch: master + +coverage: + ignore: + - tests/.* + - lib/.* + status: + project: false + patch: false + changes: false + +comment: false diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1050604 --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "woocommerce/action-scheduler", + "description": "Action Scheduler for WordPress and WooCommerce", + "homepage": "https://actionscheduler.org/", + "type": "wordpress-plugin", + "license": "GPL-3.0-or-later", + "prefer-stable": true, + "minimum-stability": "dev", + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.5", + "wp-cli/wp-cli": "~2.5.0", + "woocommerce/woocommerce-sniffs": "0.1.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "platform": { + "php": "7.1" + } + }, + "archive": { + "exclude": [ + "node_modules" + ] + }, + "scripts": { + "test": [ + "./vendor/bin/phpunit tests -c tests/phpunit.xml.dist" + ], + "phpcs": [ + "phpcs -s -p" + ], + "phpcs-pre-commit": [ + "phpcs -s -p -n" + ], + "phpcbf": [ + "phpcbf -p" + ] + }, + "extra": { + "scripts-description": { + "test": "Run unit tests", + "phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer", + "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..3421363 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2561 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "81fd3951ccfed0a222e6f4b949998df1", + "packages": [], + "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v0.7.0", + "source": { + "type": "git", + "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git", + "reference": "e8d808670b8f882188368faaf1144448c169c0b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/e8d808670b8f882188368faaf1144448c169c0b7", + "reference": "e8d808670b8f882188368faaf1144448c169c0b7", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2 || ^3 || 4.0.x-dev" + }, + "require-dev": { + "composer/composer": "*", + "phpcompatibility/php-compatibility": "^9.0", + "sensiolabs/security-checker": "^4.1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "franck.nijhof@dealerdirect.com", + "homepage": "http://www.frenck.nl", + "role": "Developer / IT Manager" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "homepage": "http://www.dealerdirect.com", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues", + "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer" + }, + "time": "2020-06-25T14:57:39+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:15:36+00:00" + }, + { + "name": "mustache/mustache", + "version": "v2.14.2", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/mustache.php.git", + "reference": "e62b7c3849d22ec55f3ec425507bf7968193a6cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/mustache.php/zipball/e62b7c3849d22ec55f3ec425507bf7968193a6cb", + "reference": "e62b7c3849d22ec55f3ec425507bf7968193a6cb", + "shasum": "" + }, + "require": { + "php": ">=5.2.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~1.11", + "phpunit/phpunit": "~3.7|~4.0|~5.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Mustache": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" + } + ], + "description": "A Mustache implementation in PHP.", + "homepage": "https://github.com/bobthecow/mustache.php", + "keywords": [ + "mustache", + "templating" + ], + "support": { + "issues": "https://github.com/bobthecow/mustache.php/issues", + "source": "https://github.com/bobthecow/mustache.php/tree/v2.14.2" + }, + "time": "2022-08-23T13:07:01+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2024-06-12T14:39:25+00:00" + }, + { + "name": "phar-io/manifest", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "phar-io/version": "^2.0", + "php": "^5.6 || ^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/master" + }, + "time": "2018-07-08T19:23:20+00:00" + }, + { + "name": "phar-io/version", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/master" + }, + "time": "2018-07-08T19:19:57+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.3", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/293975b465e0e709b571cbf0c957c6c0a7b9a2ac", + "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + } + ], + "time": "2024-04-24T21:30:46+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "41bef18ba688af638b7310666db28e1ea9158b2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/41bef18ba688af638b7310666db28e1ea9158b2f", + "reference": "41bef18ba688af638b7310666db28e1ea9158b2f", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "time": "2019-08-28T14:22:28+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/6568f4687e5b41b054365f9ae03fcb1ed5f2069b", + "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/master" + }, + "time": "2020-04-27T09:25:28+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "4.3.4", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "da3fd972d6bafd628114f7e7e036f45944b62e9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/da3fd972d6bafd628114f7e7e036f45944b62e9c", + "reference": "da3fd972d6bafd628114f7e7e036f45944b62e9c", + "shasum": "" + }, + "require": { + "php": "^7.0", + "phpdocumentor/reflection-common": "^1.0.0 || ^2.0.0", + "phpdocumentor/type-resolver": "~0.4 || ^1.0.0", + "webmozart/assert": "^1.0" + }, + "require-dev": { + "doctrine/instantiator": "^1.0.5", + "mockery/mockery": "^1.0", + "phpdocumentor/type-resolver": "0.4.*", + "phpunit/phpunit": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/release/4.x" + }, + "time": "2019-12-28T18:55:12+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", + "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", + "shasum": "" + }, + "require": { + "php": "^7.1", + "phpdocumentor/reflection-common": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "^7.1", + "mockery/mockery": "~1", + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/0.7.2" + }, + "time": "2019-08-22T18:11:29+00:00" + }, + { + "name": "phpspec/prophecy", + "version": "v1.10.3", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "451c3cd1418cf640de218914901e51b064abb093" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093", + "reference": "451c3cd1418cf640de218914901e51b064abb093", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": "^5.3|^7.0", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0", + "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0", + "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0" + }, + "require-dev": { + "phpspec/phpspec": "^2.5 || ^3.2", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Prophecy\\": "src/Prophecy" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "support": { + "issues": "https://github.com/phpspec/prophecy/issues", + "source": "https://github.com/phpspec/prophecy/tree/v1.10.3" + }, + "time": "2020-03-05T15:02:03+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "6.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlwriter": "*", + "php": "^7.1", + "phpunit/php-file-iterator": "^2.0", + "phpunit/php-text-template": "^1.2.1", + "phpunit/php-token-stream": "^3.0", + "sebastian/code-unit-reverse-lookup": "^1.0.1", + "sebastian/environment": "^3.1 || ^4.0", + "sebastian/version": "^2.0.1", + "theseer/tokenizer": "^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "suggest": { + "ext-xdebug": "^2.6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/master" + }, + "time": "2018-10-31T16:06:48+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "2.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "69deeb8664f611f156a924154985fbd4911eb36b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/69deeb8664f611f156a924154985fbd4911eb36b", + "reference": "69deeb8664f611f156a924154985fbd4911eb36b", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/2.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:39:50+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/1.2.1" + }, + "time": "2015-06-21T13:50:34+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "2.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a691211e94ff39a34811abd521c31bd5b305b0bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a691211e94ff39a34811abd521c31bd5b305b0bb", + "reference": "a691211e94ff39a34811abd521c31bd5b305b0bb", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/2.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:42:41+00:00" + }, + { + "name": "phpunit/php-token-stream", + "version": "3.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-token-stream.git", + "reference": "9c1da83261628cb24b6a6df371b6e312b3954768" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/9c1da83261628cb24b6a6df371b6e312b3954768", + "reference": "9c1da83261628cb24b6a6df371b6e312b3954768", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Wrapper around PHP's tokenizer extension.", + "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "keywords": [ + "tokenizer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-token-stream/issues", + "source": "https://github.com/sebastianbergmann/php-token-stream/tree/3.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "abandoned": true, + "time": "2021-07-26T12:15:06+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "7.5.20", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "9467db479d1b0487c99733bb1e7944d32deded2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9467db479d1b0487c99733bb1e7944d32deded2c", + "reference": "9467db479d1b0487c99733bb1e7944d32deded2c", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.1", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "myclabs/deep-copy": "^1.7", + "phar-io/manifest": "^1.0.2", + "phar-io/version": "^2.0", + "php": "^7.1", + "phpspec/prophecy": "^1.7", + "phpunit/php-code-coverage": "^6.0.7", + "phpunit/php-file-iterator": "^2.0.1", + "phpunit/php-text-template": "^1.2.1", + "phpunit/php-timer": "^2.1", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0", + "sebastian/environment": "^4.0", + "sebastian/exporter": "^3.1", + "sebastian/global-state": "^2.0", + "sebastian/object-enumerator": "^3.0.3", + "sebastian/resource-operations": "^2.0", + "sebastian/version": "^2.0.1" + }, + "conflict": { + "phpunit/phpunit-mock-objects": "*" + }, + "require-dev": { + "ext-pdo": "*" + }, + "suggest": { + "ext-soap": "*", + "ext-xdebug": "*", + "phpunit/php-invoker": "^2.0" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.5-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "source": "https://github.com/sebastianbergmann/phpunit/tree/7.5.20" + }, + "time": "2020-01-08T08:45:45+00:00" + }, + { + "name": "rmccue/requests", + "version": "v1.8.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/Requests.git", + "reference": "82e6936366eac3af4d836c18b9d8c31028fe4cd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/Requests/zipball/82e6936366eac3af4d836c18b9d8c31028fe4cd5", + "reference": "82e6936366eac3af4d836c18b9d8c31028fe4cd5", + "shasum": "" + }, + "require": { + "php": ">=5.2" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.7", + "php-parallel-lint/php-console-highlighter": "^0.5.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpcompatibility/php-compatibility": "^9.0", + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5", + "requests/test-server": "dev-master", + "squizlabs/php_codesniffer": "^3.5", + "wp-coding-standards/wpcs": "^2.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Requests": "library/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Ryan McCue", + "homepage": "http://ryanmccue.info" + } + ], + "description": "A HTTP library written in PHP, for human beings.", + "homepage": "http://github.com/WordPress/Requests", + "keywords": [ + "curl", + "fsockopen", + "http", + "idna", + "ipv6", + "iri", + "sockets" + ], + "support": { + "issues": "https://github.com/WordPress/Requests/issues", + "source": "https://github.com/WordPress/Requests/tree/v1.8.1" + }, + "time": "2021-06-04T09:56:25+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54", + "reference": "92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/1.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:45:45+00:00" + }, + { + "name": "sebastian/comparator", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "1dc7ceb4a24aede938c7af2a9ed1de09609ca770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/1dc7ceb4a24aede938c7af2a9ed1de09609ca770", + "reference": "1dc7ceb4a24aede938c7af2a9ed1de09609ca770", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "sebastian/diff": "^3.0", + "sebastian/exporter": "^3.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/3.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T12:31:48+00:00" + }, + { + "name": "sebastian/diff", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "98ff311ca519c3aa73ccd3de053bdb377171d7b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/98ff311ca519c3aa73ccd3de053bdb377171d7b6", + "reference": "98ff311ca519c3aa73ccd3de053bdb377171d7b6", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.0", + "symfony/process": "^2 || ^3.3 || ^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:16:36+00:00" + }, + { + "name": "sebastian/environment", + "version": "4.2.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "56932f6049a0482853056ffd617c91ffcc754205" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/56932f6049a0482853056ffd617c91ffcc754205", + "reference": "56932f6049a0482853056ffd617c91ffcc754205", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.5" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/4.2.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:49:59+00:00" + }, + { + "name": "sebastian/exporter", + "version": "3.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "73a9676f2833b9a7c36968f9d882589cd75511e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/73a9676f2833b9a7c36968f9d882589cd75511e6", + "reference": "73a9676f2833b9a7c36968f9d882589cd75511e6", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/3.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T06:00:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", + "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/2.0.0" + }, + "time": "2017-04-27T15:39:26+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "ac5b293dba925751b808e02923399fb44ff0d541" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/ac5b293dba925751b808e02923399fb44ff0d541", + "reference": "ac5b293dba925751b808e02923399fb44ff0d541", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "sebastian/object-reflector": "^1.1.1", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/3.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:54:02+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "1d439c229e61f244ff1f211e5c99737f90c67def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/1d439c229e61f244ff1f211e5c99737f90c67def", + "reference": "1d439c229e61f244ff1f211e5c99737f90c67def", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/1.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:56:04+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "9bfd3c6f1f08c026f542032dfb42813544f7d64c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/9bfd3c6f1f08c026f542032dfb42813544f7d64c", + "reference": "9bfd3c6f1f08c026f542032dfb42813544f7d64c", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T14:07:30+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "72a7f7674d053d548003b16ff5a106e7e0e06eee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/72a7f7674d053d548003b16ff5a106e7e0e06eee", + "reference": "72a7f7674d053d548003b16ff5a106e7e0e06eee", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:59:09+00:00" + }, + { + "name": "sebastian/version", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/master" + }, + "time": "2016-10-03T07:35:21+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.10.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/86e5f5dd9a840c46810ebe5ff1885581c42a3017", + "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + } + ], + "time": "2024-07-21T23:26:44+00:00" + }, + { + "name": "symfony/finder", + "version": "v3.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "b6b6ad3db3edb1b4b1c1896b1975fb684994de6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/b6b6ad3db3edb1b4b1c1896b1975fb684994de6e", + "reference": "b6b6ad3db3edb1b4b1c1896b1975fb684994de6e", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Finder Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v3.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-11-16T17:02:08+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.30.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "0424dff1c58f028c451efff2045f5d92410bd540" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", + "reference": "0424dff1c58f028c451efff2045f5d92410bd540", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-05-31T15:07:36+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/master" + }, + "time": "2019-06-13T22:48:21+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.9.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389", + "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0 || ^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<3.9.1" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36 || ^7.5.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.9.1" + }, + "time": "2020-07-08T17:02:28+00:00" + }, + { + "name": "woocommerce/woocommerce-sniffs", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce-sniffs.git", + "reference": "b72b7dd2e70aa6aed16f80cdae5b1e6cce2e4c79" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/woocommerce/woocommerce-sniffs/zipball/b72b7dd2e70aa6aed16f80cdae5b1e6cce2e4c79", + "reference": "b72b7dd2e70aa6aed16f80cdae5b1e6cce2e4c79", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "0.7.0", + "php": ">=7.0", + "phpcompatibility/phpcompatibility-wp": "2.1.0", + "wp-coding-standards/wpcs": "2.3.0" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Claudio Sanches", + "email": "claudio@automattic.com" + } + ], + "description": "WooCommerce sniffs", + "keywords": [ + "phpcs", + "standards", + "woocommerce", + "wordpress" + ], + "support": { + "issues": "https://github.com/woocommerce/woocommerce-sniffs/issues", + "source": "https://github.com/woocommerce/woocommerce-sniffs/tree/master" + }, + "time": "2020-08-06T18:23:45+00:00" + }, + { + "name": "wp-cli/mustangostang-spyc", + "version": "0.6.3", + "source": { + "type": "git", + "url": "https://github.com/wp-cli/spyc.git", + "reference": "6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-cli/spyc/zipball/6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7", + "reference": "6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7", + "shasum": "" + }, + "require": { + "php": ">=5.3.1" + }, + "require-dev": { + "phpunit/phpunit": "4.3.*@dev" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.5.x-dev" + } + }, + "autoload": { + "files": [ + "includes/functions.php" + ], + "psr-4": { + "Mustangostang\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "mustangostang", + "email": "vlad.andersen@gmail.com" + } + ], + "description": "A simple YAML loader/dumper class for PHP (WP-CLI fork)", + "homepage": "https://github.com/mustangostang/spyc/", + "support": { + "source": "https://github.com/wp-cli/spyc/tree/autoload" + }, + "time": "2017-04-25T11:26:20+00:00" + }, + { + "name": "wp-cli/php-cli-tools", + "version": "v0.11.22", + "source": { + "type": "git", + "url": "https://github.com/wp-cli/php-cli-tools.git", + "reference": "a6bb94664ca36d0962f9c2ff25591c315a550c51" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/a6bb94664ca36d0962f9c2ff25591c315a550c51", + "reference": "a6bb94664ca36d0962f9c2ff25591c315a550c51", + "shasum": "" + }, + "require": { + "php": ">= 5.3.0" + }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "wp-cli/wp-cli-tests": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.11.x-dev" + } + }, + "autoload": { + "files": [ + "lib/cli/cli.php" + ], + "psr-0": { + "cli": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Bachhuber", + "email": "daniel@handbuilt.co", + "role": "Maintainer" + }, + { + "name": "James Logsdon", + "email": "jlogsdon@php.net", + "role": "Developer" + } + ], + "description": "Console utilities for PHP", + "homepage": "http://github.com/wp-cli/php-cli-tools", + "keywords": [ + "cli", + "console" + ], + "support": { + "issues": "https://github.com/wp-cli/php-cli-tools/issues", + "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.11.22" + }, + "time": "2023-12-03T19:25:05+00:00" + }, + { + "name": "wp-cli/wp-cli", + "version": "v2.5.0", + "source": { + "type": "git", + "url": "https://github.com/wp-cli/wp-cli.git", + "reference": "0bcf0c54f4d35685211d435e25219cc7acbe6d48" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/0bcf0c54f4d35685211d435e25219cc7acbe6d48", + "reference": "0bcf0c54f4d35685211d435e25219cc7acbe6d48", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "mustache/mustache": "~2.13", + "php": "^5.6 || ^7.0 || ^8.0", + "rmccue/requests": "^1.8", + "symfony/finder": ">2.7", + "wp-cli/mustangostang-spyc": "^0.6.3", + "wp-cli/php-cli-tools": "~0.11.2" + }, + "require-dev": { + "roave/security-advisories": "dev-master", + "wp-cli/db-command": "^1.3 || ^2", + "wp-cli/entity-command": "^1.2 || ^2", + "wp-cli/extension-command": "^1.1 || ^2", + "wp-cli/package-command": "^1 || ^2", + "wp-cli/wp-cli-tests": "^3.0.7" + }, + "suggest": { + "ext-readline": "Include for a better --prompt implementation", + "ext-zip": "Needed to support extraction of ZIP archives when doing downloads or updates" + }, + "bin": [ + "bin/wp", + "bin/wp.bat" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.5.x-dev" + } + }, + "autoload": { + "psr-0": { + "WP_CLI\\": "php/" + }, + "classmap": [ + "php/class-wp-cli.php", + "php/class-wp-cli-command.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WP-CLI framework", + "homepage": "https://wp-cli.org", + "keywords": [ + "cli", + "wordpress" + ], + "support": { + "docs": "https://make.wordpress.org/cli/handbook/", + "issues": "https://github.com/wp-cli/wp-cli/issues", + "source": "https://github.com/wp-cli/wp-cli" + }, + "time": "2021-05-14T13:44:51+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "7da1894633f168fe244afc6de00d141f27517b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7da1894633f168fe244afc6de00d141f27517b62", + "reference": "7da1894633f168fe244afc6de00d141f27517b62", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.3.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || ^0.6", + "phpcompatibility/php-compatibility": "^9.0", + "phpcsstandards/phpcsdevtools": "^1.0", + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.6 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "time": "2020-05-13T23:57:56+00:00" + }, + { + "name": "yoast/phpunit-polyfills", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", + "reference": "4a088f125c970d6d6ea52c927f96fe39b330d0f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/4a088f125c970d6d6ea52c927f96fe39b330d0f1", + "reference": "4a088f125c970d6d6ea52c927f96fe39b330d0f1", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "yoast/yoastcs": "^3.1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "files": [ + "phpunitpolyfills-autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Team Yoast", + "email": "support@yoast.com", + "homepage": "https://yoast.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors" + } + ], + "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills", + "keywords": [ + "phpunit", + "polyfill", + "testing" + ], + "support": { + "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", + "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", + "source": "https://github.com/Yoast/PHPUnit-Polyfills" + }, + "time": "2024-04-05T16:36:44+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=7.0" + }, + "platform-dev": [], + "platform-overrides": { + "php": "7.1" + }, + "plugin-api-version": "2.6.0" +} diff --git a/deprecated/ActionScheduler_Abstract_QueueRunner_Deprecated.php b/deprecated/ActionScheduler_Abstract_QueueRunner_Deprecated.php new file mode 100644 index 0000000..e24ddca --- /dev/null +++ b/deprecated/ActionScheduler_Abstract_QueueRunner_Deprecated.php @@ -0,0 +1,27 @@ +get_date(); + $replacement_method = 'get_date()'; + } else { + $return_value = $this->get_next( $after ); + $replacement_method = 'get_next( $after )'; + } + + _deprecated_function( __METHOD__, '3.0.0', __CLASS__ . '::' . $replacement_method ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + return $return_value; + } +} diff --git a/deprecated/ActionScheduler_Store_Deprecated.php b/deprecated/ActionScheduler_Store_Deprecated.php new file mode 100644 index 0000000..02dc8b7 --- /dev/null +++ b/deprecated/ActionScheduler_Store_Deprecated.php @@ -0,0 +1,50 @@ +mark_failure( $action_id ); + } + + /** + * Add base hooks + * + * @since 2.2.6 + */ + protected static function hook() { + _deprecated_function( __METHOD__, '3.0.0' ); + } + + /** + * Remove base hooks + * + * @since 2.2.6 + */ + protected static function unhook() { + _deprecated_function( __METHOD__, '3.0.0' ); + } + + /** + * Get the site's local time. + * + * @deprecated 2.1.0 + * @return DateTimeZone + */ + protected function get_local_timezone() { + _deprecated_function( __FUNCTION__, '2.1.0', 'ActionScheduler_TimezoneHelper::set_local_timezone()' ); + return ActionScheduler_TimezoneHelper::get_local_timezone(); + } +} diff --git a/deprecated/functions.php b/deprecated/functions.php new file mode 100644 index 0000000..76ac019 --- /dev/null +++ b/deprecated/functions.php @@ -0,0 +1,129 @@ + '' - the name of the action that will be triggered + * 'args' => NULL - the args array that will be passed with the action + * 'date' => NULL - the scheduled date of the action. Expects a DateTime object, a unix timestamp, or a string that can parsed with strtotime(). Used in UTC timezone. + * 'date_compare' => '<=' - operator for testing "date". accepted values are '!=', '>', '>=', '<', '<=', '=' + * 'modified' => NULL - the date the action was last updated. Expects a DateTime object, a unix timestamp, or a string that can parsed with strtotime(). Used in UTC timezone. + * 'modified_compare' => '<=' - operator for testing "modified". accepted values are '!=', '>', '>=', '<', '<=', '=' + * 'group' => '' - the group the action belongs to + * 'status' => '' - ActionScheduler_Store::STATUS_COMPLETE or ActionScheduler_Store::STATUS_PENDING + * 'claimed' => NULL - TRUE to find claimed actions, FALSE to find unclaimed actions, a string to find a specific claim ID + * 'per_page' => 5 - Number of results to return + * 'offset' => 0 + * 'orderby' => 'date' - accepted values are 'hook', 'group', 'modified', or 'date' + * 'order' => 'ASC'. + * @param string $return_format OBJECT, ARRAY_A, or ids. + * + * @deprecated 2.1.0 + * + * @return array + */ +function wc_get_scheduled_actions( $args = array(), $return_format = OBJECT ) { + _deprecated_function( __FUNCTION__, '2.1.0', 'as_get_scheduled_actions()' ); + return as_get_scheduled_actions( $args, $return_format ); +} diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..3b480b5 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +actionscheduler.org \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..fe521d9 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,7 @@ +title: Action Scheduler - Job Queue for WordPress +description: A scalable, traceable job queue for background processing large queues of tasks in WordPress. Designed for distribution in WordPress plugins - no server access required. +theme: jekyll-theme-hacker +permalink: /:slug/ +plugins: + - jekyll-seo-tag + - jekyll-sitemap diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 0000000..3559804 --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + +{% seo %} + + + + +
    + + + +
    +

    Usage | Admin | WP-CLI | Background Processing at Scale | API | FAQ | Version 3.0 +

    action-scheduler

    +

    A scalable, traceable job queue for background processing large queues of tasks in WordPress. Designed for distribution in WordPress plugins - no server access required.

    +
    +
    + +
    +
    + {{ content }} +
    +
    + + + + {% if site.google_analytics %} + + {% endif %} + + \ No newline at end of file diff --git a/docs/admin.md b/docs/admin.md new file mode 100644 index 0000000..18769eb --- /dev/null +++ b/docs/admin.md @@ -0,0 +1,22 @@ +--- +description: Learn how to administer background jobs with the Action Scheduler job queue for WordPress. +--- +# Scheduled Actions Administration Screen + +Action Scheduler has a built in administration screen for monitoring, debugging and manually triggering scheduled actions. + +The administration interface is accessible through both: + +1. **Tools > Scheduled Actions** +1. **WooCommerce > Status > Scheduled Actions**, when WooCommerce is installed. + +Among other tasks, from the admin screen you can: + +* run a pending action +* view the scheduled actions with a specific status, like the all actions which have failed or are in-progress (https://cldup.com/NNTwE88Xl8.png). +* view the log entries for a specific action to find out why it failed. +* sort scheduled actions by hook name, scheduled date, claim ID or group name. + +Still have questions? Check out the [FAQ](/faq). + +![](https://cldup.com/5BA2BNB1sw.png) diff --git a/docs/android-chrome-192x192.png b/docs/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..36475ecb0544b6c0f2daecefa696e18dafa6ae69 GIT binary patch literal 6933 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 literal 0 HcmV?d00001 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e60f201 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,253 @@ +--- +description: Reference guide for background processing functions provided by the Action Scheduler job queue for WordPress. +--- +# API Reference + +Action Scheduler provides a range of functions for scheduling hooks to run at some time in the future on one or more occasions. + +To understand the scheduling functions, it can help to think of them as extensions to WordPress' `do_action()` function that add the ability to delay and repeat when the hook will be triggered. + +## WP-Cron APIs vs. Action Scheduler APIs + +The Action Scheduler API functions are designed to mirror the WordPress [WP-Cron API functions](http://codex.wordpress.org/Category:WP-Cron_Functions). + +Functions return similar values and accept similar arguments to their WP-Cron counterparts. The notable differences are: + +* `as_schedule_single_action()` & `as_schedule_recurring_action()` will return the ID of the scheduled action rather than boolean indicating whether the event was scheduled +* `as_schedule_recurring_action()` takes an interval in seconds as the recurring interval rather than an arbitrary string +* `as_schedule_single_action()` & `as_schedule_recurring_action()` can accept a `$group` parameter to group different actions for the one plugin together. +* the `wp_` prefix is substituted with `as_` and the term `event` is replaced with `action` + +## API Function Availability + +As mentioned in the [Usage - Load Order](usage.md#load-order) section, Action Scheduler will initialize itself on the `'init'` hook with priority `1`. While API functions are loaded prior to this and can be called, they should not be called until after `'init'` with priority `1`, because each component, like the data store, has not yet been initialized. + +Do not use Action Scheduler API functions prior to `'init'` hook with priority `1`. Doing so could lead to unexpected results, like data being stored in the incorrect location. To make this easier: + +- Action Scheduler provides `Action_Scheduler::is_initialized()` for use in hooks to confirm that the data stores have been initialized. +- It also provides the `'action_scheduler_init'` action hook. It is safe to call API functions during or after this event has fired (tip: you can take advantage of WordPress's [did_action()](https://developer.wordpress.org/reference/functions/did_action/) function to check this). + +## Function Reference / `as_enqueue_async_action()` + +### Description + +Enqueue an action to run one time, as soon as possible. + +### Usage + +```php +as_enqueue_async_action( $hook, $args, $group, $unique, $priority ); +``` + +### Parameters + +- **$hook** (string)(required) Name of the action hook. +- **$args** (array) Arguments to pass to callbacks when the hook triggers. Default: _`array()`_. +- **$group** (string) The group to assign this job to. Default: _''_. +- **$unique** (boolean) Whether the action should be unique. Default: _`false`_. +- **$priority** (integer) Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. + +### Return value + +`(integer)` the action's ID. Zero if there was an error scheduling the action. The error will be sent to `error_log`. + +## Function Reference / `as_schedule_single_action()` + +### Description + +Schedule an action to run one time at some defined point in the future. + +### Usage + +```php +as_schedule_single_action( $timestamp, $hook, $args, $group, $unique, $priority ); +``` + +### Parameters + +- **$timestamp** (integer)(required) The Unix timestamp representing the date you want the action to run. +- **$hook** (string)(required) Name of the action hook. +- **$args** (array) Arguments to pass to callbacks when the hook triggers. Default: _`array()`_. +- **$group** (string) The group to assign this job to. Default: _''_. +- **$unique** (boolean) Whether the action should be unique. Default: _`false`_. +- **$priority** (integer) Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.) + +### Return value + +`(integer)` the action's ID. Zero if there was an error scheduling the action. The error will be sent to `error_log`. + +## Function Reference / `as_schedule_recurring_action()` + +### Description + +Schedule an action to run repeatedly with a specified interval in seconds. + +### Usage + +```php +as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args, $group, $unique, $priority ); +``` + +### Parameters + +- **$timestamp** (integer)(required) The Unix timestamp representing the date you want the action to run. +- **$interval_in_seconds** (integer)(required) How long to wait between runs. +- **$hook** (string)(required) Name of the action hook. +- **$args** (array) Arguments to pass to callbacks when the hook triggers. Default: _`array()`_. +- **$group** (string) The group to assign this job to. Default: _''_. +- **$unique** (boolean) Whether the action should be unique. Default: _`false`_. +- **$priority** (integer) Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. + +### Return value + +`(integer)` the action's ID. Zero if there was an error scheduling the action. The error will be sent to `error_log`. + +## Function Reference / `as_schedule_cron_action()` + +### Description + +Schedule an action that recurs on a cron-like schedule. + +If execution of a cron-like action is delayed, the next attempt will still be scheduled according to the provided cron expression. + +### Usage + +```php +as_schedule_cron_action( $timestamp, $schedule, $hook, $args, $group, $unique, $priority ); +``` + +### Parameters + +- **$timestamp** (integer)(required) The Unix timestamp representing the date you want the action to run. +- **$schedule** (string)(required) $schedule A cron-like schedule string, see http://en.wikipedia.org/wiki/Cron. +- **$hook** (string)(required) Name of the action hook. +- **$args** (array) Arguments to pass to callbacks when the hook triggers. Default: _`array()`_. +- **$group** (string) The group to assign this job to. Default: _''_. +- **$unique** (boolean) Whether the action should be unique. Default: _`false`_. +- **$priority** (integer) Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. + +### Return value + +`(integer)` the action's ID. Zero if there was an error scheduling the action. The error will be sent to `error_log`. + +## Function Reference / `as_unschedule_action()` + +### Description + +Cancel the next occurrence of a scheduled action. + +### Usage + +```php +as_unschedule_action( $hook, $args, $group ); +``` + +### Parameters + +- **$hook** (string)(required) Name of the action hook. +- **$args** (array) Arguments passed to callbacks when the hook triggers. Default: _`array()`_. +- **$group** (string) The group the job is assigned to. Default: _''_. + +### Return value + +`(null)` + +## Function Reference / `as_unschedule_all_actions()` + +### Description + +Cancel all occurrences of a scheduled action. + +### Usage + +```php +as_unschedule_all_actions( $hook, $args, $group ) +``` + +### Parameters + +- **$hook** (string)(required) Name of the action hook. +- **$args** (array) Arguments passed to callbacks when the hook triggers. Default: _`array()`_. +- **$group** (string) The group the job is assigned to. Default: _''_. + +### Return value + +`(string|null)` The scheduled action ID if a scheduled action was found, or null if no matching action found. + +## Function Reference / `as_next_scheduled_action()` + +### Description + +Returns the next timestamp for a scheduled action. + +### Usage + +```php +as_next_scheduled_action( $hook, $args, $group ); +``` + +### Parameters + +- **$hook** (string)(required) Name of the action hook. Default: _none_. +- **$args** (array) Arguments passed to callbacks when the hook triggers. Default: _`array()`_. +- **$group** (string) The group the job is assigned to. Default: _''_. + +### Return value + +`(integer|boolean)` The timestamp for the next occurrence of a pending scheduled action, true for an async or in-progress action or false if there is no matching action. + +## Function Reference / `as_has_scheduled_action()` + +### Description + +Check if there is a scheduled action in the queue, but more efficiently than as_next_scheduled_action(). It's recommended to use this function when you need to know whether a specific action is currently scheduled. _Available since 3.3.0._ + +### Usage + +```php +as_has_scheduled_action( $hook, $args, $group ); +``` + +### Parameters + +- **$hook** (string)(required) Name of the action hook. Default: _none_. +- **$args** (array) Arguments passed to callbacks when the hook triggers. Default: _`array()`_. +- **$group** (string) The group the job is assigned to. Default: _''_. + +### Return value + +`(boolean)` True if a matching action is pending or in-progress, false otherwise. + +## Function Reference / `as_get_scheduled_actions()` + +### Description + +Find scheduled actions. + +### Usage + +```php +as_get_scheduled_actions( $args, $return_format ); +``` + +### Parameters + +- **$args** (array) Arguments to search and filter results by. Possible arguments, with their default values: + * `'hook' => ''` - the name of the action that will be triggered + * `'args' => NULL` - the args array that will be passed with the action + * `'date' => NULL` - the scheduled date of the action. Expects a DateTime object, a unix timestamp, or a string that can parsed with strtotime(). + * `'date_compare' => '<=`' - operator for testing "date". accepted values are '!=', '>', '>=', '<', '<=', '=' + * `'modified' => NULL` - the date the action was last updated. Expects a DateTime object, a unix timestamp, or a string that can parsed with strtotime(). + * `'modified_compare' => '<='` - operator for testing "modified". accepted values are '!=', '>', '>=', '<', '<=', '=' + * `'group' => ''` - the group the action belongs to + * `'status' => ''` - ActionScheduler_Store::STATUS_COMPLETE or ActionScheduler_Store::STATUS_PENDING + * `'claimed' => NULL` - TRUE to find claimed actions, FALSE to find unclaimed actions, a string to find a specific claim ID + * `'per_page' => 5` - Number of results to return + * `'offset' => 0` + * `'orderby' => 'date'` - accepted values are 'hook', 'group', 'modified', or 'date' + * `'order' => 'ASC'` +- **$return_format** (string) The format in which to return the scheduled actions: 'OBJECT', 'ARRAY_A', or 'ids'. Default: _'OBJECT'_. + +### Return value + +`(array)` Array of action rows matching the criteria specified with `$args`. diff --git a/docs/apple-touch-icon.png b/docs/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..36475ecb0544b6c0f2daecefa696e18dafa6ae69 GIT binary patch literal 6933 zcmd6M^;6tU^X+GG2@(kK03pF077Z@J-66PJAV6>y2rLP%iwAdi4Z$_IE$;4+MS|Ox z_n)|RYig#?4?WdgHB;56&Iwafk;TCx!vX*RM_x`!<0W?fZ)2dnoEpnt&|d*l* zZeEmr{fLj06NF3M=9w=S0j(F`7jf%OL#&_mZ6_F6TaOQ>T%y-rkEAxt9y?`;aF^xZ zwI79sSLeP#E~hBi%eLm zK-pOV*^N*%aW&Nr9b;s1G31X9ARK6Rre8(tOF^{%hXpx@_SoY>!gbi?1+aE=`@?tW z9=<(x^M(ukr+7wEH8pt>vVXk6&-o*5yA)&!H*NmyiM<}+->hiFtWkneAfNolz9$eJ z^Nr^^?a9wyK`IC_c|1ab)<&XllE~uqK|cchb6lj0Cs`^OHKRv@dHrrqsUF+6`4+hx z*j74x*1n2m;_w1q`rL95BClMI2Ja%80{N`jIMlIcND{FhXS}0HhgC^Oh6)+rQC@Vf zG236Oz7*4}nY66uptm(%;XhI9M1n6GV;Khr1}<+b5{kV1bOw)ON605B#WYpe>*Uf*EZ<6Jx=LrL<6k;cX4zRROo1YgAwhqQdg_gbUEb>tRjUvn(4c_iz zotWSRP9J)IF0tnEQu6-3Uie)Y%YOHn>z})Qon=;-pu3%&Xub$hiEcW%Du+Io9qtmn z$Vg%l*jfR|o*ZT?(K_KuV4g8q6KtndaY4*Bq}8uak-DArLI~U3G+T%nb+4SguiDs? zc1PysIqk3DL+}01W&)2y`ax=CvdE?LwW(|cr;61#!?w*nivG9@T=`*RY-@i9uI_K! zA2}b+GW_A2uP5ngYv!iaf%Uz*1p@~r+KOvQ!o0(ZXGoB}s$RSo_BM+)bkTPrYSaMZ zx{KhiST?6f$L`o1xuh&;!cP}v2b*nQ`Ge-1^YpvhD$c^Y{Xes;YQM(0wb~CfX9e#M zf;2bY)5zR@?n5+M0G%81X?V>pkHP7WU2g<^_IbpPY*hwXVVnl^huph6mU9pzXx2oW zx+|pSVz{3ZnEp$lLX@g~#*&?ac0|uw)TZc}dXT%6S}#6>+fc0Ezew*x>f~oSgQ)jD zCfK6*(&+XII3>rl5)u<;@<2GxMZ@ShiRIF$*p{vlJLl(bq!rsU6=i=>5cG+Xn)Cw` zd;Q5#h+782rHi1cW9XOFQ$}hJne4C*)R*u7kuBJ5ef$O-JvVMra3D!v-81Ub{YP$S zo)c*0cgLSY_%ovQNVf?@Z-Uq4N{gP-exRnduLdg!8>J#MjI(}F(wFz~TiCcQ%r1)t zW%n?MFggt)EzgAey0dO5=Z00Ga_x*r7_}culGa*p58r?t=f;e3QY0BBjlvx9|6Ak) z0=XGVIaE}%!fa4N82ObZd*_f;Klp?n{__h4Pf0D$awaFWbqtC1JHG-h(Rm7LH;9_Z z-ImI6>P5V1Jlng*s#p3~z6mle){JM|2=N>gASf`yUvm3O6Y_U|EH2fg^|uS{IpvJ1 z+l#DDkyPkWDprN1a*2MMLZnOH;-}xmex**W4c;agPk+gx_|}M+u{Tfe--9I!=Bf= znRK6$I%c55*Mlpv2LnlB`N6n_3K>K0<|dTo)%fx676>F2J5s=bzg`zcgMjYKn=8mr zDnrX_0i0hSp(Ev<3{#0y$*-=}bIk6P4vd@@;O@hBZn#nt1Ahj~bU7wKOy)L&8Bx?S zc-hlbB|(uPKNR1Z3Q9*KWj~(2Lm8J?<#D><`P3XGP8I0a_--+*dd4;+$t6O{$Q%1$k&tt?Hl z;!SsUH<}l-PPgp(LVhXu+Stipg^INk*1+q6d2F63z2~HXT`&DW-vOu+x)%4 z@C~~oU@Bjg@YTizLs49FgXrP{4@96L_N--{@^>L!Jv zvyMkH2fp&kaUa5ZjUHjj8VrFIuLukTyOYk^l)|5Ft#e*lBZDw$ouluVDuGT;C z1y;t4geY@(CW)G?+%^=Dr%fsqA`fw9u(qS2O8#Dj!wn9efbG zbwPh~?)(Io9Zyvq<<}--k{;=pi${qEIH|T!U@d{yF-0qtaNuPEdF}pG6}~kntjJs2 zb!fa7p8p#o;hZ6ga7c?0L(^RQPxjNe;nmy-;_>gZU-Wv?Xd0~E)NswotFEC>6%z>F%5FT+Y8{U3Ij7tL1y}PufW#E5#`tE%44_MO6Rn(cIwt z>~A>=ASE4AvhMU9rIRh?SJA7*Hd~>kJ0mN5Ms-(EPCAzFwG}=c01Ik!7sU2DG6lW8 zB~$!)q?TKbuXBTZS$Cc^g0RPJTIt!t6a8_l7WMbC#O3JFL)Yv0o@0Y*l!92=##{x` zikOzB1ArZAsSuGS&#c_FEC~{dQc6OdUvGKe&ZqkzaX8@(=ZRcleu-JRlhD;_P9-lF z|6tyry_ghCkq81xqq1jNom{vCm8Q+%$x+9L5!|G&H)4wP%#I;F(}F*>uz&&nNPu*; z-b!!@GnK`kfwBCZ>zS#O|3dvYVuKr51B06)3+KVd{tjj-OYI9@rVDIJ=8K=lshVEi zhK{RQoO}qR&00nu9|eoqS~3GM-Le`wJ3idnv1iNXI6B8f5VUyfdbozfsGWhU#5|%@ ztIS3+Me<1`iDG;rd_=w~DfB@M?|rkzOm=vTUH&To|4p=$_3KI`t{)a^M@=;SrX(zQ zuT%mn@`GMC;0k%tM(_IaYkL!U(KKqJ0gSPS3Z1O zN9YJk)>th?&wLry6wV^4oCTNd0?E3BRsfLm)wH+el%>W6ql1hE73vbjtkL$QT)w|i7gJ8FL7g$+%lvbj{wx9|r{IZ5?E)jZs1sD>%vM zPCA(kD~#nU*$1WS%I*LY#}yupbp?>rUXzX#S^wJUO=^rpLiLa8bX;ll#(`ksCdvrex?O`fEG#qDuL?5o_8xn>&SAUeY z{mCm0Cjl97vD*9u<;z&~>~?=U9>2~rdwvqFbu*YNyx_8B#Un1G^$J?bJ`sjf{uYo@ z4qi}2qEXEm*shWJb3OOR_A+r|(W+#Uh3&D1``g+M(j%H{Kzosjb#%T0BRZ7l0N!j! z&Befsm!M_K;=g8t>1tEg=4?EecX}0a1PzV1XSstMS@hyay%Y&ej^-1oF#d!Ks7YyQ z=w!YN%V`*z;AS0Jd%*7z0N-Cps~yNt=x{ICT9|768t*-XR8bzF6J^1f?AuHo_YO0Z!1EPQZykQxU-v)OdnM{|)}K09W$g*A@W>!e|=#+|0V z;`vUvK9Uo^zxbPFP^xNFf{&#dUuBiH2RS)pWC$tyi0YXN+1e&nmJ z%~wH-kp%Fx?9#k}UmYz?d>%hx%K9i&+}s}B%L$nkpPPLb`ZW^0{mF@-h;{DVns;0h zA#ZV@{&(H0`56d3S6^r_WpPMt{u3_}XC%q%qvf%oH^X^A$;s+)Gt>bO&SM9wq`cg| zjFAKWJNrnGlc$%dfShfvm{eRMi(Dso>)=oKm*9m(uR00w;A^-25#dk&XI=YMc>qPi zI3G9b-y4Dq)GDR7;a&1LTIUBGcIS6>j_OU1Y=u$PhSg{h5@dw-;f9g#4|J4Y3?%W<7o5;(PS z$BM40mlP4kXZ9_~U563|?*KSVGek#to0gUpN|=qG>~X$PpVofnKdmPc;OS5oW6NZ9 z`=H6#GSThVL&x(Sc|}%Jcac+px)a|~xqu;00cpAXx z;O1?vfoF`LgIB^*L5$1mm4>0qKW|v^v>R<^cIB2zJ`f0;v$yv_MsoA0TAPf;M8 zz0R;hO@LU`y=5~We%F2B+!&ZD+qLEI_Lz`bgoRGWJY(1*3Ws3(f=2*8MwV_76aZXM zac*wi>s-x>+m`IrwJ80}sXS`i&PLI`!ImG_fBaiU;cO=7f(^=$0rWW5^^h!1Fg8TU zLe+Qgp<;T=-vs~gG+26%l{nFTEAI9`y)T32_8;&WVYpqU=sNMVJ*8 zTlV=n*CEp}r9Jq~uAEgRbKi?{(Hc|qdbt%j2nh?I-m;Qb`j1Pkb8qh|%D`|wg4JqB zvNNA5S=cLl_q7@@V8GSEV#uLpdlY$DZ!tz+xf$*;)kss02BkP6-A`kZweEJrAeb&y zx!KL)IYn2HlUrwIR;bCPEeog3eU)e{f_z8<{DC?tb#cW+G)6c)r4^RbPt&X`Q+=M| z@H|;na12WwZ0uTM*SSS~3s~}(>+u%0tc|jq@illU7T>O`v+A0ze+qO0XoH~+MdX{=xL++MJl8fH2J&EBxWR_L7|J1hAjCIh~O@ zY|H;GTY&56Tn#lq7FV6#?Q?_`8{QE+$cNQc>g|{nngp|wme36AN5yF{Pqdm%?f(mi zXAU*j4Thos@PwA|&vm&(r7-)wke!vly&|Q!*S$_!S(xMgvcIY>1TH@a7Or@JFagGu zf1{0Bx%$OMkH0TXg#1WP4B8{e#quKYIfJLp(t~;noH>~lvn>Nv%2hj&vZ>4LwIgPJ zX#~J9L84-{Yfp|Vl6r~PGUbacQ~4lDXEdqZM!vG(Dz1EMh@Qh*SO#v7-k3ZbfZ0C6 zc&MhMcMu~l9J5zF9*@pvkKs2;Oy!G;)Y-rZe8Mb}Ll&>yIl~N8rDBl38jArNk+;Fa z`>+$vW~BB-aDbd$<*`;qMYC0N4n?!S_+4x7e_yGGd_wK^U>p+YvY*9-17AB2!77jl zD>apq&H>gg*0Q4@o+D=UTl&Nj$2FPg(0tH3bRn&EHdSF3mc_j{^1jHntB4zew_Ph& zt3*@%K8A)7Y(O|bj{__a+iWG8WCrdx9&wy?QTiWB0sAlY;*jz6?iNy*s(=k|x zrFkXlR%y_M?4jiOS9C@&d0N|c1=1`Lbq#aMyp5WtjvSKcIrV|dv2?hfKNpL$o9Z@) zA%v#Ew}&JZvxGNEYr>>GRkGjXt%|qaL5C&^U}{GCk!$`kY{kd$08^RS(9XmBooKZ{ z&@6$!cpEYUv+;M*BLkC^;? zpx=nnuXt(iA-$N87CN-Hm-ahRvbW8C)w-d46CnrW!<@Lwaa8Er*u!ST*;z!45Wb>oh;+(_(jf{p2th{zb*s#! z02GPaq|%vgZoJh<;(HGdzzQVV%%ygrqGHg@987AK)$*-o)5yYtZgBtX}ycVT0Ms6NQmkfqeO;r3Rfw;xqP+#h$CeaYI zh9V@Zkt$4lLW;*#O9?kaN{w(-fF9Y2*LUwu=%H(y?LpXz-788oL5@o;$4a|&w&uge zvkV5_vmr5wb0WnXH0A=00$`K4d^)iv+k|g(%i?e9YIj!Co0I0#C-WaiZ{{ zD-W{4TIAkIz(KwW)U(tUjY@7VQ-mbZHakd~FAnebqWQolSxZIUeq@8>*R0yIfLNvx-z~N|El3ebKsX|L; zfLD + + + + + #151515 + + + diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..e937032 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,129 @@ +## FAQ + +### Is it safe to release Action Scheduler in my plugin? Won't its functions conflict with another copy of the library? + +Action Scheduler is designed to be used and released in plugins. It avoids redeclaring public API functions when more than one copy of the library is being loaded by different plugins. It will also load only the most recent version of itself (by checking registered versions after all plugins are loaded on the `'plugins_loaded'` hook). + +To use it in your plugin (or theme), simply require the `action-scheduler/action-scheduler.php` file. Action Scheduler will take care of the rest. __Note:__ Action Scheduler is only loaded from a theme if it is not included in any active plugins. + +### I don't want to use WP-Cron. Does Action Scheduler depend on WP-Cron? + +By default, Action Scheduler is initiated by WP-Cron (and the `'shutdown'` hook on admin requests). However, it has no dependency on the WP-Cron system. You can initiate the Action Scheduler queue in other ways with just one or two lines of code. + +For example, you can start a queue directly by calling: + +```php +ActionScheduler::runner()->run(); +``` + +Or trigger the `'action_scheduler_run_queue'` hook and let Action Scheduler do it for you: + +```php +do_action( 'action_scheduler_run_queue', $context_identifier ); +``` + +Further customization can be done by extending the `ActionScheduler_Abstract_QueueRunner` class to create a custom Queue Runner. For an example of a customized queue runner, see the [`ActionScheduler_WPCLI_QueueRunner`](https://github.com/woocommerce/action-scheduler/blob/trunk/classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php), which is used when running WP CLI. + +Want to create some other method for initiating Action Scheduler? [Open a new issue](https://github.com/woocommerce/action-scheduler/issues/new), we'd love to help you with it. + +### I don't want to use WP-Cron, ever. Does Action Scheduler replace WP-Cron? + +By default, Action Scheduler is designed to work alongside WP-Cron and not change any of its behaviour. This helps avoid unexpectedly overriding WP-Cron on sites installing your plugin, which may have nothing to do with WP-Cron. + +However, we can understand why you might want to replace WP-Cron completely in environments within your control, especially as it gets you the advantages of Action Scheduler. This should be possible without too much code. + +You could use the `'schedule_event'` hook in WordPress to use Action Scheduler for only newly scheduled WP-Cron jobs and map the `$event` param to Action Scheduler API functions. + +Alternatively, you can use a combination of the `'pre_update_option_cron'` and `'pre_option_cron'` hooks to override all new and previously scheduled WP-Cron jobs (similar to the way [Cavalcade](https://github.com/humanmade/Cavalcade) does it). + +If you'd like to create a plugin to do this automatically and want to share your work with others, [open a new issue to let us know](https://github.com/woocommerce/action-scheduler/issues/new), we'd love to help you with it. + +### How does Action Scheduler store its data? + +Action Scheduler 3.0 and newer stores data in custom tables prefixed with `actionscheduler_`. For the list of all tables and their schemas, refer to the `ActionScheduler_StoreSchema` class. + +Prior to Action 3.0, actions were a custom post type, and data was stored in `wp_posts`, `wp_postmeta` and related tables. + +Action Scheduler 3+ migrates data from the custom post type to custom tables. + +### Can I use a different storage scheme? + +Of course! Action Scheduler data storage is completely swappable, and always has been. + +If you choose to, you can actually store them anywhere, like in a remote storage service from Amazon Web Services. + +To implement a custom store: + +1. extend the abstract `ActionScheduler_Store` class, being careful to implement each of its methods +2. attach a callback to `'action_scheduler_store_class'` to tell Action Scheduler your class is the one which should be used to manage storage, e.g. + +``` +function eg_define_custom_store( $existing_storage_class ) { + return 'My_Radical_Action_Scheduler_Store'; +} +add_filter( 'action_scheduler_store_class', 'eg_define_custom_store', 10, 1 ); +``` + +Take a look at the `classes/data-stores/ActionScheduler_DBStore.php` class for an example implementation of `ActionScheduler_Store`. + +If you'd like to create a plugin to do this automatically and release it publicly to help others, [open a new issue to let us know](https://github.com/woocommerce/action-scheduler/issues/new), we'd love to help you with it. + +### Can I use a different storage scheme just for logging? + +Of course! Action Scheduler's logger is completely swappable, and always has been. You can also customise where logs are stored, and the storage mechanism. + +To implement a custom logger: + +1. extend the abstract `ActionScheduler_Logger` class, being careful to implement each of its methods +2. attach a callback to `'action_scheduler_logger_class'` to tell Action Scheduler your class is the one which should be used to manage logging, e.g. + +``` +function eg_define_custom_logger( $existing_storage_class ) { + return 'My_Radical_Action_Scheduler_Logger'; +} +add_filter( 'action_scheduler_logger_class', 'eg_define_custom_logger', 10, 1 ); +``` + +Take a look at the `classes/data-stores/ActionScheduler_DBLogger.php` class for an example implementation of `ActionScheduler_Logger`. + +### I want to run Action Scheduler only on a dedicated application server in my cluster. Can I do that? + +Wow, now you're really asking the tough questions. In theory, yes, this is possible. The `ActionScheduler_QueueRunner` class, which is responsible for running queues, is swappable via the `'action_scheduler_queue_runner_class'` filter. + +Because of this, you can effectively customise queue running however you need. Whether that means tweaking minor things, like not using WP-Cron at all to initiate queues by overriding `ActionScheduler_QueueRunner::init()`, or completely changing how and where queues are run, by overriding `ActionScheduler_QueueRunner::run()`. + +### Is Action Scheduler safe to use on my production site? + +Yes, absolutely! Action Scheduler is actively used on tens of thousands of production sites already. Right now it's responsible for scheduling everything from emails to payments. + +In fact, every month, Action Scheduler processes millions of payments as part of the [WooCommerce Subscriptions](https://woocommerce.com/products/woocommerce-subscriptions/) extension. + +It requires no setup, and won't override any WordPress APIs (unless you want it to). + +### How does Action Scheduler work on WordPress Multisite? + +Action Scheduler is designed to manage the scheduled actions on a single site. It has no special handling for running queues across multiple sites in a multisite network. That said, because its storage and Queue Runner are completely swappable, it would be possible to write multisite handling classes to use with it. + +If you'd like to create a multisite plugin to do this and release it publicly to help others, [open a new issue to let us know](https://github.com/woocommerce/action-scheduler/issues/new), we'd love to help you with it. + +### How can I change the Action Scheduler User-Agent to better identify its requests? + +Action Scheduler has a filter available named `as_async_request_queue_runner_post_args` which can be used to filter the arguments that are being sent to the `wp_remote_post` call. + +The User-Agent parameter is just one of them and can be adjusted as follows: + +``` +function eg_define_custom_user_agent( $args ) { + $versions = ActionScheduler_Versions::instance(); + $args['user-agent'] = 'Action Scheduler/' . $versions->latest_version(); + + return $args; +} +add_filter( 'as_async_request_queue_runner_post_args', 'eg_define_custom_user_agent', 10, 1 ); +``` + +### My site has past-due actions; what can I do? + +Actions that are past-due have missed their scheduled date; because of [how WP Cron works](https://developer.wordpress.org/plugins/cron/), it is normal to have some past-due actions. + +If there are several past-due actions more than one day old, there may be something wrong with your site. If you are using WooCommerce and need help determining the issue, please [contact us on our forum page](https://wordpress.org/support/plugin/woocommerce/). diff --git a/docs/favicon-16x16.png b/docs/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..c46600f590e62bb785a220e310e6c7a1ff6656f5 GIT binary patch literal 398 zcmV;90df9`P)i>|kguegq|zL&PYnYhHEy~U%y%dExBuExx-$I!IP z(zwmo!PMHr)!M|?+s4=3$=Tx4-{aEX)+|_{r>&_|NsAmM5t8&0004EOGiXJr)pu(0001mNkl429vSh>8V!MG-4@vFrTr|6JlY>@|ykaSn1a z!Pn4cD$*yABaVh58_7g$v+87a-LB%ny+LSTjeAym7K{x999Xt^;Th1~-ol=w7umdjJ3c07*qoM6N<$g5R{*9RL6T literal 0 HcmV?d00001 diff --git a/docs/favicon-32x32.png b/docs/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..7d088698d89d02d660970e6e557a1b35d2c42477 GIT binary patch literal 1189 zcmV;W1X}xvP)xPHd?Kv#ufy8@u(n`V8sU_pz=^Chy}WcFQNz%8pJAZ*v<^S!`|Jy zEG9Pn!~EGf-+X80yJyZhla(D%?b6_X9I{aXqY?P8BVglTi%CAtXs&;V=DJ3`beBv* z>#+*B2Y8~lyBF2xD$v){r{_2lz}#vK*6fXfrHvId8V$0)I*y06_eHLf0z$tD$2g}~ z8Sz^B75sj(WF+V5ab^b3!CcjRj+dkq;QDW6#sx$jiiVkmIU_by+(ypsU-dRO3dn%9 z`!+&p|1zTv^ba8I!vs8OX&1RlMqtI);jo)D7=b5`9wTM@LFx0zA+uNbVWQV$RAiOn zZh1ZD*-v)B@}29reYdP0XZL35?IS5*!RA1?`g<_4W4xi<|Oq?)sV^85$iJIHOL{h*ka(8pI?k*$d2wLy8h@9o7a;*M73W{+` zj?)l7Zfaw8(C4e*=rNJa=|>#ev>)Bix$?85%js4uF8%tVIBB$7vC%-DMCy+_E1IV$1`hNJ+6of2WO?{kN$(`6`5F5osz z(6v8ogr(9-v}sN5bzDlyg{|XQsJ`34*J>^hJTZ6V0w^;waT{nI)A69X0jGAR^9_7L z$o+;6-LJ6F*7z5G`8Wwv=DJ}~bP(qkCY(oYUKO{sCz27Mgy1h!81FuTap~P9Mg7R; z779!D_2!m?;uxqklDEdeb^bKG9y*VsSzo51N&P^V%4~xI{MH4)bD0l!O5Krw_U1PD zh6TVYa0sV$t+cn{yeo(ESP8qmF`Z*H0lWV0d_Dm#ejZr3C6GJO1Zz^?XRTFkZpv`l zqBB1`hLzv0Cy8gmu;!bM80hQA_6>Glv^ z!mt8tUn31=_fSI-oGY`hlCQrc+RKzArfN^5s5MhUd1f)nP88{$%k5zWP!_Fu;Yp=- z*V9g$)xcg$SUJ5`CP6k^l1I&Z(vjaB|Eq|!uk~jZk5)N zx7GL`DcY1=NNLjFy2~V5!?=t{fVScnJs%Y?8i9WRC045t!v^v;00000NkvXXu0mjf Dyp2SJ literal 0 HcmV?d00001 diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..03560e3a6183c2a9179daf3d0d681fa780c73d22 GIT binary patch literal 184010 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 Ao&W#< literal 0 HcmV?d00001 diff --git a/docs/google14ef723abb376cd3.html b/docs/google14ef723abb376cd3.html new file mode 100644 index 0000000..f3bf171 --- /dev/null +++ b/docs/google14ef723abb376cd3.html @@ -0,0 +1 @@ +google-site-verification: google14ef723abb376cd3.html \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ae7af33 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,66 @@ +--- +title: Action Scheduler - Background Processing Job Queue for WordPress +--- +## WordPress Job Queue with Background Processing + +Action Scheduler is a library for triggering a WordPress hook to run at some time in the future (or as soon as possible, in the case of an async action). Each hook can be scheduled with unique data, to allow callbacks to perform operations on that data. The hook can also be scheduled to run on one or more occasions. + +Think of it like an extension to `do_action()` which adds the ability to delay and repeat a hook. + +It just so happens, this functionality also creates a robust job queue for background processing large queues of tasks in WordPress. With the addition of logging and an [administration interface](/admin/), it also provides traceability on your tasks processed in the background. + +### Battle-Tested Background Processing + +Every month, Action Scheduler processes millions of payments for [Subscriptions](https://woocommerce.com/products/woocommerce-subscriptions/), webhooks for [WooCommerce](https://wordpress.org/plugins/woocommerce/), as well as emails and other events for a range of other plugins. + +It's been seen on live sites processing queues in excess of 50,000 jobs and doing resource intensive operations, like processing payments and creating orders, in 10 concurrent queues at a rate of over 10,000 actions / hour without negatively impacting normal site operations. + +This is all possible on infrastructure and WordPress sites outside the control of the plugin author. + +Action Scheduler is specifically designed for distribution in WordPress plugins (and themes) - no server access required. If your plugin needs background processing, especially of large sets of tasks, Action Scheduler can help. + +### How it Works + +Action Scheduler stores the hook name, arguments and scheduled date for an action that should be triggered at some time in the future. + +The scheduler will attempt to run every minute by attaching itself as a callback to the `'action_scheduler_run_schedule'` hook, which is scheduled using WordPress's built-in [WP-Cron](http://codex.wordpress.org/Function_Reference/wp_cron) system. Once per minute on, it will also check on the `'shutdown'` hook of WP Admin requests whether there are pending actions, and if there are, it will initiate a queue via an async loopback request. + +When triggered, Action Scheduler will check for scheduled actions that have a due date at or before this point in time i.e. actions scheduled to run now or at sometime in the past. Action scheduled to run asynchronously, i.e. not scheduled, have a zero date, meaning they will always be due no matter when the check occurs. + +### Batch Processing Background Jobs + +If there are actions to be processed, Action Scheduler will stake a unique claim for a batch of 25 actions and begin processing that batch. The PHP process spawned to run the batch will then continue processing batches of 25 actions until it uses 90% of available memory or has been processing for 30 seconds. + +At that point, if there are additional actions to process, an asynchronous loopback request will be made to the site to continue processing actions in a new request. + +This process and the loopback requests will continue until all actions are processed. + +### Housekeeping + +Before processing a batch, the scheduler will remove any existing claims on actions which have been sitting in a queue for more than five minutes (or more specifically, 10 times the allowed time limit, which defaults to 30 seconds). + +Action Scheduler will also delete any actions which were completed or canceled more than a month ago. + +If an action runs for more than 5 minutes, Action Scheduler will assume the action has timed out and will mark it as failed. However, if all callbacks attached to the action were to successfully complete sometime after that 5 minute timeout, its status would later be updated to completed. + +### Traceable Background Processing + +Did your background job run? + +Never be left wondering with Action Scheduler's built-in record keeping. + +All events for each action are logged in the `actionscheduler_logs` table and displayed in the [administration interface](/admin/). + +The events logged by default include when an action: + * is created + * starts (including details of how it was run, e.g. via [WP CLI](/wp-cli/) or WP Cron) + * completes + * fails + +If it fails with an error that can be recorded, that error will be recorded in the log and visible in administration interface, making it possible to trace what went wrong at some point in the past on a site you didn't have access to in the past. + +## Credits + +Action Scheduler is developed and maintained by folks at [Automattic](http://automattic.com/) with significant early development completed by [Flightless](https://flightless.us/). + +Collaboration is cool. We'd love to work with you to improve Action Scheduler. [Pull Requests](https://github.com/woocommerce/action-scheduler/pulls) welcome. diff --git a/docs/mstile-150x150.png b/docs/mstile-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..3a3ed191109e302730b66dd5853a6e6f5228e489 GIT binary patch literal 4246 zcmbVPS5y;aUNax_Gj9x#S}cq_i~s3kp85i;)8WZYXN2tC|GNZ7l|2 zO;>Zf_I6aRUkSbY=ux%rOJvn7{lDr;4T%PLH|uEtf7C@DrDOFaUJZv)>U~&q)kH?gyWX zt^|BPnBO^F4LNyna@P9Sz<(c7qI776#2AjmJYWQe0U|U-%=1i8pEo}Xl?-KwR-9t* z?Y$yqFAyf4A8UN4?ONZRzMDGS2yyyD(b40VYqHwM@9~QhwYk!sjttE+^Jlk;6GZSd zRJo|6RO4O?q!Sb*R+uVCt=Ekh5?1K&Zg@{P34A9%aaC(cg|t1^ovcR7jTC0TGaS%` z2CIp0cfs5oC3p6CYHIJ#aGlhmY90&WsgT(5tglcJG^|j}+n;lE(P*MB`p?>fs=*zz zA}&!pjn17;=OGk~UCi}a0zYwP=oaDUS3PoEW;*~ralpY8 z%7Vrx5n7O}!gsoyQ4l;tyi{yE0LGf0cAm%;=DhG4YeNvhQ;mBStUL6&jBzya@FKUn z8cb)}NsfPu(?p94wFxk48zim;T>I+HxoiLa!S)=bLP?jEo*5$WMNg(>tr@sLs{( z*hs0}Ka>wW?P|WFiYWuG=JW%1pA6X>m}$t5t1G{q8Mu1?eUhbLZHDu)zXpfFfW^@2 ztu=#L?dTPK+zJ-s25@@#?$baCIA%0jsCiaX{);Q$q-R0Wg=+ux=%y&S^SjgF=5{D@ z4;U=!rj(yH&4Pg{OuVbh?03!TBQxVkH^#rBruME>mZSa(3SHxmX-yrLd#4LSBnqAE zw+E9jz1%#Nc+<+%vFQR$;?#3$YxN{Yns|fUjWjZjXrSv{)a>iEaFl~LDTr_v?R4;( zeu}erun`<*6dxPl7Rx`;+LQTXGQ0^ipqO1qg(f=|rf=HXl$P^U{Hk#vompJnM0q8| zzEBO8wjzv~!Y15}cunGL#}cp+U6mFJxHY=G@MRb?+fA9{ht&wUc;JkZ&_2~xA=Z5y;C+|Wvbm{eCR|L#hj%XcB!y)5YEN$r=` zAhQW$E06IFe9f5VtsVs(a+_1duKZ#d#5CJSdvq=H>PBr;_3GW#zKPKDyn}d!H$iHp zU3|t<-B23}JjE%u=E+zA&Bec&hVi2EQd3d4vP=3rE$`7&RoyVWqtnYuAC!tlEpY=1 z-6=fmPD-U4Uc{+JlVlC}YQ(m=FnVQYC&ks38C($!k? z^>1OQLdBBanNqQgN59-;lU2e74a=`RMC3*zP-~=$KI>)cZ$yw!;>C0`Hv3Zm5}m5D)0g^ z?GdS2X>kFAnVP^apaw2HHM1n0Mv9?Lsu>6Ek=>R`NL-0afYQZbfLr&zGihTEf&rI3 zrdtyBS_-n(I4fyMWR%F%g2YW2_WSu&5I+AZ-ts!73lm3OQ@f+d-OfE=po4fHtM49x zX60%;J5gVwH0Yti>9mPLx4|)6CRdGtR`TnRG_vOBa?qxAOtOdf!H>O+I3U?OH+`y< zi(J~BR;LXCE2ZnsV-4O})}YbT82)j@V$mNKp@V%ATdjrRugTEY@fQFq~p3abFw?4OO^6l^o z)8Xa29$+zIIxHTzCGGg^hKb|czK{YSRMU3K=T6^4m3C+shJTePb07Wcl@LRjD}&YS zSDeR;j+$QztuLcF9$B38HG*5X`}fH67KIRA158yoks|y9X&L>@W5e6wLPgD8si0 zcDCI+C6**pHCvieO}RPeIrf`5@JMxf7s-Sa&`du&ujj=&OEB?mH+SiC_YO(@bMP`! zN=m_Uj#rDdcX^av34Bi=LO*1njz?*j^D?ib%BEr@6yh*lyR7Ey^;XkdSsZ(PmbQIR+uJwq1D(y^ z8b75Vvx_3~{cSBLifk$-MJCHMtxQWb`8*&O@68TC{k-|pg81c~-r&8|tK`$fTXqA% zG@-t5HQ&k#xcA2h#VVf9c{?7cLmVXJ34`GG&-&{qwhh_OY({He*4A36N;{V;@*Lij z!3dkn5yO5KUHo#Sl?+Tt^JIiI1x#pLKb{D#DQv~PAWoHs*d!42XQ42&kE|vqpwCR5 zz3Te^>hJ4~B$_?hN|O^^Y1}d7Qj_;pmC=h&AUgR{hBHd*9k+Q3e(%Uj4M~)^>nU&h z54Ox*G%^#}shSje?->5+o_noN^~kAfh1)|Cgk7Xd%usZ52~~h)I)-ScTE|Jz<(YlK z8X0X;g3hkAIt+U}$GLb*!OBm$aF?89G>> zK}{z__CW@OFReY_H^Dw-dxgt-=rl)_`$CK^nb%$t_#xEaLRXFQ?@Fvwx=e^f?>B*% zw@wxRtR;H+Q34v){j~J$Y96y*vn(snp}nIg9(G4P`r`5uOOEYg8Jpr$K#b*m=SV5B ze}=C&gX+R>qo?D2-;|WW3UswMcIqSo)9**7&gJzltDq=?i)^1<_e(sx1WhVj)pSE7 z{AC=8e5T~wo|I8MR4>$aDOm+9zo7wYf`_y#$>DPe|$xHxilZmv}t zUuBh;dYr~AeX!x_C;XAWcy^90iAVgxE7FB?V+Y=?Ui{-s#q+xX{6wSSFQA8!=A}IE z!7N=Xci`QJ%Mnrc!5Ne&71h08BjQO)Jl-b=d_;WNWPN@X-pUOkxn(s1%cQwJ}R4 zpvq;#?+Q<4DQJ3;*pJGYbj%ypBJc_MKD894&nB9KhHa-PV_2QKnR2oHQ|&R2IQ_z+ zDtOPHHwVhdLxyuQA0U+A?8LHJ7PQ0Q_OA44;P_)Pjp3uRM_4q18;*q zGP@_fxP3b5CmW^~kzEa>wD;^Wi;t%jRgIP;NCj78~tWou6MN3$0 zm3e605e2SjkMnNWQ~ER1XKlNX+1i@LYtHUVYKn6WO^xQ(R4{%R!>yMW8*drL@z;}% z@hZk-G| zN;)skL|fP7Pa|k;R((sSRaJhqzfA(RDnXcGeWZSU=0P1E=V76Cd&f}C%V!7v?XY~s z;L5Zi=P8B>U*;q^S>+=Ab{0;{(EQ#QS2J0f9M*e|UU@3B%RA}m!gWT)zz?5XUf3A6 z5{0sBU6nT7T%U+!scl z#6o2qX+^}tToR@#-Qqn!Mxb(^F23AO#C7GHv)(Olr;Kx|8b>>Ne5=GmWVXptW+c#u z#eYZ6ULU(l?AR}Fo`GK;HXYGWV|wB$8&L;jbbpY<(BFNC~Q^6 zNt47sIt6AV8Hd1yz10R=0&h_i4m__yxW?MSN9fdXeX8bGx*vKnL_XjkPbIo`@{jnU zwNMI=I;pMM@!Ihj@e%SpJPnVnz7iJK?{H%qs8Wvb*2doRs3FC4J0I;mw zLuolVX)we@Rvrd{UZ$k1986ZWoo$-?e;K@doZV4@|F_|To=@?m!5n8|fi-gQ7kGy8 zLAiUn2w(%Bxd^yF!#V)~0XfV2%-7InVq$%d`v>zgC6b@Q;0rgkV vc4t~porysI%tTMeKnHIJL;ov8p3XhMCKnraA1 WARNING: because the async queue runner introduced in Action Scheduler 3.0 will continue asynchronous loopback requests to process actions, increasing the number of concurrent batches can substantially increase server load and take down a site. [WP CLI](/wp-cli/) is a better method to achieve higher throughput. + +## Increasing Initialisation Rate of Runners + +By default, Action Scheduler initiates at most one queue runner every time the `'action_scheduler_run_queue'` action is triggered by WP Cron. + +Because this action is only triggered at most once every minute, then there will rarely be more than one queue processing actions even if the concurrent runners are increased. + +To handle larger queues on more powerful servers, it's possible to initiate additional queue runners whenever the `'action_scheduler_run_queue'` action is run. + +That can be done by initiating additional secure requests to our server via loopback requests. + +The code below demonstrates how to create 5 loopback requests each time a queue begins: + +```php +/** + * Trigger 5 additional loopback requests with unique URL params. + */ +function eg_request_additional_runners() { + + // allow self-signed SSL certificates + add_filter( 'https_local_ssl_verify', '__return_false', 100 ); + + for ( $i = 0; $i < 5; $i++ ) { + $response = wp_remote_post( admin_url( 'admin-ajax.php' ), array( + 'method' => 'POST', + 'timeout' => 45, + 'redirection' => 5, + 'httpversion' => '1.0', + 'blocking' => false, + 'headers' => array(), + 'body' => array( + 'action' => 'eg_create_additional_runners', + 'instance' => $i, + 'eg_nonce' => wp_create_nonce( 'eg_additional_runner_' . $i ), + ), + 'cookies' => array(), + ) ); + } +} +add_action( 'action_scheduler_run_queue', 'eg_request_additional_runners', 0 ); + +/** + * Handle requests initiated by eg_request_additional_runners() and start a queue runner if the request is valid. + */ +function eg_create_additional_runners() { + + if ( isset( $_POST['eg_nonce'] ) && isset( $_POST['instance'] ) && wp_verify_nonce( $_POST['eg_nonce'], 'eg_additional_runner_' . $_POST['instance'] ) ) { + ActionScheduler_QueueRunner::instance()->run(); + } + + wp_die(); +} +add_action( 'wp_ajax_nopriv_eg_create_additional_runners', 'eg_create_additional_runners', 0 ); +``` + +> WARNING: because of the processing rate of scheduled actions, this kind of increase can very easily take down a site. Use only on high-powered servers and be sure to test before attempting to use it in production. + +## Cleaning Failed Actions + +By default, Action Scheduler does not automatically delete old failed actions. There are two optional methods of removing these actions: + +- Include the failed status in the list of statuses to purge: +```php +add_filter( 'action_scheduler_default_cleaner_statuses', function( $statuses ) { + $statuses[] = ActionScheduler_Store::STATUS_FAILED; + return $statuses; +} ); +``` +- Use [WP CLI](/wp-cli/): +```shell +// Example +wp action-scheduler clean --status=failed --batch-size=50 --before='90 days ago' --pause=2 +``` + +## High Volume Plugin + +It's not necessary to add all of this code yourself, there is a handy plugin to get access to each of these increases - the [Action Scheduler - High Volume](https://github.com/woocommerce/action-scheduler-high-volume) plugin. diff --git a/docs/safari-pinned-tab.svg b/docs/safari-pinned-tab.svg new file mode 100644 index 0000000..b67c32b --- /dev/null +++ b/docs/safari-pinned-tab.svg @@ -0,0 +1,40 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/docs/site.webmanifest b/docs/site.webmanifest new file mode 100644 index 0000000..de65106 --- /dev/null +++ b/docs/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..5a779d1 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,154 @@ +--- +description: Learn how to use the Action Scheduler background processing job queue for WordPress in your WordPress plugin. +--- +# Usage + +Using Action Scheduler requires: + +1. installing the library +1. scheduling an action + +## Installation + +There are two ways to install Action Scheduler: + +1. regular WordPress plugin; or +1. a library within your plugin's codebase. + +Note that [Action Scheduler follows an L-2 dependency version policy](https://developer.woocommerce.com/2023/10/24/action-scheduler-to-adopt-l-2-dependency-version-policy/). That is, the library requires at least the "latest minus two" version of WordPress and the PHP minimum version requirement of that WordPress version. + +### Usage as a Plugin + +Action Scheduler includes the necessary file headers to be used as a standard WordPress plugin. + +To install it as a plugin: + +1. Download the .zip archive of the latest [stable release](https://github.com/woocommerce/action-scheduler/releases) +1. Go to the **Plugins > Add New > Upload** administration screen on your WordPress site +1. Select the archive file you just downloaded +1. Click **Install Now** +1. Click **Activate** + +Or clone the Git repository into your site's `wp-content/plugins` folder. + +Using Action Scheduler as a plugin can be handy for developing against newer versions, rather than having to update the subtree in your codebase. **When installed as a plugin, Action Scheduler does not provide any user interfaces for scheduling actions**. The only way to interact with Action Scheduler is via code. + +### Usage as a Library + +To use Action Scheduler as a library: + +1. include the Action Scheduler codebase +1. load the library by including the `action-scheduler.php` file + +Using a [subtree in your plugin, theme or site's Git repository](https://www.atlassian.com/blog/git/alternatives-to-git-submodule-git-subtree) to include Action Scheduler is the recommended method. Composer can also be used. + +To include Action Scheduler as a git subtree: + +#### Step 1. Add the Repository as a Remote + +``` +git remote add -f subtree-action-scheduler https://github.com/woocommerce/action-scheduler.git +``` + +Adding the subtree as a remote allows us to refer to it in short from via the name `subtree-action-scheduler`, instead of the full GitHub URL. + +#### Step 2. Add the Repo as a Subtree + +``` +git subtree add --prefix libraries/action-scheduler subtree-action-scheduler trunk --squash +``` + +This will add the `trunk` branch of Action Scheduler to your repository in the folder `libraries/action-scheduler`. + +You can change the `--prefix` to change where the code is included. Or change the `trunk` branch to a tag, like `2.1.0` to include only a stable version. + +#### Step 3. Update the Subtree + +To update Action Scheduler to a new version, use the commands: + +``` +git fetch subtree-action-scheduler trunk +git subtree pull --prefix libraries/action-scheduler subtree-action-scheduler trunk --squash +``` + +### Loading Action Scheduler + +Regardless of how it is installed, to load Action Scheduler, you only need to include the `action-scheduler.php` file, e.g. + +```php + Action Scheduler +1. Take a screenshot of the action status counts at the top of the page. Example screenshot: https://cld.wthms.co/kwIqv7 + + +#### Stage 2: Install & Activate Action Scheduler 3.0+ as a plugin + +1. The migration will start almost immediately +1. Keep an eye on your error log +1. Report any notices, errors or other issues on GitHub + +#### Stage 3: Verify Migration + +1. Go to Tools > Action Scheduler +1. Take a screenshot of the action status counts at the top of the page. +1. Verify the counts match the status counts taken in Stage 1 (the Completed counts could be higher because actions will have been completed to run the migration). diff --git a/docs/wp-cli.md b/docs/wp-cli.md new file mode 100644 index 0000000..63287d7 --- /dev/null +++ b/docs/wp-cli.md @@ -0,0 +1,100 @@ +--- +description: Learn how to do WordPress background processing at scale with WP CLI and the Action Scheduler job queue. +--- +# WP CLI + +Action Scheduler has custom [WP CLI](http://wp-cli.org) commands available for processing actions. + +For large sites, WP CLI is a much better choice for running queues of actions than the default WP Cron runner. These are some common cases where WP CLI is a better option: + +* long-running tasks - Tasks that take a significant amount of time to run +* large queues - A large number of tasks will naturally take a longer time +* other plugins with extensive WP Cron usage - WP Cron's limited resources are spread across more tasks + +With a regular web request, you may have to deal with script timeouts enforced by hosts, or other restraints that make it more challenging to run Action Scheduler tasks. Utilizing WP CLI to run commands directly on the server give you more freedom. This means that you typically don't have the same constraints of a normal web request. + +If you choose to utilize WP CLI exclusively, you can disable the normal WP CLI queue runner by installing the [Action Scheduler - Disable Default Queue Runner](https://github.com/woocommerce/action-scheduler-disable-default-runner) plugin. Note that if you do this, you **must** run Action Scheduler via WP CLI or another method, otherwise no scheduled actions will be processed. + +## Commands + +These are the commands available to use with Action Scheduler: + +* `action-scheduler clean` + + Options: + * `--batch-size` - This is the number of actions per status to clean in a single batch. Default is `20`. + * `--batches` - This is the number of batches to process. Default 0 means that batches will continue to process until there are no more actions to delete. + * `--status` - Process only actions with specific status or statuses. Default is `canceled` and `complete`. Define multiple statuses as a comma separated string (without spaces), e.g. `--status=complete,failed,canceled` + * `--before` - Process only actions with scheduled date older than this. Defaults to 31 days. e.g `--before='7 days ago'`, `--before='02-Feb-2020 20:20:20'` + * `--pause` - The number of seconds to pause between batches. Default no pause. + +* `action-scheduler migrate` + + **Note**: This command is only available while the migration to custom tables is in progress. + +* `action-scheduler run` + + Options: + * `--batch-size` - This is the number of actions to run in a single batch. The default is `100`. + * `--batches` - This is the number of batches to run. Using 0 means that batches will continue running until there are no more actions to run. + * `--hooks` - Process only actions with specific hook or hooks, like `'woocommerce_scheduled_subscription_payment'`. By default, actions with any hook will be processed. Define multiple hooks as a comma separated string (without spaces), e.g. `--hooks=woocommerce_scheduled_subscription_trial_end,woocommerce_scheduled_subscription_payment,woocommerce_scheduled_subscription_expiration` + * `--group` - Process only actions in a specific group, like `'woocommerce-memberships'`. By default, actions in any group (or no group) will be processed. + * `--exclude-groups` - Ignore actions from the specified group or groups (to specify multiple groups, supply a comma-separated list of slugs). This option is ignored if `--group` is also specified. + * `--force` - By default, Action Scheduler limits the number of concurrent batches that can be run at once to ensure the server does not get overwhelmed. Using the `--force` flag overrides this behavior to force the WP CLI queue to run. + +* `action-scheduler action cancel` +* `action-scheduler action create` +* `action-scheduler action delete` +* `action-scheduler action generate` +* `action-scheduler action get` +* `action-scheduler action list` +* `action-scheduler action next` +* `action-scheduler action run` +* `action-scheduler datastore` +* `action-scheduler runner` +* `action-scheduler status` +* `action-scheduler version` + +The best way to get a full list of commands and their available options is to use WP CLI itself. This can be done by running `wp action-scheduler` to list all Action Scheduler commands, or by including the `--help` flag with any of the individual commands. This will provide all relevant parameters and flags for the command. + +## Cautionary Note on Action Dependencies when using `--group` or `--hooks` Options + +The `--group` and `--hooks` options should be used with caution if you have an implicit dependency between scheduled actions based on their schedule. + +For example, consider two scheduled actions for the same subscription: + +* `scheduled_payment` scheduled for `2015-11-13 00:00:00` and +* `scheduled_expiration` scheduled for `2015-11-13 00:01:00`. + +Under normal conditions, Action Scheduler will ensure the `scheduled_payment` action is run before the `scheduled_expiration` action. Because that's how they are scheduled. + +However, when using the `--hooks` option, the `scheduled_payment` and `scheduled_expiration` actions will be processed in separate queues. As a result, this dependency is not guaranteed. + +For example, consider a site with both: + +* 100,000 `scheduled_payment` actions, scheduled for `2015-11-13 00:00:00` +* 100 `scheduled_expiration` actions, scheduled for `2015-11-13 00:01:00` + +If two queue runners are running alongside each other with each runner dedicated to just one of these hooks, the queue runner handling expiration hooks will complete the processing of the expiration hooks more quickly than the queue runner handling all the payment actions. + +**Because of this, the `--group` and `--hooks` options should be used with caution to avoid processing actions with an implicit dependency based on their schedule in separate queues.** + +## Improving Performance with `--group` or `--hooks` + +Being able to run queues for specific hooks or groups of actions is valuable at scale. Why? Because it means you can restrict the concurrency for similar actions. + +For example, let's say you have 300,000 actions queued up comprised of: + +* 100,000 renewals payments +* 100,000 email notifications +* 100,000 membership status updates + +Action Scheduler's default WP Cron queue runner will process them all together. e.g. when it claims a batch of actions, some may be emails, some membership updates and some renewals. + +When you add concurrency to that, you can end up with issues. For example, if you have 3 queues running, they may all be attempting to process similar actions at the same time, which can lead to querying the same database tables with similar queries. Depending on the code/queries running, this can lead to database locks or other issues. + +If you can batch based on each action's group, then you can improve performance by processing like actions consecutively, but still processing the full set of actions concurrently. + +For example, if one queue is created to process emails, another to process membership updates, and another to process renewal payments, then the same queries won't be run at the same time, and 3 separate queues will be able to run more efficiently. + +The WP CLI runner can achieve this using the `--group` option to create queue runners that focus on a specific group and, optionally, the `--exclude-groups` option to create one or more 'catch-all' queue runners that ignore those groups. diff --git a/functions.php b/functions.php new file mode 100644 index 0000000..59e5542 --- /dev/null +++ b/functions.php @@ -0,0 +1,495 @@ +create( + array( + 'type' => 'async', + 'hook' => $hook, + 'arguments' => $args, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ) + ); +} + +/** + * Schedule an action to run one time + * + * @param int $timestamp When the job will run. + * @param string $hook The hook to trigger. + * @param array $args Arguments to pass when the hook triggers. + * @param string $group The group to assign this job to. + * @param bool $unique Whether the action should be unique. It will not be scheduled if another pending or running action has the same hook and group parameters. + * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. + * + * @return int The action ID. Zero if there was an error scheduling the action. + */ +function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { + if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { + return 0; + } + + /** + * Provides an opportunity to short-circuit the default process for enqueuing single + * actions. + * + * Returning a value other than null from the filter will short-circuit the normal + * process. The expectation in such a scenario is that callbacks will return an integer + * representing the scheduled action ID (scheduled using some alternative process) or else + * zero. + * + * @param int|null $pre_option The value to return instead of the option value. + * @param int $timestamp When the action will run. + * @param string $hook Action hook. + * @param array $args Action arguments. + * @param string $group Action group. + * @param int $priorities Action priority. + */ + $pre = apply_filters( 'pre_as_schedule_single_action', null, $timestamp, $hook, $args, $group, $priority ); + if ( null !== $pre ) { + return is_int( $pre ) ? $pre : 0; + } + + return ActionScheduler::factory()->create( + array( + 'type' => 'single', + 'hook' => $hook, + 'arguments' => $args, + 'when' => $timestamp, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ) + ); +} + +/** + * Schedule a recurring action + * + * @param int $timestamp When the first instance of the job will run. + * @param int $interval_in_seconds How long to wait between runs. + * @param string $hook The hook to trigger. + * @param array $args Arguments to pass when the hook triggers. + * @param string $group The group to assign this job to. + * @param bool $unique Whether the action should be unique. It will not be scheduled if another pending or running action has the same hook and group parameters. + * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. + * + * @return int The action ID. Zero if there was an error scheduling the action. + */ +function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { + if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { + return 0; + } + + $interval = (int) $interval_in_seconds; + + // We expect an integer and allow it to be passed using float and string types, but otherwise + // should reject unexpected values. + // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison + if ( ! is_numeric( $interval_in_seconds ) || $interval_in_seconds != $interval ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: provided value 2: provided type. */ + esc_html__( 'An integer was expected but "%1$s" (%2$s) was received.', 'action-scheduler' ), + esc_html( $interval_in_seconds ), + esc_html( gettype( $interval_in_seconds ) ) + ), + '3.6.0' + ); + + return 0; + } + + /** + * Provides an opportunity to short-circuit the default process for enqueuing recurring + * actions. + * + * Returning a value other than null from the filter will short-circuit the normal + * process. The expectation in such a scenario is that callbacks will return an integer + * representing the scheduled action ID (scheduled using some alternative process) or else + * zero. + * + * @param int|null $pre_option The value to return instead of the option value. + * @param int $timestamp When the action will run. + * @param int $interval_in_seconds How long to wait between runs. + * @param string $hook Action hook. + * @param array $args Action arguments. + * @param string $group Action group. + * @param int $priority Action priority. + */ + $pre = apply_filters( 'pre_as_schedule_recurring_action', null, $timestamp, $interval_in_seconds, $hook, $args, $group, $priority ); + if ( null !== $pre ) { + return is_int( $pre ) ? $pre : 0; + } + + return ActionScheduler::factory()->create( + array( + 'type' => 'recurring', + 'hook' => $hook, + 'arguments' => $args, + 'when' => $timestamp, + 'pattern' => $interval_in_seconds, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ) + ); +} + +/** + * Schedule an action that recurs on a cron-like schedule. + * + * @param int $timestamp The first instance of the action will be scheduled + * to run at a time calculated after this timestamp matching the cron + * expression. This can be used to delay the first instance of the action. + * @param string $schedule A cron-link schedule string. + * @see http://en.wikipedia.org/wiki/Cron + * * * * * * * + * ┬ ┬ ┬ ┬ ┬ ┬ + * | | | | | | + * | | | | | + year [optional] + * | | | | +----- day of week (0 - 7) (Sunday=0 or 7) + * | | | +---------- month (1 - 12) + * | | +--------------- day of month (1 - 31) + * | +-------------------- hour (0 - 23) + * +------------------------- min (0 - 59) + * @param string $hook The hook to trigger. + * @param array $args Arguments to pass when the hook triggers. + * @param string $group The group to assign this job to. + * @param bool $unique Whether the action should be unique. It will not be scheduled if another pending or running action has the same hook and group parameters. + * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255. + * + * @return int The action ID. Zero if there was an error scheduling the action. + */ +function as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { + if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { + return 0; + } + + /** + * Provides an opportunity to short-circuit the default process for enqueuing cron + * actions. + * + * Returning a value other than null from the filter will short-circuit the normal + * process. The expectation in such a scenario is that callbacks will return an integer + * representing the scheduled action ID (scheduled using some alternative process) or else + * zero. + * + * @param int|null $pre_option The value to return instead of the option value. + * @param int $timestamp When the action will run. + * @param string $schedule Cron-like schedule string. + * @param string $hook Action hook. + * @param array $args Action arguments. + * @param string $group Action group. + * @param int $priority Action priority. + */ + $pre = apply_filters( 'pre_as_schedule_cron_action', null, $timestamp, $schedule, $hook, $args, $group, $priority ); + if ( null !== $pre ) { + return is_int( $pre ) ? $pre : 0; + } + + return ActionScheduler::factory()->create( + array( + 'type' => 'cron', + 'hook' => $hook, + 'arguments' => $args, + 'when' => $timestamp, + 'pattern' => $schedule, + 'group' => $group, + 'unique' => $unique, + 'priority' => $priority, + ) + ); +} + +/** + * Cancel the next occurrence of a scheduled action. + * + * While only the next instance of a recurring or cron action is unscheduled by this method, that will also prevent + * all future instances of that recurring or cron action from being run. Recurring and cron actions are scheduled in + * a sequence instead of all being scheduled at once. Each successive occurrence of a recurring action is scheduled + * only after the former action is run. If the next instance is never run, because it's unscheduled by this function, + * then the following instance will never be scheduled (or exist), which is effectively the same as being unscheduled + * by this method also. + * + * @param string $hook The hook that the job will trigger. + * @param array $args Args that would have been passed to the job. + * @param string $group The group the job is assigned to. + * + * @return int|null The scheduled action ID if a scheduled action was found, or null if no matching action found. + */ +function as_unschedule_action( $hook, $args = array(), $group = '' ) { + if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { + return 0; + } + $params = array( + 'hook' => $hook, + 'status' => ActionScheduler_Store::STATUS_PENDING, + 'orderby' => 'date', + 'order' => 'ASC', + 'group' => $group, + ); + if ( is_array( $args ) ) { + $params['args'] = $args; + } + + $action_id = ActionScheduler::store()->query_action( $params ); + + if ( $action_id ) { + try { + ActionScheduler::store()->cancel_action( $action_id ); + } catch ( Exception $exception ) { + ActionScheduler::logger()->log( + $action_id, + sprintf( + /* translators: %1$s is the name of the hook to be cancelled, %2$s is the exception message. */ + __( 'Caught exception while cancelling action "%1$s": %2$s', 'action-scheduler' ), + $hook, + $exception->getMessage() + ) + ); + + $action_id = null; + } + } + + return $action_id; +} + +/** + * Cancel all occurrences of a scheduled action. + * + * @param string $hook The hook that the job will trigger. + * @param array $args Args that would have been passed to the job. + * @param string $group The group the job is assigned to. + */ +function as_unschedule_all_actions( $hook, $args = array(), $group = '' ) { + if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { + return; + } + if ( empty( $args ) ) { + if ( ! empty( $hook ) && empty( $group ) ) { + ActionScheduler_Store::instance()->cancel_actions_by_hook( $hook ); + return; + } + if ( ! empty( $group ) && empty( $hook ) ) { + ActionScheduler_Store::instance()->cancel_actions_by_group( $group ); + return; + } + } + do { + $unscheduled_action = as_unschedule_action( $hook, $args, $group ); + } while ( ! empty( $unscheduled_action ) ); +} + +/** + * Check if there is an existing action in the queue with a given hook, args and group combination. + * + * An action in the queue could be pending, in-progress or async. If the is pending for a time in + * future, its scheduled date will be returned as a timestamp. If it is currently being run, or an + * async action sitting in the queue waiting to be processed, in which case boolean true will be + * returned. Or there may be no async, in-progress or pending action for this hook, in which case, + * boolean false will be the return value. + * + * @param string $hook Name of the hook to search for. + * @param array $args Arguments of the action to be searched. + * @param string $group Group of the action to be searched. + * + * @return int|bool The timestamp for the next occurrence of a pending scheduled action, true for an async or in-progress action or false if there is no matching action. + */ +function as_next_scheduled_action( $hook, $args = null, $group = '' ) { + if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { + return false; + } + + $params = array( + 'hook' => $hook, + 'orderby' => 'date', + 'order' => 'ASC', + 'group' => $group, + ); + + if ( is_array( $args ) ) { + $params['args'] = $args; + } + + $params['status'] = ActionScheduler_Store::STATUS_RUNNING; + $action_id = ActionScheduler::store()->query_action( $params ); + if ( $action_id ) { + return true; + } + + $params['status'] = ActionScheduler_Store::STATUS_PENDING; + $action_id = ActionScheduler::store()->query_action( $params ); + if ( null === $action_id ) { + return false; + } + + $action = ActionScheduler::store()->fetch_action( $action_id ); + $scheduled_date = $action->get_schedule()->get_date(); + if ( $scheduled_date ) { + return (int) $scheduled_date->format( 'U' ); + } elseif ( null === $scheduled_date ) { // pending async action with NullSchedule. + return true; + } + + return false; +} + +/** + * Check if there is a scheduled action in the queue but more efficiently than as_next_scheduled_action(). + * + * It's recommended to use this function when you need to know whether a specific action is currently scheduled + * (pending or in-progress). + * + * @since 3.3.0 + * + * @param string $hook The hook of the action. + * @param array $args Args that have been passed to the action. Null will matches any args. + * @param string $group The group the job is assigned to. + * + * @return bool True if a matching action is pending or in-progress, false otherwise. + */ +function as_has_scheduled_action( $hook, $args = null, $group = '' ) { + if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { + return false; + } + + $query_args = array( + 'hook' => $hook, + 'status' => array( ActionScheduler_Store::STATUS_RUNNING, ActionScheduler_Store::STATUS_PENDING ), + 'group' => $group, + 'orderby' => 'none', + ); + + if ( null !== $args ) { + $query_args['args'] = $args; + } + + $action_id = ActionScheduler::store()->query_action( $query_args ); + + return null !== $action_id; +} + +/** + * Find scheduled actions + * + * @param array $args Possible arguments, with their default values. + * 'hook' => '' - the name of the action that will be triggered. + * 'args' => NULL - the args array that will be passed with the action. + * 'date' => NULL - the scheduled date of the action. Expects a DateTime object, a unix timestamp, or a string that can parsed with strtotime(). Used in UTC timezone. + * 'date_compare' => '<=' - operator for testing "date". accepted values are '!=', '>', '>=', '<', '<=', '='. + * 'modified' => NULL - the date the action was last updated. Expects a DateTime object, a unix timestamp, or a string that can parsed with strtotime(). Used in UTC timezone. + * 'modified_compare' => '<=' - operator for testing "modified". accepted values are '!=', '>', '>=', '<', '<=', '='. + * 'group' => '' - the group the action belongs to. + * 'status' => '' - ActionScheduler_Store::STATUS_COMPLETE or ActionScheduler_Store::STATUS_PENDING. + * 'claimed' => NULL - TRUE to find claimed actions, FALSE to find unclaimed actions, a string to find a specific claim ID. + * 'per_page' => 5 - Number of results to return. + * 'offset' => 0. + * 'orderby' => 'date' - accepted values are 'hook', 'group', 'modified', 'date' or 'none'. + * 'order' => 'ASC'. + * + * @param string $return_format OBJECT, ARRAY_A, or ids. + * + * @return array + */ +function as_get_scheduled_actions( $args = array(), $return_format = OBJECT ) { + if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) { + return array(); + } + $store = ActionScheduler::store(); + foreach ( array( 'date', 'modified' ) as $key ) { + if ( isset( $args[ $key ] ) ) { + $args[ $key ] = as_get_datetime_object( $args[ $key ] ); + } + } + $ids = $store->query_actions( $args ); + + if ( 'ids' === $return_format || 'int' === $return_format ) { + return $ids; + } + + $actions = array(); + foreach ( $ids as $action_id ) { + $actions[ $action_id ] = $store->fetch_action( $action_id ); + } + + if ( ARRAY_A === $return_format ) { + foreach ( $actions as $action_id => $action_object ) { + $actions[ $action_id ] = get_object_vars( $action_object ); + } + } + + return $actions; +} + +/** + * Helper function to create an instance of DateTime based on a given + * string and timezone. By default, will return the current date/time + * in the UTC timezone. + * + * Needed because new DateTime() called without an explicit timezone + * will create a date/time in PHP's timezone, but we need to have + * assurance that a date/time uses the right timezone (which we almost + * always want to be UTC), which means we need to always include the + * timezone when instantiating datetimes rather than leaving it up to + * the PHP default. + * + * @param mixed $date_string A date/time string. Valid formats are explained in http://php.net/manual/en/datetime.formats.php. + * @param string $timezone A timezone identifier, like UTC or Europe/Lisbon. The list of valid identifiers is available http://php.net/manual/en/timezones.php. + * + * @return ActionScheduler_DateTime + */ +function as_get_datetime_object( $date_string = null, $timezone = 'UTC' ) { + if ( is_object( $date_string ) && $date_string instanceof DateTime ) { + $date = new ActionScheduler_DateTime( $date_string->format( 'Y-m-d H:i:s' ), new DateTimeZone( $timezone ) ); + } elseif ( is_numeric( $date_string ) ) { + $date = new ActionScheduler_DateTime( '@' . $date_string, new DateTimeZone( $timezone ) ); + } else { + $date = new ActionScheduler_DateTime( null === $date_string ? 'now' : $date_string, new DateTimeZone( $timezone ) ); + } + return $date; +} diff --git a/lib/WP_Async_Request.php b/lib/WP_Async_Request.php new file mode 100644 index 0000000..a40c80a --- /dev/null +++ b/lib/WP_Async_Request.php @@ -0,0 +1,188 @@ +identifier = $this->prefix . '_' . $this->action; + + add_action( 'wp_ajax_' . $this->identifier, array( $this, 'maybe_handle' ) ); + add_action( 'wp_ajax_nopriv_' . $this->identifier, array( $this, 'maybe_handle' ) ); + } + + /** + * Set data used during the request + * + * @param array $data Data. + * + * @return $this + */ + public function data( $data ) { + $this->data = $data; + + return $this; + } + + /** + * Dispatch the async request + * + * @return array|WP_Error + */ + public function dispatch() { + $url = add_query_arg( $this->get_query_args(), $this->get_query_url() ); + $args = $this->get_post_args(); + + return wp_remote_post( esc_url_raw( $url ), $args ); + } + + /** + * Get query args + * + * @return array + */ + protected function get_query_args() { + if ( property_exists( $this, 'query_args' ) ) { + return $this->query_args; + } + + $args = array( + 'action' => $this->identifier, + 'nonce' => wp_create_nonce( $this->identifier ), + ); + + /** + * Filters the post arguments used during an async request. + * + * @param array $url + */ + return apply_filters( $this->identifier . '_query_args', $args ); + } + + /** + * Get query URL + * + * @return string + */ + protected function get_query_url() { + if ( property_exists( $this, 'query_url' ) ) { + return $this->query_url; + } + + $url = admin_url( 'admin-ajax.php' ); + + /** + * Filters the post arguments used during an async request. + * + * @param string $url + */ + return apply_filters( $this->identifier . '_query_url', $url ); + } + + /** + * Get post args + * + * @return array + */ + protected function get_post_args() { + if ( property_exists( $this, 'post_args' ) ) { + return $this->post_args; + } + + $args = array( + 'timeout' => 0.01, + 'blocking' => false, + 'body' => $this->data, + 'cookies' => $_COOKIE, + 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), + ); + + /** + * Filters the post arguments used during an async request. + * + * @param array $args + */ + return apply_filters( $this->identifier . '_post_args', $args ); + } + + /** + * Maybe handle + * + * Check for correct nonce and pass to handler. + */ + public function maybe_handle() { + // Don't lock up other requests while processing. + session_write_close(); + + check_ajax_referer( $this->identifier, 'nonce' ); + + $this->handle(); + + wp_die(); + } + + /** + * Handle + * + * Override this method to perform any actions required + * during the async request. + */ + abstract protected function handle(); + + } +} diff --git a/lib/cron-expression/CronExpression.php b/lib/cron-expression/CronExpression.php new file mode 100644 index 0000000..33040ed --- /dev/null +++ b/lib/cron-expression/CronExpression.php @@ -0,0 +1,318 @@ + + * @link http://en.wikipedia.org/wiki/Cron + */ +class CronExpression +{ + const MINUTE = 0; + const HOUR = 1; + const DAY = 2; + const MONTH = 3; + const WEEKDAY = 4; + const YEAR = 5; + + /** + * @var array CRON expression parts + */ + private $cronParts; + + /** + * @var CronExpression_FieldFactory CRON field factory + */ + private $fieldFactory; + + /** + * @var array Order in which to test of cron parts + */ + private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE); + + /** + * Factory method to create a new CronExpression. + * + * @param string $expression The CRON expression to create. There are + * several special predefined values which can be used to substitute the + * CRON expression: + * + * @yearly, @annually) - Run once a year, midnight, Jan. 1 - 0 0 1 1 * + * @monthly - Run once a month, midnight, first of month - 0 0 1 * * + * @weekly - Run once a week, midnight on Sun - 0 0 * * 0 + * @daily - Run once a day, midnight - 0 0 * * * + * @hourly - Run once an hour, first minute - 0 * * * * + * +*@param CronExpression_FieldFactory $fieldFactory (optional) Field factory to use + * + * @return CronExpression + */ + public static function factory($expression, ?CronExpression_FieldFactory $fieldFactory = null) + { + $mappings = array( + '@yearly' => '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@hourly' => '0 * * * *' + ); + + if (isset($mappings[$expression])) { + $expression = $mappings[$expression]; + } + + return new self($expression, $fieldFactory ? $fieldFactory : new CronExpression_FieldFactory()); + } + + /** + * Parse a CRON expression + * + * @param string $expression CRON expression (e.g. '8 * * * *') + * @param CronExpression_FieldFactory $fieldFactory Factory to create cron fields + */ + public function __construct($expression, CronExpression_FieldFactory $fieldFactory) + { + $this->fieldFactory = $fieldFactory; + $this->setExpression($expression); + } + + /** + * Set or change the CRON expression + * + * @param string $value CRON expression (e.g. 8 * * * *) + * + * @return CronExpression + * @throws InvalidArgumentException if not a valid CRON expression + */ + public function setExpression($value) + { + $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); + if (count($this->cronParts) < 5) { + throw new InvalidArgumentException( + $value . ' is not a valid CRON expression' + ); + } + + foreach ($this->cronParts as $position => $part) { + $this->setPart($position, $part); + } + + return $this; + } + + /** + * Set part of the CRON expression + * + * @param int $position The position of the CRON expression to set + * @param string $value The value to set + * + * @return CronExpression + * @throws InvalidArgumentException if the value is not valid for the part + */ + public function setPart($position, $value) + { + if (!$this->fieldFactory->getField($position)->validate($value)) { + throw new InvalidArgumentException( + 'Invalid CRON field value ' . $value . ' as position ' . $position + ); + } + + $this->cronParts[$position] = $value; + + return $this; + } + + /** + * Get a next run date relative to the current date or a specific date + * + * @param string|DateTime $currentTime (optional) Relative calculation date + * @param int $nth (optional) Number of matches to skip before returning a + * matching next run date. 0, the default, will return the current + * date and time if the next run date falls on the current date and + * time. Setting this value to 1 will skip the first match and go to + * the second match. Setting this value to 2 will skip the first 2 + * matches and so on. + * @param bool $allowCurrentDate (optional) Set to TRUE to return the + * current date if it matches the cron expression + * + * @return DateTime + * @throws RuntimeException on too many iterations + */ + public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) + { + return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate); + } + + /** + * Get a previous run date relative to the current date or a specific date + * + * @param string|DateTime $currentTime (optional) Relative calculation date + * @param int $nth (optional) Number of matches to skip before returning + * @param bool $allowCurrentDate (optional) Set to TRUE to return the + * current date if it matches the cron expression + * + * @return DateTime + * @throws RuntimeException on too many iterations + * @see CronExpression::getNextRunDate + */ + public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) + { + return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate); + } + + /** + * Get multiple run dates starting at the current date or a specific date + * + * @param int $total Set the total number of dates to calculate + * @param string|DateTime $currentTime (optional) Relative calculation date + * @param bool $invert (optional) Set to TRUE to retrieve previous dates + * @param bool $allowCurrentDate (optional) Set to TRUE to return the + * current date if it matches the cron expression + * + * @return array Returns an array of run dates + */ + public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false) + { + $matches = array(); + for ($i = 0; $i < max(0, $total); $i++) { + $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate); + } + + return $matches; + } + + /** + * Get all or part of the CRON expression + * + * @param string $part (optional) Specify the part to retrieve or NULL to + * get the full cron schedule string. + * + * @return string|null Returns the CRON expression, a part of the + * CRON expression, or NULL if the part was specified but not found + */ + public function getExpression($part = null) + { + if (null === $part) { + return implode(' ', $this->cronParts); + } elseif (array_key_exists($part, $this->cronParts)) { + return $this->cronParts[$part]; + } + + return null; + } + + /** + * Helper method to output the full expression. + * + * @return string Full CRON expression + */ + public function __toString() + { + return $this->getExpression(); + } + + /** + * Determine if the cron is due to run based on the current date or a + * specific date. This method assumes that the current number of + * seconds are irrelevant, and should be called once per minute. + * + * @param string|DateTime $currentTime (optional) Relative calculation date + * + * @return bool Returns TRUE if the cron is due to run or FALSE if not + */ + public function isDue($currentTime = 'now') + { + if ('now' === $currentTime) { + $currentDate = date('Y-m-d H:i'); + $currentTime = strtotime($currentDate); + } elseif ($currentTime instanceof DateTime) { + $currentDate = $currentTime->format('Y-m-d H:i'); + $currentTime = strtotime($currentDate); + } else { + $currentTime = new DateTime($currentTime); + $currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0); + $currentDate = $currentTime->format('Y-m-d H:i'); + $currentTime = (int)($currentTime->getTimestamp()); + } + + return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime; + } + + /** + * Get the next or previous run date of the expression relative to a date + * + * @param string|DateTime $currentTime (optional) Relative calculation date + * @param int $nth (optional) Number of matches to skip before returning + * @param bool $invert (optional) Set to TRUE to go backwards in time + * @param bool $allowCurrentDate (optional) Set to TRUE to return the + * current date if it matches the cron expression + * + * @return DateTime + * @throws RuntimeException on too many iterations + */ + protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false) + { + if ($currentTime instanceof DateTime) { + $currentDate = $currentTime; + } else { + $currentDate = new DateTime($currentTime ? $currentTime : 'now'); + $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get())); + } + + $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0); + $nextRun = clone $currentDate; + $nth = (int) $nth; + + // Set a hard limit to bail on an impossible date + for ($i = 0; $i < 1000; $i++) { + + foreach (self::$order as $position) { + $part = $this->getExpression($position); + if (null === $part) { + continue; + } + + $satisfied = false; + // Get the field object used to validate this part + $field = $this->fieldFactory->getField($position); + // Check if this is singular or a list + if (strpos($part, ',') === false) { + $satisfied = $field->isSatisfiedBy($nextRun, $part); + } else { + foreach (array_map('trim', explode(',', $part)) as $listPart) { + if ($field->isSatisfiedBy($nextRun, $listPart)) { + $satisfied = true; + break; + } + } + } + + // If the field is not satisfied, then start over + if (!$satisfied) { + $field->increment($nextRun, $invert); + continue 2; + } + } + + // Skip this match if needed + if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { + $this->fieldFactory->getField(0)->increment($nextRun, $invert); + continue; + } + + return $nextRun; + } + + // @codeCoverageIgnoreStart + throw new RuntimeException('Impossible CRON expression'); + // @codeCoverageIgnoreEnd + } +} diff --git a/lib/cron-expression/CronExpression_AbstractField.php b/lib/cron-expression/CronExpression_AbstractField.php new file mode 100644 index 0000000..f8d5c00 --- /dev/null +++ b/lib/cron-expression/CronExpression_AbstractField.php @@ -0,0 +1,100 @@ + + */ +abstract class CronExpression_AbstractField implements CronExpression_FieldInterface +{ + /** + * Check to see if a field is satisfied by a value + * + * @param string $dateValue Date value to check + * @param string $value Value to test + * + * @return bool + */ + public function isSatisfied($dateValue, $value) + { + if ($this->isIncrementsOfRanges($value)) { + return $this->isInIncrementsOfRanges($dateValue, $value); + } elseif ($this->isRange($value)) { + return $this->isInRange($dateValue, $value); + } + + return $value == '*' || $dateValue == $value; + } + + /** + * Check if a value is a range + * + * @param string $value Value to test + * + * @return bool + */ + public function isRange($value) + { + return strpos($value, '-') !== false; + } + + /** + * Check if a value is an increments of ranges + * + * @param string $value Value to test + * + * @return bool + */ + public function isIncrementsOfRanges($value) + { + return strpos($value, '/') !== false; + } + + /** + * Test if a value is within a range + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInRange($dateValue, $value) + { + $parts = array_map('trim', explode('-', $value, 2)); + + return $dateValue >= $parts[0] && $dateValue <= $parts[1]; + } + + /** + * Test if a value is within an increments of ranges (offset[-to]/step size) + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInIncrementsOfRanges($dateValue, $value) + { + $parts = array_map('trim', explode('/', $value, 2)); + $stepSize = isset($parts[1]) ? $parts[1] : 0; + if ($parts[0] == '*' || $parts[0] === '0') { + return (int) $dateValue % $stepSize == 0; + } + + $range = explode('-', $parts[0], 2); + $offset = $range[0]; + $to = isset($range[1]) ? $range[1] : $dateValue; + // Ensure that the date value is within the range + if ($dateValue < $offset || $dateValue > $to) { + return false; + } + + for ($i = $offset; $i <= $to; $i+= $stepSize) { + if ($i == $dateValue) { + return true; + } + } + + return false; + } +} diff --git a/lib/cron-expression/CronExpression_DayOfMonthField.php b/lib/cron-expression/CronExpression_DayOfMonthField.php new file mode 100644 index 0000000..40c1d6c --- /dev/null +++ b/lib/cron-expression/CronExpression_DayOfMonthField.php @@ -0,0 +1,110 @@ + + */ +class CronExpression_DayOfMonthField extends CronExpression_AbstractField +{ + /** + * Get the nearest day of the week for a given day in a month + * + * @param int $currentYear Current year + * @param int $currentMonth Current month + * @param int $targetDay Target day of the month + * + * @return DateTime Returns the nearest date + */ + private static function getNearestWeekday($currentYear, $currentMonth, $targetDay) + { + $tday = str_pad($targetDay, 2, '0', STR_PAD_LEFT); + $target = new DateTime("$currentYear-$currentMonth-$tday"); + $currentWeekday = (int) $target->format('N'); + + if ($currentWeekday < 6) { + return $target; + } + + $lastDayOfMonth = $target->format('t'); + + foreach (array(-1, 1, -2, 2) as $i) { + $adjusted = $targetDay + $i; + if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) { + $target->setDate($currentYear, $currentMonth, $adjusted); + if ($target->format('N') < 6 && $target->format('m') == $currentMonth) { + return $target; + } + } + } + } + + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + // ? states that the field value is to be skipped + if ($value == '?') { + return true; + } + + $fieldValue = $date->format('d'); + + // Check to see if this is the last day of the month + if ($value == 'L') { + return $fieldValue == $date->format('t'); + } + + // Check to see if this is the nearest weekday to a particular value + if (strpos($value, 'W')) { + // Parse the target day + $targetDay = substr($value, 0, strpos($value, 'W')); + // Find out if the current day is the nearest day of the week + return $date->format('j') == self::getNearestWeekday( + $date->format('Y'), + $date->format('m'), + $targetDay + )->format('j'); + } + + return $this->isSatisfied($date->format('d'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('previous day'); + $date->setTime(23, 59); + } else { + $date->modify('next day'); + $date->setTime(0, 0); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-\?LW0-9A-Za-z]+/', $value); + } +} diff --git a/lib/cron-expression/CronExpression_DayOfWeekField.php b/lib/cron-expression/CronExpression_DayOfWeekField.php new file mode 100644 index 0000000..e9f68a7 --- /dev/null +++ b/lib/cron-expression/CronExpression_DayOfWeekField.php @@ -0,0 +1,124 @@ + + */ +class CronExpression_DayOfWeekField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + if ($value == '?') { + return true; + } + + // Convert text day of the week values to integers + $value = str_ireplace( + array('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'), + range(0, 6), + $value + ); + + $currentYear = $date->format('Y'); + $currentMonth = $date->format('m'); + $lastDayOfMonth = $date->format('t'); + + // Find out if this is the last specific weekday of the month + if (strpos($value, 'L')) { + $weekday = str_replace('7', '0', substr($value, 0, strpos($value, 'L'))); + $tdate = clone $date; + $tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth); + while ($tdate->format('w') != $weekday) { + $tdate->setDate($currentYear, $currentMonth, --$lastDayOfMonth); + } + + return $date->format('j') == $lastDayOfMonth; + } + + // Handle # hash tokens + if (strpos($value, '#')) { + list($weekday, $nth) = explode('#', $value); + // Validate the hash fields + if ($weekday < 1 || $weekday > 5) { + throw new InvalidArgumentException("Weekday must be a value between 1 and 5. {$weekday} given"); + } + if ($nth > 5) { + throw new InvalidArgumentException('There are never more than 5 of a given weekday in a month'); + } + // The current weekday must match the targeted weekday to proceed + if ($date->format('N') != $weekday) { + return false; + } + + $tdate = clone $date; + $tdate->setDate($currentYear, $currentMonth, 1); + $dayCount = 0; + $currentDay = 1; + while ($currentDay < $lastDayOfMonth + 1) { + if ($tdate->format('N') == $weekday) { + if (++$dayCount >= $nth) { + break; + } + } + $tdate->setDate($currentYear, $currentMonth, ++$currentDay); + } + + return $date->format('j') == $currentDay; + } + + // Handle day of the week values + if (strpos($value, '-')) { + $parts = explode('-', $value); + if ($parts[0] == '7') { + $parts[0] = '0'; + } elseif ($parts[1] == '0') { + $parts[1] = '7'; + } + $value = implode('-', $parts); + } + + // Test to see which Sunday to use -- 0 == 7 == Sunday + $format = in_array(7, str_split($value)) ? 'N' : 'w'; + $fieldValue = $date->format($format); + + return $this->isSatisfied($fieldValue, $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 day'); + $date->setTime(23, 59, 0); + } else { + $date->modify('+1 day'); + $date->setTime(0, 0, 0); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9A-Z]+/', $value); + } +} diff --git a/lib/cron-expression/CronExpression_FieldFactory.php b/lib/cron-expression/CronExpression_FieldFactory.php new file mode 100644 index 0000000..556ba1a --- /dev/null +++ b/lib/cron-expression/CronExpression_FieldFactory.php @@ -0,0 +1,55 @@ + + * @link http://en.wikipedia.org/wiki/Cron + */ +class CronExpression_FieldFactory +{ + /** + * @var array Cache of instantiated fields + */ + private $fields = array(); + + /** + * Get an instance of a field object for a cron expression position + * + * @param int $position CRON expression position value to retrieve + * + * @return CronExpression_FieldInterface + * @throws InvalidArgumentException if a position is not valid + */ + public function getField($position) + { + if (!isset($this->fields[$position])) { + switch ($position) { + case 0: + $this->fields[$position] = new CronExpression_MinutesField(); + break; + case 1: + $this->fields[$position] = new CronExpression_HoursField(); + break; + case 2: + $this->fields[$position] = new CronExpression_DayOfMonthField(); + break; + case 3: + $this->fields[$position] = new CronExpression_MonthField(); + break; + case 4: + $this->fields[$position] = new CronExpression_DayOfWeekField(); + break; + case 5: + $this->fields[$position] = new CronExpression_YearField(); + break; + default: + throw new InvalidArgumentException( + $position . ' is not a valid position' + ); + } + } + + return $this->fields[$position]; + } +} diff --git a/lib/cron-expression/CronExpression_FieldInterface.php b/lib/cron-expression/CronExpression_FieldInterface.php new file mode 100644 index 0000000..5d5109b --- /dev/null +++ b/lib/cron-expression/CronExpression_FieldInterface.php @@ -0,0 +1,39 @@ + + */ +interface CronExpression_FieldInterface +{ + /** + * Check if the respective value of a DateTime field satisfies a CRON exp + * + * @param DateTime $date DateTime object to check + * @param string $value CRON expression to test against + * + * @return bool Returns TRUE if satisfied, FALSE otherwise + */ + public function isSatisfiedBy(DateTime $date, $value); + + /** + * When a CRON expression is not satisfied, this method is used to increment + * or decrement a DateTime object by the unit of the cron field + * + * @param DateTime $date DateTime object to change + * @param bool $invert (optional) Set to TRUE to decrement + * + * @return CronExpression_FieldInterface + */ + public function increment(DateTime $date, $invert = false); + + /** + * Validates a CRON expression for a given field + * + * @param string $value CRON expression value to validate + * + * @return bool Returns TRUE if valid, FALSE otherwise + */ + public function validate($value); +} diff --git a/lib/cron-expression/CronExpression_HoursField.php b/lib/cron-expression/CronExpression_HoursField.php new file mode 100644 index 0000000..088ca73 --- /dev/null +++ b/lib/cron-expression/CronExpression_HoursField.php @@ -0,0 +1,47 @@ + + */ +class CronExpression_HoursField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + return $this->isSatisfied($date->format('H'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + // Change timezone to UTC temporarily. This will + // allow us to go back or forwards and hour even + // if DST will be changed between the hours. + $timezone = $date->getTimezone(); + $date->setTimezone(new DateTimeZone('UTC')); + if ($invert) { + $date->modify('-1 hour'); + $date->setTime($date->format('H'), 59); + } else { + $date->modify('+1 hour'); + $date->setTime($date->format('H'), 0); + } + $date->setTimezone($timezone); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9]+/', $value); + } +} diff --git a/lib/cron-expression/CronExpression_MinutesField.php b/lib/cron-expression/CronExpression_MinutesField.php new file mode 100644 index 0000000..436acf2 --- /dev/null +++ b/lib/cron-expression/CronExpression_MinutesField.php @@ -0,0 +1,39 @@ + + */ +class CronExpression_MinutesField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + return $this->isSatisfied($date->format('i'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 minute'); + } else { + $date->modify('+1 minute'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9]+/', $value); + } +} diff --git a/lib/cron-expression/CronExpression_MonthField.php b/lib/cron-expression/CronExpression_MonthField.php new file mode 100644 index 0000000..d3deb12 --- /dev/null +++ b/lib/cron-expression/CronExpression_MonthField.php @@ -0,0 +1,55 @@ + + */ +class CronExpression_MonthField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + // Convert text month values to integers + $value = str_ireplace( + array( + 'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', + 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC' + ), + range(1, 12), + $value + ); + + return $this->isSatisfied($date->format('m'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + // $date->modify('last day of previous month'); // remove for php 5.2 compat + $date->modify('previous month'); + $date->modify($date->format('Y-m-t')); + $date->setTime(23, 59); + } else { + //$date->modify('first day of next month'); // remove for php 5.2 compat + $date->modify('next month'); + $date->modify($date->format('Y-m-01')); + $date->setTime(0, 0); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9A-Z]+/', $value); + } +} diff --git a/lib/cron-expression/CronExpression_YearField.php b/lib/cron-expression/CronExpression_YearField.php new file mode 100644 index 0000000..f11562e --- /dev/null +++ b/lib/cron-expression/CronExpression_YearField.php @@ -0,0 +1,43 @@ + + */ +class CronExpression_YearField extends CronExpression_AbstractField +{ + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTime $date, $value) + { + return $this->isSatisfied($date->format('Y'), $value); + } + + /** + * {@inheritdoc} + */ + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 year'); + $date->setDate($date->format('Y'), 12, 31); + $date->setTime(23, 59, 0); + } else { + $date->modify('+1 year'); + $date->setDate($date->format('Y'), 1, 1); + $date->setTime(0, 0, 0); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate($value) + { + return (bool) preg_match('/[\*,\/\-0-9]+/', $value); + } +} diff --git a/lib/cron-expression/LICENSE b/lib/cron-expression/LICENSE new file mode 100644 index 0000000..c6d88ac --- /dev/null +++ b/lib/cron-expression/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 Michael Dowling and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/cron-expression/README.md b/lib/cron-expression/README.md new file mode 100644 index 0000000..d4d9d5a --- /dev/null +++ b/lib/cron-expression/README.md @@ -0,0 +1,92 @@ +PHP Cron Expression Parser +========================== + +[![Latest Stable Version](https://poser.pugx.org/mtdowling/cron-expression/v/stable.png)](https://packagist.org/packages/mtdowling/cron-expression) [![Total Downloads](https://poser.pugx.org/mtdowling/cron-expression/downloads.png)](https://packagist.org/packages/mtdowling/cron-expression) [![Build Status](https://secure.travis-ci.org/mtdowling/cron-expression.png)](http://travis-ci.org/mtdowling/cron-expression) + +The PHP cron expression parser can parse a CRON expression, determine if it is +due to run, calculate the next run date of the expression, and calculate the previous +run date of the expression. You can calculate dates far into the future or past by +skipping n number of matching dates. + +The parser can handle increments of ranges (e.g. */12, 2-59/3), intervals (e.g. 0-9), +lists (e.g. 1,2,3), W to find the nearest weekday for a given day of the month, L to +find the last day of the month, L to find the last given weekday of a month, and hash +(#) to find the nth weekday of a given month. + +Credits +========== + +Created by Micheal Dowling. Ported to PHP 5.2 by Flightless, Inc. +Based on version 1.0.3: https://github.com/mtdowling/cron-expression/tree/v1.0.3 + +Installing +========== + +Add the following to your project's composer.json: + +```javascript +{ + "require": { + "mtdowling/cron-expression": "1.0.*" + } +} +``` + +Usage +===== +```php +isDue(); +echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); +echo $cron->getPreviousRunDate()->format('Y-m-d H:i:s'); + +// Works with complex expressions +$cron = Cron\CronExpression::factory('3-59/15 2,6-12 */15 1 2-5'); +echo $cron->getNextRunDate()->format('Y-m-d H:i:s'); + +// Calculate a run date two iterations into the future +$cron = Cron\CronExpression::factory('@daily'); +echo $cron->getNextRunDate(null, 2)->format('Y-m-d H:i:s'); + +// Calculate a run date relative to a specific time +$cron = Cron\CronExpression::factory('@monthly'); +echo $cron->getNextRunDate('2010-01-12 00:00:00')->format('Y-m-d H:i:s'); +``` + +CRON Expressions +================ + +A CRON expression is a string representing the schedule for a particular command to execute. The parts of a CRON schedule are as follows: + + * * * * * * + - - - - - - + | | | | | | + | | | | | + year [optional] + | | | | +----- day of week (0 - 7) (Sunday=0 or 7) + | | | +---------- month (1 - 12) + | | +--------------- day of month (1 - 31) + | +-------------------- hour (0 - 23) + +------------------------- min (0 - 59) + +Requirements +============ + +- PHP 5.3+ +- PHPUnit is required to run the unit tests +- Composer is required to run the unit tests + +CHANGELOG +========= + +1.0.3 (2013-11-23) +------------------ + +* Only set default timezone if the given $currentTime is not a DateTime instance (#34) +* Fixes issue #28 where PHP increments of ranges were failing due to PHP casting hyphens to 0 +* Now supports expressions with any number of extra spaces, tabs, or newlines +* Using static instead of self in `CronExpression::factory` diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/license.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8584cb9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4995 @@ +{ + "name": "action-scheduler", + "version": "3.9.2", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "action-scheduler", + "version": "3.8.0", + "license": "GPL-3.0+", + "devDependencies": { + "grunt": "1.5.3", + "grunt-checktextdomain": "1.0.1", + "grunt-phpcs": "0.4.0", + "husky": "3.0.9", + "lint-staged": "13.2.1" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", + "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.0.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", + "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "dev": true, + "dependencies": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/argparse/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "node_modules/array-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", + "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-slice": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", + "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/caller-callsite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", + "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=", + "dev": true, + "dependencies": { + "callsites": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/caller-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", + "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=", + "dev": true, + "dependencies": { + "caller-callsite": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/callsites": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", + "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", + "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "dev": true, + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/colors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "integrity": "sha512-ENwblkFQpqqia6b++zLD/KUWafYlVY/UNnAp7oz7LY7E924wmpye416wBOmvv/HMWzl8gL1kJlfvId/1Dg176w==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "node_modules/cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "dependencies": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/dateformat": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", + "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", + "integrity": "sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ==", + "dev": true + }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "dev": true, + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/findup-sync": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.3.0.tgz", + "integrity": "sha512-z8Nrwhi6wzxNMIbxlrTzuUW6KWuKkogZ/7OdDVq+0+kxn77KUH1nipx8iU6suqkHqc4y6n7a9A8IpmxY/pTjWg==", + "dev": true, + "dependencies": { + "glob": "~5.0.0" + }, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/findup-sync/node_modules/glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==", + "dev": true, + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/fined": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", + "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.2", + "is-plain-object": "^2.0.3", + "object.defaults": "^1.1.0", + "object.pick": "^1.2.0", + "parse-filepath": "^1.0.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/flagged-respawn": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", + "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", + "dev": true, + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/getobject": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/getobject/-/getobject-1.0.2.tgz", + "integrity": "sha512-2zblDBaFcb3rB4rF77XVnuINOE2h2k/OnqXAiy0IrTxUfV1iFp3la33oAQVY9pCpWU268WFYVt2t71hlMuLsOg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "dependencies": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/grunt": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.5.3.tgz", + "integrity": "sha512-mKwmo4X2d8/4c/BmcOETHek675uOqw0RuA/zy12jaspWqvTp4+ZeQF1W+OTpcbncnaBsfbQJ6l0l4j+Sn/GmaQ==", + "dev": true, + "dependencies": { + "dateformat": "~3.0.3", + "eventemitter2": "~0.4.13", + "exit": "~0.1.2", + "findup-sync": "~0.3.0", + "glob": "~7.1.6", + "grunt-cli": "~1.4.3", + "grunt-known-options": "~2.0.0", + "grunt-legacy-log": "~3.0.0", + "grunt-legacy-util": "~2.0.1", + "iconv-lite": "~0.4.13", + "js-yaml": "~3.14.0", + "minimatch": "~3.0.4", + "mkdirp": "~1.0.4", + "nopt": "~3.0.6", + "rimraf": "~3.0.2" + }, + "bin": { + "grunt": "bin/grunt" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/grunt-checktextdomain": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/grunt-checktextdomain/-/grunt-checktextdomain-1.0.1.tgz", + "integrity": "sha1-slTQHh3pEwBdTbHFMD2QI7mD4Zs=", + "dev": true, + "dependencies": { + "chalk": "~0.2.1", + "text-table": "~0.2.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "peerDependencies": { + "grunt": ">=0.4.1" + } + }, + "node_modules/grunt-checktextdomain/node_modules/ansi-styles": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-0.2.0.tgz", + "integrity": "sha1-NZq0sV3NZLptdHNLcsNjYKmvLBk=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/grunt-checktextdomain/node_modules/chalk": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.2.1.tgz", + "integrity": "sha1-dhPhV1FFshOGSD9/SFql/6jL0Qw=", + "dev": true, + "dependencies": { + "ansi-styles": "~0.2.0", + "has-color": "~0.1.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/grunt-known-options": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz", + "integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/grunt-legacy-log": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/grunt-legacy-log/-/grunt-legacy-log-3.0.0.tgz", + "integrity": "sha512-GHZQzZmhyq0u3hr7aHW4qUH0xDzwp2YXldLPZTCjlOeGscAOWWPftZG3XioW8MasGp+OBRIu39LFx14SLjXRcA==", + "dev": true, + "dependencies": { + "colors": "~1.1.2", + "grunt-legacy-log-utils": "~2.1.0", + "hooker": "~0.2.3", + "lodash": "~4.17.19" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/grunt-legacy-log-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/grunt-legacy-log-utils/-/grunt-legacy-log-utils-2.1.0.tgz", + "integrity": "sha512-lwquaPXJtKQk0rUM1IQAop5noEpwFqOXasVoedLeNzaibf/OPWjKYvvdqnEHNmU+0T0CaReAXIbGo747ZD+Aaw==", + "dev": true, + "dependencies": { + "chalk": "~4.1.0", + "lodash": "~4.17.19" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/grunt-legacy-log-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/grunt-legacy-log-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/grunt-legacy-log-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/grunt-legacy-log-utils/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/grunt-legacy-log-utils/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/grunt-legacy-log-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/grunt-legacy-util": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-2.0.1.tgz", + "integrity": "sha512-2bQiD4fzXqX8rhNdXkAywCadeqiPiay0oQny77wA2F3WF4grPJXCvAcyoWUJV+po/b15glGkxuSiQCK299UC2w==", + "dev": true, + "dependencies": { + "async": "~3.2.0", + "exit": "~0.1.2", + "getobject": "~1.0.0", + "hooker": "~0.2.3", + "lodash": "~4.17.21", + "underscore.string": "~3.3.5", + "which": "~2.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/grunt-legacy-util/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/grunt-phpcs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/grunt-phpcs/-/grunt-phpcs-0.4.0.tgz", + "integrity": "sha1-oI1iX8ZEZeRTsr2T+BCyqB6Uvao=", + "dev": true, + "engines": { + "node": "0.10.x" + } + }, + "node_modules/grunt/node_modules/grunt-cli": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.4.3.tgz", + "integrity": "sha512-9Dtx/AhVeB4LYzsViCjUQkd0Kw0McN2gYpdmGYKtE2a5Yt7v1Q+HYZVWhqXc/kGnxlMtqKDxSwotiGeFmkrCoQ==", + "dev": true, + "dependencies": { + "grunt-known-options": "~2.0.0", + "interpret": "~1.1.0", + "liftup": "~3.0.1", + "nopt": "~4.0.1", + "v8flags": "~3.2.0" + }, + "bin": { + "grunt": "bin/grunt" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/grunt/node_modules/grunt-cli/node_modules/nopt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", + "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", + "dev": true, + "dependencies": { + "abbrev": "1", + "osenv": "^0.1.4" + }, + "bin": { + "nopt": "bin/nopt.js" + } + }, + "node_modules/grunt/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-color": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/has-color/-/has-color-0.1.7.tgz", + "integrity": "sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "dependencies": { + "parse-passwd": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/hooker": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz", + "integrity": "sha512-t+UerCsQviSymAInD01Pw+Dn/usmz1sRO+3Zk1+lx8eg+WKpD2ulcwWqHHL0+aseRBr+3+vIhiG1K1JTwaIcTA==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "dev": true, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/husky": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/husky/-/husky-3.0.9.tgz", + "integrity": "sha512-Yolhupm7le2/MqC1VYLk/cNmYxsSsqKkTyBhzQHhPK1jFnC89mmmNVuGtLNabjDI6Aj8UNIr0KpRNuBkiC4+sg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "chalk": "^2.4.2", + "ci-info": "^2.0.0", + "cosmiconfig": "^5.2.1", + "execa": "^1.0.0", + "get-stdin": "^7.0.0", + "opencollective-postinstall": "^2.0.2", + "pkg-dir": "^4.2.0", + "please-upgrade-node": "^3.2.0", + "read-pkg": "^5.2.0", + "run-node": "^1.0.0", + "slash": "^3.0.0" + }, + "bin": { + "husky-run": "run.js", + "husky-upgrade": "lib/upgrader/bin.js" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/husky/node_modules/get-stdin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz", + "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/husky/node_modules/parse-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", + "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/husky/node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", + "dev": true, + "dependencies": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==", + "dev": true + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "node_modules/is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/liftup": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz", + "integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==", + "dev": true, + "dependencies": { + "extend": "^3.0.2", + "findup-sync": "^4.0.0", + "fined": "^1.2.0", + "flagged-respawn": "^1.0.1", + "is-plain-object": "^2.0.4", + "object.map": "^1.0.1", + "rechoir": "^0.7.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/liftup/node_modules/findup-sync": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", + "integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==", + "dev": true, + "dependencies": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.0", + "micromatch": "^4.0.2", + "resolve-dir": "^1.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/liftup/node_modules/resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "node_modules/lint-staged": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-13.2.1.tgz", + "integrity": "sha512-8gfzinVXoPfga5Dz/ZOn8I2GOhf81Wvs+KwbEXQn/oWZAvCVS2PivrXfVbFJc93zD16uC0neS47RXHIjXKYZQw==", + "dev": true, + "dependencies": { + "chalk": "5.2.0", + "cli-truncate": "^3.1.0", + "commander": "^10.0.0", + "debug": "^4.3.4", + "execa": "^7.0.0", + "lilconfig": "2.1.0", + "listr2": "^5.0.7", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-inspect": "^1.12.3", + "pidtree": "^0.6.0", + "string-argv": "^0.3.1", + "yaml": "^2.2.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz", + "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/lint-staged/node_modules/execa": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz", + "integrity": "sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", + "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lint-staged/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lint-staged/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lint-staged/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/listr2": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-5.0.8.tgz", + "integrity": "sha512-mC73LitKHj9w6v30nLNGPetZIlfpUniNSsxxrbaPcWOjDb92SHPzJPi/t+v1YC/lxKz/AJ9egOjww0qUuFxBpA==", + "dev": true, + "dependencies": { + "cli-truncate": "^2.1.0", + "colorette": "^2.0.19", + "log-update": "^4.0.0", + "p-map": "^4.0.0", + "rfdc": "^1.3.0", + "rxjs": "^7.8.0", + "through": "^2.3.8", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "enquirer": ">= 2.3.0 < 3" + }, + "peerDependenciesMeta": { + "enquirer": { + "optional": true + } + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/listr2/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/listr2/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/listr2/node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-update/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/make-iterator": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", + "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", + "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.defaults": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", + "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", + "dev": true, + "dependencies": { + "array-each": "^1.0.1", + "array-slice": "^1.0.0", + "for-own": "^1.0.0", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", + "integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==", + "dev": true, + "dependencies": { + "for-own": "^1.0.0", + "make-iterator": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz", + "integrity": "sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw==", + "dev": true, + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "dependencies": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", + "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "dev": true, + "dependencies": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "dev": true, + "dependencies": { + "path-root-regex": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "dependencies": { + "semver-compare": "^1.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", + "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "dev": true, + "dependencies": { + "path-parse": "^1.0.6" + } + }, + "node_modules/resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/run-node/-/run-node-1.0.0.tgz", + "integrity": "sha512-kc120TBlQ3mih1LSzdAJXo4xn/GWS2ec0l3S+syHDXP9uRr0JAT8Qd3mdMuyjqCzeZktgP3try92cEgf9Nks8A==", + "dev": true, + "bin": { + "run-node": "run-node" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rxjs": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz", + "integrity": "sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", + "dev": true + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", + "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz", + "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", + "dev": true + }, + "node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "dev": true + }, + "node_modules/string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "dev": true, + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", + "dev": true + }, + "node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/underscore.string": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.6.tgz", + "integrity": "sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==", + "dev": true, + "dependencies": { + "sprintf-js": "^1.1.1", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/v8flags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", + "integrity": "sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg==", + "dev": true, + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "node_modules/yaml": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz", + "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==", + "dev": true, + "engines": { + "node": ">= 14" + } + } + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", + "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/highlight": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", + "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@types/normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", + "dev": true + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "requires": { + "type-fest": "^0.21.3" + }, + "dependencies": { + "type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true + } + } + }, + "ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + }, + "dependencies": { + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + } + } + }, + "array-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", + "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", + "dev": true + }, + "array-slice": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", + "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", + "dev": true + }, + "astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true + }, + "async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "requires": { + "fill-range": "^7.1.1" + } + }, + "caller-callsite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", + "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=", + "dev": true, + "requires": { + "callsites": "^2.0.0" + } + }, + "caller-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", + "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=", + "dev": true, + "requires": { + "caller-callsite": "^2.0.0" + } + }, + "callsites": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", + "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-truncate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", + "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "dev": true, + "requires": { + "slice-ansi": "^5.0.0", + "string-width": "^5.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "colors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "integrity": "sha512-ENwblkFQpqqia6b++zLD/KUWafYlVY/UNnAp7oz7LY7E924wmpye416wBOmvv/HMWzl8gL1kJlfvId/1Dg176w==", + "dev": true + }, + "commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + }, + "dependencies": { + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + } + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "dateformat": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", + "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", + "dev": true + }, + "eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "eventemitter2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", + "integrity": "sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ==", + "dev": true + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true + }, + "expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.1" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "findup-sync": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.3.0.tgz", + "integrity": "sha512-z8Nrwhi6wzxNMIbxlrTzuUW6KWuKkogZ/7OdDVq+0+kxn77KUH1nipx8iU6suqkHqc4y6n7a9A8IpmxY/pTjWg==", + "dev": true, + "requires": { + "glob": "~5.0.0" + }, + "dependencies": { + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "fined": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", + "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", + "dev": true, + "requires": { + "expand-tilde": "^2.0.2", + "is-plain-object": "^2.0.3", + "object.defaults": "^1.1.0", + "object.pick": "^1.2.0", + "parse-filepath": "^1.0.1" + } + }, + "flagged-respawn": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", + "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", + "dev": true + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true + }, + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", + "dev": true, + "requires": { + "for-in": "^1.0.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "getobject": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/getobject/-/getobject-1.0.2.tgz", + "integrity": "sha512-2zblDBaFcb3rB4rF77XVnuINOE2h2k/OnqXAiy0IrTxUfV1iFp3la33oAQVY9pCpWU268WFYVt2t71hlMuLsOg==", + "dev": true + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "requires": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + } + }, + "global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "dev": true, + "requires": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + } + }, + "grunt": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.5.3.tgz", + "integrity": "sha512-mKwmo4X2d8/4c/BmcOETHek675uOqw0RuA/zy12jaspWqvTp4+ZeQF1W+OTpcbncnaBsfbQJ6l0l4j+Sn/GmaQ==", + "dev": true, + "requires": { + "dateformat": "~3.0.3", + "eventemitter2": "~0.4.13", + "exit": "~0.1.2", + "findup-sync": "~0.3.0", + "glob": "~7.1.6", + "grunt-cli": "~1.4.3", + "grunt-known-options": "~2.0.0", + "grunt-legacy-log": "~3.0.0", + "grunt-legacy-util": "~2.0.1", + "iconv-lite": "~0.4.13", + "js-yaml": "~3.14.0", + "minimatch": "~3.0.4", + "mkdirp": "~1.0.4", + "nopt": "~3.0.6", + "rimraf": "~3.0.2" + }, + "dependencies": { + "grunt-cli": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.4.3.tgz", + "integrity": "sha512-9Dtx/AhVeB4LYzsViCjUQkd0Kw0McN2gYpdmGYKtE2a5Yt7v1Q+HYZVWhqXc/kGnxlMtqKDxSwotiGeFmkrCoQ==", + "dev": true, + "requires": { + "grunt-known-options": "~2.0.0", + "interpret": "~1.1.0", + "liftup": "~3.0.1", + "nopt": "~4.0.1", + "v8flags": "~3.2.0" + }, + "dependencies": { + "nopt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", + "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", + "dev": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + } + } + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } + } + }, + "grunt-checktextdomain": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/grunt-checktextdomain/-/grunt-checktextdomain-1.0.1.tgz", + "integrity": "sha1-slTQHh3pEwBdTbHFMD2QI7mD4Zs=", + "dev": true, + "requires": { + "chalk": "~0.2.1", + "text-table": "~0.2.0" + }, + "dependencies": { + "ansi-styles": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-0.2.0.tgz", + "integrity": "sha1-NZq0sV3NZLptdHNLcsNjYKmvLBk=", + "dev": true + }, + "chalk": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.2.1.tgz", + "integrity": "sha1-dhPhV1FFshOGSD9/SFql/6jL0Qw=", + "dev": true, + "requires": { + "ansi-styles": "~0.2.0", + "has-color": "~0.1.0" + } + } + } + }, + "grunt-known-options": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz", + "integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==", + "dev": true + }, + "grunt-legacy-log": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/grunt-legacy-log/-/grunt-legacy-log-3.0.0.tgz", + "integrity": "sha512-GHZQzZmhyq0u3hr7aHW4qUH0xDzwp2YXldLPZTCjlOeGscAOWWPftZG3XioW8MasGp+OBRIu39LFx14SLjXRcA==", + "dev": true, + "requires": { + "colors": "~1.1.2", + "grunt-legacy-log-utils": "~2.1.0", + "hooker": "~0.2.3", + "lodash": "~4.17.19" + } + }, + "grunt-legacy-log-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/grunt-legacy-log-utils/-/grunt-legacy-log-utils-2.1.0.tgz", + "integrity": "sha512-lwquaPXJtKQk0rUM1IQAop5noEpwFqOXasVoedLeNzaibf/OPWjKYvvdqnEHNmU+0T0CaReAXIbGo747ZD+Aaw==", + "dev": true, + "requires": { + "chalk": "~4.1.0", + "lodash": "~4.17.19" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "grunt-legacy-util": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-2.0.1.tgz", + "integrity": "sha512-2bQiD4fzXqX8rhNdXkAywCadeqiPiay0oQny77wA2F3WF4grPJXCvAcyoWUJV+po/b15glGkxuSiQCK299UC2w==", + "dev": true, + "requires": { + "async": "~3.2.0", + "exit": "~0.1.2", + "getobject": "~1.0.0", + "hooker": "~0.2.3", + "lodash": "~4.17.21", + "underscore.string": "~3.3.5", + "which": "~2.0.2" + }, + "dependencies": { + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "grunt-phpcs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/grunt-phpcs/-/grunt-phpcs-0.4.0.tgz", + "integrity": "sha1-oI1iX8ZEZeRTsr2T+BCyqB6Uvao=", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-color": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/has-color/-/has-color-0.1.7.tgz", + "integrity": "sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "requires": { + "parse-passwd": "^1.0.0" + } + }, + "hooker": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz", + "integrity": "sha512-t+UerCsQviSymAInD01Pw+Dn/usmz1sRO+3Zk1+lx8eg+WKpD2ulcwWqHHL0+aseRBr+3+vIhiG1K1JTwaIcTA==", + "dev": true + }, + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "dev": true + }, + "husky": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/husky/-/husky-3.0.9.tgz", + "integrity": "sha512-Yolhupm7le2/MqC1VYLk/cNmYxsSsqKkTyBhzQHhPK1jFnC89mmmNVuGtLNabjDI6Aj8UNIr0KpRNuBkiC4+sg==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "ci-info": "^2.0.0", + "cosmiconfig": "^5.2.1", + "execa": "^1.0.0", + "get-stdin": "^7.0.0", + "opencollective-postinstall": "^2.0.2", + "pkg-dir": "^4.2.0", + "please-upgrade-node": "^3.2.0", + "read-pkg": "^5.2.0", + "run-node": "^1.0.0", + "slash": "^3.0.0" + }, + "dependencies": { + "get-stdin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz", + "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==", + "dev": true + }, + "parse-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", + "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1", + "lines-and-columns": "^1.1.6" + } + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + } + } + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + } + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==", + "dev": true + }, + "is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "requires": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "requires": { + "is-unc-path": "^1.0.0" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "requires": { + "unc-path-regex": "^0.1.2" + } + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "liftup": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz", + "integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==", + "dev": true, + "requires": { + "extend": "^3.0.2", + "findup-sync": "^4.0.0", + "fined": "^1.2.0", + "flagged-respawn": "^1.0.1", + "is-plain-object": "^2.0.4", + "object.map": "^1.0.1", + "rechoir": "^0.7.0", + "resolve": "^1.19.0" + }, + "dependencies": { + "findup-sync": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", + "integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==", + "dev": true, + "requires": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.0", + "micromatch": "^4.0.2", + "resolve-dir": "^1.0.1" + } + }, + "resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "requires": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + } + } + }, + "lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true + }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "lint-staged": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-13.2.1.tgz", + "integrity": "sha512-8gfzinVXoPfga5Dz/ZOn8I2GOhf81Wvs+KwbEXQn/oWZAvCVS2PivrXfVbFJc93zD16uC0neS47RXHIjXKYZQw==", + "dev": true, + "requires": { + "chalk": "5.2.0", + "cli-truncate": "^3.1.0", + "commander": "^10.0.0", + "debug": "^4.3.4", + "execa": "^7.0.0", + "lilconfig": "2.1.0", + "listr2": "^5.0.7", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-inspect": "^1.12.3", + "pidtree": "^0.6.0", + "string-argv": "^0.3.1", + "yaml": "^2.2.1" + }, + "dependencies": { + "chalk": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz", + "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==", + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "execa": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz", + "integrity": "sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, + "is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true + }, + "mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true + }, + "npm-run-path": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", + "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "dev": true, + "requires": { + "path-key": "^4.0.0" + }, + "dependencies": { + "path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true + } + } + }, + "onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "requires": { + "mimic-fn": "^4.0.0" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "listr2": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-5.0.8.tgz", + "integrity": "sha512-mC73LitKHj9w6v30nLNGPetZIlfpUniNSsxxrbaPcWOjDb92SHPzJPi/t+v1YC/lxKz/AJ9egOjww0qUuFxBpA==", + "dev": true, + "requires": { + "cli-truncate": "^2.1.0", + "colorette": "^2.0.19", + "log-update": "^4.0.0", + "p-map": "^4.0.0", + "rfdc": "^1.3.0", + "rxjs": "^7.8.0", + "through": "^2.3.8", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "requires": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "requires": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "make-iterator": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", + "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "minimatch": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", + "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "dev": true + }, + "object.defaults": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", + "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", + "dev": true, + "requires": { + "array-each": "^1.0.1", + "array-slice": "^1.0.0", + "for-own": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "object.map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", + "integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==", + "dev": true, + "requires": { + "for-own": "^1.0.0", + "make-iterator": "^1.0.0" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "opencollective-postinstall": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz", + "integrity": "sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw==", + "dev": true + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "dev": true + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", + "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "dev": true, + "requires": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + } + }, + "parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "dev": true, + "requires": { + "path-root-regex": "^0.1.0" + } + }, + "path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } + }, + "please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "requires": { + "semver-compare": "^1.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "requires": { + "resolve": "^1.9.0" + } + }, + "resolve": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", + "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "dev": true, + "requires": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + } + }, + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "run-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/run-node/-/run-node-1.0.0.tgz", + "integrity": "sha512-kc120TBlQ3mih1LSzdAJXo4xn/GWS2ec0l3S+syHDXP9uRr0JAT8Qd3mdMuyjqCzeZktgP3try92cEgf9Nks8A==", + "dev": true + }, + "rxjs": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz", + "integrity": "sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "requires": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true + } + } + }, + "spdx-correct": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", + "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz", + "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", + "dev": true + }, + "sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "dev": true + }, + "string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "dev": true + }, + "string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "requires": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + } + }, + "strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, + "requires": { + "ansi-regex": "^6.0.1" + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", + "dev": true + }, + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + }, + "unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true + }, + "underscore.string": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.6.tgz", + "integrity": "sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==", + "dev": true, + "requires": { + "sprintf-js": "^1.1.1", + "util-deprecate": "^1.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "v8flags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", + "integrity": "sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg==", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.1" + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "yaml": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz", + "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4df5b07 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "action-scheduler", + "title": "Action Scheduler", + "version": "3.9.2", + "homepage": "https://actionscheduler.org/", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/action-scheduler.git" + }, + "license": "GPL-3.0+", + "main": "Gruntfile.js", + "scripts": { + "build": "composer install --no-dev && npm install --only=prod && composer archive --file=$npm_package_name --format=zip && npm run postarchive", + "postarchive": "rm -rf $npm_package_name && unzip $npm_package_name.zip -d $npm_package_name && rm $npm_package_name.zip && zip -r $npm_package_name.zip $npm_package_name && rm -rf $npm_package_name", + "check-textdomain": "grunt checktextdomain", + "phpcs": "grunt phpcs" + }, + "woorelease": { + "wp_org_slug": "action-scheduler" + }, + "devDependencies": { + "grunt": "1.5.3", + "grunt-checktextdomain": "1.0.1", + "grunt-phpcs": "0.4.0", + "husky": "3.0.9", + "lint-staged": "13.2.1" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.php": [ + "php -d display_errors=1 -l", + "composer run-script phpcs-pre-commit" + ] + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..4b72a7f --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,63 @@ + + + WooCommerce dev PHP_CodeSniffer ruleset. + + + docs/ + */node_modules/* + */vendor/* + + + + + + + + + + + + + + + + classes/* + deprecated/* + lib/* + tests/* + + + classes/* + deprecated/* + lib/* + tests/* + + + + tests/ + + + + classes/* + deprecated/* + lib/* + tests/* + + + + classes/ActionScheduler_wcSystemStatus.php + classes/data-stores/ActionScheduler_wpCommentLogger.php + classes/data-stores/ActionScheduler_wpPostStore.php + classes/data-stores/ActionScheduler_wpPostStore_PostStatusRegistrar.php + classes/data-stores/ActionScheduler_wpPostStore_PostTypeRegistrar.php + classes/data-stores/ActionScheduler_wpPostStore_TaxonomyRegistrar.php + classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php + tests/phpunit/jobstore/ActionScheduler_wpPostStore_Test.php + tests/phpunit/logging/ActionScheduler_wpCommentLogger_Test.php + tests/phpunit/procedural_api/wc_get_scheduled_actions_Test.php + + + + tests/* + + diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..9ef478b --- /dev/null +++ b/readme.txt @@ -0,0 +1,231 @@ +=== Action Scheduler === +Contributors: Automattic, wpmuguru, claudiosanches, peterfabian1000, vedjain, jamosova, obliviousharmony, konamiman, sadowski, royho, barryhughes-1 +Tags: scheduler, cron +Stable tag: 3.9.2 +License: GPLv3 +Requires at least: 6.5 +Tested up to: 6.7 +Requires PHP: 7.1 + +Action Scheduler - Job Queue for WordPress + +== Description == + +Action Scheduler is a scalable, traceable job queue for background processing large sets of actions in WordPress. It's specially designed to be distributed in WordPress plugins. + +Action Scheduler works by triggering an action hook to run at some time in the future. Each hook can be scheduled with unique data, to allow callbacks to perform operations on that data. The hook can also be scheduled to run on one or more occasions. + +Think of it like an extension to `do_action()` which adds the ability to delay and repeat a hook. + +## Battle-Tested Background Processing + +Every month, Action Scheduler processes millions of payments for [Subscriptions](https://woocommerce.com/products/woocommerce-subscriptions/), webhooks for [WooCommerce](https://wordpress.org/plugins/woocommerce/), as well as emails and other events for a range of other plugins. + +It's been seen on live sites processing queues in excess of 50,000 jobs and doing resource intensive operations, like processing payments and creating orders, at a sustained rate of over 10,000 / hour without negatively impacting normal site operations. + +This is all on infrastructure and WordPress sites outside the control of the plugin author. + +If your plugin needs background processing, especially of large sets of tasks, Action Scheduler can help. + +## Learn More + +To learn more about how to Action Scheduler works, and how to use it in your plugin, check out the docs on [ActionScheduler.org](https://actionscheduler.org). + +There you will find: + +* [Usage guide](https://actionscheduler.org/usage/): instructions on installing and using Action Scheduler +* [WP CLI guide](https://actionscheduler.org/wp-cli/): instructions on running Action Scheduler at scale via WP CLI +* [API Reference](https://actionscheduler.org/api/): complete reference guide for all API functions +* [Administration Guide](https://actionscheduler.org/admin/): guide to managing scheduled actions via the administration screen +* [Guide to Background Processing at Scale](https://actionscheduler.org/perf/): instructions for running Action Scheduler at scale via the default WP Cron queue runner + +## Credits + +Action Scheduler is developed and maintained by [Automattic](http://automattic.com/) with significant early development completed by [Flightless](https://flightless.us/). + +Collaboration is cool. We'd love to work with you to improve Action Scheduler. [Pull Requests](https://github.com/woocommerce/action-scheduler/pulls) welcome. + +== Changelog == + += 3.9.2 - 2025-02-03 = +* Fixed fatal errors by moving version info methods to a new class and deprecating conflicting ones in ActionScheduler_Versions + += 3.9.1 - 2025-01-21 = +* A number of new WP CLI commands have been added, making it easier to manage actions in the terminal and from scripts. +* New wp action-scheduler source command to help determine how Action Scheduler is being loaded. +* Additional information about the active instance of Action Scheduler is now available in the Help pull-down drawer. +* Make some other nullable parameters explicitly nullable. +* Set option value to `no` rather than deleting. + += 3.9.0 - 2024-11-14 = +* Minimum required version of PHP is now 7.1. +* Performance improvements for the `as_pending_actions_due()` function. +* Existing filter hook `action_scheduler_claim_actions_order_by` enhanced to provide callbacks with additional information. +* Improved compatibility with PHP 8.4, specifically by making implicitly nullable parameters explicitly nullable. +* A large number of coding standards-enhancements, to help reduce friction when submitting plugins to marketplaces and plugin directories. Special props @crstauf for this effort. +* Minor documentation tweaks and improvements. + += 3.8.2 - 2024-09-12 = +* Add missing parameter to the `pre_as_enqueue_async_action` hook. +* Bump minimum PHP version to 7.0. +* Bump minimum WordPress version to 6.4. +* Make the batch size adjustable during processing. + += 3.8.1 - 2024-06-20 = +* Fix typos. +* Improve the messaging in our unidentified action exceptions. + += 3.8.0 - 2024-05-22 = +* Documentation - Fixed typos in perf.md. +* Update - We now require WordPress 6.3 or higher. +* Update - We now require PHP 7.0 or higher. + += 3.7.4 - 2024-04-05 = +* Give a clear description of how the $unique parameter works. +* Preserve the tab field if set. +* Tweak - WP 6.5 compatibility. + += 3.7.3 - 2024-03-20 = +* Do not iterate over all of GET when building form in list table. +* Fix a few issues reported by PCP (Plugin Check Plugin). +* Try to save actions as unique even when the store doesn't support it. +* Tweak - WP 6.4 compatibility. +* Update "Tested up to" tag to WordPress 6.5. +* update version in package-lock.json. + += 3.7.2 - 2024-02-14 = +* No longer user variables in `_n()` translation function. + += 3.7.1 - 2023-12-13 = +* update semver to 5.7.2 because of a security vulnerability in 5.7.1. + += 3.7.0 - 2023-11-20 = +* Important: starting with this release, Action Scheduler follows an L-2 version policy (WordPress, and consequently PHP). +* Add extended indexes for hook_status_scheduled_date_gmt and status_scheduled_date_gmt. +* Catch and log exceptions thrown when actions can't be created, e.g. under a corrupt database schema. +* Tweak - WP 6.4 compatibility. +* Update unit tests for upcoming dependency version policy. +* make sure hook action_scheduler_failed_execution can access original exception object. +* mention dependency version policy in usage.md. + += 3.6.4 - 2023-10-11 = +* Performance improvements when bulk cancelling actions. +* Dev-related fixes. + += 3.6.3 - 2023-09-13 = +* Use `_doing_it_wrong` in initialization check. + += 3.6.2 - 2023-08-09 = +* Add guidance about passing arguments. +* Atomic option locking. +* Improve bulk delete handling. +* Include database error in the exception message. +* Tweak - WP 6.3 compatibility. + += 3.6.1 - 2023-06-14 = +* Document new optional `$priority` arg for various API functions. +* Document the new `--exclude-groups` WP CLI option. +* Document the new `action_scheduler_init` hook. +* Ensure actions within each claim are executed in the expected order. +* Fix incorrect text domain. +* Remove SHOW TABLES usage when checking if tables exist. + += 3.6.0 - 2023-05-10 = +* Add $unique parameter to function signatures. +* Add a cast-to-int for extra safety before forming new DateTime object. +* Add a hook allowing exceptions for consistently failing recurring actions. +* Add action priorities. +* Add init hook. +* Always raise the time limit. +* Bump minimatch from 3.0.4 to 3.0.8. +* Bump yaml from 2.2.1 to 2.2.2. +* Defensive coding relating to gaps in declared schedule types. +* Do not process an action if it cannot be set to `in-progress`. +* Filter view labels (status names) should be translatable | #919. +* Fix WPCLI progress messages. +* Improve data-store initialization flow. +* Improve error handling across all supported PHP versions. +* Improve logic for flushing the runtime cache. +* Support exclusion of multiple groups. +* Update lint-staged and Node/NPM requirements. +* add CLI clean command. +* add CLI exclude-group filter. +* exclude past-due from list table all filter count. +* throwing an exception if as_schedule_recurring_action interval param is not of type integer. + += 3.5.4 - 2023-01-17 = +* Add pre filters during action registration. +* Async scheduling. +* Calculate timeouts based on total actions. +* Correctly order the parameters for `ActionScheduler_ActionFactory`'s calls to `single_unique`. +* Fetch action in memory first before releasing claim to avoid deadlock. +* PHP 8.2: declare property to fix creation of dynamic property warning. +* PHP 8.2: fix "Using ${var} in strings is deprecated, use {$var} instead". +* Prevent `undefined variable` warning for `$num_pastdue_actions`. + += 3.5.3 - 2022-11-09 = +* Query actions with partial match. + += 3.5.2 - 2022-09-16 = +* Fix - erroneous 3.5.1 release. + += 3.5.1 - 2022-09-13 = +* Maintenance on A/S docs. +* fix: PHP 8.2 deprecated notice. + += 3.5.0 - 2022-08-25 = +* Add - The active view link within the "Tools > Scheduled Actions" screen is now clickable. +* Add - A warning when there are past-due actions. +* Enhancement - Added the ability to schedule unique actions via an atomic operation. +* Enhancement - Improvements to cache invalidation when processing batches (when running on WordPress 6.0+). +* Enhancement - If a recurring action is found to be consistently failing, it will stop being rescheduled. +* Enhancement - Adds a new "Past Due" view to the scheduled actions list table. + += 3.4.2 - 2022-06-08 = +* Fix - Change the include for better linting. +* Fix - update: Added Action scheduler completed action hook. + += 3.4.1 - 2022-05-24 = +* Fix - Change the include for better linting. +* Fix - Fix the documented return type. + += 3.4.0 - 2021-10-29 = +* Enhancement - Number of items per page can now be set for the Scheduled Actions view (props @ovidiul). #771 +* Fix - Do not lower the max_execution_time if it is already set to 0 (unlimited) (props @barryhughes). #755 +* Fix - Avoid triggering autoloaders during the version resolution process (props @olegabr). #731 & #776 +* Dev - ActionScheduler_wcSystemStatus PHPCS fixes (props @ovidiul). #761 +* Dev - ActionScheduler_DBLogger.php PHPCS fixes (props @ovidiul). #768 +* Dev - Fixed phpcs for ActionScheduler_Schedule_Deprecated (props @ovidiul). #762 +* Dev - Improve actions table indices (props @glagonikas). #774 & #777 +* Dev - PHPCS fixes for ActionScheduler_DBStore.php (props @ovidiul). #769 & #778 +* Dev - PHPCS Fixes for ActionScheduler_Abstract_ListTable (props @ovidiul). #763 & #779 +* Dev - Adds new filter action_scheduler_claim_actions_order_by to allow tuning of the claim query (props @glagonikas). #773 +* Dev - PHPCS fixes for ActionScheduler_WpPostStore class (props @ovidiul). #780 + += 3.3.0 - 2021-09-15 = +* Enhancement - Adds as_has_scheduled_action() to provide a performant way to test for existing actions. #645 +* Fix - Improves compatibility with environments where NO_ZERO_DATE is enabled. #519 +* Fix - Adds safety checks to guard against errors when our database tables cannot be created. #645 +* Dev - Now supports queries that use multiple statuses. #649 +* Dev - Minimum requirements for WordPress and PHP bumped (to 5.2 and 5.6 respectively). #723 + += 3.2.1 - 2021-06-21 = +* Fix - Add extra safety/account for different versions of AS and different loading patterns. #714 +* Fix - Handle hidden columns (Tools → Scheduled Actions) | #600. + += 3.2.0 - 2021-06-03 = +* Fix - Add "no ordering" option to as_next_scheduled_action(). +* Fix - Add secondary scheduled date checks when claiming actions (DBStore) | #634. +* Fix - Add secondary scheduled date checks when claiming actions (wpPostStore) | #634. +* Fix - Adds a new index to the action table, reducing the potential for deadlocks (props: @glagonikas). +* Fix - Fix unit tests infrastructure and adapt tests to PHP 8. +* Fix - Identify in-use data store. +* Fix - Improve test_migration_is_scheduled. +* Fix - PHP notice on list table. +* Fix - Speed up clean up and batch selects. +* Fix - Update pending dependencies. +* Fix - [PHP 8.0] Only pass action arg values through to do_action_ref_array(). +* Fix - [PHP 8] Set the PHP version to 7.1 in composer.json for PHP 8 compatibility. +* Fix - add is_initialized() to docs. +* Fix - fix file permissions. +* Fix - fixes #664 by replacing __ with esc_html__. diff --git a/tests/ActionScheduler_UnitTestCase.php b/tests/ActionScheduler_UnitTestCase.php new file mode 100644 index 0000000..8b7217a --- /dev/null +++ b/tests/ActionScheduler_UnitTestCase.php @@ -0,0 +1,70 @@ +createResult(); + } + + $this->existing_timezone = date_default_timezone_get(); + + if ( 'UTC' !== $this->existing_timezone ) { + date_default_timezone_set( 'UTC' ); + $result->run( $this ); + } + + date_default_timezone_set( 'Pacific/Fiji' ); // UTC+12. + $result->run( $this ); + + date_default_timezone_set( 'Pacific/Tahiti' ); // UTC-10: it's a magical place. + $result->run( $this ); + + date_default_timezone_set( $this->existing_timezone ); + + return $result; + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..79873d9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,20 @@ +# Action Scheduler tests + +To run unit tests: + +1. Make sure that PHPUnit is installed by running: + ``` + $ composer install + ``` + +2. Install WordPress and the WP Unit Test lib using the `install.sh` script: + ``` + $ tests/bin/install.sh [db-host] [wp-version] [skip-database-creation] + ``` + +You may need to quote strings with backslashes to prevent them from being processed by the shell or other programs. + +Then, to run the tests: + ``` + $ composer run test + ``` diff --git a/tests/bin/install.sh b/tests/bin/install.sh new file mode 100755 index 0000000..ee05775 --- /dev/null +++ b/tests/bin/install.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash + +if [ $# -lt 3 ]; then + echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-latest} +SKIP_DB_CREATE=${6-false} + +TMPDIR=${TMPDIR-/tmp} +TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") +WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress} + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then + WP_BRANCH=${WP_VERSION%\-*} + WP_TESTS_TAG="branches/$WP_BRANCH" + +elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then + WP_TESTS_TAG="branches/$WP_VERSION" +elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x + WP_TESTS_TAG="tags/${WP_VERSION%??}" + else + WP_TESTS_TAG="tags/$WP_VERSION" + fi +elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + WP_TESTS_TAG="trunk" +else + # http serves a single offer, whereas https serves multiple. we only want one + download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json + grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json + LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') + if [[ -z "$LATEST_VERSION" ]]; then + echo "Latest WordPress version could not be found" + exit 1 + fi + WP_TESTS_TAG="tags/$LATEST_VERSION" +fi +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + return; + fi + + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p $TMPDIR/wordpress-trunk + rm -rf $TMPDIR/wordpress-trunk/* + svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress + mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then + # https serves multiple offers, whereas http serves single. + download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x + LATEST_VERSION=${WP_VERSION%??} + else + # otherwise, scan the releases and get the most up to date minor version of the major release + local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` + LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) + fi + if [[ -z "$LATEST_VERSION" ]]; then + local ARCHIVE_NAME="wordpress-$WP_VERSION" + else + local ARCHIVE_NAME="wordpress-$LATEST_VERSION" + fi + else + local ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz + tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR + fi + + download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php +} + +install_test_suite() { + # portable in-place argument for both GNU sed and Mac OSX sed + if [[ $(uname -s) == 'Darwin' ]]; then + local ioption='-i.bak' + else + local ioption='-i' + fi + + # set up testing suite if it doesn't yet exist + if [ ! -d $WP_TESTS_DIR ]; then + # set up testing suite + mkdir -p $WP_TESTS_DIR + rm -rf $WP_TESTS_DIR/{includes,data} + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data + fi + + if [ ! -f wp-tests-config.php ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + # remove all forward slashes in the end + WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + +} + +recreate_db() { + shopt -s nocasematch + if [[ $1 =~ ^(y|yes)$ ]] + then + mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA + create_db + echo "Recreated the database ($DB_NAME)." + else + echo "Leaving the existing database ($DB_NAME) in place." + fi + shopt -u nocasematch +} + +create_db() { + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_db() { + + if [ ${SKIP_DB_CREATE} = "true" ]; then + return 0 + fi + + # parse DB_HOST for port or socket references + local PARTS=(${DB_HOST//\:/ }) + local DB_HOSTNAME=${PARTS[0]}; + local DB_SOCK_OR_PORT=${PARTS[1]}; + local EXTRA="" + + if ! [ -z $DB_HOSTNAME ] ; then + if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then + EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" + elif ! [ -z $DB_SOCK_OR_PORT ] ; then + EXTRA=" --socket=$DB_SOCK_OR_PORT" + elif ! [ -z $DB_HOSTNAME ] ; then + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + # create database + if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ] + then + echo "Reinstalling will delete the existing test database ($DB_NAME)" + read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB + recreate_db $DELETE_EXISTING_DB + else + create_db + fi +} + +install_wp +install_test_suite +install_db diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..ba6c31b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,37 @@ + + + + + + ./phpunit/migration + + + ./phpunit/jobstore/ActionScheduler_DBStoreMigrator_Test.php + ./phpunit/jobstore/ActionScheduler_DBStore_Test.php + ./phpunit/jobstore/ActionScheduler_HybridStore_Test.php + ./phpunit/logging/ActionScheduler_DBLogger_Test.php + + + ./phpunit/helpers + ./phpunit/jobs + ./phpunit/lock + ./phpunit/procedural_api + ./phpunit/runner + ./phpunit/schedules + ./phpunit/versioning + ./phpunit/logging/ActionScheduler_wpCommentLogger_Test.php + ./phpunit/jobstore/ActionScheduler_wpPostStore_Test.php + + + + + ignore + + + + + .. + + . + + + + diff --git a/tests/phpunit/ActionScheduler_Mock_Async_Request_QueueRunner.php b/tests/phpunit/ActionScheduler_Mock_Async_Request_QueueRunner.php new file mode 100644 index 0000000..35d286c --- /dev/null +++ b/tests/phpunit/ActionScheduler_Mock_Async_Request_QueueRunner.php @@ -0,0 +1,16 @@ +createResult(); + } + + $this->existing_timezone = date_default_timezone_get(); + + if ( 'UTC' !== $this->existing_timezone ) { + date_default_timezone_set( 'UTC' ); + $result->run( $this ); + } + + date_default_timezone_set( 'Pacific/Fiji' ); // UTC+12. + $result->run( $this ); + + date_default_timezone_set( 'Pacific/Tahiti' ); // UTC-10: it's a magical place. + $result->run( $this ); + + date_default_timezone_set( $this->existing_timezone ); + + return $result; + } +} diff --git a/tests/phpunit/helpers/ActionScheduler_Callbacks.php b/tests/phpunit/helpers/ActionScheduler_Callbacks.php new file mode 100644 index 0000000..fe56531 --- /dev/null +++ b/tests/phpunit/helpers/ActionScheduler_Callbacks.php @@ -0,0 +1,34 @@ +assertEquals( + '0', + ini_get( 'max_execution_time' ), + 'If the max_execution_time was already zero (unlimited), then it will not be changed.' + ); + + ini_set( 'max_execution_time', 60 ); + ActionScheduler_Compatibility::raise_time_limit( 30 ); + $this->assertEquals( + '60', + ini_get( 'max_execution_time' ), + 'If the max_execution_time was already a higher value than we specify, then it will not be changed.' + ); + + ActionScheduler_Compatibility::raise_time_limit( 200 ); + $this->assertEquals( + '200', + ini_get( 'max_execution_time' ), + 'If the max_execution_time was a lower value than we specify, but was above zero, then it will be updated to the new value.' + ); + + ActionScheduler_Compatibility::raise_time_limit( 0 ); + $this->assertEquals( + '0', + ini_get( 'max_execution_time' ), + 'If the max_execution_time was a positive, non-zero value and we then specify zero (unlimited) as the new value, then it will be updated.' + ); + + // Cleanup. + ini_set( 'max_execution_time', $default_max_execution_time ); + } +} diff --git a/tests/phpunit/helpers/ActionScheduler_TimezoneHelper_Test.php b/tests/phpunit/helpers/ActionScheduler_TimezoneHelper_Test.php new file mode 100644 index 0000000..4c46a5e --- /dev/null +++ b/tests/phpunit/helpers/ActionScheduler_TimezoneHelper_Test.php @@ -0,0 +1,110 @@ +getTimezone(); + $this->assertInstanceOf( 'DateTimeZone', $timezone ); + $this->assertEquals( $timezone_string, $timezone->getName() ); + + remove_filter( 'option_timezone_string', $timezone_filter ); + } + + /** + * Get timezone strings. + * + * @return array[] + */ + public function local_timezone_provider() { + return array( + array( 'America/New_York' ), + array( 'Australia/Melbourne' ), + array( 'UTC' ), + ); + } + + /** + * Ensure that most GMT offsets don't return UTC as the timezone. + * + * @dataProvider local_timezone_offsets_provider + * + * @param string $gmt_offset GMT offset. + */ + public function test_local_timezone_offsets( $gmt_offset ) { + $gmt_filter = function ( $gmt ) use ( $gmt_offset ) { + return $gmt_offset; + }; + + $date = new ActionScheduler_DateTime(); + + add_filter( 'option_gmt_offset', $gmt_filter ); + ActionScheduler_TimezoneHelper::set_local_timezone( $date ); + remove_filter( 'option_gmt_offset', $gmt_filter ); + + $offset_in_seconds = $gmt_offset * HOUR_IN_SECONDS; + + $this->assertEquals( $offset_in_seconds, $date->getOffset() ); + $this->assertEquals( $offset_in_seconds, $date->getOffsetTimestamp() - $date->getTimestamp() ); + } + + /** + * Get timezone offsets. + * + * @return array[] + */ + public function local_timezone_offsets_provider() { + return array( + array( '-11' ), + array( '-10.5' ), + array( '-10' ), + array( '-9' ), + array( '-8' ), + array( '-7' ), + array( '-6' ), + array( '-5' ), + array( '-4.5' ), + array( '-4' ), + array( '-3.5' ), + array( '-3' ), + array( '-2' ), + array( '-1' ), + array( '1' ), + array( '1.5' ), + array( '2' ), + array( '3' ), + array( '4' ), + array( '5' ), + array( '5.5' ), + array( '5.75' ), + array( '6' ), + array( '7' ), + array( '8' ), + array( '8.5' ), + array( '9' ), + array( '9.5' ), + array( '10' ), + array( '10.5' ), + array( '11' ), + array( '11.5' ), + array( '12' ), + array( '13' ), + ); + } +} diff --git a/tests/phpunit/jobs/ActionScheduler_Action_Test.php b/tests/phpunit/jobs/ActionScheduler_Action_Test.php new file mode 100644 index 0000000..2966525 --- /dev/null +++ b/tests/phpunit/jobs/ActionScheduler_Action_Test.php @@ -0,0 +1,54 @@ +assertEquals( $schedule, $action->get_schedule() ); + } + + public function test_null_schedule() { + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ); + $this->assertInstanceOf( 'ActionScheduler_NullSchedule', $action->get_schedule() ); + } + + public function test_set_hook() { + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ); + $this->assertEquals( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, $action->get_hook() ); + } + + public function test_args() { + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ); + $this->assertEmpty( $action->get_args() ); + + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 5, 10, 15 ) ); + $this->assertEqualSets( array( 5, 10, 15 ), $action->get_args() ); + } + + public function test_set_group() { + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), null, 'my_group' ); + $this->assertEquals( 'my_group', $action->get_group() ); + } + + public function test_execute() { + $mock = new MockAction(); + + $random = md5( wp_rand() ); + add_action( $random, array( $mock, 'action' ) ); + + $action = new ActionScheduler_Action( $random, array( $random ) ); + $action->execute(); + + remove_action( $random, array( $mock, 'action' ) ); + + $this->assertEquals( 1, $mock->get_call_count() ); + $events = $mock->get_events(); + $event = reset( $events ); + $this->assertEquals( $random, reset( $event['args'] ) ); + } +} diff --git a/tests/phpunit/jobs/ActionScheduler_NullAction_Test.php b/tests/phpunit/jobs/ActionScheduler_NullAction_Test.php new file mode 100644 index 0000000..9e630eb --- /dev/null +++ b/tests/phpunit/jobs/ActionScheduler_NullAction_Test.php @@ -0,0 +1,15 @@ +assertEmpty( $action->get_hook() ); + $this->assertEmpty( $action->get_args() ); + $this->assertNull( $action->get_schedule()->get_date() ); + } +} diff --git a/tests/phpunit/jobstore/AbstractStoreTest.php b/tests/phpunit/jobstore/AbstractStoreTest.php new file mode 100644 index 0000000..fb16ba7 --- /dev/null +++ b/tests/phpunit/jobstore/AbstractStoreTest.php @@ -0,0 +1,163 @@ +get_store(); + $action_id = $store->save_action( $action ); + + $this->assertEquals( ActionScheduler_Store::STATUS_PENDING, $store->get_status( $action_id ) ); + + $store->mark_complete( $action_id ); + $this->assertEquals( ActionScheduler_Store::STATUS_COMPLETE, $store->get_status( $action_id ) ); + + $store->mark_failure( $action_id ); + $this->assertEquals( ActionScheduler_Store::STATUS_FAILED, $store->get_status( $action_id ) ); + } + + // Start tests for \ActionScheduler_Store::query_actions(). + + // phpcs:ignore Squiz.Commenting.FunctionComment.WrongStyle + public function test_query_actions_query_type_arg_invalid_option() { + $this->expectException( InvalidArgumentException::class ); + $this->get_store()->query_actions( array( 'hook' => ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ), 'invalid' ); + } + + public function test_query_actions_query_type_arg_valid_options() { + $store = $this->get_store(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( 'tomorrow' ) ); + + $action_id_1 = $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule ) ); + $action_id_2 = $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule ) ); + + $this->assertEquals( array( $action_id_1, $action_id_2 ), $store->query_actions( array( 'hook' => ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ) ) ); + $this->assertEquals( 2, $store->query_actions( array( 'hook' => ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ), 'count' ) ); + } + + public function test_query_actions_by_single_status() { + $store = $this->get_store(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( 'tomorrow' ) ); + + $this->assertEquals( 0, $store->query_actions( array( 'status' => ActionScheduler_Store::STATUS_PENDING ), 'count' ) ); + + $action_id_1 = $store->save_action( new ActionScheduler_Action( 'my_hook_1', array( 1 ), $schedule ) ); + $action_id_2 = $store->save_action( new ActionScheduler_Action( 'my_hook_2', array( 1 ), $schedule ) ); + $action_id_3 = $store->save_action( new ActionScheduler_Action( 'my_hook_3', array( 1 ), $schedule ) ); + $store->mark_complete( $action_id_3 ); + + $this->assertEquals( 2, $store->query_actions( array( 'status' => ActionScheduler_Store::STATUS_PENDING ), 'count' ) ); + $this->assertEquals( 1, $store->query_actions( array( 'status' => ActionScheduler_Store::STATUS_COMPLETE ), 'count' ) ); + } + + public function test_query_actions_by_array_status() { + $store = $this->get_store(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( 'tomorrow' ) ); + + $this->assertEquals( + 0, + $store->query_actions( + array( + 'status' => array( ActionScheduler_Store::STATUS_PENDING, ActionScheduler_Store::STATUS_RUNNING ), + ), + 'count' + ) + ); + + $action_id_1 = $store->save_action( new ActionScheduler_Action( 'my_hook_1', array( 1 ), $schedule ) ); + $action_id_2 = $store->save_action( new ActionScheduler_Action( 'my_hook_2', array( 1 ), $schedule ) ); + $action_id_3 = $store->save_action( new ActionScheduler_Action( 'my_hook_3', array( 1 ), $schedule ) ); + $store->mark_failure( $action_id_3 ); + + $this->assertEquals( + 3, + $store->query_actions( + array( + 'status' => array( ActionScheduler_Store::STATUS_PENDING, ActionScheduler_Store::STATUS_FAILED ), + ), + 'count' + ) + ); + $this->assertEquals( + 2, + $store->query_actions( + array( + 'status' => array( ActionScheduler_Store::STATUS_PENDING, ActionScheduler_Store::STATUS_COMPLETE ), + ), + 'count' + ) + ); + } + + // phpcs:ignore Squiz.PHP.CommentedOutCode.Found + // End tests for \ActionScheduler_Store::query_actions(). + + /** + * The `has_pending_actions_due` method should return a boolean value depending on whether there are + * pending actions. + * + * @return void + */ + public function test_has_pending_actions_due() { + $store = $this->get_store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + + for ( $i = - 3; $i <= 3; $i ++ ) { + // Some past actions, some future actions. + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $store->save_action( $action ); + } + $this->assertTrue( $store->has_pending_actions_due() ); + + $runner->run(); + $this->assertFalse( $store->has_pending_actions_due() ); + } + + /** + * The `has_pending_actions_due` method should return false when all pending actions are in the future. + * + * @return void + */ + public function test_has_pending_actions_due_only_future_actions() { + $store = $this->get_store(); + + for ( $i = 1; $i <= 3; $i ++ ) { + // Only future actions. + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $store->save_action( $action ); + } + $this->assertFalse( $store->has_pending_actions_due() ); + } +} diff --git a/tests/phpunit/jobstore/ActionScheduler_DBStoreMigrator_Test.php b/tests/phpunit/jobstore/ActionScheduler_DBStoreMigrator_Test.php new file mode 100644 index 0000000..2cee74e --- /dev/null +++ b/tests/phpunit/jobstore/ActionScheduler_DBStoreMigrator_Test.php @@ -0,0 +1,26 @@ +save_action( $action, null, $last_attempt_date ); + $action_date = $store->get_date( $action_id ); + + $this->assertEquals( $last_attempt_date->format( 'U' ), $action_date->format( 'U' ) ); + + $action_id = $store->save_action( $action, $scheduled_date, $last_attempt_date ); + $action_date = $store->get_date( $action_id ); + + $this->assertEquals( $last_attempt_date->format( 'U' ), $action_date->format( 'U' ) ); + } +} diff --git a/tests/phpunit/jobstore/ActionScheduler_DBStore_Test.php b/tests/phpunit/jobstore/ActionScheduler_DBStore_Test.php new file mode 100644 index 0000000..c8394b2 --- /dev/null +++ b/tests/phpunit/jobstore/ActionScheduler_DBStore_Test.php @@ -0,0 +1,722 @@ +query( "DELETE FROM {$wpdb->actionscheduler_actions}" ); + + parent::setUp(); + } + + /** + * Get data store for tests. + * + * @return ActionScheduler_DBStore + */ + protected function get_store() { + return new ActionScheduler_DBStore(); + } + + public function test_create_action() { + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $store = new ActionScheduler_DBStore(); + $action_id = $store->save_action( $action ); + + $this->assertNotEmpty( $action_id ); + } + + public function test_create_action_with_scheduled_date() { + $time = as_get_datetime_object( strtotime( '-1 week' ) ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), new ActionScheduler_SimpleSchedule( $time ) ); + $store = new ActionScheduler_DBStore(); + $action_id = $store->save_action( $action, $time ); + $action_date = $store->get_date( $action_id ); + + $this->assertEquals( $time->format( 'U' ), $action_date->format( 'U' ) ); + } + + public function test_retrieve_action() { + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, 'my_group' ); + $store = new ActionScheduler_DBStore(); + $action_id = $store->save_action( $action ); + + $retrieved = $store->fetch_action( $action_id ); + $this->assertEquals( $action->get_hook(), $retrieved->get_hook() ); + $this->assertEqualSets( $action->get_args(), $retrieved->get_args() ); + $this->assertEquals( $action->get_schedule()->get_date()->format( 'U' ), $retrieved->get_schedule()->get_date()->format( 'U' ) ); + $this->assertEquals( $action->get_group(), $retrieved->get_group() ); + } + + public function test_cancel_action() { + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, 'my_group' ); + $store = new ActionScheduler_DBStore(); + $action_id = $store->save_action( $action ); + $store->cancel_action( $action_id ); + + $fetched = $store->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_CanceledAction', $fetched ); + } + + public function test_cancel_actions_by_hook() { + $store = new ActionScheduler_DBStore(); + $actions = array(); + $hook = 'by_hook_test'; + for ( $day = 1; $day <= 3; $day++ ) { + $delta = sprintf( '+%d day', $day ); + $time = as_get_datetime_object( $delta ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( $hook, array(), $schedule, 'my_group' ); + $actions[] = $store->save_action( $action ); + } + $store->cancel_actions_by_hook( $hook ); + + foreach ( $actions as $action_id ) { + $fetched = $store->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_CanceledAction', $fetched ); + } + } + + public function test_cancel_actions_by_group() { + $store = new ActionScheduler_DBStore(); + $actions = array(); + $group = 'by_group_test'; + for ( $day = 1; $day <= 3; $day++ ) { + $delta = sprintf( '+%d day', $day ); + $time = as_get_datetime_object( $delta ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, $group ); + $actions[] = $store->save_action( $action ); + } + $store->cancel_actions_by_group( $group ); + + foreach ( $actions as $action_id ) { + $fetched = $store->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_CanceledAction', $fetched ); + } + } + + public function test_claim_actions() { + $created_actions = array(); + $store = new ActionScheduler_DBStore(); + for ( $i = 3; $i > - 3; $i -- ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $created_actions[] = $store->save_action( $action ); + } + + $claim = $store->stake_claim(); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions, 3, 3 ), $claim->get_actions() ); + } + + public function test_claim_actions_order() { + + $store = new ActionScheduler_DBStore(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 hour' ) ); + $created_actions = array( + $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'my_group' ) ), + $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'my_group' ) ), + ); + + $claim = $store->stake_claim(); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + + // Verify uniqueness of action IDs. + $this->assertEquals( 2, count( array_unique( $created_actions ) ) ); + + // Verify the count and order of the actions. + $claimed_actions = $claim->get_actions(); + $this->assertCount( 2, $claimed_actions ); + $this->assertEquals( $created_actions, $claimed_actions ); + + // Verify the reversed order doesn't pass. + $reversed_actions = array_reverse( $created_actions ); + $this->assertNotEquals( $reversed_actions, $claimed_actions ); + } + + public function test_claim_actions_by_hooks() { + $created_actions_by_hook = array(); + $created_actions = array(); + + $store = new ActionScheduler_DBStore(); + $unique_hook_one = 'my_unique_hook_one'; + $unique_hook_two = 'my_unique_hook_two'; + $unique_hooks = array( + $unique_hook_one, + $unique_hook_two, + ); + + for ( $i = 3; $i > - 3; $i -- ) { + foreach ( $unique_hooks as $unique_hook ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( $unique_hook, array( $i ), $schedule, 'my_group' ); + $action_id = $store->save_action( $action ); + + $created_actions[] = $action_id; + $created_actions_by_hook[ $unique_hook ][] = $action_id; + } + } + + $claim = $store->stake_claim( 10, null, $unique_hooks ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 6, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions, 6, 6 ), $claim->get_actions() ); + + $store->release_claim( $claim ); + + $claim = $store->stake_claim( 10, null, array( $unique_hook_one ) ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions_by_hook[ $unique_hook_one ], 3, 3 ), $claim->get_actions() ); + + $store->release_claim( $claim ); + + $claim = $store->stake_claim( 10, null, array( $unique_hook_two ) ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions_by_hook[ $unique_hook_two ], 3, 3 ), $claim->get_actions() ); + } + + public function test_claim_actions_by_group() { + $created_actions = array(); + $store = new ActionScheduler_DBStore(); + $unique_group_one = 'my_unique_group_one'; + $unique_group_two = 'my_unique_group_two'; + $unique_groups = array( + $unique_group_one, + $unique_group_two, + ); + + for ( $i = 3; $i > - 3; $i -- ) { + foreach ( $unique_groups as $unique_group ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, $unique_group ); + + $created_actions[ $unique_group ][] = $store->save_action( $action ); + } + } + + $claim = $store->stake_claim( 10, null, array(), $unique_group_one ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions[ $unique_group_one ], 3, 3 ), $claim->get_actions() ); + + $store->release_claim( $claim ); + + $claim = $store->stake_claim( 10, null, array(), $unique_group_two ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions[ $unique_group_two ], 3, 3 ), $claim->get_actions() ); + } + + /** + * The DBStore allows one or more groups to be excluded from a claim. + */ + public function test_claim_actions_with_group_exclusions() { + $created_actions = array(); + $store = new ActionScheduler_DBStore(); + $groups = array( 'foo', 'bar', 'baz' ); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 hour' ) ); + + // Create 6 actions (with 2 in each test group). + foreach ( $groups as $group_slug ) { + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, $group_slug ); + + $created_actions[ $group_slug ] = array( + $store->save_action( $action ), + $store->save_action( $action ), + ); + } + + // If we exclude group 'foo' (representing 2 actions) the remaining 4 actions from groups 'bar' and 'baz' should still be claimed. + $store->set_claim_filter( 'exclude-groups', 'foo' ); + $claim = $store->stake_claim(); + $this->assertEquals( + array_merge( $created_actions['bar'], $created_actions['baz'] ), + $claim->get_actions(), + 'A single group can successfully be excluded from claims.' + ); + $store->release_claim( $claim ); + + // If we exclude groups 'bar' and 'baz' (representing 4 actions) the remaining 2 actions from group 'foo' should still be claimed. + $store->set_claim_filter( 'exclude-groups', array( 'bar', 'baz' ) ); + $claim = $store->stake_claim(); + $this->assertEquals( + $created_actions['foo'], + $claim->get_actions(), + 'Multiple groups can successfully be excluded from claims.' + ); + $store->release_claim( $claim ); + + // If we include group 'foo' (representing 2 actions) after excluding all groups, the inclusion should 'win'. + $store->set_claim_filter( 'exclude-groups', array( 'foo', 'bar', 'baz' ) ); + $claim = $store->stake_claim( 10, null, array(), 'foo' ); + $this->assertEquals( + $created_actions['foo'], + $claim->get_actions(), + 'Including a specific group takes precedence over group exclusions.' + ); + $store->release_claim( $claim ); + } + + public function test_claim_actions_by_hook_and_group() { + $created_actions_by_hook = array(); + $created_actions = array(); + $store = new ActionScheduler_DBStore(); + + $unique_hook_one = 'my_other_unique_hook_one'; + $unique_hook_two = 'my_other_unique_hook_two'; + $unique_hooks = array( + $unique_hook_one, + $unique_hook_two, + ); + + $unique_group_one = 'my_other_other_unique_group_one'; + $unique_group_two = 'my_other_unique_group_two'; + $unique_groups = array( + $unique_group_one, + $unique_group_two, + ); + + for ( $i = 3; $i > - 3; $i -- ) { + foreach ( $unique_hooks as $unique_hook ) { + foreach ( $unique_groups as $unique_group ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( $unique_hook, array( $i ), $schedule, $unique_group ); + $action_id = $store->save_action( $action ); + + $created_actions[ $unique_group ][] = $action_id; + $created_actions_by_hook[ $unique_hook ][ $unique_group ][] = $action_id; + } + } + } + + /** Test Both Hooks with Each Group */ + + $claim = $store->stake_claim( 10, null, $unique_hooks, $unique_group_one ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 6, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions[ $unique_group_one ], 6, 6 ), $claim->get_actions() ); + + $store->release_claim( $claim ); + + $claim = $store->stake_claim( 10, null, $unique_hooks, $unique_group_two ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 6, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions[ $unique_group_two ], 6, 6 ), $claim->get_actions() ); + + $store->release_claim( $claim ); + + /** Test Just One Hook with Group One */ + + $claim = $store->stake_claim( 10, null, array( $unique_hook_one ), $unique_group_one ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions_by_hook[ $unique_hook_one ][ $unique_group_one ], 3, 3 ), $claim->get_actions() ); + + $store->release_claim( $claim ); + + $claim = $store->stake_claim( 24, null, array( $unique_hook_two ), $unique_group_one ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions_by_hook[ $unique_hook_two ][ $unique_group_one ], 3, 3 ), $claim->get_actions() ); + + $store->release_claim( $claim ); + + /** Test Just One Hook with Group Two */ + + $claim = $store->stake_claim( 10, null, array( $unique_hook_one ), $unique_group_two ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions_by_hook[ $unique_hook_one ][ $unique_group_two ], 3, 3 ), $claim->get_actions() ); + + $store->release_claim( $claim ); + + $claim = $store->stake_claim( 24, null, array( $unique_hook_two ), $unique_group_two ); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions_by_hook[ $unique_hook_two ][ $unique_group_two ], 3, 3 ), $claim->get_actions() ); + } + + /** + * Confirm that priorities are respected when claiming actions. + * + * @return void + */ + public function test_claim_actions_respecting_priority() { + $store = new ActionScheduler_DBStore(); + + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-2 hours' ) ); + $routine_action_1 = $store->save_action( new ActionScheduler_Action( 'routine_past_due', array(), $schedule, '' ) ); + + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 hour' ) ); + $action = new ActionScheduler_Action( 'high_priority_past_due', array(), $schedule, '' ); + $action->set_priority( 5 ); + $priority_action = $store->save_action( $action ); + + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-4 hours' ) ); + $routine_action_2 = $store->save_action( new ActionScheduler_Action( 'routine_past_due', array(), $schedule, '' ) ); + + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '+1 hour' ) ); + $action = new ActionScheduler_Action( 'high_priority_future', array(), $schedule, '' ); + $action->set_priority( 2 ); + $priority_future_action = $store->save_action( $action ); + + $claim = $store->stake_claim(); + $this->assertEquals( + array( $priority_action, $routine_action_2, $routine_action_1 ), + $claim->get_actions(), + 'High priority actions take precedence over older but lower priority actions.' + ); + } + + /** + * The query used to claim actions explicitly ignores future pending actions, but it + * is still possible under unusual conditions (such as if MySQL runs out of temporary + * storage space) for such actions to be returned. + * + * When this happens, we still expect the store to filter them out, otherwise there is + * a risk that actions will be unexpectedly processed ahead of time. + * + * @see https://github.com/woocommerce/action-scheduler/issues/634 + */ + public function test_claim_filters_out_unexpected_future_actions() { + $group = __METHOD__; + $store = new ActionScheduler_DBStore(); + + // Create 4 actions: 2 that are already due (-3hrs and -1hrs) and 2 that are not yet due (+1hr and +3hrs). + for ( $i = -3; $i <= 3; $i += 2 ) { + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( $i . ' hours' ) ); + $action_ids[] = $store->save_action( new ActionScheduler_Action( 'test_' . $i, array(), $schedule, $group ) ); + } + + // This callback is used to simulate the unusual conditions whereby MySQL might unexpectedly return future + // actions, contrary to the conditions used by the store object when staking its claim. + $simulate_unexpected_db_behavior = function( $sql ) use ( $action_ids ) { + global $wpdb; + + // Look out for the claim update query, ignore all others. + if ( + 0 !== strpos( $sql, "UPDATE $wpdb->actionscheduler_actions" ) + || ! preg_match( "/claim_id = 0 AND scheduled_date_gmt <= '([0-9:\-\s]{19})'/", $sql, $matches ) + || count( $matches ) !== 2 + ) { + return $sql; + } + + // Now modify the query, forcing it to also return the future actions we created. + return str_replace( $matches[1], as_get_datetime_object( '+4 hours' )->format( 'Y-m-d H:i:s' ), $sql ); + }; + + add_filter( 'query', $simulate_unexpected_db_behavior ); + $claim = $store->stake_claim( 10, null, array(), $group ); + $claimed_actions = $claim->get_actions(); + $this->assertCount( 2, $claimed_actions ); + + // Cleanup. + remove_filter( 'query', $simulate_unexpected_db_behavior ); + $store->release_claim( $claim ); + } + + public function test_duplicate_claim() { + $created_actions = array(); + $store = new ActionScheduler_DBStore(); + for ( $i = 0; $i > - 3; $i -- ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $created_actions[] = $store->save_action( $action ); + } + + $claim1 = $store->stake_claim(); + $claim2 = $store->stake_claim(); + $this->assertCount( 3, $claim1->get_actions() ); + $this->assertCount( 0, $claim2->get_actions() ); + } + + public function test_release_claim() { + $created_actions = array(); + $store = new ActionScheduler_DBStore(); + for ( $i = 0; $i > - 3; $i -- ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $created_actions[] = $store->save_action( $action ); + } + + $claim1 = $store->stake_claim(); + + $store->release_claim( $claim1 ); + $this->assertCount( 0, $store->find_actions_by_claim_id( $claim1->get_id() ) ); + + $claim2 = $store->stake_claim(); + $this->assertCount( 3, $claim2->get_actions() ); + $store->release_claim( $claim2 ); + $this->assertCount( 0, $store->find_actions_by_claim_id( $claim1->get_id() ) ); + + } + + public function test_search() { + $created_actions = array(); + $store = new ActionScheduler_DBStore(); + for ( $i = - 3; $i <= 3; $i ++ ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $created_actions[] = $store->save_action( $action ); + } + + $next_no_args = $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ); + $this->assertEquals( $created_actions[0], $next_no_args ); + + $next_with_args = $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'args' => array( 1 ) ) ); + $this->assertEquals( $created_actions[4], $next_with_args ); + + $non_existent = $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'args' => array( 17 ) ) ); + $this->assertNull( $non_existent ); + } + + public function test_search_by_group() { + $store = new ActionScheduler_DBStore(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( 'tomorrow' ) ); + + $abc = $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'abc' ) ); + $def = $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'def' ) ); + $ghi = $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'ghi' ) ); + + $this->assertEquals( $abc, $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'group' => 'abc' ) ) ); + $this->assertEquals( $def, $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'group' => 'def' ) ) ); + $this->assertEquals( $ghi, $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'group' => 'ghi' ) ) ); + } + + public function test_get_run_date() { + $time = as_get_datetime_object( '-10 minutes' ); + $schedule = new ActionScheduler_IntervalSchedule( $time, HOUR_IN_SECONDS ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $store = new ActionScheduler_DBStore(); + $action_id = $store->save_action( $action ); + + $this->assertEquals( $time->format( 'U' ), $store->get_date( $action_id )->format( 'U' ) ); + + $action = $store->fetch_action( $action_id ); + $action->execute(); + $now = as_get_datetime_object(); + $store->mark_complete( $action_id ); + + $this->assertEquals( $now->format( 'U' ), $store->get_date( $action_id )->format( 'U' ) ); + + $next = $action->get_schedule()->get_next( $now ); + $new_action_id = $store->save_action( $action, $next ); + + $this->assertEquals( (int) ( $now->format( 'U' ) ) + HOUR_IN_SECONDS, $store->get_date( $new_action_id )->format( 'U' ) ); + } + + /** + * Test creating a unique action. + */ + public function test_create_action_unique() { + $time = as_get_datetime_object(); + $hook = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $store = new ActionScheduler_DBStore(); + $action = new ActionScheduler_Action( $hook, array(), $schedule ); + + $action_id = $store->save_action( $action ); + $this->assertNotEquals( 0, $action_id ); + $action_from_db = $store->fetch_action( $action_id ); + $this->assertTrue( is_a( $action_from_db, ActionScheduler_Action::class ) ); + + $action = new ActionScheduler_Action( $hook, array(), $schedule ); + $action_id_duplicate = $store->save_unique_action( $action ); + $this->assertEquals( 0, $action_id_duplicate ); + } + + /** + * Test saving unique actions across different groups. Different groups should be saved, same groups shouldn't. + */ + public function test_create_action_unique_with_different_groups() { + $time = as_get_datetime_object(); + $hook = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $store = new ActionScheduler_DBStore(); + $action = new ActionScheduler_Action( $hook, array(), $schedule, 'group1' ); + + $action_id = $store->save_action( $action ); + $action_from_db = $store->fetch_action( $action_id ); + $this->assertNotEquals( 0, $action_id ); + $this->assertTrue( is_a( $action_from_db, ActionScheduler_Action::class ) ); + + $action2 = new ActionScheduler_Action( $hook, array(), $schedule, 'group2' ); + $action_id_group2 = $store->save_unique_action( $action2 ); + $this->assertNotEquals( 0, $action_id_group2 ); + $action_2_from_db = $store->fetch_action( $action_id_group2 ); + $this->assertTrue( is_a( $action_2_from_db, ActionScheduler_Action::class ) ); + + $action3 = new ActionScheduler_Action( $hook, array(), $schedule, 'group2' ); + $action_id_group2_double = $store->save_unique_action( $action3 ); + $this->assertEquals( 0, $action_id_group2_double ); + } + + /** + * Test saving a unique action first, and then successfully scheduling a non-unique action. + */ + public function test_create_action_unique_and_then_non_unique() { + $time = as_get_datetime_object(); + $hook = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $store = new ActionScheduler_DBStore(); + $action = new ActionScheduler_Action( $hook, array(), $schedule ); + + $action_id = $store->save_unique_action( $action ); + $this->assertNotEquals( 0, $action_id ); + $action_from_db = $store->fetch_action( $action_id ); + $this->assertTrue( is_a( $action_from_db, ActionScheduler_Action::class ) ); + + // Non unique action is scheduled even if the previous one was unique. + $action = new ActionScheduler_Action( $hook, array(), $schedule ); + $action_id_duplicate = $store->save_action( $action ); + $this->assertNotEquals( 0, $action_id_duplicate ); + $action_from_db_duplicate = $store->fetch_action( $action_id_duplicate ); + $this->assertTrue( is_a( $action_from_db_duplicate, ActionScheduler_Action::class ) ); + } + + /** + * Test asserting that action when an action is created with empty args, it matches with actions created with args for uniqueness. + */ + public function test_create_action_unique_with_empty_array() { + $time = as_get_datetime_object(); + $hook = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $store = new ActionScheduler_DBStore(); + $action = new ActionScheduler_Action( $hook, array( 'foo' => 'bar' ), $schedule ); + + $action_id = $store->save_unique_action( $action ); + $this->assertNotEquals( 0, $action_id ); + $action_from_db = $store->fetch_action( $action_id ); + $this->assertTrue( is_a( $action_from_db, ActionScheduler_Action::class ) ); + + $action_with_empty_args = new ActionScheduler_Action( $hook, array(), $schedule ); + $action_id_duplicate = $store->save_unique_action( $action_with_empty_args ); + $this->assertEquals( 0, $action_id_duplicate ); + } + + /** + * Uniqueness does not check for args, so actions with different args can't be scheduled when unique is true. + */ + public function test_create_action_unique_with_different_args_still_fail() { + $time = as_get_datetime_object(); + $hook = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $store = new ActionScheduler_DBStore(); + $action = new ActionScheduler_Action( $hook, array( 'foo' => 'bar' ), $schedule ); + + $action_id = $store->save_unique_action( $action ); + $this->assertNotEquals( 0, $action_id ); + $action_from_db = $store->fetch_action( $action_id ); + $this->assertTrue( is_a( $action_from_db, ActionScheduler_Action::class ) ); + + $action_with_diff_args = new ActionScheduler_Action( $hook, array( 'foo' => 'bazz' ), $schedule ); + $action_id_duplicate = $store->save_unique_action( $action_with_diff_args ); + $this->assertEquals( 0, $action_id_duplicate ); + } + + /** + * When a set of claimed actions are processed, they should be executed in the expected order (by priority, + * then by least number of attempts, then by scheduled date, then finally by action ID). + * + * @return void + */ + public function test_actions_are_processed_in_correct_order() { + global $wpdb; + + $now = time(); + $actual_order = array(); + + // When `foo` actions are processed, record the sequence number they supply. + $watcher = function ( $number ) use ( &$actual_order ) { + $actual_order[] = $number; + }; + + as_schedule_single_action( $now - 10, 'foo', array( 4 ), '', false, 10 ); + as_schedule_single_action( $now - 20, 'foo', array( 3 ), '', false, 10 ); + as_schedule_single_action( $now - 5, 'foo', array( 2 ), '', false, 5 ); + as_schedule_single_action( $now - 20, 'foo', array( 1 ), '', false, 5 ); + $reattempted = as_schedule_single_action( $now - 40, 'foo', array( 7 ), '', false, 20 ); + as_schedule_single_action( $now - 40, 'foo', array( 5 ), '', false, 20 ); + as_schedule_single_action( $now - 40, 'foo', array( 6 ), '', false, 20 ); + + // Modify the `attempt` count on one of our test actions, to change expectations about its execution order. + $wpdb->update( + $wpdb->actionscheduler_actions, + array( 'attempts' => 5 ), + array( 'action_id' => $reattempted ) + ); + + add_action( 'foo', $watcher ); + ActionScheduler_Mocker::get_queue_runner( ActionScheduler::store() )->run(); + remove_action( 'foo', $watcher ); + + $this->assertEquals( range( 1, 7 ), $actual_order, 'When a claim is processed, individual actions execute in the expected order.' ); + } + + /** + * When a set of claimed actions are processed, they should be executed in the expected order (by priority, + * then by least number of attempts, then by scheduled date, then finally by action ID). This should be true + * even if actions are scheduled from within other scheduled actions. + * + * This test is a variation of `test_actions_are_processed_in_correct_order`, see discussion in + * https://github.com/woocommerce/action-scheduler/issues/951 to see why this specific nuance is tested. + * + * @return void + */ + public function test_child_actions_are_processed_in_correct_order() { + $time = time() - 10; + $actual_order = array(); + $watcher = function ( $number ) use ( &$actual_order ) { + $actual_order[] = $number; + }; + $parent_action = function () use ( $time ) { + // We generate 20 test actions because this is optimal for reproducing the conditions in the + // linked bug report. With fewer actions, the error condition is less likely to surface. + for ( $i = 1; $i <= 20; $i++ ) { + as_schedule_single_action( $time, 'foo', array( $i ) ); + } + }; + + add_action( 'foo', $watcher ); + add_action( 'parent', $parent_action ); + + as_schedule_single_action( $time, 'parent' ); + ActionScheduler_Mocker::get_queue_runner( ActionScheduler::store() )->run(); + + remove_action( 'foo', $watcher ); + add_action( 'parent', $parent_action ); + + $this->assertEquals( range( 1, 20 ), $actual_order, 'Once claimed, scheduled actions are executed in the expected order, including if "child actions" are scheduled from within another action.' ); + } +} diff --git a/tests/phpunit/jobstore/ActionScheduler_HybridStore_Test.php b/tests/phpunit/jobstore/ActionScheduler_HybridStore_Test.php new file mode 100644 index 0000000..f1cb65e --- /dev/null +++ b/tests/phpunit/jobstore/ActionScheduler_HybridStore_Test.php @@ -0,0 +1,277 @@ +init(); + } + update_option( ActionScheduler_HybridStore::DEMARKATION_OPTION, $this->demarkation_id ); + $hybrid = new ActionScheduler_HybridStore(); + $hybrid->set_autoincrement( '', ActionScheduler_StoreSchema::ACTIONS_TABLE ); + } + + public function tearDown() { + parent::tearDown(); + + // reset the autoincrement index. + /** @var \wpdb $wpdb */ + global $wpdb; + $wpdb->query( "TRUNCATE TABLE {$wpdb->actionscheduler_actions}" ); + $wpdb->query( "TRUNCATE TABLE {$wpdb->actionscheduler_logs}" ); + delete_option( ActionScheduler_HybridStore::DEMARKATION_OPTION ); + } + + public function test_actions_are_migrated_on_find() { + $source_store = new PostStore(); + $destination_store = new ActionScheduler_DBStore(); + $source_logger = new CommentLogger(); + $destination_logger = new ActionScheduler_DBLogger(); + + $config = new Config(); + $config->set_source_store( $source_store ); + $config->set_source_logger( $source_logger ); + $config->set_destination_store( $destination_store ); + $config->set_destination_logger( $destination_logger ); + + $hybrid_store = new ActionScheduler_HybridStore( $config ); + + $time = as_get_datetime_object( '10 minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + $source_id = $source_store->save_action( $action ); + + $found = $hybrid_store->find_action( __FUNCTION__, array() ); + + $this->assertNotEquals( $source_id, $found ); + $this->assertGreaterThanOrEqual( $this->demarkation_id, $found ); + + $found_in_source = $source_store->fetch_action( $source_id ); + $this->assertInstanceOf( NullAction::class, $found_in_source ); + } + + public function test_actions_are_migrated_on_query() { + $source_store = new PostStore(); + $destination_store = new ActionScheduler_DBStore(); + $source_logger = new CommentLogger(); + $destination_logger = new ActionScheduler_DBLogger(); + + $config = new Config(); + $config->set_source_store( $source_store ); + $config->set_source_logger( $source_logger ); + $config->set_destination_store( $destination_store ); + $config->set_destination_logger( $destination_logger ); + + $hybrid_store = new ActionScheduler_HybridStore( $config ); + + $source_actions = array(); + $destination_actions = array(); + + for ( $i = 0; $i < 10; $i++ ) { + // create in instance in the source store. + $time = as_get_datetime_object( ( $i * 10 + 1 ) . ' minutes' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + + $source_actions[] = $source_store->save_action( $action ); + + // create an instance in the destination store. + $time = as_get_datetime_object( ( $i * 10 + 5 ) . ' minutes' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + + $destination_actions[] = $destination_store->save_action( $action ); + } + + $found = $hybrid_store->query_actions( + array( + 'hook' => __FUNCTION__, + 'per_page' => 6, + ) + ); + + $this->assertCount( 6, $found ); + foreach ( $found as $key => $action_id ) { + $this->assertNotContains( $action_id, $source_actions ); + $this->assertGreaterThanOrEqual( $this->demarkation_id, $action_id ); + if ( 0 === $key % 2 ) { // it should have been in the source store. + $this->assertNotContains( $action_id, $destination_actions ); + } else { // it should have already been in the destination store. + $this->assertContains( $action_id, $destination_actions ); + } + } + + // six of the original 10 should have migrated to the new store, + // even though only three were retrieve in the final query. + $found_in_source = $source_store->query_actions( + array( + 'hook' => __FUNCTION__, + 'per_page' => 10, + ) + ); + $this->assertCount( 4, $found_in_source ); + } + + + public function test_actions_are_migrated_on_claim() { + $source_store = new PostStore(); + $destination_store = new ActionScheduler_DBStore(); + $source_logger = new CommentLogger(); + $destination_logger = new ActionScheduler_DBLogger(); + + $config = new Config(); + $config->set_source_store( $source_store ); + $config->set_source_logger( $source_logger ); + $config->set_destination_store( $destination_store ); + $config->set_destination_logger( $destination_logger ); + + $hybrid_store = new ActionScheduler_HybridStore( $config ); + + $source_actions = array(); + $destination_actions = array(); + + for ( $i = 0; $i < 10; $i++ ) { + // create in instance in the source store. + $time = as_get_datetime_object( ( $i * 10 + 1 ) . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + + $source_actions[] = $source_store->save_action( $action ); + + // create an instance in the destination store. + $time = as_get_datetime_object( ( $i * 10 + 5 ) . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + + $destination_actions[] = $destination_store->save_action( $action ); + } + + $claim = $hybrid_store->stake_claim( 6 ); + + $claimed_actions = $claim->get_actions(); + $this->assertCount( 6, $claimed_actions ); + $this->assertCount( 3, array_intersect( $destination_actions, $claimed_actions ) ); + + // six of the original 10 should have migrated to the new store, + // even though only three were retrieve in the final claim. + $found_in_source = $source_store->query_actions( + array( + 'hook' => __FUNCTION__, + 'per_page' => 10, + ) + ); + $this->assertCount( 4, $found_in_source ); + + $this->assertEquals( 0, $source_store->get_claim_count() ); + $this->assertEquals( 1, $destination_store->get_claim_count() ); + $this->assertEquals( 1, $hybrid_store->get_claim_count() ); + + } + + public function test_fetch_respects_demarkation() { + $source_store = new PostStore(); + $destination_store = new ActionScheduler_DBStore(); + $source_logger = new CommentLogger(); + $destination_logger = new ActionScheduler_DBLogger(); + + $config = new Config(); + $config->set_source_store( $source_store ); + $config->set_source_logger( $source_logger ); + $config->set_destination_store( $destination_store ); + $config->set_destination_logger( $destination_logger ); + + $hybrid_store = new ActionScheduler_HybridStore( $config ); + + $source_actions = array(); + $destination_actions = array(); + + for ( $i = 0; $i < 2; $i++ ) { + // create in instance in the source store. + $time = as_get_datetime_object( ( $i * 10 + 1 ) . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + + $source_actions[] = $source_store->save_action( $action ); + + // create an instance in the destination store. + $time = as_get_datetime_object( ( $i * 10 + 5 ) . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + + $destination_actions[] = $destination_store->save_action( $action ); + } + + foreach ( $source_actions as $action_id ) { + $action = $hybrid_store->fetch_action( $action_id ); + $this->assertInstanceOf( ActionScheduler_Action::class, $action ); + $this->assertNotInstanceOf( NullAction::class, $action ); + } + + foreach ( $destination_actions as $action_id ) { + $action = $hybrid_store->fetch_action( $action_id ); + $this->assertInstanceOf( ActionScheduler_Action::class, $action ); + $this->assertNotInstanceOf( NullAction::class, $action ); + } + } + + public function test_mark_complete_respects_demarkation() { + $source_store = new PostStore(); + $destination_store = new ActionScheduler_DBStore(); + $source_logger = new CommentLogger(); + $destination_logger = new ActionScheduler_DBLogger(); + + $config = new Config(); + $config->set_source_store( $source_store ); + $config->set_source_logger( $source_logger ); + $config->set_destination_store( $destination_store ); + $config->set_destination_logger( $destination_logger ); + + $hybrid_store = new ActionScheduler_HybridStore( $config ); + + $source_actions = array(); + $destination_actions = array(); + + for ( $i = 0; $i < 2; $i++ ) { + // create in instance in the source store. + $time = as_get_datetime_object( ( $i * 10 + 1 ) . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + + $source_actions[] = $source_store->save_action( $action ); + + // create an instance in the destination store. + $time = as_get_datetime_object( ( $i * 10 + 5 ) . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( __FUNCTION__, array(), $schedule ); + + $destination_actions[] = $destination_store->save_action( $action ); + } + + foreach ( $source_actions as $action_id ) { + $hybrid_store->mark_complete( $action_id ); + $action = $hybrid_store->fetch_action( $action_id ); + $this->assertInstanceOf( ActionScheduler_FinishedAction::class, $action ); + } + + foreach ( $destination_actions as $action_id ) { + $hybrid_store->mark_complete( $action_id ); + $action = $hybrid_store->fetch_action( $action_id ); + $this->assertInstanceOf( ActionScheduler_FinishedAction::class, $action ); + } + } +} diff --git a/tests/phpunit/jobstore/ActionScheduler_wpPostStore_Test.php b/tests/phpunit/jobstore/ActionScheduler_wpPostStore_Test.php new file mode 100644 index 0000000..e11fe94 --- /dev/null +++ b/tests/phpunit/jobstore/ActionScheduler_wpPostStore_Test.php @@ -0,0 +1,474 @@ +save_action( $action ); + + $this->assertNotEmpty( $action_id ); + } + + public function test_create_action_with_scheduled_date() { + $time = as_get_datetime_object( strtotime( '-1 week' ) ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), new ActionScheduler_SimpleSchedule( $time ) ); + $store = new ActionScheduler_wpPostStore(); + + $action_id = $store->save_action( $action, $time ); + $action_date = $store->get_date( $action_id ); + + $this->assertEquals( $time->getTimestamp(), $action_date->getTimestamp() ); + } + + public function test_retrieve_action() { + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, 'my_group' ); + $store = new ActionScheduler_wpPostStore(); + $action_id = $store->save_action( $action ); + $retrieved = $store->fetch_action( $action_id ); + + $this->assertEquals( $action->get_hook(), $retrieved->get_hook() ); + $this->assertEqualSets( $action->get_args(), $retrieved->get_args() ); + $this->assertEquals( $action->get_schedule()->get_date()->getTimestamp(), $retrieved->get_schedule()->get_date()->getTimestamp() ); + $this->assertEquals( $action->get_group(), $retrieved->get_group() ); + } + + /** + * @dataProvider provide_bad_args + * + * @param string $content Post content. + */ + public function test_action_bad_args( $content ) { + $store = new ActionScheduler_wpPostStore(); + $post_id = wp_insert_post( + array( + 'post_type' => ActionScheduler_wpPostStore::POST_TYPE, + 'post_status' => ActionScheduler_Store::STATUS_PENDING, + 'post_content' => $content, + ) + ); + + $fetched = $store->fetch_action( $post_id ); + $this->assertInstanceOf( 'ActionScheduler_NullSchedule', $fetched->get_schedule() ); + } + + public function provide_bad_args() { + return array( + array( '{"bad_json":true}}' ), + ); + } + + public function test_cancel_action() { + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, 'my_group' ); + $store = new ActionScheduler_wpPostStore(); + $action_id = $store->save_action( $action ); + $store->cancel_action( $action_id ); + + $fetched = $store->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_CanceledAction', $fetched ); + } + + public function test_cancel_actions_by_hook() { + $store = new ActionScheduler_wpPostStore(); + $actions = array(); + $hook = 'by_hook_test'; + for ( $day = 1; $day <= 3; $day++ ) { + $delta = sprintf( '+%d day', $day ); + $time = as_get_datetime_object( $delta ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( $hook, array(), $schedule, 'my_group' ); + $actions[] = $store->save_action( $action ); + } + $store->cancel_actions_by_hook( $hook ); + + foreach ( $actions as $action_id ) { + $fetched = $store->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_CanceledAction', $fetched ); + } + } + + public function test_cancel_actions_by_group() { + $store = new ActionScheduler_wpPostStore(); + $actions = array(); + $group = 'by_group_test'; + + for ( $day = 1; $day <= 3; $day++ ) { + $delta = sprintf( '+%d day', $day ); + $time = as_get_datetime_object( $delta ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, $group ); + $actions[] = $store->save_action( $action ); + } + $store->cancel_actions_by_group( $group ); + + foreach ( $actions as $action_id ) { + $fetched = $store->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_CanceledAction', $fetched ); + } + } + + public function test_claim_actions() { + $created_actions = array(); + $store = new ActionScheduler_wpPostStore(); + + for ( $i = 3; $i > -3; $i-- ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $created_actions[] = $store->save_action( $action ); + } + + $claim = $store->stake_claim(); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + + $this->assertCount( 3, $claim->get_actions() ); + $this->assertEqualSets( array_slice( $created_actions, 3, 3 ), $claim->get_actions() ); + } + + public function test_claim_actions_order() { + $store = new ActionScheduler_wpPostStore(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 hour' ) ); + $created_actions = array( + $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'my_group' ) ), + $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'my_group' ) ), + ); + + $claim = $store->stake_claim(); + $this->assertInstanceof( 'ActionScheduler_ActionClaim', $claim ); + + // Verify uniqueness of action IDs. + $this->assertEquals( 2, count( array_unique( $created_actions ) ) ); + + // Verify the count and order of the actions. + $claimed_actions = $claim->get_actions(); + $this->assertCount( 2, $claimed_actions ); + $this->assertEquals( $created_actions, $claimed_actions ); + + // Verify the reversed order doesn't pass. + $reversed_actions = array_reverse( $created_actions ); + $this->assertNotEquals( $reversed_actions, $claimed_actions ); + } + + public function test_duplicate_claim() { + $created_actions = array(); + $store = new ActionScheduler_wpPostStore(); + + for ( $i = 0; $i > -3; $i-- ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $created_actions[] = $store->save_action( $action ); + } + + $claim1 = $store->stake_claim(); + $claim2 = $store->stake_claim(); + $this->assertCount( 3, $claim1->get_actions() ); + $this->assertCount( 0, $claim2->get_actions() ); + } + + public function test_release_claim() { + $created_actions = array(); + $store = new ActionScheduler_wpPostStore(); + + for ( $i = 0; $i > -3; $i-- ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $created_actions[] = $store->save_action( $action ); + } + + $claim1 = $store->stake_claim(); + + $store->release_claim( $claim1 ); + + $claim2 = $store->stake_claim(); + $this->assertCount( 3, $claim2->get_actions() ); + } + + public function test_search() { + $created_actions = array(); + $store = new ActionScheduler_wpPostStore(); + + for ( $i = -3; $i <= 3; $i++ ) { + $time = as_get_datetime_object( $i . ' hours' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( $i ), $schedule, 'my_group' ); + + $created_actions[] = $store->save_action( $action ); + } + + $next_no_args = $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ); + $this->assertEquals( $created_actions[0], $next_no_args ); + + $next_with_args = $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'args' => array( 1 ) ) ); + $this->assertEquals( $created_actions[4], $next_with_args ); + + $non_existent = $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'args' => array( 17 ) ) ); + $this->assertNull( $non_existent ); + } + + public function test_search_by_group() { + $store = new ActionScheduler_wpPostStore(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( 'tomorrow' ) ); + + $abc = $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'abc' ) ); + $def = $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'def' ) ); + $ghi = $store->save_action( new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 1 ), $schedule, 'ghi' ) ); + + $this->assertEquals( $abc, $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'group' => 'abc' ) ) ); + $this->assertEquals( $def, $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'group' => 'def' ) ) ); + $this->assertEquals( $ghi, $store->find_action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array( 'group' => 'ghi' ) ) ); + } + + public function test_post_author() { + $current_user = get_current_user_id(); + + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $store = new ActionScheduler_wpPostStore(); + $action_id = $store->save_action( $action ); + + $post = get_post( $action_id ); + $this->assertEquals( 0, $post->post_author ); + + $new_user = $this->factory->user->create_object( + array( + 'user_login' => __FUNCTION__, + 'user_pass' => md5( wp_rand() ), + ) + ); + + wp_set_current_user( $new_user ); + + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $action_id = $store->save_action( $action ); + $post = get_post( $action_id ); + $this->assertEquals( 0, $post->post_author ); + + wp_set_current_user( $current_user ); + } + + /** + * @issue 13 + */ + public function test_post_status_for_recurring_action() { + $time = as_get_datetime_object( '10 minutes' ); + $schedule = new ActionScheduler_IntervalSchedule( $time, HOUR_IN_SECONDS ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $store = new ActionScheduler_wpPostStore(); + $action_id = $store->save_action( $action ); + + $action = $store->fetch_action( $action_id ); + $action->execute(); + $store->mark_complete( $action_id ); + + $next = $action->get_schedule()->get_next( as_get_datetime_object() ); + $new_action_id = $store->save_action( $action, $next ); + + $this->assertEquals( 'publish', get_post_status( $action_id ) ); + $this->assertEquals( 'pending', get_post_status( $new_action_id ) ); + } + + public function test_get_run_date() { + $time = as_get_datetime_object( '-10 minutes' ); + $schedule = new ActionScheduler_IntervalSchedule( $time, HOUR_IN_SECONDS ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $store = new ActionScheduler_wpPostStore(); + $action_id = $store->save_action( $action ); + + $this->assertEquals( $store->get_date( $action_id )->getTimestamp(), $time->getTimestamp() ); + + $action = $store->fetch_action( $action_id ); + $action->execute(); + $now = as_get_datetime_object(); + $store->mark_complete( $action_id ); + + $this->assertEquals( $store->get_date( $action_id )->getTimestamp(), $now->getTimestamp(), '', 1 ); // allow timestamp to be 1 second off for older versions of PHP. + + $next = $action->get_schedule()->get_next( $now ); + $new_action_id = $store->save_action( $action, $next ); + + $this->assertEquals( (int) ( $now->getTimestamp() ) + HOUR_IN_SECONDS, $store->get_date( $new_action_id )->getTimestamp() ); + } + + public function test_claim_actions_by_hooks() { + $hook1 = __FUNCTION__ . '_hook_1'; + $hook2 = __FUNCTION__ . '_hook_2'; + $store = new ActionScheduler_wpPostStore(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 hour' ) ); + + $action1 = $store->save_action( new ActionScheduler_Action( $hook1, array(), $schedule ) ); + $action2 = $store->save_action( new ActionScheduler_Action( $hook2, array(), $schedule ) ); + + // Claiming no hooks should include all actions. + $claim = $store->stake_claim( 10 ); + $this->assertEquals( 2, count( $claim->get_actions() ) ); + $this->assertTrue( in_array( $action1, $claim->get_actions(), true ) ); + $this->assertTrue( in_array( $action2, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + + // Claiming a hook should claim only actions with that hook. + $claim = $store->stake_claim( 10, null, array( $hook1 ) ); + $this->assertEquals( 1, count( $claim->get_actions() ) ); + $this->assertTrue( in_array( $action1, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + + // Claiming two hooks should claim actions with either of those hooks. + $claim = $store->stake_claim( 10, null, array( $hook1, $hook2 ) ); + $this->assertEquals( 2, count( $claim->get_actions() ) ); + $this->assertTrue( in_array( $action1, $claim->get_actions(), true ) ); + $this->assertTrue( in_array( $action2, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + + // Claiming two hooks should claim actions with either of those hooks. + $claim = $store->stake_claim( 10, null, array( __METHOD__ . '_hook_3' ) ); + $this->assertEquals( 0, count( $claim->get_actions() ) ); + $this->assertFalse( in_array( $action1, $claim->get_actions(), true ) ); + $this->assertFalse( in_array( $action2, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + } + + /** + * @issue 121 + */ + public function test_claim_actions_by_group() { + $group1 = md5( wp_rand() ); + $store = new ActionScheduler_wpPostStore(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 hour' ) ); + + $action1 = $store->save_action( new ActionScheduler_Action( __METHOD__, array(), $schedule, $group1 ) ); + $action2 = $store->save_action( new ActionScheduler_Action( __METHOD__, array(), $schedule ) ); + + // Claiming no group should include all actions. + $claim = $store->stake_claim( 10 ); + $this->assertEquals( 2, count( $claim->get_actions() ) ); + $this->assertTrue( in_array( $action1, $claim->get_actions(), true ) ); + $this->assertTrue( in_array( $action2, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + + // Claiming a group should claim only actions in that group. + $claim = $store->stake_claim( 10, null, array(), $group1 ); + $this->assertEquals( 1, count( $claim->get_actions() ) ); + $this->assertTrue( in_array( $action1, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + } + + public function test_claim_actions_by_hook_and_group() { + $hook1 = __FUNCTION__ . '_hook_1'; + $hook2 = __FUNCTION__ . '_hook_2'; + $hook3 = __FUNCTION__ . '_hook_3'; + $group1 = 'group_' . md5( wp_rand() ); + $group2 = 'group_' . md5( wp_rand() ); + $store = new ActionScheduler_wpPostStore(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 hour' ) ); + + $action1 = $store->save_action( new ActionScheduler_Action( $hook1, array(), $schedule, $group1 ) ); + $action2 = $store->save_action( new ActionScheduler_Action( $hook2, array(), $schedule ) ); + $action3 = $store->save_action( new ActionScheduler_Action( $hook3, array(), $schedule, $group2 ) ); + + // Claiming no hooks or group should include all actions. + $claim = $store->stake_claim( 10 ); + $this->assertEquals( 3, count( $claim->get_actions() ) ); + $this->assertTrue( in_array( $action1, $claim->get_actions(), true ) ); + $this->assertTrue( in_array( $action2, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + + // Claiming a group and hook should claim only actions in that group. + $claim = $store->stake_claim( 10, null, array( $hook1 ), $group1 ); + $this->assertEquals( 1, count( $claim->get_actions() ) ); + $this->assertTrue( in_array( $action1, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + + // Claiming a group and hook should claim only actions with that hook in that group. + $claim = $store->stake_claim( 10, null, array( $hook2 ), $group1 ); + $this->assertEquals( 0, count( $claim->get_actions() ) ); + $this->assertFalse( in_array( $action1, $claim->get_actions(), true ) ); + $this->assertFalse( in_array( $action2, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + + // Claiming a group and hook should claim only actions with that hook in that group. + $claim = $store->stake_claim( 10, null, array( $hook1, $hook2 ), $group2 ); + $this->assertEquals( 0, count( $claim->get_actions() ) ); + $this->assertFalse( in_array( $action1, $claim->get_actions(), true ) ); + $this->assertFalse( in_array( $action2, $claim->get_actions(), true ) ); + $store->release_claim( $claim ); + } + + /** + * The query used to claim actions explicitly ignores future pending actions, but it + * is still possible under unusual conditions (such as if MySQL runs out of temporary + * storage space) for such actions to be returned. + * + * When this happens, we still expect the store to filter them out, otherwise there is + * a risk that actions will be unexpectedly processed ahead of time. + * + * @see https://github.com/woocommerce/action-scheduler/issues/634 + */ + public function test_claim_filters_out_unexpected_future_actions() { + $group = __METHOD__; + $store = new ActionScheduler_wpPostStore(); + + // Create 4 actions: 2 that are already due (-3hrs and -1hrs) and 2 that are not yet due (+1hr and +3hrs). + for ( $i = -3; $i <= 3; $i += 2 ) { + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( $i . ' hours' ) ); + $action_ids[] = $store->save_action( new ActionScheduler_Action( 'test_' . $i, array(), $schedule, $group ) ); + } + + // This callback is used to simulate the unusual conditions whereby MySQL might unexpectedly return future + // actions, contrary to the conditions used by the store object when staking its claim. + $simulate_unexpected_db_behavior = function( $sql ) use ( $action_ids ) { + global $wpdb; + + $post_type = ActionScheduler_wpPostStore::POST_TYPE; + $pending = ActionScheduler_wpPostStore::STATUS_PENDING; + + // Look out for the claim update query, ignore all others. + if ( + 0 !== strpos( $sql, "UPDATE $wpdb->posts" ) + || 0 !== strpos( $sql, "WHERE post_type = '$post_type' AND post_status = '$pending' AND post_password = ''" ) + || ! preg_match( "/AND post_date_gmt <= '([0-9:\-\s]{19})'/", $sql, $matches ) + || count( $matches ) !== 2 + ) { + return $sql; + } + + // Now modify the query, forcing it to also return the future actions we created. + return str_replace( $matches[1], as_get_datetime_object( '+4 hours' )->format( 'Y-m-d H:i:s' ), $sql ); + }; + + add_filter( 'query', $simulate_unexpected_db_behavior ); + $claim = $store->stake_claim( 10, null, array(), $group ); + $claimed_actions = $claim->get_actions(); + $this->assertCount( 2, $claimed_actions ); + + // Cleanup. + remove_filter( 'query', $simulate_unexpected_db_behavior ); + $store->release_claim( $claim ); + } +} diff --git a/tests/phpunit/lock/ActionScheduler_OptionLock_Test.php b/tests/phpunit/lock/ActionScheduler_OptionLock_Test.php new file mode 100644 index 0000000..63afa31 --- /dev/null +++ b/tests/phpunit/lock/ActionScheduler_OptionLock_Test.php @@ -0,0 +1,77 @@ +assertInstanceOf( 'ActionScheduler_Lock', $lock ); + $this->assertInstanceOf( 'ActionScheduler_OptionLock', $lock ); + } + + public function test_is_locked() { + $lock = ActionScheduler::lock(); + $lock_type = md5( wp_rand() ); + + $this->assertFalse( $lock->is_locked( $lock_type ) ); + + $lock->set( $lock_type ); + $this->assertTrue( $lock->is_locked( $lock_type ) ); + } + + public function test_set() { + $lock = ActionScheduler::lock(); + $lock_type = md5( wp_rand() ); + + $lock->set( $lock_type ); + $this->assertTrue( $lock->is_locked( $lock_type ) ); + } + + public function test_get_expiration() { + $lock = ActionScheduler::lock(); + $lock_type = md5( wp_rand() ); + + $lock->set( $lock_type ); + + $expiration = $lock->get_expiration( $lock_type ); + $current_time = time(); + + $this->assertGreaterThanOrEqual( 0, $expiration ); + $this->assertGreaterThan( $current_time, $expiration ); + $this->assertLessThan( $current_time + MINUTE_IN_SECONDS + 1, $expiration ); + } + + /** + * A call to `ActionScheduler::lock()->set()` should fail if the lock is already held (ie, by another process). + * + * @return void + */ + public function test_lock_resists_race_conditions() { + global $wpdb; + + $lock = ActionScheduler::lock(); + $type = md5( wp_rand() ); + + // Approximate conditions in which a concurrently executing request manages to set (and obtain) the lock + // immediately before the current request can do so. + $simulate_concurrent_claim = function ( $query ) use ( $lock, $type ) { + static $executed = false; + + if ( ! $executed && false !== strpos( $query, 'action_scheduler_lock_' ) && 0 === strpos( $query, 'INSERT INTO' ) ) { + $executed = true; + $lock->set( $type ); + } + + return $query; + }; + + add_filter( 'query', $simulate_concurrent_claim ); + $wpdb->suppress_errors( true ); + $this->assertFalse( $lock->is_locked( $type ), 'Initially, the lock is not held' ); + $this->assertFalse( $lock->set( $type ), 'The lock was not obtained, because another process already claimed it.' ); + $wpdb->suppress_errors( false ); + remove_filter( 'query', $simulate_concurrent_claim ); + } +} diff --git a/tests/phpunit/logging/ActionScheduler_DBLogger_Test.php b/tests/phpunit/logging/ActionScheduler_DBLogger_Test.php new file mode 100644 index 0000000..27f9b4c --- /dev/null +++ b/tests/phpunit/logging/ActionScheduler_DBLogger_Test.php @@ -0,0 +1,134 @@ +assertInstanceOf( 'ActionScheduler_Logger', $logger ); + $this->assertInstanceOf( ActionScheduler_DBLogger::class, $logger ); + } + + public function test_add_log_entry() { + $action_id = as_schedule_single_action( time(), __METHOD__ ); + $logger = ActionScheduler::logger(); + $message = 'Logging that something happened'; + $log_id = $logger->log( $action_id, $message ); + $entry = $logger->get_entry( $log_id ); + + $this->assertEquals( $action_id, $entry->get_action_id() ); + $this->assertEquals( $message, $entry->get_message() ); + } + + public function test_storage_logs() { + $action_id = as_schedule_single_action( time(), __METHOD__ ); + $logger = ActionScheduler::logger(); + $logs = $logger->get_logs( $action_id ); + $expected = new ActionScheduler_LogEntry( $action_id, 'action created' ); + $this->assertCount( 1, $logs ); + $this->assertEquals( $expected->get_action_id(), $logs[0]->get_action_id() ); + $this->assertEquals( $expected->get_message(), $logs[0]->get_message() ); + } + + public function test_execution_logs() { + $action_id = as_schedule_single_action( time(), ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ); + $logger = ActionScheduler::logger(); + $started = new ActionScheduler_LogEntry( $action_id, 'action started via Unit Tests' ); + $finished = new ActionScheduler_LogEntry( $action_id, 'action complete via Unit Tests' ); + + $runner = ActionScheduler_Mocker::get_queue_runner(); + $runner->run( 'Unit Tests' ); + + // Expect 3 logs with the correct action ID. + $logs = $logger->get_logs( $action_id ); + $this->assertCount( 3, $logs ); + foreach ( $logs as $log ) { + $this->assertEquals( $action_id, $log->get_action_id() ); + } + + // Expect created, then started, then completed. + $this->assertEquals( 'action created', $logs[0]->get_message() ); + $this->assertEquals( $started->get_message(), $logs[1]->get_message() ); + $this->assertEquals( $finished->get_message(), $logs[2]->get_message() ); + } + + public function test_failed_execution_logs() { + $hook = __METHOD__; + add_action( $hook, array( $this, 'a_hook_callback_that_throws_an_exception' ) ); + $action_id = as_schedule_single_action( time(), $hook ); + $logger = ActionScheduler::logger(); + $started = new ActionScheduler_LogEntry( $action_id, 'action started via Unit Tests' ); + $finished = new ActionScheduler_LogEntry( $action_id, 'action complete via Unit Tests' ); + $failed = new ActionScheduler_LogEntry( $action_id, 'action failed via Unit Tests: Execution failed' ); + + $runner = ActionScheduler_Mocker::get_queue_runner(); + $runner->run( 'Unit Tests' ); + + // Expect 3 logs with the correct action ID. + $logs = $logger->get_logs( $action_id ); + $this->assertCount( 3, $logs ); + foreach ( $logs as $log ) { + $this->assertEquals( $action_id, $log->get_action_id() ); + $this->assertNotEquals( $finished->get_message(), $log->get_message() ); + } + + // Expect created, then started, then failed. + $this->assertEquals( 'action created', $logs[0]->get_message() ); + $this->assertEquals( $started->get_message(), $logs[1]->get_message() ); + $this->assertEquals( $failed->get_message(), $logs[2]->get_message() ); + } + + public function test_fatal_error_log() { + $action_id = as_schedule_single_action( time(), __METHOD__ ); + $logger = ActionScheduler::logger(); + $args = array( + 'type' => E_ERROR, + 'message' => 'Test error', + 'file' => __FILE__, + 'line' => __LINE__, + ); + + do_action( 'action_scheduler_unexpected_shutdown', $action_id, $args ); + + $logs = $logger->get_logs( $action_id ); + $found_log = false; + foreach ( $logs as $l ) { + if ( strpos( $l->get_message(), 'unexpected shutdown' ) === 0 ) { + $found_log = true; + } + } + $this->assertTrue( $found_log, 'Unexpected shutdown log not found' ); + } + + public function test_canceled_action_log() { + $action_id = as_schedule_single_action( time(), __METHOD__ ); + as_unschedule_action( __METHOD__ ); + $logger = ActionScheduler::logger(); + $logs = $logger->get_logs( $action_id ); + $expected = new ActionScheduler_LogEntry( $action_id, 'action canceled' ); + $this->assertEquals( $expected->get_message(), end( $logs )->get_message() ); + } + + public function test_deleted_action_cleanup() { + $time = as_get_datetime_object( '-10 minutes' ); + $schedule = new \ActionScheduler_SimpleSchedule( $time ); + $action = new \ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $store = new ActionScheduler_DBStore(); + $action_id = $store->save_action( $action ); + + $logger = new ActionScheduler_DBLogger(); + $logs = $logger->get_logs( $action_id ); + $this->assertNotEmpty( $logs ); + + $store->delete_action( $action_id ); + $logs = $logger->get_logs( $action_id ); + $this->assertEmpty( $logs ); + } + + public function a_hook_callback_that_throws_an_exception() { + throw new \RuntimeException( 'Execution failed' ); + } +} diff --git a/tests/phpunit/logging/ActionScheduler_wpCommentLogger_Test.php b/tests/phpunit/logging/ActionScheduler_wpCommentLogger_Test.php new file mode 100644 index 0000000..b5d41db --- /dev/null +++ b/tests/phpunit/logging/ActionScheduler_wpCommentLogger_Test.php @@ -0,0 +1,241 @@ +assertInstanceOf( 'ActionScheduler_Logger', $logger ); + if ( $this->using_comment_logger() ) { + $this->assertInstanceOf( 'ActionScheduler_wpCommentLogger', $logger ); + } else { + $this->assertNotInstanceOf( 'ActionScheduler_wpCommentLogger', $logger ); + } + } + + public function test_add_log_entry() { + $action_id = as_schedule_single_action( time(), 'a hook' ); + $logger = ActionScheduler::logger(); + $message = 'Logging that something happened'; + $log_id = $logger->log( $action_id, $message ); + $entry = $logger->get_entry( $log_id ); + + $this->assertEquals( $action_id, $entry->get_action_id() ); + $this->assertEquals( $message, $entry->get_message() ); + } + + public function test_add_log_datetime() { + $action_id = as_schedule_single_action( time(), 'a hook' ); + $logger = ActionScheduler::logger(); + $message = 'Logging that something happened'; + $date = new DateTime( 'now', new DateTimeZone( 'UTC' ) ); + $log_id = $logger->log( $action_id, $message, $date ); + $entry = $logger->get_entry( $log_id ); + + $this->assertEquals( $action_id, $entry->get_action_id() ); + $this->assertEquals( $message, $entry->get_message() ); + + $date = new ActionScheduler_DateTime( 'now', new DateTimeZone( 'UTC' ) ); + $log_id = $logger->log( $action_id, $message, $date ); + $entry = $logger->get_entry( $log_id ); + + $this->assertEquals( $action_id, $entry->get_action_id() ); + $this->assertEquals( $message, $entry->get_message() ); + } + + public function test_erroneous_entry_id() { + $comment = wp_insert_comment( + array( + 'comment_post_ID' => 1, + 'comment_author' => 'test', + 'comment_content' => 'this is not a log entry', + ) + ); + + $logger = ActionScheduler::logger(); + $entry = $logger->get_entry( $comment ); + + $this->assertEquals( '', $entry->get_action_id() ); + $this->assertEquals( '', $entry->get_message() ); + } + + public function test_storage_comments() { + $action_id = as_schedule_single_action( time(), 'a hook' ); + $logger = ActionScheduler::logger(); + $logs = $logger->get_logs( $action_id ); + $expected = new ActionScheduler_LogEntry( $action_id, 'action created' ); + + // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict + $this->assertTrue( in_array( $this->log_entry_to_array( $expected ), $this->log_entry_to_array( $logs ) ) ); + } + + protected function log_entry_to_array( $logs ) { + if ( $logs instanceof ActionScheduler_LogEntry ) { + return array( + 'action_id' => $logs->get_action_id(), + 'message' => $logs->get_message(), + ); + } + + foreach ( $logs as $id => $log ) { + $logs[ $id ] = array( + 'action_id' => $log->get_action_id(), + 'message' => $log->get_message(), + ); + } + + return $logs; + } + + public function test_execution_comments() { + $action_id = as_schedule_single_action( time(), ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ); + $logger = ActionScheduler::logger(); + $started = new ActionScheduler_LogEntry( $action_id, 'action started via Unit Tests' ); + $finished = new ActionScheduler_LogEntry( $action_id, 'action complete via Unit Tests' ); + + $runner = ActionScheduler_Mocker::get_queue_runner(); + $runner->run( 'Unit Tests' ); + + $logs = $logger->get_logs( $action_id ); + + // phpcs:disable WordPress.PHP.StrictInArray.MissingTrueStrict + $this->assertTrue( in_array( $this->log_entry_to_array( $started ), $this->log_entry_to_array( $logs ) ) ); + $this->assertTrue( in_array( $this->log_entry_to_array( $finished ), $this->log_entry_to_array( $logs ) ) ); + // phpcs:enable + } + + public function test_failed_execution_comments() { + $hook = md5( wp_rand() ); + add_action( $hook, array( $this, 'a_hook_callback_that_throws_an_exception' ) ); + + $action_id = as_schedule_single_action( time(), $hook ); + $logger = ActionScheduler::logger(); + $started = new ActionScheduler_LogEntry( $action_id, 'action started via Unit Tests' ); + $finished = new ActionScheduler_LogEntry( $action_id, 'action complete via Unit Tests' ); + $failed = new ActionScheduler_LogEntry( $action_id, 'action failed via Unit Tests: Execution failed' ); + + $runner = ActionScheduler_Mocker::get_queue_runner(); + $runner->run( 'Unit Tests' ); + + $logs = $logger->get_logs( $action_id ); + + // phpcs:disable WordPress.PHP.StrictInArray.MissingTrueStrict + $this->assertTrue( in_array( $this->log_entry_to_array( $started ), $this->log_entry_to_array( $logs ) ) ); + $this->assertFalse( in_array( $this->log_entry_to_array( $finished ), $this->log_entry_to_array( $logs ) ) ); + $this->assertTrue( in_array( $this->log_entry_to_array( $failed ), $this->log_entry_to_array( $logs ) ) ); + // phpcs:enable + } + + public function test_failed_schedule_next_instance_comments() { + $action_id = wp_rand(); + $logger = ActionScheduler::logger(); + $log_entry = new ActionScheduler_LogEntry( $action_id, 'There was a failure scheduling the next instance of this action: Execution failed' ); + + try { + $this->a_hook_callback_that_throws_an_exception(); + } catch ( Exception $e ) { + do_action( 'action_scheduler_failed_to_schedule_next_instance', $action_id, $e, new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK ) ); + } + + $logs = $logger->get_logs( $action_id ); + + // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict + $this->assertTrue( in_array( $this->log_entry_to_array( $log_entry ), $this->log_entry_to_array( $logs ) ) ); + } + + public function test_fatal_error_comments() { + $hook = md5( wp_rand() ); + $action_id = as_schedule_single_action( time(), $hook ); + $logger = ActionScheduler::logger(); + $args = array( + 'type' => E_ERROR, + 'message' => 'Test error', + 'file' => __FILE__, + 'line' => __LINE__, + ); + + do_action( 'action_scheduler_unexpected_shutdown', $action_id, $args ); + + $logs = $logger->get_logs( $action_id ); + $found_log = false; + foreach ( $logs as $l ) { + if ( strpos( $l->get_message(), 'unexpected shutdown' ) === 0 ) { + $found_log = true; + } + } + $this->assertTrue( $found_log, 'Unexpected shutdown log not found' ); + } + + public function test_canceled_action_comments() { + $action_id = as_schedule_single_action( time(), 'a hook' ); + as_unschedule_action( 'a hook' ); + + $logger = ActionScheduler::logger(); + $logs = $logger->get_logs( $action_id ); + $expected = new ActionScheduler_LogEntry( $action_id, 'action canceled' ); + + // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict + $this->assertTrue( in_array( $this->log_entry_to_array( $expected ), $this->log_entry_to_array( $logs ) ) ); + } + + public function a_hook_callback_that_throws_an_exception() { + throw new RuntimeException( 'Execution failed' ); + } + + public function test_filtering_of_get_comments() { + if ( ! $this->using_comment_logger() ) { + $this->assertTrue( true ); + return; + } + + $post_id = $this->factory->post->create_object( array( 'post_title' => __FUNCTION__ ) ); + $comment_id = $this->factory->comment->create_object( + array( + 'comment_post_ID' => $post_id, + 'comment_author' => __CLASS__, + 'comment_content' => __FUNCTION__, + ) + ); + + // Verify that we're getting the expected comment before we add logging comments. + $comments = get_comments(); + $this->assertCount( 1, $comments ); + $this->assertEquals( $comment_id, $comments[0]->comment_ID ); + + $action_id = as_schedule_single_action( time(), 'a hook' ); + $logger = ActionScheduler::logger(); + $message = 'Logging that something happened'; + $log_id = $logger->log( $action_id, $message ); + + // Verify that logging comments are excluded from general comment queries. + $comments = get_comments(); + $this->assertCount( 1, $comments ); + $this->assertEquals( $comment_id, $comments[0]->comment_ID ); + + // Verify that logging comments are returned when asking for them specifically. + $comments = get_comments( + array( + 'type' => ActionScheduler_wpCommentLogger::TYPE, + ) + ); + + // Expecting two: one when the action is created, another when we added our custom log. + $this->assertCount( 2, $comments ); + $this->assertContains( $log_id, wp_list_pluck( $comments, 'comment_ID' ) ); + } + + private function using_comment_logger() { + if ( is_null( $this->use_comment_logger ) ) { + $this->use_comment_logger = ! ActionScheduler_DataController::dependencies_met(); + } + + return $this->use_comment_logger; + } +} + diff --git a/tests/phpunit/migration/ActionMigrator_Test.php b/tests/phpunit/migration/ActionMigrator_Test.php new file mode 100644 index 0000000..a4d8285 --- /dev/null +++ b/tests/phpunit/migration/ActionMigrator_Test.php @@ -0,0 +1,144 @@ +init(); + } + } + + public function test_migrate_from_wpPost_to_db() { + $source = new ActionScheduler_wpPostStore(); + $destination = new ActionScheduler_DBStore(); + $migrator = new ActionMigrator( $source, $destination, $this->get_log_migrator() ); + + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, 'my_group' ); + $action_id = $source->save_action( $action ); + + $new_id = $migrator->migrate( $action_id ); + + // ensure we get the same record out of the new store as we stored in the old. + $retrieved = $destination->fetch_action( $new_id ); + $this->assertEquals( $action->get_hook(), $retrieved->get_hook() ); + $this->assertEqualSets( $action->get_args(), $retrieved->get_args() ); + $this->assertEquals( $action->get_schedule()->get_date()->format( 'U' ), $retrieved->get_schedule()->get_date()->format( 'U' ) ); + $this->assertEquals( $action->get_group(), $retrieved->get_group() ); + $this->assertEquals( \ActionScheduler_Store::STATUS_PENDING, $destination->get_status( $new_id ) ); + + // ensure that the record in the old store does not exist. + $old_action = $source->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_NullAction', $old_action ); + } + + public function test_does_not_migrate_missing_action_from_wpPost_to_db() { + $source = new ActionScheduler_wpPostStore(); + $destination = new ActionScheduler_DBStore(); + $migrator = new ActionMigrator( $source, $destination, $this->get_log_migrator() ); + + $action_id = wp_rand( 100, 100000 ); + + $new_id = $migrator->migrate( $action_id ); + $this->assertEquals( 0, $new_id ); + + // ensure we get the same record out of the new store as we stored in the old. + $retrieved = $destination->fetch_action( $new_id ); + $this->assertInstanceOf( 'ActionScheduler_NullAction', $retrieved ); + } + + public function test_migrate_completed_action_from_wpPost_to_db() { + $source = new ActionScheduler_wpPostStore(); + $destination = new ActionScheduler_DBStore(); + $migrator = new ActionMigrator( $source, $destination, $this->get_log_migrator() ); + + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, 'my_group' ); + $action_id = $source->save_action( $action ); + $source->mark_complete( $action_id ); + + $new_id = $migrator->migrate( $action_id ); + + // ensure we get the same record out of the new store as we stored in the old. + $retrieved = $destination->fetch_action( $new_id ); + $this->assertEquals( $action->get_hook(), $retrieved->get_hook() ); + $this->assertEqualSets( $action->get_args(), $retrieved->get_args() ); + $this->assertEquals( $action->get_schedule()->get_date()->format( 'U' ), $retrieved->get_schedule()->get_date()->format( 'U' ) ); + $this->assertEquals( $action->get_group(), $retrieved->get_group() ); + $this->assertTrue( $retrieved->is_finished() ); + $this->assertEquals( \ActionScheduler_Store::STATUS_COMPLETE, $destination->get_status( $new_id ) ); + + // ensure that the record in the old store does not exist. + $old_action = $source->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_NullAction', $old_action ); + } + + public function test_migrate_failed_action_from_wpPost_to_db() { + $source = new ActionScheduler_wpPostStore(); + $destination = new ActionScheduler_DBStore(); + $migrator = new ActionMigrator( $source, $destination, $this->get_log_migrator() ); + + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, 'my_group' ); + $action_id = $source->save_action( $action ); + $source->mark_failure( $action_id ); + + $new_id = $migrator->migrate( $action_id ); + + // ensure we get the same record out of the new store as we stored in the old. + $retrieved = $destination->fetch_action( $new_id ); + $this->assertEquals( $action->get_hook(), $retrieved->get_hook() ); + $this->assertEqualSets( $action->get_args(), $retrieved->get_args() ); + $this->assertEquals( $action->get_schedule()->get_date()->format( 'U' ), $retrieved->get_schedule()->get_date()->format( 'U' ) ); + $this->assertEquals( $action->get_group(), $retrieved->get_group() ); + $this->assertTrue( $retrieved->is_finished() ); + $this->assertEquals( \ActionScheduler_Store::STATUS_FAILED, $destination->get_status( $new_id ) ); + + // ensure that the record in the old store does not exist. + $old_action = $source->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_NullAction', $old_action ); + } + + public function test_migrate_canceled_action_from_wpPost_to_db() { + $source = new ActionScheduler_wpPostStore(); + $destination = new ActionScheduler_DBStore(); + $migrator = new ActionMigrator( $source, $destination, $this->get_log_migrator() ); + + $time = as_get_datetime_object(); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule, 'my_group' ); + $action_id = $source->save_action( $action ); + $source->cancel_action( $action_id ); + + $new_id = $migrator->migrate( $action_id ); + + // ensure we get the same record out of the new store as we stored in the old. + $retrieved = $destination->fetch_action( $new_id ); + $this->assertEquals( $action->get_hook(), $retrieved->get_hook() ); + $this->assertEqualSets( $action->get_args(), $retrieved->get_args() ); + $this->assertEquals( $action->get_schedule()->get_date()->format( 'U' ), $retrieved->get_schedule()->get_date()->format( 'U' ) ); + $this->assertEquals( $action->get_group(), $retrieved->get_group() ); + $this->assertTrue( $retrieved->is_finished() ); + $this->assertEquals( \ActionScheduler_Store::STATUS_CANCELED, $destination->get_status( $new_id ) ); + + // ensure that the record in the old store does not exist. + $old_action = $source->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_NullAction', $old_action ); + } + + private function get_log_migrator() { + return new LogMigrator( \ActionScheduler::logger(), new ActionScheduler_DBLogger() ); + } +} diff --git a/tests/phpunit/migration/BatchFetcher_Test.php b/tests/phpunit/migration/BatchFetcher_Test.php new file mode 100644 index 0000000..affbd31 --- /dev/null +++ b/tests/phpunit/migration/BatchFetcher_Test.php @@ -0,0 +1,75 @@ +init(); + } + } + + public function test_nothing_to_migrate() { + $store = new PostStore(); + $batch_fetcher = new BatchFetcher( $store ); + + $actions = $batch_fetcher->fetch(); + $this->assertEmpty( $actions ); + } + + public function test_get_due_before_future() { + $store = new PostStore(); + $due = array(); + $future = array(); + + for ( $i = 0; $i < 5; $i ++ ) { + $time = as_get_datetime_object( $i + 1 . ' minutes' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $future[] = $store->save_action( $action ); + + $time = as_get_datetime_object( $i + 1 . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $due[] = $store->save_action( $action ); + } + + $batch_fetcher = new BatchFetcher( $store ); + + $actions = $batch_fetcher->fetch(); + + $this->assertEqualSets( $due, $actions ); + } + + public function test_get_future_before_complete() { + $store = new PostStore(); + $future = array(); + $complete = array(); + + for ( $i = 0; $i < 5; $i ++ ) { + $time = as_get_datetime_object( $i + 1 . ' minutes' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $future[] = $store->save_action( $action ); + + $time = as_get_datetime_object( $i + 1 . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_FinishedAction( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $complete[] = $store->save_action( $action ); + } + + $batch_fetcher = new BatchFetcher( $store ); + + $actions = $batch_fetcher->fetch(); + + $this->assertEqualSets( $future, $actions ); + } +} diff --git a/tests/phpunit/migration/Config_Test.php b/tests/phpunit/migration/Config_Test.php new file mode 100644 index 0000000..6ca1e74 --- /dev/null +++ b/tests/phpunit/migration/Config_Test.php @@ -0,0 +1,33 @@ +expectException( \RuntimeException::class ); + $config->get_source_store(); + } + + public function test_source_logger_required() { + $config = new Config(); + $this->expectException( \RuntimeException::class ); + $config->get_source_logger(); + } + + public function test_destination_store_required() { + $config = new Config(); + $this->expectException( \RuntimeException::class ); + $config->get_destination_store(); + } + + public function test_destination_logger_required() { + $config = new Config(); + $this->expectException( \RuntimeException::class ); + $config->get_destination_logger(); + } +} diff --git a/tests/phpunit/migration/Controller_Test.php b/tests/phpunit/migration/Controller_Test.php new file mode 100644 index 0000000..b35001c --- /dev/null +++ b/tests/phpunit/migration/Controller_Test.php @@ -0,0 +1,76 @@ +schedule_migration(); + + $this->assertTrue( + as_next_scheduled_action( Scheduler::HOOK ) > 0, + 'Confirm that the Migration Controller scheduled the migration.' + ); + + as_unschedule_action( Scheduler::HOOK ); + } + + /** + * Test to ensure that if an essential table is missing, the Migration + * Controller will not schedule a migration. + * + * @see https://github.com/woocommerce/action-scheduler/issues/653 + */ + public function test_migration_not_scheduled_if_tables_are_missing() { + as_unschedule_action( Scheduler::HOOK ); + $this->rename_claims_table(); + Controller::instance()->schedule_migration(); + + $this->assertFalse( + as_next_scheduled_action( Scheduler::HOOK ), + 'When required tables are missing, the migration will not be scheduled.' + ); + + $this->restore_claims_table_name(); + } + + /** + * Rename the claims table, so that it cannot be used by the library. + */ + private function rename_claims_table() { + global $wpdb; + $normal_table_name = $wpdb->prefix . Schema::CLAIMS_TABLE; + $modified_table_name = $normal_table_name . 'x'; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "RENAME TABLE {$normal_table_name} TO {$modified_table_name}" ); + } + + /** + * Restore the expected name of the claims table, so that it can be used by the library + * and any further tests. + */ + private function restore_claims_table_name() { + global $wpdb; + $normal_table_name = $wpdb->prefix . Schema::CLAIMS_TABLE; + $modified_table_name = $normal_table_name . 'x'; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "RENAME TABLE {$modified_table_name} TO {$normal_table_name}" ); + } +} diff --git a/tests/phpunit/migration/LogMigrator_Test.php b/tests/phpunit/migration/LogMigrator_Test.php new file mode 100644 index 0000000..4913e80 --- /dev/null +++ b/tests/phpunit/migration/LogMigrator_Test.php @@ -0,0 +1,52 @@ +init(); + } + } + + public function test_migrate_from_wpComment_to_db() { + $source = new ActionScheduler_wpCommentLogger(); + $destination = new ActionScheduler_DBLogger(); + $migrator = new LogMigrator( $source, $destination ); + $source_action_id = wp_rand( 10, 10000 ); + $destination_action_id = wp_rand( 10, 10000 ); + + $logs = array(); + for ( $i = 0; $i < 3; $i++ ) { + for ( $j = 0; $j < 5; $j++ ) { + $logs[ $i ][ $j ] = md5( wp_rand() ); + if ( 1 === $i ) { + $source->log( $source_action_id, $logs[ $i ][ $j ] ); + } + } + } + + $migrator->migrate( $source_action_id, $destination_action_id ); + + $migrated = $destination->get_logs( $destination_action_id ); + $this->assertEqualSets( + $logs[1], + array_map( + function( $log ) { + return $log->get_message(); + }, + $migrated + ) + ); + + // no API for deleting logs, so we leave them for manual cleanup later. + $this->assertCount( 5, $source->get_logs( $source_action_id ) ); + } +} diff --git a/tests/phpunit/migration/Runner_Test.php b/tests/phpunit/migration/Runner_Test.php new file mode 100644 index 0000000..1067ef8 --- /dev/null +++ b/tests/phpunit/migration/Runner_Test.php @@ -0,0 +1,95 @@ +init(); + } + } + + public function test_migrate_batches() { + $source_store = new PostStore(); + $destination_store = new ActionScheduler_DBStore(); + $source_logger = new CommentLogger(); + $destination_logger = new ActionScheduler_DBLogger(); + + $config = new Config(); + $config->set_source_store( $source_store ); + $config->set_source_logger( $source_logger ); + $config->set_destination_store( $destination_store ); + $config->set_destination_logger( $destination_logger ); + + $runner = new Runner( $config ); + + $due = array(); + $future = array(); + $complete = array(); + + for ( $i = 0; $i < 5; $i ++ ) { + $time = as_get_datetime_object( $i + 1 . ' minutes' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $future[] = $source_store->save_action( $action ); + + $time = as_get_datetime_object( $i + 1 . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $due[] = $source_store->save_action( $action ); + + $time = as_get_datetime_object( $i + 1 . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_FinishedAction( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $complete[] = $source_store->save_action( $action ); + } + + $created = $source_store->query_actions( array( 'per_page' => 0 ) ); + $this->assertCount( 15, $created ); + + $runner->run( 10 ); + + $args = array( + 'per_page' => 0, + 'hook' => ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, + ); + + // due actions should migrate in the first batch. + $migrated = $destination_store->query_actions( $args ); + $this->assertCount( 5, $migrated ); + + $remaining = $source_store->query_actions( $args ); + $this->assertCount( 10, $remaining ); + + $runner->run( 10 ); + + // pending actions should migrate in the second batch. + $migrated = $destination_store->query_actions( $args ); + $this->assertCount( 10, $migrated ); + + $remaining = $source_store->query_actions( $args ); + $this->assertCount( 5, $remaining ); + + $runner->run( 10 ); + + // completed actions should migrate in the third batch. + $migrated = $destination_store->query_actions( $args ); + $this->assertCount( 15, $migrated ); + + $remaining = $source_store->query_actions( $args ); + $this->assertCount( 0, $remaining ); + + } + +} diff --git a/tests/phpunit/migration/Scheduler_Test.php b/tests/phpunit/migration/Scheduler_Test.php new file mode 100644 index 0000000..c6b14f4 --- /dev/null +++ b/tests/phpunit/migration/Scheduler_Test.php @@ -0,0 +1,138 @@ +init(); + } + } + + public function test_migration_is_complete() { + ActionScheduler_DataController::mark_migration_complete(); + $this->assertTrue( ActionScheduler_DataController::is_migration_complete() ); + } + + public function test_migration_is_not_complete() { + $this->assertFalse( ActionScheduler_DataController::is_migration_complete() ); + update_option( ActionScheduler_DataController::STATUS_FLAG, 'something_random' ); + $this->assertFalse( ActionScheduler_DataController::is_migration_complete() ); + } + + public function test_migration_is_scheduled() { + // Clear the any existing migration hooks that have already been setup. + as_unschedule_all_actions( Scheduler::HOOK ); + + $scheduler = new Scheduler(); + $this->assertFalse( + $scheduler->is_migration_scheduled(), + 'Migration is not automatically scheduled when a new ' . Scheduler::class . ' instance is created.' + ); + + $scheduler->schedule_migration(); + $this->assertTrue( + $scheduler->is_migration_scheduled(), + 'Migration is scheduled only after schedule_migration() has been called.' + ); + } + + public function test_scheduler_runs_migration() { + $source_store = new PostStore(); + $destination_store = new ActionScheduler_DBStore(); + + $return_5 = function () { + return 5; + }; + add_filter( 'action_scheduler/migration_batch_size', $return_5 ); + + // Make sure successive migration actions are delayed so all actions aren't migrated at once on separate hooks. + $return_60 = function () { + return 60; + }; + add_filter( 'action_scheduler/migration_interval', $return_60 ); + + for ( $i = 0; $i < 10; $i++ ) { + $time = as_get_datetime_object( $i + 1 . ' minutes' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $future[] = $source_store->save_action( $action ); + + $time = as_get_datetime_object( $i + 1 . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $due[] = $source_store->save_action( $action ); + } + + $this->assertCount( 20, $source_store->query_actions( array( 'per_page' => 0 ) ) ); + + $scheduler = new Scheduler(); + $scheduler->unschedule_migration(); + $scheduler->schedule_migration( time() - 1 ); + + $queue_runner = ActionScheduler_Mocker::get_queue_runner( $destination_store ); + $queue_runner->run(); + + // 5 actions should have moved from the source store when the queue runner triggered the migration action. + $args = array( + 'per_page' => 0, + 'hook' => ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, + ); + $this->assertCount( 15, $source_store->query_actions( $args ) ); + + remove_filter( 'action_scheduler/migration_batch_size', $return_5 ); + remove_filter( 'action_scheduler/migration_interval', $return_60 ); + } + + public function test_scheduler_marks_itself_complete() { + $source_store = new PostStore(); + $destination_store = new ActionScheduler_DBStore(); + + for ( $i = 0; $i < 5; $i ++ ) { + $time = as_get_datetime_object( $i + 1 . ' minutes ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $action = new ActionScheduler_Action( ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, array(), $schedule ); + $due[] = $source_store->save_action( $action ); + } + + $this->assertCount( 5, $source_store->query_actions( array( 'per_page' => 0 ) ) ); + + $scheduler = new Scheduler(); + $scheduler->unschedule_migration(); + $scheduler->schedule_migration( time() - 1 ); + + $queue_runner = ActionScheduler_Mocker::get_queue_runner( $destination_store ); + $queue_runner->run(); + + // All actions should have moved from the source store when the queue runner triggered the migration action. + $args = array( + 'per_page' => 0, + 'hook' => ActionScheduler_Callbacks::HOOK_WITH_CALLBACK, + ); + $this->assertCount( 0, $source_store->query_actions( $args ) ); + + // schedule another so we can get it to run immediately. + $scheduler->unschedule_migration(); + $scheduler->schedule_migration( time() - 1 ); + + // run again so it knows that there's nothing left to process. + $queue_runner->run(); + + $scheduler->unhook(); + + // ensure the flag is set marking migration as complete. + $this->assertTrue( ActionScheduler_DataController::is_migration_complete() ); + + // ensure that another instance has not been scheduled. + $this->assertFalse( $scheduler->is_migration_scheduled() ); + + } +} diff --git a/tests/phpunit/procedural_api/procedural_api_Test.php b/tests/phpunit/procedural_api/procedural_api_Test.php new file mode 100644 index 0000000..eb9a1c9 --- /dev/null +++ b/tests/phpunit/procedural_api/procedural_api_Test.php @@ -0,0 +1,484 @@ +fetch_action( $action_id ); + $this->assertEquals( $time, $action->get_schedule()->get_date()->getTimestamp() ); + $this->assertEquals( $hook, $action->get_hook() ); + } + + public function test_recurring_action() { + $time = time(); + $hook = md5( wp_rand() ); + $action_id = as_schedule_recurring_action( $time, HOUR_IN_SECONDS, $hook ); + + $store = ActionScheduler::store(); + $action = $store->fetch_action( $action_id ); + $this->assertEquals( $time, $action->get_schedule()->get_date()->getTimestamp() ); + $this->assertEquals( $time + HOUR_IN_SECONDS + 2, $action->get_schedule()->get_next( as_get_datetime_object( $time + 2 ) )->getTimestamp() ); + $this->assertEquals( $hook, $action->get_hook() ); + } + + /** + * Test that we reject attempts to register a recurring action with an invalid interval. This guards against + * 'runaway' recurring actions that are created accidentally and treated as having a zero-second interval. + * + * @return void + */ + public function test_recurring_actions_reject_invalid_intervals() { + $this->assertGreaterThan( + 0, + as_schedule_recurring_action( time(), 1, 'foo' ), + 'When an integer is provided as the interval, a recurring action is successfully created.' + ); + + $this->assertGreaterThan( + 0, + as_schedule_recurring_action( time(), '10', 'foo' ), + 'When an integer-like string is provided as the interval, a recurring action is successfully created.' + ); + + $this->assertGreaterThan( + 0, + as_schedule_recurring_action( time(), 100.0, 'foo' ), + 'When an integer-value as a double is provided as the interval, a recurring action is successfully created.' + ); + + $this->setExpectedIncorrectUsage( 'as_schedule_recurring_action' ); + $this->assertEquals( + 0, + as_schedule_recurring_action( time(), 'nonsense', 'foo' ), + 'When a non-numeric string is provided as the interval, a recurring action is not created and a doing-it-wrong notice is emitted.' + ); + + $this->setExpectedIncorrectUsage( 'as_schedule_recurring_action' ); + $this->assertEquals( + 0, + as_schedule_recurring_action( time(), 123.456, 'foo' ), + 'When a non-integer double is provided as the interval, a recurring action is not created and a doing-it-wrong notice is emitted.' + ); + } + + public function test_cron_schedule() { + $time = as_get_datetime_object( '2014-01-01' ); + $hook = md5( wp_rand() ); + $action_id = as_schedule_cron_action( $time->getTimestamp(), '0 0 10 10 *', $hook ); + + $store = ActionScheduler::store(); + $action = $store->fetch_action( $action_id ); + $expected_date = as_get_datetime_object( '2014-10-10' ); + $this->assertEquals( $expected_date->getTimestamp(), $action->get_schedule()->get_date()->getTimestamp() ); + $this->assertEquals( $hook, $action->get_hook() ); + + $expected_date = as_get_datetime_object( '2015-10-10' ); + $this->assertEquals( $expected_date->getTimestamp(), $action->get_schedule()->get_next( as_get_datetime_object( '2015-01-02' ) )->getTimestamp() ); + } + + public function test_get_next() { + $time = as_get_datetime_object( 'tomorrow' ); + $hook = md5( wp_rand() ); + as_schedule_recurring_action( $time->getTimestamp(), HOUR_IN_SECONDS, $hook ); + + $next = as_next_scheduled_action( $hook ); + + $this->assertEquals( $time->getTimestamp(), $next ); + } + + public function test_get_next_async() { + $hook = md5( wp_rand() ); + $action_id = as_enqueue_async_action( $hook ); + + $next = as_next_scheduled_action( $hook ); + + $this->assertTrue( $next ); + + $store = ActionScheduler::store(); + + // Completed async actions should still return false. + $store->mark_complete( $action_id ); + $next = as_next_scheduled_action( $hook ); + $this->assertFalse( $next ); + + // Failed async actions should still return false. + $store->mark_failure( $action_id ); + $next = as_next_scheduled_action( $hook ); + $this->assertFalse( $next ); + + // Cancelled async actions should still return false. + $store->cancel_action( $action_id ); + $next = as_next_scheduled_action( $hook ); + $this->assertFalse( $next ); + } + + public function provider_time_hook_args_group() { + $time = time() + 60 * 2; + $hook = md5( wp_rand() ); + $args = array( wp_rand(), wp_rand() ); + $group = 'test_group'; + + return array( + + // Test with no args or group. + array( + 'time' => $time, + 'hook' => $hook, + 'args' => array(), + 'group' => '', + ), + + // Test with args but no group. + array( + 'time' => $time, + 'hook' => $hook, + 'args' => $args, + 'group' => '', + ), + + // Test with group but no args. + array( + 'time' => $time, + 'hook' => $hook, + 'args' => array(), + 'group' => $group, + ), + + // Test with args & group. + array( + 'time' => $time, + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + ), + ); + } + + /** + * @dataProvider provider_time_hook_args_group + */ + public function test_unschedule( $time, $hook, $args, $group ) { + + $action_id_unscheduled = as_schedule_single_action( $time, $hook, $args, $group ); + $action_scheduled_time = $time + 1; + $action_id_scheduled = as_schedule_single_action( $action_scheduled_time, $hook, $args, $group ); + + as_unschedule_action( $hook, $args, $group ); + + $next = as_next_scheduled_action( $hook, $args, $group ); + $this->assertEquals( $action_scheduled_time, $next ); + + $store = ActionScheduler::store(); + $unscheduled_action = $store->fetch_action( $action_id_unscheduled ); + + // Make sure the next scheduled action is unscheduled. + $this->assertEquals( $hook, $unscheduled_action->get_hook() ); + $this->assertEquals( as_get_datetime_object( $time ), $unscheduled_action->get_schedule()->get_date() ); + $this->assertEquals( ActionScheduler_Store::STATUS_CANCELED, $store->get_status( $action_id_unscheduled ) ); + $this->assertNull( $unscheduled_action->get_schedule()->get_next( as_get_datetime_object() ) ); + + // Make sure other scheduled actions are not unscheduled. + $this->assertEquals( ActionScheduler_Store::STATUS_PENDING, $store->get_status( $action_id_scheduled ) ); + $scheduled_action = $store->fetch_action( $action_id_scheduled ); + + $this->assertEquals( $hook, $scheduled_action->get_hook() ); + $this->assertEquals( $action_scheduled_time, $scheduled_action->get_schedule()->get_date()->getTimestamp() ); + } + + /** + * @dataProvider provider_time_hook_args_group + */ + public function test_unschedule_all( $time, $hook, $args, $group ) { + + $hook = md5( $hook ); + $action_ids = array(); + + for ( $i = 0; $i < 3; $i++ ) { + $action_ids[] = as_schedule_single_action( $time, $hook, $args, $group ); + } + + as_unschedule_all_actions( $hook, $args, $group ); + + $next = as_next_scheduled_action( $hook ); + $this->assertFalse( $next ); + + $after = as_get_datetime_object( $time ); + $after->modify( '+1 minute' ); + + $store = ActionScheduler::store(); + + foreach ( $action_ids as $action_id ) { + $action = $store->fetch_action( $action_id ); + + $this->assertEquals( $hook, $action->get_hook() ); + $this->assertEquals( as_get_datetime_object( $time ), $action->get_schedule()->get_date() ); + $this->assertEquals( ActionScheduler_Store::STATUS_CANCELED, $store->get_status( $action_id ) ); + $this->assertNull( $action->get_schedule()->get_next( $after ) ); + } + } + + public function test_as_get_datetime_object_default() { + + $utc_now = new ActionScheduler_DateTime( 'now', new DateTimeZone( 'UTC' ) ); + $as_now = as_get_datetime_object(); + + // Don't want to use 'U' as timestamps will always be in UTC. + $this->assertEquals( $utc_now->format( 'Y-m-d H:i:s' ), $as_now->format( 'Y-m-d H:i:s' ) ); + } + + public function test_as_get_datetime_object_relative() { + + $utc_tomorrow = new ActionScheduler_DateTime( 'tomorrow', new DateTimeZone( 'UTC' ) ); + $as_tomorrow = as_get_datetime_object( 'tomorrow' ); + + $this->assertEquals( $utc_tomorrow->format( 'Y-m-d H:i:s' ), $as_tomorrow->format( 'Y-m-d H:i:s' ) ); + + $utc_tomorrow = new ActionScheduler_DateTime( 'yesterday', new DateTimeZone( 'UTC' ) ); + $as_tomorrow = as_get_datetime_object( 'yesterday' ); + + $this->assertEquals( $utc_tomorrow->format( 'Y-m-d H:i:s' ), $as_tomorrow->format( 'Y-m-d H:i:s' ) ); + } + + public function test_as_get_datetime_object_fixed() { + + $utc_tomorrow = new ActionScheduler_DateTime( '29 February 2016', new DateTimeZone( 'UTC' ) ); + $as_tomorrow = as_get_datetime_object( '29 February 2016' ); + + $this->assertEquals( $utc_tomorrow->format( 'Y-m-d H:i:s' ), $as_tomorrow->format( 'Y-m-d H:i:s' ) ); + + $utc_tomorrow = new ActionScheduler_DateTime( '1st January 2024', new DateTimeZone( 'UTC' ) ); + $as_tomorrow = as_get_datetime_object( '1st January 2024' ); + + $this->assertEquals( $utc_tomorrow->format( 'Y-m-d H:i:s' ), $as_tomorrow->format( 'Y-m-d H:i:s' ) ); + } + + public function test_as_get_datetime_object_timezone() { + + $timezone_au = 'Australia/Brisbane'; + $timezone_default = date_default_timezone_get(); + + // phpcs:ignore + date_default_timezone_set( $timezone_au ); + + $au_now = new ActionScheduler_DateTime( 'now' ); + $as_now = as_get_datetime_object(); + + // Make sure they're for the same time. + $this->assertEquals( $au_now->getTimestamp(), $as_now->getTimestamp() ); + + // But not in the same timezone, as $as_now should be using UTC. + $this->assertNotEquals( $au_now->format( 'Y-m-d H:i:s' ), $as_now->format( 'Y-m-d H:i:s' ) ); + + $au_now = new ActionScheduler_DateTime( 'now' ); + $as_au_now = as_get_datetime_object(); + + $this->assertEquals( $au_now->getTimestamp(), $as_now->getTimestamp(), '', 2 ); + + // But not in the same timezone, as $as_now should be using UTC. + $this->assertNotEquals( $au_now->format( 'Y-m-d H:i:s' ), $as_now->format( 'Y-m-d H:i:s' ) ); + + // phpcs:ignore + date_default_timezone_set( $timezone_default ); + } + + public function test_as_get_datetime_object_type() { + $f = 'Y-m-d H:i:s'; + $now = as_get_datetime_object(); + $this->assertInstanceOf( 'ActionScheduler_DateTime', $now ); + + $datetime = new DateTime( 'now', new DateTimeZone( 'UTC' ) ); + $as_datetime = as_get_datetime_object( $datetime ); + $this->assertEquals( $datetime->format( $f ), $as_datetime->format( $f ) ); + } + + public function test_as_has_scheduled_action() { + $store = ActionScheduler::store(); + + $time = as_get_datetime_object( 'tomorrow' ); + $action_id = as_schedule_single_action( $time->getTimestamp(), 'hook_1' ); + + $this->assertTrue( as_has_scheduled_action( 'hook_1' ) ); + $this->assertFalse( as_has_scheduled_action( 'hook_2' ) ); + + // Go to in-progress. + $store->log_execution( $action_id ); + $this->assertTrue( as_has_scheduled_action( 'hook_1' ) ); + + // Go to complete. + $store->mark_complete( $action_id ); + $this->assertFalse( as_has_scheduled_action( 'hook_1' ) ); + + // Go to failed. + $store->mark_failure( $action_id ); + $this->assertFalse( as_has_scheduled_action( 'hook_1' ) ); + } + + public function test_as_has_scheduled_action_with_args() { + as_schedule_single_action( time(), 'hook_1', array( 'a' ) ); + + $this->assertTrue( as_has_scheduled_action( 'hook_1' ) ); + $this->assertFalse( as_has_scheduled_action( 'hook_1', array( 'b' ) ) ); + + // Test for any args. + $this->assertTrue( as_has_scheduled_action( 'hook_1', array( 'a' ) ) ); + } + + public function test_as_has_scheduled_action_with_group() { + as_schedule_single_action( time(), 'hook_1', array(), 'group_1' ); + + $this->assertTrue( as_has_scheduled_action( 'hook_1', null, 'group_1' ) ); + $this->assertTrue( as_has_scheduled_action( 'hook_1', array(), 'group_1' ) ); + } + // phpcs:enable + + /** + * Test as_enqueue_async_action with unique param. + */ + public function test_as_enqueue_async_action_unique() { + $this->set_action_scheduler_store( new ActionScheduler_DBStore() ); + + $action_id = as_enqueue_async_action( 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertValidAction( $action_id ); + + $action_id_duplicate = as_enqueue_async_action( 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertEquals( 0, $action_id_duplicate ); + } + + /** + * Test enqueuing a unique action using the hybrid store. + * This is using a best-effort approach, so it's possible that the action will be enqueued even if it's not unique. + */ + public function test_as_enqueue_async_action_unique_hybrid_best_effort() { + $this->set_action_scheduler_store( new ActionScheduler_HybridStore() ); + + $action_id = as_enqueue_async_action( 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertValidAction( $action_id ); + + $action_id_duplicate = as_enqueue_async_action( 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertEquals( 0, $action_id_duplicate ); + } + + /** + * Test as_schedule_single_action with unique param. + */ + public function test_as_schedule_single_action_unique() { + $this->set_action_scheduler_store( new ActionScheduler_DBStore() ); + + $action_id = as_schedule_single_action( time(), 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertValidAction( $action_id ); + + $action_id_duplicate = as_schedule_single_action( time(), 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertEquals( 0, $action_id_duplicate ); + } + + /** + * Test as_schedule_recurring_action with unique param. + */ + public function test_as_schedule_recurring_action_unique() { + $this->set_action_scheduler_store( new ActionScheduler_DBStore() ); + + $action_id = as_schedule_recurring_action( time(), MINUTE_IN_SECONDS, 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertValidAction( $action_id ); + + $action_id_duplicate = as_schedule_recurring_action( time(), MINUTE_IN_SECONDS, 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertEquals( 0, $action_id_duplicate ); + } + + /** + * Test as_schedule_cron with unique param. + */ + public function test_as_schedule_cron_action() { + $this->set_action_scheduler_store( new ActionScheduler_DBStore() ); + + $action_id = as_schedule_cron_action( time(), '0 0 * * *', 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertValidAction( $action_id ); + + $action_id_duplicate = as_schedule_cron_action( time(), '0 0 * * *', 'hook_1', array( 'a' ), 'dummy', true ); + $this->assertEquals( 0, $action_id_duplicate ); + } + + /** + * Test recovering from an incorrect database schema when scheduling a single action. + */ + public function test_as_recover_from_incorrect_schema() { + // custom error reporting so we can test for errors sent to error_log. + global $wpdb; + $wpdb->suppress_errors( true ); + $error_capture = tmpfile(); + $actual_error_log = ini_set( 'error_log', stream_get_meta_data( $error_capture )['uri'] ); // phpcs:ignore WordPress.PHP.IniSet.Risky + + // we need a hybrid store so that dropping the priority column will cause an exception. + $this->set_action_scheduler_store( new ActionScheduler_HybridStore() ); + $this->assertEquals( 'ActionScheduler_HybridStore', get_class( ActionScheduler::store() ) ); + + // drop the priority column from the actions table. + $wpdb->query( "ALTER TABLE {$wpdb->actionscheduler_actions} DROP COLUMN priority" ); + + // try to schedule a single action. + $action_id = as_schedule_single_action( time(), 'hook_17', array( 'a', 'b' ), 'dummytest', true ); + + // ensure that no exception was thrown and zero was returned. + $this->assertEquals( 0, $action_id ); + + // try to schedule an async action. + $action_id = as_enqueue_async_action( 'hook_18', array( 'a', 'b' ), 'dummytest', true ); + // ensure that no exception was thrown and zero was returned. + $this->assertEquals( 0, $action_id ); + + // try to schedule a recurring action. + $action_id = as_schedule_recurring_action( time(), MINUTE_IN_SECONDS, 'hook_19', array( 'a', 'b' ), 'dummytest', true ); + // ensure that no exception was thrown and zero was returned. + $this->assertEquals( 0, $action_id ); + + // try to schedule a cron action. + $action_id = as_schedule_cron_action( time(), '0 0 * * *', 'hook_20', array( 'a', 'b' ), 'dummytest', true ); + // ensure that no exception was thrown and zero was returned. + $this->assertEquals( 0, $action_id ); + + // ensure that all four errors were logged to error_log. + $logged_errors = stream_get_contents( $error_capture ); + $this->assertContains( 'Caught exception while enqueuing action "hook_17": Error saving action', $logged_errors ); + $this->assertContains( 'Caught exception while enqueuing action "hook_18": Error saving action', $logged_errors ); + $this->assertContains( 'Caught exception while enqueuing action "hook_19": Error saving action', $logged_errors ); + $this->assertContains( 'Caught exception while enqueuing action "hook_20": Error saving action', $logged_errors ); + $this->assertContains( "Unknown column 'priority' in 'field list'", $logged_errors ); + + // recreate the priority column. + $wpdb->query( "ALTER TABLE {$wpdb->actionscheduler_actions} ADD COLUMN priority tinyint(10) UNSIGNED NOT NULL DEFAULT 10" ); + // restore error logging. + $wpdb->suppress_errors( false ); + ini_set( 'error_log', $actual_error_log ); // phpcs:ignore WordPress.PHP.IniSet.Risky + } + + /** + * Helper method to set actions scheduler store. + * + * @param ActionScheduler_Store $store Store instance to set. + */ + private function set_action_scheduler_store( $store ) { + $store_factory_setter = function() use ( $store ) { + self::$store = $store; + }; + $binded_store_factory_setter = Closure::bind( $store_factory_setter, null, ActionScheduler_Store::class ); + $binded_store_factory_setter(); + } + + /** + * Helper method to assert valid action. + * + * @param int $action_id Action ID to assert. + */ + private function assertValidAction( $action_id ) { + $this->assertNotEquals( 0, $action_id ); + $action = ActionScheduler::store()->fetch_action( $action_id ); + $this->assertInstanceOf( 'ActionScheduler_Action', $action ); + } +} diff --git a/tests/phpunit/procedural_api/wc_get_scheduled_actions_Test.php b/tests/phpunit/procedural_api/wc_get_scheduled_actions_Test.php new file mode 100644 index 0000000..a8a2913 --- /dev/null +++ b/tests/phpunit/procedural_api/wc_get_scheduled_actions_Test.php @@ -0,0 +1,131 @@ + */ + private $hooks = array(); + + /** @var array */ + private $args = array(); + + /** @var array */ + private $groups = array(); + + public function setUp() { + parent::setUp(); + + $store = ActionScheduler::store(); + + for ( $i = 0; $i < 10; $i++ ) { + $this->hooks[ $i ] = md5( wp_rand() ); + $this->args[ $i ] = md5( wp_rand() ); + $this->groups[ $i ] = md5( wp_rand() ); + } + + for ( $i = 0; $i < 10; $i++ ) { + for ( $j = 0; $j < 10; $j++ ) { + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( $j - 3 . 'days' ) ); + $group = $this->groups[ ( $i + $j ) % 10 ]; + $action = new ActionScheduler_Action( $this->hooks[ $i ], array( $this->args[ $j ] ), $schedule, $group ); + $store->save_action( $action ); + } + } + } + + public function test_date_queries() { + $actions = as_get_scheduled_actions( + array( + 'date' => as_get_datetime_object( gmdate( 'Y-m-d 00:00:00' ) ), + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 30, $actions ); + + $actions = as_get_scheduled_actions( + array( + 'date' => as_get_datetime_object( gmdate( 'Y-m-d 00:00:00' ) ), + 'date_compare' => '>=', + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 70, $actions ); + } + + public function test_hook_queries() { + $actions = as_get_scheduled_actions( + array( + 'hook' => $this->hooks[2], + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 10, $actions ); + + $actions = as_get_scheduled_actions( + array( + 'hook' => $this->hooks[2], + 'date' => as_get_datetime_object( gmdate( 'Y-m-d 00:00:00' ) ), + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 3, $actions ); + } + + public function test_args_queries() { + $actions = as_get_scheduled_actions( + array( + 'args' => array( $this->args[5] ), + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 10, $actions ); + + $actions = as_get_scheduled_actions( + array( + 'args' => array( $this->args[5] ), + 'hook' => $this->hooks[3], + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 1, $actions ); + + $actions = as_get_scheduled_actions( + array( + 'args' => array( $this->args[5] ), + 'hook' => $this->hooks[3], + 'date' => as_get_datetime_object( gmdate( 'Y-m-d 00:00:00' ) ), + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 0, $actions ); + } + + public function test_group_queries() { + $actions = as_get_scheduled_actions( + array( + 'group' => $this->groups[1], + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 10, $actions ); + + $actions = as_get_scheduled_actions( + array( + 'group' => $this->groups[1], + 'hook' => $this->hooks[9], + 'per_page' => -1, + ), + 'ids' + ); + $this->assertCount( 1, $actions ); + } +} diff --git a/tests/phpunit/runner/ActionScheduler_QueueCleaner_Test.php b/tests/phpunit/runner/ActionScheduler_QueueCleaner_Test.php new file mode 100644 index 0000000..e137200 --- /dev/null +++ b/tests/phpunit/runner/ActionScheduler_QueueCleaner_Test.php @@ -0,0 +1,173 @@ +save_action( $action ); + } + + $runner->run(); + + add_filter( 'action_scheduler_retention_period', '__return_zero' ); // delete any finished job. + $cleaner = new ActionScheduler_QueueCleaner( $store ); + $cleaned = $cleaner->delete_old_actions(); + remove_filter( 'action_scheduler_retention_period', '__return_zero' ); + + $this->assertIsArray( $cleaned, 'ActionScheduler_QueueCleaner::delete_old_actions() returns an array.' ); + $this->assertCount( 5, $cleaned, 'ActionScheduler_QueueCleaner::delete_old_actions() deleted the expected number of actions.' ); + + foreach ( $created_actions as $action_id ) { + $action = $store->fetch_action( $action_id ); + $this->assertFalse( $action->is_finished() ); // it's a NullAction. + } + } + + public function test_invalid_retention_period_filter_hook() { + // Supplying a non-integer such as null would break under 3.5.4 and earlier. + add_filter( 'action_scheduler_retention_period', '__return_null' ); + $cleaner = new ActionScheduler_QueueCleaner( ActionScheduler::store() ); + + $this->setExpectedIncorrectUsage( 'ActionScheduler_QueueCleaner::delete_old_actions' ); + $result = $cleaner->delete_old_actions(); + remove_filter( 'action_scheduler_retention_period', '__return_zero' ); + + $this->assertIsArray( + $result, + 'ActionScheduler_QueueCleaner::delete_old_actions() can be invoked without a fatal error, even if the retention period was invalid.' + ); + + $this->assertCount( + 0, + $result, + 'ActionScheduler_QueueCleaner::delete_old_actions() will not delete any actions if the retention period was invalid.' + ); + } + + public function test_delete_canceled_actions() { + $store = ActionScheduler::store(); + + $random = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '1 day ago' ) ); + + $created_actions = array(); + for ( $i = 0; $i < 5; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $action_id = $store->save_action( $action ); + $store->cancel_action( $action_id ); + $created_actions[] = $action_id; + } + + // track the actions that are deleted. + $mock_action = new MockAction(); + add_action( 'action_scheduler_deleted_action', array( $mock_action, 'action' ), 10, 1 ); + add_filter( 'action_scheduler_retention_period', '__return_zero' ); // delete any finished job. + + $cleaner = new ActionScheduler_QueueCleaner( $store ); + $cleaner->delete_old_actions(); + + remove_filter( 'action_scheduler_retention_period', '__return_zero' ); + remove_action( 'action_scheduler_deleted_action', array( $mock_action, 'action' ), 10 ); + + $deleted_actions = array(); + foreach ( $mock_action->get_args() as $action ) { + $deleted_actions[] = reset( $action ); + } + + $this->assertEqualSets( $created_actions, $deleted_actions ); + } + + public function test_do_not_delete_recent_actions() { + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $random = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '1 day ago' ) ); + + $created_actions = array(); + for ( $i = 0; $i < 5; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $created_actions[] = $store->save_action( $action ); + } + + $runner->run(); + + $cleaner = new ActionScheduler_QueueCleaner( $store ); + $cleaner->delete_old_actions(); + + foreach ( $created_actions as $action_id ) { + $action = $store->fetch_action( $action_id ); + $this->assertTrue( $action->is_finished() ); // It's a FinishedAction. + } + } + + public function test_reset_unrun_actions() { + $store = ActionScheduler::store(); + + $random = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '1 day ago' ) ); + + $created_actions = array(); + for ( $i = 0; $i < 5; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $created_actions[] = $store->save_action( $action ); + } + + $store->stake_claim( 10 ); + + // don't actually process the jobs, to simulate a request that timed out. + + add_filter( 'action_scheduler_timeout_period', '__return_zero' ); // delete any finished job. + $cleaner = new ActionScheduler_QueueCleaner( $store ); + $cleaner->reset_timeouts(); + + remove_filter( 'action_scheduler_timeout_period', '__return_zero' ); + + $claim = $store->stake_claim( 10 ); + $this->assertEqualSets( $created_actions, $claim->get_actions() ); + } + + public function test_do_not_reset_failed_action() { + $store = ActionScheduler::store(); + $random = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '1 day ago' ) ); + + $created_actions = array(); + for ( $i = 0; $i < 5; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $created_actions[] = $store->save_action( $action ); + } + + $claim = $store->stake_claim( 10 ); + foreach ( $claim->get_actions() as $action_id ) { + // simulate the first action interrupted by an uncatchable fatal error. + $store->log_execution( $action_id ); + break; + } + + add_filter( 'action_scheduler_timeout_period', '__return_zero' ); // delete any finished job. + $cleaner = new ActionScheduler_QueueCleaner( $store ); + $cleaner->reset_timeouts(); + remove_filter( 'action_scheduler_timeout_period', '__return_zero' ); + + $new_claim = $store->stake_claim( 10 ); + $this->assertCount( 4, $new_claim->get_actions() ); + + add_filter( 'action_scheduler_failure_period', '__return_zero' ); + $cleaner->mark_failures(); + remove_filter( 'action_scheduler_failure_period', '__return_zero' ); + + $failed = $store->query_actions( array( 'status' => ActionScheduler_Store::STATUS_FAILED ) ); + $this->assertEquals( $created_actions[0], $failed[0] ); + $this->assertCount( 1, $failed ); + } +} diff --git a/tests/phpunit/runner/ActionScheduler_QueueRunner_Test.php b/tests/phpunit/runner/ActionScheduler_QueueRunner_Test.php new file mode 100644 index 0000000..46e850c --- /dev/null +++ b/tests/phpunit/runner/ActionScheduler_QueueRunner_Test.php @@ -0,0 +1,613 @@ +run(); + + $this->assertEquals( 0, $actions_run ); + } + + public function test_run() { + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $mock = new MockAction(); + $random = md5( wp_rand() ); + + add_action( $random, array( $mock, 'action' ) ); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '1 day ago' ) ); + + for ( $i = 0; $i < 5; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $store->save_action( $action ); + } + + $actions_run = $runner->run(); + + remove_action( $random, array( $mock, 'action' ) ); + + $this->assertEquals( 5, $mock->get_call_count() ); + $this->assertEquals( 5, $actions_run ); + } + + public function test_run_with_future_actions() { + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $mock = new MockAction(); + $random = md5( wp_rand() ); + + add_action( $random, array( $mock, 'action' ) ); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '1 day ago' ) ); + + for ( $i = 0; $i < 3; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $store->save_action( $action ); + } + + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( 'tomorrow' ) ); + for ( $i = 0; $i < 3; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $store->save_action( $action ); + } + + $actions_run = $runner->run(); + + remove_action( $random, array( $mock, 'action' ) ); + + $this->assertEquals( 3, $mock->get_call_count() ); + $this->assertEquals( 3, $actions_run ); + } + + /** + * When an action is processed, it is set to "in-progress" (running) status immediately before the + * callback is invoked. If this fails (which could be because it is already in progress) then the + * action should be skipped. + * + * @return void + */ + public function test_run_with_action_that_is_already_in_progress() { + $store = ActionScheduler::store(); + $hook = uniqid(); + $callback = function () {}; + $count = 0; + $actions = array(); + $completed = array(); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '1 day ago' ) ); + + for ( $i = 0; $i < 3; $i++ ) { + $actions[] = $store->save_action( new ActionScheduler_Action( $hook, array( $hook ), $schedule ) ); + } + + /** + * This function "sabotages" the next action by prematurely setting its status to "in-progress", simulating + * an edge case where a concurrent process runs the action. + */ + $saboteur = function () use ( &$count, $store, $actions ) { + if ( 0 === $count++ ) { + $store->log_execution( $actions[1] ); + } + }; + + /** + * @param int $action_id The ID of the recently completed action. + * + * @return void + */ + $spy = function ( $action_id ) use ( &$completed ) { + $completed[] = $action_id; + }; + + add_action( 'action_scheduler_begin_execute', $saboteur ); + add_action( 'action_scheduler_completed_action', $spy ); + add_action( $hook, $callback ); + + $actions_attempted = ActionScheduler_Mocker::get_queue_runner( $store )->run(); + + remove_action( 'action_scheduler_begin_execute', $saboteur ); + remove_action( 'action_scheduler_completed_action', $spy ); + remove_action( $hook, $callback ); + + $this->assertEquals( 3, $actions_attempted, 'The queue runner attempted to process all 3 actions.' ); + $this->assertEquals( array( $actions[0], $actions[2] ), $completed, 'Only two of the three actions were completed (one was skipped, because it was processed by a concurrent request).' ); + } + + public function test_completed_action_status() { + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $random = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '12 hours ago' ) ); + $action = new ActionScheduler_Action( $random, array(), $schedule ); + $action_id = $store->save_action( $action ); + + $runner->run(); + + $finished_action = $store->fetch_action( $action_id ); + + $this->assertTrue( $finished_action->is_finished() ); + } + + public function test_next_instance_of_cron_action() { + // Create an action with daily Cron expression (i.e. midnight each day). + $random = md5( wp_rand() ); + $action_id = ActionScheduler::factory()->cron( $random, array(), null, '0 0 * * *' ); + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + + // Make sure the 1st instance of the action is scheduled to occur tomorrow. + $date = as_get_datetime_object( 'tomorrow' ); + $date->modify( '-1 minute' ); + $claim = $store->stake_claim( 10, $date ); + $this->assertCount( 0, $claim->get_actions() ); + + $store->release_claim( $claim ); + + $date->modify( '+1 minute' ); + + $claim = $store->stake_claim( 10, $date ); + $actions = $claim->get_actions(); + $this->assertCount( 1, $actions ); + + $fetched_action_id = reset( $actions ); + $fetched_action = $store->fetch_action( $fetched_action_id ); + + $this->assertEquals( $fetched_action_id, $action_id ); + $this->assertEquals( $random, $fetched_action->get_hook() ); + $this->assertEquals( $date->getTimestamp(), $fetched_action->get_schedule()->get_date()->getTimestamp(), '', 1 ); + + $store->release_claim( $claim ); + + // Make sure the 2nd instance of the cron action is scheduled to occur tomorrow still. + $runner->process_action( $action_id ); + + $claim = $store->stake_claim( 10, $date ); + $actions = $claim->get_actions(); + $this->assertCount( 1, $actions ); + + $fetched_action_id = reset( $actions ); + $fetched_action = $store->fetch_action( $fetched_action_id ); + + $this->assertNotEquals( $fetched_action_id, $action_id ); + $this->assertEquals( $random, $fetched_action->get_hook() ); + $this->assertEquals( $date->getTimestamp(), $fetched_action->get_schedule()->get_date()->getTimestamp(), '', 1 ); + } + + public function test_next_instance_of_interval_action() { + $random = md5( wp_rand() ); + $date = as_get_datetime_object( '12 hours ago' ); + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + + // Create an action to recur every 24 hours, with the first instance scheduled to run 12 hours ago. + $action_id = ActionScheduler::factory()->create( + array( + 'type' => 'recurring', + 'hook' => $random, + 'when' => $date->getTimestamp(), + 'pattern' => DAY_IN_SECONDS, + 'priority' => 2, + ) + ); + + // Make sure the 1st instance of the action is scheduled to occur 12 hours ago. + $claim = $store->stake_claim( 10, $date ); + $actions = $claim->get_actions(); + $this->assertCount( 1, $actions ); + + $fetched_action_id = reset( $actions ); + $fetched_action = $store->fetch_action( $fetched_action_id ); + + $this->assertEquals( $fetched_action_id, $action_id ); + $this->assertEquals( $random, $fetched_action->get_hook() ); + $this->assertEquals( $date->getTimestamp(), $fetched_action->get_schedule()->get_date()->getTimestamp(), '', 1 ); + + $store->release_claim( $claim ); + + // Make sure after the queue is run, the 2nd instance of the action is scheduled to occur in 24 hours. + $runner->run(); + + $date = as_get_datetime_object( '+1 day' ); + $claim = $store->stake_claim( 10, $date ); + $actions = $claim->get_actions(); + $this->assertCount( 1, $actions ); + + $fetched_action_id = reset( $actions ); + $fetched_action = $store->fetch_action( $fetched_action_id ); + + $this->assertNotEquals( $fetched_action_id, $action_id ); + $this->assertEquals( $random, $fetched_action->get_hook() ); + $this->assertEquals( $date->getTimestamp(), $fetched_action->get_schedule()->get_date()->getTimestamp(), '', 1 ); + $this->assertEquals( 2, $fetched_action->get_priority(), 'The replacement action should inherit the same priority as the original action.' ); + $store->release_claim( $claim ); + + // Make sure the 3rd instance of the cron action is scheduled for 24 hours from now, as the action was run early, ahead of schedule. + $runner->process_action( $fetched_action_id ); + $date = as_get_datetime_object( '+1 day' ); + + $claim = $store->stake_claim( 10, $date ); + $actions = $claim->get_actions(); + $this->assertCount( 1, $actions ); + + $fetched_action_id = reset( $actions ); + $fetched_action = $store->fetch_action( $fetched_action_id ); + + $this->assertNotEquals( $fetched_action_id, $action_id ); + $this->assertEquals( $random, $fetched_action->get_hook() ); + $this->assertEquals( $date->getTimestamp(), $fetched_action->get_schedule()->get_date()->getTimestamp(), '', 1 ); + } + + /** + * As soon as one recurring action has been executed its replacement will be scheduled. + * + * This is true even if the current action fails. This makes sense, since a failure may be temporary in nature. + * However, if the same recurring action consistently fails then it is likely that there is a problem and we should + * stop creating new instances. This test outlines the expected behavior in this regard. + * + * @return void + */ + public function test_failing_recurring_actions_are_not_rescheduled_when_threshold_met() { + $store = ActionScheduler_Store::instance(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $created_actions = array(); + + // Create 4 failed actions (one below the threshold of what counts as 'consistently failing'). + for ( $i = 0; $i < 3; $i++ ) { + // We give each action a unique set of args, this illustrates that in the context of determining consistent + // failure we care only about the hook and not other properties of the action. + $args = array( 'unique-' . $i => hash( 'md5', $i ) ); + $hook = 'will-fail'; + $date = as_get_datetime_object( 12 - $i . ' hours ago' ); + $action_id = as_schedule_recurring_action( $date->getTimestamp(), HOUR_IN_SECONDS, $hook, $args ); + $store->mark_failure( $action_id ); + $created_actions[] = $action_id; + } + + // Now create a further action using the same hook, that is also destined to fail. + $date = as_get_datetime_object( '6 hours ago' ); + $pending_action_id = as_schedule_recurring_action( $date->getTimestamp(), HOUR_IN_SECONDS, $hook, $args ); + $created_actions[] = $pending_action_id; + + // Process the queue! + $runner->run(); + + $pending_actions = $store->query_actions( + array( + 'hook' => $hook, + 'args' => $args, + 'status' => ActionScheduler_Store::STATUS_PENDING, + ) + ); + + $new_pending_action_id = current( $pending_actions ); + + // We now have 5 instances of the same recurring action. 4 have already failed, one is pending. + $this->assertCount( 1, $pending_actions, 'If the threshold for consistent failure has not been met, a replacement action should have been scheduled.' ); + $this->assertNotContains( $new_pending_action_id, $created_actions, 'Confirm that the replacement action is new, and not one of those we created manually earlier in the test.' ); + + // Process the pending action (we do this directly instead of via `$runner->run()` because it won't actually + // become due for another hour). + $runner->process_action( $new_pending_action_id ); + $pending_actions = $store->query_actions( + array( + 'hook' => $hook, + 'args' => $args, + 'status' => ActionScheduler_Store::STATUS_PENDING, + ) + ); + + // Now 5 instances of the same recurring action have all failed, therefore the threshold for consistent failure + // has been met and, this time, a new action should *not* have been scheduled. + $this->assertCount( 0, $pending_actions, 'The failure threshold (5 consecutive fails for recurring actions with the same signature) having been met, no further actions were scheduled.' ); + } + + /** + * If a recurring action continually fails, it will not be re-scheduled. However, a hook makes it possible to + * exempt specific actions from this behavior (without impacting other unrelated recurring actions). + * + * @see self::test_failing_recurring_actions_are_not_rescheduled_when_threshold_met() + * @return void + */ + public function test_exceptions_can_be_made_for_failing_recurring_actions() { + $store = ActionScheduler_Store::instance(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $observed = 0; + + // Create 2 sets of 5 actions that have already past and have already failed (five being the threshold of what + // counts as 'consistently failing'). + for ( $i = 0; $i < 4; $i++ ) { + $date = as_get_datetime_object( 12 - $i . ' hours ago' ); + $store->mark_failure( as_schedule_recurring_action( $date->getTimestamp(), HOUR_IN_SECONDS, 'foo' ) ); + $store->mark_failure( as_schedule_recurring_action( $date->getTimestamp(), HOUR_IN_SECONDS, 'bar' ) ); + } + + // Add one more action (pending and past-due) to each set. + $date = as_get_datetime_object( '6 hours ago' ); + as_schedule_recurring_action( $date->getTimestamp(), HOUR_IN_SECONDS, 'foo' ); + as_schedule_recurring_action( $date->getTimestamp(), HOUR_IN_SECONDS, 'bar' ); + + // Define a filter function that allows scheduled actions for hook 'foo' to still be rescheduled, despite its + // history of consistent failure. + $filter = function( $is_failing, $action ) use ( &$observed ) { + $observed++; + return 'foo' === $action->get_hook() ? false : $is_failing; + }; + + // Process the queue with our consistent-failure filter function in place. + add_filter( 'action_scheduler_recurring_action_is_consistently_failing', $filter, 10, 2 ); + $runner->run(); + + // Check how many (if any) of our test actions were re-scheduled. + $pending_foo_actions = $store->query_actions( + array( + 'hook' => 'foo', + 'status' => ActionScheduler_Store::STATUS_PENDING, + ) + ); + $pending_bar_actions = $store->query_actions( + array( + 'hook' => 'bar', + 'status' => ActionScheduler_Store::STATUS_PENDING, + ) + ); + + // Expectations... + $this->assertCount( 1, $pending_foo_actions, 'We expect a new instance of action "foo" will have been scheduled.' ); + $this->assertCount( 0, $pending_bar_actions, 'We expect no further instances of action "bar" will have been scheduled.' ); + $this->assertEquals( 2, $observed, 'We expect our callback to have been invoked twice, once in relation to each test action.' ); + + // Clean-up... + remove_filter( 'action_scheduler_recurring_action_is_consistently_failing', $filter, 10, 2 ); + } + + public function test_hooked_into_wp_cron() { + $next = wp_next_scheduled( ActionScheduler_QueueRunner::WP_CRON_HOOK, array( 'WP Cron' ) ); + $this->assertNotEmpty( $next ); + } + + public function test_batch_count_limit() { + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $mock = new MockAction(); + $random = md5( wp_rand() ); + + add_action( $random, array( $mock, 'action' ) ); + $schedule = new ActionScheduler_SimpleSchedule( new ActionScheduler_DateTime( '1 day ago' ) ); + + for ( $i = 0; $i < 2; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $store->save_action( $action ); + } + + $claim = $store->stake_claim(); + + $actions_run = $runner->run(); + + $this->assertEquals( 0, $mock->get_call_count() ); + $this->assertEquals( 0, $actions_run ); + + $store->release_claim( $claim ); + + $actions_run = $runner->run(); + + $this->assertEquals( 2, $mock->get_call_count() ); + $this->assertEquals( 2, $actions_run ); + + remove_action( $random, array( $mock, 'action' ) ); + } + + public function test_changing_batch_count_limit() { + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $random = md5( wp_rand() ); + $schedule = new ActionScheduler_SimpleSchedule( new ActionScheduler_DateTime( '1 day ago' ) ); + + for ( $i = 0; $i < 30; $i++ ) { + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $store->save_action( $action ); + } + + $claims = array(); + + for ( $i = 0; $i < 5; $i++ ) { + $claims[] = $store->stake_claim( 5 ); + } + + $mock1 = new MockAction(); + add_action( $random, array( $mock1, 'action' ) ); + $actions_run = $runner->run(); + remove_action( $random, array( $mock1, 'action' ) ); + + $this->assertEquals( 0, $mock1->get_call_count() ); + $this->assertEquals( 0, $actions_run ); + + add_filter( 'action_scheduler_queue_runner_concurrent_batches', array( $this, 'return_6' ) ); + + $mock2 = new MockAction(); + add_action( $random, array( $mock2, 'action' ) ); + $actions_run = $runner->run(); + remove_action( $random, array( $mock2, 'action' ) ); + + $this->assertEquals( 5, $mock2->get_call_count() ); + $this->assertEquals( 5, $actions_run ); + + remove_filter( 'action_scheduler_queue_runner_concurrent_batches', array( $this, 'return_6' ) ); + + for ( $i = 0; $i < 5; $i++ ) { // to make up for the actions we just processed. + $action = new ActionScheduler_Action( $random, array( $random ), $schedule ); + $store->save_action( $action ); + } + + $mock3 = new MockAction(); + add_action( $random, array( $mock3, 'action' ) ); + $actions_run = $runner->run(); + remove_action( $random, array( $mock3, 'action' ) ); + + $this->assertEquals( 0, $mock3->get_call_count() ); + $this->assertEquals( 0, $actions_run ); + + remove_filter( 'action_scheduler_queue_runner_concurrent_batches', array( $this, 'return_6' ) ); + } + + public function return_6() { + return 6; + } + + public function test_store_fetch_action_failure_schedule_next_instance() { + $random = md5( wp_rand() ); + $schedule = new ActionScheduler_IntervalSchedule( as_get_datetime_object( '12 hours ago' ), DAY_IN_SECONDS ); + $action = new ActionScheduler_Action( $random, array(), $schedule ); + $action_id = ActionScheduler::store()->save_action( $action ); + + // Set up a mock store that will throw an exception when fetching actions. + $store = $this + ->getMockBuilder( 'ActionScheduler_wpPostStore' ) + ->setMethods( array( 'fetch_action' ) ) + ->getMock(); + $store + ->method( 'fetch_action' ) + ->with( array( $action_id ) ) + ->will( $this->throwException( new Exception() ) ); + + // Set up a mock queue runner to verify that schedule_next_instance() + // isn't called for an undefined $action. + $runner = $this + ->getMockBuilder( 'ActionScheduler_QueueRunner' ) + ->setConstructorArgs( array( $store ) ) + ->setMethods( array( 'schedule_next_instance' ) ) + ->getMock(); + $runner + ->expects( $this->never() ) + ->method( 'schedule_next_instance' ); + + $runner->run(); + + // Set up a mock store that will throw an exception when fetching actions. + $store2 = $this + ->getMockBuilder( 'ActionScheduler_wpPostStore' ) + ->setMethods( array( 'fetch_action' ) ) + ->getMock(); + $store2 + ->method( 'fetch_action' ) + ->with( array( $action_id ) ) + ->willReturn( null ); + + // Set up a mock queue runner to verify that schedule_next_instance() + // isn't called for an undefined $action. + $runner2 = $this + ->getMockBuilder( 'ActionScheduler_QueueRunner' ) + ->setConstructorArgs( array( $store ) ) + ->setMethods( array( 'schedule_next_instance' ) ) + ->getMock(); + $runner2 + ->expects( $this->never() ) + ->method( 'schedule_next_instance' ); + + $runner2->run(); + } + + /** + * Checks that actions are processed in the correct order. Specifically, that past-due actions are not + * penalized in favor of newer async actions. + * + * @return void + */ + public function test_order_in_which_actions_are_processed() { + /** @var ActionScheduler_Store $store */ + $store = ActionScheduler::store(); + $runner = ActionScheduler_Mocker::get_queue_runner( $store ); + $execution_order = array(); + $past_due_action = as_schedule_single_action( time() - HOUR_IN_SECONDS, __METHOD__, array( 'execute' => 'first' ) ); + $async_action = as_enqueue_async_action( __METHOD__, array( 'execute' => 'second' ) ); + + $monitor = function ( $order ) use ( &$execution_order ) { + $execution_order[] = $order; + }; + + add_action( __METHOD__, $monitor ); + $runner->run(); + remove_action( __METHOD__, $monitor ); + + $this->assertEquals( + array( + 'first', + 'second', + ), + $execution_order + ); + } + + /** + * Tests the ability of the queue runner to accommodate a range of error conditions (raised recoverable errors + * under PHP 5.6, thrown errors under PHP 7.0 upwards, and exceptions under all supported versions). + * + * @return void + */ + public function test_recoverable_errors_do_not_break_queue_runner() { + $executed = 0; + as_enqueue_async_action( 'foo' ); + as_enqueue_async_action( 'bar' ); + as_enqueue_async_action( 'baz' ); + as_enqueue_async_action( 'foobar' ); + + /** + * Trigger a custom user error. + * + * @return void + */ + $foo = function () use ( &$executed ) { + $executed++; + trigger_error( 'Trouble.', E_USER_ERROR ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + }; + + /** + * Throw an exception. + * + * @throws Exception Intentionally raised for testing purposes. + * + * @return void + */ + $bar = function () use ( &$executed ) { + $executed++; + throw new Exception( 'More trouble.' ); + }; + + /** + * Trigger a recoverable fatal error. Under PHP 5.6 the error will be raised, and under PHP 7.0 and higher the + * error will be thrown (different mechanisms are needed to support this difference). + * + * @throws Throwable Intentionally raised for testing purposes. + * + * @return void + */ + $baz = function () use ( &$executed ) { + $executed++; + (string) (object) array(); + }; + + /** + * A problem-free callback. + * + * @return void + */ + $foobar = function () use ( &$executed ) { + $executed++; + }; + + add_action( 'foo', $foo ); + add_action( 'bar', $bar ); + add_action( 'baz', $baz ); + add_action( 'foobar', $foobar ); + + ActionScheduler_Mocker::get_queue_runner( ActionScheduler::store() )->run(); + $this->assertEquals( 4, $executed, 'All enqueued actions ran as expected despite errors and exceptions being raised by the first actions in the set.' ); + + remove_action( 'foo', $foo ); + remove_action( 'bar', $bar ); + remove_action( 'baz', $baz ); + remove_action( 'foobar', $foobar ); + } +} diff --git a/tests/phpunit/schedules/ActionScheduler_CronSchedule_Test.php b/tests/phpunit/schedules/ActionScheduler_CronSchedule_Test.php new file mode 100644 index 0000000..0900514 --- /dev/null +++ b/tests/phpunit/schedules/ActionScheduler_CronSchedule_Test.php @@ -0,0 +1,75 @@ +modify( '-1 hour' ); + $schedule = new ActionScheduler_CronSchedule( $start, $cron ); + $this->assertEquals( $time, $schedule->get_date() ); + $this->assertEquals( $start, $schedule->get_first_date() ); + + // Test delaying for a future start date. + $start->modify( '+1 week' ); + $time->modify( '+1 week' ); + + $schedule = new ActionScheduler_CronSchedule( $start, $cron ); + $this->assertEquals( $time, $schedule->get_date() ); + $this->assertEquals( $start, $schedule->get_first_date() ); + } + + public function test_creation_with_first_date() { + $time = as_get_datetime_object( 'tomorrow' ); + $cron = CronExpression::factory( '@daily' ); + $start = clone $time; + $start->modify( '-1 hour' ); + $schedule = new ActionScheduler_CronSchedule( $start, $cron ); + $this->assertEquals( $time, $schedule->get_date() ); + $this->assertEquals( $start, $schedule->get_first_date() ); + + // Test delaying for a future start date. + $first = clone $time; + $first->modify( '-1 day' ); + $start->modify( '+1 week' ); + $time->modify( '+1 week' ); + + $schedule = new ActionScheduler_CronSchedule( $start, $cron, $first ); + $this->assertEquals( $time, $schedule->get_date() ); + $this->assertEquals( $first, $schedule->get_first_date() ); + } + + public function test_next() { + $time = as_get_datetime_object( '2013-06-14' ); + $cron = CronExpression::factory( '@daily' ); + $schedule = new ActionScheduler_CronSchedule( $time, $cron ); + $this->assertEquals( as_get_datetime_object( 'tomorrow' ), $schedule->get_next( as_get_datetime_object() ) ); + } + + public function test_is_recurring() { + $schedule = new ActionScheduler_CronSchedule( as_get_datetime_object( '2013-06-14' ), CronExpression::factory( '@daily' ) ); + $this->assertTrue( $schedule->is_recurring() ); + } + + public function test_cron_format() { + $time = as_get_datetime_object( '2014-01-01' ); + $cron = CronExpression::factory( '0 0 10 10 *' ); + $schedule = new ActionScheduler_CronSchedule( $time, $cron ); + $this->assertEquals( as_get_datetime_object( '2014-10-10' ), $schedule->get_date() ); + + $cron = CronExpression::factory( '0 0 L 1/2 *' ); + $schedule = new ActionScheduler_CronSchedule( $time, $cron ); + $this->assertEquals( as_get_datetime_object( '2014-01-31' ), $schedule->get_date() ); + $this->assertEquals( as_get_datetime_object( '2014-07-31' ), $schedule->get_next( as_get_datetime_object( '2014-06-01' ) ) ); + $this->assertEquals( as_get_datetime_object( '2028-11-30' ), $schedule->get_next( as_get_datetime_object( '2028-11-01' ) ) ); + + $cron = CronExpression::factory( '30 14 * * MON#3 *' ); + $schedule = new ActionScheduler_CronSchedule( $time, $cron ); + $this->assertEquals( as_get_datetime_object( '2014-01-20 14:30:00' ), $schedule->get_date() ); + $this->assertEquals( as_get_datetime_object( '2014-05-19 14:30:00' ), $schedule->get_next( as_get_datetime_object( '2014-05-01' ) ) ); + } +} diff --git a/tests/phpunit/schedules/ActionScheduler_IntervalSchedule_Test.php b/tests/phpunit/schedules/ActionScheduler_IntervalSchedule_Test.php new file mode 100644 index 0000000..a40052a --- /dev/null +++ b/tests/phpunit/schedules/ActionScheduler_IntervalSchedule_Test.php @@ -0,0 +1,37 @@ +assertEquals( $time, $schedule->get_date() ); + $this->assertEquals( $time, $schedule->get_first_date() ); + } + + public function test_creation_with_first_date() { + $first = as_get_datetime_object(); + $time = as_get_datetime_object( '+12 hours' ); + $schedule = new ActionScheduler_IntervalSchedule( $time, HOUR_IN_SECONDS, $first ); + $this->assertEquals( $time, $schedule->get_date() ); + $this->assertEquals( $first, $schedule->get_first_date() ); + } + + public function test_next() { + $now = time(); + $start = $now - 30; + $schedule = new ActionScheduler_IntervalSchedule( as_get_datetime_object( "@$start" ), MINUTE_IN_SECONDS ); + $this->assertEquals( $start, $schedule->get_date()->getTimestamp() ); + $this->assertEquals( $now + MINUTE_IN_SECONDS, $schedule->get_next( as_get_datetime_object() )->getTimestamp() ); + $this->assertEquals( $start, $schedule->get_next( as_get_datetime_object( "@$start" ) )->getTimestamp() ); + } + + public function test_is_recurring() { + $start = time() - 30; + $schedule = new ActionScheduler_IntervalSchedule( as_get_datetime_object( "@$start" ), MINUTE_IN_SECONDS ); + $this->assertTrue( $schedule->is_recurring() ); + } +} diff --git a/tests/phpunit/schedules/ActionScheduler_NullSchedule_Test.php b/tests/phpunit/schedules/ActionScheduler_NullSchedule_Test.php new file mode 100644 index 0000000..4e10da2 --- /dev/null +++ b/tests/phpunit/schedules/ActionScheduler_NullSchedule_Test.php @@ -0,0 +1,17 @@ +assertNull( $schedule->get_date() ); + } + + public function test_is_recurring() { + $schedule = new ActionScheduler_NullSchedule(); + $this->assertFalse( $schedule->is_recurring() ); + } +} diff --git a/tests/phpunit/schedules/ActionScheduler_SimpleSchedule_Test.php b/tests/phpunit/schedules/ActionScheduler_SimpleSchedule_Test.php new file mode 100644 index 0000000..d017673 --- /dev/null +++ b/tests/phpunit/schedules/ActionScheduler_SimpleSchedule_Test.php @@ -0,0 +1,36 @@ +assertEquals( $time, $schedule->get_date() ); + } + + public function test_past_date() { + $time = as_get_datetime_object( '-1 day' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $this->assertEquals( $time, $schedule->get_date() ); + } + + public function test_future_date() { + $time = as_get_datetime_object( '+1 day' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $this->assertEquals( $time, $schedule->get_date() ); + } + + public function test_grace_period_for_next() { + $time = as_get_datetime_object( '3 seconds ago' ); + $schedule = new ActionScheduler_SimpleSchedule( $time ); + $this->assertEquals( $time, $schedule->get_date() ); + } + + public function test_is_recurring() { + $schedule = new ActionScheduler_SimpleSchedule( as_get_datetime_object( '+1 day' ) ); + $this->assertFalse( $schedule->is_recurring() ); + } +} diff --git a/tests/phpunit/versioning/ActionScheduler_Versions_Test.php b/tests/phpunit/versioning/ActionScheduler_Versions_Test.php new file mode 100644 index 0000000..7eeee35 --- /dev/null +++ b/tests/phpunit/versioning/ActionScheduler_Versions_Test.php @@ -0,0 +1,42 @@ +register( '1.0-dev', 'callback_1_dot_0_dev' ); + $versions->register( '1.0', 'callback_1_dot_0' ); + + $registered = $versions->get_versions(); + + $this->assertArrayHasKey( '1.0-dev', $registered ); + $this->assertArrayHasKey( '1.0', $registered ); + $this->assertCount( 2, $registered ); + + $this->assertEquals( 'callback_1_dot_0_dev', $registered['1.0-dev'] ); + } + + public function test_duplicate_version() { + $versions = new ActionScheduler_Versions(); + $versions->register( '1.0', 'callback_1_dot_0_a' ); + $versions->register( '1.0', 'callback_1_dot_0_b' ); + + $registered = $versions->get_versions(); + + $this->assertArrayHasKey( '1.0', $registered ); + $this->assertCount( 1, $registered ); + } + + public function test_latest_version() { + $versions = new ActionScheduler_Versions(); + $this->assertEquals( '__return_null', $versions->latest_version_callback() ); + $versions->register( '1.2', 'callback_1_dot_2' ); + $versions->register( '1.3', 'callback_1_dot_3' ); + $versions->register( '1.0', 'callback_1_dot_0' ); + + $this->assertEquals( '1.3', $versions->latest_version() ); + $this->assertEquals( 'callback_1_dot_3', $versions->latest_version_callback() ); + } +} From 2a4886f8333295340a184cdeee5463488b4368fe Mon Sep 17 00:00:00 2001 From: Fernando Morgenstern Date: Thu, 5 Jun 2025 09:20:10 -0300 Subject: [PATCH 2/2] Use Action Scheduler to sync users in background --- inc/global-hooks.php | 21 ++++++++++++++++++--- mailrelay.php | 2 ++ phpcs.xml | 1 + 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/inc/global-hooks.php b/inc/global-hooks.php index 23362a9..5eeca97 100644 --- a/inc/global-hooks.php +++ b/inc/global-hooks.php @@ -4,14 +4,29 @@ die( 'Access Denied.' ); } -function mailrelay_new_user_auto_sync( $user_id ) { +function mailrelay_user_register_hook( $user_id ) { if ( '1' !== get_option( 'mailrelay_auto_sync' ) ) { // Autosync isn't enabled return; } - $user = new WP_User( $user_id ); + // Schedule the sync to run in the background + as_enqueue_async_action( + 'mailrelay_sync_user_background', + array( $user_id ), + 'mailrelay', + true + ); +} +add_action( 'user_register', 'mailrelay_user_register_hook' ); +/** + * Background handler for syncing users with Mailrelay + * + * @param int $user_id The WordPress user ID to sync. + */ +function mailrelay_sync_user_background( $user_id ) { + $user = new WP_User( $user_id ); $groups = get_option( 'mailrelay_auto_sync_groups' ); if ( ! empty( $groups ) ) { @@ -22,4 +37,4 @@ function mailrelay_new_user_auto_sync( $user_id ) { } } } -add_action( 'user_register', 'mailrelay_new_user_auto_sync' ); +add_action( 'mailrelay_sync_user_background', 'mailrelay_sync_user_background', 10, 1 ); diff --git a/mailrelay.php b/mailrelay.php index 1766bb5..200c814 100644 --- a/mailrelay.php +++ b/mailrelay.php @@ -19,6 +19,8 @@ define( 'MAILRELAY_BASE_DOMAIN', 'ipzmarketing.com' ); } +require_once __DIR__ . '/libraries/action-scheduler/action-scheduler.php'; + require_once __DIR__ . '/inc/common.php'; require_once __DIR__ . '/inc/block-widgets.php'; require_once __DIR__ . '/inc/global-hooks.php'; diff --git a/phpcs.xml b/phpcs.xml index 10458cc..b211bdd 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -26,6 +26,7 @@ + */libraries/* */node_modules/* */vendor/* \ No newline at end of file