feat: email notice

This commit is contained in:
Baoshuo Ren 2023-02-13 20:29:32 +08:00
parent b7428e1c85
commit 8c189fd9eb
Signed by: baoshuo
GPG Key ID: 00CB9680AB29F51A
60 changed files with 13962 additions and 85 deletions

View File

@ -429,6 +429,34 @@ LOCK TABLES `custom_test_submissions` WRITE;
/*!40000 ALTER TABLE `custom_test_submissions` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `emails`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `emails` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`receiver` varchar(20) NOT NULL,
`subject` varchar(100) NOT NULL,
`content` text NOT NULL,
`created_at` datetime NOT NULL,
`send_time` datetime DEFAULT NULL,
`priority` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `send_time` (`send_time`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `emails`
--
LOCK TABLES `emails` WRITE;
/*!40000 ALTER TABLE `emails` DISABLE KEYS */;
/*!40000 ALTER TABLE `emails` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `friend_links`
--

View File

@ -10,65 +10,79 @@ require $_SERVER['DOCUMENT_ROOT'] . '/app/libs/uoj-lib.php';
$handlers = [
'upgrade:up' => function ($name) {
if (func_num_args() != 1) {
die("php7.4 cli.php upgrade:up <name>\n");
print("php cli.php upgrade:up <name>\n");
exit(1);
}
Upgrader::transaction(function() use ($name) {
Upgrader::transaction(function () use ($name) {
Upgrader::up($name);
});
die("finished!\n");
print("finished!\n");
},
'upgrade:down' => function ($name) {
if (func_num_args() != 1) {
die("php7.4 cli.php upgrade:down <name>\n");
print("php cli.php upgrade:down <name>\n");
exit(1);
}
Upgrader::transaction(function() use ($name) {
Upgrader::transaction(function () use ($name) {
Upgrader::down($name);
});
die("finished!\n");
print("finished!\n");
},
'upgrade:refresh' => function ($name) {
if (func_num_args() != 1) {
die("php7.4 cli.php upgrade:refresh <name>\n");
print("php cli.php upgrade:refresh <name>\n");
exit(1);
}
Upgrader::transaction(function() use ($name) {
Upgrader::transaction(function () use ($name) {
Upgrader::refresh($name);
});
die("finished!\n");
print("finished!\n");
},
'upgrade:remove' => function ($name) {
if (func_num_args() != 1) {
die("php7.4 cli.php upgrade:remove <name>\n");
print("php cli.php upgrade:remove <name>\n");
exit(1);
}
Upgrader::transaction(function() use ($name) {
Upgrader::transaction(function () use ($name) {
Upgrader::remove($name);
});
die("finished!\n");
print("finished!\n");
},
'upgrade:latest' => function () {
if (func_num_args() != 0) {
die("php7.4 cli.php upgrade:latest\n");
print("php cli.php upgrade:latest\n");
exit(1);
}
Upgrader::transaction(function() {
Upgrader::transaction(function () {
Upgrader::upgradeToLatest();
});
die("finished!\n");
print("finished!\n");
},
'upgrade:remove-all' => function () {
if (func_num_args() != 0) {
die("php7.4 cli.php upgrade:remove-all\n");
print("php cli.php upgrade:remove-all\n");
exit(1);
}
Upgrader::transaction(function() {
Upgrader::transaction(function () {
Upgrader::removeAll();
});
die("finished!\n");
print("finished!\n");
},
'help' => 'showHelp'
'email:send-all' => function () {
if (func_num_args() != 0) {
print("php cli.php email:send-all\n");
exit(1);
}
UOJMail::cronSendEmail();
print("finished!\n");
},
'help' => 'showHelp',
];
function showHelp() {
global $handlers;
echo "UOJ Command-Line Interface\n";
echo "php7.4 cli.php <task-name> params1 params2 ...\n";
echo "php cli.php <task-name> params1 params2 ...\n";
echo "\n";
echo "The following tasks are available:\n";
foreach ($handlers as $cmd => $handler) {

View File

@ -6,7 +6,8 @@
"erusev/parsedown": "^1.7",
"php-curl-class/php-curl-class": "^9.13",
"ext-dom": "20031129",
"ivopetkov/html5-dom-document-php": "2.*"
"ivopetkov/html5-dom-document-php": "2.*",
"peppeocchi/php-cron-scheduler": "^4.0"
},
"autoload": {
"classmap": [

View File

@ -46,7 +46,7 @@ $forgot_form->handle = function (&$vdata) {
unset($_SESSION['phrase']);
if (!$user['email']) {
becomeMsgPage('用户未填写邮件地址,请联系管理员重置');
becomeMsgPage('用户未填写邮件地址,请联系管理员重置密码');
}
$oj_name = UOJConfig::$data['profile']['oj-name'];
@ -54,7 +54,6 @@ $forgot_form->handle = function (&$vdata) {
$check_code = md5($user['username'] . "+" . $password . '+' . UOJTime::$time_now_str);
$sufs = base64url_encode($user['username'] . "." . $check_code);
$url = HTML::url("/reset_password", ['params' => ['p' => $sufs]]);
$oj_url = HTML::url('/');
$name = $user['username'];
$remote_addr = UOJContext::remoteAddr();
$http_x_forwarded_for = UOJContext::httpXForwardedFor();
@ -64,53 +63,28 @@ $forgot_form->handle = function (&$vdata) {
$name .= ' (' . $user['realname'] . ')';
}
$html = <<<EOD
<base target="_blank" />
sendEmail($user['username'], $oj_name_short . ' 密码找回', <<<EOD
<p>您最近告知我们需要重置您在 {$oj_name_short} 上账号的密码。请访问以下链接:<a href="{$url}">{$url}</a> (如果无法点击链接,请试着复制链接并粘贴至浏览器中打开。)</p>
<p>如果您没有请求重置密码,则忽略此信息。该链接将在 72 小时后自动过期失效。</p>
<p>{$name} 您好,</p>
<ul>
<li>请求 IP: {$remote_addr}</li>
<li>转发源 IP:{$http_x_forwarded_for} </li>
<li>用户代理: {$user_agent}</li>
</ul>
EOD);
<p>您最近告知我们需要重置您在 {$oj_name_short} 上账号的密码。请访问以下链接:<a href="{$url}">{$url}</a> (如果无法点击链接,请试着复制链接并粘贴至浏览器中打开。)</p>
<p>如果您没有请求重置密码,则忽略此信息。该链接将在 72 小时后自动过期失效。</p>
DB::update([
"update user_info",
"set", [
'extra' => DB::json_set('extra', '$.reset_password_check_code', $check_code, '$.reset_password_time', UOJTime::$time_now_str),
],
"where", [
"username" => $user['username'],
],
]);
<ul>
<li><small>请求 IP: {$remote_addr} (转发来源: {$http_x_forwarded_for})</small></li>
<li><small>用户代理: {$user_agent}</small></li>
</ul>
<p>{$oj_name}</p>
<p><a href="{$oj_url}">{$oj_url}</a></p>
EOD;
$mailer = UOJMail::noreply();
$mailer->addAddress($user['email'], $user['username']);
$mailer->Subject = $oj_name_short . " 密码找回";
$mailer->msgHTML($html);
$res = retry_loop(function () use (&$mailer) {
$res = $mailer->send();
if ($res) return true;
UOJLog::error($mailer->ErrorInfo);
return false;
});
if (!$res) {
becomeMsgPage('<div class="text-center"><h2>邮件发送失败,请重试!</h2><a href="">返回</a></div>');
} else {
DB::update([
"update user_info",
"set", [
'extra' => DB::json_set('extra', '$.reset_password_check_code', $check_code, '$.reset_password_time', UOJTime::$time_now_str),
],
"where", [
"username" => $user['username'],
],
]);
becomeMsgPage('<div class="text-center"><h2>邮件发送成功,请检查收件箱!</h2><span>如果邮件未出现在收件箱中,请检查垃圾箱。</span></div>');
}
becomeMsgPage('<div class="text-center"><h2>邮件已发送,请检查收件箱!</h2><span>如果邮件未出现在收件箱中,请检查垃圾箱。</span></div>');
};
$forgot_form->runAtServer();
?>

View File

@ -42,6 +42,22 @@ function handleLoginPost() {
}
Auth::login($user['username']);
$remote_addr = UOJContext::remoteAddr();
$http_x_forwarded_for = UOJContext::httpXForwardedFor();
$user_agent = UOJContext::httpUserAgent();
sendEmail($user['username'], '新登录', <<<EOD
<p>您收到这封邮件是因为有人通过以下方式登录了您的帐户:</p>
<ul>
<li>请求 IP: {$remote_addr}</li>
<li>转发源 IP:{$http_x_forwarded_for} </li>
<li>用户代理: {$user_agent}</li>
</ul>
<p>如果这是您进行的登录操作,请忽略此邮件。如果您没有进行过登录操作,请立即重置您账号的密码。</p>
EOD);
return "ok";
}

View File

@ -153,7 +153,7 @@ $reply_form->handle = function (&$vdata) {
}
if ($blog['poster'] !== Auth::id() && !in_array($blog['poster'], $notified)) {
$notified[] = $blog['poster'];
$content = $user_link . '回复了您的博客 ' . $blog['title'] . ' <a href="' . $uri . '">点击此处查看</a>';
$content = $user_link . ' 回复了您的博客 ' . $blog['title'] . ' <a href="' . $uri . '">点击此处查看</a>';
sendSystemMsg($blog['poster'], '博客新回复通知', $content);
}
@ -195,8 +195,7 @@ if (UOJUserBlog::userHasManagePermission(Auth::user())) {
sendSystemMsg(
$comment->info['poster'],
'评论隐藏通知',
"<p>" . UOJUser::getLink($comment->info['poster'], ['color' => false]) . " 您好:</p>" .
"<p>您为博客 " . UOJBlog::cur()->getLink() . " 回复的评论 “" . substr($comment->info['content'], 0, 30) . "……” 已被管理员隐藏,隐藏原因为 “{$reason}”。</p>"
"您为博客 " . UOJBlog::cur()->getLink() . " 回复的评论 “" . substr($comment->info['content'], 0, 30) . "……” 已被管理员隐藏,隐藏原因为 “{$reason}”。"
);
}
};

View File

@ -106,12 +106,9 @@ function getLongTablePageRawUri($page) {
unset($param['page']);
}
if ($param) {
return $path . '?' . http_build_query($param);
} else {
return $path;
}
return HTML::url($path, ['params' => $param]);
}
function getLongTablePageUri($page) {
return HTML::escape(getLongTablePageRawUri($page));
}

View File

@ -206,6 +206,16 @@ function sendSystemMsg($username, $title, $content) {
"(receiver, title, content, send_time)",
"values", DB::tuple([$username, $title, $content, DB::now()])
]);
sendEmail($username, $title, $content);
}
function sendEmail($username, $title, $content) {
DB::insert([
"insert into emails",
"(receiver, subject, content, created_at)",
"values", DB::tuple([$username, $title, $content, DB::now()])
]);
}
function retry_loop(callable $f, $retry = 5, $ms = 10) {

View File

@ -66,12 +66,9 @@ class UOJContest {
calcStandings($contest, $data, $score, $standings, ['update_contests_submissions' => true]);
for ($i = 0; $i < count($standings); $i++) {
$user_link = UOJUser::getLink($standings[$i][2][0], ['color' => false]);
$tail = $standings[$i][0] == $total_score ? ',请继续保持。' : ',请继续努力!';
$content = '<p>' . $user_link . ' 您好:</p>';
$content .= '<p>' . '您参与的比赛 <a href="/contest/' . $contest['id'] . '">' . $contest['name'] . '</a> 现已公布成绩,您的成绩为 <a class="uoj-score" data-max="' . $total_score . '">' . $standings[$i][0] . '</a>' . $tail . '</p>';
sendSystemMsg($standings[$i][2][0], '比赛成绩公布通知', $content);
sendSystemMsg($standings[$i][2][0], '比赛成绩公布通知', '您参与的比赛 <a href="' . HTML::url('/contest/' . $contest['id']) . '">' . $contest['name'] . '</a> 现已公布成绩,您的成绩为 <a class="uoj-score" data-max="' . $total_score . '">' . $standings[$i][0] . '</a>' . $tail);
DB::update([
"update contests_registrants",
"set", ["final_rank" => $standings[$i][3]],

View File

@ -4,7 +4,7 @@ use PHPMailer\PHPMailer\PHPMailer;
class UOJMail {
public static function noreply() {
$mailer = new PHPMailer();
$mailer = new PHPMailer();
$mailer->isSMTP();
$mailer->Host = UOJConfig::$data['mail']['noreply']['host'];
$mailer->Port = UOJConfig::$data['mail']['noreply']['port'];
@ -12,9 +12,91 @@ class UOJMail {
$mailer->SMTPSecure = UOJConfig::$data['mail']['noreply']['secure'];
$mailer->Username = UOJConfig::$data['mail']['noreply']['username'];
$mailer->Password = UOJConfig::$data['mail']['noreply']['password'];
$mailer->setFrom(UOJConfig::$data['mail']['noreply']['username'], "UOJ noreply");
$mailer->setFrom(UOJConfig::$data['mail']['noreply']['username'], UOJConfig::$data['profile']['oj-name-short']);
$mailer->CharSet = "utf-8";
$mailer->Encoding = "base64";
return $mailer;
}
public static function cronSendEmail() {
$emails = DB::selectAll([
"select * from emails",
"where", DB::land([
["created_at", ">=", DB::raw("addtime(now(), '-24:00:00')")],
"send_time" => null,
]),
"order by priority desc",
]);
$oj_name = UOJConfig::$data['profile']['oj-name'];
$oj_name_short = UOJConfig::$data['profile']['oj-name-short'];
$oj_url = HTML::url('/');
$oj_logo_url = HTML::url('/images/logo_small.png');
foreach ($emails as $email) {
$user = UOJUser::query($email['receiver']);
$name = $user['username'];
if ($user['realname']) {
$name .= ' (' . $user['realname'] . ')';
}
if ($user['email']) {
$mailer = UOJMail::noreply();
$mailer->addAddress($user['email'], $user['username']);
$mailer->Subject = $email['subject'];
$mailer->msgHTML(<<<EOD
<base target="_blank" />
<div style="padding: 48px; margin: 60px auto 60px auto; box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.15), inset 0px 0px 1px rgba(0, 0, 0, 0.5); max-width: 700px">
<div style="display: block">
<div style="font-size: 20px; font-weight: bold; display: inline-block">{$oj_name_short}</div>
<img style="float: right" src="{$oj_logo_url}" height="32" width="32" />
</div>
<hr />
<br />
<h1><center>{$email['subject']}</center></h1>
<div style="font-size: 18px">{$name} 您好,</div>
<br />
<div>
{$email['content']}
</div>
<br />
<br />
<div style="text-align: right;">
<a href="{$oj_url}">{$oj_name}</a>
</div>
<hr />
<div style="font-size: 12px; color: grey; text-align: center;">
您之所以收到本邮件,是因为您是 {$oj_name} 的用户。
<br />
本邮件由系统自动发送,请勿回复。
</div>
</div>
EOD);
$res = retry_loop(function () use (&$mailer) {
$res = $mailer->send();
if ($res) return true;
UOJLog::error($mailer->ErrorInfo);
return false;
});
if ($res) {
DB::update("update emails set send_time = now() where id = {$email['id']}");
echo '[UOJMail::cronSendEmail] ID: ' . $email['id'] . ' sent.' . "\n";
}
}
}
echo '[UOJMail::cronSendEmail] Done.' . "\n";
}
}

31
web/app/scheduler.php Normal file
View File

@ -0,0 +1,31 @@
<?php
$_SERVER['DOCUMENT_ROOT'] = dirname(__DIR__);
require_once $_SERVER['DOCUMENT_ROOT'] . '/app/vendor/autoload.php';
require $_SERVER['DOCUMENT_ROOT'] . '/app/libs/uoj-lib.php';
// Create a new scheduler
$scheduler = new GO\Scheduler([
'tempDir' => '/tmp'
]);
echo '[UOJScheduler] Init', "\n";
// =========== JOBS ===========
// Email
$scheduler->call('UOJMail::cronSendEmail', [], 'cronSendEmail')
->at('* * * * *')
->onlyOne()
->before(function () {
echo "[cronSendEmail] started at " . time() . "\n";
})
->then(function () {
echo "[cronSendEmail] ended at " . time() . "\n";
});
// ============================
// Let the scheduler execute jobs which are due.
$scheduler->run();

View File

@ -6,9 +6,12 @@ $vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);
return array(
'Webmozart\\Assert\\' => array($vendorDir . '/webmozart/assert/src'),
'Symfony\\Polyfill\\Php80\\' => array($vendorDir . '/symfony/polyfill-php80'),
'Symfony\\Component\\Finder\\' => array($vendorDir . '/symfony/finder'),
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
'Gregwar\\' => array($vendorDir . '/gregwar/captcha/src/Gregwar'),
'GO\\' => array($vendorDir . '/peppeocchi/php-cron-scheduler/src/GO'),
'Curl\\' => array($vendorDir . '/php-curl-class/php-curl-class/src/Curl'),
'Cron\\' => array($vendorDir . '/dragonmantank/cron-expression/src/Cron'),
);

View File

@ -14,6 +14,10 @@ class ComposerStaticInit0d7c2cd5c2dbf2120e4372996869e900
);
public static $prefixLengthsPsr4 = array (
'W' =>
array (
'Webmozart\\Assert\\' => 17,
),
'S' =>
array (
'Symfony\\Polyfill\\Php80\\' => 23,
@ -26,14 +30,20 @@ class ComposerStaticInit0d7c2cd5c2dbf2120e4372996869e900
'G' =>
array (
'Gregwar\\' => 8,
'GO\\' => 3,
),
'C' =>
array (
'Curl\\' => 5,
'Cron\\' => 5,
),
);
public static $prefixDirsPsr4 = array (
'Webmozart\\Assert\\' =>
array (
0 => __DIR__ . '/..' . '/webmozart/assert/src',
),
'Symfony\\Polyfill\\Php80\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-php80',
@ -50,10 +60,18 @@ class ComposerStaticInit0d7c2cd5c2dbf2120e4372996869e900
array (
0 => __DIR__ . '/..' . '/gregwar/captcha/src/Gregwar',
),
'GO\\' =>
array (
0 => __DIR__ . '/..' . '/peppeocchi/php-cron-scheduler/src/GO',
),
'Curl\\' =>
array (
0 => __DIR__ . '/..' . '/php-curl-class/php-curl-class/src/Curl',
),
'Cron\\' =>
array (
0 => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron',
),
);
public static $prefixesPsr0 = array (

View File

@ -1,5 +1,69 @@
{
"packages": [
{
"name": "dragonmantank/cron-expression",
"version": "v3.3.2",
"version_normalized": "3.3.2.0",
"source": {
"type": "git",
"url": "https://github.com/dragonmantank/cron-expression.git",
"reference": "782ca5968ab8b954773518e9e49a6f892a34b2a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/782ca5968ab8b954773518e9e49a6f892a34b2a8",
"reference": "782ca5968ab8b954773518e9e49a6f892a34b2a8",
"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",
"phpstan/phpstan-webmozart-assert": "^1.0",
"phpunit/phpunit": "^7.0|^8.0|^9.0"
},
"time": "2022-09-10T18:51:20+00:00",
"type": "library",
"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.3.2"
},
"funding": [
{
"url": "https://github.com/dragonmantank",
"type": "github"
}
],
"install-path": "../dragonmantank/cron-expression"
},
{
"name": "erusev/parsedown",
"version": "1.7.4",
@ -213,6 +277,68 @@
},
"install-path": "../ivopetkov/html5-dom-document-php"
},
{
"name": "peppeocchi/php-cron-scheduler",
"version": "v4.0",
"version_normalized": "4.0.0.0",
"source": {
"type": "git",
"url": "https://github.com/peppeocchi/php-cron-scheduler.git",
"reference": "0acfa032e60f0ea22a27b96a6b15a673a31d3448"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/peppeocchi/php-cron-scheduler/zipball/0acfa032e60f0ea22a27b96a6b15a673a31d3448",
"reference": "0acfa032e60f0ea22a27b96a6b15a673a31d3448",
"shasum": ""
},
"require": {
"dragonmantank/cron-expression": "^3.0",
"php": "^7.3 || ^8.0"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.4",
"phpunit/phpunit": "~9.5",
"swiftmailer/swiftmailer": "~5.4 || ^6.0"
},
"suggest": {
"swiftmailer/swiftmailer": "Required to send the output of a job to email address/es (~5.4 || ^6.0)."
},
"time": "2021-04-22T21:32:03+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"GO\\": "src/GO/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Giuseppe Occhipinti",
"email": "peppeocchi@gmail.com"
},
{
"name": "Carsten Windler",
"email": "carsten@carstenwindler.de",
"homepage": "http://carstenwindler.de",
"role": "Contributor"
}
],
"description": "PHP Cron Job Scheduler",
"keywords": [
"cron job",
"scheduler"
],
"support": {
"issues": "https://github.com/peppeocchi/php-cron-scheduler/issues",
"source": "https://github.com/peppeocchi/php-cron-scheduler/tree/v4.0"
},
"install-path": "../peppeocchi/php-cron-scheduler"
},
{
"name": "php-curl-class/php-curl-class",
"version": "9.13.1",
@ -580,6 +706,67 @@
}
],
"install-path": "../symfony/polyfill-php80"
},
{
"name": "webmozart/assert",
"version": "1.11.0",
"version_normalized": "1.11.0.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"
},
"time": "2022-06-03T18:03:27+00:00",
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.10-dev"
}
},
"installation-source": "dist",
"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"
},
"install-path": "../webmozart/assert"
}
],
"dev": true,

View File

@ -5,7 +5,7 @@
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'reference' => 'aa6bf6a363501f9836d2bb26a513c2ac3985de83',
'reference' => '8314eb67602af0416e50f8514f64e1d9d1a3acd8',
'name' => '__root__',
'dev' => true,
),
@ -16,7 +16,16 @@
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'reference' => 'aa6bf6a363501f9836d2bb26a513c2ac3985de83',
'reference' => '8314eb67602af0416e50f8514f64e1d9d1a3acd8',
'dev_requirement' => false,
),
'dragonmantank/cron-expression' => array(
'pretty_version' => 'v3.3.2',
'version' => '3.3.2.0',
'type' => 'library',
'install_path' => __DIR__ . '/../dragonmantank/cron-expression',
'aliases' => array(),
'reference' => '782ca5968ab8b954773518e9e49a6f892a34b2a8',
'dev_requirement' => false,
),
'erusev/parsedown' => array(
@ -55,6 +64,21 @@
'reference' => '32c5ba748d661a9654c190bf70ce2854eaf5ad22',
'dev_requirement' => false,
),
'mtdowling/cron-expression' => array(
'dev_requirement' => false,
'replaced' => array(
0 => '^1.0',
),
),
'peppeocchi/php-cron-scheduler' => array(
'pretty_version' => 'v4.0',
'version' => '4.0.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../peppeocchi/php-cron-scheduler',
'aliases' => array(),
'reference' => '0acfa032e60f0ea22a27b96a6b15a673a31d3448',
'dev_requirement' => false,
),
'php-curl-class/php-curl-class' => array(
'pretty_version' => '9.13.1',
'version' => '9.13.1.0',
@ -100,5 +124,14 @@
'reference' => 'cfa0ae98841b9e461207c13ab093d76b0fa7bace',
'dev_requirement' => false,
),
'webmozart/assert' => array(
'pretty_version' => '1.11.0',
'version' => '1.11.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../webmozart/assert',
'aliases' => array(),
'reference' => '11cb2199493b2f8a3b53e7f19068fc6aac760991',
'dev_requirement' => false,
),
),
);

View File

@ -4,8 +4,8 @@
$issues = array();
if (!(PHP_VERSION_ID >= 70205)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 7.2.5". You are running ' . PHP_VERSION . '.';
if (!(PHP_VERSION_ID >= 70300)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 7.3.0". You are running ' . PHP_VERSION . '.';
}
if ($issues) {

View File

@ -0,0 +1,228 @@
# Change Log
## [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))

View File

@ -0,0 +1,19 @@
Copyright (c) 2011 Michael Dowling <mtdowling@gmail.com>, 2016 Chris Tankersley <chris@ctankersley.com>, 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.

View File

@ -0,0 +1,87 @@
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) [![Build Status](https://secure.travis-ci.org/dragonmantank/cron-expression.png)](http://travis-ci.org/dragonmantank/cron-expression) [![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
<?php
require_once '/vendor/autoload.php';
// Works with predefined scheduling definitions
$cron = new Cron\CronExpression('@daily');
$cron->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)
| | | +---------- month (1 - 12)
| | +--------------- day of month (1 - 31)
| +-------------------- hour (0 - 23)
+------------------------- min (0 - 59)
This library also supports a few macros:
* `@yearly`, `@annually` - Run once a year, midnight, Jan. 1 - `0 0 1 1 *`
* `@monthly` - Run once a month, midnight, first of month - `0 0 1 * *`
* `@weekly` - Run once a week, midnight on Sun - `0 0 * * 0`
* `@daily`, `@midnight` - Run once a day, midnight - `0 0 * * *`
* `@hourly` - Run once an hour, first minute - `0 * * * *`
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/lavary/crunz)

View File

@ -0,0 +1,47 @@
{
"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/phpstan-webmozart-assert": "^1.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"
},
"config": {
"allow-plugins": {
"ocramius/package-versions": true,
"phpstan/extension-installer": true
}
}
}

View File

@ -0,0 +1,15 @@
parameters:
checkMissingIterableValueType: false
ignoreErrors:
- '#Call to an undefined method DateTimeInterface::add\(\)#'
- '#Call to an undefined method DateTimeInterface::modify\(\)#'
- '#Call to an undefined method DateTimeInterface::setDate\(\)#'
- '#Call to an undefined method DateTimeInterface::setTime\(\)#'
- '#Call to an undefined method DateTimeInterface::setTimezone\(\)#'
- '#Call to an undefined method DateTimeInterface::sub\(\)#'
level: max
paths:
- src/

View File

@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace Cron;
use DateTimeInterface;
/**
* Abstract CRON expression field.
*/
abstract class AbstractField implements FieldInterface
{
/**
* Full range of values that are allowed for this field type.
*
* @var array
*/
protected $fullRange = [];
/**
* Literal values we need to convert to integers.
*
* @var array
*/
protected $literals = [];
/**
* Start value of the full range.
*
* @var int
*/
protected $rangeStart;
/**
* End value of the full range.
*
* @var int
*/
protected $rangeEnd;
/**
* Constructor
*/
public function __construct()
{
$this->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;
}
}

View File

@ -0,0 +1,568 @@
<?php
declare(strict_types=1);
namespace Cron;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Exception;
use InvalidArgumentException;
use LogicException;
use RuntimeException;
use Webmozart\Assert\Assert;
/**
* CRON expression parser that can determine whether or not a CRON expression is
* due to run, the next run date and previous run date of a CRON expression.
* The determinations made by this class are accurate if checked run once per
* minute (seconds are dropped from date time comparisons).
*
* Schedule parts must map to:
* minute [0-59], hour [0-23], day of month, month [1-12|JAN-DEC], day of week
* [1-7|MON-SUN], and an optional year.
*
* @see http://en.wikipedia.org/wiki/Cron
*/
class CronExpression
{
public const MINUTE = 0;
public const HOUR = 1;
public const DAY = 2;
public const MONTH = 3;
public const WEEKDAY = 4;
/** @deprecated */
public const YEAR = 5;
public const MAPPINGS = [
'@yearly' => '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<string, string>
*/
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<string, string>
*/
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
*/
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);
Assert::isArray($split);
$this->cronParts = $split;
if (\count($this->cronParts) < 5) {
throw new InvalidArgumentException(
$value . ' is not a valid CRON expression'
);
}
foreach ($this->cronParts as $position => $part) {
$this->setPart($position, $part);
}
return $this;
}
/**
* Set part of the CRON expression.
*
* @param int $position The position of the CRON expression to set
* @param string $value The value to set
*
* @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);
}
Assert::isInstanceOf($currentTime, DateTime::class);
$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);
}
Assert::isInstanceOf($currentTime, DateTime::class);
$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');
}
Assert::isInstanceOf($currentDate, DateTime::class);
$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();
}
}

View File

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Cron;
use DateTime;
use DateTimeInterface;
/**
* Day of month field. Allows: * , / - ? L W.
*
* 'L' stands for "last" and specifies the last day of the month.
*
* The 'W' character is used to specify the weekday (Monday-Friday) nearest the
* given day. As an example, if you were to specify "15W" as the value for the
* day-of-month field, 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. The 'W' character can only be specified when the
* day-of-month is a single day, not a range or list of days.
*
* @author Michael Dowling <mtdowling@gmail.com>
*/
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;
}
}

View File

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace Cron;
use DateTimeInterface;
use InvalidArgumentException;
/**
* Day of week field. Allows: * / , - ? L #.
*
* Days of the week can be represented as a number 0-7 (0|7 = Sunday)
* or as a three letter string: SUN, MON, TUE, WED, THU, FRI, SAT.
*
* 'L' stands for "last". It allows you to specify constructs such as
* "the last Friday" of a given month.
*
* '#' is allowed for the day-of-week field, and must be followed by a
* number between one and five. It allows you to specify constructs such as
* "the second Friday" of a given month.
*/
class DayOfWeekField extends AbstractField
{
/**
* {@inheritdoc}
*/
protected $rangeStart = 0;
/**
* {@inheritdoc}
*/
protected $rangeEnd = 7;
/**
* @var array Weekday range
*/
protected $nthRange;
/**
* {@inheritdoc}
*/
protected $literals = [1 => '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;
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Cron;
use InvalidArgumentException;
/**
* CRON field factory implementing a flyweight factory.
*
* @see http://en.wikipedia.org/wiki/Cron
*/
class FieldFactory implements FieldFactoryInterface
{
/**
* @var array Cache of instantiated fields
*/
private $fields = [];
/**
* Get an instance of a field object for a cron expression position.
*
* @param int $position CRON expression position value to retrieve
*
* @throws InvalidArgumentException if a position is not valid
*/
public function getField(int $position): FieldInterface
{
return $this->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'
);
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace Cron;
interface FieldFactoryInterface
{
public function getField(int $position): FieldInterface;
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Cron;
use DateTimeInterface;
/**
* CRON field interface.
*/
interface FieldInterface
{
/**
* Check if the respective value of a DateTime field satisfies a CRON exp.
*
* @internal
* @param DateTimeInterface $date DateTime object to check
* @param string $value CRON expression to test against
*
* @return bool Returns TRUE if satisfied, FALSE otherwise
*/
public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool;
/**
* When a CRON expression is not satisfied, this method is used to increment
* or decrement a DateTime object by the unit of the cron field.
*
* @internal
* @param DateTimeInterface $date DateTime object to change
* @param bool $invert (optional) Set to TRUE to decrement
* @param string|null $parts (optional) Set parts to use
*
* @return FieldInterface
*/
public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface;
/**
* Validates a CRON expression for a given field.
*
* @param string $value CRON expression value to validate
*
* @return bool Returns TRUE if valid, FALSE otherwise
*/
public function validate(string $value): bool;
}

View File

@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace Cron;
use DateTimeInterface;
use DateTimeZone;
/**
* Hours field. Allows: * , / -.
*/
class HoursField extends AbstractField
{
/**
* {@inheritdoc}
*/
protected $rangeStart = 0;
/**
* {@inheritdoc}
*/
protected $rangeEnd = 23;
/**
* @var array|null Transitions returned by DateTimeZone::getTransitions()
*/
protected $transitions = [];
/**
* @var int|null Timestamp of the start of the transitions range
*/
protected $transitionsStart = null;
/**
* @var int|null Timestamp of the end of the transitions range
*/
protected $transitionsEnd = null;
/**
* {@inheritdoc}
*/
public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert): bool
{
$checkValue = (int) $date->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;
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Cron;
use DateTimeInterface;
/**
* Minutes field. Allows: * , / -.
*/
class MinutesField extends AbstractField
{
/**
* {@inheritdoc}
*/
protected $rangeStart = 0;
/**
* {@inheritdoc}
*/
protected $rangeEnd = 59;
/**
* {@inheritdoc}
*/
public function isSatisfiedBy(DateTimeInterface $date, $value, bool $invert):bool
{
if ($value === '?') {
return true;
}
return $this->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];
$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;
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Cron;
use DateTimeInterface;
/**
* Month field. Allows: * , / -.
*/
class MonthField extends AbstractField
{
/**
* {@inheritdoc}
*/
protected $rangeStart = 1;
/**
* {@inheritdoc}
*/
protected $rangeEnd = 12;
/**
* {@inheritdoc}
*/
protected $literals = [1 => '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;
}
}

View File

@ -0,0 +1,3 @@
service_name: travis-ci
coverage_clover: clover.xml
json_path: coveralls-upload.json

View File

@ -0,0 +1,10 @@
.DS_Store
/vendor
composer.lock
/examples
*.old
# PHPUnit coverage file
clover.xml

View File

@ -0,0 +1,23 @@
language: php
php:
- '7.3'
- '7.4'
- '8.0'
- hhvm
matrix:
allow_failures:
- php: hhvm
fast_finish: true
sudo: false
install:
- curl -s http://getcomposer.org/installer | php
- php composer.phar install --no-interaction
script:
- XDEBUG_MODE=coverage php vendor/bin/phpunit -c phpunit.xml --coverage-clover clover.xml
after_success:
- travis_retry php vendor/bin/php-coveralls -v

View File

@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at peppeocchi@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Giuseppe Occhipinti
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.

View File

@ -0,0 +1,502 @@
PHP Cron Scheduler
==
[![Latest Stable Version](https://poser.pugx.org/peppeocchi/php-cron-scheduler/v/stable)](https://packagist.org/packages/peppeocchi/php-cron-scheduler) [![License](https://poser.pugx.org/peppeocchi/php-cron-scheduler/license)](https://packagist.org/packages/peppeocchi/php-cron-scheduler) [![Build Status](https://travis-ci.org/peppeocchi/php-cron-scheduler.svg)](https://travis-ci.org/peppeocchi/php-cron-scheduler) [![Coverage Status](https://coveralls.io/repos/github/peppeocchi/php-cron-scheduler/badge.svg?branch=v3.x)](https://coveralls.io/github/peppeocchi/php-cron-scheduler?branch=v3.x) [![StyleCI](https://styleci.io/repos/38302733/shield)](https://styleci.io/repos/38302733) [![Total Downloads](https://poser.pugx.org/peppeocchi/php-cron-scheduler/downloads)](https://packagist.org/packages/peppeocchi/php-cron-scheduler)
This is a framework agnostic cron jobs scheduler that can be easily integrated with your project or run as a standalone command scheduler.
The idea was originally inspired by the [Laravel Task Scheduling](http://laravel.com/docs/5.1/scheduling).
## Installing via Composer
The recommended way is to install the php-cron-scheduler is through [Composer](https://getcomposer.org/).
Please refer to [Getting Started](https://getcomposer.org/doc/00-intro.md) on how to download and install Composer.
After you have downloaded/installed Composer, run
`php composer.phar require peppeocchi/php-cron-scheduler`
or add the package to your `composer.json`
```json
{
"require": {
"peppeocchi/php-cron-scheduler": "3.*"
}
}
```
Scheduler V4 requires php >= 7.3, please use the [v3 branch](https://github.com/peppeocchi/php-cron-scheduler/tree/v3.x) for php versions < 7.3 and > 7.1 or the [v2 branch](https://github.com/peppeocchi/php-cron-scheduler/tree/v2.x) for php versions < 7.1.
## How it works
Create a `scheduler.php` file in the root your project with the following content.
```php
<?php require_once __DIR__.'/vendor/autoload.php';
use GO\Scheduler;
// Create a new scheduler
$scheduler = new Scheduler();
// ... configure the scheduled jobs (see below) ...
// Let the scheduler execute jobs which are due.
$scheduler->run();
```
Then add a new entry to your crontab to run `scheduler.php` every minute.
````
* * * * * path/to/phpbin path/to/scheduler.php 1>> /dev/null 2>&1
````
That's it! Your scheduler is up and running, now you can add your jobs without worring anymore about the crontab.
## Scheduling jobs
By default all your jobs will try to run in background.
PHP scripts and raw commands will run in background by default, while functions will always run in foreground.
You can force a command to run in foreground by calling the `inForeground()` method.
**Jobs that have to send the output to email, will run foreground**.
### Schedule a php script
```php
$scheduler->php('path/to/my/script.php');
```
The `php` method accepts 4 arguments:
- The path to your php script
- The PHP binary to use
- Arguments to be passed to the script (**NOTE**: You need to have **register_argc_argv** enable in your php.ini for this to work ([ref](https://github.com/peppeocchi/php-cron-scheduler/issues/88)). Don't worry it's enabled by default, so unlessy you've intentionally disabled it or your host has it disabled by default, you can ignore it.)
- Identifier
```php
$scheduler->php(
'path/to/my/script.php', // The script to execute
'path/to/my/custom/bin/php', // The PHP bin
[
'-c' => 'ignore',
'--merge' => null,
],
'myCustomIdentifier'
);
```
### Schedule a raw command
```php
$scheduler->raw('ps aux | grep httpd');
```
The `raw` method accepts 3 arguments:
- Your command
- Arguments to be passed to the command
- Identifier
```php
$scheduler->raw(
'mycommand | myOtherCommand',
[
'-v' => '6',
'--silent' => null,
],
'myCustomIdentifier'
);
```
### Schedule a function
```php
$scheduler->call(function () {
return true;
});
```
The `call` method accepts 3 arguments:
- Your function
- Arguments to be passed to the function
- Identifier
```php
$scheduler->call(
function ($args) {
return $args['user'];
},
[
['user' => $user],
],
'myCustomIdentifier'
);
```
All of the arguments you pass in the array will be injected to your function.
For example
```php
$scheduler->call(
function ($firstName, $lastName) {
return implode(' ', [$firstName, $lastName]);
},
[
'John',
'last_name' => 'Doe', // The keys are being ignored
],
'myCustomIdentifier'
);
```
If you want to pass a key => value pair, please pass an array within the arguments array
```php
$scheduler->call(
function ($user, $role) {
return implode(' ', [$user['first_name'], $user['last_name']]) . " has role: '{$role}'";
},
[
[
'first_name' => 'John',
'last_name' => 'Doe',
],
'Admin'
],
'myCustomIdentifier'
);
```
### Schedules execution time
There are a few methods to help you set the execution time of your schedules.
If you don't call any of this method, the job will run every minute (* * * * *).
- `at` - This method accepts any expression supported by [dragonmantank/cron-expression](https://github.com/dragonmantank/cron-expression)
```php
$scheduler->php('script.php')->at('* * * * *');
```
- `everyMinute` - Run every minute. You can optionally pass a `$minute` to specify the job runs every `$minute` minutes.
```php
$scheduler->php('script.php')->everyMinute();
$scheduler->php('script.php')->everyMinute(5);
```
- `hourly` - Run once per hour. You can optionally pass the `$minute` you want to run, by default it will run every hour at minute '00'.
```php
$scheduler->php('script.php')->hourly();
$scheduler->php('script.php')->hourly(53);
```
- `daily` - Run once per day. You can optionally pass `$hour` and `$minute` to have more granular control (or a string `hour:minute`)
```php
$scheduler->php('script.php')->daily();
$scheduler->php('script.php')->daily(22, 03);
$scheduler->php('script.php')->daily('22:03');
```
There are additional helpers for weekdays (all accepting optionals hour and minute - defaulted at 00:00)
- `sunday`
- `monday`
- `tuesday`
- `wednesday`
- `thursday`
- `friday`
- `saturday`
```php
$scheduler->php('script.php')->saturday();
$scheduler->php('script.php')->friday(18);
$scheduler->php('script.php')->sunday(12, 30);
```
And additional helpers for months (all accepting optionals day, hour and minute - defaulted to the 1st of the month at 00:00)
- `january`
- `february`
- `march`
- `april`
- `may`
- `june`
- `july`
- `august`
- `september`
- `october`
- `november`
- `december`
```php
$scheduler->php('script.php')->january();
$scheduler->php('script.php')->december(25);
$scheduler->php('script.php')->august(15, 20, 30);
```
You can also specify a `date` for when the job should run.
The date can be specified as string or as instance of `DateTime`. In both cases you can specify the date only (e.g. 2018-01-01) or the time as well (e.g. 2018-01-01 10:30), if you don't specify the time it will run at 00:00 on that date.
If you're providing a date in a "non standard" format, it is strongly adviced to pass an instance of `DateTime`. If you're using `createFromFormat` without specifying a time, and you want to default it to 00:00, just make sure to add a `!` to the date format, otherwise the time would be the current time. [Read more](http://php.net/manual/en/datetime.createfromformat.php)
```php
$scheduler->php('script.php')->date('2018-01-01 12:20');
$scheduler->php('script.php')->date(new DateTime('2018-01-01'));
$scheduler->php('script.php')->date(DateTime::createFromFormat('!d/m Y', '01/01 2018'));
```
### Send output to file/s
You can define one or multiple files where you want the output of your script/command/function execution to be sent to.
```php
$scheduler->php('script.php')->output([
'my_file1.log', 'my_file2.log'
]);
// The scheduler catches both stdout and function return and send
// those values to the output file
$scheduler->call(function () {
echo "Hello";
return " world!";
})->output('my_file.log');
```
### Send output to email/s
You can define one or multiple email addresses where you want the output of your script/command/function execution to be sent to.
In order for the email to be sent, the output of the job needs to be sent first to a file.
In fact, the files will be attached to your email address.
In order for this to work, you need to install [swiftmailer/swiftmailer](https://github.com/swiftmailer/swiftmailer)
```php
$scheduler->php('script.php')->output([
// If you specify multiple files, both will be attached to the email
'my_file1.log', 'my_file2.log'
])->email([
'someemail@mail.com' => 'My custom name',
'someotheremail@mail.com'
]);
```
You can optionally customize the `Swift_Mailer` instance with a custom `Swift_Transport`.
You can configure:
- `subject` - The subject of the email sent
- `from` - The email address set as sender
- `body` - The body of the email
- `transport` - The transport to use. For example if you want to use your gmail account or any other SMTP account. The value should be an instance of `Swift_Tranport`
- `ignore_empty_output` - If this is set to `true`, jobs that return no output won't fire any email.
The configuration can be set "globally" for all the scheduler commands, when creating the scheduler.
```php
$scheduler = new Scheduler([
'email' => [
'subject' => 'Visitors count',
'from' => 'cron@email.com',
'body' => 'This is the daily visitors count',
'transport' => Swift_SmtpTransport::newInstance('smtp.gmail.com', 465, 'ssl')
->setUsername('username')
->setPassword('password'),
'ignore_empty_output' => false,
]
]);
```
Or can be set on a job per job basis.
```php
$scheduler = new Scheduler();
$scheduler->php('myscript.php')->configure([
'email' => [
'subject' => 'Visitors count',
]
]);
$scheduler->php('my_other_script.php')->configure([
'email' => [
'subject' => 'Page views count',
]
]);
```
### Schedule conditional execution
Sometimes you might want to execute a schedule not only when the execution is due, but also depending on some other condition.
You can delegate the execution of a cronjob to a truthful test with the method `when`.
```php
$scheduler->php('script.php')->when(function () {
// The job will run (if due) only when
// this function returns true
return true;
});
```
### Schedules execution order
The jobs that are due to run are being ordered by their execution: jobs that can run in **background** will be executed **first**.
### Schedules overlapping
To prevent the execution of a schedule while the previous execution is still in progress, use the method `onlyOne`. To avoid overlapping, the Scheduler needs to create **lock files**.
By default it will be used the directory path used for temporary files.
You can specify a custom directory path globally, when creating a new Scheduler instance.
```php
$scheduler = new Scheduler([
'tempDir' => 'path/to/my/tmp/dir'
]);
$scheduler->php('script.php')->onlyOne();
```
Or you can define the directory path on a job per job basis.
```php
$scheduler = new Scheduler();
// This will use the default directory path
$scheduler->php('script.php')->onlyOne();
$scheduler->php('script.php')->onlyOne('path/to/my/tmp/dir');
$scheduler->php('other_script.php')->onlyOne('path/to/my/other/tmp/dir');
```
In some cases you might want to run the job also if it's overlapping.
For example if the last execution was more that 5 minutes ago.
You can pass a function as a second parameter, the last execution time will be injected.
The job will not run until this function returns `false`. If it returns `true`, the job will run if overlapping.
```php
$scheduler->php('script.php')->onlyOne(null, function ($lastExecutionTime) {
return (time() - $lastExecutionTime) > (60 * 5);
});
```
### Before job execution
In some cases you might want to run some code, if the job is due to run, before it's being executed.
For example you might want to add a log entry, ping a url or anything else.
To do so, you can call the `before` like the example below.
```php
// $logger here is your own implementation
$scheduler->php('script.php')->before(function () use ($logger) {
$logger->info("script.php started at " . time());
});
```
### After job execution
Sometime you might wish to do something after a job runs. The `then` methods provides you the flexibility to do anything you want after the job execution. The output of the job will be injected to this function.
For example you might want to add an entry to you logs, ping a url etc...
By default, the job will be forced to run in foreground (because the output is injected to the function), if you don't need the output, you can pass `true` as a second parameter to allow the execution in background (in this case `$output` will be empty).
```php
// $logger and $messenger here are your own implementation
$scheduler->php('script.php')->then(function ($output) use ($logger, $messenger) {
$logger->info($output);
$messenger->ping('myurl.com', $output);
});
$scheduler->php('script.php')->then(function ($output) use ($logger) {
$logger->info('Job executed!');
}, true);
```
#### Using "before" and "then" together
```php
// $logger here is your own implementation
$scheduler->php('script.php')
->before(function () use ($logger) {
$logger->info("script.php started at " . time());
})
->then(function ($output) use ($logger) {
$logger->info("script.php completed at " . time(), [
'output' => $output,
]);
});
```
### Multiple scheduler runs
In some cases you might need to run the scheduler multiple times in the same script.
Although this is not a common case, the following methods will allow you to re-use the same instance of the scheduler.
```php
# some code
$scheduler->run();
# ...
// Reset the scheduler after a previous run
$scheduler->resetRun()
->run(); // now we can run it again
```
Another handy method if you are re-using the same instance of the scheduler with different jobs (e.g. job coming from an external source - db, file ...) on every run, is to clear the current scheduled jobs.
```php
$scheduler->clearJobs();
$jobsFromDb = $db->query(/*...*/);
foreach ($jobsFromDb as $job) {
$scheduler->php($job->script)->at($job->schedule);
}
$scheduler->resetRun()
->run();
```
### Faking scheduler run time
When running the scheduler you might pass an `DateTime` to fake the scheduler run time.
The resons for this feature are described [here](https://github.com/peppeocchi/php-cron-scheduler/pull/28);
```
// ...
$fakeRunTime = new DateTime('2017-09-13 00:00:00');
$scheduler->run($fakeRunTime);
```
### Job failures
If some job fails, you can access list of failed jobs and reasons for failures.
```php
// get all failed jobs and select first
$failedJob = $scheduler->getFailedJobs()[0];
// exception that occurred during job
$exception = $failedJob->getException();
// job that failed
$job = $failedJob->getJob();
```
### Worker
You can simulate a cronjob by starting a worker. Let's see a simple example
```php
$scheduler = new Scheduler();
$scheduler->php('some/script.php');
$scheduler->work();
```
The above code starts a worker that will run your job/s every minute.
This is meant to be a testing/debugging tool, but you're free to use it however you like.
You can optionally pass an array of "seconds" of when you want the worker to run your jobs, for example by passing `[0, 30]`, the worker will run your jobs at second **0** and at second **30** of the minute.
```php
$scheduler->work([0, 10, 25, 50, 55]);
```
It is highly advisable that you run your worker separately from your scheduler, although you can run the worker within your scheduler. The problem comes when your scheduler has one or more synchronous job, and the worker will have to wait for your job to complete before continuing the loop. For example
```php
$scheduler->call(function () {
sleep(120);
});
$scheduler->work();
```
The above will skip more than one execution, so it won't run anymore every minute but it will run probably every 2 or 3 minutes.
Instead the preferred approach would be to separate the worker from your scheduler.
```php
// File scheduler.php
$scheduler = new Scheduler();
$scheduler->call(function () {
sleep(120);
});
$scheduler->run();
```
```php
// File worker.php
$scheduler = new Scheduler();
$scheduler->php('scheduler.php');
$scheduler->work();
```
Then in your command line run `php worker.php`. This will start a foreground process that you can kill by simply exiting the command.
The worker is not meant to collect any data about your runs, and as already said it is meant to be a testing/debugging tool.
## License
[The MIT License (MIT)](LICENSE)

View File

@ -0,0 +1,41 @@
{
"name": "peppeocchi/php-cron-scheduler",
"description": "PHP Cron Job Scheduler",
"license": "MIT",
"keywords": ["cron job", "scheduler"],
"authors": [
{
"name": "Giuseppe Occhipinti",
"email": "peppeocchi@gmail.com"
},
{
"name": "Carsten Windler",
"email": "carsten@carstenwindler.de",
"homepage": "http://carstenwindler.de",
"role": "Contributor"
}
],
"minimum-stability": "dev",
"require": {
"php": "^7.3 || ^8.0",
"dragonmantank/cron-expression": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "~9.5",
"php-coveralls/php-coveralls": "^2.4",
"swiftmailer/swiftmailer": "~5.4 || ^6.0"
},
"suggest": {
"swiftmailer/swiftmailer": "Required to send the output of a job to email address/es (~5.4 || ^6.0)."
},
"autoload": {
"psr-4": {
"GO\\": "src/GO/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/GO/"
}
}
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" backupStaticAttributes="false" bootstrap="./vendor/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
<coverage processUncoveredFiles="true">
<include>
<directory>src/GO</directory>
</include>
<report>
<clover outputFile="clover.xml"/>
</report>
</coverage>
<testsuites>
<testsuite name="Scheduler Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
</php>
<logging/>
</phpunit>

View File

@ -0,0 +1,32 @@
<?php namespace GO;
use Exception;
class FailedJob
{
/**
* @var Job
*/
private $job;
/**
* @var Exception
*/
private $exception;
public function __construct(Job $job, Exception $exception)
{
$this->job = $job;
$this->exception = $exception;
}
public function getJob(): Job
{
return $this->job;
}
public function getException(): Exception
{
return $this->exception;
}
}

View File

@ -0,0 +1,590 @@
<?php namespace GO;
use DateTime;
use Exception;
use InvalidArgumentException;
class Job
{
use Traits\Interval,
Traits\Mailer;
/**
* Job identifier.
*
* @var string
*/
private $id;
/**
* Command to execute.
*
* @var mixed
*/
private $command;
/**
* Arguments to be passed to the command.
*
* @var array
*/
private $args = [];
/**
* Defines if the job should run in background.
*
* @var bool
*/
private $runInBackground = true;
/**
* Creation time.
*
* @var DateTime
*/
private $creationTime;
/**
* Job schedule time.
*
* @var Cron\CronExpression
*/
private $executionTime;
/**
* Job schedule year.
*
* @var string
*/
private $executionYear = null;
/**
* Temporary directory path for
* lock files to prevent overlapping.
*
* @var string
*/
private $tempDir;
/**
* Path to the lock file.
*
* @var string
*/
private $lockFile;
/**
* This could prevent the job to run.
* If true, the job will run (if due).
*
* @var bool
*/
private $truthTest = true;
/**
* The output of the executed job.
*
* @var mixed
*/
private $output;
/**
* The return code of the executed job.
*
* @var int
*/
private $returnCode = 0;
/**
* Files to write the output of the job.
*
* @var array
*/
private $outputTo = [];
/**
* Email addresses where the output should be sent to.
*
* @var array
*/
private $emailTo = [];
/**
* Configuration for email sending.
*
* @var array
*/
private $emailConfig = [];
/**
* A function to execute before the job is executed.
*
* @var callable
*/
private $before;
/**
* A function to execute after the job is executed.
*
* @var callable
*/
private $after;
/**
* A function to ignore an overlapping job.
* If true, the job will run also if it's overlapping.
*
* @var callable
*/
private $whenOverlapping;
/**
* @var string
*/
private $outputMode;
/**
* Create a new Job instance.
*
* @param string|callable $command
* @param array $args
* @param string $id
*/
public function __construct($command, $args = [], $id = null)
{
if (is_string($id)) {
$this->id = $id;
} else {
if (is_string($command)) {
$this->id = md5($command);
} elseif (is_array($command)) {
$this->id = md5(serialize($command));
} else {
/* @var object $command */
$this->id = spl_object_hash($command);
}
}
$this->creationTime = new DateTime('now');
// initialize the directory path for lock files
$this->tempDir = sys_get_temp_dir();
$this->command = $command;
$this->args = $args;
}
/**
* Get the Job id.
*
* @return string
*/
public function getId()
{
return $this->id;
}
/**
* Check if the Job is due to run.
* It accepts as input a DateTime used to check if
* the job is due. Defaults to job creation time.
* It also defaults the execution time if not previously defined.
*
* @param DateTime $date
* @return bool
*/
public function isDue(DateTime $date = null)
{
// The execution time is being defaulted if not defined
if (! $this->executionTime) {
$this->at('* * * * *');
}
$date = $date !== null ? $date : $this->creationTime;
if ($this->executionYear && $this->executionYear !== $date->format('Y')) {
return false;
}
return $this->executionTime->isDue($date);
}
/**
* Check if the Job is overlapping.
*
* @return bool
*/
public function isOverlapping()
{
return $this->lockFile &&
file_exists($this->lockFile) &&
call_user_func($this->whenOverlapping, filemtime($this->lockFile)) === false;
}
/**
* Force the Job to run in foreground.
*
* @return self
*/
public function inForeground()
{
$this->runInBackground = false;
return $this;
}
/**
* Check if the Job can run in background.
*
* @return bool
*/
public function canRunInBackground()
{
if (is_callable($this->command) || $this->runInBackground === false) {
return false;
}
return true;
}
/**
* This will prevent the Job from overlapping.
* It prevents another instance of the same Job of
* being executed if the previous is still running.
* The job id is used as a filename for the lock file.
*
* @param string $tempDir The directory path for the lock files
* @param callable $whenOverlapping A callback to ignore job overlapping
* @return self
*/
public function onlyOne($tempDir = null, callable $whenOverlapping = null)
{
if ($tempDir === null || ! is_dir($tempDir)) {
$tempDir = $this->tempDir;
}
$this->lockFile = implode('/', [
trim($tempDir),
trim($this->id) . '.lock',
]);
if ($whenOverlapping) {
$this->whenOverlapping = $whenOverlapping;
} else {
$this->whenOverlapping = function () {
return false;
};
}
return $this;
}
/**
* Compile the Job command.
*
* @return mixed
*/
public function compile()
{
$compiled = $this->command;
// If callable, return the function itself
if (is_callable($compiled)) {
return $compiled;
}
// Augment with any supplied arguments
foreach ($this->args as $key => $value) {
$compiled .= ' ' . escapeshellarg($key);
if ($value !== null) {
$compiled .= ' ' . escapeshellarg($value);
}
}
// Add the boilerplate to redirect the output to file/s
if (count($this->outputTo) > 0) {
$compiled .= ' | tee ';
$compiled .= $this->outputMode === 'a' ? '-a ' : '';
foreach ($this->outputTo as $file) {
$compiled .= $file . ' ';
}
$compiled = trim($compiled);
}
// Add boilerplate to remove lockfile after execution
if ($this->lockFile) {
$compiled .= '; rm ' . $this->lockFile;
}
// Add boilerplate to run in background
if ($this->canRunInBackground()) {
// Parentheses are need execute the chain of commands in a subshell
// that can then run in background
$compiled = '(' . $compiled . ') > /dev/null 2>&1 &';
}
return trim($compiled);
}
/**
* Configure the job.
*
* @param array $config
* @return self
*/
public function configure(array $config = [])
{
if (isset($config['email'])) {
if (! is_array($config['email'])) {
throw new InvalidArgumentException('Email configuration should be an array.');
}
$this->emailConfig = $config['email'];
}
// Check if config has defined a tempDir
if (isset($config['tempDir']) && is_dir($config['tempDir'])) {
$this->tempDir = $config['tempDir'];
}
return $this;
}
/**
* Truth test to define if the job should run if due.
*
* @param callable $fn
* @return self
*/
public function when(callable $fn)
{
$this->truthTest = $fn();
return $this;
}
/**
* Run the job.
*
* @return bool
*/
public function run()
{
// If the truthTest failed, don't run
if ($this->truthTest !== true) {
return false;
}
// If overlapping, don't run
if ($this->isOverlapping()) {
return false;
}
$compiled = $this->compile();
// Write lock file if necessary
$this->createLockFile();
if (is_callable($this->before)) {
call_user_func($this->before);
}
if (is_callable($compiled)) {
$this->output = $this->exec($compiled);
} else {
exec($compiled, $this->output, $this->returnCode);
}
$this->finalise();
return true;
}
/**
* Create the job lock file.
*
* @param mixed $content
* @return void
*/
private function createLockFile($content = null)
{
if ($this->lockFile) {
if ($content === null || ! is_string($content)) {
$content = $this->getId();
}
file_put_contents($this->lockFile, $content);
}
}
/**
* Remove the job lock file.
*
* @return void
*/
private function removeLockFile()
{
if ($this->lockFile && file_exists($this->lockFile)) {
unlink($this->lockFile);
}
}
/**
* Execute a callable job.
*
* @param callable $fn
* @throws Exception
* @return string
*/
private function exec(callable $fn)
{
ob_start();
try {
$returnData = call_user_func_array($fn, $this->args);
} catch (Exception $e) {
ob_end_clean();
throw $e;
}
$outputBuffer = ob_get_clean();
foreach ($this->outputTo as $filename) {
if ($outputBuffer) {
file_put_contents($filename, $outputBuffer, $this->outputMode === 'a' ? FILE_APPEND : 0);
}
if ($returnData) {
file_put_contents($filename, $returnData, FILE_APPEND);
}
}
$this->removeLockFile();
return $outputBuffer . (is_string($returnData) ? $returnData : '');
}
/**
* Set the file/s where to write the output of the job.
*
* @param string|array $filename
* @param bool $append
* @return self
*/
public function output($filename, $append = false)
{
$this->outputTo = is_array($filename) ? $filename : [$filename];
$this->outputMode = $append === false ? 'w' : 'a';
return $this;
}
/**
* Get the job output.
*
* @return mixed
*/
public function getOutput()
{
return $this->output;
}
/**
* Set the emails where the output should be sent to.
* The Job should be set to write output to a file
* for this to work.
*
* @param string|array $email
* @return self
*/
public function email($email)
{
if (! is_string($email) && ! is_array($email)) {
throw new InvalidArgumentException('The email can be only string or array');
}
$this->emailTo = is_array($email) ? $email : [$email];
// Force the job to run in foreground
$this->inForeground();
return $this;
}
/**
* Finilise the job after execution.
*
* @return void
*/
private function finalise()
{
// Send output to email
$this->emailOutput();
// Call any callback defined
if (is_callable($this->after)) {
call_user_func($this->after, $this->output, $this->returnCode);
}
}
/**
* Email the output of the job, if any.
*
* @return bool
*/
private function emailOutput()
{
if (! count($this->outputTo) || ! count($this->emailTo)) {
return false;
}
if (isset($this->emailConfig['ignore_empty_output']) &&
$this->emailConfig['ignore_empty_output'] === true &&
empty($this->output)
) {
return false;
}
$this->sendToEmails($this->outputTo);
return true;
}
/**
* Set function to be called before job execution
* Job object is injected as a parameter to callable function.
*
* @param callable $fn
* @return self
*/
public function before(callable $fn)
{
$this->before = $fn;
return $this;
}
/**
* Set a function to be called after job execution.
* By default this will force the job to run in foreground
* because the output is injected as a parameter of this
* function, but it could be avoided by passing true as a
* second parameter. The job will run in background if it
* meets all the other criteria.
*
* @param callable $fn
* @param bool $runInBackground
* @return self
*/
public function then(callable $fn, $runInBackground = false)
{
$this->after = $fn;
// Force the job to run in foreground
if ($runInBackground === false) {
$this->inForeground();
}
return $this;
}
}

View File

@ -0,0 +1,327 @@
<?php namespace GO;
use DateTime;
use Exception;
use InvalidArgumentException;
class Scheduler
{
/**
* The queued jobs.
*
* @var array
*/
private $jobs = [];
/**
* Successfully executed jobs.
*
* @var array
*/
private $executedJobs = [];
/**
* Failed jobs.
*
* @var FailedJob[]
*/
private $failedJobs = [];
/**
* The verbose output of the scheduled jobs.
*
* @var array
*/
private $outputSchedule = [];
/**
* @var array
*/
private $config;
/**
* Create new instance.
*
* @param array $config
*/
public function __construct(array $config = [])
{
$this->config = $config;
}
/**
* Queue a job for execution in the correct queue.
*
* @param Job $job
* @return void
*/
private function queueJob(Job $job)
{
$this->jobs[] = $job;
}
/**
* Prioritise jobs in background.
*
* @return array
*/
private function prioritiseJobs()
{
$background = [];
$foreground = [];
foreach ($this->jobs as $job) {
if ($job->canRunInBackground()) {
$background[] = $job;
} else {
$foreground[] = $job;
}
}
return array_merge($background, $foreground);
}
/**
* Get the queued jobs.
*
* @return array
*/
public function getQueuedJobs()
{
return $this->prioritiseJobs();
}
/**
* Queues a function execution.
*
* @param callable $fn The function to execute
* @param array $args Optional arguments to pass to the php script
* @param string $id Optional custom identifier
* @return Job
*/
public function call(callable $fn, $args = [], $id = null)
{
$job = new Job($fn, $args, $id);
$this->queueJob($job->configure($this->config));
return $job;
}
/**
* Queues a php script execution.
*
* @param string $script The path to the php script to execute
* @param string $bin Optional path to the php binary
* @param array $args Optional arguments to pass to the php script
* @param string $id Optional custom identifier
* @return Job
*/
public function php($script, $bin = null, $args = [], $id = null)
{
if (! is_string($script)) {
throw new InvalidArgumentException('The script should be a valid path to a file.');
}
$bin = $bin !== null && is_string($bin) && file_exists($bin) ?
$bin : (PHP_BINARY === '' ? '/usr/bin/php' : PHP_BINARY);
$job = new Job($bin . ' ' . $script, $args, $id);
if (! file_exists($script)) {
$this->pushFailedJob(
$job,
new InvalidArgumentException('The script should be a valid path to a file.')
);
}
$this->queueJob($job->configure($this->config));
return $job;
}
/**
* Queue a raw shell command.
*
* @param string $command The command to execute
* @param array $args Optional arguments to pass to the command
* @param string $id Optional custom identifier
* @return Job
*/
public function raw($command, $args = [], $id = null)
{
$job = new Job($command, $args, $id);
$this->queueJob($job->configure($this->config));
return $job;
}
/**
* Run the scheduler.
*
* @param DateTime $runTime Optional, run at specific moment
* @return array Executed jobs
*/
public function run(Datetime $runTime = null)
{
$jobs = $this->getQueuedJobs();
if (is_null($runTime)) {
$runTime = new DateTime('now');
}
foreach ($jobs as $job) {
if ($job->isDue($runTime)) {
try {
$job->run();
$this->pushExecutedJob($job);
} catch (\Exception $e) {
$this->pushFailedJob($job, $e);
}
}
}
return $this->getExecutedJobs();
}
/**
* Reset all collected data of last run.
*
* Call before run() if you call run() multiple times.
*/
public function resetRun()
{
// Reset collected data of last run
$this->executedJobs = [];
$this->failedJobs = [];
$this->outputSchedule = [];
return $this;
}
/**
* Add an entry to the scheduler verbose output array.
*
* @param string $string
* @return void
*/
private function addSchedulerVerboseOutput($string)
{
$now = '[' . (new DateTime('now'))->format('c') . '] ';
$this->outputSchedule[] = $now . $string;
// Print to stdoutput in light gray
// echo "\033[37m{$string}\033[0m\n";
}
/**
* Push a succesfully executed job.
*
* @param Job $job
* @return Job
*/
private function pushExecutedJob(Job $job)
{
$this->executedJobs[] = $job;
$compiled = $job->compile();
// If callable, log the string Closure
if (is_callable($compiled)) {
$compiled = 'Closure';
}
$this->addSchedulerVerboseOutput("Executing {$compiled}");
return $job;
}
/**
* Get the executed jobs.
*
* @return array
*/
public function getExecutedJobs()
{
return $this->executedJobs;
}
/**
* Push a failed job.
*
* @param Job $job
* @param Exception $e
* @return Job
*/
private function pushFailedJob(Job $job, Exception $e)
{
$this->failedJobs[] = new FailedJob($job, $e);
$compiled = $job->compile();
// If callable, log the string Closure
if (is_callable($compiled)) {
$compiled = 'Closure';
}
$this->addSchedulerVerboseOutput("{$e->getMessage()}: {$compiled}");
return $job;
}
/**
* Get the failed jobs.
*
* @return FailedJob[]
*/
public function getFailedJobs()
{
return $this->failedJobs;
}
/**
* Get the scheduler verbose output.
*
* @param string $type Allowed: text, html, array
* @return mixed The return depends on the requested $type
*/
public function getVerboseOutput($type = 'text')
{
switch ($type) {
case 'text':
return implode("\n", $this->outputSchedule);
case 'html':
return implode('<br>', $this->outputSchedule);
case 'array':
return $this->outputSchedule;
default:
throw new InvalidArgumentException('Invalid output type');
}
}
/**
* Remove all queued Jobs.
*/
public function clearJobs()
{
$this->jobs = [];
return $this;
}
/**
* Start a worker.
*
* @param array $seconds - When the scheduler should run
*/
public function work(array $seconds = [0])
{
while (true) {
if (in_array((int) date('s'), $seconds)) {
$this->run();
sleep(1);
}
}
}
}

View File

@ -0,0 +1,418 @@
<?php namespace GO\Traits;
use DateTime;
use Cron\CronExpression;
use InvalidArgumentException;
trait Interval
{
/**
* Set the Job execution time.
*
* @param string $expression
* @return self
*/
public function at($expression)
{
$this->executionTime = CronExpression::factory($expression);
return $this;
}
/**
* Run the Job at a specific date.
*
* @param string/DateTime $date
* @return self
*/
public function date($date)
{
if (! $date instanceof DateTime) {
$date = new DateTime($date);
}
$this->executionYear = $date->format('Y');
return $this->at("{$date->format('i')} {$date->format('H')} {$date->format('d')} {$date->format('m')} *");
}
/**
* Set the execution time to every minute.
*
* @param int|string|null When set, specifies that the job will be run every $minute minutes
*
* @return self
*/
public function everyMinute($minute = null)
{
$minuteExpression = '*';
if ($minute !== null) {
$c = $this->validateCronSequence($minute);
$minuteExpression = '*/' . $c['minute'];
}
return $this->at($minuteExpression . ' * * * *');
}
/**
* Set the execution time to every hour.
*
* @param int|string $minute
* @return self
*/
public function hourly($minute = 0)
{
$c = $this->validateCronSequence($minute);
return $this->at("{$c['minute']} * * * *");
}
/**
* Set the execution time to once a day.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function daily($hour = 0, $minute = 0)
{
if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = isset($parts[1]) ? $parts[1] : '0';
}
$c = $this->validateCronSequence($minute, $hour);
return $this->at("{$c['minute']} {$c['hour']} * * *");
}
/**
* Set the execution time to once a week.
*
* @param int|string $weekday
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function weekly($weekday = 0, $hour = 0, $minute = 0)
{
if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = isset($parts[1]) ? $parts[1] : '0';
}
$c = $this->validateCronSequence($minute, $hour, null, null, $weekday);
return $this->at("{$c['minute']} {$c['hour']} * * {$c['weekday']}");
}
/**
* Set the execution time to once a month.
*
* @param int|string $month
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0)
{
if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = isset($parts[1]) ? $parts[1] : '0';
}
$c = $this->validateCronSequence($minute, $hour, $day, $month);
return $this->at("{$c['minute']} {$c['hour']} {$c['day']} {$c['month']} *");
}
/**
* Set the execution time to every Sunday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function sunday($hour = 0, $minute = 0)
{
return $this->weekly(0, $hour, $minute);
}
/**
* Set the execution time to every Monday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function monday($hour = 0, $minute = 0)
{
return $this->weekly(1, $hour, $minute);
}
/**
* Set the execution time to every Tuesday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function tuesday($hour = 0, $minute = 0)
{
return $this->weekly(2, $hour, $minute);
}
/**
* Set the execution time to every Wednesday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function wednesday($hour = 0, $minute = 0)
{
return $this->weekly(3, $hour, $minute);
}
/**
* Set the execution time to every Thursday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function thursday($hour = 0, $minute = 0)
{
return $this->weekly(4, $hour, $minute);
}
/**
* Set the execution time to every Friday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function friday($hour = 0, $minute = 0)
{
return $this->weekly(5, $hour, $minute);
}
/**
* Set the execution time to every Saturday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function saturday($hour = 0, $minute = 0)
{
return $this->weekly(6, $hour, $minute);
}
/**
* Set the execution time to every January.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function january($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(1, $day, $hour, $minute);
}
/**
* Set the execution time to every February.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function february($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(2, $day, $hour, $minute);
}
/**
* Set the execution time to every March.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function march($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(3, $day, $hour, $minute);
}
/**
* Set the execution time to every April.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function april($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(4, $day, $hour, $minute);
}
/**
* Set the execution time to every May.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function may($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(5, $day, $hour, $minute);
}
/**
* Set the execution time to every June.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function june($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(6, $day, $hour, $minute);
}
/**
* Set the execution time to every July.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function july($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(7, $day, $hour, $minute);
}
/**
* Set the execution time to every August.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function august($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(8, $day, $hour, $minute);
}
/**
* Set the execution time to every September.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function september($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(9, $day, $hour, $minute);
}
/**
* Set the execution time to every October.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function october($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(10, $day, $hour, $minute);
}
/**
* Set the execution time to every November.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function november($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(11, $day, $hour, $minute);
}
/**
* Set the execution time to every December.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function december($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(12, $day, $hour, $minute);
}
/**
* Validate sequence of cron expression.
*
* @param int|string $minute
* @param int|string $hour
* @param int|string $day
* @param int|string $month
* @param int|string $weekday
* @return array
*/
private function validateCronSequence($minute = null, $hour = null, $day = null, $month = null, $weekday = null)
{
return [
'minute' => $this->validateCronRange($minute, 0, 59),
'hour' => $this->validateCronRange($hour, 0, 23),
'day' => $this->validateCronRange($day, 1, 31),
'month' => $this->validateCronRange($month, 1, 12),
'weekday' => $this->validateCronRange($weekday, 0, 6),
];
}
/**
* Validate sequence of cron expression.
*
* @param int|string $value
* @param int $min
* @param int $max
* @return mixed
*/
private function validateCronRange($value, $min, $max)
{
if ($value === null || $value === '*') {
return '*';
}
if (! is_numeric($value) ||
! ($value >= $min && $value <= $max)
) {
throw new InvalidArgumentException(
"Invalid value: it should be '*' or between {$min} and {$max}."
);
}
return (int) $value;
}
}

View File

@ -0,0 +1,62 @@
<?php namespace GO\Traits;
trait Mailer
{
/**
* Get email configuration.
*
* @return array
*/
public function getEmailConfig()
{
if (! isset($this->emailConfig['subject']) ||
! is_string($this->emailConfig['subject'])
) {
$this->emailConfig['subject'] = 'Cronjob execution';
}
if (! isset($this->emailConfig['from'])) {
$this->emailConfig['from'] = ['cronjob@server.my' => 'My Email Server'];
}
if (! isset($this->emailConfig['body']) ||
! is_string($this->emailConfig['body'])
) {
$this->emailConfig['body'] = 'Cronjob output attached';
}
if (! isset($this->emailConfig['transport']) ||
! ($this->emailConfig['transport'] instanceof \Swift_Transport)
) {
$this->emailConfig['transport'] = new \Swift_SendmailTransport();
}
return $this->emailConfig;
}
/**
* Send files to emails.
*
* @param array $files
* @return void
*/
private function sendToEmails(array $files)
{
$config = $this->getEmailConfig();
$mailer = new \Swift_Mailer($config['transport']);
$message = (new \Swift_Message())
->setSubject($config['subject'])
->setFrom($config['from'])
->setTo($this->emailTo)
->setBody($config['body'])
->addPart('<q>Cronjob output attached</q>', 'text/html');
foreach ($files as $filename) {
$message->attach(\Swift_Attachment::fromPath($filename));
}
$mailer->send($message);
}
}

View File

@ -0,0 +1,274 @@
<?php namespace GO\Job\Tests;
use GO\Job;
use PHPUnit\Framework\TestCase;
class IntervalTest extends TestCase
{
public function testShouldRunEveryMinute()
{
$job = new Job('ls');
$this->assertTrue($job->everyMinute()->isDue(\DateTime::createFromFormat('H:i', '00:00')));
}
public function testShouldRunHourly()
{
$job = new Job('ls');
// Default run is at minute 00 every hour
$this->assertTrue($job->hourly()->isDue(\DateTime::createFromFormat('H:i', '10:00')));
$this->assertFalse($job->hourly()->isDue(\DateTime::createFromFormat('H:i', '10:01')));
$this->assertTrue($job->hourly()->isDue(\DateTime::createFromFormat('H:i', '11:00')));
}
public function testShouldRunHourlyWithCustomInput()
{
$job = new Job('ls');
$this->assertTrue($job->hourly(19)->isDue(\DateTime::createFromFormat('H:i', '10:19')));
$this->assertTrue($job->hourly('07')->isDue(\DateTime::createFromFormat('H:i', '10:07')));
$this->assertFalse($job->hourly(19)->isDue(\DateTime::createFromFormat('H:i', '10:01')));
$this->assertTrue($job->hourly(19)->isDue(\DateTime::createFromFormat('H:i', '11:19')));
}
public function testShouldThrowExceptionWithInvalidHourlyMinuteInput()
{
$this->expectException(\InvalidArgumentException::class);
$job = new Job('ls');
$job->hourly('abc');
}
public function testShouldRunDaily()
{
$job = new Job('ls');
// Default run is at 00:00 every day
$this->assertTrue($job->daily()->isDue(\DateTime::createFromFormat('H:i', '00:00')));
}
public function testShouldRunDailyWithCustomInput()
{
$job = new Job('ls');
$this->assertTrue($job->daily(19)->isDue(\DateTime::createFromFormat('H:i', '19:00')));
$this->assertTrue($job->daily(19, 53)->isDue(\DateTime::createFromFormat('H:i', '19:53')));
$this->assertFalse($job->daily(19)->isDue(\DateTime::createFromFormat('H:i', '18:00')));
$this->assertFalse($job->daily(19, 53)->isDue(\DateTime::createFromFormat('H:i', '19:52')));
// A string is also acceptable
$this->assertTrue($job->daily('19')->isDue(\DateTime::createFromFormat('H:i', '19:00')));
$this->assertTrue($job->daily('19:53')->isDue(\DateTime::createFromFormat('H:i', '19:53')));
}
public function testShouldThrowExceptionWithInvalidDailyHourInput()
{
$this->expectException(\InvalidArgumentException::class);
$job = new Job('ls');
$job->daily('abc');
}
public function testShouldThrowExceptionWithInvalidDailyMinuteInput()
{
$this->expectException(\InvalidArgumentException::class);
$job = new Job('ls');
$job->daily(2, 'abc');
}
public function testShouldRunWeekly()
{
$job = new Job('ls');
// Default run is every Sunday at 00:00
$this->assertTrue($job->weekly()->isDue(
new \DateTime('Sunday'))
);
$this->assertFalse($job->weekly()->isDue(
new \DateTime('Tuesday'))
);
}
public function testShouldRunWeeklyOnCustomDay()
{
$job = new Job('ls');
$this->assertTrue($job->weekly(6)->isDue(
new \DateTime('Saturday'))
);
// Testing also the helpers to run weekly on custom day
$this->assertTrue($job->monday()->isDue(
new \DateTime('Monday'))
);
$this->assertFalse($job->monday()->isDue(
new \DateTime('Saturday'))
);
$this->assertTrue($job->tuesday()->isDue(
new \DateTime('Tuesday'))
);
$this->assertTrue($job->wednesday()->isDue(
new \DateTime('Wednesday'))
);
$this->assertTrue($job->thursday()->isDue(
new \DateTime('Thursday'))
);
$this->assertTrue($job->friday()->isDue(
new \DateTime('Friday'))
);
$this->assertTrue($job->saturday()->isDue(
new \DateTime('Saturday'))
);
$this->assertTrue($job->sunday()->isDue(
new \DateTime('Sunday'))
);
}
public function testShouldRunWeeklyOnCustomDayAndTime()
{
$job = new Job('ls');
$date1 = new \DateTime('Saturday 03:45');
$date2 = new \DateTime('Saturday 03:46');
$this->assertTrue($job->weekly(6, 3, 45)->isDue($date1));
$this->assertTrue($job->weekly(6, '03:45')->isDue($date1));
$this->assertFalse($job->weekly(6, '03:45')->isDue($date2));
}
public function testShouldRunMonthly()
{
$job = new Job('ls');
// Default run is every 1st of the month at 00:00
$this->assertTrue($job->monthly()->isDue(
new \DateTime('01 January'))
);
$this->assertTrue($job->monthly()->isDue(
new \DateTime('01 December'))
);
$this->assertFalse($job->monthly()->isDue(
new \DateTime('02 January'))
);
}
public function testShouldRunMonthlyOnCustomMonth()
{
$job = new Job('ls');
$this->assertTrue($job->monthly()->isDue(
new \DateTime('01 January'))
);
// Testing also the helpers to run weekly on custom day
$this->assertTrue($job->january()->isDue(
new \DateTime('01 January'))
);
$this->assertFalse($job->january()->isDue(
new \DateTime('01 February'))
);
$this->assertTrue($job->february()->isDue(
new \DateTime('01 February'))
);
$this->assertTrue($job->march()->isDue(
new \DateTime('01 March'))
);
$this->assertTrue($job->april()->isDue(
new \DateTime('01 April'))
);
$this->assertTrue($job->may()->isDue(
new \DateTime('01 May'))
);
$this->assertTrue($job->june()->isDue(
new \DateTime('01 June'))
);
$this->assertTrue($job->july()->isDue(
new \DateTime('01 July'))
);
$this->assertTrue($job->august()->isDue(
new \DateTime('01 August'))
);
$this->assertTrue($job->september()->isDue(
new \DateTime('01 September'))
);
$this->assertTrue($job->october()->isDue(
new \DateTime('01 October'))
);
$this->assertTrue($job->november()->isDue(
new \DateTime('01 November'))
);
$this->assertTrue($job->december()->isDue(
new \DateTime('01 December'))
);
}
public function testShouldRunMonthlyOnCustomDayAndTime()
{
$job = new Job('ls');
$date1 = new \DateTime('May 15 12:21');
$date2 = new \DateTime('February 15 12:21');
$date3 = new \DateTime('February 16 12:21');
$this->assertTrue($job->monthly(5, 15, 12, 21)->isDue($date1));
$this->assertTrue($job->monthly(5, 15, '12:21')->isDue($date1));
$this->assertFalse($job->monthly(5, 15, '12:21')->isDue($date2));
// Every 15th at 12:21
$this->assertTrue($job->monthly(null, 15, '12:21')->isDue($date1));
$this->assertTrue($job->monthly(null, 15, '12:21')->isDue($date2));
$this->assertFalse($job->monthly(null, 15, '12:21')->isDue($date3));
}
public function testShouldRunAtSpecificDate()
{
$job = new Job('ls');
$date = '2018-01-01';
// As instance of datetime
$this->assertTrue($job->date(new \DateTime($date))->isDue(new \DateTime($date)));
// As date string
$this->assertTrue($job->date($date)->isDue(new \DateTime($date)));
// Fail for different day
$this->assertFalse($job->date($date)->isDue(new \DateTime('2018-01-02')));
}
public function testShouldRunAtSpecificDateTime()
{
$job = new Job('ls');
$date = '2018-01-01 12:20';
// As instance of datetime
$this->assertTrue($job->date(new \DateTime($date))->isDue(new \DateTime($date)));
// As date string
$this->assertTrue($job->date($date)->isDue(new \DateTime($date)));
// Fail for different time
$this->assertFalse($job->date($date)->isDue(new \DateTime('2018-01-01 12:21')));
}
public function testShouldFailIfDifferentYear()
{
$job = new Job('ls');
// As instance of datetime
$this->assertFalse($job->date('2018-01-01')->isDue(new \DateTime('2019-01-01')));
}
public function testEveryMinuteWithParameter()
{
$job = new Job('ls');
// Job should run at 10:00, 10:05, 10:10 etc., but not at 10:02
$this->assertTrue($job->everyMinute(5)->isDue(\DateTime::createFromFormat('H:i', '10:00')));
$this->assertFalse($job->everyMinute(5)->isDue(\DateTime::createFromFormat('H:i', '10:02')));
$this->assertTrue($job->everyMinute(5)->isDue(\DateTime::createFromFormat('H:i', '10:05')));
}
}

View File

@ -0,0 +1,201 @@
<?php namespace GO\Job\Tests;
use GO\Job;
use PHPUnit\Framework\TestCase;
class JobOutputFilesTest extends TestCase
{
public function testShouldWriteCommandOutputToSingleFile()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../test_job.php';
$job = new Job($command);
$outputFile = __DIR__ . '/../tmp/output.log';
@unlink($outputFile);
// Test fist that the file doesn't exist yet
$this->assertFalse(file_exists($outputFile));
$job->output($outputFile)->run();
sleep(2);
$this->assertTrue(file_exists($outputFile));
// Content should be 'hi'
$this->assertEquals('hi', file_get_contents($outputFile));
unlink($outputFile);
}
public function testShouldWriteCommandOutputToMultipleFiles()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../test_job.php';
$job = new Job($command);
$outputFile1 = __DIR__ . '/../tmp/output1.log';
$outputFile2 = __DIR__ . '/../tmp/output2.log';
$outputFile3 = __DIR__ . '/../tmp/output3.log';
@unlink($outputFile1);
@unlink($outputFile2);
@unlink($outputFile3);
// Test fist that the file doesn't exist yet
$this->assertFalse(file_exists($outputFile1));
$this->assertFalse(file_exists($outputFile2));
$this->assertFalse(file_exists($outputFile3));
$job->output([
$outputFile1,
$outputFile2,
$outputFile3,
])->run();
sleep(2);
$this->assertTrue(file_exists($outputFile1));
$this->assertTrue(file_exists($outputFile2));
$this->assertTrue(file_exists($outputFile3));
$this->assertEquals('hi', file_get_contents($outputFile1));
$this->assertEquals('hi', file_get_contents($outputFile2));
$this->assertEquals('hi', file_get_contents($outputFile3));
unlink($outputFile1);
unlink($outputFile2);
unlink($outputFile3);
}
public function testShouldWriteFunctionOutputToSingleFile()
{
$job = new Job(function () {
echo 'Hello ';
return 'World!';
});
$outputFile = __DIR__ . '/../tmp/output.log';
@unlink($outputFile);
// Test fist that the file doesn't exist yet
$this->assertFalse(file_exists($outputFile));
$job->output($outputFile)->run();
sleep(2);
$this->assertTrue(file_exists($outputFile));
$this->assertEquals('Hello World!', file_get_contents($outputFile));
unlink($outputFile);
}
public function testShouldWriteFunctionOutputToMultipleFiles()
{
$job = new Job(function () {
echo 'Hello';
});
$outputFile1 = __DIR__ . '/../tmp/output1.log';
$outputFile2 = __DIR__ . '/../tmp/output2.log';
$outputFile3 = __DIR__ . '/../tmp/output3.log';
@unlink($outputFile1);
@unlink($outputFile2);
@unlink($outputFile3);
// Test fist that the file doesn't exist yet
$this->assertFalse(file_exists($outputFile1));
$this->assertFalse(file_exists($outputFile2));
$this->assertFalse(file_exists($outputFile3));
$job->output([
$outputFile1,
$outputFile2,
$outputFile3,
])->run();
sleep(2);
$this->assertTrue(file_exists($outputFile1));
$this->assertTrue(file_exists($outputFile2));
$this->assertTrue(file_exists($outputFile3));
$this->assertEquals('Hello', file_get_contents($outputFile1));
$this->assertEquals('Hello', file_get_contents($outputFile2));
$this->assertEquals('Hello', file_get_contents($outputFile3));
unlink($outputFile1);
unlink($outputFile2);
unlink($outputFile3);
}
public function testShouldWriteFunctionReturnToSingleFile()
{
$job = new Job(function () {
return 'Hello World!';
});
$outputFile = __DIR__ . '/../tmp/output1.log';
// Test fist that the file doesn't exist yet
$this->assertFalse(file_exists($outputFile));
$job->output($outputFile)->run();
sleep(2);
$this->assertTrue(file_exists($outputFile));
$this->assertEquals('Hello World!', file_get_contents($outputFile));
unlink($outputFile);
}
public function testShouldWriteFunctionReturnToMultipleFiles()
{
$job = new Job(function () {
return ['Hello ', 'World!'];
});
$outputFile1 = __DIR__ . '/../tmp/output1.log';
$outputFile2 = __DIR__ . '/../tmp/output2.log';
$outputFile3 = __DIR__ . '/../tmp/output3.log';
@unlink($outputFile1);
@unlink($outputFile2);
@unlink($outputFile3);
// Test fist that the file doesn't exist yet
$this->assertFalse(file_exists($outputFile1));
$this->assertFalse(file_exists($outputFile2));
$this->assertFalse(file_exists($outputFile3));
$job->output([
$outputFile1,
$outputFile2,
$outputFile3,
])->run();
sleep(2);
$this->assertTrue(file_exists($outputFile1));
$this->assertTrue(file_exists($outputFile2));
$this->assertTrue(file_exists($outputFile3));
$this->assertEquals('Hello World!', file_get_contents($outputFile1));
$this->assertEquals('Hello World!', file_get_contents($outputFile2));
$this->assertEquals('Hello World!', file_get_contents($outputFile3));
unlink($outputFile1);
unlink($outputFile2);
unlink($outputFile3);
}
public function testShouldWriteFunctionOutputAndReturnToFile()
{
$job = new Job(function () {
echo 'Hello ';
return 'World!';
});
$outputFile = __DIR__ . '/../tmp/output1.log';
// Test fist that the file doesn't exist yet
$this->assertFalse(file_exists($outputFile));
$job->output($outputFile)->run();
sleep(2);
$this->assertTrue(file_exists($outputFile));
$this->assertEquals('Hello World!', file_get_contents($outputFile));
unlink($outputFile);
}
}

View File

@ -0,0 +1,470 @@
<?php namespace GO\Job\Tests;
use GO\Job;
use PHPUnit\Framework\TestCase;
class JobTest extends TestCase
{
public function testShouldAlwaysGenerateAnId()
{
$job1 = new Job('ls');
$this->assertTrue(is_string($job1->getId()));
$job2 = new Job(function () {
return true;
});
$this->assertTrue(is_string($job2->getId()));
$job3 = new Job(['MyClass', 'myMethod']);
$this->assertTrue(is_string($job3->getId()));
}
public function testShouldGenerateIdFromSignature()
{
$job1 = new Job('ls');
$this->assertEquals(md5('ls'), $job1->getId());
$job2 = new Job('whoami');
$this->assertNotEquals($job1->getId(), $job2->getId());
$job3 = new Job(['MyClass', 'myMethod']);
$this->assertNotEquals($job1->getId(), $job3->getId());
}
public function testShouldAllowCustomId()
{
$job = new Job('ls', [], 'aCustomId');
$this->assertNotEquals(md5('ls'), $job->getId());
$this->assertEquals('aCustomId', $job->getId());
$job2 = new Job(['MyClass', 'myMethod'], null, 'myCustomId');
$this->assertEquals('myCustomId', $job2->getId());
}
public function testShouldKnowIfDue()
{
$job1 = new Job('ls');
$this->assertTrue($job1->isDue());
$job2 = new Job('ls');
$job2->at('* * * * *');
$this->assertTrue($job2->isDue());
$job3 = new Job('ls');
$job3->at('10 * * * *');
$this->assertTrue($job3->isDue(\DateTime::createFromFormat('i', '10')));
$this->assertFalse($job3->isDue(\DateTime::createFromFormat('i', '12')));
}
public function testShouldKnowIfCanRunInBackground()
{
$job = new Job('ls');
$this->assertTrue($job->canRunInBackground());
$job2 = new Job(function () {
return "I can't run in background";
});
$this->assertFalse($job2->canRunInBackground());
}
public function testShouldForceTheJobToRunInForeground()
{
$job = new Job('ls');
$this->assertTrue($job->canRunInBackground());
$this->assertFalse($job->inForeground()->canRunInBackground());
}
public function testShouldReturnCompiledJobCommand()
{
$job1 = new Job('ls');
$this->assertEquals('ls', $job1->inForeground()->compile());
$fn = function () {
return true;
};
$job2 = new Job($fn);
$this->assertEquals($fn, $job2->compile());
}
public function testShouldCompileWithArguments()
{
$job = new Job('ls', [
'-l' => null,
'-arg' => 'value',
]);
$this->assertEquals("ls '-l' '-arg' 'value'", $job->inForeground()->compile());
}
public function testShouldCompileCommandInBackground()
{
$job1 = new Job('ls');
$job1->at('* * * * *');
$this->assertEquals('(ls) > /dev/null 2>&1 &', $job1->compile());
}
public function testShouldRunInBackground()
{
// This script has a 5 seconds sleep
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
$startTime = microtime(true);
$job->at('* * * * *')->run();
$endTime = microtime(true);
$this->assertTrue(5 > ($endTime - $startTime));
$startTime = microtime(true);
$job->at('* * * * *')->inForeground()->run();
$endTime = microtime(true);
$this->assertTrue(($endTime - $startTime) >= 5);
}
public function testShouldRunInForegroundIfSendsEmails()
{
$job = new Job('ls');
$job->email('test@mail.com');
$this->assertFalse($job->canRunInBackground());
}
public function testShouldAcceptSingleOrMultipleEmails()
{
$job = new Job('ls');
$this->assertInstanceOf(Job::class, $job->email('test@mail.com'));
$this->assertInstanceOf(Job::class, $job->email(['test@mail.com', 'other@mail.com']));
}
public function testShouldFailIfEmailInputIsNotStringOrArray()
{
$this->expectException(\InvalidArgumentException::class);
$job = new Job('ls');
$job->email(1);
}
public function testShouldAcceptEmailConfigurationAndItShouldBeChainable()
{
$job = new Job('ls');
$this->assertInstanceOf(Job::class, $job->configure([
'email' => [],
]));
}
public function testShouldFailIfEmailConfigurationIsNotArray()
{
$this->expectException(\InvalidArgumentException::class);
$job = new Job('ls');
$job->configure([
'email' => 123,
]);
}
public function testShouldCreateLockFileIfOnlyOne()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
// Default temp dir
$tmpDir = sys_get_temp_dir();
$lockFile = $tmpDir . '/' . $job->getId() . '.lock';
@unlink($lockFile);
$this->assertFalse(file_exists($lockFile));
$job->onlyOne()->run();
$this->assertTrue(file_exists($lockFile));
}
public function testShouldCreateLockFilesInCustomPath()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
// Default temp dir
$tmpDir = __DIR__ . '/../tmp';
$lockFile = $tmpDir . '/' . $job->getId() . '.lock';
@unlink($lockFile);
$this->assertFalse(file_exists($lockFile));
$job->onlyOne($tmpDir)->run();
$this->assertTrue(file_exists($lockFile));
}
public function testShouldRemoveLockFileAfterRunningClosures()
{
$job = new Job(function () {
sleep(3);
});
// Default temp dir
$tmpDir = __DIR__ . '/../tmp';
$lockFile = $tmpDir . '/' . $job->getId() . '.lock';
$job->onlyOne($tmpDir)->run();
$this->assertFalse(file_exists($lockFile));
}
public function testShouldRemoveLockFileAfterRunningCommands()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
// Default temp dir
$tmpDir = __DIR__ . '/../tmp';
$lockFile = $tmpDir . '/' . $job->getId() . '.lock';
$job->onlyOne($tmpDir)->run();
sleep(1);
$this->assertTrue(file_exists($lockFile));
sleep(5);
$this->assertFalse(file_exists($lockFile));
}
public function testShouldKnowIfOverlapping()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
$this->assertFalse($job->isOverlapping());
$tmpDir = __DIR__ . '/../tmp';
$job->onlyOne($tmpDir)->run();
sleep(1);
$this->assertTrue($job->isOverlapping());
sleep(5);
$this->assertFalse($job->isOverlapping());
}
public function testShouldNotRunIfOverlapping()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
$this->assertFalse($job->isOverlapping());
$tmpDir = __DIR__ . '/../tmp';
$job->onlyOne($tmpDir);
sleep(1);
$this->assertTrue($job->run());
$this->assertFalse($job->run());
sleep(6);
$this->assertTrue($job->run());
}
public function testShouldRunIfOverlappingCallbackReturnsTrue()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
$this->assertFalse($job->isOverlapping());
$tmpDir = __DIR__ . '/../tmp';
$job->onlyOne($tmpDir, function ($lastExecution) {
return time() - $lastExecution > 2;
})->run();
// The job should not run as it is overlapping
$this->assertFalse($job->run());
sleep(3);
// The job should run now as the function should now return true,
// while it's still being executed
$lockFile = $tmpDir . '/' . $job->getId() . '.lock';
$this->assertTrue(file_exists($lockFile));
$this->assertTrue($job->run());
}
public function testShouldAcceptTempDirInConfiguration()
{
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
$tmpDir = __DIR__ . '/../tmp';
$job->configure([
'tempDir' => $tmpDir,
])->onlyOne()->run();
sleep(1);
$this->assertTrue(file_exists($tmpDir . '/' . $job->getId() . '.lock'));
}
public function testWhenMethodShouldBeChainable()
{
$job = new Job('ls');
$this->assertInstanceOf(Job::class, $job->when(function () {
return true;
}));
}
public function testShouldNotRunIfTruthTestFails()
{
$job = new Job('ls');
$this->assertFalse($job->when(function () {
return false;
})->run());
$this->assertTrue($job->when(function () {
return true;
})->run());
}
public function testShouldReturnOutputOfJobExecution()
{
$job1 = new Job(function () {
echo 'hi';
});
$job1->run();
$this->assertEquals('hi', $job1->getOutput());
$job2 = new Job(function () {
return 'hello';
});
$job2->run();
$this->assertEquals('hello', $job2->getOutput());
$command = PHP_BINARY . ' ' . __DIR__ . '/../test_job.php';
$job3 = new Job($command);
$job3->inForeground()->run();
$this->assertEquals(['hi'], $job3->getOutput());
}
public function testShouldRunCallbackBeforeJobExecution()
{
$job = new Job(function () {
return 'Job for testing before function';
});
$callbackWasExecuted = false;
$outputWasSet = false;
$job->before(function () use ($job, &$callbackWasExecuted, &$outputWasSet) {
$callbackWasExecuted = true;
$outputWasSet = ! is_null($job->getOutput());
})->run();
$this->assertTrue($callbackWasExecuted);
$this->assertFalse($outputWasSet);
}
public function testShouldRunCallbackAfterJobExecution()
{
$job = new Job(function () {
$visitors = 1000;
return 'Daily visitors: ' . $visitors;
});
$jobResult = null;
$job->then(function ($output) use (&$jobResult) {
$jobResult = $output;
})->run();
$this->assertEquals($jobResult, $job->getOutput());
$command = PHP_BINARY . ' ' . __DIR__ . '/../test_job.php';
$job2 = new Job($command);
$job2Result = null;
$job2->then(function ($output) use (&$job2Result) {
$job2Result = $output;
}, true)->run();
// Commands in background should return an empty string
$this->assertTrue(empty($job2Result));
$job2Result = null;
$job2->then(function ($output) use (&$job2Result) {
$job2Result = $output;
})->inForeground()->run();
$this->assertTrue(! empty($job2Result) &&
$job2Result === $job2->getOutput());
}
public function testThenMethodShouldPassReturnCode()
{
$command_success = PHP_BINARY . ' ' . __DIR__ . '/../test_job.php';
$command_fail = $command_success . ' fail';
$run = function ($command) {
$job = new Job($command);
$testReturnCode = null;
$job->then(function ($output, $returnCode) use (&$testReturnCode, &$testOutput) {
$testReturnCode = $returnCode;
})->run();
return $testReturnCode;
};
$this->assertEquals(0, $run($command_success));
$this->assertNotEquals(0, $run($command_fail));
}
public function testThenMethodShouldBeChainable()
{
$job = new Job('ls');
$this->assertInstanceOf(Job::class, $job->then(function () {
return true;
}));
}
public function testShouldDefaultExecutionInForegroundIfMethodThenIsDefined()
{
$job = new Job('ls');
$job->then(function () {
return true;
});
$this->assertFalse($job->canRunInBackground());
}
public function testShouldAllowForcingTheJobToRunInBackgroundIfMethodThenIsDefined()
{
// This is a use case when you want to execute a callback every time your
// job is executed, but you don't care about the output of the job
$job = new Job('ls');
$job->then(function () {
return true;
}, true);
$this->assertTrue($job->canRunInBackground());
}
}

View File

@ -0,0 +1,166 @@
<?php namespace GO\Job\Tests;
use GO\Job;
use PHPUnit\Framework\TestCase;
class MailerTest extends TestCase
{
public function testShouldHaveDefaultConfigToSendAnEmail()
{
$job = new Job('ls');
$config = $job->getEmailConfig();
$this->assertTrue(isset($config['subject']));
$this->assertTrue(isset($config['from']));
$this->assertTrue(isset($config['body']));
$this->assertTrue(isset($config['transport']));
}
public function testShouldAllowCustomTransportWhenSendingEmails()
{
$job = new Job(function () {
return 'hi';
});
$job->configure([
'email' => [
'transport' => new \Swift_NullTransport(),
],
]);
$this->assertInstanceOf(\Swift_NullTransport::class, $job->getEmailConfig()['transport']);
}
public function testEmailTransportShouldAlwaysBeInstanceOfSwift_Transport()
{
$job = new Job(function () {
return 'hi';
});
$job->configure([
'email' => [
'transport' => 'Something not allowed',
],
]);
$this->assertInstanceOf(\Swift_Transport::class, $job->getEmailConfig()['transport']);
}
public function testShouldSendJobOutputToEmail()
{
$emailAddress = 'local@localhost.com';
$command = PHP_BINARY . ' ' . __DIR__ . '/../test_job.php';
$job1 = new Job($command);
$job2 = new Job(function () {
return 'Hello World!';
});
$nullTransportConfig = [
'email' => [
'transport' => new \Swift_NullTransport(),
],
];
$job1->configure($nullTransportConfig);
$job2->configure($nullTransportConfig);
$outputFile1 = __DIR__ . '/../tmp/output001.log';
$this->assertTrue($job1->output($outputFile1)->email($emailAddress)->run());
$outputFile2 = __DIR__ . '/../tmp/output002.log';
$this->assertTrue($job2->output($outputFile2)->email($emailAddress)->run());
unlink($outputFile1);
unlink($outputFile2);
}
public function testShouldSendMultipleFilesToEmail()
{
$emailAddress = 'local@localhost.com';
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
$outputFile1 = __DIR__ . '/../tmp/output003.log';
$outputFile2 = __DIR__ . '/../tmp/output004.log';
$nullTransportConfig = [
'email' => [
'transport' => new \Swift_NullTransport(),
],
];
$job->configure($nullTransportConfig);
$this->assertTrue($job->output([
$outputFile1, $outputFile2,
])->email([$emailAddress])->run());
unlink($outputFile1);
unlink($outputFile2);
}
public function testShouldSendToMultipleEmails()
{
$emailAddress1 = 'local@localhost.com';
$emailAddress2 = 'local1@localhost.com';
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
$outputFile = __DIR__ . '/../tmp/output005.log';
$nullTransportConfig = [
'email' => [
'transport' => new \Swift_NullTransport(),
],
];
$job->configure($nullTransportConfig);
$this->assertTrue($job->output($outputFile)->email([
$emailAddress1, $emailAddress2,
])->run());
unlink($outputFile);
}
public function testShouldAcceptCustomEmailConfig()
{
$emailAddress = 'local@localhost.com';
$command = PHP_BINARY . ' ' . __DIR__ . '/../async_job.php';
$job = new Job($command);
$outputFile = __DIR__ . '/../tmp/output6.log';
$this->assertTrue(
$job->output($outputFile)->email($emailAddress)
->configure([
'email' => [
'subject' => 'My custom subject',
'from' => 'my@custom.from',
'body' => 'My custom body',
'transport' => new \Swift_NullTransport(),
],
])->run()
);
unlink($outputFile);
}
public function testShouldIgnoreEmailIfSpecifiedInConfig()
{
$job = new Job(function () {
$tot = 1 + 2;
// Return nothing....
});
$nullTransportConfig = [
'email' => [
'transport' => new \Swift_NullTransport(),
'ignore_empty_output' => true,
],
];
$job->configure($nullTransportConfig);
$outputFile = __DIR__ . '/../tmp/output.log';
$this->assertTrue($job->output($outputFile)->email('local@localhost.com')->run());
@unlink($outputFile);
}
}

View File

@ -0,0 +1,381 @@
<?php namespace GO\Job\Tests;
use GO\Job;
use DateTime;
use GO\FailedJob;
use GO\Scheduler;
use PHPUnit\Framework\TestCase;
class SchedulerTest extends TestCase
{
public function testShouldQueueJobs()
{
$scheduler = new Scheduler();
$this->assertEquals(count($scheduler->getQueuedJobs()), 0);
$scheduler->raw('ls');
$this->assertEquals(count($scheduler->getQueuedJobs()), 1);
}
public function testShouldQueueAPhpScript()
{
$scheduler = new Scheduler();
$script = __DIR__ . '/../test_job.php';
$this->assertEquals(count($scheduler->getQueuedJobs()), 0);
$scheduler->php($script);
$this->assertEquals(count($scheduler->getQueuedJobs()), 1);
}
public function testShouldAllowCustomPhpBin()
{
$scheduler = new Scheduler();
$script = __DIR__ . '/../test_job.php';
// Create fake bin
$bin = __DIR__ . '/../custom_bin';
touch($bin);
$job = $scheduler->php($script, $bin)->inForeground();
unlink($bin);
$this->assertEquals($bin . ' ' . $script, $job->compile());
}
public function testShouldUseSystemPhpBinIfCustomBinDoesNotExist()
{
$scheduler = new Scheduler();
$script = __DIR__ . '/../test_job.php';
// Create fake bin
$bin = '/my/custom/php/bin';
$job = $scheduler->php($script, $bin)->inForeground();
$this->assertNotEquals($bin . ' ' . $script, $job->compile());
$this->assertEquals(PHP_BINARY . ' ' . $script, $job->compile());
}
public function testShouldThrowExceptionIfScriptIsNotAString()
{
$this->expectException(\InvalidArgumentException::class);
$scheduler = new Scheduler();
$scheduler->php(function () {
return false;
});
$scheduler->run();
}
public function testShouldMarkJobAsFailedIfScriptPathIsInvalid()
{
$scheduler = new Scheduler();
$scheduler->php('someInvalidPathToAScript');
$scheduler->run();
$fail = $scheduler->getFailedJobs();
$this->assertCount(1, $fail);
$this->assertContainsOnlyInstancesOf(FailedJob::class, $fail);
}
public function testShouldQueueAShellCommand()
{
$scheduler = new Scheduler();
$this->assertEquals(count($scheduler->getQueuedJobs()), 0);
$scheduler->raw('ls');
$this->assertEquals(count($scheduler->getQueuedJobs()), 1);
}
public function testShouldQueueAFunction()
{
$scheduler = new Scheduler();
$this->assertEquals(count($scheduler->getQueuedJobs()), 0);
$scheduler->call(function () {
return true;
});
$this->assertEquals(count($scheduler->getQueuedJobs()), 1);
}
public function testShouldKeepTrackOfExecutedJobs()
{
$scheduler = new Scheduler();
$scheduler->call(function () {
return true;
});
$this->assertEquals(count($scheduler->getQueuedJobs()), 1);
$this->assertEquals(count($scheduler->getExecutedJobs()), 0);
$scheduler->run();
$this->assertEquals(count($scheduler->getExecutedJobs()), 1);
}
public function testShouldPassParametersToAFunction()
{
$scheduler = new Scheduler();
$outputFile = __DIR__ . '/../tmp/output.txt';
$scheduler->call(function ($phrase) {
return $phrase;
}, [
'Hello World!',
])->output($outputFile);
@unlink($outputFile);
$this->assertFalse(file_exists($outputFile));
$scheduler->run();
$this->assertNotEquals('Hello', file_get_contents($outputFile));
$this->assertEquals('Hello World!', file_get_contents($outputFile));
@unlink($outputFile);
}
public function testShouldKeepTrackOfFailedJobs()
{
$scheduler = new Scheduler();
$exception = new \Exception('Something failed');
$scheduler->call(function () use ($exception) {
throw $exception;
});
$this->assertEquals(count($scheduler->getFailedJobs()), 0);
$scheduler->run();
$this->assertEquals(count($scheduler->getExecutedJobs()), 0);
$this->assertEquals(count($scheduler->getFailedJobs()), 1);
$failedJob = $scheduler->getFailedJobs()[0];
$this->assertInstanceOf(FailedJob::class, $failedJob);
$this->assertSame($exception, $failedJob->getException());
$this->assertInstanceOf(Job::class, $failedJob->getJob());
}
public function testShouldKeepExecutingJobsIfOneFails()
{
$scheduler = new Scheduler();
$scheduler->call(function () {
throw new \Exception('Something failed');
});
$scheduler->call(function () {
return true;
});
$scheduler->run();
$this->assertEquals(count($scheduler->getExecutedJobs()), 1);
$this->assertEquals(count($scheduler->getFailedJobs()), 1);
}
public function testShouldInjectConfigToTheJobs()
{
$schedulerConfig = [
'email' => [
'subject' => 'My custom subject',
],
];
$scheduler = new Scheduler($schedulerConfig);
$job = $scheduler->raw('ls');
$this->assertEquals($job->getEmailConfig()['subject'], $schedulerConfig['email']['subject']);
}
public function testShouldPrioritizeJobConfigOverSchedulerConfig()
{
$schedulerConfig = [
'email' => [
'subject' => 'My custom subject',
],
];
$scheduler = new Scheduler($schedulerConfig);
$jobConfig = [
'email' => [
'subject' => 'My job subject',
],
];
$job = $scheduler->raw('ls')->configure($jobConfig);
$this->assertNotEquals($job->getEmailConfig()['subject'], $schedulerConfig['email']['subject']);
$this->assertEquals($job->getEmailConfig()['subject'], $jobConfig['email']['subject']);
}
public function testShouldShowClosuresVerboseOutputAsText()
{
$scheduler = new Scheduler();
$scheduler->call(function ($phrase) {
return $phrase;
}, [
'Hello World!',
]);
$scheduler->run();
$this->assertMatchesRegularExpression('/ Executing Closure$/', $scheduler->getVerboseOutput());
$this->assertMatchesRegularExpression('/ Executing Closure$/', $scheduler->getVerboseOutput('text'));
}
public function testShouldShowClosuresVerboseOutputAsHtml()
{
$scheduler = new Scheduler();
$scheduler->call(function ($phrase) {
return $phrase;
}, [
'Hello World!',
]);
$scheduler->call(function () {
return true;
});
$scheduler->run();
$this->assertMatchesRegularExpression('/<br>/', $scheduler->getVerboseOutput('html'));
}
public function testShouldShowClosuresVerboseOutputAsArray()
{
$scheduler = new Scheduler();
$scheduler->call(function ($phrase) {
return $phrase;
}, [
'Hello World!',
]);
$scheduler->call(function () {
return true;
});
$scheduler->run();
$this->assertTrue(is_array($scheduler->getVerboseOutput('array')));
$this->assertEquals(count($scheduler->getVerboseOutput('array')), 2);
}
public function testShouldThrowExceptionWithInvalidOutputType()
{
$this->expectException(\InvalidArgumentException::class);
$scheduler = new Scheduler();
$scheduler->call(function ($phrase) {
return $phrase;
}, [
'Hello World!',
]);
$scheduler->call(function () {
return true;
});
$scheduler->run();
$scheduler->getVerboseOutput('multiline');
}
public function testShouldPrioritizeJobsInBackround()
{
$scheduler = new Scheduler();
$scheduler->php(__DIR__ . '/../async_job.php', null, null, 'async_foreground')->then(function () {
return true;
});
$scheduler->php(__DIR__ . '/../async_job.php', null, null, 'async_background');
$jobs = $scheduler->getQueuedJobs();
$this->assertEquals('async_background', $jobs[0]->getId());
$this->assertEquals('async_foreground', $jobs[1]->getId());
}
public function testCouldRunTwice()
{
$scheduler = new Scheduler();
$scheduler->call(function () {
return true;
});
$scheduler->run();
$this->assertCount(1, $scheduler->getExecutedJobs(), 'Number of executed jobs');
$scheduler->resetRun();
$scheduler->run();
$this->assertCount(1, $scheduler->getExecutedJobs(), 'Number of executed jobs');
}
public function testClearJobs()
{
$scheduler = new Scheduler();
$scheduler->call(function () {
return true;
});
$this->assertCount(1, $scheduler->getQueuedJobs(), 'Number of queued jobs');
$scheduler->clearJobs();
$this->assertCount(0, $scheduler->getQueuedJobs(), 'Number of queued jobs');
}
public function testShouldRunDelayedJobsIfDueWhenCreated()
{
$scheduler = new Scheduler();
$currentTime = date('H:i');
$scheduler->call(function () {
$s = (int) date('s');
sleep(60 - $s + 1);
})->daily($currentTime);
$scheduler->call(function () {
// do nothing
})->daily($currentTime);
$executed = $scheduler->run();
$this->assertEquals(2, count($executed));
}
public function testShouldRunAtSpecificTime()
{
$scheduler = new Scheduler();
$runTime = new DateTime('2017-09-13 00:00:00');
$scheduler->call(function () {
// do nothing
})->daily('00:00');
$executed = $scheduler->run($runTime);
$this->assertEquals(1, count($executed));
}
}

View File

@ -0,0 +1,4 @@
<?php
sleep(5);
echo 'hi';

View File

@ -0,0 +1,7 @@
<?php
echo 'hi';
if (in_array('fail', $argv)) {
exit(1);
}

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,207 @@
Changelog
=========
## UNRELEASED
## 1.11.0
### Added
* Added explicit (non magic) `allNullOr*` methods, with `@psalm-assert` annotations, for better Psalm support.
### Changed
* Trait methods will now check the assertion themselves, instead of using `__callStatic`
* `isList` will now deal correctly with (modified) lists that contain `NaN`
* `reportInvalidArgument` now has a return type of `never`.
### Removed
* Removed `symfony/polyfill-ctype` as a dependency, and require `ext-cytpe` instead.
* You can still require the `symfony/polyfill-ctype` in your project if you need it, as it provides `ext-ctype`
## 1.10.0
### Added
* On invalid assertion, we throw a `Webmozart\Assert\InvalidArgumentException`
* Added `Assert::positiveInteger()`
### Changed
* Using a trait with real implementations of `all*()` and `nullOr*()` methods to improve psalm compatibility.
### Removed
* Support for PHP <7.2
## 1.9.1
## Fixed
* provisional support for PHP 8.0
## 1.9.0
* added better Psalm support for `all*` & `nullOr*` methods
* These methods are now understood by Psalm through a mixin. You may need a newer version of Psalm in order to use this
* added `@psalm-pure` annotation to `Assert::notFalse()`
* added more `@psalm-assert` annotations where appropriate
## Changed
* the `all*` & `nullOr*` methods are now declared on an interface, instead of `@method` annotations.
This interface is linked to the `Assert` class with a `@mixin` annotation. Most IDE's have supported this
for a long time, and you should not lose any autocompletion capabilities. PHPStan has supported this since
version `0.12.20`. This package is marked incompatible (with a composer conflict) with phpstan version prior to that.
If you do not use PHPStan than this does not matter.
## 1.8.0
### Added
* added `Assert::notStartsWith()`
* added `Assert::notEndsWith()`
* added `Assert::inArray()`
* added `@psalm-pure` annotations to pure assertions
### Fixed
* Exception messages of comparisons between `DateTime(Immutable)` objects now display their date & time.
* Custom Exception messages for `Assert::count()` now use the values to render the exception message.
## 1.7.0 (2020-02-14)
### Added
* added `Assert::notFalse()`
* added `Assert::isAOf()`
* added `Assert::isAnyOf()`
* added `Assert::isNotA()`
## 1.6.0 (2019-11-24)
### Added
* added `Assert::validArrayKey()`
* added `Assert::isNonEmptyList()`
* added `Assert::isNonEmptyMap()`
* added `@throws InvalidArgumentException` annotations to all methods that throw.
* added `@psalm-assert` for the list type to the `isList` assertion.
### Fixed
* `ResourceBundle` & `SimpleXMLElement` now pass the `isCountable` assertions.
They are countable, without implementing the `Countable` interface.
* The doc block of `range` now has the proper variables.
* An empty array will now pass `isList` and `isMap`. As it is a valid form of both.
If a non-empty variant is needed, use `isNonEmptyList` or `isNonEmptyMap`.
### Changed
* Removed some `@psalm-assert` annotations, that were 'side effect' assertions See:
* [#144](https://github.com/webmozart/assert/pull/144)
* [#145](https://github.com/webmozart/assert/issues/145)
* [#146](https://github.com/webmozart/assert/pull/146)
* [#150](https://github.com/webmozart/assert/pull/150)
* If you use Psalm, the minimum version needed is `3.6.0`. Which is enforced through a composer conflict.
If you don't use Psalm, then this has no impact.
## 1.5.0 (2019-08-24)
### Added
* added `Assert::uniqueValues()`
* added `Assert::unicodeLetters()`
* added: `Assert::email()`
* added support for [Psalm](https://github.com/vimeo/psalm), by adding `@psalm-assert` annotations where appropriate.
### Fixed
* `Assert::endsWith()` would not give the correct result when dealing with a multibyte suffix.
* `Assert::length(), minLength, maxLength, lengthBetween` would not give the correct result when dealing with multibyte characters.
**NOTE**: These 2 changes may break your assertions if you relied on the fact that multibyte characters didn't behave correctly.
### Changed
* The names of some variables have been updated to better reflect what they are.
* All function calls are now in their FQN form, slightly increasing performance.
* Tests are now properly ran against HHVM-3.30 and PHP nightly.
### Deprecation
* deprecated `Assert::isTraversable()` in favor of `Assert::isIterable()`
* This was already done in 1.3.0, but it was only done through a silenced `trigger_error`. It is now annotated as well.
## 1.4.0 (2018-12-25)
### Added
* added `Assert::ip()`
* added `Assert::ipv4()`
* added `Assert::ipv6()`
* added `Assert::notRegex()`
* added `Assert::interfaceExists()`
* added `Assert::isList()`
* added `Assert::isMap()`
* added polyfill for ctype
### Fixed
* Special case when comparing objects implementing `__toString()`
## 1.3.0 (2018-01-29)
### Added
* added `Assert::minCount()`
* added `Assert::maxCount()`
* added `Assert::countBetween()`
* added `Assert::isCountable()`
* added `Assert::notWhitespaceOnly()`
* added `Assert::natural()`
* added `Assert::notContains()`
* added `Assert::isArrayAccessible()`
* added `Assert::isInstanceOfAny()`
* added `Assert::isIterable()`
### Fixed
* `stringNotEmpty` will no longer report "0" is an empty string
### Deprecation
* deprecated `Assert::isTraversable()` in favor of `Assert::isIterable()`
## 1.2.0 (2016-11-23)
* added `Assert::throws()`
* added `Assert::count()`
* added extension point `Assert::reportInvalidArgument()` for custom subclasses
## 1.1.0 (2016-08-09)
* added `Assert::object()`
* added `Assert::propertyExists()`
* added `Assert::propertyNotExists()`
* added `Assert::methodExists()`
* added `Assert::methodNotExists()`
* added `Assert::uuid()`
## 1.0.2 (2015-08-24)
* integrated Style CI
* add tests for minimum package dependencies on Travis CI
## 1.0.1 (2015-05-12)
* added support for PHP 5.3.3
## 1.0.0 (2015-05-12)
* first stable release
## 1.0.0-beta (2015-03-19)
* first beta release

20
web/app/vendor/webmozart/assert/LICENSE vendored Normal file
View File

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2014 Bernhard Schussek
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.

View File

@ -0,0 +1,287 @@
Webmozart Assert
================
[![Latest Stable Version](https://poser.pugx.org/webmozart/assert/v/stable.svg)](https://packagist.org/packages/webmozart/assert)
[![Total Downloads](https://poser.pugx.org/webmozart/assert/downloads.svg)](https://packagist.org/packages/webmozart/assert)
This library contains efficient assertions to test the input and output of
your methods. With these assertions, you can greatly reduce the amount of coding
needed to write a safe implementation.
All assertions in the [`Assert`] class throw an `Webmozart\Assert\InvalidArgumentException` if
they fail.
FAQ
---
**What's the difference to [beberlei/assert]?**
This library is heavily inspired by Benjamin Eberlei's wonderful [assert package],
but fixes a usability issue with error messages that can't be fixed there without
breaking backwards compatibility.
This package features usable error messages by default. However, you can also
easily write custom error messages:
```
Assert::string($path, 'The path is expected to be a string. Got: %s');
```
In [beberlei/assert], the ordering of the `%s` placeholders is different for
every assertion. This package, on the contrary, provides consistent placeholder
ordering for all assertions:
* `%s`: The tested value as string, e.g. `"/foo/bar"`.
* `%2$s`, `%3$s`, ...: Additional assertion-specific values, e.g. the
minimum/maximum length, allowed values, etc.
Check the source code of the assertions to find out details about the additional
available placeholders.
Installation
------------
Use [Composer] to install the package:
```bash
composer require webmozart/assert
```
Example
-------
```php
use Webmozart\Assert\Assert;
class Employee
{
public function __construct($id)
{
Assert::integer($id, 'The employee ID must be an integer. Got: %s');
Assert::greaterThan($id, 0, 'The employee ID must be a positive integer. Got: %s');
}
}
```
If you create an employee with an invalid ID, an exception is thrown:
```php
new Employee('foobar');
// => Webmozart\Assert\InvalidArgumentException:
// The employee ID must be an integer. Got: string
new Employee(-10);
// => Webmozart\Assert\InvalidArgumentException:
// The employee ID must be a positive integer. Got: -10
```
Assertions
----------
The [`Assert`] class provides the following assertions:
### Type Assertions
Method | Description
-------------------------------------------------------- | --------------------------------------------------
`string($value, $message = '')` | Check that a value is a string
`stringNotEmpty($value, $message = '')` | Check that a value is a non-empty string
`integer($value, $message = '')` | Check that a value is an integer
`integerish($value, $message = '')` | Check that a value casts to an integer
`positiveInteger($value, $message = '')` | Check that a value is a positive (non-zero) integer
`float($value, $message = '')` | Check that a value is a float
`numeric($value, $message = '')` | Check that a value is numeric
`natural($value, $message= ''')` | Check that a value is a non-negative integer
`boolean($value, $message = '')` | Check that a value is a boolean
`scalar($value, $message = '')` | Check that a value is a scalar
`object($value, $message = '')` | Check that a value is an object
`resource($value, $type = null, $message = '')` | Check that a value is a resource
`isCallable($value, $message = '')` | Check that a value is a callable
`isArray($value, $message = '')` | Check that a value is an array
`isTraversable($value, $message = '')` (deprecated) | Check that a value is an array or a `\Traversable`
`isIterable($value, $message = '')` | Check that a value is an array or a `\Traversable`
`isCountable($value, $message = '')` | Check that a value is an array or a `\Countable`
`isInstanceOf($value, $class, $message = '')` | Check that a value is an `instanceof` a class
`isInstanceOfAny($value, array $classes, $message = '')` | Check that a value is an `instanceof` at least one class on the array of classes
`notInstanceOf($value, $class, $message = '')` | Check that a value is not an `instanceof` a class
`isAOf($value, $class, $message = '')` | Check that a value is of the class or has one of its parents
`isAnyOf($value, array $classes, $message = '')` | Check that a value is of at least one of the classes or has one of its parents
`isNotA($value, $class, $message = '')` | Check that a value is not of the class or has not one of its parents
`isArrayAccessible($value, $message = '')` | Check that a value can be accessed as an array
`uniqueValues($values, $message = '')` | Check that the given array contains unique values
### Comparison Assertions
Method | Description
----------------------------------------------- | ------------------------------------------------------------------
`true($value, $message = '')` | Check that a value is `true`
`false($value, $message = '')` | Check that a value is `false`
`notFalse($value, $message = '')` | Check that a value is not `false`
`null($value, $message = '')` | Check that a value is `null`
`notNull($value, $message = '')` | Check that a value is not `null`
`isEmpty($value, $message = '')` | Check that a value is `empty()`
`notEmpty($value, $message = '')` | Check that a value is not `empty()`
`eq($value, $value2, $message = '')` | Check that a value equals another (`==`)
`notEq($value, $value2, $message = '')` | Check that a value does not equal another (`!=`)
`same($value, $value2, $message = '')` | Check that a value is identical to another (`===`)
`notSame($value, $value2, $message = '')` | Check that a value is not identical to another (`!==`)
`greaterThan($value, $value2, $message = '')` | Check that a value is greater than another
`greaterThanEq($value, $value2, $message = '')` | Check that a value is greater than or equal to another
`lessThan($value, $value2, $message = '')` | Check that a value is less than another
`lessThanEq($value, $value2, $message = '')` | Check that a value is less than or equal to another
`range($value, $min, $max, $message = '')` | Check that a value is within a range
`inArray($value, array $values, $message = '')` | Check that a value is one of a list of values
`oneOf($value, array $values, $message = '')` | Check that a value is one of a list of values (alias of `inArray`)
### String Assertions
You should check that a value is a string with `Assert::string()` before making
any of the following assertions.
Method | Description
--------------------------------------------------- | -----------------------------------------------------------------
`contains($value, $subString, $message = '')` | Check that a string contains a substring
`notContains($value, $subString, $message = '')` | Check that a string does not contain a substring
`startsWith($value, $prefix, $message = '')` | Check that a string has a prefix
`notStartsWith($value, $prefix, $message = '')` | Check that a string does not have a prefix
`startsWithLetter($value, $message = '')` | Check that a string starts with a letter
`endsWith($value, $suffix, $message = '')` | Check that a string has a suffix
`notEndsWith($value, $suffix, $message = '')` | Check that a string does not have a suffix
`regex($value, $pattern, $message = '')` | Check that a string matches a regular expression
`notRegex($value, $pattern, $message = '')` | Check that a string does not match a regular expression
`unicodeLetters($value, $message = '')` | Check that a string contains Unicode letters only
`alpha($value, $message = '')` | Check that a string contains letters only
`digits($value, $message = '')` | Check that a string contains digits only
`alnum($value, $message = '')` | Check that a string contains letters and digits only
`lower($value, $message = '')` | Check that a string contains lowercase characters only
`upper($value, $message = '')` | Check that a string contains uppercase characters only
`length($value, $length, $message = '')` | Check that a string has a certain number of characters
`minLength($value, $min, $message = '')` | Check that a string has at least a certain number of characters
`maxLength($value, $max, $message = '')` | Check that a string has at most a certain number of characters
`lengthBetween($value, $min, $max, $message = '')` | Check that a string has a length in the given range
`uuid($value, $message = '')` | Check that a string is a valid UUID
`ip($value, $message = '')` | Check that a string is a valid IP (either IPv4 or IPv6)
`ipv4($value, $message = '')` | Check that a string is a valid IPv4
`ipv6($value, $message = '')` | Check that a string is a valid IPv6
`email($value, $message = '')` | Check that a string is a valid e-mail address
`notWhitespaceOnly($value, $message = '')` | Check that a string contains at least one non-whitespace character
### File Assertions
Method | Description
----------------------------------- | --------------------------------------------------
`fileExists($value, $message = '')` | Check that a value is an existing path
`file($value, $message = '')` | Check that a value is an existing file
`directory($value, $message = '')` | Check that a value is an existing directory
`readable($value, $message = '')` | Check that a value is a readable path
`writable($value, $message = '')` | Check that a value is a writable path
### Object Assertions
Method | Description
----------------------------------------------------- | --------------------------------------------------
`classExists($value, $message = '')` | Check that a value is an existing class name
`subclassOf($value, $class, $message = '')` | Check that a class is a subclass of another
`interfaceExists($value, $message = '')` | Check that a value is an existing interface name
`implementsInterface($value, $class, $message = '')` | Check that a class implements an interface
`propertyExists($value, $property, $message = '')` | Check that a property exists in a class/object
`propertyNotExists($value, $property, $message = '')` | Check that a property does not exist in a class/object
`methodExists($value, $method, $message = '')` | Check that a method exists in a class/object
`methodNotExists($value, $method, $message = '')` | Check that a method does not exist in a class/object
### Array Assertions
Method | Description
-------------------------------------------------- | ------------------------------------------------------------------
`keyExists($array, $key, $message = '')` | Check that a key exists in an array
`keyNotExists($array, $key, $message = '')` | Check that a key does not exist in an array
`validArrayKey($key, $message = '')` | Check that a value is a valid array key (int or string)
`count($array, $number, $message = '')` | Check that an array contains a specific number of elements
`minCount($array, $min, $message = '')` | Check that an array contains at least a certain number of elements
`maxCount($array, $max, $message = '')` | Check that an array contains at most a certain number of elements
`countBetween($array, $min, $max, $message = '')` | Check that an array has a count in the given range
`isList($array, $message = '')` | Check that an array is a non-associative list
`isNonEmptyList($array, $message = '')` | Check that an array is a non-associative list, and not empty
`isMap($array, $message = '')` | Check that an array is associative and has strings as keys
`isNonEmptyMap($array, $message = '')` | Check that an array is associative and has strings as keys, and is not empty
### Function Assertions
Method | Description
------------------------------------------- | -----------------------------------------------------------------------------------------------------
`throws($closure, $class, $message = '')` | Check that a function throws a certain exception. Subclasses of the exception class will be accepted.
### Collection Assertions
All of the above assertions can be prefixed with `all*()` to test the contents
of an array or a `\Traversable`:
```php
Assert::allIsInstanceOf($employees, 'Acme\Employee');
```
### Nullable Assertions
All of the above assertions can be prefixed with `nullOr*()` to run the
assertion only if it the value is not `null`:
```php
Assert::nullOrString($middleName, 'The middle name must be a string or null. Got: %s');
```
### Extending Assert
The `Assert` class comes with a few methods, which can be overridden to change the class behaviour. You can also extend it to
add your own assertions.
#### Overriding methods
Overriding the following methods in your assertion class allows you to change the behaviour of the assertions:
* `public static function __callStatic($name, $arguments)`
* This method is used to 'create' the `nullOr` and `all` versions of the assertions.
* `protected static function valueToString($value)`
* This method is used for error messages, to convert the value to a string value for displaying. You could use this for representing a value object with a `__toString` method for example.
* `protected static function typeToString($value)`
* This method is used for error messages, to convert the a value to a string representing its type.
* `protected static function strlen($value)`
* This method is used to calculate string length for relevant methods, using the `mb_strlen` if available and useful.
* `protected static function reportInvalidArgument($message)`
* This method is called when an assertion fails, with the specified error message. Here you can throw your own exception, or log something.
## Static analysis support
Where applicable, assertion functions are annotated to support Psalm's
[Assertion syntax](https://psalm.dev/docs/annotating_code/assertion_syntax/).
A dedicated [PHPStan Plugin](https://github.com/phpstan/phpstan-webmozart-assert) is
required for proper type support.
Authors
-------
* [Bernhard Schussek] a.k.a. [@webmozart]
* [The Community Contributors]
Contribute
----------
Contributions to the package are always welcome!
* Report any bugs or issues you find on the [issue tracker].
* You can grab the source code at the package's [Git repository].
License
-------
All contents of this package are licensed under the [MIT license].
[beberlei/assert]: https://github.com/beberlei/assert
[assert package]: https://github.com/beberlei/assert
[Composer]: https://getcomposer.org
[Bernhard Schussek]: https://webmozarts.com
[The Community Contributors]: https://github.com/webmozart/assert/graphs/contributors
[issue tracker]: https://github.com/webmozart/assert/issues
[Git repository]: https://github.com/webmozart/assert
[@webmozart]: https://twitter.com/webmozart
[MIT license]: LICENSE
[`Assert`]: src/Assert.php

View File

@ -0,0 +1,43 @@
{
"name": "webmozart/assert",
"description": "Assertions to validate method input/output with nice error messages.",
"license": "MIT",
"keywords": [
"assert",
"check",
"validate"
],
"authors": [
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
}
],
"require": {
"php": "^7.2 || ^8.0",
"ext-ctype": "*"
},
"require-dev": {
"phpunit/phpunit": "^8.5.13"
},
"conflict": {
"phpstan/phpstan": "<0.12.20",
"vimeo/psalm": "<4.6.1 || 4.6.2"
},
"autoload": {
"psr-4": {
"Webmozart\\Assert\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Webmozart\\Assert\\Tests\\": "tests/",
"Webmozart\\Assert\\Bin\\": "bin/src"
}
},
"extra": {
"branch-alias": {
"dev-master": "1.10-dev"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
<?php
/*
* This file is part of the webmozart/assert package.
*
* (c) Bernhard Schussek <bschussek@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Webmozart\Assert;
class InvalidArgumentException extends \InvalidArgumentException
{
}

File diff suppressed because it is too large Load Diff

View File

@ -79,6 +79,8 @@ initProgress(){
#Start services
service ntp restart
service apache2 restart
service cron start
echo "* * * * * root /usr/bin/php7.4 /opt/uoj/web/app/scheduler.php" >> /etc/crontab
mkdir -p /opt/uoj/web/app/storage/submission
mkdir -p /opt/uoj/web/app/storage/tmp
mkdir -p /opt/uoj/web/app/storage/image_hosting