From 5499f2edb66230e157fc33b154b9c7872d42ab11 Mon Sep 17 00:00:00 2001 From: Andy Miller <1084697+rhukster@users.noreply.github.com> Date: Wed, 5 Sep 2018 19:02:12 -0600 Subject: [PATCH] Feature/scheduler (#2170) * scheduler first commit * moved jobs to config * got some args working * commands and static methods working.. events hooked up * No longer dependent on `exec()`.. uses Symfony\Process * More improvements * support getAt() * Make inflector available in DI * Fix for inflector * store job run states * more improvements including cron twig function * Add scheduler to event + cleanup * improvements to the CLI command * Added id field * use proper func * Added email * Fix quotes * Updated built-in composer * Better command for adding the scheduler cron via terminal * Fixed typo and added cron language * Added Cron class to make at -> human readable date possible * Added some checks when there are no jobs * Added method to get CronExpression * Revamped with Symfony 4.1 CLI updates --- bin/composer.phar | Bin 1861877 -> 1875611 bytes bin/grav | 8 +- composer.json | 4 +- composer.lock | 154 ++++-- composer_BACKUP_46658.json | 89 +++ composer_BASE_46658.json | 76 +++ composer_LOCAL_46658.json | 78 +++ composer_REMOTE_46658.json | 82 +++ system/blueprints/config/scheduler.yaml | 96 ++++ system/languages/en.yaml | 17 + system/src/Grav/Common/Grav.php | 4 + .../Common/Processors/AssetsProcessor.php | 2 +- .../Common/Processors/SchedulerProcessor.php | 24 + system/src/Grav/Common/Scheduler/Cron.php | 512 ++++++++++++++++++ .../Grav/Common/Scheduler/IntervalTrait.php | 365 +++++++++++++ system/src/Grav/Common/Scheduler/Job.php | 491 +++++++++++++++++ .../src/Grav/Common/Scheduler/Scheduler.php | 354 ++++++++++++ .../Service/InflectorServiceProvider.php | 25 + .../Service/SchedulerServiceProvider.php | 21 + system/src/Grav/Common/Twig/TwigExtension.php | 23 + .../src/Grav/Console/Cli/SchedulerCommand.php | 171 ++++++ system/src/Grav/Console/ConsoleTrait.php | 2 +- 22 files changed, 2537 insertions(+), 61 deletions(-) create mode 100644 composer_BACKUP_46658.json create mode 100644 composer_BASE_46658.json create mode 100644 composer_LOCAL_46658.json create mode 100644 composer_REMOTE_46658.json create mode 100644 system/blueprints/config/scheduler.yaml create mode 100644 system/src/Grav/Common/Processors/SchedulerProcessor.php create mode 100644 system/src/Grav/Common/Scheduler/Cron.php create mode 100644 system/src/Grav/Common/Scheduler/IntervalTrait.php create mode 100644 system/src/Grav/Common/Scheduler/Job.php create mode 100644 system/src/Grav/Common/Scheduler/Scheduler.php create mode 100644 system/src/Grav/Common/Service/InflectorServiceProvider.php create mode 100644 system/src/Grav/Common/Service/SchedulerServiceProvider.php create mode 100644 system/src/Grav/Console/Cli/SchedulerCommand.php diff --git a/bin/composer.phar b/bin/composer.phar index 041775a1274ffc0a74952eec9ceedfc062644408..96fa2df7bdbe24e62d2435a043fd8d5e6228d4a4 100755 GIT binary patch delta 61857 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 K_=GA $autoload)); if (version_compare($ver = PHP_VERSION, $req = GRAV_PHP_MIN, '<')) { exit(sprintf("You are running PHP %s, but Grav needs at least PHP %s to run.\n", $ver, $req)); @@ -41,5 +42,6 @@ $app->addCommands(array( new \Grav\Console\Cli\ClearCacheCommand(), new \Grav\Console\Cli\BackupCommand(), new \Grav\Console\Cli\NewProjectCommand(), + new \Grav\Console\Cli\SchedulerCommand(), )); $app->run(); diff --git a/composer.json b/composer.json index 099a40ab8..252224a51 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "symfony/console": "~4.1", "symfony/event-dispatcher": "~4.1", "symfony/var-dumper": "~4.1", + "symfony/process": "~4.1", "doctrine/cache": "^1.8", "doctrine/collections": "^1.5", "guzzlehttp/psr7": "^1.4", @@ -41,7 +42,8 @@ "league/climate": "^3.4", "antoligy/dom-string-iterators": "^1.0", "miljar/php-exif": "^0.6.4", - "composer/ca-bundle": "^1.0" + "composer/ca-bundle": "^1.0", + "dragonmantank/cron-expression": "^1.2" }, "require-dev": { "codeception/codeception": "^2.4", diff --git a/composer.lock b/composer.lock index ad7b42d68..09e8cbb4c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cb0cecd3938f21e0f46cb68022ab2b88", + "content-hash": "ee3b919ebe2a385d66074d22125c4317", "packages": [ { "name": "antoligy/dom-string-iterators", @@ -299,6 +299,50 @@ ], "time": "2018-06-21T15:54:46+00:00" }, + { + "name": "dragonmantank/cron-expression", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "9504fa9ea681b586028adaaa0877db4aecf32bad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/9504fa9ea681b586028adaaa0877db4aecf32bad", + "reference": "9504fa9ea681b586028adaaa0877db4aecf32bad", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.0|~5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "time": "2017-01-23T04:29:33+00:00" + }, { "name": "erusev/parsedown", "version": "1.6.4", @@ -1731,6 +1775,55 @@ ], "time": "2018-08-06T14:22:27+00:00" }, + { + "name": "symfony/process", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "86cdb930a6a855b0ab35fb60c1504cb36184f843" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/86cdb930a6a855b0ab35fb60c1504cb36184f843", + "reference": "86cdb930a6a855b0ab35fb60c1504cb36184f843", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "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 Process Component", + "homepage": "https://symfony.com", + "time": "2018-08-03T11:13:38+00:00" + }, { "name": "symfony/var-dumper", "version": "v4.1.4", @@ -3052,16 +3145,16 @@ }, { "name": "phpunit/phpunit", - "version": "7.3.2", + "version": "7.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "34705f81bddc3f505b9599a2ef96e2b4315ba9b8" + "reference": "1bd5629cccfb2c0a9ef5474b4ff772349e1ec898" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/34705f81bddc3f505b9599a2ef96e2b4315ba9b8", - "reference": "34705f81bddc3f505b9599a2ef96e2b4315ba9b8", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1bd5629cccfb2c0a9ef5474b4ff772349e1ec898", + "reference": "1bd5629cccfb2c0a9ef5474b4ff772349e1ec898", "shasum": "" }, "require": { @@ -3132,7 +3225,7 @@ "testing", "xunit" ], - "time": "2018-08-22T06:39:21+00:00" + "time": "2018-09-01T15:49:55+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -3913,55 +4006,6 @@ "homepage": "https://symfony.com", "time": "2018-07-26T11:24:31+00:00" }, - { - "name": "symfony/process", - "version": "v4.1.4", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "86cdb930a6a855b0ab35fb60c1504cb36184f843" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/86cdb930a6a855b0ab35fb60c1504cb36184f843", - "reference": "86cdb930a6a855b0ab35fb60c1504cb36184f843", - "shasum": "" - }, - "require": { - "php": "^7.1.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.1-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "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 Process Component", - "homepage": "https://symfony.com", - "time": "2018-08-03T11:13:38+00:00" - }, { "name": "theseer/tokenizer", "version": "1.1.0", diff --git a/composer_BACKUP_46658.json b/composer_BACKUP_46658.json new file mode 100644 index 000000000..e4c680d78 --- /dev/null +++ b/composer_BACKUP_46658.json @@ -0,0 +1,89 @@ +{ + "name": "getgrav/grav", + "type": "project", + "description": "Modern, Crazy Fast, Ridiculously Easy and Amazingly Powerful Flat-File CMS", + "keywords": ["cms","flat-file cms","flat cms","flatfile cms","php"], + "homepage": "http://getgrav.org", + "license": "MIT", + "require": { + "php": ">=7.1.3", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-curl": "*", + "ext-zip": "*", + "symfony/polyfill-iconv": "^1.9", + "symfony/polyfill-php72": "^1.9", + "symfony/polyfill-php73": "^1.9", + + "psr/simple-cache": "^1.0", + "psr/http-message": "^1.0", + + "twig/twig": "~1.35", + "erusev/parsedown": "1.6.4", + "erusev/parsedown-extra": "~0.7", + "symfony/yaml": "~4.1", + "symfony/console": "~4.1", + "symfony/event-dispatcher": "~4.1", + "symfony/var-dumper": "~4.1", + "doctrine/cache": "^1.7", + "doctrine/collections": "^1.5", + "guzzlehttp/psr7": "^1.4", + "filp/whoops": "~2.2", + + "matthiasmullie/minify": "^1.3", + "monolog/monolog": "~1.0", + "gregwar/image": "2.*", + "donatj/phpuseragentparser": "~0.10", + "pimple/pimple": "~3.2", + "rockettheme/toolbox": "~1.4", + "maximebf/debugbar": "~1.15", + "league/climate": "^3.4", + "antoligy/dom-string-iterators": "^1.0", +<<<<<<< HEAD + "miljar/php-exif": "^0.6.3", + "composer/ca-bundle": "^1.0", + "dragonmantank/cron-expression": "^1.2", + "symfony/process": "^3.4" +======= + "miljar/php-exif": "^0.6.4", + "composer/ca-bundle": "^1.0" +>>>>>>> 1.6 + }, + "require-dev": { + "codeception/codeception": "^2.4", + "phpunit/php-code-coverage": "~6.0", + "fzaninotto/faker": "^1.8", + "victorjonsson/markdowndocs": "dev-master" + }, + "config": { + "platform": { + "php": "7.1.3" + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator" + } + ], + "autoload": { + "psr-4": { + "Grav\\": "system/src/Grav" + }, + "files": ["system/defines.php"] + }, + "archive": { + "exclude": ["VERSION"] + }, + "scripts": { + "post-create-project-cmd": "bin/grav install", + "test": "vendor/bin/codecept run unit", + "test-windows": "vendor\\bin\\codecept run unit" + }, + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + } +} diff --git a/composer_BASE_46658.json b/composer_BASE_46658.json new file mode 100644 index 000000000..fbd5106f7 --- /dev/null +++ b/composer_BASE_46658.json @@ -0,0 +1,76 @@ +{ + "name": "getgrav/grav", + "type": "project", + "description": "Modern, Crazy Fast, Ridiculously Easy and Amazingly Powerful Flat-File CMS", + "keywords": ["cms","flat-file cms","flat cms","flatfile cms","php"], + "homepage": "http://getgrav.org", + "license": "MIT", + "require": { + "php": ">=5.6.4", + "twig/twig": "~1.24", + "erusev/parsedown": "1.6.4", + "erusev/parsedown-extra": "~0.7", + "symfony/yaml": "~3.4", + "symfony/console": "~3.4", + "symfony/event-dispatcher": "~3.4", + "symfony/var-dumper": "~3.4", + "symfony/polyfill-iconv": "~1.0", + "doctrine/cache": "^1.6", + "doctrine/collections": "^1.4", + "psr/simple-cache": "^1.0", + "psr/http-message": "^1.0", + "guzzlehttp/psr7": "^1.4", + "filp/whoops": "~2.0", + "matthiasmullie/minify": "^1.3", + "monolog/monolog": "~1.0", + "gregwar/image": "2.*", + "donatj/phpuseragentparser": "~0.3", + "pimple/pimple": "~3.2", + "rockettheme/toolbox": "~1.4", + "maximebf/debugbar": "~1.10", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-curl": "*", + "ext-zip": "*", + "league/climate": "^3.2", + "antoligy/dom-string-iterators": "^1.0", + "miljar/php-exif": "^0.6.3", + "composer/ca-bundle": "^1.0" + }, + "require-dev": { + "codeception/codeception": "^2.1", + "phpunit/php-code-coverage": "~2.0", + "fzaninotto/faker": "^1.5", + "victorjonsson/markdowndocs": "dev-master" + }, + "config": { + "platform": { + "php": "5.6.4" + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator" + } + ], + "autoload": { + "psr-4": { + "Grav\\": "system/src/Grav" + }, + "files": ["system/defines.php"] + }, + "archive": { + "exclude": ["VERSION"] + }, + "scripts": { + "post-create-project-cmd": "bin/grav install", + "test": "vendor/bin/codecept run unit", + "test-windows": "vendor\\bin\\codecept run unit" + }, + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + } +} diff --git a/composer_LOCAL_46658.json b/composer_LOCAL_46658.json new file mode 100644 index 000000000..76885b077 --- /dev/null +++ b/composer_LOCAL_46658.json @@ -0,0 +1,78 @@ +{ + "name": "getgrav/grav", + "type": "project", + "description": "Modern, Crazy Fast, Ridiculously Easy and Amazingly Powerful Flat-File CMS", + "keywords": ["cms","flat-file cms","flat cms","flatfile cms","php"], + "homepage": "http://getgrav.org", + "license": "MIT", + "require": { + "php": ">=5.6.4", + "twig/twig": "~1.24", + "erusev/parsedown": "1.6.4", + "erusev/parsedown-extra": "~0.7", + "symfony/yaml": "~3.4", + "symfony/console": "~3.4", + "symfony/event-dispatcher": "~3.4", + "symfony/var-dumper": "~3.4", + "symfony/polyfill-iconv": "~1.0", + "doctrine/cache": "^1.6", + "doctrine/collections": "^1.4", + "psr/simple-cache": "^1.0", + "psr/http-message": "^1.0", + "guzzlehttp/psr7": "^1.4", + "filp/whoops": "~2.0", + "matthiasmullie/minify": "^1.3", + "monolog/monolog": "~1.0", + "gregwar/image": "2.*", + "donatj/phpuseragentparser": "~0.3", + "pimple/pimple": "~3.2", + "rockettheme/toolbox": "~1.4", + "maximebf/debugbar": "~1.10", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-curl": "*", + "ext-zip": "*", + "league/climate": "^3.2", + "antoligy/dom-string-iterators": "^1.0", + "miljar/php-exif": "^0.6.3", + "composer/ca-bundle": "^1.0", + "dragonmantank/cron-expression": "^1.2", + "symfony/process": "^3.4" + }, + "require-dev": { + "codeception/codeception": "^2.1", + "phpunit/php-code-coverage": "~2.0", + "fzaninotto/faker": "^1.5", + "victorjonsson/markdowndocs": "dev-master" + }, + "config": { + "platform": { + "php": "5.6.4" + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator" + } + ], + "autoload": { + "psr-4": { + "Grav\\": "system/src/Grav" + }, + "files": ["system/defines.php"] + }, + "archive": { + "exclude": ["VERSION"] + }, + "scripts": { + "post-create-project-cmd": "bin/grav install", + "test": "vendor/bin/codecept run unit", + "test-windows": "vendor\\bin\\codecept run unit" + }, + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + } +} diff --git a/composer_REMOTE_46658.json b/composer_REMOTE_46658.json new file mode 100644 index 000000000..a211b6e82 --- /dev/null +++ b/composer_REMOTE_46658.json @@ -0,0 +1,82 @@ +{ + "name": "getgrav/grav", + "type": "project", + "description": "Modern, Crazy Fast, Ridiculously Easy and Amazingly Powerful Flat-File CMS", + "keywords": ["cms","flat-file cms","flat cms","flatfile cms","php"], + "homepage": "http://getgrav.org", + "license": "MIT", + "require": { + "php": ">=7.1.3", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-curl": "*", + "ext-zip": "*", + "symfony/polyfill-iconv": "^1.9", + "symfony/polyfill-php72": "^1.9", + "symfony/polyfill-php73": "^1.9", + + "psr/simple-cache": "^1.0", + "psr/http-message": "^1.0", + + "twig/twig": "~1.35", + "erusev/parsedown": "1.6.4", + "erusev/parsedown-extra": "~0.7", + "symfony/yaml": "~4.1", + "symfony/console": "~4.1", + "symfony/event-dispatcher": "~4.1", + "symfony/var-dumper": "~4.1", + "doctrine/cache": "^1.7", + "doctrine/collections": "^1.5", + "guzzlehttp/psr7": "^1.4", + "filp/whoops": "~2.2", + + "matthiasmullie/minify": "^1.3", + "monolog/monolog": "~1.0", + "gregwar/image": "2.*", + "donatj/phpuseragentparser": "~0.10", + "pimple/pimple": "~3.2", + "rockettheme/toolbox": "~1.4", + "maximebf/debugbar": "~1.15", + "league/climate": "^3.4", + "antoligy/dom-string-iterators": "^1.0", + "miljar/php-exif": "^0.6.4", + "composer/ca-bundle": "^1.0" + }, + "require-dev": { + "codeception/codeception": "^2.4", + "phpunit/php-code-coverage": "~6.0", + "fzaninotto/faker": "^1.8", + "victorjonsson/markdowndocs": "dev-master" + }, + "config": { + "platform": { + "php": "7.1.3" + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator" + } + ], + "autoload": { + "psr-4": { + "Grav\\": "system/src/Grav" + }, + "files": ["system/defines.php"] + }, + "archive": { + "exclude": ["VERSION"] + }, + "scripts": { + "post-create-project-cmd": "bin/grav install", + "test": "vendor/bin/codecept run unit", + "test-windows": "vendor\\bin\\codecept run unit" + }, + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + } +} diff --git a/system/blueprints/config/scheduler.yaml b/system/blueprints/config/scheduler.yaml new file mode 100644 index 000000000..e0949aa81 --- /dev/null +++ b/system/blueprints/config/scheduler.yaml @@ -0,0 +1,96 @@ +title: PLUGIN_ADMIN.SCHEDULER + +form: + validation: loose + fields: + tabs: + type: tabs + class: subtle + + fields: + status_tab: + type: tab + title: Status + + fields: + setup_title: + type: section + title: PLUGIN_ADMIN.SCHEDULER_SETUP + underline: true + + setup: + type: croninstall + + status_title: + type: section + title: PLUGIN_ADMIN.SCHEDULER_STATUS + underline: true + + status: + type: cronstatus + validate: + type: commalist + + custom_tab: + type: tab + title: Custom + + fields: + jobs_title: + type: section + title: PLUGIN_ADMIN.SCHEDULER_JOBS + underline: false + custom_jobs: + type: list + style: vertical + label: + classes: cron-job-list + key: id + fields: + .id: + type: key + label: ID + placeholder: 'process-name' + validate: + required: true + pattern: '[a-zа-я0-9_\-]+' + max: 20 + message: 'ID must be lowercase with dashes/underscores only and less than 20 characters' + .command: + type: text + label: Command + placeholder: 'cd ~;ls -lah;' + validate: + required: true + .args: + type: text + label: Extra Arguments + .at: + type: cron + label: Run At + help: 'Cron formatted "at" syntax' + placeholder: '* * * * *' + validate: + required: true + .output: + type: text + label: Output File + help: 'The path/filename of the output file (from the root of the Grav installation)' + placeholder: 'logs/ls-cron.out' + .output_mode: + type: select + label: Output Type + help: 'Either append to the same file each run, or overwrite the file with each run' + default: append + options: + append: Append + overwrite: Overwrite + .email: + type: text + label: Email + help: 'Email to send output to. NOTE: requires output file to be set' + placeholder: 'notifications@yoursite.com' + + + + diff --git a/system/languages/en.yaml b/system/languages/en.yaml index ed72c7b55..a9ed9471d 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -93,6 +93,23 @@ NICETIME: MO_PLURAL: mos YR_PLURAL: yrs DEC_PLURAL: decs +CRON: + EVERY: every + EVERY_HOUR: every hour + EVERY_MINUTE: every minute + EVERY_DAY_OF_WEEK: every day of the week + EVERY_DAY_OF_MONTH: every day of the month + EVERY_MONTH: every month + TEXT_PERIOD: Every + TEXT_MINS: ' at minute(s) past the hour' + TEXT_TIME: ' at :' + TEXT_DOW: ' on ' + TEXT_MONTH: ' of ' + TEXT_DOM: ' on ' + ERROR1: The tag %s is not supported! + ERROR2: Bad number of elements + ERROR3: The jquery_element should be set into jqCron settings + ERROR4: Unrecognized expression FORM: VALIDATION_FAIL: Validation failed: INVALID_INPUT: Invalid input in diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index 84260d01c..42e315615 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -40,6 +40,7 @@ class Grav extends Container 'cache' => 'Grav\Common\Cache', 'Grav\Common\Service\SessionServiceProvider', 'plugins' => 'Grav\Common\Plugins', + 'scheduler' => 'Grav\Common\Scheduler\Scheduler', 'themes' => 'Grav\Common\Themes', 'twig' => 'Grav\Common\Twig\Twig', 'taxonomy' => 'Grav\Common\Taxonomy', @@ -53,6 +54,7 @@ class Grav extends Container 'exif' => 'Grav\Common\Helpers\Exif', 'Grav\Common\Service\StreamsServiceProvider', 'Grav\Common\Service\ConfigServiceProvider', + 'Grav\Common\Service\InflectorServiceProvider', 'inflector' => 'Grav\Common\Inflector', 'siteSetupProcessor' => 'Grav\Common\Processors\SiteSetupProcessor', 'configurationProcessor' => 'Grav\Common\Processors\ConfigurationProcessor', @@ -61,6 +63,7 @@ class Grav extends Container 'initializeProcessor' => 'Grav\Common\Processors\InitializeProcessor', 'pluginsProcessor' => 'Grav\Common\Processors\PluginsProcessor', 'themesProcessor' => 'Grav\Common\Processors\ThemesProcessor', + 'schedulerProcessor' => 'Grav\Common\Processors\SchedulerProcessor', 'tasksProcessor' => 'Grav\Common\Processors\TasksProcessor', 'assetsProcessor' => 'Grav\Common\Processors\AssetsProcessor', 'twigProcessor' => 'Grav\Common\Processors\TwigProcessor', @@ -80,6 +83,7 @@ class Grav extends Container 'initializeProcessor', 'pluginsProcessor', 'themesProcessor', + 'schedulerProcessor', 'tasksProcessor', 'assetsProcessor', 'twigProcessor', diff --git a/system/src/Grav/Common/Processors/AssetsProcessor.php b/system/src/Grav/Common/Processors/AssetsProcessor.php index 6ca952d70..89997d237 100644 --- a/system/src/Grav/Common/Processors/AssetsProcessor.php +++ b/system/src/Grav/Common/Processors/AssetsProcessor.php @@ -10,7 +10,7 @@ namespace Grav\Common\Processors; class AssetsProcessor extends ProcessorBase implements ProcessorInterface { - public $id = 'assets'; + public $id = '_assets'; public $title = 'Assets'; public function process() diff --git a/system/src/Grav/Common/Processors/SchedulerProcessor.php b/system/src/Grav/Common/Processors/SchedulerProcessor.php new file mode 100644 index 000000000..2b3d62506 --- /dev/null +++ b/system/src/Grav/Common/Processors/SchedulerProcessor.php @@ -0,0 +1,24 @@ +container['scheduler']; + $scheduler->loadSavedJobs(); + $this->container->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); + } +} diff --git a/system/src/Grav/Common/Scheduler/Cron.php b/system/src/Grav/Common/Scheduler/Cron.php new file mode 100644 index 000000000..682a12af2 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Cron.php @@ -0,0 +1,512 @@ + modified for Grav integration + * @copyright Copyright (C) 2015 - 2018 Trilby Media, LLC. All rights reserved. + * @license MIT License; see LICENSE file for details. + */ + +namespace Grav\Common\Scheduler; + +/* + * Usage examples : + * ---------------- + * + * $cron = new Cron('10-30/5 12 * * *'); + * + * var_dump($cron->getMinutes()); + * // array(5) { + * // [0]=> int(10) + * // [1]=> int(15) + * // [2]=> int(20) + * // [3]=> int(25) + * // [4]=> int(30) + * // } + * + * var_dump($cron->getText('fr')); + * // string(32) "Chaque jour à 12:10,15,20,25,30" + * + * var_dump($cron->getText('en')); + * // string(30) "Every day at 12:10,15,20,25,30" + * + * var_dump($cron->getType()); + * // string(3) "day" + * + * var_dump($cron->getCronHours()); + * // string(2) "12" + * + * var_dump($cron->matchExact(new \DateTime('2012-07-01 13:25:10'))); + * // bool(false) + * + * var_dump($cron->matchExact(new \DateTime('2012-07-01 12:15:20'))); + * // bool(true) + * + * var_dump($cron->matchWithMargin(new \DateTime('2012-07-01 12:32:50'), -3, 5)); + * // bool(true) + */ +class Cron { + const TYPE_UNDEFINED = ''; + const TYPE_MINUTE = 'minute'; + const TYPE_HOUR = 'hour'; + const TYPE_DAY = 'day'; + const TYPE_WEEK = 'week'; + const TYPE_MONTH = 'month'; + const TYPE_YEAR = 'year'; + /** + * + * @var array + */ + protected $texts = array( + 'fr' => array( + 'empty' => '-tout-', + 'name_minute' => 'minute', + 'name_hour' => 'heure', + 'name_day' => 'jour', + 'name_week' => 'semaine', + 'name_month' => 'mois', + 'name_year' => 'année', + 'text_period' => 'Chaque %s', + 'text_mins' => 'à %s minutes', + 'text_time' => 'à %s:%s', + 'text_dow' => 'le %s', + 'text_month' => 'de %s', + 'text_dom' => 'le %s', + 'weekdays' => array('lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'), + 'months' => array('janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'), + ), + 'en' => array( + 'empty' => '-all-', + 'name_minute' => 'minute', + 'name_hour' => 'hour', + 'name_day' => 'day', + 'name_week' => 'week', + 'name_month' => 'month', + 'name_year' => 'year', + 'text_period' => 'Every %s', + 'text_mins' => 'at %s minutes past the hour', + 'text_time' => 'at %s:%s', + 'text_dow' => 'on %s', + 'text_month' => 'of %s', + 'text_dom' => 'on the %s', + 'weekdays' => array('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'), + 'months' => array('january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'), + ), + ); + /** + * min hour dom month dow + * @var string + */ + protected $cron = ''; + /** + * + * @var array + */ + protected $minutes = array(); + /** + * + * @var array + */ + protected $hours = array(); + /** + * + * @var array + */ + protected $months = array(); + /** + * 0-7 : sunday, monday, ... saturday, sunday + * @var array + */ + protected $dow = array(); + /** + * + * @var array + */ + protected $dom = array(); + /** + * + * @param string $cron + */ + public function __construct($cron = null) { + if (!empty($cron)) { + $this->setCron($cron); + } + } + /** + * + * @return string + */ + public function getCron() { + return implode(' ', array( + $this->getCronMinutes(), + $this->getCronHours(), + $this->getCronDaysOfMonth(), + $this->getCronMonths(), + $this->getCronDaysOfWeek(), + )); + } + /** + * + * @param string $lang 'fr' or 'en' + * @return string + */ + public function getText($lang) { + // check lang + if (!isset($this->texts[$lang])) { + return $this->getCron(); + } + $texts = $this->texts[$lang]; + // check type + $type = $this->getType(); + if ($type == self::TYPE_UNDEFINED) { + return $this->getCron(); + } + // init + $elements = array(); + $elements[] = sprintf($texts['text_period'], $texts['name_' . $type]); + // hour + if (in_array($type, array(self::TYPE_HOUR))) { + $elements[] = sprintf($texts['text_mins'], $this->getCronMinutes()); + } + // week + if (in_array($type, array(self::TYPE_WEEK))) { + $dow = $this->getCronDaysOfWeek(); + foreach ($texts['weekdays'] as $i => $wd) { + $dow = str_replace((string) ($i + 1), $wd, $dow); + } + $elements[] = sprintf($texts['text_dow'], $dow); + } + // month + year + if (in_array($type, array(self::TYPE_MONTH, self::TYPE_YEAR))) { + $elements[] = sprintf($texts['text_dom'], $this->getCronDaysOfMonth()); + } + // year + if (in_array($type, array(self::TYPE_YEAR))) { + $months = $this->getCronMonths(); + for ($i = count($texts['months']) - 1; $i >= 0; $i--) { + $months = str_replace((string) ($i + 1), $texts['months'][$i], $months); + } + $elements[] = sprintf($texts['text_month'], $months); + } + // day + week + month + year + if (in_array($type, array(self::TYPE_DAY, self::TYPE_WEEK, self::TYPE_MONTH, self::TYPE_YEAR))) { + $elements[] = sprintf($texts['text_time'], $this->getCronHours(), $this->getCronMinutes()); + } + return str_replace('*', $texts['empty'], implode(' ', $elements)); + } + /** + * + * @return string + */ + public function getType() { + $mask = preg_replace('/[^\* ]/si', '-', $this->getCron()); + $mask = preg_replace('/-+/si', '-', $mask); + $mask = preg_replace('/[^-\*]/si', '', $mask); + if ($mask == '*****') { + return self::TYPE_MINUTE; + } + elseif ($mask == '-****') { + return self::TYPE_HOUR; + } + elseif (substr($mask, -3) == '***') { + return self::TYPE_DAY; + } + elseif (substr($mask, -3) == '-**') { + return self::TYPE_MONTH; + } + elseif (substr($mask, -3) == '**-') { + return self::TYPE_WEEK; + } + elseif (substr($mask, -2) == '-*') { + return self::TYPE_YEAR; + } + return self::TYPE_UNDEFINED; + } + /** + * + * @param string $cron + * @return Cron + */ + public function setCron($cron) { + // sanitize + $cron = trim($cron); + $cron = preg_replace('/\s+/', ' ', $cron); + // explode + $elements = explode(' ', $cron); + if (count($elements) != 5) { + throw new Exception('Bad number of elements'); + } + $this->cron = $cron; + $this->setMinutes($elements[0]); + $this->setHours($elements[1]); + $this->setDaysOfMonth($elements[2]); + $this->setMonths($elements[3]); + $this->setDaysOfWeek($elements[4]); + return $this; + } + /** + * + * @return string + */ + public function getCronMinutes() { + return $this->arrayToCron($this->minutes); + } + /** + * + * @return string + */ + public function getCronHours() { + return $this->arrayToCron($this->hours); + } + /** + * + * @return string + */ + public function getCronDaysOfMonth() { + return $this->arrayToCron($this->dom); + } + /** + * + * @return string + */ + public function getCronMonths() { + return $this->arrayToCron($this->months); + } + /** + * + * @return string + */ + public function getCronDaysOfWeek() { + return $this->arrayToCron($this->dow); + } + /** + * + * @return array + */ + public function getMinutes() { + return $this->minutes; + } + /** + * + * @return array + */ + public function getHours() { + return $this->hours; + } + /** + * + * @return array + */ + public function getDaysOfMonth() { + return $this->dom; + } + /** + * + * @return array + */ + public function getMonths() { + return $this->months; + } + /** + * + * @return array + */ + public function getDaysOfWeek() { + return $this->dow; + } + /** + * + * @param string|array $minutes + * @return Cron + */ + public function setMinutes($minutes) { + $this->minutes = $this->cronToArray($minutes, 0, 59); + return $this; + } + /** + * + * @param string|array $hours + * @return Cron + */ + public function setHours($hours) { + $this->hours = $this->cronToArray($hours, 0, 23); + return $this; + } + /** + * + * @param string|array $months + * @return Cron + */ + public function setMonths($months) { + $this->months = $this->cronToArray($months, 1, 12); + return $this; + } + /** + * + * @param string|array $dow + * @return Cron + */ + public function setDaysOfWeek($dow) { + $this->dow = $this->cronToArray($dow, 0, 7); + return $this; + } + /** + * + * @param string|array $dom + * @return Cron + */ + public function setDaysOfMonth($dom) { + $this->dom = $this->cronToArray($dom, 1, 31); + return $this; + } + /** + * + * @param mixed $date + * @param int $min + * @param int $hour + * @param int $day + * @param int $month + * @param int $weekday + * @return DateTime + */ + protected function parseDate($date, &$min, &$hour, &$day, &$month, &$weekday) { + if (is_numeric($date) && intval($date) == $date) { + $date = new \DateTime('@' . $date); + } + elseif (is_string($date)) { + $date = new \DateTime('@' . strtotime($date)); + } + if ($date instanceof \DateTime) { + $min = intval($date->format('i')); + $hour = intval($date->format('H')); + $day = intval($date->format('d')); + $month = intval($date->format('m')); + $weekday = intval($date->format('w')); // 0-6 + } + else { + throw new Exception('Date format not supported'); + } + return new \DateTime($date->format('Y-m-d H:i:sP')); + } + /** + * + * @param int|string|\Datetime $date + */ + public function matchExact($date) { + $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday); + return + (empty($this->minutes) || in_array($min, $this->minutes)) && + (empty($this->hours) || in_array($hour, $this->hours)) && + (empty($this->dom) || in_array($day, $this->dom)) && + (empty($this->months) || in_array($month, $this->months)) && + (empty($this->dow) || in_array($weekday, $this->dow) || ($weekday == 0 && in_array(7, $this->dow)) || ($weekday == 7 && in_array(0, $this->dow)) + ); + } + /** + * + * @param int|string|\Datetime $date + * @param int $minuteBefore + * @param int $minuteAfter + */ + public function matchWithMargin($date, $minuteBefore = 0, $minuteAfter = 0) { + if ($minuteBefore > 0) { + throw new Exception('MinuteBefore parameter cannot be positive !'); + } + if ($minuteAfter < 0) { + throw new Exception('MinuteAfter parameter cannot be negative !'); + } + $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday); + $interval = new \DateInterval('PT1M'); // 1 min + if ($minuteBefore != 0) { + $date->sub(new \DateInterval('PT' . abs($minuteBefore) . 'M')); + } + $n = $minuteAfter - $minuteBefore + 1; + for ($i = 0; $i < $n; $i++) { + if ($this->matchExact($date)) { + return true; + } + $date->add($interval); + } + return false; + } + /** + * + * @param array $array + * @return string + */ + protected function arrayToCron($array) { + $n = count($array); + if (!is_array($array) || $n == 0) { + return '*'; + } + $cron = array($array[0]); + $s = $c = $array[0]; + for ($i = 1; $i < $n; $i++) { + if ($array[$i] == $c + 1) { + $c = $array[$i]; + $cron[count($cron) - 1] = $s . '-' . $c; + } + else { + $s = $c = $array[$i]; + $cron[] = $c; + } + } + return implode(',', $cron); + } + /** + * + * @param string $string + * @param int $min + * @param int $max + * @return array + */ + protected function cronToArray($string, $min, $max) { + $array = array(); + if (is_array($string)) { + foreach ($string as $val) { + if (is_numeric($val) && intval($val) == $val && $val >= $min && $val <= $max) { + $array[] = intval($val); + } + } + } + else if ($string !== '*') { + while ($string != '') { + // test "*/n" expression + if (preg_match('/^\*\/([0-9]+),?/', $string, $m)) { + for ($i = max(0, $min); $i <= min(59, $max); $i+=$m[1]) { + $array[] = intval($i); + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "a-b/n" expression + if (preg_match('/^([0-9]+)-([0-9]+)\/([0-9]+),?/', $string, $m)) { + for ($i = max($m[1], $min); $i <= min($m[2], $max); $i+=$m[3]) { + $array[] = intval($i); + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "a-b" expression + if (preg_match('/^([0-9]+)-([0-9]+),?/', $string, $m)) { + for ($i = max($m[1], $min); $i <= min($m[2], $max); $i++) { + $array[] = intval($i); + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "c" expression + if (preg_match('/^([0-9]+),?/', $string, $m)) { + if ($m[1] >= $min && $m[1] <= $max) { + $array[] = intval($m[1]); + } + $string = substr($string, strlen($m[0])); + continue; + } + // something goes wrong in the expression + return array(); + } + } + sort($array); + return $array; + } +} diff --git a/system/src/Grav/Common/Scheduler/IntervalTrait.php b/system/src/Grav/Common/Scheduler/IntervalTrait.php new file mode 100644 index 000000000..9df03699c --- /dev/null +++ b/system/src/Grav/Common/Scheduler/IntervalTrait.php @@ -0,0 +1,365 @@ +at = $expression; + $this->executionTime = CronExpression::factory($expression); + return $this; + } + /** + * Set the execution time to every minute. + * + * @return self + */ + public function everyMinute() + { + return $this->at('* * * * *'); + } + /** + * Set the execution time to every hour. + * + * @param int|string $minute + * @return self + */ + public function hourly($minute = 0) + { + $c = $this->validateCronSequence($minute); + return $this->at("{$c['minute']} * * * *"); + } + /** + * Set the execution time to once a day. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function daily($hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = isset($parts[1]) ? $parts[1] : '0'; + } + $c = $this->validateCronSequence($minute, $hour); + return $this->at("{$c['minute']} {$c['hour']} * * *"); + } + /** + * Set the execution time to once a week. + * + * @param int|string $weekday + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function weekly($weekday = 0, $hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = isset($parts[1]) ? $parts[1] : '0'; + } + $c = $this->validateCronSequence($minute, $hour, null, null, $weekday); + return $this->at("{$c['minute']} {$c['hour']} * * {$c['weekday']}"); + } + /** + * Set the execution time to once a month. + * + * @param int|string $month + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = isset($parts[1]) ? $parts[1] : '0'; + } + $c = $this->validateCronSequence($minute, $hour, $day, $month); + return $this->at("{$c['minute']} {$c['hour']} {$c['day']} {$c['month']} *"); + } + /** + * Set the execution time to every Sunday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function sunday($hour = 0, $minute = 0) + { + return $this->weekly(0, $hour, $minute); + } + /** + * Set the execution time to every Monday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function monday($hour = 0, $minute = 0) + { + return $this->weekly(1, $hour, $minute); + } + /** + * Set the execution time to every Tuesday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function tuesday($hour = 0, $minute = 0) + { + return $this->weekly(2, $hour, $minute); + } + /** + * Set the execution time to every Wednesday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function wednesday($hour = 0, $minute = 0) + { + return $this->weekly(3, $hour, $minute); + } + /** + * Set the execution time to every Thursday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function thursday($hour = 0, $minute = 0) + { + return $this->weekly(4, $hour, $minute); + } + /** + * Set the execution time to every Friday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function friday($hour = 0, $minute = 0) + { + return $this->weekly(5, $hour, $minute); + } + /** + * Set the execution time to every Saturday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function saturday($hour = 0, $minute = 0) + { + return $this->weekly(6, $hour, $minute); + } + /** + * Set the execution time to every January. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function january($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(1, $day, $hour, $minute); + } + /** + * Set the execution time to every February. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function february($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(2, $day, $hour, $minute); + } + /** + * Set the execution time to every March. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function march($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(3, $day, $hour, $minute); + } + /** + * Set the execution time to every April. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function april($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(4, $day, $hour, $minute); + } + /** + * Set the execution time to every May. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function may($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(5, $day, $hour, $minute); + } + /** + * Set the execution time to every June. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function june($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(6, $day, $hour, $minute); + } + /** + * Set the execution time to every July. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function july($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(7, $day, $hour, $minute); + } + /** + * Set the execution time to every August. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function august($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(8, $day, $hour, $minute); + } + /** + * Set the execution time to every September. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function september($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(9, $day, $hour, $minute); + } + /** + * Set the execution time to every October. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function october($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(10, $day, $hour, $minute); + } + /** + * Set the execution time to every November. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function november($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(11, $day, $hour, $minute); + } + /** + * Set the execution time to every December. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function december($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(12, $day, $hour, $minute); + } + /** + * Validate sequence of cron expression. + * + * @param int|string $minute + * @param int|string $hour + * @param int|string $day + * @param int|string $month + * @param int|string $weekday + * @return array + */ + private function validateCronSequence($minute = null, $hour = null, $day = null, $month = null, $weekday = null) + { + return [ + 'minute' => $this->validateCronRange($minute, 0, 59), + 'hour' => $this->validateCronRange($hour, 0, 23), + 'day' => $this->validateCronRange($day, 1, 31), + 'month' => $this->validateCronRange($month, 1, 12), + 'weekday' => $this->validateCronRange($weekday, 0, 6), + ]; + } + /** + * Validate sequence of cron expression. + * + * @param int|string $value + * @param int $min + * @param int $max + * @return mixed + */ + private function validateCronRange($value, $min, $max) + { + if ($value === null || $value === '*') { + return '*'; + } + if (! is_numeric($value) || + ! ($value >= $min && $value <= $max) + ) { + throw new InvalidArgumentException( + "Invalid value: it should be '*' or between {$min} and {$max}." + ); + } + return $value; + } +} + diff --git a/system/src/Grav/Common/Scheduler/Job.php b/system/src/Grav/Common/Scheduler/Job.php new file mode 100644 index 000000000..995e69609 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Job.php @@ -0,0 +1,491 @@ +id = Grav::instance()['inflector']->hyphenize($id); + } else { + if (is_string($command)) { + $this->id = md5($command); + } else { + /* @var object $command */ + $this->id = spl_object_hash($command); + } + } + $this->creationTime = new \DateTime('now'); + // initialize the directory path for lock files + $this->tempDir = sys_get_temp_dir(); + $this->command = $command; + $this->args = $args; + // Set enabled state + $status = Grav::instance()['config']->get('scheduler.status'); + $this->enabled = isset($status[$id]) && $status[$id] === 'disabled' ? false : true; + } + + /** + * Get the command + * + * @return string + */ + public function getCommand() + { + return $this->command; + } + + /** + * Get the cron 'at' syntax for this job + * + * @return string + */ + public function getAt() + { + return $this->at; + } + + /** + * Get the status of this job + * + * @return bool + */ + public function getEnabled() + { + return $this->enabled; + } + + /** + * Get optional arguments + * + * @return array|string|void + */ + public function getArguments() + { + if (is_string($this->args)) { + return $this->args; + } + return; + } + + public function getCronExpression() + { + return CronExpression::factory($this->at); + } + + /** + * Get the status of the last run for this job + * + * @return bool + */ + public function isSuccessful() + { + return $this->successful; + } + + /** + * Get the Job id. + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Check if the Job is due to run. + * It accepts as input a DateTime used to check if + * the job is due. Defaults to job creation time. + * It also default the execution time if not previously defined. + * + * @param DateTime $date + * @return bool + */ + public function isDue(\DateTime $date = null) + { + // The execution time is being defaulted if not defined + if (!$this->executionTime) { + $this->at('* * * * *'); + } + $date = $date !== null ? $date : $this->creationTime; + return $this->executionTime->isDue($date); + } + + /** + * Check if the Job is overlapping. + * + * @return bool + */ + public function isOverlapping() + { + return $this->lockFile && + file_exists($this->lockFile) && + call_user_func($this->whenOverlapping, filemtime($this->lockFile)) === false; + } + + /** + * Force the Job to run in foreground. + * + * @return self + */ + public function inForeground() + { + $this->runInBackground = false; + return $this; + } + + /** + * Check if the Job can run in background. + * + * @return bool + */ + public function runInBackground() + { + if (is_callable($this->command) || $this->runInBackground === false) { + return false; + } + return true; + } + + /** + * This will prevent the Job from overlapping. + * It prevents another instance of the same Job of + * being executed if the previous is still running. + * The job id is used as a filename for the lock file. + * + * @param string $tempDir The directory path for the lock files + * @param callable $whenOverlapping A callback to ignore job overlapping + * @return self + */ + public function onlyOne($tempDir = null, callable $whenOverlapping = null) + { + if ($tempDir === null || !is_dir($tempDir)) { + $tempDir = $this->tempDir; + } + $this->lockFile = implode('/', [ + trim($tempDir), + trim($this->id) . '.lock', + ]); + if ($whenOverlapping) { + $this->whenOverlapping = $whenOverlapping; + } else { + $this->whenOverlapping = function () { + return false; + }; + } + return $this; + } + + /** + * Configure the job. + * + * @param array $config + * @return self + */ + public function configure(array $config = []) + { + // Check if config has defined a tempDir + if (isset($config['tempDir']) && is_dir($config['tempDir'])) { + $this->tempDir = $config['tempDir']; + } + return $this; + } + + /** + * Truth test to define if the job should run if due. + * + * @param callable $fn + * @return self + */ + public function when(callable $fn) + { + $this->truthTest = $fn(); + return $this; + } + + /** + * Run the job. + * + * @return bool + */ + public function run() + { + // If the truthTest failed, don't run + if ($this->truthTest !== true) { + return false; + } + // If overlapping, don't run + if ($this->isOverlapping()) { + return false; + } + // Write lock file if necessary + $this->createLockFile(); + + // Call before if required + if (is_callable($this->before)) { + call_user_func($this->before); + } + // If command is callable... + if (is_callable($this->command)) { + $this->output = $this->exec(); + } else { + /** @var Process process */ + $args = is_string($this->args) ? $this->args : implode(' ', $this->args); + $command = $this->command . ' ' . $args; + $process = new Process($command); + + $this->process = $process; + + if ($this->runInBackground()) { + $process->start(); + } else { + $process->run(); + $this->finalize(); + } + } + return true; + } + + /** + * Finish up processing the job + * + * @return void + */ + public function finalize() + { + /** @var Process $process */ + $process = $this->process; + + if ($process) { + $process->wait(); + + if ($process->isSuccessful()) { + $this->successful = true; + $this->output = $process->getOutput(); + } else { + $this->successful = false; + $this->output = $process->getErrorOutput(); + } + + $this->postRun(); + + unset($this->process); + } + } + + /** + * Things to run after job has run + */ + private function postRun() + { + if (count($this->outputTo) > 0) { + foreach ($this->outputTo as $file) { + $output_mode = $this->outputMode === 'append' ? FILE_APPEND | LOCK_EX : LOCK_EX; + file_put_contents($file, $this->output, $output_mode); + } + } + + // Send output to email + $this->emailOutput(); + + // Call any callback defined + if (is_callable($this->after)) { + call_user_func($this->after, $this->output, $this->returnCode); + } + + $this->removeLockFile(); + } + + /** + * Create the job lock file. + * + * @param mixed $content + * @return void + */ + private function createLockFile($content = null) + { + if ($this->lockFile) { + if ($content === null || !is_string($content)) { + $content = $this->getId(); + } + file_put_contents($this->lockFile, $content); + } + } + + /** + * Remove the job lock file. + * + * @return void + */ + private function removeLockFile() + { + if ($this->lockFile && file_exists($this->lockFile)) { + unlink($this->lockFile); + } + } + + /** + * Execute a callable job. + * + * @throws Exception + * @return string + */ + private function exec() + { + $return_data = ''; + ob_start(); + try { + $return_data = call_user_func_array($this->command, $this->args); + $this->successful = true; + } catch (Exception $e) { + $this->successful = false; + } + $this->output = ob_get_clean() . (is_string($return_data) ? $return_data : ''); + + $this->postRun(); + } + + /** + * Set the file/s where to write the output of the job. + * + * @param string|array $filename + * @param bool $append + * @return self + */ + public function output($filename, $append = false) + { + $this->outputTo = is_array($filename) ? $filename : [$filename]; + $this->outputMode = $append === false ? 'overwrite' : 'append'; + return $this; + } + + /** + * Get the job output. + * + * @return mixed + */ + public function getOutput() + { + return $this->output; + } + + /** + * Set the emails where the output should be sent to. + * The Job should be set to write output to a file + * for this to work. + * + * @param string|array $email + * @return self + */ + public function email($email) + { + if (!is_string($email) && !is_array($email)) { + throw new InvalidArgumentException('The email can be only string or array'); + } + $this->emailTo = is_array($email) ? $email : [$email]; + // Force the job to run in foreground + $this->inForeground(); + return $this; + } + + /** + * Email the output of the job, if any. + * + * @return bool + */ + private function emailOutput() + { + if (!count($this->outputTo) || !count($this->emailTo)) { + return false; + } + + if (is_callable('Grav\Plugin\Email\Utils::sendEmail')) { + $subject ='Grav Scheduled Job [' . $this->getId() . ']'; + $content = "

Output from Job ID: {$this->getId()}

\n

Command: {$this->getCommand()}


\n".$this->getOutput()."\n
"; + $to = $this->emailTo; + + \Grav\Plugin\Email\Utils::sendEmail($subject, $content, $to); + } + return true; + } + + /** + * Set function to be called before job execution + * Job object is injected as a parameter to callable function. + * + * @param callable $fn + * @return self + */ + public function before(callable $fn) + { + $this->before = $fn; + return $this; + } + + /** + * Set a function to be called after job execution. + * By default this will force the job to run in foreground + * because the output is injected as a parameter of this + * function, but it could be avoided by passing true as a + * second parameter. The job will run in background if it + * meets all the other criteria. + * + * @param callable $fn + * @param bool $runInBackground + * @return self + */ + public function then(callable $fn, $runInBackground = false) + { + $this->after = $fn; + // Force the job to run in foreground + if ($runInBackground === false) { + $this->inForeground(); + } + return $this; + } +} + diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php new file mode 100644 index 000000000..04b450021 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -0,0 +1,354 @@ +get('scheduler.defaults', []); + $this->config = $config; + $this->loadSavedJobs(); + + $this->status_path = Grav::instance()['locator']->findResource('user://data/scheduler', true, true); + if (!file_exists($this->status_path)) { + Folder::create($this->status_path); + } + + } + + /** + * Load saved jobs from config/scheduler.yaml file + */ + public function loadSavedJobs() + { + if (!$this->jobs) { + $saved_jobs = (array) Grav::instance()['config']->get('scheduler.custom_jobs', []); + + foreach ($saved_jobs as $id => $j) { + $args = isset($j['args']) ? $j['args'] : []; + $id = Grav::instance()['inflector']->hyphenize($id); + $job = $this->addCommand($j['command'], $args, $id); + + if (isset($j['at'])) { + $job->at($j['at']); + } + + if (isset($j['output'])) { + $mode = isset($j['output_mode']) && $j['output_mode'] === 'append' ? true : false; + $job->output($j['output'], $mode); + } + + if (isset($j['email'])) { + $job->email($j['email']); + } + + // store in saved_jobs + $this->saved_jobs[] = $job; + } + } + } + + /** + * Get the queued jobs as background/foreground + * + * @param bool $all + * @return array + */ + public function getQueuedJobs($all = false) + { + $background = []; + $foreground = []; + foreach ($this->jobs as $job) { + if ($all || $job->getEnabled()) { + if ($job->runInBackground()) { + $background[] = $job; + } else { + $foreground[] = $job; + } + } + + } + return [$background, $foreground]; + } + + /** + * Get all jobs if they are disabled or not as one array + * + * @param bool $all + * @return array + */ + public function getAllJobs() + { + list($background, $foreground) = $this->getQueuedJobs(true); + return array_merge($background, $foreground); + } + + /** + * Queues a PHP function execution. + * + * @param callable $fn The function to execute + * @param array $args Optional arguments to pass to the php script + * @param string $id Optional custom identifier + * @return Job + */ + public function addFunction(callable $fn, $args = [], $id = null) + { + $job = new Job($fn, $args, $id); + $this->queueJob($job->configure($this->config)); + return $job; + } + + /** + * Queue a raw shell command. + * + * @param string $command The command to execute + * @param array $args Optional arguments to pass to the command + * @param string $id Optional custom identifier + * @return Job + */ + public function addCommand($command, $args = [], $id = null) + { + $job = new Job($command, $args, $id); + $this->queueJob($job->configure($this->config)); + return $job; + } + + /** + * Run the scheduler. + * + * @param \DateTime $runTime Optional, run at specific moment + * @return array Executed jobs + */ + public function run(\Datetime $runTime = null) + { + list($background, $foreground) = $this->getQueuedJobs(false); + $alljobs = array_merge($background, $foreground); + + if (is_null($runTime)) { + $runTime = new \DateTime('now'); + } + + // Star processing jobs + foreach ($alljobs as $job) { + if ($job->isDue($runTime)) { + $job->run(); + $this->jobs_run[] = $job; + } + } + + // Finish handling any background jobs + foreach($background as $job) { + $job->finalize(); + } + + // Store states + $this->saveJobStates(); + } + + /** + * Reset all collected data of last run. + * + * Call before run() if you call run() multiple times. + */ + public function resetRun() + { + // Reset collected data of last run + $this->executedJobs = []; + $this->failedJobs = []; + $this->outputSchedule = []; + return $this; + } + + /** + * Get the scheduler verbose output. + * + * @param string $type Allowed: text, html, array + * @return mixed The return depends on the requested $type + */ + public function getVerboseOutput($type = 'text') + { + switch ($type) { + case 'text': + return implode("\n", $this->outputSchedule); + case 'html': + return implode('
', $this->outputSchedule); + case 'array': + return $this->outputSchedule; + default: + throw new \InvalidArgumentException('Invalid output type'); + } + } + + /** + * Remove all queued Jobs. + */ + public function clearJobs() + { + $this->jobs = []; + return $this; + } + + /** + * Helper to get the full Cron command + * + * @return string + */ + public function getCronCommand() + { + $phpBinaryFinder = new PhpExecutableFinder(); + $php = $phpBinaryFinder->find(); + $command = 'cd ' . GRAV_ROOT . ';' . $php . ' bin/grav scheduler'; + + return "(crontab -l; echo \"* * * * * {$command} 1>> /dev/null 2>&1\") | crontab -"; + } + + /** + * Helper to determine if cron job is setup + * + * @return int + */ + public function isCrontabSetup() + { + $process = new Process('crontab -l'); + $process->run(); + + if ($process->isSuccessful()) { + $output = $process->getOutput(); + + if (preg_match('$bin\/grav schedule$', $output)) { + return 1; + } else { + return 0; + } + } else { + return 2; + } + } + + /** + * Get the Job states file + * + * @return \RocketTheme\Toolbox\File\FileInterface|YamlFile + */ + public function getJobStates() + { + $file = YamlFile::instance($this->status_path . '/status.yaml'); + return $file; + } + + /** + * Save job states to statys file + */ + private function saveJobStates() + { + $now = time(); + $new_states = []; + + foreach ($this->jobs_run as $job) { + if ($job->isSuccessful()) { + $new_states[$job->getId()] = ['state' => 'success', 'last-run' => $now]; + $this->pushExecutedJob($job); + } else { + $new_states[$job->getId()] = ['state' => 'failure', 'last-run' => $now, 'error' => $job->getOutput()]; + $this->pushFailedJob($job); + } + } + $saved_states = $this->getJobStates(); + $saved_states->save(array_merge($saved_states->content(), $new_states)); + } + + /** + * Queue a job for execution in the correct queue. + * + * @param Job $job + * @return void + */ + private function queueJob(Job $job) + { + $this->jobs[] = $job; + + // Store jobs + } + + /** + * Add an entry to the scheduler verbose output array. + * + * @param string $string + * @return void + */ + private function addSchedulerVerboseOutput($string) + { + $now = '[' . (new \DateTime('now'))->format('c') . '] '; + $this->outputSchedule[] = $now . $string; + // Print to stdoutput in light gray + // echo "\033[37m{$string}\033[0m\n"; + } + + /** + * Push a succesfully executed job. + * + * @param Job $job + * @return Job + */ + private function pushExecutedJob(Job $job) + { + $this->executedJobs[] = $job; + $command = $job->getCommand(); + $args = $job->getArguments(); + // If callable, log the string Closure + if (is_callable($command)) { + $command = is_string($command) ? $command : 'Closure'; + } + $this->addSchedulerVerboseOutput("Success: {$command} {$args}"); + return $job; + } + + /** + * Push a failed job. + * + * @param Job $job + * @return Job + */ + private function pushFailedJob(Job $job) + { + $this->failedJobs[] = $job; + $command = $job->getCommand(); + // If callable, log the string Closure + if (is_callable($command)) { + $command = is_string($command) ? $command : 'Closure'; + } + $output = trim($job->getOutput()); + $this->addSchedulerVerboseOutput("Error: {$command}{$output}"); + return $job; + } +} diff --git a/system/src/Grav/Common/Service/InflectorServiceProvider.php b/system/src/Grav/Common/Service/InflectorServiceProvider.php new file mode 100644 index 000000000..043646ddc --- /dev/null +++ b/system/src/Grav/Common/Service/InflectorServiceProvider.php @@ -0,0 +1,25 @@ + true]), new \Twig_SimpleFilter('array', [$this, 'arrayFilter']), + new \Twig_SimpleFilter('nicecron', [$this, 'niceCronFilter']), ]; } @@ -157,6 +160,8 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn new \Twig_SimpleFunction('nicenumber', [$this, 'niceNumberFunc']), new \Twig_SimpleFunction('nicefilesize', [$this, 'niceFilesizeFunc']), new \Twig_SimpleFunction('nicetime', [$this, 'nicetimeFilter']), + new \Twig_SimpleFunction('cron', [$this, 'cronFunc']), + // Translations new \Twig_simpleFunction('t', [$this, 'translate']), @@ -437,6 +442,24 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn return (strpos($haystack, $needle) !== false); } + public function niceCronFilter($at) + { + $cron = new Cron($at); + return $cron->getText('en'); + } + + /** + * Get Cron object for a crontab 'at' format + * + * @param $at + * @return CronExpression + */ + public function cronFunc($at) + { + $cron = CronExpression::factory($at); + return $cron; + } + /** * displays a facebook style 'time ago' formatted date/time * diff --git a/system/src/Grav/Console/Cli/SchedulerCommand.php b/system/src/Grav/Console/Cli/SchedulerCommand.php new file mode 100644 index 000000000..fdb7701a8 --- /dev/null +++ b/system/src/Grav/Console/Cli/SchedulerCommand.php @@ -0,0 +1,171 @@ +setName('scheduler') + ->addOption( + 'install', + 'i', + InputOption::VALUE_NONE, + 'Show Install Command' + ) + ->addOption( + 'jobs', + 'j', + InputOption::VALUE_NONE, + 'Show Jobs Summary' + ) + ->addOption( + 'details', + 'd', + InputOption::VALUE_NONE, + 'Show Job Details' + ) + ->setDescription('Run the Grav Scheduler. Best when integrated with system cron') + ->setHelp("Running without any options will force the Scheduler to run through it's jobs and process them"); + } + + /** + * @return int|null|void + */ + protected function serve() + { +// error_reporting(1); + $grav = Grav::instance(); + + $grav['uri']->init(); + $grav['config']->init(); + $grav['streams']; + $grav['plugins']->init(); + $grav['themes']->init(); + + // Initialize Plugins + $grav->fireEvent('onPluginsInitialized'); + + /** @var Scheduler $scheduler */ + $scheduler = $grav['scheduler']; + $grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); + + $this->setHelp('foo'); + + /** @var use new SymfonyStyle helper $io */ + $io = new SymfonyStyle($this->input, $this->output); + + if ($this->input->getOption('jobs')) { + // Show jobs list + + $jobs = $scheduler->getAllJobs(); + $job_states = $scheduler->getJobStates()->content(); + $rows = []; + + $table = new Table($this->output); + $table->setStyle('box'); + $headers = ['Job ID', 'Command', 'Run At', 'Status', 'Last Run', 'State']; + + $io->title('Scheduler Jobs Listing'); + + foreach ($jobs as $job) { + $job_status = ucfirst($job_states[$job->getId()]['state'] ?? 'ready'); + $last_run = $job_states[$job->getId()]['last-run'] ?? 0; + $status = $job_status === 'Failure' ? "{$job_status}" : "{$job_status}"; + $state = $job->getEnabled() ? "Enabled" : "Disabled"; + $row = [ + $job->getId(), + "{$job->getCommand()}", + "{$job->getAt()}", + "{$status}", + "" . ($last_run === 0 ? 'Never' : date('Y-m-d H:i', $last_run)) . "", + $state, + + ]; + $rows[] = $row; + } + + if (!empty($rows)) { + $table->setHeaders($headers); + $table->setRows($rows); + $table->render(); + } else { + $io->text('no jobs found...'); + } + + $io->newLine(); + $io->note('For error details run "bin/grav scheduler -d"'); + $io->newLine(); + } elseif ($this->input->getOption('details')) { + $jobs = $scheduler->getAllJobs(); + $job_states = $scheduler->getJobStates()->content(); + + $io->title('Job Details'); + + $table = new Table($this->output); + $table->setStyle('box'); + $table->setHeaders(['Job ID', 'Last Run', 'Next Run', 'Errors']); + $rows = []; + + foreach ($jobs as $job) { + $job_state = $job_states[$job->getId()]; + $error = isset($job_state['error']) ? trim($job_state['error']) : false; + + /** @var CronExpression $expression */ + $expression = $job->getCronExpression(); + $next_run = $expression->getNextRunDate(); + + $row = []; + $row[] = $job->getId(); + $row[] = "" . date('Y-m-d H:i', $job_state['last-run']) . ""; + $row[] = "" . $next_run->format('Y-m-d H:i') . ""; + + if ($error) { + $row[] = "{$error}"; + } else { + $row[] = "None"; + } + $rows[] = $row; + } + + $table->setRows($rows); + $table->render(); + + } elseif ($this->input->getOption('install')) { + $io->title('Install Scheduler'); + + if ($scheduler->isCrontabSetup()) { + $io->success('All Ready! You have already set up Grav\'s Scheduler in your crontab'); + } else { + $io->error('You still need to set up Grav\'s Scheduler in your crontab'); + } + $io->note('To install, run the following command from your terminal:'); + $io->newLine(); + $io->text(trim($scheduler->getCronCommand())); + } else { + // Run scheduler + $scheduler->run(); + + if ($this->input->getOption('verbose')) { + $io->title('Running Scheduled Jobs'); + $io->text($scheduler->getVerboseOutput()); + } + } + } +} diff --git a/system/src/Grav/Console/ConsoleTrait.php b/system/src/Grav/Console/ConsoleTrait.php index 449817245..6c3a3c997 100644 --- a/system/src/Grav/Console/ConsoleTrait.php +++ b/system/src/Grav/Console/ConsoleTrait.php @@ -20,7 +20,7 @@ use Symfony\Component\Console\Output\OutputInterface; trait ConsoleTrait { - use GravTrait; +// use GravTrait; /** * @var