From f22e32a26bde89e3f3bf3ac109da1eadf6c8904c Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 08:40:32 +0300 Subject: import cron-expression --- composer.json | 3 +- composer.lock | 183 +++++-- vendor/composer/autoload_classmap.php | 10 + vendor/composer/autoload_psr4.php | 1 + vendor/composer/autoload_static.php | 18 + vendor/composer/installed.json | 71 ++- vendor/composer/installed.php | 21 +- vendor/dragonmantank/cron-expression/CHANGELOG.md | 240 +++++++++ vendor/dragonmantank/cron-expression/LICENSE | 19 + vendor/dragonmantank/cron-expression/README.md | 131 +++++ vendor/dragonmantank/cron-expression/composer.json | 51 ++ .../cron-expression/src/Cron/AbstractField.php | 346 ++++++++++++ .../cron-expression/src/Cron/CronExpression.php | 591 +++++++++++++++++++++ .../cron-expression/src/Cron/DayOfMonthField.php | 164 ++++++ .../cron-expression/src/Cron/DayOfWeekField.php | 194 +++++++ .../cron-expression/src/Cron/FieldFactory.php | 52 ++ .../src/Cron/FieldFactoryInterface.php | 8 + .../cron-expression/src/Cron/FieldInterface.php | 46 ++ .../cron-expression/src/Cron/HoursField.php | 212 ++++++++ .../cron-expression/src/Cron/MinutesField.php | 97 ++++ .../cron-expression/src/Cron/MonthField.php | 61 +++ 21 files changed, 2454 insertions(+), 65 deletions(-) create mode 100644 vendor/dragonmantank/cron-expression/CHANGELOG.md create mode 100644 vendor/dragonmantank/cron-expression/LICENSE create mode 100644 vendor/dragonmantank/cron-expression/README.md create mode 100644 vendor/dragonmantank/cron-expression/composer.json create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/FieldFactoryInterface.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/FieldInterface.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/HoursField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php create mode 100644 vendor/dragonmantank/cron-expression/src/Cron/MonthField.php diff --git a/composer.json b/composer.json index e9031ad82..018b53220 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "mervick/material-design-icons": "^2.2", "j4mie/idiorm": "dev-master", "soundasleep/html2text": "^2.1", - "guzzlehttp/guzzle": "^7.0" + "guzzlehttp/guzzle": "^7.0", + "dragonmantank/cron-expression": "^3.4" }, "require-dev": { "phpstan/phpstan": "2.1.13", diff --git a/composer.lock b/composer.lock index b78a7a17a..65f4e4e43 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": "ad2f3020f2db46475c16f9710a84bca4", + "content-hash": "6275e0932b09f1cf029085d4db0d80b5", "packages": [ { "name": "chillerlan/php-qrcode", @@ -165,6 +165,71 @@ ], "time": "2024-07-16T11:13:48+00:00" }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "8c784d071debd117328803d86b2097615b457500" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2024-10-09T13:47:03+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.9.2", @@ -1120,6 +1185,64 @@ } ], "time": "2024-09-25T14:20:29+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "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.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" } ], "packages-dev": [ @@ -3138,64 +3261,6 @@ } ], "time": "2021-07-28T10:34:58+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.11.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "php": "^7.2 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "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.11.0" - }, - "time": "2022-06-03T18:03:27+00:00" } ], "aliases": [], diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php index 39b16fb04..762a8d99e 100644 --- a/vendor/composer/autoload_classmap.php +++ b/vendor/composer/autoload_classmap.php @@ -14,6 +14,16 @@ return array( 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 'Config' => $baseDir . '/classes/Config.php', 'Counters' => $baseDir . '/classes/Counters.php', + 'Cron\\AbstractField' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/AbstractField.php', + 'Cron\\CronExpression' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/CronExpression.php', + 'Cron\\DayOfMonthField' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php', + 'Cron\\DayOfWeekField' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php', + 'Cron\\FieldFactory' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/FieldFactory.php', + 'Cron\\FieldFactoryInterface' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/FieldFactoryInterface.php', + 'Cron\\FieldInterface' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/FieldInterface.php', + 'Cron\\HoursField' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/HoursField.php', + 'Cron\\MinutesField' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/MinutesField.php', + 'Cron\\MonthField' => $vendorDir . '/dragonmantank/cron-expression/src/Cron/MonthField.php', 'Crypt' => $baseDir . '/classes/Crypt.php', 'Db' => $baseDir . '/classes/Db.php', 'Db_Migrations' => $baseDir . '/classes/Db_Migrations.php', diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php index 9c7d39932..da01ffb49 100644 --- a/vendor/composer/autoload_psr4.php +++ b/vendor/composer/autoload_psr4.php @@ -23,5 +23,6 @@ return array( 'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'), 'Doctrine\\Instantiator\\' => array($vendorDir . '/doctrine/instantiator/src/Doctrine/Instantiator'), 'DeepCopy\\' => array($vendorDir . '/myclabs/deep-copy/src/DeepCopy'), + 'Cron\\' => array($vendorDir . '/dragonmantank/cron-expression/src/Cron'), '' => array($baseDir . '/classes'), ); diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index f7e6408aa..3fcb34b42 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -58,6 +58,10 @@ class ComposerStaticInit19fc2ff1c0f9a92279c7979386bb2056 'Doctrine\\Instantiator\\' => 22, 'DeepCopy\\' => 9, ), + 'C' => + array ( + 'Cron\\' => 5, + ), ); public static $prefixDirsPsr4 = array ( @@ -132,6 +136,10 @@ class ComposerStaticInit19fc2ff1c0f9a92279c7979386bb2056 array ( 0 => __DIR__ . '/..' . '/myclabs/deep-copy/src/DeepCopy', ), + 'Cron\\' => + array ( + 0 => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron', + ), ); public static $fallbackDirsPsr4 = array ( @@ -147,6 +155,16 @@ class ComposerStaticInit19fc2ff1c0f9a92279c7979386bb2056 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 'Config' => __DIR__ . '/../..' . '/classes/Config.php', 'Counters' => __DIR__ . '/../..' . '/classes/Counters.php', + 'Cron\\AbstractField' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/AbstractField.php', + 'Cron\\CronExpression' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/CronExpression.php', + 'Cron\\DayOfMonthField' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php', + 'Cron\\DayOfWeekField' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php', + 'Cron\\FieldFactory' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/FieldFactory.php', + 'Cron\\FieldFactoryInterface' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/FieldFactoryInterface.php', + 'Cron\\FieldInterface' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/FieldInterface.php', + 'Cron\\HoursField' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/HoursField.php', + 'Cron\\MinutesField' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/MinutesField.php', + 'Cron\\MonthField' => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron/MonthField.php', 'Crypt' => __DIR__ . '/../..' . '/classes/Crypt.php', 'Db' => __DIR__ . '/../..' . '/classes/Db.php', 'Db_Migrations' => __DIR__ . '/../..' . '/classes/Db_Migrations.php', diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index 0fdf8b38e..0002f2176 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -238,6 +238,74 @@ ], "install-path": "../doctrine/instantiator" }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.4.0", + "version_normalized": "3.4.0.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "8c784d071debd117328803d86b2097615b457500" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "time": "2024-10-09T13:47:03+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "install-path": "../dragonmantank/cron-expression" + }, { "name": "guzzlehttp/guzzle", "version": "7.9.2", @@ -3374,7 +3442,6 @@ "sebastian/resource-operations", "sebastian/type", "sebastian/version", - "theseer/tokenizer", - "webmozart/assert" + "theseer/tokenizer" ] } diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index edcc369dd..c1441fad4 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => '__root__', 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => '0961c8bd4c8a4f61f9458c08bf0e91b5cc2aa5d0', + 'reference' => '0520ca2226f095046eaf57aa8125e88f69ccf376', 'type' => 'library', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -13,7 +13,7 @@ '__root__' => array( 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => '0961c8bd4c8a4f61f9458c08bf0e91b5cc2aa5d0', + 'reference' => '0520ca2226f095046eaf57aa8125e88f69ccf376', 'type' => 'library', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -46,6 +46,15 @@ 'aliases' => array(), 'dev_requirement' => true, ), + 'dragonmantank/cron-expression' => array( + 'pretty_version' => 'v3.4.0', + 'version' => '3.4.0.0', + 'reference' => '8c784d071debd117328803d86b2097615b457500', + 'type' => 'library', + 'install_path' => __DIR__ . '/../dragonmantank/cron-expression', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'guzzlehttp/guzzle' => array( 'pretty_version' => '7.9.2', 'version' => '7.9.2.0', @@ -93,6 +102,12 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'mtdowling/cron-expression' => array( + 'dev_requirement' => false, + 'replaced' => array( + 0 => '^1.0', + ), + ), 'myclabs/deep-copy' => array( 'pretty_version' => '1.11.0', 'version' => '1.11.0.0', @@ -487,7 +502,7 @@ 'type' => 'library', 'install_path' => __DIR__ . '/../webmozart/assert', 'aliases' => array(), - 'dev_requirement' => true, + 'dev_requirement' => false, ), ), ); diff --git a/vendor/dragonmantank/cron-expression/CHANGELOG.md b/vendor/dragonmantank/cron-expression/CHANGELOG.md new file mode 100644 index 000000000..17ab2ce44 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/CHANGELOG.md @@ -0,0 +1,240 @@ +# Change Log + +## [3.3.3] - 2024-08-10 + +### Added +- N/A + +### Changed +- N/A + +### Fixed +- Added fixes for making sure `?` is not passed for both DOM and DOW (#148, thank you https://github.com/LeoVie) +- Fixed bug in Next Execution Time by sorting minutes properly (#160, thank you https://github.com/imyip) + +## [3.3.2] - 2022-09-19 + +### Added +- N/A + +### Changed +- Skip some daylight savings time tests for PHP 8.1 daylight savings time weirdness (#146) + +### Fixed +- Changed string interpolations to work better with PHP 8.2 (#142) + +## [3.3.1] - 2022-01-18 + +### Added +- N/A + +### Changed +- N/A + +### Fixed +- Fixed issue when timezones had no transition, which can occur over very short timespans (#134) + +## [3.3.0] - 2022-01-13 + +### Added +- Added ability to register your own expression aliases (#132) + +### Changed +- Changed how Day of Week and Day of Month resolve when one or the other is `*` or `?` + +### Fixed +- PHPStan should no longer error out + +## [3.2.4] - 2022-01-12 + +### Added +- N/A + +### Changed +- Changed how Day of Week increment/decrement to help with DST changes (#131) + +### Fixed +- N/A + +## [3.2.3] - 2022-01-05 + +### Added +- N/A + +### Changed +- Changed how minutes and hours increment/decrement to help with DST changes (#131) + +### Fixed +- N/A + +## [3.2.2] - 2022-01-05 + +### Added +- N/A + +### Changed +- Marked some methods `@internal` (#124) + +### Fixed +- Fixed issue with small ranges and large steps that caused an error with `range()` (#88) +- Fixed issue where wraparound logic incorrectly considered high bound on range (#89) + +## [3.2.1] - 2022-01-04 + +### Added +- N/A + +### Changed +- Added PHP 8.1 to testing (#125) + +### Fixed +- Allow better mixture of ranges, steps, and lists (#122) +- Fixed return order when multiple dates are requested and inverted (#121) +- Better handling over DST (#115) +- Fixed PHPStan tests (#130) + +## [3.2.0] - 2022-01-04 + +### Added +- Added alias for `@midnight` (#117) + +### Changed +- Improved testing for instance of field in tests (#105) +- Optimization for determining multiple run dates (#75) +- `CronExpression` properties changed from private to protected (#106) + +### Fixed +- N/A + +## [3.1.0] - 2020-11-24 + +### Added +- Added `CronExpression::getParts()` method to get parts of the expression as an array (#83) + +### Changed +- Changed to Interfaces for some type hints (#97, #86) +- Dropped minimum PHP version to 7.2 +- Few syntax changes for phpstan compatibility (#93) + +### Fixed +- N/A + +### Deprecated +- Deprecated `CronExpression::factory` in favor of the constructor (#56) +- Deprecated `CronExpression::YEAR` as a formality, the functionality is already removed (#87) + +## [3.0.1] - 2020-10-12 +### Added +- Added support for PHP 8 (#92) +### Changed +- N/A +### Fixed +- N/A + +## [3.0.0] - 2020-03-25 + +**MAJOR CHANGE** - In previous versions of this library, setting both a "Day of Month" and a "Day of Week" would be interpreted as an `AND` statement, not an `OR` statement. For example: + +`30 0 1 * 1` + +would evaluate to "Run 30 minutes after the 0 hour when the Day Of Month is 1 AND a Monday" instead of "Run 30 minutes after the 0 hour on Day Of Month 1 OR a Monday", where the latter is more inline with most cron systems. This means that if your cron expression has both of these fields set, you may see your expression fire more often starting with v3.0.0. + +### Added +- Additional docblocks for IDE and documentation +- Added phpstan as a development dependency +- Added a `Cron\FieldFactoryInterface` to make migrations easier (#38) +### Changed +- Changed some DI testing during TravisCI runs +- `\Cron\CronExpression::determineTimezone()` now checks for `\DateTimeInterface` instead of just `\DateTime` +- Errors with fields now report a more human-understandable error and are 1-based instead of 0-based +- Better support for `\DateTimeImmutable` across the library by typehinting for `\DateTimeInterface` now +- Literals should now be less case-sensative across the board +- Changed logic for when both a Day of Week and a Day of Month are supplied to now be an OR statement, not an AND +### Fixed +- Fixed infinite loop when determining last day of week from literals +- Fixed bug where single number ranges were allowed (ex: `1/10`) +- Fixed nullable FieldFactory in CronExpression where no factory could be supplied +- Fixed issue where logic for dropping seconds to 0 could lead to a timezone change + +## [2.3.1] - 2020-10-12 +### Added +- Added support for PHP 8 (#92) +### Changed +- N/A +### Fixed +- N/A + +## [2.3.0] - 2019-03-30 +### Added +- Added support for DateTimeImmutable via DateTimeInterface +- Added support for PHP 7.3 +- Started listing projects that use the library +### Changed +- Errors should now report a human readable position in the cron expression, instead of starting at 0 +### Fixed +- N/A + +## [2.2.0] - 2018-06-05 +### Added +- Added support for steps larger than field ranges (#6) +## Changed +- N/A +### Fixed +- Fixed validation for numbers with leading 0s (#12) + +## [2.1.0] - 2018-04-06 +### Added +- N/A +### Changed +- Upgraded to PHPUnit 6 (#2) +### Fixed +- Refactored timezones to deal with some inconsistent behavior (#3) +- Allow ranges and lists in same expression (#5) +- Fixed regression where literals were not converted to their numerical counterpart (#) + +## [2.0.0] - 2017-10-12 +### Added +- N/A + +### Changed +- Dropped support for PHP 5.x +- Dropped support for the YEAR field, as it was not part of the cron standard + +### Fixed +- Reworked validation for all the field types +- Stepping should now work for 1-indexed fields like Month (#153) + +## [1.2.0] - 2017-01-22 +### Added +- Added IDE, CodeSniffer, and StyleCI.IO support + +### Changed +- Switched to PSR-4 Autoloading + +### Fixed +- 0 step expressions are handled better +- Fixed `DayOfMonth` validation to be more strict +- Typos + +## [1.1.0] - 2016-01-26 +### Added +- Support for non-hourly offset timezones +- Checks for valid expressions + +### Changed +- Max Iterations no longer hardcoded for `getRunDate()` +- Supports DateTimeImmutable for newer PHP verions + +### Fixed +- Fixed looping bug for PHP 7 when determining the last specified weekday of a month + +## [1.0.3] - 2013-11-23 +### Added +- Now supports expressions with any number of extra spaces, tabs, or newlines + +### Changed +- Using static instead of self in `CronExpression::factory` + +### Fixed +- Fixes issue [#28](https://github.com/mtdowling/cron-expression/issues/28) where PHP increments of ranges were failing due to PHP casting hyphens to 0 +- Only set default timezone if the given $currentTime is not a DateTime instance ([#34](https://github.com/mtdowling/cron-expression/issues/34)) diff --git a/vendor/dragonmantank/cron-expression/LICENSE b/vendor/dragonmantank/cron-expression/LICENSE new file mode 100644 index 000000000..3e38bbc88 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 Michael Dowling , 2016 Chris Tankersley , 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/vendor/dragonmantank/cron-expression/README.md b/vendor/dragonmantank/cron-expression/README.md new file mode 100644 index 000000000..b9df3db5f --- /dev/null +++ b/vendor/dragonmantank/cron-expression/README.md @@ -0,0 +1,131 @@ +PHP Cron Expression Parser +========================== + +[![Latest Stable Version](https://poser.pugx.org/dragonmantank/cron-expression/v/stable.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Total Downloads](https://poser.pugx.org/dragonmantank/cron-expression/downloads.png)](https://packagist.org/packages/dragonmantank/cron-expression) [![Tests](https://github.com/dragonmantank/cron-expression/actions/workflows/tests.yml/badge.svg)](https://github.com/dragonmantank/cron-expression/actions/workflows/tests.yml) [![StyleCI](https://github.styleci.io/repos/103715337/shield?branch=master)](https://github.styleci.io/repos/103715337) + +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. + +More information about this fork can be found in the blog post [here](http://ctankersley.com/2017/10/12/cron-expression-update/). tl;dr - v2.0.0 is a major breaking change, and @dragonmantank can better take care of the project in a separate fork. + +Installing +========== + +Add the dependency to your project: + +```bash +composer require dragonmantank/cron-expression +``` + +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 = new Cron\CronExpression('3-59/15 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 = new Cron\CronExpression('@daily'); +echo $cron->getNextRunDate(null, 2)->format('Y-m-d H:i:s'); + +// Calculate a run date relative to a specific time +$cron = new Cron\CronExpression('@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: + +``` +* * * * * +- - - - - +| | | | | +| | | | | +| | | | +----- day of week (0-7) (Sunday = 0 or 7) (or SUN-SAT) +| | | +--------- month (1-12) (or JAN-DEC) +| | +------------- day of month (1-31) +| +----------------- hour (0-23) ++--------------------- minute (0-59) +``` + +Each part of expression can also use wildcard, lists, ranges and steps: + +- wildcard - match always + - `* * * * *` - At every minute. + - day of week and day of month also support `?`, an alias to `*` +- lists - match list of values, ranges and steps + - e.g. `15,30 * * * *` - At minute 15 and 30. +- ranges - match values in range + - e.g. `1-9 * * * *` - At every minute from 1 through 9. +- steps - match every nth value in range + - e.g. `*/5 * * * *` - At every 5th minute. + - e.g. `0-30/5 * * * *` - At every 5th minute from 0 through 30. +- combinations + - e.g. `0-14,30-44 * * * *` - At every minute from 0 through 14 and every minute from 30 through 44. + +You can also use macro instead of an expression: + +- `@yearly`, `@annually` - At 00:00 on 1st of January. (same as `0 0 1 1 *`) +- `@monthly` - At 00:00 on day-of-month 1. (same as `0 0 1 * *`) +- `@weekly` - At 00:00 on Sunday. (same as `0 0 * * 0`) +- `@daily`, `@midnight` - At 00:00. (same as `0 0 * * *`) +- `@hourly` - At minute 0. (same as `0 * * * *`) + +Day of month extra features: + +- nearest weekday - weekday (Monday-Friday) nearest to the given day + - e.g. `* * 15W * *` - At every minute on a weekday nearest to the 15th. + - If you were to specify `15W` as the value, the meaning is: "the nearest weekday to the 15th of the month" + So if the 15th is a Saturday, the trigger will fire on Friday the 14th. + If the 15th is a Sunday, the trigger will fire on Monday the 16th. + If the 15th is a Tuesday, then it will fire on Tuesday the 15th. + - However, if you specify `1W` as the value for day-of-month, + and the 1st is a Saturday, the trigger will fire on Monday the 3rd, + as it will not 'jump' over the boundary of a month's days. +- last day of the month + - e.g. `* * L * *` - At every minute on a last day-of-month. +- last weekday of the month + - e.g. `* * LW * *` - At every minute on a last weekday. + +Day of week extra features: + +- nth day + - e.g. `* * * * 7#4` - At every minute on 4th Sunday. + - 1-5 + - Every day of week repeats 4-5 times a month. To target the last one, use "last day" feature instead. +- last day + - e.g. `* * * * 7L` - At every minute on the last Sunday. + +Requirements +============ + +- PHP 7.2+ +- PHPUnit is required to run the unit tests +- Composer is required to run the unit tests + +Projects that Use cron-expression +================================= +* Part of the [Laravel Framework](https://github.com/laravel/framework/) +* Available as a [Symfony Bundle - setono/cron-expression-bundle](https://github.com/Setono/CronExpressionBundle) +* Framework agnostic, PHP-based job scheduler - [Crunz](https://github.com/crunzphp/crunz) +* Framework agnostic job scheduler - with locks, parallelism, per-second scheduling and more - [orisai/scheduler](https://github.com/orisai/scheduler) +* Explain expression in English (and other languages) with [orisai/cron-expression-explainer](https://github.com/orisai/cron-expression-explainer) diff --git a/vendor/dragonmantank/cron-expression/composer.json b/vendor/dragonmantank/cron-expression/composer.json new file mode 100644 index 000000000..fdb46ee41 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/composer.json @@ -0,0 +1,51 @@ +{ + "name": "dragonmantank/cron-expression", + "type": "library", + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": ["cron", "schedule"], + "license": "MIT", + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0", + "phpstan/extension-installer": "^1.0" + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "autoload-dev": { + "psr-4": { + "Cron\\Tests\\": "tests/Cron/" + } + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "scripts": { + "phpstan": "./vendor/bin/phpstan analyze", + "test": "phpunit" + }, + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "config": { + "allow-plugins": { + "ocramius/package-versions": true, + "phpstan/extension-installer": true + } + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php b/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php new file mode 100644 index 000000000..df2848df4 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/AbstractField.php @@ -0,0 +1,346 @@ +fullRange = range($this->rangeStart, $this->rangeEnd); + } + + /** + * Check to see if a field is satisfied by a value. + * + * @internal + * @param int $dateValue Date value to check + * @param string $value Value to test + * + * @return bool + */ + public function isSatisfied(int $dateValue, string $value): bool + { + if ($this->isIncrementsOfRanges($value)) { + return $this->isInIncrementsOfRanges($dateValue, $value); + } + + if ($this->isRange($value)) { + return $this->isInRange($dateValue, $value); + } + + return '*' === $value || $dateValue === (int) $value; + } + + /** + * Check if a value is a range. + * + * @internal + * @param string $value Value to test + * + * @return bool + */ + public function isRange(string $value): bool + { + return false !== strpos($value, '-'); + } + + /** + * Check if a value is an increments of ranges. + * + * @internal + * @param string $value Value to test + * + * @return bool + */ + public function isIncrementsOfRanges(string $value): bool + { + return false !== strpos($value, '/'); + } + + /** + * Test if a value is within a range. + * + * @internal + * @param int $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInRange(int $dateValue, $value): bool + { + $parts = array_map( + function ($value) { + $value = trim($value); + + return $this->convertLiterals($value); + }, + explode('-', $value, 2) + ); + + return $dateValue >= $parts[0] && $dateValue <= $parts[1]; + } + + /** + * Test if a value is within an increments of ranges (offset[-to]/step size). + * + * @internal + * @param int $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInIncrementsOfRanges(int $dateValue, string $value): bool + { + $chunks = array_map('trim', explode('/', $value, 2)); + $range = $chunks[0]; + $step = $chunks[1] ?? 0; + + // No step or 0 steps aren't cool + /** @phpstan-ignore-next-line */ + if (null === $step || '0' === $step || 0 === $step) { + return false; + } + + // Expand the * to a full range + if ('*' === $range) { + $range = $this->rangeStart . '-' . $this->rangeEnd; + } + + // Generate the requested small range + $rangeChunks = explode('-', $range, 2); + $rangeStart = (int) $rangeChunks[0]; + $rangeEnd = $rangeChunks[1] ?? $rangeStart; + $rangeEnd = (int) $rangeEnd; + + if ($rangeStart < $this->rangeStart || $rangeStart > $this->rangeEnd || $rangeStart > $rangeEnd) { + throw new \OutOfRangeException('Invalid range start requested'); + } + + if ($rangeEnd < $this->rangeStart || $rangeEnd > $this->rangeEnd || $rangeEnd < $rangeStart) { + throw new \OutOfRangeException('Invalid range end requested'); + } + + // Steps larger than the range need to wrap around and be handled + // slightly differently than smaller steps + + // UPDATE - This is actually false. The C implementation will allow a + // larger step as valid syntax, it never wraps around. It will stop + // once it hits the end. Unfortunately this means in future versions + // we will not wrap around. However, because the logic exists today + // per the above documentation, fixing the bug from #89 + if ($step > $this->rangeEnd) { + $thisRange = [$this->fullRange[$step % \count($this->fullRange)]]; + } else { + if ($step > ($rangeEnd - $rangeStart)) { + $thisRange[$rangeStart] = (int) $rangeStart; + } else { + $thisRange = range($rangeStart, $rangeEnd, (int) $step); + } + } + + return \in_array($dateValue, $thisRange, true); + } + + /** + * Returns a range of values for the given cron expression. + * + * @param string $expression The expression to evaluate + * @param int $max Maximum offset for range + * + * @return array + */ + public function getRangeForExpression(string $expression, int $max): array + { + $values = []; + $expression = $this->convertLiterals($expression); + + if (false !== strpos($expression, ',')) { + $ranges = explode(',', $expression); + $values = []; + foreach ($ranges as $range) { + $expanded = $this->getRangeForExpression($range, $this->rangeEnd); + $values = array_merge($values, $expanded); + } + + return $values; + } + + if ($this->isRange($expression) || $this->isIncrementsOfRanges($expression)) { + if (!$this->isIncrementsOfRanges($expression)) { + [$offset, $to] = explode('-', $expression); + $offset = $this->convertLiterals($offset); + $to = $this->convertLiterals($to); + $stepSize = 1; + } else { + $range = array_map('trim', explode('/', $expression, 2)); + $stepSize = $range[1] ?? 0; + $range = $range[0]; + $range = explode('-', $range, 2); + $offset = $range[0]; + $to = $range[1] ?? $max; + } + $offset = '*' === $offset ? $this->rangeStart : $offset; + if ($stepSize >= $this->rangeEnd) { + $values = [$this->fullRange[$stepSize % \count($this->fullRange)]]; + } else { + for ($i = $offset; $i <= $to; $i += $stepSize) { + $values[] = (int) $i; + } + } + sort($values); + } else { + $values = [$expression]; + } + + return $values; + } + + /** + * Convert literal. + * + * @param string $value + * + * @return string + */ + protected function convertLiterals(string $value): string + { + if (\count($this->literals)) { + $key = array_search(strtoupper($value), $this->literals, true); + if (false !== $key) { + return (string) $key; + } + } + + return $value; + } + + /** + * Checks to see if a value is valid for the field. + * + * @param string $value + * + * @return bool + */ + public function validate(string $value): bool + { + $value = $this->convertLiterals($value); + + // All fields allow * as a valid value + if ('*' === $value) { + return true; + } + + // Validate each chunk of a list individually + if (false !== strpos($value, ',')) { + foreach (explode(',', $value) as $listItem) { + if (!$this->validate($listItem)) { + return false; + } + } + + return true; + } + + if (false !== strpos($value, '/')) { + [$range, $step] = explode('/', $value); + + // Don't allow numeric ranges + if (is_numeric($range)) { + return false; + } + + return $this->validate($range) && filter_var($step, FILTER_VALIDATE_INT); + } + + if (false !== strpos($value, '-')) { + if (substr_count($value, '-') > 1) { + return false; + } + + $chunks = explode('-', $value); + $chunks[0] = $this->convertLiterals($chunks[0]); + $chunks[1] = $this->convertLiterals($chunks[1]); + + if ('*' === $chunks[0] || '*' === $chunks[1]) { + return false; + } + + return $this->validate($chunks[0]) && $this->validate($chunks[1]); + } + + if (!is_numeric($value)) { + return false; + } + + if (false !== strpos($value, '.')) { + return false; + } + + // We should have a numeric by now, so coerce this into an integer + $value = (int) $value; + + return \in_array($value, $this->fullRange, true); + } + + protected function timezoneSafeModify(DateTimeInterface $dt, string $modification): DateTimeInterface + { + $timezone = $dt->getTimezone(); + $dt = $dt->setTimezone(new \DateTimeZone("UTC")); + $dt = $dt->modify($modification); + $dt = $dt->setTimezone($timezone); + return $dt; + } + + protected function setTimeHour(DateTimeInterface $date, bool $invert, int $originalTimestamp): DateTimeInterface + { + $date = $date->setTime((int)$date->format('H'), ($invert ? 59 : 0)); + + // setTime caused the offset to change, moving time in the wrong direction + $actualTimestamp = $date->format('U'); + if ((! $invert) && ($actualTimestamp <= $originalTimestamp)) { + $date = $this->timezoneSafeModify($date, "+1 hour"); + } elseif ($invert && ($actualTimestamp >= $originalTimestamp)) { + $date = $this->timezoneSafeModify($date, "-1 hour"); + } + + return $date; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php b/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php new file mode 100644 index 000000000..f3d8eb003 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/CronExpression.php @@ -0,0 +1,591 @@ + '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@midnight' => '0 0 * * *', + '@hourly' => '0 * * * *', + ]; + + /** + * @var array CRON expression parts + */ + protected $cronParts; + + /** + * @var FieldFactoryInterface CRON field factory + */ + protected $fieldFactory; + + /** + * @var int Max iteration count when searching for next run date + */ + protected $maxIterationCount = 1000; + + /** + * @var array Order in which to test of cron parts + */ + protected static $order = [ + self::YEAR, + self::MONTH, + self::DAY, + self::WEEKDAY, + self::HOUR, + self::MINUTE, + ]; + + /** + * @var array + */ + private static $registeredAliases = self::MAPPINGS; + + /** + * Registered a user defined CRON Expression Alias. + * + * @throws LogicException If the expression or the alias name are invalid + * or if the alias is already registered. + */ + public static function registerAlias(string $alias, string $expression): void + { + try { + new self($expression); + } catch (InvalidArgumentException $exception) { + throw new LogicException("The expression `$expression` is invalid", 0, $exception); + } + + $shortcut = strtolower($alias); + if (1 !== preg_match('/^@\w+$/', $shortcut)) { + throw new LogicException("The alias `$alias` is invalid. It must start with an `@` character and contain alphanumeric (letters, numbers, regardless of case) plus underscore (_)."); + } + + if (isset(self::$registeredAliases[$shortcut])) { + throw new LogicException("The alias `$alias` is already registered."); + } + + self::$registeredAliases[$shortcut] = $expression; + } + + /** + * Unregistered a user defined CRON Expression Alias. + * + * @throws LogicException If the user tries to unregister a built-in alias + */ + public static function unregisterAlias(string $alias): bool + { + $shortcut = strtolower($alias); + if (isset(self::MAPPINGS[$shortcut])) { + throw new LogicException("The alias `$alias` is a built-in alias; it can not be unregistered."); + } + + if (!isset(self::$registeredAliases[$shortcut])) { + return false; + } + + unset(self::$registeredAliases[$shortcut]); + + return true; + } + + /** + * Tells whether a CRON Expression alias is registered. + */ + public static function supportsAlias(string $alias): bool + { + return isset(self::$registeredAliases[strtolower($alias)]); + } + + /** + * Returns all registered aliases as an associated array where the aliases are the key + * and their associated expressions are the values. + * + * @return array + */ + public static function getAliases(): array + { + return self::$registeredAliases; + } + + /** + * @deprecated since version 3.0.2, use __construct instead. + */ + public static function factory(string $expression, ?FieldFactoryInterface $fieldFactory = null): CronExpression + { + /** @phpstan-ignore-next-line */ + return new static($expression, $fieldFactory); + } + + /** + * Validate a CronExpression. + * + * @param string $expression the CRON expression to validate + * + * @return bool True if a valid CRON expression was passed. False if not. + */ + public static function isValidExpression(string $expression): bool + { + try { + new CronExpression($expression); + } catch (InvalidArgumentException $e) { + return false; + } + + return true; + } + + /** + * Parse a CRON expression. + * + * @param string $expression CRON expression (e.g. '8 * * * *') + * @param null|FieldFactoryInterface $fieldFactory Factory to create cron fields + * @throws InvalidArgumentException + */ + public function __construct(string $expression, ?FieldFactoryInterface $fieldFactory = null) + { + $shortcut = strtolower($expression); + $expression = self::$registeredAliases[$shortcut] ?? $expression; + + $this->fieldFactory = $fieldFactory ?: new FieldFactory(); + $this->setExpression($expression); + } + + /** + * Set or change the CRON expression. + * + * @param string $value CRON expression (e.g. 8 * * * *) + * + * @throws \InvalidArgumentException if not a valid CRON expression + * + * @return CronExpression + */ + public function setExpression(string $value): CronExpression + { + $split = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); + + if (!\is_array($split)) { + throw new InvalidArgumentException( + $value . ' is not a valid CRON expression' + ); + } + + $notEnoughParts = \count($split) < 5; + + $questionMarkInInvalidPart = array_key_exists(0, $split) && $split[0] === '?' + || array_key_exists(1, $split) && $split[1] === '?' + || array_key_exists(3, $split) && $split[3] === '?'; + + $tooManyQuestionMarks = array_key_exists(2, $split) && $split[2] === '?' + && array_key_exists(4, $split) && $split[4] === '?'; + + if ($notEnoughParts || $questionMarkInInvalidPart || $tooManyQuestionMarks) { + throw new InvalidArgumentException( + $value . ' is not a valid CRON expression' + ); + } + + $this->cronParts = $split; + 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 + * + * @throws \InvalidArgumentException if the value is not valid for the part + * + * @return CronExpression + */ + public function setPart(int $position, string $value): CronExpression + { + if (!$this->fieldFactory->getField($position)->validate($value)) { + throw new InvalidArgumentException( + 'Invalid CRON field value ' . $value . ' at position ' . $position + ); + } + + $this->cronParts[$position] = $value; + + return $this; + } + + /** + * Set max iteration count for searching next run dates. + * + * @param int $maxIterationCount Max iteration count when searching for next run date + * + * @return CronExpression + */ + public function setMaxIterationCount(int $maxIterationCount): CronExpression + { + $this->maxIterationCount = $maxIterationCount; + + return $this; + } + + /** + * Get a next run date relative to the current date or a specific date + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth 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 Set to TRUE to return the current date if + * it matches the cron expression. + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @throws \RuntimeException on too many iterations + * @throws \Exception + * + * @return \DateTime + */ + public function getNextRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime + { + return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone); + } + + /** + * Get a previous run date relative to the current date or a specific date. + * + * @param string|\DateTimeInterface $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @throws \RuntimeException on too many iterations + * @throws \Exception + * + * @return \DateTime + * + * @see \Cron\CronExpression::getNextRunDate + */ + public function getPreviousRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime + { + return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate, $timeZone); + } + + /** + * 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|\DateTimeInterface|null $currentTime Relative calculation date + * @param bool $invert Set to TRUE to retrieve previous dates + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return \DateTime[] Returns an array of run dates + */ + public function getMultipleRunDates(int $total, $currentTime = 'now', bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): array + { + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + + if ('now' === $currentTime) { + $currentTime = new DateTime(); + } elseif ($currentTime instanceof DateTime) { + $currentTime = clone $currentTime; + } elseif ($currentTime instanceof DateTimeImmutable) { + $currentTime = DateTime::createFromFormat('U', $currentTime->format('U')); + } elseif (\is_string($currentTime)) { + $currentTime = new DateTime($currentTime); + } + + if (!$currentTime instanceof DateTime) { + throw new InvalidArgumentException('invalid current time'); + } + + $currentTime->setTimezone(new DateTimeZone($timeZone)); + + $matches = []; + for ($i = 0; $i < $total; ++$i) { + try { + $result = $this->getRunDate($currentTime, 0, $invert, $allowCurrentDate, $timeZone); + } catch (RuntimeException $e) { + break; + } + + $allowCurrentDate = false; + $currentTime = clone $result; + $matches[] = $result; + } + + return $matches; + } + + /** + * Get all or part of the CRON expression. + * + * @param int|string|null $part specify the part to retrieve or NULL to get the full + * cron schedule string + * + * @return null|string 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): ?string + { + if (null === $part) { + return implode(' ', $this->cronParts); + } + + if (array_key_exists($part, $this->cronParts)) { + return $this->cronParts[$part]; + } + + return null; + } + + /** + * Gets the parts of the cron expression as an array. + * + * @return string[] + * The array of parts that make up this expression. + */ + public function getParts() + { + return $this->cronParts; + } + + /** + * Helper method to output the full expression. + * + * @return string Full CRON expression + */ + public function __toString(): string + { + return (string) $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|\DateTimeInterface $currentTime Relative calculation date + * @param null|string $timeZone TimeZone to use instead of the system default + * + * @return bool Returns TRUE if the cron is due to run or FALSE if not + */ + public function isDue($currentTime = 'now', $timeZone = null): bool + { + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + + if ('now' === $currentTime) { + $currentTime = new DateTime(); + } elseif ($currentTime instanceof DateTime) { + $currentTime = clone $currentTime; + } elseif ($currentTime instanceof DateTimeImmutable) { + $currentTime = DateTime::createFromFormat('U', $currentTime->format('U')); + } elseif (\is_string($currentTime)) { + $currentTime = new DateTime($currentTime); + } + + if (!$currentTime instanceof DateTime) { + throw new InvalidArgumentException('invalid current time'); + } + + $currentTime->setTimezone(new DateTimeZone($timeZone)); + + // drop the seconds to 0 + $currentTime->setTime((int) $currentTime->format('H'), (int) $currentTime->format('i'), 0); + + try { + return $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp(); + } catch (Exception $e) { + return false; + } + } + + /** + * Get the next or previous run date of the expression relative to a date. + * + * @param string|\DateTimeInterface|null $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $invert Set to TRUE to go backwards in time + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * @param string|null $timeZone TimeZone to use instead of the system default + * + * @throws \RuntimeException on too many iterations + * @throws Exception + * + * @return \DateTime + */ + protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): DateTime + { + $timeZone = $this->determineTimeZone($currentTime, $timeZone); + + if ($currentTime instanceof DateTime) { + $currentDate = clone $currentTime; + } elseif ($currentTime instanceof DateTimeImmutable) { + $currentDate = DateTime::createFromFormat('U', $currentTime->format('U')); + } elseif (\is_string($currentTime)) { + $currentDate = new DateTime($currentTime); + } else { + $currentDate = new DateTime('now'); + } + + if (!$currentDate instanceof DateTime) { + throw new InvalidArgumentException('invalid current date'); + } + + $currentDate->setTimezone(new DateTimeZone($timeZone)); + // Workaround for setTime causing an offset change: https://bugs.php.net/bug.php?id=81074 + $currentDate = DateTime::createFromFormat("!Y-m-d H:iO", $currentDate->format("Y-m-d H:iP"), $currentDate->getTimezone()); + if ($currentDate === false) { + throw new \RuntimeException('Unable to create date from format'); + } + $currentDate->setTimezone(new DateTimeZone($timeZone)); + + $nextRun = clone $currentDate; + + // We don't have to satisfy * or null fields + $parts = []; + $fields = []; + foreach (self::$order as $position) { + $part = $this->getExpression($position); + if (null === $part || '*' === $part) { + continue; + } + $parts[$position] = $part; + $fields[$position] = $this->fieldFactory->getField($position); + } + + if (isset($parts[self::DAY]) && isset($parts[self::WEEKDAY])) { + $domExpression = sprintf('%s %s %s %s *', $this->getExpression(0), $this->getExpression(1), $this->getExpression(2), $this->getExpression(3)); + $dowExpression = sprintf('%s %s * %s %s', $this->getExpression(0), $this->getExpression(1), $this->getExpression(3), $this->getExpression(4)); + + $domExpression = new self($domExpression); + $dowExpression = new self($dowExpression); + + $domRunDates = $domExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone); + $dowRunDates = $dowExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone); + + if ($parts[self::DAY] === '?' || $parts[self::DAY] === '*') { + $domRunDates = []; + } + + if ($parts[self::WEEKDAY] === '?' || $parts[self::WEEKDAY] === '*') { + $dowRunDates = []; + } + + $combined = array_merge($domRunDates, $dowRunDates); + usort($combined, function ($a, $b) { + return $a->format('Y-m-d H:i:s') <=> $b->format('Y-m-d H:i:s'); + }); + if ($invert) { + $combined = array_reverse($combined); + } + + return $combined[$nth]; + } + + // Set a hard limit to bail on an impossible date + for ($i = 0; $i < $this->maxIterationCount; ++$i) { + foreach ($parts as $position => $part) { + $satisfied = false; + // Get the field object used to validate this part + $field = $fields[$position]; + // Check if this is singular or a list + if (false === strpos($part, ',')) { + $satisfied = $field->isSatisfiedBy($nextRun, $part, $invert); + } else { + foreach (array_map('trim', explode(',', $part)) as $listPart) { + if ($field->isSatisfiedBy($nextRun, $listPart, $invert)) { + $satisfied = true; + + break; + } + } + } + + // If the field is not satisfied, then start over + if (!$satisfied) { + $field->increment($nextRun, $invert, $part); + + continue 2; + } + } + + // Skip this match if needed + if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { + $this->fieldFactory->getField(self::MINUTE)->increment($nextRun, $invert, $parts[self::MINUTE] ?? null); + continue; + } + + return $nextRun; + } + + // @codeCoverageIgnoreStart + throw new RuntimeException('Impossible CRON expression'); + // @codeCoverageIgnoreEnd + } + + /** + * Workout what timeZone should be used. + * + * @param string|\DateTimeInterface|null $currentTime Relative calculation date + * @param string|null $timeZone TimeZone to use instead of the system default + * + * @return string + */ + protected function determineTimeZone($currentTime, ?string $timeZone): string + { + if (null !== $timeZone) { + return $timeZone; + } + + if ($currentTime instanceof DateTimeInterface) { + return $currentTime->getTimezone()->getName(); + } + + return date_default_timezone_get(); + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php b/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php new file mode 100644 index 000000000..39ff59783 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/DayOfMonthField.php @@ -0,0 +1,164 @@ + + */ +class DayOfMonthField extends AbstractField +{ + /** + * {@inheritdoc} + */ + protected $rangeStart = 1; + + /** + * {@inheritdoc} + */ + protected $rangeEnd = 31; + + /** + * 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|null Returns the nearest date + */ + private static function getNearestWeekday(int $currentYear, int $currentMonth, int $targetDay): ?DateTime + { + $tday = str_pad((string) $targetDay, 2, '0', STR_PAD_LEFT); + $target = DateTime::createFromFormat('Y-m-d', "{$currentYear}-{$currentMonth}-{$tday}"); + + if ($target === false) { + return null; + } + + $currentWeekday = (int) $target->format('N'); + + if ($currentWeekday < 6) { + return $target; + } + + $lastDayOfMonth = $target->format('t'); + foreach ([-1, 1, -2, 2] as $i) { + $adjusted = $targetDay + $i; + if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) { + $target->setDate($currentYear, $currentMonth, $adjusted); + + if ((int) $target->format('N') < 6 && (int) $target->format('m') === $currentMonth) { + return $target; + } + } + } + + return null; + } + + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool + { + // ? 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 ('L' === $value) { + return $fieldValue === $date->format('t'); + } + + // Check to see if this is the nearest weekday to a particular value + if ($wPosition = strpos($value, 'W')) { + // Parse the target day + $targetDay = (int) substr($value, 0, $wPosition); + // Find out if the current day is the nearest day of the week + $nearest = self::getNearestWeekday( + (int) $date->format('Y'), + (int) $date->format('m'), + $targetDay + ); + if ($nearest) { + return $date->format('j') === $nearest->format('j'); + } + + throw new \RuntimeException('Unable to return nearest weekday'); + } + + return $this->isSatisfied((int) $date->format('d'), $value); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable $date + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface + { + if (! $invert) { + $date = $date->add(new \DateInterval('P1D')); + $date = $date->setTime(0, 0); + } else { + $date = $date->sub(new \DateInterval('P1D')); + $date = $date->setTime(23, 59); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate(string $value): bool + { + $basicChecks = parent::validate($value); + + // Validate that a list don't have W or L + if (false !== strpos($value, ',') && (false !== strpos($value, 'W') || false !== strpos($value, 'L'))) { + return false; + } + + if (!$basicChecks) { + if ('?' === $value) { + return true; + } + + if ('L' === $value) { + return true; + } + + if (preg_match('/^(.*)W$/', $value, $matches)) { + return $this->validate($matches[1]); + } + + return false; + } + + return $basicChecks; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php b/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php new file mode 100644 index 000000000..b9bbf48b6 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/DayOfWeekField.php @@ -0,0 +1,194 @@ + 'MON', 2 => 'TUE', 3 => 'WED', 4 => 'THU', 5 => 'FRI', 6 => 'SAT', 7 => 'SUN']; + + /** + * Constructor + */ + public function __construct() + { + $this->nthRange = range(1, 5); + parent::__construct(); + } + + /** + * @inheritDoc + */ + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool + { + if ('?' === $value) { + return true; + } + + // Convert text day of the week values to integers + $value = $this->convertLiterals($value); + + $currentYear = (int) $date->format('Y'); + $currentMonth = (int) $date->format('m'); + $lastDayOfMonth = (int) $date->format('t'); + + // Find out if this is the last specific weekday of the month + if ($lPosition = strpos($value, 'L')) { + $weekday = $this->convertLiterals(substr($value, 0, $lPosition)); + $weekday %= 7; + + $daysInMonth = (int) $date->format('t'); + $remainingDaysInMonth = $daysInMonth - (int) $date->format('d'); + return (($weekday === (int) $date->format('w')) && ($remainingDaysInMonth < 7)); + } + + // Handle # hash tokens + if (strpos($value, '#')) { + [$weekday, $nth] = explode('#', $value); + + if (!is_numeric($nth)) { + throw new InvalidArgumentException("Hashed weekdays must be numeric, {$nth} given"); + } else { + $nth = (int) $nth; + } + + // 0 and 7 are both Sunday, however 7 matches date('N') format ISO-8601 + if ('0' === $weekday) { + $weekday = 7; + } + + $weekday = (int) $this->convertLiterals((string) $weekday); + + // Validate the hash fields + if ($weekday < 0 || $weekday > 7) { + throw new InvalidArgumentException("Weekday must be a value between 0 and 7. {$weekday} given"); + } + + if (!\in_array($nth, $this->nthRange, true)) { + throw new InvalidArgumentException("There are never more than 5 or less than 1 of a given weekday in a month, {$nth} given"); + } + + // The current weekday must match the targeted weekday to proceed + if ((int) $date->format('N') !== $weekday) { + return false; + } + + $tdate = clone $date; + $tdate = $tdate->setDate($currentYear, $currentMonth, 1); + $dayCount = 0; + $currentDay = 1; + while ($currentDay < $lastDayOfMonth + 1) { + if ((int) $tdate->format('N') === $weekday) { + if (++$dayCount >= $nth) { + break; + } + } + $tdate = $tdate->setDate($currentYear, $currentMonth, ++$currentDay); + } + + return (int) $date->format('j') === $currentDay; + } + + // Handle day of the week values + if (false !== strpos($value, '-')) { + $parts = explode('-', $value); + if ('7' === $parts[0]) { + $parts[0] = 0; + } elseif ('0' === $parts[1]) { + $parts[1] = 7; + } + $value = implode('-', $parts); + } + + // Test to see which Sunday to use -- 0 == 7 == Sunday + $format = \in_array(7, array_map(function ($value) { + return (int) $value; + }, str_split($value)), true) ? 'N' : 'w'; + $fieldValue = (int) $date->format($format); + + return $this->isSatisfied($fieldValue, $value); + } + + /** + * @inheritDoc + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface + { + if (! $invert) { + $date = $date->add(new \DateInterval('P1D')); + $date = $date->setTime(0, 0); + } else { + $date = $date->sub(new \DateInterval('P1D')); + $date = $date->setTime(23, 59); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function validate(string $value): bool + { + $basicChecks = parent::validate($value); + + if (!$basicChecks) { + if ('?' === $value) { + return true; + } + + // Handle the # value + if (false !== strpos($value, '#')) { + $chunks = explode('#', $value); + $chunks[0] = $this->convertLiterals($chunks[0]); + + if (parent::validate($chunks[0]) && is_numeric($chunks[1]) && \in_array((int) $chunks[1], $this->nthRange, true)) { + return true; + } + } + + if (preg_match('/^(.*)L$/', $value, $matches)) { + return $this->validate($matches[1]); + } + + return false; + } + + return $basicChecks; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php b/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php new file mode 100644 index 000000000..839b2757d --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/FieldFactory.php @@ -0,0 +1,52 @@ +fields[$position] ?? $this->fields[$position] = $this->instantiateField($position); + } + + private function instantiateField(int $position): FieldInterface + { + switch ($position) { + case CronExpression::MINUTE: + return new MinutesField(); + case CronExpression::HOUR: + return new HoursField(); + case CronExpression::DAY: + return new DayOfMonthField(); + case CronExpression::MONTH: + return new MonthField(); + case CronExpression::WEEKDAY: + return new DayOfWeekField(); + } + + throw new InvalidArgumentException( + ($position + 1) . ' is not a valid position' + ); + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/FieldFactoryInterface.php b/vendor/dragonmantank/cron-expression/src/Cron/FieldFactoryInterface.php new file mode 100644 index 000000000..8bd3c6581 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/FieldFactoryInterface.php @@ -0,0 +1,8 @@ +format('H'); + $retval = $this->isSatisfied($checkValue, $value); + if ($retval) { + return $retval; + } + + // Are we on the edge of a transition + $lastTransition = $this->getPastTransition($date); + if (($lastTransition !== null) && ($lastTransition["ts"] > ((int) $date->format('U') - 3600))) { + $dtLastOffset = clone $date; + $this->timezoneSafeModify($dtLastOffset, "-1 hour"); + $lastOffset = $dtLastOffset->getOffset(); + + $dtNextOffset = clone $date; + $this->timezoneSafeModify($dtNextOffset, "+1 hour"); + $nextOffset = $dtNextOffset->getOffset(); + + $offsetChange = $nextOffset - $lastOffset; + if ($offsetChange >= 3600) { + $checkValue -= 1; + return $this->isSatisfied($checkValue, $value); + } + if ((! $invert) && ($offsetChange <= -3600)) { + $checkValue += 1; + return $this->isSatisfied($checkValue, $value); + } + } + + return $retval; + } + + public function getPastTransition(DateTimeInterface $date): ?array + { + $currentTimestamp = (int) $date->format('U'); + if ( + ($this->transitions === null) + || ($this->transitionsStart < ($currentTimestamp + 86400)) + || ($this->transitionsEnd > ($currentTimestamp - 86400)) + ) { + // We start a day before current time so we can differentiate between the first transition entry + // and a change that happens now + $dtLimitStart = clone $date; + $dtLimitStart = $dtLimitStart->modify("-12 months"); + $dtLimitEnd = clone $date; + $dtLimitEnd = $dtLimitEnd->modify('+12 months'); + + $this->transitions = $date->getTimezone()->getTransitions( + $dtLimitStart->getTimestamp(), + $dtLimitEnd->getTimestamp() + ); + if (empty($this->transitions)) { + return null; + } + $this->transitionsStart = $dtLimitStart->getTimestamp(); + $this->transitionsEnd = $dtLimitEnd->getTimestamp(); + } + + $nextTransition = null; + foreach ($this->transitions as $transition) { + if ($transition["ts"] > $currentTimestamp) { + continue; + } + + if (($nextTransition !== null) && ($transition["ts"] < $nextTransition["ts"])) { + continue; + } + + $nextTransition = $transition; + } + + return ($nextTransition ?? null); + } + + /** + * {@inheritdoc} + * + * @param string|null $parts + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface + { + $originalTimestamp = (int) $date->format('U'); + + // 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. + if (null === $parts || '*' === $parts) { + if ($invert) { + $date = $date->sub(new \DateInterval('PT1H')); + } else { + $date = $date->add(new \DateInterval('PT1H')); + } + + $date = $this->setTimeHour($date, $invert, $originalTimestamp); + return $this; + } + + $parts = false !== strpos($parts, ',') ? explode(',', $parts) : [$parts]; + $hours = []; + foreach ($parts as $part) { + $hours = array_merge($hours, $this->getRangeForExpression($part, 23)); + } + + $current_hour = (int) $date->format('H'); + $position = $invert ? \count($hours) - 1 : 0; + $countHours = \count($hours); + if ($countHours > 1) { + for ($i = 0; $i < $countHours - 1; ++$i) { + if ((!$invert && $current_hour >= $hours[$i] && $current_hour < $hours[$i + 1]) || + ($invert && $current_hour > $hours[$i] && $current_hour <= $hours[$i + 1])) { + $position = $invert ? $i : $i + 1; + + break; + } + } + } + + $target = (int) $hours[$position]; + $originalHour = (int)$date->format('H'); + + $originalDay = (int)$date->format('d'); + $previousOffset = $date->getOffset(); + + if (! $invert) { + if ($originalHour >= $target) { + $distance = 24 - $originalHour; + $date = $this->timezoneSafeModify($date, "+{$distance} hours"); + + $actualDay = (int)$date->format('d'); + $actualHour = (int)$date->format('H'); + if (($actualDay !== ($originalDay + 1)) && ($actualHour !== 0)) { + $offsetChange = ($previousOffset - $date->getOffset()); + $date = $this->timezoneSafeModify($date, "+{$offsetChange} seconds"); + } + + $originalHour = (int)$date->format('H'); + } + + $distance = $target - $originalHour; + $date = $this->timezoneSafeModify($date, "+{$distance} hours"); + } else { + if ($originalHour <= $target) { + $distance = ($originalHour + 1); + $date = $this->timezoneSafeModify($date, "-" . $distance . " hours"); + + $actualDay = (int)$date->format('d'); + $actualHour = (int)$date->format('H'); + if (($actualDay !== ($originalDay - 1)) && ($actualHour !== 23)) { + $offsetChange = ($previousOffset - $date->getOffset()); + $date = $this->timezoneSafeModify($date, "+{$offsetChange} seconds"); + } + + $originalHour = (int)$date->format('H'); + } + + $distance = $originalHour - $target; + $date = $this->timezoneSafeModify($date, "-{$distance} hours"); + } + + $date = $this->setTimeHour($date, $invert, $originalTimestamp); + + $actualHour = (int)$date->format('H'); + if ($invert && ($actualHour === ($target - 1) || (($actualHour === 23) && ($target === 0)))) { + $date = $this->timezoneSafeModify($date, "+1 hour"); + } + + return $this; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php b/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php new file mode 100644 index 000000000..f077e6ec5 --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/MinutesField.php @@ -0,0 +1,97 @@ +isSatisfied((int)$date->format('i'), $value); + } + + /** + * {@inheritdoc} + * {@inheritDoc} + * + * @param string|null $parts + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface + { + if (is_null($parts)) { + $date = $this->timezoneSafeModify($date, ($invert ? "-" : "+") ."1 minute"); + return $this; + } + + $current_minute = (int) $date->format('i'); + + $parts = false !== strpos($parts, ',') ? explode(',', $parts) : [$parts]; + sort($parts); + $minutes = []; + foreach ($parts as $part) { + $minutes = array_merge($minutes, $this->getRangeForExpression($part, 59)); + } + + $position = $invert ? \count($minutes) - 1 : 0; + if (\count($minutes) > 1) { + for ($i = 0; $i < \count($minutes) - 1; ++$i) { + if ((!$invert && $current_minute >= $minutes[$i] && $current_minute < $minutes[$i + 1]) || + ($invert && $current_minute > $minutes[$i] && $current_minute <= $minutes[$i + 1])) { + $position = $invert ? $i : $i + 1; + + break; + } + } + } + + $target = (int) $minutes[$position]; + $originalMinute = (int) $date->format("i"); + + if (! $invert) { + if ($originalMinute >= $target) { + $distance = 60 - $originalMinute; + $date = $this->timezoneSafeModify($date, "+{$distance} minutes"); + + $originalMinute = (int) $date->format("i"); + } + + $distance = $target - $originalMinute; + $date = $this->timezoneSafeModify($date, "+{$distance} minutes"); + } else { + if ($originalMinute <= $target) { + $distance = ($originalMinute + 1); + $date = $this->timezoneSafeModify($date, "-{$distance} minutes"); + + $originalMinute = (int) $date->format("i"); + } + + $distance = $originalMinute - $target; + $date = $this->timezoneSafeModify($date, "-{$distance} minutes"); + } + + return $this; + } +} diff --git a/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php b/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php new file mode 100644 index 000000000..5a15fbb8b --- /dev/null +++ b/vendor/dragonmantank/cron-expression/src/Cron/MonthField.php @@ -0,0 +1,61 @@ + 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR', 5 => 'MAY', 6 => 'JUN', 7 => 'JUL', + 8 => 'AUG', 9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC', ]; + + /** + * {@inheritdoc} + */ + public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool + { + if ($value === '?') { + return true; + } + + $value = $this->convertLiterals($value); + + return $this->isSatisfied((int) $date->format('m'), $value); + } + + /** + * @inheritDoc + * + * @param \DateTime|\DateTimeImmutable $date + */ + public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface + { + if (! $invert) { + $date = $date->modify('first day of next month'); + $date = $date->setTime(0, 0); + } else { + $date = $date->modify('last day of previous month'); + $date = $date->setTime(23, 59); + } + + return $this; + } +} -- cgit v1.2.3-54-g00ecf From 6a40940ad6c6facea6c8e9d0dc1896885168c442 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 10:17:13 +0300 Subject: split housekeeping jobs to separate scheduled tasks on longer cooldown intervals, add table to record task execution timestamps, bump schema --- classes/Config.php | 2 +- classes/PluginHost.php | 90 +++++++++++++++++++++++++++++++++++++++++ classes/RSSUtils.php | 95 ++++++++++++++++++++++++++++---------------- sql/pgsql/migrations/150.sql | 5 +++ sql/pgsql/schema.sql | 7 ++++ 5 files changed, 164 insertions(+), 35 deletions(-) create mode 100644 sql/pgsql/migrations/150.sql diff --git a/classes/Config.php b/classes/Config.php index c4176b7a8..1afa4f043 100644 --- a/classes/Config.php +++ b/classes/Config.php @@ -6,7 +6,7 @@ class Config { const T_STRING = 2; const T_INT = 3; - const SCHEMA_VERSION = 149; + const SCHEMA_VERSION = 150; /** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX: * diff --git a/classes/PluginHost.php b/classes/PluginHost.php index bfc02318b..484364f26 100644 --- a/classes/PluginHost.php +++ b/classes/PluginHost.php @@ -32,6 +32,9 @@ class PluginHost { /** @var array> */ private array $plugin_actions = []; + /** @var array */ + private array $scheduled_tasks = []; + private ?int $owner_uid = null; private bool $data_loaded = false; @@ -906,4 +909,91 @@ class PluginHost { $ref = new ReflectionClass(get_class($plugin)); return basename(dirname(dirname($ref->getFileName()))) == "plugins.local"; } + + /** + * Adds a backend scheduled task which will be executed by updater (if due) when idle during + * RSSUtils::housekeeping_common(). + * + * The granularity is not strictly guaranteed, housekeeping is invoked several times per hour + * depending on how fast feed batch was processed, but no more than once per minute. + * + * Tasks are not run in user context and are available to system plugins only. Task names may not + * overlap. + * + * Tasks should return an integer value (return code) which is stored in the database, a value of + * 0 is considered successful. + * + * @param string $task_name unique name for this task, plugins should prefix this with plugin name + * @param string $cron_expression schedule for this task in cron format + * @param Closure $callback task code that gets executed + */ + function add_scheduled_task(string $task_name, string $cron_expression, Closure $callback) : bool { + $task_name = strtolower($task_name); + + if (isset($this->scheduled_tasks[$task_name])) { + user_error("Attempted to override already registered scheduled task $task_name", E_USER_WARNING); + return false; + } else { + $cron = new Cron\CronExpression($cron_expression); + + $this->scheduled_tasks[$task_name] = [ + "cron" => $cron, + "callback" => $callback, + ]; + return true; + } + } + + /** + * Execute scheduled tasks which are due to run and record last run timestamps. + * @return void + */ + function run_due_tasks() { + Debug::log('Processing all scheduled tasks...'); + + $tasks_run = 0; + + foreach ($this->scheduled_tasks as $task_name => $task) { + $last_run = '1970-01-01 00:00'; + + $task_record = ORM::for_table('ttrss_scheduled_tasks') + ->where('task_name', $task_name) + ->find_one(); + + if ($task_record) + $last_run = $task_record->last_run; + + Debug::log("Checking scheduled task: $task_name, last run: $last_run"); + + if ($task['cron']->isDue($last_run)) { + Debug::log("Task $task_name is due, executing..."); + + $rc = (int) $task['callback'](); + + Debug::log("Task $task_name has finished with RC=$rc, recording timestamp..."); + + if ($task_record) { + $task_record->last_run = time(); + $task_record->last_rc = $rc; + + $task_record->save(); + } else { + $task_record = ORM::for_table('ttrss_scheduled_tasks')->create(); + + $task_record->set([ + 'task_name' => $task_name, + 'last_rc' => $rc, + 'last_run' => Db::NOW(), + ]); + + $task_record->save(); + } + } + } + + Debug::log("Finished with $tasks_run tasks executed."); + } + + // TODO implement some sort of automatic cleanup for orphan task execution records + } diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index acf1d14e5..d46d73793 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -35,11 +35,6 @@ class RSSUtils { return sha1(implode(",", $pluginhost->get_plugin_names()) . $tmp); } - static function cleanup_feed_browser(): void { - $pdo = Db::pdo(); - $pdo->query("DELETE FROM ttrss_feedbrowser_cache"); - } - static function cleanup_feed_icons(): void { $pdo = Db::pdo(); $sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?"); @@ -81,6 +76,8 @@ class RSSUtils { die("Schema version is wrong, please upgrade the database.\n"); } + self::init_housekeeping_tasks(); + $pdo = Db::pdo(); $feeds_in_the_future = ORM::for_table('ttrss_feeds') @@ -1432,15 +1429,6 @@ class RSSUtils { WHERE ' . Db::past_comparison_qpart('created_at', '<', 7, 'day')); } - /** - * @deprecated table not used - */ - static function expire_feed_archive(): void { - $pdo = Db::pdo(); - - $pdo->query("DELETE FROM ttrss_archived_feeds"); - } - static function expire_lock_files(): void { Debug::log("Removing old lock files...", Debug::LOG_VERBOSE); @@ -1659,14 +1647,6 @@ class RSSUtils { mb_strtolower(strip_tags($title), 'utf-8')); } - /* counter cache is no longer used, if called truncate leftover data */ - static function cleanup_counters_cache(): void { - $pdo = Db::pdo(); - - $pdo->query("DELETE FROM ttrss_counters_cache"); - $pdo->query("DELETE FROM ttrss_cat_counters_cache"); - } - static function disable_failed_feeds(): void { if (Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT) > 0) { @@ -1735,21 +1715,68 @@ class RSSUtils { } } - static function housekeeping_common(): void { - $cache = DiskCache::instance(""); - $cache->expire_all(); + /** Init all system tasks which are run periodically by updater in housekeeping_common() */ + static function init_housekeeping_tasks() : void { + Debug::log('Registering scheduled tasks for housekeeping...'); + + PluginHost::getInstance()->add_scheduled_task('purge_orphans', '@daily', + function() { + Article::_purge_orphans(); return 0; + } + ); + + PluginHost::getInstance()->add_scheduled_task('disk_cache_expire_all', '@daily', + function() { + $cache = DiskCache::instance(""); + $cache->expire_all(); + + return 0; + } + ); + + PluginHost::getInstance()->add_scheduled_task('expire_error_log', '@hourly', + function() { + self::expire_error_log(); + + return 0; + } + ); - self::migrate_feed_icons(); - self::expire_lock_files(); - self::expire_error_log(); - self::expire_feed_archive(); - self::cleanup_feed_browser(); - self::cleanup_feed_icons(); - self::disable_failed_feeds(); + PluginHost::getInstance()->add_scheduled_task('expire_lock_files', '@hourly', + function() { + self::expire_lock_files(); - Article::_purge_orphans(); - self::cleanup_counters_cache(); + return 0; + } + ); + + PluginHost::getInstance()->add_scheduled_task('disable_failed_feeds', '@daily', + function() { + self::disable_failed_feeds(); + + return 0; + } + ); + PluginHost::getInstance()->add_scheduled_task('migrate_feed_icons', '@daily', + function() { + self::migrate_feed_icons(); + + return 0; + } + ); + + PluginHost::getInstance()->add_scheduled_task('cleanup_feed_icons', '@daily', + function() { + self::cleanup_feed_icons(); + + return 0; + } + ); + } + + static function housekeeping_common(): void { + PluginHost::getInstance()->run_due_tasks(); PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); } diff --git a/sql/pgsql/migrations/150.sql b/sql/pgsql/migrations/150.sql new file mode 100644 index 000000000..55d9609e9 --- /dev/null +++ b/sql/pgsql/migrations/150.sql @@ -0,0 +1,5 @@ +create table ttrss_scheduled_tasks( + id serial not null primary key, + task_name varchar(250) unique not null, + last_rc integer not null, + last_run timestamp not null default NOW()); diff --git a/sql/pgsql/schema.sql b/sql/pgsql/schema.sql index acfa619c9..3ff9a6674 100644 --- a/sql/pgsql/schema.sql +++ b/sql/pgsql/schema.sql @@ -1,3 +1,4 @@ +drop table if exists ttrss_scheduled_tasks; drop table if exists ttrss_error_log; drop table if exists ttrss_plugin_storage; drop table if exists ttrss_linked_feeds; @@ -394,4 +395,10 @@ create table ttrss_error_log( context text not null, created_at timestamp not null); +create table ttrss_scheduled_tasks( + id serial not null primary key, + task_name varchar(250) unique not null, + last_rc integer not null, + last_run timestamp not null default NOW()); + commit; -- cgit v1.2.3-54-g00ecf From a268f52de695fffb29769960332bfb34fe3ac7b5 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 10:23:30 +0300 Subject: record task duration in seconds --- classes/PluginHost.php | 6 +++++- sql/pgsql/migrations/150.sql | 1 + sql/pgsql/schema.sql | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/classes/PluginHost.php b/classes/PluginHost.php index 484364f26..e417fde1f 100644 --- a/classes/PluginHost.php +++ b/classes/PluginHost.php @@ -968,12 +968,15 @@ class PluginHost { if ($task['cron']->isDue($last_run)) { Debug::log("Task $task_name is due, executing..."); + $task_started = time(); $rc = (int) $task['callback'](); + $task_duration = time() - $task_started; - Debug::log("Task $task_name has finished with RC=$rc, recording timestamp..."); + Debug::log("Task $task_name has finished in $task_duration seconds with RC=$rc, recording timestamp..."); if ($task_record) { $task_record->last_run = time(); + $task_record->last_duration = $task_duration; $task_record->last_rc = $rc; $task_record->save(); @@ -982,6 +985,7 @@ class PluginHost { $task_record->set([ 'task_name' => $task_name, + 'last_duration' => $task_duration, 'last_rc' => $rc, 'last_run' => Db::NOW(), ]); diff --git a/sql/pgsql/migrations/150.sql b/sql/pgsql/migrations/150.sql index 55d9609e9..c0aae3bdf 100644 --- a/sql/pgsql/migrations/150.sql +++ b/sql/pgsql/migrations/150.sql @@ -1,5 +1,6 @@ create table ttrss_scheduled_tasks( id serial not null primary key, task_name varchar(250) unique not null, + last_duration integer not null, last_rc integer not null, last_run timestamp not null default NOW()); diff --git a/sql/pgsql/schema.sql b/sql/pgsql/schema.sql index 3ff9a6674..7e995f915 100644 --- a/sql/pgsql/schema.sql +++ b/sql/pgsql/schema.sql @@ -398,6 +398,7 @@ create table ttrss_error_log( create table ttrss_scheduled_tasks( id serial not null primary key, task_name varchar(250) unique not null, + last_duration integer not null, last_rc integer not null, last_run timestamp not null default NOW()); -- cgit v1.2.3-54-g00ecf From 44b5b33f3da9012e0028de6230ccbd5a729a4b71 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 10:28:35 +0300 Subject: remove synchronous usages of _purge_orphans() --- classes/RPC.php | 6 ------ classes/RSSUtils.php | 4 +++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/classes/RPC.php b/classes/RPC.php index 6b6f3e909..c6cdab7f6 100644 --- a/classes/RPC.php +++ b/classes/RPC.php @@ -82,8 +82,6 @@ class RPC extends Handler_Protected { WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); $sth->execute([...$ids, $_SESSION['uid']]); - Article::_purge_orphans(); - print json_encode(array("message" => "UPDATE_COUNTERS")); } @@ -311,10 +309,6 @@ class RPC extends Handler_Protected { } } - // Purge orphans and cleanup tags - Article::_purge_orphans(); - //cleanup_tags(14, 50000); - if ($num_updated > 0) { print json_encode(array("message" => "UPDATE_COUNTERS", "num_updated" => $num_updated)); diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index d46d73793..7c13dba63 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -1721,7 +1721,9 @@ class RSSUtils { PluginHost::getInstance()->add_scheduled_task('purge_orphans', '@daily', function() { - Article::_purge_orphans(); return 0; + Article::_purge_orphans(); + + return 0; } ); -- cgit v1.2.3-54-g00ecf From 36f60b51d7a49fe18071f67770064cccf2cb439d Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 13:17:20 +0300 Subject: make digest sending a hourly cron job --- classes/RSSUtils.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index 7c13dba63..67ee3709d 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -285,9 +285,6 @@ class RSSUtils { self::housekeeping_user($owner_uid); } - // Send feed digests by email if needed. - Digest::send_headlines_digests(); - return $nf; } @@ -1775,6 +1772,14 @@ class RSSUtils { return 0; } ); + + PluginHost::getInstance()->add_scheduled_task('send_headlines_digests', '@hourly', + function() { + Digest::send_headlines_digests(); + + return 0; + } + ); } static function housekeeping_common(): void { -- cgit v1.2.3-54-g00ecf From a51c1d5176d0285a4fecc6e1e84d9f7dc4abaca5 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 13:18:48 +0300 Subject: fix tasks_run never incremented --- classes/PluginHost.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/classes/PluginHost.php b/classes/PluginHost.php index e417fde1f..9bdf7c0b4 100644 --- a/classes/PluginHost.php +++ b/classes/PluginHost.php @@ -972,6 +972,8 @@ class PluginHost { $rc = (int) $task['callback'](); $task_duration = time() - $task_started; + ++$tasks_run; + Debug::log("Task $task_name has finished in $task_duration seconds with RC=$rc, recording timestamp..."); if ($task_record) { -- cgit v1.2.3-54-g00ecf From aeca30cb0c6b26c569f66c5043690d5528fc481b Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 13:26:58 +0300 Subject: drop SIMPLE_UPDATE_MODE, limit housekeeping and updates to background processes --- backend.php | 2 +- classes/Config.php | 8 ------ classes/Handler_Public.php | 14 --------- classes/RPC.php | 72 ---------------------------------------------- classes/RSSUtils.php | 1 - js/App.js | 5 ---- js/Feeds.js | 7 ----- 7 files changed, 1 insertion(+), 108 deletions(-) diff --git a/backend.php b/backend.php index 860a3940c..d9a0af7c7 100644 --- a/backend.php +++ b/backend.php @@ -14,7 +14,7 @@ /* Public calls compatibility shim */ - $public_calls = array("globalUpdateFeeds", "rss", "getUnread", "getProfiles", "share"); + $public_calls = array("rss", "getUnread", "getProfiles", "share"); if (array_search($op, $public_calls) !== false) { header("Location: public.php?" . $_SERVER['QUERY_STRING']); diff --git a/classes/Config.php b/classes/Config.php index 1afa4f043..c413317be 100644 --- a/classes/Config.php +++ b/classes/Config.php @@ -53,13 +53,6 @@ class Config { * your tt-rss directory protected by other means (e.g. http auth). */ const SINGLE_USER_MODE = "SINGLE_USER_MODE"; - /** enables fallback update mode where tt-rss tries to update feeds in - * background while tt-rss is open in your browser. - * if you don't have a lot of feeds and don't want to or can't run - * background processes while not running tt-rss, this method is generally - * viable to keep your feeds up to date. */ - const SIMPLE_UPDATE_MODE = "SIMPLE_UPDATE_MODE"; - /** use this PHP CLI executable to start various tasks */ const PHP_EXECUTABLE = "PHP_EXECUTABLE"; @@ -205,7 +198,6 @@ class Config { Config::DB_PORT => [ "5432", Config::T_STRING ], Config::SELF_URL_PATH => [ "https://example.com/tt-rss", Config::T_STRING ], Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ], - Config::SIMPLE_UPDATE_MODE => [ "", Config::T_BOOL ], Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ], Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ], Config::CACHE_DIR => [ "cache", Config::T_STRING ], diff --git a/classes/Handler_Public.php b/classes/Handler_Public.php index abff08376..bb3667e2a 100644 --- a/classes/Handler_Public.php +++ b/classes/Handler_Public.php @@ -360,20 +360,6 @@ class Handler_Public extends Handler { header('HTTP/1.1 403 Forbidden'); } - function updateTask(): void { - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK); - } - - function housekeepingTask(): void { - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); - } - - function globalUpdateFeeds(): void { - RPC::updaterandomfeed_real(); - - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK); - } - function login(): void { if (!Config::get(Config::SINGLE_USER_MODE)) { diff --git a/classes/RPC.php b/classes/RPC.php index c6cdab7f6..4d133c272 100644 --- a/classes/RPC.php +++ b/classes/RPC.php @@ -250,77 +250,6 @@ class RPC extends Handler_Protected { print json_encode(["wide" => $wide]); } - static function updaterandomfeed_real(): void { - $default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL); - - // Test if the feed need a update (update interval exceded). - $update_limit_qpart = "AND (( - update_interval = 0 - AND (p.value IS NULL OR p.value != '-1') - AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL) - ) OR ( - update_interval > 0 - AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL) - ) OR ( - update_interval >= 0 - AND (p.value IS NULL OR p.value != '-1') - AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) - ))"; - - // Test if feed is currently being updated by another process. - $updstart_thresh_qpart = 'AND (last_update_started IS NULL OR ' - . Db::past_comparison_qpart('last_update_started', '<', 5, 'minute') . ')'; - - $pdo = Db::pdo(); - - // we could be invoked from public.php with no active session - if (!empty($_SESSION["uid"])) { - $owner_check_qpart = "AND f.owner_uid = ".$pdo->quote($_SESSION["uid"]); - } else { - $owner_check_qpart = ""; - } - - $query = "SELECT f.feed_url,f.id - FROM - ttrss_feeds f, ttrss_users u LEFT JOIN ttrss_user_prefs2 p ON - (p.owner_uid = u.id AND profile IS NULL AND pref_name = 'DEFAULT_UPDATE_INTERVAL') - WHERE - f.owner_uid = u.id AND - u.access_level NOT IN (".sprintf("%d, %d", UserHelper::ACCESS_LEVEL_DISABLED, UserHelper::ACCESS_LEVEL_READONLY).") - $owner_check_qpart - $update_limit_qpart - $updstart_thresh_qpart - ORDER BY RANDOM() LIMIT 30"; - - $res = $pdo->query($query); - - $num_updated = 0; - - $tstart = time(); - - while ($line = $res->fetch()) { - $feed_id = $line["id"]; - - if (time() - $tstart < ini_get("max_execution_time") * 0.7) { - RSSUtils::update_rss_feed($feed_id, true); - ++$num_updated; - } else { - break; - } - } - - if ($num_updated > 0) { - print json_encode(array("message" => "UPDATE_COUNTERS", - "num_updated" => $num_updated)); - } else { - print json_encode(array("message" => "NOTHING_TO_UPDATE")); - } - } - - function updaterandomfeed(): void { - self::updaterandomfeed_real(); - } - /** * @param array $ids */ @@ -466,7 +395,6 @@ class RPC extends Handler_Protected { $params["num_feeds"] = (int) $num_feeds; $params["hotkeys"] = $this->get_hotkeys_map(); $params["widescreen"] = (int) Prefs::get(Prefs::WIDESCREEN_MODE, $_SESSION['uid'], $profile); - $params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE); $params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif"); $params["icon_oval"] = $this->image_to_base64("images/oval.svg"); $params["icon_three_dots"] = $this->image_to_base64("images/three-dots.svg"); diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index 67ee3709d..eef0d8540 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -129,7 +129,6 @@ class RSSUtils { ))"; // Test if feed is currently being updated by another process. - // TODO: Update RPC::updaterandomfeed_real() to also use 10 minutes? $updstart_thresh_qpart = 'AND (last_update_started IS NULL OR ' . Db::past_comparison_qpart('last_update_started', '<', 10, 'minute') . ')'; diff --git a/js/App.js b/js/App.js index 0c4c72f22..f1c4bc1c3 100644 --- a/js/App.js +++ b/js/App.js @@ -829,11 +829,6 @@ const App = { Headlines.initScrollHandler(); - if (this.getInitParam("simple_update")) { - console.log("scheduling simple feed updater..."); - window.setInterval(() => { Feeds.updateRandom() }, 30 * 1000); - } - if (this.getInitParam('check_for_updates')) { window.setInterval(() => { this.checkForUpdates(); diff --git a/js/Feeds.js b/js/Feeds.js index 9088b7efc..7a58a10a4 100644 --- a/js/Feeds.js +++ b/js/Feeds.js @@ -743,13 +743,6 @@ const Feeds = { dialog.show(); }, - updateRandom: function() { - console.log("in update_random_feed"); - - xhr.json("backend.php", {op: "RPC", method: "updaterandomfeed"}, () => { - // - }); - }, renderIcon: function(feed_id, exists) { const icon_url = App.getInitParam("icons_url") + '?' + dojo.objectToQuery({op: 'feed_icon', id: feed_id}); -- cgit v1.2.3-54-g00ecf From 247efe3137fadf5d74ab254cf4c80957624abc90 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 13:37:08 +0300 Subject: bring back cleanup of potentially sensitive environment variables but exclude CLI SAPI to prevent updater failures --- classes/Config.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/classes/Config.php b/classes/Config.php index c413317be..6e16f269b 100644 --- a/classes/Config.php +++ b/classes/Config.php @@ -275,6 +275,13 @@ class Config { if (isset(self::_DEFAULTS[$const])) { $override = getenv(self::_ENVVAR_PREFIX . $const); + // cleanup original env var after importing (unless it's a background process) + if (php_sapi_name() != "cli") { + putenv(self::_ENVVAR_PREFIX . $const . '='); + unset($_ENV[self::_ENVVAR_PREFIX . $const]); + unset($_SERVER[self::_ENVVAR_PREFIX . $const]); + } + list ($defval, $deftype) = self::_DEFAULTS[$const]; $this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ]; @@ -433,6 +440,13 @@ class Config { private function _add(string $param, string $default, int $type_hint): void { $override = getenv(self::_ENVVAR_PREFIX . $param); + // cleanup original env var after importing (unless it's a background process) + if (php_sapi_name() != "cli") { + putenv(self::_ENVVAR_PREFIX . $param . '='); + unset($_ENV[self::_ENVVAR_PREFIX . $param]); + unset($_SERVER[self::_ENVVAR_PREFIX . $param]); + } + $this->params[$param] = [ self::cast_to($override !== false ? $override : $default, $type_hint), $type_hint ]; } -- cgit v1.2.3-54-g00ecf From dc6ea08ca490c889f4e85bd697e6bdffb95a22f4 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 14:03:45 +0300 Subject: add workaround for due tasks because housekeeping is not run every minute, fix last_run not updated to NOW() in the db --- classes/PluginHost.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/classes/PluginHost.php b/classes/PluginHost.php index 9bdf7c0b4..8bde8df3f 100644 --- a/classes/PluginHost.php +++ b/classes/PluginHost.php @@ -965,7 +965,9 @@ class PluginHost { Debug::log("Checking scheduled task: $task_name, last run: $last_run"); - if ($task['cron']->isDue($last_run)) { + // because we don't schedule tasks every minute, we assume that task is due if its + // next estimated run based on previous timestamp is in the past + if ($task['cron']->getNextRunDate($last_run)->getTimestamp() - time() < 0) { Debug::log("Task $task_name is due, executing..."); $task_started = time(); @@ -977,7 +979,7 @@ class PluginHost { Debug::log("Task $task_name has finished in $task_duration seconds with RC=$rc, recording timestamp..."); if ($task_record) { - $task_record->last_run = time(); + $task_record->last_run = Db::NOW(); $task_record->last_duration = $task_duration; $task_record->last_rc = $rc; -- cgit v1.2.3-54-g00ecf From b30f8c93a00ce1ae2c582ca4c7f1d5d8425220ee Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 21:27:50 +0300 Subject: rename article mark/publish hooks --- classes/API.php | 4 ++-- classes/Article.php | 6 +++--- classes/Plugin.php | 7 +++++-- classes/PluginHost.php | 8 ++++---- classes/RPC.php | 8 ++++---- classes/RSSUtils.php | 4 ++-- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/classes/API.php b/classes/API.php index 83eaa22b8..6db05198a 100644 --- a/classes/API.php +++ b/classes/API.php @@ -285,10 +285,10 @@ class API extends Handler { $sth->execute([...$article_ids, $_SESSION['uid']]); if ($field == 'marked') - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARKED, $article_ids); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $article_ids); if ($field == 'published') - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISHED, $article_ids); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $article_ids); $num_updated = $sth->rowCount(); diff --git a/classes/Article.php b/classes/Article.php index 6a3111892..c0d77123c 100644 --- a/classes/Article.php +++ b/classes/Article.php @@ -98,7 +98,7 @@ class Article extends Handler_Protected { int_id = ? AND owner_uid = ?"); $sth->execute([$int_id, $owner_uid]); - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISHED, [$ref_id]); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]); } else { @@ -109,7 +109,7 @@ class Article extends Handler_Protected { (?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())"); $sth->execute([$ref_id, $owner_uid]); - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISHED, [$ref_id]); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]); } if (count($labels) != 0) { @@ -148,7 +148,7 @@ class Article extends Handler_Protected { (?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())"); $sth->execute([$ref_id, $owner_uid]); - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISHED, [$ref_id]); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]); if (count($labels) != 0) { foreach ($labels as $label) { diff --git a/classes/Plugin.php b/classes/Plugin.php index b66e2082c..3165d62f4 100644 --- a/classes/Plugin.php +++ b/classes/Plugin.php @@ -714,22 +714,25 @@ abstract class Plugin { } /** Invoked after passed article IDs were either marked (i.e. starred) or unmarked. + * * **Note** resulting state of the articles is not passed to this function (because * tt-rss may do invert operation on ID range), you will need to get this from the database. * @param array $article_ids ref_ids * @return void */ - function hook_articles_marked(array $article_ids) { + function hook_articles_mark_toggled(array $article_ids) { user_error("Dummy method invoked.", E_USER_ERROR); } /** Invoked after passed article IDs were either published or unpublished. + * * **Note** resulting state of the articles is not passed to this function (because * tt-rss may do invert operation on ID range), you will need to get this from the database. + * * @param array $article_ids ref_ids * @return void */ - function hook_articles_published(array $article_ids) { + function hook_articles_publish_toggled(array $article_ids) { user_error("Dummy method invoked.", E_USER_ERROR); } } diff --git a/classes/PluginHost.php b/classes/PluginHost.php index 8bde8df3f..5cff4afcb 100644 --- a/classes/PluginHost.php +++ b/classes/PluginHost.php @@ -202,11 +202,11 @@ class PluginHost { /** @see Plugin::hook_validate_session() */ const HOOK_VALIDATE_SESSION = "hook_validate_session"; - /** @see Plugin::hook_articles_marked() */ - const HOOK_ARTICLES_MARKED = "hook_articles_marked"; + /** @see Plugin::hook_articles_mark_toggled() */ + const HOOK_ARTICLES_MARK_TOGGLED = "hook_articles_mark_toggled"; - /** @see Plugin::hook_articles_published() */ - const HOOK_ARTICLES_PUBLISHED = "hook_articles_published"; + /** @see Plugin::hook_articles_publish_toggled() */ + const HOOK_ARTICLES_PUBLISH_TOGGLED = "hook_articles_publish_toggled"; const KIND_ALL = 1; const KIND_SYSTEM = 2; diff --git a/classes/RPC.php b/classes/RPC.php index 4d133c272..6ce2c12aa 100644 --- a/classes/RPC.php +++ b/classes/RPC.php @@ -69,7 +69,7 @@ class RPC extends Handler_Protected { $sth->execute([$mark, $id, $_SESSION['uid']]); - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARKED, [$id]); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, [$id]); print json_encode(array("message" => "UPDATE_COUNTERS")); } @@ -95,7 +95,7 @@ class RPC extends Handler_Protected { $sth->execute([$pub, $id, $_SESSION['uid']]); - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISHED, [$id]); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$id]); print json_encode(array("message" => "UPDATE_COUNTERS")); } @@ -273,7 +273,7 @@ class RPC extends Handler_Protected { $sth->execute([...$ids, $_SESSION['uid']]); - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARKED, $ids); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $ids); } /** @@ -299,7 +299,7 @@ class RPC extends Handler_Protected { $sth->execute([...$ids, $_SESSION['uid']]); - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISHED, $ids); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $ids); } function log(): void { diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index eef0d8540..575a1eda1 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -1126,10 +1126,10 @@ class RSSUtils { $published, $score]); if ($marked) - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARKED, [$ref_id]); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, [$ref_id]); if ($published) - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISHED, [$ref_id]); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]); $sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ? AND -- cgit v1.2.3-54-g00ecf From 5256edd484d6d3efdd870fd04d09c289e2d23c61 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 21:51:07 +0300 Subject: schema - spaces to tabs --- sql/pgsql/migrations/150.sql | 10 +++++----- sql/pgsql/schema.sql | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sql/pgsql/migrations/150.sql b/sql/pgsql/migrations/150.sql index c0aae3bdf..19f3aaa3a 100644 --- a/sql/pgsql/migrations/150.sql +++ b/sql/pgsql/migrations/150.sql @@ -1,6 +1,6 @@ create table ttrss_scheduled_tasks( - id serial not null primary key, - task_name varchar(250) unique not null, - last_duration integer not null, - last_rc integer not null, - last_run timestamp not null default NOW()); + id serial not null primary key, + task_name varchar(250) unique not null, + last_duration integer not null, + last_rc integer not null, + last_run timestamp not null default NOW()); diff --git a/sql/pgsql/schema.sql b/sql/pgsql/schema.sql index 7e995f915..3145629fc 100644 --- a/sql/pgsql/schema.sql +++ b/sql/pgsql/schema.sql @@ -397,9 +397,9 @@ create table ttrss_error_log( create table ttrss_scheduled_tasks( id serial not null primary key, - task_name varchar(250) unique not null, - last_duration integer not null, - last_rc integer not null, - last_run timestamp not null default NOW()); + task_name varchar(250) unique not null, + last_duration integer not null, + last_rc integer not null, + last_run timestamp not null default NOW()); commit; -- cgit v1.2.3-54-g00ecf From d5d15072e193ba96f156fea3e32bcb0af96b7b63 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 22:51:07 +0300 Subject: move scheduled tasks to a separate class, add some try-catches, improve/shorten logging and descriptions --- classes/PluginHost.php | 97 -------------------------------------- classes/RSSUtils.php | 21 +++++---- classes/Scheduler.php | 124 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 106 deletions(-) create mode 100644 classes/Scheduler.php diff --git a/classes/PluginHost.php b/classes/PluginHost.php index 5cff4afcb..f61a5a9a4 100644 --- a/classes/PluginHost.php +++ b/classes/PluginHost.php @@ -32,9 +32,6 @@ class PluginHost { /** @var array> */ private array $plugin_actions = []; - /** @var array */ - private array $scheduled_tasks = []; - private ?int $owner_uid = null; private bool $data_loaded = false; @@ -910,98 +907,4 @@ class PluginHost { return basename(dirname(dirname($ref->getFileName()))) == "plugins.local"; } - /** - * Adds a backend scheduled task which will be executed by updater (if due) when idle during - * RSSUtils::housekeeping_common(). - * - * The granularity is not strictly guaranteed, housekeeping is invoked several times per hour - * depending on how fast feed batch was processed, but no more than once per minute. - * - * Tasks are not run in user context and are available to system plugins only. Task names may not - * overlap. - * - * Tasks should return an integer value (return code) which is stored in the database, a value of - * 0 is considered successful. - * - * @param string $task_name unique name for this task, plugins should prefix this with plugin name - * @param string $cron_expression schedule for this task in cron format - * @param Closure $callback task code that gets executed - */ - function add_scheduled_task(string $task_name, string $cron_expression, Closure $callback) : bool { - $task_name = strtolower($task_name); - - if (isset($this->scheduled_tasks[$task_name])) { - user_error("Attempted to override already registered scheduled task $task_name", E_USER_WARNING); - return false; - } else { - $cron = new Cron\CronExpression($cron_expression); - - $this->scheduled_tasks[$task_name] = [ - "cron" => $cron, - "callback" => $callback, - ]; - return true; - } - } - - /** - * Execute scheduled tasks which are due to run and record last run timestamps. - * @return void - */ - function run_due_tasks() { - Debug::log('Processing all scheduled tasks...'); - - $tasks_run = 0; - - foreach ($this->scheduled_tasks as $task_name => $task) { - $last_run = '1970-01-01 00:00'; - - $task_record = ORM::for_table('ttrss_scheduled_tasks') - ->where('task_name', $task_name) - ->find_one(); - - if ($task_record) - $last_run = $task_record->last_run; - - Debug::log("Checking scheduled task: $task_name, last run: $last_run"); - - // because we don't schedule tasks every minute, we assume that task is due if its - // next estimated run based on previous timestamp is in the past - if ($task['cron']->getNextRunDate($last_run)->getTimestamp() - time() < 0) { - Debug::log("Task $task_name is due, executing..."); - - $task_started = time(); - $rc = (int) $task['callback'](); - $task_duration = time() - $task_started; - - ++$tasks_run; - - Debug::log("Task $task_name has finished in $task_duration seconds with RC=$rc, recording timestamp..."); - - if ($task_record) { - $task_record->last_run = Db::NOW(); - $task_record->last_duration = $task_duration; - $task_record->last_rc = $rc; - - $task_record->save(); - } else { - $task_record = ORM::for_table('ttrss_scheduled_tasks')->create(); - - $task_record->set([ - 'task_name' => $task_name, - 'last_duration' => $task_duration, - 'last_rc' => $rc, - 'last_run' => Db::NOW(), - ]); - - $task_record->save(); - } - } - } - - Debug::log("Finished with $tasks_run tasks executed."); - } - - // TODO implement some sort of automatic cleanup for orphan task execution records - } diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index 575a1eda1..bf05e0b98 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -1715,7 +1715,9 @@ class RSSUtils { static function init_housekeeping_tasks() : void { Debug::log('Registering scheduled tasks for housekeeping...'); - PluginHost::getInstance()->add_scheduled_task('purge_orphans', '@daily', + $scheduler = Scheduler::getInstance(); + + $scheduler->add_scheduled_task('purge_orphans', '@daily', function() { Article::_purge_orphans(); @@ -1723,7 +1725,7 @@ class RSSUtils { } ); - PluginHost::getInstance()->add_scheduled_task('disk_cache_expire_all', '@daily', + $scheduler->add_scheduled_task('disk_cache_expire_all', '@daily', function() { $cache = DiskCache::instance(""); $cache->expire_all(); @@ -1732,7 +1734,7 @@ class RSSUtils { } ); - PluginHost::getInstance()->add_scheduled_task('expire_error_log', '@hourly', + $scheduler->add_scheduled_task('expire_error_log', '@hourly', function() { self::expire_error_log(); @@ -1740,7 +1742,7 @@ class RSSUtils { } ); - PluginHost::getInstance()->add_scheduled_task('expire_lock_files', '@hourly', + $scheduler->add_scheduled_task('expire_lock_files', '@hourly', function() { self::expire_lock_files(); @@ -1748,7 +1750,7 @@ class RSSUtils { } ); - PluginHost::getInstance()->add_scheduled_task('disable_failed_feeds', '@daily', + $scheduler->add_scheduled_task('disable_failed_feeds', '@daily', function() { self::disable_failed_feeds(); @@ -1756,7 +1758,7 @@ class RSSUtils { } ); - PluginHost::getInstance()->add_scheduled_task('migrate_feed_icons', '@daily', + $scheduler->add_scheduled_task('migrate_feed_icons', '@daily', function() { self::migrate_feed_icons(); @@ -1764,7 +1766,7 @@ class RSSUtils { } ); - PluginHost::getInstance()->add_scheduled_task('cleanup_feed_icons', '@daily', + $scheduler->add_scheduled_task('cleanup_feed_icons', '@daily', function() { self::cleanup_feed_icons(); @@ -1772,7 +1774,7 @@ class RSSUtils { } ); - PluginHost::getInstance()->add_scheduled_task('send_headlines_digests', '@hourly', + $scheduler->add_scheduled_task('send_headlines_digests', '@hourly', function() { Digest::send_headlines_digests(); @@ -1782,7 +1784,8 @@ class RSSUtils { } static function housekeeping_common(): void { - PluginHost::getInstance()->run_due_tasks(); + Scheduler::getInstance()->run_due_tasks(); + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); } diff --git a/classes/Scheduler.php b/classes/Scheduler.php new file mode 100644 index 000000000..f0ca38303 --- /dev/null +++ b/classes/Scheduler.php @@ -0,0 +1,124 @@ + */ + private array $scheduled_tasks = []; + + public static function getInstance(): Scheduler { + if (self::$instance == null) + self::$instance = new self(); + + return self::$instance; + } + + /** + * Adds a backend scheduled task which will be executed by updater (if due) during housekeeping. + * + * The granularity is not strictly guaranteed, housekeeping is invoked several times per hour + * depending on how fast feed batch was processed, but no more than once per minute. + * + * Tasks do not run in user context. Task names may not overlap. Plugins should register tasks + * via PluginHost methods (to be implemented later). + * + * Tasks should return an integer value (return code) which is stored in the database, a value of + * 0 is considered successful. + * + * @param string $task_name unique name for this task, plugins should prefix this with plugin name + * @param string $cron_expression schedule for this task in cron format + * @param Closure $callback task code that gets executed + */ + function add_scheduled_task(string $task_name, string $cron_expression, Closure $callback) : bool { + $task_name = strtolower($task_name); + + if (isset($this->scheduled_tasks[$task_name])) { + user_error("Attempted to override already registered scheduled task $task_name", E_USER_WARNING); + return false; + } else { + try { + $cron = new Cron\CronExpression($cron_expression); + } catch (InvalidArgumentException $e) { + user_error("Attempt to register scheduled task $task_name failed: " . $e->getMessage(), E_USER_WARNING); + return false; + } + + $this->scheduled_tasks[$task_name] = [ + "cron" => $cron, + "callback" => $callback, + ]; + return true; + } + } + + /** + * Execute scheduled tasks which are due to run and record last run timestamps. + */ + function run_due_tasks() : void { + Debug::log('Processing all scheduled tasks...'); + + $tasks_succeeded = 0; + $tasks_failed = 0; + + foreach ($this->scheduled_tasks as $task_name => $task) { + $task_record = ORM::for_table('ttrss_scheduled_tasks') + ->where('task_name', $task_name) + ->find_one(); + + if ($task_record) + $last_run = $task_record->last_run; + else + $last_run = '1970-01-01 00:00'; + + // because we don't schedule tasks every minute, we assume that task is due if its + // next estimated run based on previous timestamp is in the past + if ($task['cron']->getNextRunDate($last_run)->getTimestamp() - time() < 0) { + Debug::log("Task $task_name is due, executing..."); + + $task_started = time(); + + try { + $rc = (int) $task['callback'](); + } catch (Exception $e) { + user_error("Scheduled task $task_name failed with exception: " . $e->getMessage(), E_USER_WARNING); + + $rc = self::TASK_RC_EXCEPTION; + } + + $task_duration = time() - $task_started; + + if ($rc === 0) { + ++$tasks_succeeded; + Debug::log("Task $task_name has finished in $task_duration seconds."); + } else { + $tasks_failed++; + Debug::log("Task $task_name has failed with RC: $rc after $task_duration seconds."); + } + + if ($task_record) { + $task_record->last_run = Db::NOW(); + $task_record->last_duration = $task_duration; + $task_record->last_rc = $rc; + + $task_record->save(); + } else { + $task_record = ORM::for_table('ttrss_scheduled_tasks')->create(); + + $task_record->set([ + 'task_name' => $task_name, + 'last_duration' => $task_duration, + 'last_rc' => $rc, + 'last_run' => Db::NOW(), + ]); + + $task_record->save(); + } + } + } + + Debug::log("Finished with $tasks_succeeded tasks succeeded and $tasks_failed tasks failed."); + } + + // TODO implement some sort of automatic cleanup for orphan task execution records +} \ No newline at end of file -- cgit v1.2.3-54-g00ecf From 55bb464cc9dbb417a144fff3862aa0abddc02c0b Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 23:24:09 +0300 Subject: update static composer autoload --- vendor/composer/autoload_classmap.php | 1 + vendor/composer/autoload_static.php | 1 + 2 files changed, 2 insertions(+) diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php index 762a8d99e..84b008687 100644 --- a/vendor/composer/autoload_classmap.php +++ b/vendor/composer/autoload_classmap.php @@ -990,6 +990,7 @@ return array( 'RPC' => $baseDir . '/classes/RPC.php', 'RSSUtils' => $baseDir . '/classes/RSSUtils.php', 'Sanitizer' => $baseDir . '/classes/Sanitizer.php', + 'Scheduler' => $baseDir . '/classes/Scheduler.php', 'SebastianBergmann\\CliParser\\AmbiguousOptionException' => $vendorDir . '/sebastian/cli-parser/src/exceptions/AmbiguousOptionException.php', 'SebastianBergmann\\CliParser\\Exception' => $vendorDir . '/sebastian/cli-parser/src/exceptions/Exception.php', 'SebastianBergmann\\CliParser\\OptionDoesNotAllowArgumentException' => $vendorDir . '/sebastian/cli-parser/src/exceptions/OptionDoesNotAllowArgumentException.php', diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 3fcb34b42..5c3e486a2 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -1131,6 +1131,7 @@ class ComposerStaticInit19fc2ff1c0f9a92279c7979386bb2056 'RPC' => __DIR__ . '/../..' . '/classes/RPC.php', 'RSSUtils' => __DIR__ . '/../..' . '/classes/RSSUtils.php', 'Sanitizer' => __DIR__ . '/../..' . '/classes/Sanitizer.php', + 'Scheduler' => __DIR__ . '/../..' . '/classes/Scheduler.php', 'SebastianBergmann\\CliParser\\AmbiguousOptionException' => __DIR__ . '/..' . '/sebastian/cli-parser/src/exceptions/AmbiguousOptionException.php', 'SebastianBergmann\\CliParser\\Exception' => __DIR__ . '/..' . '/sebastian/cli-parser/src/exceptions/Exception.php', 'SebastianBergmann\\CliParser\\OptionDoesNotAllowArgumentException' => __DIR__ . '/..' . '/sebastian/cli-parser/src/exceptions/OptionDoesNotAllowArgumentException.php', -- cgit v1.2.3-54-g00ecf From 997c10437e23552285e450f389e8a84ec4b04f5e Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Fri, 2 May 2025 23:26:13 +0300 Subject: reorder housekeeping tasks by interval --- classes/RSSUtils.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index bf05e0b98..a051a7dc2 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -1734,41 +1734,41 @@ class RSSUtils { } ); - $scheduler->add_scheduled_task('expire_error_log', '@hourly', + $scheduler->add_scheduled_task('disable_failed_feeds', '@daily', function() { - self::expire_error_log(); + self::disable_failed_feeds(); return 0; } ); - $scheduler->add_scheduled_task('expire_lock_files', '@hourly', + $scheduler->add_scheduled_task('migrate_feed_icons', '@daily', function() { - self::expire_lock_files(); + self::migrate_feed_icons(); return 0; } ); - $scheduler->add_scheduled_task('disable_failed_feeds', '@daily', + $scheduler->add_scheduled_task('cleanup_feed_icons', '@daily', function() { - self::disable_failed_feeds(); + self::cleanup_feed_icons(); return 0; } ); - $scheduler->add_scheduled_task('migrate_feed_icons', '@daily', + $scheduler->add_scheduled_task('expire_error_log', '@hourly', function() { - self::migrate_feed_icons(); + self::expire_error_log(); return 0; } ); - $scheduler->add_scheduled_task('cleanup_feed_icons', '@daily', + $scheduler->add_scheduled_task('expire_lock_files', '@hourly', function() { - self::cleanup_feed_icons(); + self::expire_lock_files(); return 0; } -- cgit v1.2.3-54-g00ecf From 4cda1da5c0c511fb2938d8b3683cbef1d75377da Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Sat, 3 May 2025 07:55:16 +0300 Subject: adjust scheduler logging to be somewhat more alike to feed updater --- classes/Scheduler.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/classes/Scheduler.php b/classes/Scheduler.php index f0ca38303..fd6301641 100644 --- a/classes/Scheduler.php +++ b/classes/Scheduler.php @@ -74,7 +74,7 @@ class Scheduler { // because we don't schedule tasks every minute, we assume that task is due if its // next estimated run based on previous timestamp is in the past if ($task['cron']->getNextRunDate($last_run)->getTimestamp() - time() < 0) { - Debug::log("Task $task_name is due, executing..."); + Debug::log("=> Scheduled task $task_name is due, executing..."); $task_started = time(); @@ -90,10 +90,10 @@ class Scheduler { if ($rc === 0) { ++$tasks_succeeded; - Debug::log("Task $task_name has finished in $task_duration seconds."); + Debug::log("<= Scheduled task $task_name has finished in $task_duration seconds."); } else { $tasks_failed++; - Debug::log("Task $task_name has failed with RC: $rc after $task_duration seconds."); + Debug::log("!! Scheduled task $task_name has failed with RC: $rc after $task_duration seconds."); } if ($task_record) { @@ -117,7 +117,7 @@ class Scheduler { } } - Debug::log("Finished with $tasks_succeeded tasks succeeded and $tasks_failed tasks failed."); + Debug::log("Processing scheduled tasks finished with $tasks_succeeded tasks succeeded and $tasks_failed tasks failed."); } // TODO implement some sort of automatic cleanup for orphan task execution records -- cgit v1.2.3-54-g00ecf From 01159fa6f8c2421457297914dd42039bc12b826e Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Sat, 3 May 2025 08:18:16 +0300 Subject: error handler - dump caught exception/fatal error to debug log if running under CLI SAPI --- include/errorhandler.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/include/errorhandler.php b/include/errorhandler.php index c155c9989..2b6d51a7d 100644 --- a/include/errorhandler.php +++ b/include/errorhandler.php @@ -49,6 +49,11 @@ function ttrss_error_handler(int $errno, string $errstr, string $file, int $line $context = format_backtrace(debug_backtrace()); $errstr = truncate_middle($errstr, 16384, " (...) "); + if (php_sapi_name() == 'cli' && class_exists("Debug")) { + Debug::log("!! Exception: $errstr ($file:$line)"); + Debug::log($context); + } + if (class_exists("Logger")) return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context); else @@ -70,6 +75,11 @@ function ttrss_fatal_handler(): bool { $file = substr(str_replace(dirname(__DIR__), "", $file), 1); + if (php_sapi_name() == 'cli' && class_exists("Debug")) { + Debug::log("!! Fatal error: $errstr ($file:$line)"); + Debug::log($context); + } + if (class_exists("Logger")) return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context); } -- cgit v1.2.3-54-g00ecf