diff --git a/mailpoet/lib/Automation/Engine/WordPress.php b/mailpoet/lib/Automation/Engine/WordPress.php index b3a86b57cc..752c65f783 100644 --- a/mailpoet/lib/Automation/Engine/WordPress.php +++ b/mailpoet/lib/Automation/Engine/WordPress.php @@ -2,6 +2,7 @@ namespace MailPoet\Automation\Engine; +use DateTimeZone; use WP_User; class WordPress { @@ -23,6 +24,10 @@ class WordPress { return apply_filters($hookName, $value, ...$args); } + public function wpTimezone(): DateTimeZone { + return wp_timezone(); + } + public function wpGetCurrentUser(): WP_User { return wp_get_current_user(); } diff --git a/mailpoet/lib/Automation/Integrations/Core/CoreIntegration.php b/mailpoet/lib/Automation/Integrations/Core/CoreIntegration.php index 87f696a190..c5737b5905 100644 --- a/mailpoet/lib/Automation/Integrations/Core/CoreIntegration.php +++ b/mailpoet/lib/Automation/Integrations/Core/CoreIntegration.php @@ -4,16 +4,22 @@ namespace MailPoet\Automation\Integrations\Core; use MailPoet\Automation\Engine\Integration; use MailPoet\Automation\Engine\Registry; +use MailPoet\Automation\Engine\WordPress; use MailPoet\Automation\Integrations\Core\Actions\DelayAction; class CoreIntegration implements Integration { /** @var DelayAction */ private $delayAction; + /** @var WordPress */ + private $wordPress; + public function __construct( - DelayAction $delayAction + DelayAction $delayAction, + WordPress $wordPress ) { $this->delayAction = $delayAction; + $this->wordPress = $wordPress; } public function register(Registry $registry): void { @@ -23,6 +29,7 @@ class CoreIntegration implements Integration { $registry->addFilter(new Filters\NumberFilter()); $registry->addFilter(new Filters\IntegerFilter()); $registry->addFilter(new Filters\StringFilter()); + $registry->addFilter(new Filters\DateTimeFilter($this->wordPress->wpTimezone())); $registry->addFilter(new Filters\EnumFilter()); $registry->addFilter(new Filters\EnumArrayFilter()); } diff --git a/mailpoet/lib/Automation/Integrations/Core/Filters/DateTimeFilter.php b/mailpoet/lib/Automation/Integrations/Core/Filters/DateTimeFilter.php new file mode 100644 index 0000000000..1b8c4dd54f --- /dev/null +++ b/mailpoet/lib/Automation/Integrations/Core/Filters/DateTimeFilter.php @@ -0,0 +1,173 @@ +timezone = $timeZone; + } + + public function getFieldType(): string { + return Field::TYPE_DATETIME; + } + + public function getConditions(): array { + return [ + self::CONDITION_BEFORE => __('before', 'mailpoet'), + self::CONDITION_AFTER => __('after', 'mailpoet'), + self::CONDITION_ON => __('on', 'mailpoet'), + self::CONDITION_NOT_ON => __('not on', 'mailpoet'), + self::CONDITION_IN_THE_LAST => __('in the last', 'mailpoet'), + self::CONDITION_NOT_IN_THE_LAST => __('not in the last', 'mailpoet'), + self::CONDITION_IS_SET => __('is set', 'mailpoet'), + self::CONDITION_IS_NOT_SET => __('is not set', 'mailpoet'), + self::CONDITION_ON_THE_DAYS_OF_THE_WEEK => __('on the day(s) of the week', 'mailpoet'), + ]; + } + + public function getArgsSchema(): ObjectSchema { + return Builder::object([ + 'value' => Builder::oneOf([ + Builder::string()->pattern(self::REGEX_DATETIME), + Builder::string()->pattern(self::REGEX_DATE), + Builder::array(Builder::integer()->minimum(0)->maximum(6))->minItems(1), + Builder::object([ + 'number' => Builder::integer()->minimum(1)->required(), + 'unit' => Builder::string()->pattern('^days|weeks|months$')->required(), + ]), + ]), + ]); + } + + public function matches(FilterData $data, $value): bool { + $filterValue = $data->getArgs()['value'] ?? null; + $condition = $data->getCondition(); + + // is set/is not set + if (in_array($condition, [self::CONDITION_IS_SET, self::CONDITION_IS_NOT_SET], true)) { + return $this->matchesSet($condition, $value); + } + + // in the last/not in the last + if (in_array($condition, [self::CONDITION_IN_THE_LAST, self::CONDITION_NOT_IN_THE_LAST], true)) { + return $this->matchesInTheLast($condition, $filterValue, $value); + } + + // on the day(s) of the week + if ($condition === self::CONDITION_ON_THE_DAYS_OF_THE_WEEK) { + return $this->matchesOnTheDaysOfTheWeek($filterValue, $value); + } + + // other conditions + if (!is_string($filterValue) || !$value instanceof DateTimeInterface) { + return false; + } + + $datetime = $this->convertToWpTimezone($value); + switch ($condition) { + case 'before': + $ref = DateTimeImmutable::createFromFormat(self::FORMAT_DATETIME, $filterValue, $this->timezone); + return $ref && $datetime < $ref; + case 'after': + $ref = DateTimeImmutable::createFromFormat(self::FORMAT_DATETIME, $filterValue, $this->timezone); + return $ref && $datetime > $ref; + case 'on': + return $datetime->format(self::FORMAT_DATE) === $filterValue; + case 'not-on': + return $datetime->format(self::FORMAT_DATE) !== $filterValue; + default: + return false; + } + } + + /** @param mixed $value */ + private function matchesSet(string $condition, $value): bool { + switch ($condition) { + case self::CONDITION_IS_SET: + return $value !== null; + case self::CONDITION_IS_NOT_SET: + return $value === null; + default: + return false; + } + } + + /** + * @param mixed $filterValue + * @param mixed $value + */ + private function matchesInTheLast(string $condition, $filterValue, $value): bool { + if (!is_array($filterValue) || !isset($filterValue['number']) || !isset($filterValue['unit']) || !$value instanceof DateTimeInterface) { + return false; + } + + $number = $filterValue['number']; + $unit = $filterValue['unit']; + if (!is_integer($number) || !in_array($unit, ['days', 'weeks', 'months'], true)) { + return false; + } + + $now = new DateTimeImmutable('now', $this->timezone); + $ref = $now->modify("-$number $unit"); + $matches = $ref <= $value && $value <= $now; + return $condition === self::CONDITION_IN_THE_LAST ? $matches : !$matches; + } + + /** + * @param mixed $filterValue + * @param mixed $value + */ + private function matchesOnTheDaysOfTheWeek($filterValue, $value): bool { + if (!is_array($filterValue) || !$value instanceof DateTimeInterface) { + return false; + } + foreach ($filterValue as $day) { + if (!is_integer($day) || $day < 0 || $day > 6) { + return false; + } + } + + $date = $this->convertToWpTimezone($value); + $day = (int)$date->format('w'); + return in_array($day, $filterValue, true); + } + + private function convertToWpTimezone(DateTimeInterface $datetime): DateTimeImmutable { + $value = DateTimeImmutable::createFromFormat('U', (string)$datetime->getTimestamp(), $this->timezone); + if (!$value) { + throw new InvalidStateException('Failed to convert datetime to WP timezone'); + } + return $value; + } +} diff --git a/mailpoet/tests/unit/Automation/Integration/Core/Filters/DateTimeFilterTest.php b/mailpoet/tests/unit/Automation/Integration/Core/Filters/DateTimeFilterTest.php new file mode 100644 index 0000000000..99f9f86bd2 --- /dev/null +++ b/mailpoet/tests/unit/Automation/Integration/Core/Filters/DateTimeFilterTest.php @@ -0,0 +1,264 @@ +timezone = new DateTimeZone('America/Los_Angeles'); + } + + public function testItReturnsCorrectConfiguration(): void { + $filter = new DateTimeFilter($this->timezone); + $this->assertSame('datetime', $filter->getFieldType()); + $this->assertSame([ + 'before' => 'before', + 'after' => 'after', + 'on' => 'on', + 'not-on' => 'not on', + 'in-the-last' => 'in the last', + 'not-in-the-last' => 'not in the last', + 'is-set' => 'is set', + 'is-not-set' => 'is not set', + 'on-the-days-of-the-week' => 'on the day(s) of the week', + ], $filter->getConditions()); + + $this->assertSame([ + 'type' => 'object', + 'properties' => [ + 'value' => [ + 'oneOf' => [ + [ + 'type' => 'string', + 'pattern' => '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$', + ], + [ + 'type' => 'string', + 'pattern' => '^\d{4}-\d{2}-\d{2}$', + ], + [ + 'type' => 'array', + 'items' => [ + 'type' => 'integer', + 'minimum' => 0, + 'maximum' => 6, + ], + 'minItems' => 1, + ], + [ + 'type' => 'object', + 'properties' => [ + 'number' => [ + 'type' => 'integer', + 'minimum' => 1, + 'required' => true, + ], + 'unit' => [ + 'type' => 'string', + 'pattern' => '^days|weeks|months$', + 'required' => true, + ], + ], + ], + ], + ], + ], + ], $filter->getArgsSchema()->toArray()); + } + + public function testInvalidValues(): void { + $this->assertNotMatches('before', '2023-04-26T16:42', null); + $this->assertNotMatches('before', '2023-04-26T16:42', true); + $this->assertNotMatches('before', '2023-04-26T16:42', 123); + $this->assertNotMatches('before', '2023-04-26T16:42', 'abc'); + $this->assertNotMatches('before', '2023-04-26T16:42', []); + $this->assertNotMatches('before', '2023-04-26T16:42', [1, 2, 3, 'a', 'b', 'c']); + $this->assertNotMatches('before', '2023-04-26T16:42', new stdClass()); + + $this->assertNotMatches('before', null, '2023-04-26T16:42+02:00'); + $this->assertNotMatches('before', true, '2023-04-26T16:42+02:00'); + $this->assertNotMatches('before', 123, '2023-04-26T16:42+02:00'); + $this->assertNotMatches('before', 'abc', '2023-04-26T16:42+02:00'); + $this->assertNotMatches('before', [], '2023-04-26T16:42+02:00'); + $this->assertNotMatches('before', [1, 2, 3, 'a', 'b', 'c'], '2023-04-26T16:42+02:00'); + $this->assertNotMatches('before', new stdClass(), '2023-04-26T16:42+02:00'); + } + + public function testBeforeCondition(): void { + $filterValue = '2023-04-26T16:42:01'; + + $this->assertMatches('before', $filterValue, $this->getDateTime('1900-01-01')); + $this->assertMatches('before', $filterValue, $this->getDateTime('2023-04-25')); + $this->assertMatches('before', $filterValue, $this->getDateTime('2023-04-26', '16:42:00', '+02:00')); + + $this->assertNotMatches('before', $filterValue, $this->getDateTime('2100-01-01')); + $this->assertNotMatches('before', $filterValue, $this->getDateTime('2023-04-27')); + $this->assertNotMatches('before', $filterValue, $this->getDateTime('2023-04-27', '16:42:00', '+01:00')); + } + + public function testAfterCondition(): void { + $filterValue = '2023-04-26T16:42:01'; + + $this->assertMatches('after', $filterValue, $this->getDateTime('2100-01-01')); + $this->assertMatches('after', $filterValue, $this->getDateTime('2023-04-27')); + $this->assertMatches('after', $filterValue, $this->getDateTime('2023-04-27', '16:42:00', '+01:00')); + + $this->assertNotMatches('after', $filterValue, $this->getDateTime('1900-01-01')); + $this->assertNotMatches('after', $filterValue, $this->getDateTime('2023-04-25')); + $this->assertNotMatches('after', $filterValue, $this->getDateTime('2023-04-26', '16:42:00', '+02:00')); + } + + public function testOnCondition(): void { + $filterValue = '2023-04-26'; + + $this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26')); + $this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00')); + $this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59')); + $this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '16:42:01', '+02:00')); + $this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00', '-05:00')); + $this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59', '+05:00')); + + $this->assertNotMatches('on', $filterValue, $this->getDateTime('1900-01-01')); + $this->assertNotMatches('on', $filterValue, $this->getDateTime('2023-04-25')); + $this->assertNotMatches('on', $filterValue, $this->getDateTime('2023-04-27')); + $this->assertNotMatches('on', $filterValue, $this->getDateTime('2100-01-01')); + $this->assertNotMatches('on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00', '+05:00')); + $this->assertNotMatches('on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59', '-05:00')); + } + + public function testNotOnCondition(): void { + $filterValue = '2023-04-26'; + + $this->assertMatches('not-on', $filterValue, $this->getDateTime('1900-01-01')); + $this->assertMatches('not-on', $filterValue, $this->getDateTime('2023-04-25')); + $this->assertMatches('not-on', $filterValue, $this->getDateTime('2023-04-27')); + $this->assertMatches('not-on', $filterValue, $this->getDateTime('2100-01-01')); + $this->assertMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00', '+05:00')); + $this->assertMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59', '-05:00')); + + $this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26')); + $this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00')); + $this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59')); + $this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '16:42:01', '+02:00')); + $this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00', '-05:00')); + $this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59', '+05:00')); + } + + public function testInTheLast(): void { + $filterDays = ['unit' => 'days', 'number' => 3]; + $filterWeeks = ['unit' => 'weeks', 'number' => 3]; + $filterMonths = ['unit' => 'months', 'number' => 3]; + + $now = new DateTimeImmutable('now', $this->timezone); + + $this->assertMatches('in-the-last', $filterDays, $now); + $this->assertMatches('in-the-last', $filterDays, $now->modify('-1 day')); + $this->assertMatches('in-the-last', $filterWeeks, $now->modify('-1 day')); + $this->assertMatches('in-the-last', $filterMonths, $now->modify('-1 day')); + $this->assertMatches('in-the-last', $filterWeeks, $now->modify('-1 week')); + $this->assertMatches('in-the-last', $filterMonths, $now->modify('-1 week')); + $this->assertMatches('in-the-last', $filterMonths, $now->modify('-1 month')); + + $this->assertNotMatches('in-the-last', $filterDays, $now->modify('+1 day')); + $this->assertNotMatches('in-the-last', $filterWeeks, $now->modify('+1 day')); + $this->assertNotMatches('in-the-last', $filterMonths, $now->modify('+1 day')); + $this->assertNotMatches('in-the-last', $filterDays, $now->modify('-4 days')); + $this->assertNotMatches('in-the-last', $filterDays, $now->modify('-1 week')); + $this->assertNotMatches('in-the-last', $filterWeeks, $now->modify('-1 month')); + $this->assertNotMatches('in-the-last', $filterMonths, $now->modify('-4 months')); + } + + public function testNotInTheLast(): void { + $filterDays = ['unit' => 'days', 'number' => 3]; + $filterWeeks = ['unit' => 'weeks', 'number' => 3]; + $filterMonths = ['unit' => 'months', 'number' => 3]; + + $now = new DateTimeImmutable('now', $this->timezone); + + $this->assertMatches('not-in-the-last', $filterDays, $now->modify('+1 day')); + $this->assertMatches('not-in-the-last', $filterWeeks, $now->modify('+1 day')); + $this->assertMatches('not-in-the-last', $filterMonths, $now->modify('+1 day')); + $this->assertMatches('not-in-the-last', $filterDays, $now->modify('-4 days')); + $this->assertMatches('not-in-the-last', $filterDays, $now->modify('-1 week')); + $this->assertMatches('not-in-the-last', $filterWeeks, $now->modify('-1 month')); + $this->assertMatches('not-in-the-last', $filterMonths, $now->modify('-4 months')); + + $this->assertNotMatches('not-in-the-last', $filterDays, $now); + $this->assertNotMatches('not-in-the-last', $filterDays, $now->modify('-1 day')); + $this->assertNotMatches('not-in-the-last', $filterWeeks, $now->modify('-1 day')); + $this->assertNotMatches('not-in-the-last', $filterMonths, $now->modify('-1 day')); + $this->assertNotMatches('not-in-the-last', $filterWeeks, $now->modify('-1 week')); + $this->assertNotMatches('not-in-the-last', $filterMonths, $now->modify('-1 week')); + $this->assertNotMatches('not-in-the-last', $filterMonths, $now->modify('-1 month')); + } + + public function testIsSet(): void { + $now = new DateTimeImmutable('now', $this->timezone); + + $this->assertMatches('is-set', null, $now); + $this->assertNotMatches('is-set', null, null); + } + + public function testIsNotSet(): void { + $now = new DateTimeImmutable('now', $this->timezone); + + $this->assertMatches('is-not-set', null, null); + $this->assertNotMatches('is-not-set', null, $now); + } + + public function testOnTheDaysOfTheWeek(): void { + $filterValue = [1, 3, 5]; // Monday, Wednesday, Friday + + $this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24')); // Monday + $this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-26')); // Wednesday + $this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-28')); // Friday + $this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24', '00:00:00', '-05:00')); + $this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24', '23:59:59', '+05:00')); + + $this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-23')); // Sunday + $this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-25')); // Tuesday + $this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-27')); // Thursday + $this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-29')); // Saturday + $this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-30')); // Sunday + $this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24', '00:00:00', '+05:00')); + $this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24', '23:59:59', '-05:00')); + } + + public function testUnknownCondition(): void { + $value = DateTimeImmutable::createFromFormat(DateTimeImmutable::W3C, '2023-04-26T16:42+02:00'); + $this->assertNotMatches('unknown', '2023-04-26T16:42', $value); + $this->assertNotMatches('unknown', ['2023-04-26T16:42', '2023-04-26T17:42'], $value); + } + + private function getDateTime(string $date, string $time = '12:00:00', string $tzOffset = '+00:00'): DateTimeImmutable { + $datetime = DateTimeImmutable::createFromFormat(DateTimeImmutable::W3C, "{$date}T{$time}{$tzOffset}"); + if ($datetime === false) { + throw new InvalidStateException('Invalid date format'); + } + return $datetime; + } + + private function assertMatches(string $condition, $filterValue, $value): void { + $this->assertTrue($this->matchesFilter($condition, $filterValue, $value)); + } + + private function assertNotMatches(string $condition, $filterValue, $value): void { + $this->assertFalse($this->matchesFilter($condition, $filterValue, $value)); + } + + private function matchesFilter(string $condition, $filterValue, $value): bool { + $filter = new DateTimeFilter($this->timezone); + return $filter->matches(new Filter('f1', 'datetime', '', $condition, ['value' => $filterValue]), $value); + } +}