File manager - Edit - /home/monara/public_html/test.athavaneng.com/Engine.tar
Back
WordPress.php 0000644 00000012273 15073230056 0007213 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine; if (!defined('ABSPATH')) exit; use DateTimeZone; use WP_Comment; use WP_Error; use WP_Locale; use WP_Post; use WP_Term; use WP_User; use wpdb; class WordPress { public function getWpdb(): wpdb { global $wpdb; return $wpdb; } public function addAction(string $hookName, callable $callback, int $priority = 10, int $acceptedArgs = 1): bool { return add_action($hookName, $callback, $priority, $acceptedArgs); } public function removeAction(string $hookName, callable $callback, int $priority = 10): bool { return remove_action($hookName, $callback, $priority); } /** @param mixed ...$arg */ public function doAction(string $hookName, ...$arg): void { do_action($hookName, ...$arg); } /** * @param mixed $value * @param mixed ...$args * @return mixed */ public function applyFilters(string $hookName, $value, ...$args) { return apply_filters($hookName, $value, ...$args); } public function wpTimezone(): DateTimeZone { return wp_timezone(); } public function wpGetCurrentUser(): WP_User { return wp_get_current_user(); } /** @param mixed ...$args */ public function currentUserCan(string $capability, ...$args): bool { return current_user_can($capability, ...$args); } public function registerRestRoute(string $namespace, string $route, array $args = [], bool $override = false): bool { return register_rest_route($namespace, $route, $args, $override); } public function getWpLocale(): WP_Locale { global $wp_locale; return $wp_locale; } /** * @param 'ARRAY_A'|'ARRAY_N'|'OBJECT' $object * @return array|WP_Post|null */ public function getPost(int $id, string $object = OBJECT) { return get_post($id, $object); } /** @return WP_Post[]|int[] */ public function getPosts(array $args = null): array { return get_posts($args); } /** * @param string|array $args * @return WP_Comment[]|int[]|int */ public function getComments($args = '') { return get_comments($args); } /** * @param string $email * @return false|string */ public function isEmail(string $email) { return is_email($email); } /** * @param 'ARRAY_A'|'ARRAY_N'|'OBJECT' $output * @return WP_Comment|array|null */ public function getComment(int $id, string $output = OBJECT) { return get_comment($id, $output); } /** * @param array|string $args * @param array|string $deprecated * @return WP_Term[]|int[]|string[]|string|WP_Error */ public function getTerms($args = [], $deprecated = '') { return get_terms($args, $deprecated); } /** * @param string|int $idOrEmail * @param array $args * @return false|string */ public function getAvatarUrl($idOrEmail, $args = null) { return get_avatar_url($idOrEmail, $args); } /** * @param string $optionName * @param mixed $default * @return false|mixed|void */ public function getOption(string $optionName, $default = false) { return get_option($optionName, $default); } /** * @return string[] */ public function getCommentStatuses(): array { return get_comment_statuses(); } public function getPostStatuses(): array { return get_post_statuses(); } /** * @return array<int,int|string|WP_Term>|string|WP_Error */ public function wpGetPostTerms(int $postId, string $taxonomy, array $args = []) { return wp_get_post_terms($postId, $taxonomy, $args); } /** * @param int|\WP_Comment $comment * @return false|string */ public function wpGetCommentStatus($comment) { return wp_get_comment_status($comment); } /** * @return string[]|\WP_Post_Type[] */ public function getPostTypes(array $args = [], string $output = 'names', string $operator = 'and'): array { return get_post_types($args, $output, $operator); } public function postTypeSupports(string $type, string $feature): bool { return post_type_supports($type, $feature); } /** * @param 'and'|'or' $operator * @return string[]|\WP_Taxonomy[] */ public function getTaxonomies(array $args = [], string $output = 'names', string $operator = 'and'): array { return get_taxonomies($args, $output, $operator); } /** * @return mixed */ public function getCommentMeta(int $commentId, string $key = '', bool $isSingle = false) { return get_comment_meta($commentId, $key, $isSingle); } /** * @param int|WP_Term|object $term * @param string $taxonomy * @param 'ARRAY_A'|'ARRAY_N'|'OBJECT' $output * @param string $filter * @return WP_Term|array|WP_Error|null */ public function getTerm($term, string $taxonomy = '', string $output = OBJECT, string $filter = 'raw') { return get_term($term, $taxonomy, $output, $filter); } /** @return \WP_Taxonomy|false */ public function getTaxonomy(string $name) { return get_taxonomy($name); } /** @return int|string */ public function currentTime(string $type, bool $gmt = false) { return current_time($type, $gmt); } /** * @param string $field * @param string|int $value * @return false|WP_User */ public function getUserBy(string $field, $value) { return get_user_by($field, $value); } } index.php 0000644 00000000006 15073230056 0006361 0 ustar 00 <?php Mappers/index.php 0000644 00000000006 15073230056 0007770 0 ustar 00 <?php Mappers/AutomationMapper.php 0000644 00000006064 15073230056 0012160 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Mappers; if (!defined('ABSPATH')) exit; use DateTimeImmutable; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\AutomationStatistics; use MailPoet\Automation\Engine\Data\NextStep; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Storage\AutomationStatisticsStorage; class AutomationMapper { /** @var AutomationStatisticsStorage */ private $statisticsStorage; public function __construct( AutomationStatisticsStorage $statisticsStorage ) { $this->statisticsStorage = $statisticsStorage; } public function buildAutomation(Automation $automation, AutomationStatistics $statistics = null): array { return [ 'id' => $automation->getId(), 'name' => $automation->getName(), 'status' => $automation->getStatus(), 'created_at' => $automation->getCreatedAt()->format(DateTimeImmutable::W3C), 'updated_at' => $automation->getUpdatedAt()->format(DateTimeImmutable::W3C), 'activated_at' => $automation->getActivatedAt() ? $automation->getActivatedAt()->format(DateTimeImmutable::W3C) : null, 'author' => [ 'id' => $automation->getAuthor()->ID, 'name' => $automation->getAuthor()->display_name, ], 'stats' => $statistics ? $statistics->toArray() : $this->statisticsStorage->getAutomationStats($automation->getId())->toArray(), 'steps' => array_map(function (Step $step) { return [ 'id' => $step->getId(), 'type' => $step->getType(), 'key' => $step->getKey(), 'args' => $step->getArgs(), 'next_steps' => array_map(function (NextStep $nextStep) { return $nextStep->toArray(); }, $step->getNextSteps()), 'filters' => $step->getFilters() ? $step->getFilters()->toArray() : null, ]; }, $automation->getSteps()), 'meta' => (object)$automation->getAllMetas(), ]; } /** @param Automation[] $automations */ public function buildAutomationList(array $automations): array { $statistics = $this->statisticsStorage->getAutomationStatisticsForAutomations(...$automations); return array_map(function (Automation $automation) use ($statistics) { return $this->buildAutomationListItem($automation, $statistics[$automation->getId()]); }, $automations); } private function buildAutomationListItem(Automation $automation, AutomationStatistics $statistics): array { return [ 'id' => $automation->getId(), 'name' => $automation->getName(), 'status' => $automation->getStatus(), 'created_at' => $automation->getCreatedAt()->format(DateTimeImmutable::W3C), 'updated_at' => $automation->getUpdatedAt()->format(DateTimeImmutable::W3C), 'stats' => $statistics->toArray(), 'activated_at' => $automation->getActivatedAt() ? $automation->getActivatedAt()->format(DateTimeImmutable::W3C) : null, 'author' => [ 'id' => $automation->getAuthor()->ID, 'name' => $automation->getAuthor()->display_name, ], ]; } } Utils/index.php 0000644 00000000006 15073230056 0007461 0 ustar 00 <?php Utils/Json.php 0000644 00000001577 15073230056 0007301 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Utils; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; class Json { public static function encode(array $value): string { $json = json_encode((object)$value, JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); $error = json_last_error(); if ($error || $json === false) { throw new InvalidStateException(json_last_error_msg(), (string)$error); } return $json; } public static function decode(string $json): array { $value = json_decode($json, true); $error = json_last_error(); if ($error) { throw new InvalidStateException(json_last_error_msg(), (string)$error); } if (!is_array($value)) { throw Exceptions::jsonNotObject($json); } return $value; } } Hooks.php 0000644 00000003405 15073230056 0006343 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\AutomationRunLog; use MailPoet\Automation\Engine\Data\Step; class Hooks { /** @var WordPress */ private $wordPress; public function __construct( WordPress $wordPress ) { $this->wordPress = $wordPress; } public const INITIALIZE = 'mailpoet/automation/initialize'; public const API_INITIALIZE = 'mailpoet/automation/api/initialize'; public const TRIGGER = 'mailpoet/automation/trigger'; public const AUTOMATION_STEP = 'mailpoet/automation/step'; public const EDITOR_BEFORE_LOAD = 'mailpoet/automation/editor/before_load'; public const AUTOMATION_BEFORE_SAVE = 'mailpoet/automation/before_save'; public const AUTOMATION_STEP_BEFORE_SAVE = 'mailpoet/automation/step/before_save'; public const AUTOMATION_STEP_LOG_AFTER_RUN = 'mailpoet/automation/step/log_after_run'; public const AUTOMATION_RUN_CREATE = 'mailpoet/automation/run/create'; public function doAutomationBeforeSave(Automation $automation): void { $this->wordPress->doAction(self::AUTOMATION_BEFORE_SAVE, $automation); } public function doAutomationStepBeforeSave(Step $step, Automation $automation): void { $this->wordPress->doAction(self::AUTOMATION_STEP_BEFORE_SAVE, $step, $automation); } public function doAutomationStepByKeyBeforeSave(Step $step, Automation $automation): void { $this->wordPress->doAction(self::AUTOMATION_STEP_BEFORE_SAVE . '/key=' . $step->getKey(), $step, $automation); } public function doAutomationStepAfterRun(AutomationRunLog $automationRunLog): void { $this->wordPress->doAction(self::AUTOMATION_STEP_LOG_AFTER_RUN, $automationRunLog); } } Data/NextStep.php 0000644 00000000762 15073230056 0007706 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; class NextStep { /** @var string|null */ protected $id; public function __construct( ?string $id ) { $this->id = $id; } public function getId(): ?string { return $this->id; } public function toArray(): array { return [ 'id' => $this->id, ]; } public static function fromArray(array $data): self { return new self($data['id']); } } Data/index.php 0000644 00000000006 15073230056 0007232 0 ustar 00 <?php Data/Automation.php 0000644 00000014423 15073230056 0010253 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; use DateTimeImmutable; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Utils\Json; class Automation { public const STATUS_ACTIVE = 'active'; public const STATUS_DEACTIVATING = 'deactivating'; public const STATUS_DRAFT = 'draft'; public const STATUS_TRASH = 'trash'; public const STATUS_ALL = [ self::STATUS_ACTIVE, self::STATUS_DEACTIVATING, self::STATUS_DRAFT, self::STATUS_TRASH, ]; /** @var int|null */ private $id; /** @var int|null */ private $versionId; /** @var string */ private $name; /** @var \WP_User */ private $author; /** @var string */ private $status = self::STATUS_DRAFT; /** @var DateTimeImmutable */ private $createdAt; /** @var DateTimeImmutable */ private $updatedAt; /** @var ?DateTimeImmutable */ private $activatedAt = null; /** @var array<string|int, Step> */ private $steps; /** @var array<string, mixed> */ private $meta = []; /** @param array<string, Step> $steps */ public function __construct( string $name, array $steps, \WP_User $author, int $id = null, int $versionId = null ) { $this->name = $name; $this->steps = $steps; $this->author = $author; $this->id = $id; $this->versionId = $versionId; $now = new DateTimeImmutable(); $this->createdAt = $now; $this->updatedAt = $now; } public function getId(): int { if ($this->id === null) { throw InvalidStateException::create()->withMessage('No automation ID was set'); } return $this->id; } public function setId(int $id): void { $this->id = $id; } public function getVersionId(): int { if (!$this->versionId) { throw InvalidStateException::create()->withMessage('No automation version ID was set'); } return $this->versionId; } public function getName(): string { return $this->name; } public function setName(string $name): void { $this->name = $name; $this->setUpdatedAt(); } public function getStatus(): string { return $this->status; } public function setStatus(string $status): void { if ($status === self::STATUS_ACTIVE && $this->status !== self::STATUS_ACTIVE) { $this->activatedAt = new DateTimeImmutable(); } $this->status = $status; $this->setUpdatedAt(); } public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } public function setCreatedAt(DateTimeImmutable $createdAt): void { $this->createdAt = $createdAt; } public function getAuthor(): \WP_User { return $this->author; } public function getUpdatedAt(): DateTimeImmutable { return $this->updatedAt; } public function getActivatedAt(): ?DateTimeImmutable { return $this->activatedAt; } /** @return array<string|int, Step> */ public function getSteps(): array { return $this->steps; } /** * @return array<string|int, Step> */ public function getTriggers(): array { return array_filter( $this->steps, function (Step $step) { return $step->getType() === Step::TYPE_TRIGGER; } ); } /** @param array<string|int, Step> $steps */ public function setSteps(array $steps): void { $this->steps = $steps; $this->setUpdatedAt(); } public function getStep(string $id): ?Step { return $this->steps[$id] ?? null; } public function getTrigger(string $key): ?Step { foreach ($this->steps as $step) { if ($step->getType() === Step::TYPE_TRIGGER && $step->getKey() === $key) { return $step; } } return null; } public function equals(Automation $compare): bool { $compareArray = $compare->toArray(); $currentArray = $this->toArray(); $ignoreValues = [ 'created_at', 'updated_at', ]; foreach ($ignoreValues as $ignore) { unset($compareArray[$ignore]); unset($currentArray[$ignore]); } return $compareArray === $currentArray; } public function needsFullValidation(): bool { return in_array($this->status, [Automation::STATUS_ACTIVE, Automation::STATUS_DEACTIVATING], true); } public function toArray(): array { return [ 'id' => $this->id, 'name' => $this->name, 'status' => $this->status, 'author' => $this->author->ID, 'created_at' => $this->createdAt->format(DateTimeImmutable::W3C), 'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C), 'activated_at' => $this->activatedAt ? $this->activatedAt->format(DateTimeImmutable::W3C) : null, 'steps' => Json::encode( array_map(function (Step $step) { return $step->toArray(); }, $this->steps) ), 'meta' => Json::encode($this->meta), ]; } private function setUpdatedAt(): void { $this->updatedAt = new DateTimeImmutable(); } /** * @param string $key * @return mixed|null */ public function getMeta(string $key) { return $this->meta[$key] ?? null; } public function getAllMetas(): array { return $this->meta; } /** * @param string $key * @param mixed $value * @return void */ public function setMeta(string $key, $value): void { $this->meta[$key] = $value; $this->setUpdatedAt(); } public function deleteMeta(string $key): void { unset($this->meta[$key]); $this->setUpdatedAt(); } public function deleteAllMetas(): void { $this->meta = []; $this->setUpdatedAt(); } public static function fromArray(array $data): self { // TODO: validation $automation = new self( $data['name'], array_map(function (array $stepData): Step { return Step::fromArray($stepData); }, Json::decode($data['steps'])), new \WP_User((int)$data['author']) ); $automation->id = (int)$data['id']; $automation->versionId = (int)$data['version_id']; $automation->status = $data['status']; $automation->createdAt = new DateTimeImmutable($data['created_at']); $automation->updatedAt = new DateTimeImmutable($data['updated_at']); $automation->activatedAt = $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null; $automation->meta = $data['meta'] ? Json::decode($data['meta']) : []; return $automation; } } Data/Field.php 0000644 00000002555 15073230056 0007161 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Integration\Payload; class Field { public const TYPE_BOOLEAN = 'boolean'; public const TYPE_INTEGER = 'integer'; public const TYPE_NUMBER = 'number'; public const TYPE_STRING = 'string'; public const TYPE_ENUM = 'enum'; public const TYPE_ENUM_ARRAY = 'enum_array'; public const TYPE_DATETIME = 'datetime'; /** @var string */ private $key; /** @var string */ private $type; /** @var string */ private $name; /** @var callable */ private $factory; /** @var array */ private $args; public function __construct( string $key, string $type, string $name, callable $factory, array $args = [] ) { $this->key = $key; $this->type = $type; $this->name = $name; $this->factory = $factory; $this->args = $args; } public function getKey(): string { return $this->key; } public function getType(): string { return $this->type; } public function getName(): string { return $this->name; } public function getFactory(): callable { return $this->factory; } /** @return mixed */ public function getValue(Payload $payload) { return $this->getFactory()($payload); } public function getArgs(): array { return $this->args; } } Data/AutomationRun.php 0000644 00000006205 15073230056 0010737 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; use DateTimeImmutable; class AutomationRun { public const STATUS_RUNNING = 'running'; public const STATUS_COMPLETE = 'complete'; public const STATUS_CANCELLED = 'cancelled'; public const STATUS_FAILED = 'failed'; /** @var int */ private $id; /** @var int */ private $automationId; /** @var int */ private $versionId; /** @var string */ private $triggerKey; /** @var string */ private $status = self::STATUS_RUNNING; /** @var DateTimeImmutable */ private $createdAt; /** @var DateTimeImmutable */ private $updatedAt; /** @var Subject[] */ private $subjects; /** * @param Subject[] $subjects */ public function __construct( int $automationId, int $versionId, string $triggerKey, array $subjects, int $id = null ) { $this->automationId = $automationId; $this->versionId = $versionId; $this->triggerKey = $triggerKey; $this->subjects = $subjects; if ($id) { $this->id = $id; } $now = new DateTimeImmutable(); $this->createdAt = $now; $this->updatedAt = $now; } public function getId(): int { return $this->id; } public function setId(int $id): void { $this->id = $id; } public function getAutomationId(): int { return $this->automationId; } public function getVersionId(): int { return $this->versionId; } public function getTriggerKey(): string { return $this->triggerKey; } public function getStatus(): string { return $this->status; } public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } public function getUpdatedAt(): DateTimeImmutable { return $this->updatedAt; } /** @return Subject[] */ public function getSubjects(string $key = null): array { if ($key) { return array_values( array_filter($this->subjects, function (Subject $subject) use ($key) { return $subject->getKey() === $key; }) ); } return $this->subjects; } public function toArray(): array { return [ 'automation_id' => $this->automationId, 'version_id' => $this->versionId, 'trigger_key' => $this->triggerKey, 'status' => $this->status, 'created_at' => $this->createdAt->format(DateTimeImmutable::W3C), 'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C), 'subjects' => array_map(function (Subject $subject): array { return $subject->toArray(); }, $this->subjects), ]; } public static function fromArray(array $data): self { $automationRun = new AutomationRun( (int)$data['automation_id'], (int)$data['version_id'], $data['trigger_key'], array_map(function (array $subject) { return Subject::fromArray($subject); }, $data['subjects']) ); $automationRun->id = (int)$data['id']; $automationRun->status = $data['status']; $automationRun->createdAt = new DateTimeImmutable($data['created_at']); $automationRun->updatedAt = new DateTimeImmutable($data['updated_at']); return $automationRun; } } Data/AutomationStatistics.php 0000644 00000002243 15073230056 0012323 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; class AutomationStatistics { private $automationId; private $versionId; private $entered; private $inProgress; public function __construct( int $automationId, int $entered = 0, int $inProcess = 0, ?int $versionId = null ) { $this->automationId = $automationId; $this->entered = $entered; $this->inProgress = $inProcess; $this->versionId = $versionId; } public function getAutomationId(): int { return $this->automationId; } public function getVersionId(): ?int { return $this->versionId; } public function getEntered(): int { return $this->entered; } public function getInProgress(): int { return $this->inProgress; } public function getExited(): int { return $this->getEntered() - $this->getInProgress(); } public function toArray(): array { return [ 'automation_id' => $this->getAutomationId(), 'totals' => [ 'entered' => $this->getEntered(), 'in_progress' => $this->getInProgress(), 'exited' => $this->getExited(), ], ]; } } Data/AutomationTemplateCategory.php 0000644 00000000746 15073230056 0013450 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; class AutomationTemplateCategory { /** @var string */ private $slug; /** @var string */ private $name; public function __construct( string $slug, string $name ) { $this->slug = $slug; $this->name = $name; } public function getSlug(): string { return $this->slug; } public function getName(): string { return $this->name; } } Data/Step.php 0000644 00000004630 15073230056 0007045 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; class Step { public const TYPE_ROOT = 'root'; public const TYPE_TRIGGER = 'trigger'; public const TYPE_ACTION = 'action'; /** @var string */ private $id; /** @var string */ private $type; /** @var string */ private $key; /** @var array */ protected $args; /** @var NextStep[] */ protected $nextSteps; /** @var Filters|null */ private $filters; /** * @param array<string, mixed> $args * @param NextStep[] $nextSteps */ public function __construct( string $id, string $type, string $key, array $args, array $nextSteps, Filters $filters = null ) { $this->id = $id; $this->type = $type; $this->key = $key; $this->args = $args; $this->nextSteps = $nextSteps; $this->filters = $filters; } public function getId(): string { return $this->id; } public function getType(): string { return $this->type; } public function getKey(): string { return $this->key; } /** @return NextStep[] */ public function getNextSteps(): array { return $this->nextSteps; } public function getNextStepIds(): array { $ids = []; foreach ($this->nextSteps as $nextStep) { $nextStepId = $nextStep->getId(); if ($nextStepId) { $ids[] = $nextStep->getId(); } } return $ids; } /** @param NextStep[] $nextSteps */ public function setNextSteps(array $nextSteps): void { $this->nextSteps = $nextSteps; } public function getArgs(): array { return $this->args; } public function getFilters(): ?Filters { return $this->filters; } public function toArray(): array { return [ 'id' => $this->id, 'type' => $this->type, 'key' => $this->key, 'args' => $this->args, 'next_steps' => array_map(function (NextStep $nextStep) { return $nextStep->toArray(); }, $this->nextSteps), 'filters' => $this->filters ? $this->filters->toArray() : null, ]; } public static function fromArray(array $data): self { return new self( $data['id'], $data['type'], $data['key'], $data['args'], array_map(function (array $nextStep) { return NextStep::fromArray($nextStep); }, $data['next_steps']), isset($data['filters']) ? Filters::fromArray($data['filters']) : null ); } } Data/Filters.php 0000644 00000002013 15073230056 0007533 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; class Filters { public const OPERATOR_AND = 'and'; public const OPERATOR_OR = 'or'; /** @var string */ private $operator; /** @var FilterGroup[] */ private $groups; public function __construct( string $operator, array $groups ) { $this->operator = $operator; $this->groups = $groups; } public function getOperator(): string { return $this->operator; } public function getGroups(): array { return $this->groups; } public function toArray(): array { return [ 'operator' => $this->operator, 'groups' => array_map(function (FilterGroup $group): array { return $group->toArray(); }, $this->groups), ]; } public static function fromArray(array $data): self { return new self( $data['operator'], array_map(function (array $group) { return FilterGroup::fromArray($group); }, $data['groups']) ); } } Data/SubjectEntry.php 0000644 00000002413 15073230056 0010550 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Subject as SubjectData; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Integration\Payload; use MailPoet\Automation\Engine\Integration\Subject; use Throwable; /** * @template-covariant S of Subject<Payload> */ class SubjectEntry { /** @var S */ private $subject; /** @var SubjectData */ private $subjectData; /** @var Payload|null */ private $payloadCache; /** @param S $subject */ public function __construct( Subject $subject, SubjectData $subjectData ) { $this->subject = $subject; $this->subjectData = $subjectData; } /** @return S */ public function getSubject(): Subject { return $this->subject; } public function getSubjectData(): SubjectData { return $this->subjectData; } /** @return Payload */ public function getPayload() { if ($this->payloadCache === null) { try { $this->payloadCache = $this->subject->getPayload($this->subjectData); } catch (Throwable $e) { throw Exceptions::subjectLoadFailed($this->subject->getKey(), $this->subjectData->getArgs()); } } return $this->payloadCache; } } Data/Filter.php 0000644 00000002616 15073230056 0007361 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; class Filter { /** @var string */ private $id; /** @var string */ private $fieldType; /** @var string */ private $fieldKey; /** @var string */ private $condition; /** @var array */ private $args; public function __construct( string $id, string $fieldType, string $fieldKey, string $condition, array $args ) { $this->id = $id; $this->fieldType = $fieldType; $this->fieldKey = $fieldKey; $this->condition = $condition; $this->args = $args; } public function getId(): string { return $this->id; } public function getFieldType(): string { return $this->fieldType; } public function getFieldKey(): string { return $this->fieldKey; } public function getCondition(): string { return $this->condition; } public function getArgs(): array { return $this->args; } public function toArray(): array { return [ 'id' => $this->id, 'field_type' => $this->fieldType, 'field_key' => $this->fieldKey, 'condition' => $this->condition, 'args' => $this->args, ]; } public static function fromArray(array $data): self { return new self( $data['id'], $data['field_type'], $data['field_key'], $data['condition'], $data['args'] ); } } Data/AutomationTemplate.php 0000644 00000003326 15073230056 0011747 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; class AutomationTemplate { public const TYPE_DEFAULT = 'default'; public const TYPE_FREE_ONLY = 'free-only'; public const TYPE_PREMIUM = 'premium'; public const TYPE_COMING_SOON = 'coming-soon'; /** @var string */ private $slug; /** @var string */ private $category; /** @var string */ private $name; /** @var string */ private $description; /** @var callable(): Automation */ private $automationFactory; /** @var string */ private $type; /** @param callable(): Automation $automationFactory */ public function __construct( string $slug, string $category, string $name, string $description, callable $automationFactory, string $type = self::TYPE_DEFAULT ) { $this->slug = $slug; $this->category = $category; $this->name = $name; $this->description = $description; $this->automationFactory = $automationFactory; $this->type = $type; } public function getSlug(): string { return $this->slug; } public function getName(): string { return $this->name; } public function getCategory(): string { return $this->category; } public function getType(): string { return $this->type; } public function getDescription(): string { return $this->description; } public function createAutomation(): Automation { return ($this->automationFactory)(); } public function toArray(): array { return [ 'slug' => $this->getSlug(), 'name' => $this->getName(), 'category' => $this->getCategory(), 'type' => $this->getType(), 'description' => $this->getDescription(), ]; } } Data/FilterGroup.php 0000644 00000002302 15073230056 0010366 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; class FilterGroup { public const OPERATOR_AND = 'and'; public const OPERATOR_OR = 'or'; /** @var string */ private $id; /** @var string */ private $operator; /** @var Filter[] */ private $filters; public function __construct( string $id, string $operator, array $filters ) { $this->id = $id; $this->operator = $operator; $this->filters = $filters; } public function getId(): string { return $this->id; } public function getOperator(): string { return $this->operator; } public function getFilters(): array { return $this->filters; } public function toArray(): array { return [ 'id' => $this->id, 'operator' => $this->operator, 'filters' => array_map(function (Filter $filter): array { return $filter->toArray(); }, $this->filters), ]; } public static function fromArray(array $data): self { return new self( $data['id'], $data['operator'], array_map(function (array $filter) { return Filter::fromArray($filter); }, $data['filters']) ); } } Data/StepValidationArgs.php 0000644 00000003515 15073230056 0011676 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Integration\Payload; use MailPoet\Automation\Engine\Integration\Subject; class StepValidationArgs { /** @var Automation */ private $automation; /** @var Step */ private $step; /** @var array<string, Subject<Payload>> */ private $subjects = []; /** @var array<class-string, string> */ private $subjectKeyClassMap = []; /** @param Subject<Payload>[] $subjects */ public function __construct( Automation $automation, Step $step, array $subjects ) { $this->automation = $automation; $this->step = $step; foreach ($subjects as $subject) { $key = $subject->getKey(); $this->subjects[$key] = $subject; $this->subjectKeyClassMap[get_class($subject)] = $key; } } public function getAutomation(): Automation { return $this->automation; } public function getStep(): Step { return $this->step; } /** @return Subject<Payload>[] */ public function getSubjects(): array { return array_values($this->subjects); } /** @return Subject<Payload> */ public function getSingleSubject(string $key): Subject { $subject = $this->subjects[$key] ?? null; if (!$subject) { throw Exceptions::subjectNotFound($key); } return $subject; } /** * @template P of Payload * @template S of Subject<P> * @param class-string<S> $class * @return S<P> */ public function getSingleSubjectByClass(string $class): Subject { $key = $this->subjectKeyClassMap[$class] ?? null; if (!$key) { throw Exceptions::subjectClassNotFound($class); } /** @var S<P> $subject -- for PHPStan */ $subject = $this->getSingleSubject($key); return $subject; } } Data/AutomationRunLog.php 0000644 00000012313 15073230056 0011376 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; use DateTimeImmutable; use InvalidArgumentException; use MailPoet\Automation\Engine\Utils\Json; use Throwable; class AutomationRunLog { public const STATUS_RUNNING = 'running'; public const STATUS_COMPLETE = 'complete'; public const STATUS_FAILED = 'failed'; public const STATUS_ALL = [ self::STATUS_RUNNING, self::STATUS_COMPLETE, self::STATUS_FAILED, ]; public const TYPE_ACTION = 'action'; public const TYPE_TRIGGER = 'trigger'; public const KEY_UNKNOWN = 'unknown'; /** @var int */ private $id; /** @var int */ private $automationRunId; /** @var string */ private $stepId; /** @var string */ private $stepType; /** @var string */ private $stepKey; /** @var string */ private $status; /** @var DateTimeImmutable */ private $startedAt; /** @var DateTimeImmutable */ private $updatedAt; /** @var int */ private $runNumber = 1; /** @var array */ private $data = []; /** @var array|null */ private $error; public function __construct( int $automationRunId, string $stepId, string $stepType, int $id = null ) { $this->automationRunId = $automationRunId; $this->stepId = $stepId; $this->stepType = $stepType; $this->stepKey = self::KEY_UNKNOWN; $this->status = self::STATUS_RUNNING; $now = new DateTimeImmutable(); $this->startedAt = $now; $this->updatedAt = $now; if ($id) { $this->id = $id; } } public function getId(): int { return $this->id; } public function getAutomationRunId(): int { return $this->automationRunId; } public function getStepId(): string { return $this->stepId; } public function getStepType(): string { return $this->stepType; } public function getStepKey(): string { return $this->stepKey; } public function setStepKey(string $stepKey): void { $this->stepKey = $stepKey; $this->updatedAt = new DateTimeImmutable(); } public function getStatus(): string { return $this->status; } public function setStatus(string $status): void { if (!in_array($status, self::STATUS_ALL, true)) { throw new InvalidArgumentException("Invalid status '$status'."); } $this->status = $status; $this->updatedAt = new DateTimeImmutable(); } public function getStartedAt(): DateTimeImmutable { return $this->startedAt; } public function getUpdatedAt(): DateTimeImmutable { return $this->updatedAt; } public function getRunNumber(): int { return $this->runNumber; } public function setRunNumber(int $runNumber): void { $this->runNumber = $runNumber; } public function setUpdatedAt(DateTimeImmutable $updatedAt): void { $this->updatedAt = $updatedAt; } public function getData(): array { return $this->data; } /** @param mixed $value */ public function setData(string $key, $value): void { if (!$this->isDataStorable($value)) { throw new InvalidArgumentException("Invalid data provided for key '$key'. Only scalar values and arrays of scalar values are allowed."); } $this->data[$key] = $value; $this->updatedAt = new DateTimeImmutable(); } public function getError(): ?array { return $this->error; } public function toArray(): array { return [ 'id' => $this->id, 'automation_run_id' => $this->automationRunId, 'step_id' => $this->stepId, 'step_type' => $this->stepType, 'step_key' => $this->stepKey, 'status' => $this->status, 'started_at' => $this->startedAt->format(DateTimeImmutable::W3C), 'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C), 'run_number' => $this->runNumber, 'data' => Json::encode($this->data), 'error' => $this->error ? Json::encode($this->error) : null, ]; } public function setError(Throwable $error): void { // Normalize all nested objects in error trace to associative arrays. // Empty objects would then get decoded to "[]" instead of "{}". $trace = Json::decode(Json::encode($error->getTrace())); $this->error = [ 'message' => $error->getMessage(), 'errorClass' => get_class($error), 'code' => $error->getCode(), 'trace' => $trace, ]; $this->updatedAt = new DateTimeImmutable(); } public static function fromArray(array $data): self { $log = new AutomationRunLog((int)$data['automation_run_id'], $data['step_id'], $data['step_type']); $log->id = (int)$data['id']; $log->stepKey = $data['step_key']; $log->status = $data['status']; $log->startedAt = new DateTimeImmutable($data['started_at']); $log->updatedAt = new DateTimeImmutable($data['updated_at']); $log->runNumber = (int)$data['run_number']; $log->data = Json::decode($data['data']); $log->error = isset($data['error']) ? Json::decode($data['error']) : null; return $log; } /** @param mixed $data */ private function isDataStorable($data): bool { if (is_scalar($data)) { return true; } if (!is_array($data)) { return false; } foreach ($data as $value) { if (!$this->isDataStorable($value)) { return false; } } return true; } } Data/Subject.php 0000644 00000001615 15073230056 0007531 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Utils\Json; class Subject { /** @var string */ private $key; /** @var array */ private $args; public function __construct( string $key, array $args ) { $this->key = $key; $this->args = $args; } public function getKey(): string { return $this->key; } public function getArgs(): array { return $this->args; } public function getHash(): string { return md5($this->getKey() . serialize($this->getArgs())); } public function toArray(): array { return [ 'key' => $this->getKey(), 'args' => Json::encode($this->getArgs()), 'hash' => $this->getHash(), ]; } public static function fromArray(array $data): self { return new self($data['key'], Json::decode($data['args'])); } } Data/StepRunArgs.php 0000644 00000011012 15073230056 0010337 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Data; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Integration\Payload; use MailPoet\Automation\Engine\Integration\Subject; use Throwable; class StepRunArgs { /** @var Automation */ private $automation; /** @var AutomationRun */ private $automationRun; /** @var Step */ private $step; /** @var array<string, SubjectEntry<Subject<Payload>>[]> */ private $subjectEntries = []; /** @var array<class-string, string> */ private $subjectKeyClassMap = []; /** @var array<string, Field> */ private $fields = []; /** @var array<string, string> */ private $fieldToSubjectMap = []; /** @var int */ private $runNumber; /** @param SubjectEntry<Subject<Payload>>[] $subjectsEntries */ public function __construct( Automation $automation, AutomationRun $automationRun, Step $step, array $subjectsEntries, int $runNumber ) { $this->automation = $automation; $this->step = $step; $this->automationRun = $automationRun; $this->runNumber = $runNumber; foreach ($subjectsEntries as $entry) { $subject = $entry->getSubject(); $key = $subject->getKey(); $this->subjectEntries[$key] = array_merge($this->subjectEntries[$key] ?? [], [$entry]); $this->subjectKeyClassMap[get_class($subject)] = $key; foreach ($subject->getFields() as $field) { $this->fields[$field->getKey()] = $field; $this->fieldToSubjectMap[$field->getKey()] = $key; } } } public function getAutomation(): Automation { return $this->automation; } public function getAutomationRun(): AutomationRun { return $this->automationRun; } public function getStep(): Step { return $this->step; } /** @return array<string, SubjectEntry<Subject<Payload>>[]> */ public function getSubjectEntries(): array { return $this->subjectEntries; } /** @return SubjectEntry<Subject<Payload>> */ public function getSingleSubjectEntry(string $key): SubjectEntry { $subjects = $this->subjectEntries[$key] ?? []; if (count($subjects) === 0) { throw Exceptions::subjectDataNotFound($key, $this->automationRun->getId()); } if (count($subjects) > 1) { throw Exceptions::multipleSubjectsFound($key, $this->automationRun->getId()); } return $subjects[0]; } /** * @template P of Payload * @template S of Subject<P> * @param class-string<S> $class * @return SubjectEntry<S<P>> */ public function getSingleSubjectEntryByClass(string $class): SubjectEntry { $key = $this->subjectKeyClassMap[$class] ?? null; if (!$key) { throw Exceptions::subjectClassNotFound($class); } /** @var SubjectEntry<S<P>> $entry -- for PHPStan */ $entry = $this->getSingleSubjectEntry($key); return $entry; } /** * @template P of Payload * @param class-string<P> $class * @return P */ public function getSinglePayloadByClass(string $class): Payload { $payloads = []; foreach ($this->subjectEntries as $entries) { if (count($entries) > 1) { throw Exceptions::multiplePayloadsFound($class, $this->automationRun->getId()); } $entry = $entries[0]; $payload = $entry->getPayload(); if (get_class($payload) === $class) { $payloads[] = $payload; } } if (count($payloads) === 0) { throw Exceptions::payloadNotFound($class, $this->automationRun->getId()); } if (count($payloads) > 1) { throw Exceptions::multiplePayloadsFound($class, $this->automationRun->getId()); } // ensure PHPStan we're indeed returning an instance of $class $payload = $payloads[0]; if (!$payload instanceof $class) { throw InvalidStateException::create(); } return $payload; } /** @return mixed */ public function getFieldValue(string $key) { $field = $this->fields[$key] ?? null; $subjectKey = $this->fieldToSubjectMap[$key] ?? null; if (!$field || !$subjectKey) { throw Exceptions::fieldNotFound($key); } $entry = $this->getSingleSubjectEntry($subjectKey); try { $value = $field->getValue($entry->getPayload()); } catch (Throwable $e) { throw Exceptions::fieldLoadFailed($field->getKey(), $field->getArgs()); } return $value; } public function getRunNumber(): int { return $this->runNumber; } public function isFirstRun(): bool { return $this->runNumber === 1; } } Endpoints/index.php 0000644 00000000006 15073230056 0010324 0 ustar 00 <?php Endpoints/Automations/AutomationsCreateFromTemplateEndpoint.php 0000644 00000002456 15073230056 0021203 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Endpoints\Automations; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\Request; use MailPoet\API\REST\Response; use MailPoet\Automation\Engine\API\Endpoint; use MailPoet\Automation\Engine\Builder\CreateAutomationFromTemplateController; use MailPoet\Automation\Engine\Mappers\AutomationMapper; use MailPoet\Validator\Builder; class AutomationsCreateFromTemplateEndpoint extends Endpoint { /** @var CreateAutomationFromTemplateController */ private $createAutomationFromTemplateController; /** @var AutomationMapper */ private $automationMapper; public function __construct( CreateAutomationFromTemplateController $createAutomationFromTemplateController, AutomationMapper $automationMapper ) { $this->createAutomationFromTemplateController = $createAutomationFromTemplateController; $this->automationMapper = $automationMapper; } public function handle(Request $request): Response { $automation = $this->createAutomationFromTemplateController->createAutomation((string)$request->getParam('slug')); return new Response($this->automationMapper->buildAutomation($automation)); } public static function getRequestSchema(): array { return [ 'slug' => Builder::string()->required(), ]; } } Endpoints/Automations/index.php 0000644 00000000006 15073230056 0012627 0 ustar 00 <?php Endpoints/Automations/AutomationTemplatesGetEndpoint.php 0000644 00000002051 15073230056 0017662 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Endpoints\Automations; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\Request; use MailPoet\API\REST\Response; use MailPoet\Automation\Engine\API\Endpoint; use MailPoet\Automation\Engine\Data\AutomationTemplate; use MailPoet\Automation\Engine\Registry; use MailPoet\Validator\Builder; class AutomationTemplatesGetEndpoint extends Endpoint { /** @var Registry */ private $registry; public function __construct( Registry $registry ) { $this->registry = $registry; } public function handle(Request $request): Response { /** @var string|null $category */ $category = $request->getParam('category'); $templates = array_values($this->registry->getTemplates($category ? strval($category) : null)); return new Response(array_map(function (AutomationTemplate $automation) { return $automation->toArray(); }, $templates)); } public static function getRequestSchema(): array { return [ 'category' => Builder::string(), ]; } } Endpoints/Automations/AutomationsPutEndpoint.php 0000644 00000002743 15073230056 0016227 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Endpoints\Automations; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\Request; use MailPoet\API\REST\Response; use MailPoet\Automation\Engine\API\Endpoint; use MailPoet\Automation\Engine\Builder\UpdateAutomationController; use MailPoet\Automation\Engine\Mappers\AutomationMapper; use MailPoet\Automation\Engine\Validation\AutomationSchema; use MailPoet\Validator\Builder; class AutomationsPutEndpoint extends Endpoint { /** @var UpdateAutomationController */ private $updateController; /** @var AutomationMapper */ private $automationMapper; public function __construct( UpdateAutomationController $updateController, AutomationMapper $automationMapper ) { $this->updateController = $updateController; $this->automationMapper = $automationMapper; } public function handle(Request $request): Response { $data = $request->getParams(); /** @var int $automationId */ $automationId = $request->getParam('id'); $automation = $this->updateController->updateAutomation(intval($automationId), $data); return new Response($this->automationMapper->buildAutomation($automation)); } public static function getRequestSchema(): array { return [ 'id' => Builder::integer()->required(), 'name' => Builder::string()->minLength(1), 'status' => Builder::string(), 'steps' => AutomationSchema::getStepsSchema(), 'meta' => Builder::object(), ]; } } Endpoints/Automations/AutomationsDeleteEndpoint.php 0000644 00000001747 15073230056 0016664 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Endpoints\Automations; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\Request; use MailPoet\API\REST\Response; use MailPoet\Automation\Engine\API\Endpoint; use MailPoet\Automation\Engine\Builder\DeleteAutomationController; use MailPoet\Validator\Builder; class AutomationsDeleteEndpoint extends Endpoint { /** @var DeleteAutomationController */ private $deleteController; public function __construct( DeleteAutomationController $deleteController ) { $this->deleteController = $deleteController; } public function handle(Request $request): Response { /** @var int $automationId */ $automationId = $request->getParam('id'); $automationId = intval($automationId); $this->deleteController->deleteAutomation($automationId); return new Response(null); } public static function getRequestSchema(): array { return [ 'id' => Builder::integer()->required(), ]; } } Endpoints/Automations/AutomationTemplateGetEndpoint.php 0000644 00000003345 15073230056 0017506 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Endpoints\Automations; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\Request; use MailPoet\API\REST\Response; use MailPoet\Automation\Engine\API\Endpoint; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Mappers\AutomationMapper; use MailPoet\Automation\Engine\Registry; use MailPoet\Automation\Engine\Validation\AutomationValidator; use MailPoet\Validator\Builder; class AutomationTemplateGetEndpoint extends Endpoint { /** @var AutomationMapper */ private $automationMapper; /** @var AutomationValidator */ private $automationValidator; /** @var Registry */ private $registry; public function __construct( AutomationMapper $automationMapper, AutomationValidator $automationValidator, Registry $registry ) { $this->registry = $registry; $this->automationValidator = $automationValidator; $this->automationMapper = $automationMapper; } public function handle(Request $request): Response { /** @var string|null $slug - for PHPStan because strval() doesn't accept a value of mixed */ $slug = $request->getParam('slug'); $slug = strval($slug); $template = $this->registry->getTemplate($slug); if (!$template) { throw Exceptions::automationTemplateNotFound($slug); } $automation = $template->createAutomation(); $automation->setId(0); $this->automationValidator->validate($automation); $data = $template->toArray() + [ 'automation' => $this->automationMapper->buildAutomation($automation), ]; return new Response($data); } public static function getRequestSchema(): array { return [ 'slug' => Builder::string()->required(), ]; } } Endpoints/Automations/AutomationsGetEndpoint.php 0000644 00000002277 15073230056 0016200 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Endpoints\Automations; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\Request; use MailPoet\API\REST\Response; use MailPoet\Automation\Engine\API\Endpoint; use MailPoet\Automation\Engine\Mappers\AutomationMapper; use MailPoet\Automation\Engine\Storage\AutomationStorage; use MailPoet\Validator\Builder; class AutomationsGetEndpoint extends Endpoint { /** @var AutomationMapper */ private $automationMapper; /** @var AutomationStorage */ private $automationStorage; public function __construct( AutomationMapper $automationMapper, AutomationStorage $automationStorage ) { $this->automationMapper = $automationMapper; $this->automationStorage = $automationStorage; } public function handle(Request $request): Response { $status = $request->getParam('status') ? (array)$request->getParam('status') : null; $automations = $this->automationStorage->getAutomations($status); return new Response($this->automationMapper->buildAutomationList($automations)); } public static function getRequestSchema(): array { return [ 'status' => Builder::array(Builder::string()), ]; } } Endpoints/Automations/AutomationsDuplicateEndpoint.php 0000644 00000002421 15073230056 0017362 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Endpoints\Automations; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\Request; use MailPoet\API\REST\Response; use MailPoet\Automation\Engine\API\Endpoint; use MailPoet\Automation\Engine\Builder\DuplicateAutomationController; use MailPoet\Automation\Engine\Mappers\AutomationMapper; use MailPoet\Validator\Builder; class AutomationsDuplicateEndpoint extends Endpoint { /** @var AutomationMapper */ private $automationMapper; /** @var DuplicateAutomationController */ private $duplicateController; public function __construct( DuplicateAutomationController $duplicateController, AutomationMapper $automationMapper ) { $this->automationMapper = $automationMapper; $this->duplicateController = $duplicateController; } public function handle(Request $request): Response { /** @var int $automationId */ $automationId = $request->getParam('id'); $automationId = intval($automationId); $duplicate = $this->duplicateController->duplicateAutomation($automationId); return new Response($this->automationMapper->buildAutomation($duplicate)); } public static function getRequestSchema(): array { return [ 'id' => Builder::integer()->required(), ]; } } Builder/UpdateAutomationController.php 0000644 00000010610 15073230056 0014171 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Builder; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException; use MailPoet\Automation\Engine\Hooks; use MailPoet\Automation\Engine\Storage\AutomationStatisticsStorage; use MailPoet\Automation\Engine\Storage\AutomationStorage; use MailPoet\Automation\Engine\Validation\AutomationValidator; class UpdateAutomationController { /** @var Hooks */ private $hooks; /** @var AutomationStorage */ private $storage; /** @var AutomationStatisticsStorage */ private $statisticsStorage; /** @var AutomationValidator */ private $automationValidator; /** @var UpdateStepsController */ private $updateStepsController; public function __construct( Hooks $hooks, AutomationStorage $storage, AutomationStatisticsStorage $statisticsStorage, AutomationValidator $automationValidator, UpdateStepsController $updateStepsController ) { $this->hooks = $hooks; $this->storage = $storage; $this->statisticsStorage = $statisticsStorage; $this->automationValidator = $automationValidator; $this->updateStepsController = $updateStepsController; } public function updateAutomation(int $id, array $data): Automation { $automation = $this->storage->getAutomation($id); if (!$automation) { throw Exceptions::automationNotFound($id); } $this->validateIfAutomationCanBeUpdated($automation, $data); if (array_key_exists('name', $data)) { $automation->setName($data['name']); } if (array_key_exists('status', $data)) { $this->checkAutomationStatus($data['status']); $automation->setStatus($data['status']); } if (array_key_exists('steps', $data)) { $this->validateAutomationSteps($automation, $data['steps']); $this->updateStepsController->updateSteps($automation, $data['steps']); foreach ($automation->getSteps() as $step) { $this->hooks->doAutomationStepBeforeSave($step, $automation); $this->hooks->doAutomationStepByKeyBeforeSave($step, $automation); } } if (array_key_exists('meta', $data)) { $automation->deleteAllMetas(); foreach ($data['meta'] as $key => $value) { $automation->setMeta($key, $value); } } $this->hooks->doAutomationBeforeSave($automation); $this->automationValidator->validate($automation); $this->storage->updateAutomation($automation); $automation = $this->storage->getAutomation($id); if (!$automation) { throw Exceptions::automationNotFound($id); } return $automation; } /** * This is a temporary validation, see MAILPOET-4744 */ private function validateIfAutomationCanBeUpdated(Automation $automation, array $data): void { if ( !in_array( $automation->getStatus(), [ Automation::STATUS_ACTIVE, Automation::STATUS_DEACTIVATING, ], true ) ) { return; } $statistics = $this->statisticsStorage->getAutomationStats($automation->getId()); if ($statistics->getInProgress() === 0) { return; } if (!isset($data['status']) || $data['status'] === $automation->getStatus()) { throw Exceptions::automationHasActiveRuns($automation->getId()); } } private function checkAutomationStatus(string $status): void { if (!in_array($status, Automation::STATUS_ALL, true)) { // translators: %s is the status. throw UnexpectedValueException::create()->withMessage(sprintf(__('Invalid status: %s', 'mailpoet'), $status)); } } protected function validateAutomationSteps(Automation $automation, array $steps): void { $existingSteps = $automation->getSteps(); if (count($steps) !== count($existingSteps)) { throw Exceptions::automationStructureModificationNotSupported(); } foreach ($steps as $id => $data) { $existingStep = $existingSteps[$id] ?? null; if (!$existingStep || !$this->stepChanged(Step::fromArray($data), $existingStep)) { throw Exceptions::automationStructureModificationNotSupported(); } } } private function stepChanged(Step $a, Step $b): bool { $aData = $a->toArray(); $bData = $b->toArray(); unset($aData['args']); unset($bData['args']); return $aData === $bData; } } Builder/index.php 0000644 00000000006 15073230056 0007747 0 ustar 00 <?php Builder/UpdateStepsController.php 0000644 00000002121 15073230056 0013145 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Builder; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Registry; class UpdateStepsController { /** @var Registry */ private $registry; public function __construct( Registry $registry ) { $this->registry = $registry; } public function updateSteps(Automation $automation, array $data): Automation { $steps = []; foreach ($data as $index => $stepData) { $step = $this->processStep($stepData, $automation->getStep($stepData['id'])); $steps[$index] = $step; } $automation->setSteps($steps); return $automation; } private function processStep(array $data, ?Step $existingStep): Step { $key = $data['key']; $step = $this->registry->getStep($key); if (!$step && $existingStep && $data !== $existingStep->toArray()) { throw Exceptions::automationStepNotFound($key); } return Step::fromArray($data); } } Builder/DeleteAutomationController.php 0000644 00000001620 15073230056 0014152 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Builder; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Storage\AutomationStorage; class DeleteAutomationController { /** @var AutomationStorage */ private $automationStorage; public function __construct( AutomationStorage $automationStorage ) { $this->automationStorage = $automationStorage; } public function deleteAutomation(int $id): Automation { $automation = $this->automationStorage->getAutomation($id); if (!$automation) { throw Exceptions::automationNotFound($id); } if ($automation->getStatus() !== Automation::STATUS_TRASH) { throw Exceptions::automationNotTrashed($id); } $this->automationStorage->deleteAutomation($automation); return $automation; } } Builder/DuplicateAutomationController.php 0000644 00000005351 15073230056 0014667 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Builder; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\NextStep; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Storage\AutomationStorage; use MailPoet\Automation\Engine\WordPress; use MailPoet\Util\Security; class DuplicateAutomationController { /** @var WordPress */ private $wordPress; /** @var AutomationStorage */ private $automationStorage; public function __construct( WordPress $wordPress, AutomationStorage $automationStorage ) { $this->wordPress = $wordPress; $this->automationStorage = $automationStorage; } public function duplicateAutomation(int $id): Automation { $automation = $this->automationStorage->getAutomation($id); if (!$automation) { throw Exceptions::automationNotFound($id); } $duplicate = new Automation( $this->getName($automation->getName()), $this->getSteps($automation->getSteps()), $this->wordPress->wpGetCurrentUser() ); $duplicate->setStatus(Automation::STATUS_DRAFT); $automationId = $this->automationStorage->createAutomation($duplicate); $savedAutomation = $this->automationStorage->getAutomation($automationId); if (!$savedAutomation) { throw new InvalidStateException('Automation not found.'); } return $savedAutomation; } private function getName(string $name): string { // translators: %s is the original automation name. $newName = sprintf(__('Copy of %s', 'mailpoet'), $name); $maxLength = $this->automationStorage->getNameColumnLength(); if (strlen($newName) > $maxLength) { $append = '…'; return substr($newName, 0, $maxLength - strlen($append)) . $append; } return $newName; } /** * @param Step[] $steps * @return Step[] */ private function getSteps(array $steps): array { $newIds = []; foreach ($steps as $step) { $id = $step->getId(); $newIds[$id] = $id === 'root' ? 'root' : $this->getId(); } $newSteps = []; foreach ($steps as $step) { $newId = $newIds[$step->getId()]; $newSteps[$newId] = new Step( $newId, $step->getType(), $step->getKey(), $step->getArgs(), array_map(function (NextStep $nextStep) use ($newIds): NextStep { $nextStepId = $nextStep->getId(); return new NextStep($nextStepId ? $newIds[$nextStepId] : null); }, $step->getNextSteps()) ); } return $newSteps; } private function getId(): string { return Security::generateRandomString(16); } } Builder/CreateAutomationFromTemplateController.php 0000644 00000002715 15073230056 0016501 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Builder; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Registry; use MailPoet\Automation\Engine\Storage\AutomationStorage; use MailPoet\Automation\Engine\Validation\AutomationValidator; class CreateAutomationFromTemplateController { /** @var AutomationStorage */ private $storage; /** @var AutomationValidator */ private $automationValidator; /** @var Registry */ private $registry; public function __construct( AutomationStorage $storage, AutomationValidator $automationValidator, Registry $registry ) { $this->storage = $storage; $this->automationValidator = $automationValidator; $this->registry = $registry; } public function createAutomation(string $slug): Automation { $template = $this->registry->getTemplate($slug); if (!$template) { throw Exceptions::automationTemplateNotFound($slug); } $automation = $template->createAutomation(); $this->automationValidator->validate($automation); $automationId = $this->storage->createAutomation($automation); $savedAutomation = $this->storage->getAutomation($automationId); if (!$savedAutomation) { throw new InvalidStateException('Automation not found.'); } return $savedAutomation; } } Control/StepRunController.php 0000644 00000002142 15073230056 0012341 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\StepRunArgs; class StepRunController { /** @var StepScheduler */ private $stepScheduler; /** @var StepRunArgs */ private $stepRunArgs; public function __construct( StepScheduler $stepScheduler, StepRunArgs $stepRunArgs ) { $this->stepScheduler = $stepScheduler; $this->stepRunArgs = $stepRunArgs; } public function scheduleProgress(int $timestamp = null): int { return $this->stepScheduler->scheduleProgress($this->stepRunArgs, $timestamp); } public function scheduleNextStep(int $timestamp = null): int { return $this->stepScheduler->scheduleNextStep($this->stepRunArgs, $timestamp); } public function scheduleNextStepByIndex(int $nextStepIndex, int $timestamp = null): int { return $this->stepScheduler->scheduleNextStepByIndex($this->stepRunArgs, $nextStepIndex, $timestamp); } public function hasScheduledNextStep(): bool { return $this->stepScheduler->hasScheduledNextStep($this->stepRunArgs); } } Control/index.php 0000644 00000000006 15073230056 0010001 0 ustar 00 <?php Control/RootStep.php 0000644 00000001323 15073230056 0010454 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\StepValidationArgs; use MailPoet\Automation\Engine\Integration\Step; use MailPoet\Validator\Schema\ObjectSchema; class RootStep implements Step { public function getKey(): string { return 'core:root'; } public function getName(): string { // translators: not shown to user, no need to translate return __('Root step', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return new ObjectSchema(); } public function getSubjectKeys(): array { return []; } public function validate(StepValidationArgs $args): void { } } Control/SubjectLoader.php 0000644 00000002410 15073230056 0011421 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Subject as SubjectData; use MailPoet\Automation\Engine\Data\SubjectEntry; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Integration\Payload; use MailPoet\Automation\Engine\Integration\Subject; use MailPoet\Automation\Engine\Registry; class SubjectLoader { /** @var Registry */ private $registry; public function __construct( Registry $registry ) { $this->registry = $registry; } /** * @param SubjectData[] $subjectData * @return SubjectEntry<Subject<Payload>>[] */ public function getSubjectsEntries(array $subjectData): array { $subjectEntries = []; foreach ($subjectData as $data) { $subjectEntries[] = $this->getSubjectEntry($data); } return $subjectEntries; } /** * @param SubjectData $subjectData * @return SubjectEntry<Subject<Payload>> */ public function getSubjectEntry(SubjectData $subjectData): SubjectEntry { $key = $subjectData->getKey(); $subject = $this->registry->getSubject($key); if (!$subject) { throw Exceptions::subjectNotFound($key); } return new SubjectEntry($subject, $subjectData); } } Control/TriggerHandler.php 0000644 00000007441 15073230056 0011605 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\AutomationRun; use MailPoet\Automation\Engine\Data\AutomationRunLog; use MailPoet\Automation\Engine\Data\StepRunArgs; use MailPoet\Automation\Engine\Data\Subject; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Hooks; use MailPoet\Automation\Engine\Integration\Trigger; use MailPoet\Automation\Engine\Storage\AutomationRunStorage; use MailPoet\Automation\Engine\Storage\AutomationStorage; use MailPoet\Automation\Engine\WordPress; class TriggerHandler { /** @var AutomationStorage */ private $automationStorage; /** @var AutomationRunStorage */ private $automationRunStorage; /** @var SubjectLoader */ private $subjectLoader; /** @var SubjectTransformerHandler */ private $subjectTransformerHandler; /** @var FilterHandler */ private $filterHandler; /** @var StepScheduler */ private $stepScheduler; /** @var StepRunLoggerFactory */ private $stepRunLoggerFactory; /** @var WordPress */ private $wordPress; public function __construct( AutomationStorage $automationStorage, AutomationRunStorage $automationRunStorage, SubjectLoader $subjectLoader, SubjectTransformerHandler $subjectTransformerHandler, FilterHandler $filterHandler, StepScheduler $stepScheduler, StepRunLoggerFactory $stepRunLoggerFactory, WordPress $wordPress ) { $this->automationStorage = $automationStorage; $this->automationRunStorage = $automationRunStorage; $this->subjectLoader = $subjectLoader; $this->subjectTransformerHandler = $subjectTransformerHandler; $this->filterHandler = $filterHandler; $this->stepScheduler = $stepScheduler; $this->stepRunLoggerFactory = $stepRunLoggerFactory; $this->wordPress = $wordPress; } public function initialize(): void { $this->wordPress->addAction(Hooks::TRIGGER, [$this, 'processTrigger'], 10, 2); } /** @param Subject[] $subjects */ public function processTrigger(Trigger $trigger, array $subjects): void { $automations = $this->automationStorage->getActiveAutomationsByTrigger($trigger); if (!$automations) { return; } // expand all subject transformations and load subject entries $subjects = $this->subjectTransformerHandler->getAllSubjects($subjects); $subjectEntries = $this->subjectLoader->getSubjectsEntries($subjects); foreach ($automations as $automation) { $step = $automation->getTrigger($trigger->getKey()); if (!$step) { throw Exceptions::automationTriggerNotFound($automation->getId(), $trigger->getKey()); } $automationRun = new AutomationRun($automation->getId(), $automation->getVersionId(), $trigger->getKey(), $subjects); $stepRunArgs = new StepRunArgs($automation, $automationRun, $step, $subjectEntries, 1); $match = false; try { $match = $this->filterHandler->matchesFilters($stepRunArgs); } catch (Exceptions\Exception $e) { // failed filter evaluation won't match ; } if (!$match) { continue; } $createAutomationRun = $trigger->isTriggeredBy($stepRunArgs); $createAutomationRun = $this->wordPress->applyFilters( Hooks::AUTOMATION_RUN_CREATE, $createAutomationRun, $stepRunArgs ); if (!$createAutomationRun) { continue; } $automationRunId = $this->automationRunStorage->createAutomationRun($automationRun); $automationRun->setId($automationRunId); $this->stepScheduler->scheduleNextStep($stepRunArgs); $logger = $this->stepRunLoggerFactory->createLogger($automationRunId, $step->getId(), AutomationRunLog::TYPE_TRIGGER, 1); $logger->logStepData($step); $logger->logSuccess(); } } } Control/StepScheduler.php 0000644 00000007756 15073230056 0011467 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\AutomationRun; use MailPoet\Automation\Engine\Data\StepRunArgs; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Hooks; use MailPoet\Automation\Engine\Storage\AutomationRunStorage; class StepScheduler { /** @var ActionScheduler */ private $actionScheduler; /** @var AutomationRunStorage */ private $automationRunStorage; public function __construct( ActionScheduler $actionScheduler, AutomationRunStorage $automationRunStorage ) { $this->actionScheduler = $actionScheduler; $this->automationRunStorage = $automationRunStorage; } public function scheduleProgress(StepRunArgs $args, int $timestamp = null): int { $runId = $args->getAutomationRun()->getId(); $data = $this->getActionData($runId, $args->getStep()->getId(), $args->getRunNumber() + 1); return $this->scheduleStepAction($data, $timestamp); } public function scheduleNextStep(StepRunArgs $args, int $timestamp = null): int { $step = $args->getStep(); $nextSteps = $step->getNextSteps(); // complete the automation run if there are no more steps if (count($nextSteps) === 0) { $this->completeAutomationRun($args); return 0; } if (count($nextSteps) > 1) { throw Exceptions::nextStepNotScheduled($step->getId()); } return $this->scheduleNextStepByIndex($args, 0, $timestamp); } public function scheduleNextStepByIndex(StepRunArgs $args, int $nextStepIndex, int $timestamp = null): int { $step = $args->getStep(); $nextStep = $step->getNextSteps()[$nextStepIndex] ?? null; if (!$nextStep) { throw Exceptions::nextStepNotFound($step->getId(), $nextStepIndex); } $runId = $args->getAutomationRun()->getId(); $nextStepId = $nextStep->getId(); if (!$nextStepId) { $this->completeAutomationRun($args); return 0; } $data = $this->getActionData($runId, $nextStepId); $id = $this->scheduleStepAction($data, $timestamp); $this->automationRunStorage->updateNextStep($runId, $nextStepId); return $id; } public function hasScheduledNextStep(StepRunArgs $args): bool { $runId = $args->getAutomationRun()->getId(); foreach ($args->getStep()->getNextStepIds() as $nextStepId) { $data = $this->getActionData($runId, $nextStepId); $hasStep = $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data); if ($hasStep) { return true; } // BC for old steps without run number unset($data[0]['run_number']); $hasStep = $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data); if ($hasStep) { return true; } } return false; } public function hasScheduledProgress(StepRunArgs $args): bool { $runId = $args->getAutomationRun()->getId(); $data = $this->getActionData($runId, $args->getStep()->getId(), $args->getRunNumber() + 1); return $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data); } public function hasScheduledStep(StepRunArgs $args): bool { return $this->hasScheduledNextStep($args) || $this->hasScheduledProgress($args); } private function scheduleStepAction(array $data, int $timestamp = null): int { return $timestamp === null ? $this->actionScheduler->enqueue(Hooks::AUTOMATION_STEP, $data) : $this->actionScheduler->schedule($timestamp, Hooks::AUTOMATION_STEP, $data); } private function completeAutomationRun(StepRunArgs $args): void { $runId = $args->getAutomationRun()->getId(); $this->automationRunStorage->updateNextStep($runId, null); $this->automationRunStorage->updateStatus($runId, AutomationRun::STATUS_COMPLETE); } private function getActionData(int $runId, string $stepId, int $runNumber = 1): array { return [ [ 'automation_run_id' => $runId, 'step_id' => $stepId, 'run_number' => $runNumber, ], ]; } } Control/StepHandler.php 0000644 00000016734 15073230056 0011122 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use Exception; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\AutomationRun; use MailPoet\Automation\Engine\Data\AutomationRunLog; use MailPoet\Automation\Engine\Data\StepRunArgs; use MailPoet\Automation\Engine\Data\StepValidationArgs; use MailPoet\Automation\Engine\Data\SubjectEntry; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Hooks; use MailPoet\Automation\Engine\Integration\Action; use MailPoet\Automation\Engine\Integration\Payload; use MailPoet\Automation\Engine\Integration\Subject; use MailPoet\Automation\Engine\Registry; use MailPoet\Automation\Engine\Storage\AutomationRunStorage; use MailPoet\Automation\Engine\Storage\AutomationStorage; use MailPoet\Automation\Engine\WordPress; use Throwable; class StepHandler { /** @var SubjectLoader */ private $subjectLoader; /** @var WordPress */ private $wordPress; /** @var AutomationRunStorage */ private $automationRunStorage; /** @var AutomationStorage */ private $automationStorage; /** @var Registry */ private $registry; /** @var StepRunControllerFactory */ private $stepRunControllerFactory; /** @var StepRunLoggerFactory */ private $stepRunLoggerFactory; /** @var StepScheduler */ private $stepScheduler; public function __construct( SubjectLoader $subjectLoader, WordPress $wordPress, AutomationRunStorage $automationRunStorage, AutomationStorage $automationStorage, Registry $registry, StepRunControllerFactory $stepRunControllerFactory, StepRunLoggerFactory $stepRunLoggerFactory, StepScheduler $stepScheduler ) { $this->subjectLoader = $subjectLoader; $this->wordPress = $wordPress; $this->automationRunStorage = $automationRunStorage; $this->automationStorage = $automationStorage; $this->registry = $registry; $this->stepRunControllerFactory = $stepRunControllerFactory; $this->stepRunLoggerFactory = $stepRunLoggerFactory; $this->stepScheduler = $stepScheduler; } public function initialize(): void { $this->wordPress->addAction(Hooks::AUTOMATION_STEP, [$this, 'handle']); } /** @param mixed $args */ public function handle($args): void { // TODO: better args validation if (!is_array($args) || !isset($args['automation_run_id']) || !array_key_exists('step_id', $args)) { throw new InvalidStateException(); } $runId = (int)$args['automation_run_id']; $stepId = (string)$args['step_id']; $runNumber = (int)($args['run_number'] ?? 1); // BC — complete automation run if "step_id" is empty (was nullable in the past) if (!$stepId) { $this->automationRunStorage->updateStatus($runId, AutomationRun::STATUS_COMPLETE); return; } $logger = $this->stepRunLoggerFactory->createLogger($runId, $stepId, AutomationRunLog::TYPE_ACTION, $runNumber); $logger->logStart(); try { $this->handleStep($runId, $stepId, $runNumber, $logger); } catch (Throwable $e) { $status = $e instanceof InvalidStateException && $e->getErrorCode() === 'mailpoet_automation_not_active' ? AutomationRun::STATUS_CANCELLED : AutomationRun::STATUS_FAILED; $this->automationRunStorage->updateStatus((int)$args['automation_run_id'], $status); $logger->logFailure($e); // Action Scheduler catches only Exception instances, not other errors. // We need to convert them to exceptions to be processed and logged. if (!$e instanceof Exception) { throw new Exception($e->getMessage(), intval($e->getCode()), $e); } throw $e; } finally { $this->postProcessAutomationRun($runId); } } private function handleStep(int $runId, string $stepId, int $runNumber, StepRunLogger $logger): void { $automationRun = $this->automationRunStorage->getAutomationRun($runId); if (!$automationRun) { throw Exceptions::automationRunNotFound($runId); } if ($automationRun->getStatus() !== AutomationRun::STATUS_RUNNING) { throw Exceptions::automationRunNotRunning($runId, $automationRun->getStatus()); } $automation = $this->automationStorage->getAutomation($automationRun->getAutomationId(), $automationRun->getVersionId()); if (!$automation) { throw Exceptions::automationVersionNotFound($automationRun->getAutomationId(), $automationRun->getVersionId()); } if (!in_array($automation->getStatus(), [Automation::STATUS_ACTIVE, Automation::STATUS_DEACTIVATING], true)) { throw Exceptions::automationNotActive($automationRun->getAutomationId()); } $stepData = $automation->getStep($stepId); if (!$stepData) { throw Exceptions::automationStepNotFound($stepId); } $logger->logStepData($stepData); $step = $this->registry->getStep($stepData->getKey()); if (!$step instanceof Action) { throw new InvalidStateException(); } $requiredSubjects = $step->getSubjectKeys(); $subjectEntries = $this->getSubjectEntries($automationRun, $requiredSubjects); $args = new StepRunArgs($automation, $automationRun, $stepData, $subjectEntries, $runNumber); $validationArgs = new StepValidationArgs($automation, $stepData, array_map(function (SubjectEntry $entry) { return $entry->getSubject(); }, $subjectEntries)); $step->validate($validationArgs); $step->run($args, $this->stepRunControllerFactory->createController($args)); // schedule next step if not scheduled by action if (!$this->stepScheduler->hasScheduledStep($args)) { $this->stepScheduler->scheduleNextStep($args); } // logging if ($this->stepScheduler->hasScheduledProgress($args)) { $logger->logProgress(); } else { $logger->logSuccess(); } } /** @return SubjectEntry<Subject<Payload>>[] */ private function getSubjectEntries(AutomationRun $automationRun, array $requiredSubjectKeys): array { $subjectDataMap = []; foreach ($automationRun->getSubjects() as $data) { $subjectDataMap[$data->getKey()] = array_merge($subjectDataMap[$data->getKey()] ?? [], [$data]); } $subjectEntries = []; foreach ($requiredSubjectKeys as $key) { $subjectData = $subjectDataMap[$key] ?? null; if (!$subjectData) { throw Exceptions::subjectDataNotFound($key, $automationRun->getId()); } } foreach ($subjectDataMap as $subjectData) { foreach ($subjectData as $data) { $subjectEntries[] = $this->subjectLoader->getSubjectEntry($data); } } return $subjectEntries; } private function postProcessAutomationRun(int $automationRunId): void { $automationRun = $this->automationRunStorage->getAutomationRun($automationRunId); if (!$automationRun) { return; } $automation = $this->automationStorage->getAutomation($automationRun->getAutomationId()); if (!$automation) { return; } $this->postProcessAutomation($automation); } private function postProcessAutomation(Automation $automation): void { if ($automation->getStatus() === Automation::STATUS_DEACTIVATING) { $activeRuns = $this->automationRunStorage->getCountForAutomation($automation, AutomationRun::STATUS_RUNNING); // Set a deactivating Automation to draft once all automation runs are finished. if ($activeRuns === 0) { $automation->setStatus(Automation::STATUS_DRAFT); $this->automationStorage->updateAutomation($automation); } } } } Control/SubjectTransformerHandler.php 0000644 00000005510 15073230056 0014017 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step as StepData; use MailPoet\Automation\Engine\Data\Subject; use MailPoet\Automation\Engine\Integration\SubjectTransformer; use MailPoet\Automation\Engine\Integration\Trigger; use MailPoet\Automation\Engine\Registry; class SubjectTransformerHandler { /* @var Registry */ private $registry; public function __construct( Registry $registry ) { $this->registry = $registry; } public function getSubjectKeysForAutomation(Automation $automation): array { $triggerData = array_values(array_filter( $automation->getSteps(), function (StepData $step): bool { return $step->getType() === StepData::TYPE_TRIGGER; } )); $triggers = array_filter(array_map( function (StepData $step): ?Trigger { return $this->registry->getTrigger($step->getKey()); }, $triggerData )); $all = []; foreach ($triggers as $trigger) { $all[] = $this->getSubjectKeysForTrigger($trigger); } $all = count($all) > 1 ? array_intersect(...$all) : $all[0] ?? []; return array_values(array_unique($all)); } public function getSubjectKeysForTrigger(Trigger $trigger): array { $transformerMap = $this->getTransformerMap(); $all = $trigger->getSubjectKeys(); $queue = $all; while ($key = array_shift($queue)) { foreach ($transformerMap[$key] ?? [] as $transformer) { $newKey = $transformer->returns(); if (!in_array($newKey, $all, true)) { $all[] = $newKey; $queue[] = $newKey; } } } sort($all); return $all; } /** * @param Subject[] $subjects * @return Subject[] */ public function getAllSubjects(array $subjects): array { $transformerMap = $this->getTransformerMap(); $all = []; foreach ($subjects as $subject) { $all[$subject->getKey()] = $subject; } $queue = array_keys($all); while ($key = array_shift($queue)) { foreach ($transformerMap[$key] ?? [] as $transformer) { $newKey = $transformer->returns(); if (!isset($all[$newKey])) { $newSubject = $transformer->transform($all[$key]); if (!$newSubject) { continue; } $all[$newKey] = $newSubject; $queue[] = $newKey; } } } return array_values($all); } /** * @return SubjectTransformer[][] */ private function getTransformerMap(): array { $transformerMap = []; foreach ($this->registry->getSubjectTransformers() as $transformer) { $transformerMap[$transformer->accepts()] = array_merge($transformerMap[$transformer->accepts()] ?? [], [$transformer]); } return $transformerMap; } } Control/StepRunLoggerFactory.php 0000644 00000001433 15073230056 0012767 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Hooks; use MailPoet\Automation\Engine\Storage\AutomationRunLogStorage; class StepRunLoggerFactory { /** @var AutomationRunLogStorage */ private $automationRunLogStorage; /** @var Hooks */ private $hooks; public function __construct( AutomationRunLogStorage $automationRunLogStorage, Hooks $hooks ) { $this->automationRunLogStorage = $automationRunLogStorage; $this->hooks = $hooks; } public function createLogger(int $runId, string $stepId, string $stepType, int $runNumber): StepRunLogger { return new StepRunLogger($this->automationRunLogStorage, $this->hooks, $runId, $stepId, $stepType, $runNumber); } } Control/StepRunControllerFactory.php 0000644 00000001002 15073230056 0013663 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\StepRunArgs; class StepRunControllerFactory { /** @var StepScheduler */ private $stepScheduler; public function __construct( StepScheduler $stepScheduler ) { $this->stepScheduler = $stepScheduler; } public function createController(StepRunArgs $args): StepRunController { return new StepRunController($this->stepScheduler, $args); } } Control/ActionScheduler.php 0000644 00000001201 15073230056 0011744 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; class ActionScheduler { private const GROUP_ID = 'mailpoet-automation'; public function enqueue(string $hook, array $args = []): int { return as_enqueue_async_action($hook, $args, self::GROUP_ID); } public function schedule(int $timestamp, string $hook, array $args = []): int { return as_schedule_single_action($timestamp, $hook, $args, self::GROUP_ID); } public function hasScheduledAction(string $hook, array $args = []): bool { return as_has_scheduled_action($hook, $args, self::GROUP_ID); } } Control/StepRunLogger.php 0000644 00000006773 15073230056 0011453 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use DateTimeImmutable; use MailPoet\Automation\Engine\Data\AutomationRunLog; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Hooks; use MailPoet\Automation\Engine\Storage\AutomationRunLogStorage; use MailPoet\InvalidStateException; use Throwable; class StepRunLogger { /** @var AutomationRunLogStorage */ private $automationRunLogStorage; /** @var Hooks */ private $hooks; /** @var int */ private $runId; /** @var string */ private $stepId; /** @var AutomationRunLog|null */ private $log; /** @var string */ private $stepType; /** @var int */ private $runNumber; /** @var bool */ private $isWpDebug; public function __construct( AutomationRunLogStorage $automationRunLogStorage, Hooks $hooks, int $runId, string $stepId, string $stepType, int $runNumber, bool $isWpDebug = null ) { $this->automationRunLogStorage = $automationRunLogStorage; $this->hooks = $hooks; $this->runId = $runId; $this->stepId = $stepId; $this->stepType = $stepType; $this->runNumber = $runNumber; $this->isWpDebug = $isWpDebug !== null ? $isWpDebug : $this->getWpDebug(); } private function getWpDebug(): bool { if (!defined('WP_DEBUG')) { return false; } if (!is_bool(WP_DEBUG)) { return in_array(strtolower((string)WP_DEBUG), ['true', '1'], true); } return WP_DEBUG; } public function logStart(): void { $this->getLog(); } public function logStepData(Step $step): void { $log = $this->getLog(); $log->setStepKey($step->getKey()); $this->automationRunLogStorage->updateAutomationRunLog($log); } public function logProgress(): void { $log = $this->getLog(); $log->setStatus(AutomationRunLog::STATUS_RUNNING); $log->setUpdatedAt(new DateTimeImmutable()); $this->automationRunLogStorage->updateAutomationRunLog($log); } public function logSuccess(): void { $log = $this->getLog(); $log->setStatus(AutomationRunLog::STATUS_COMPLETE); $log->setUpdatedAt(new DateTimeImmutable()); $this->triggerAfterRunHook($log); $this->automationRunLogStorage->updateAutomationRunLog($log); } public function logFailure(Throwable $error): void { $log = $this->getLog(); $log->setStatus(AutomationRunLog::STATUS_FAILED); $log->setError($error); $log->setUpdatedAt(new DateTimeImmutable()); $this->triggerAfterRunHook($log); $this->automationRunLogStorage->updateAutomationRunLog($log); } private function getLog(): AutomationRunLog { if (!$this->log) { $this->log = $this->automationRunLogStorage->getAutomationRunLogByRunAndStepId($this->runId, $this->stepId); } if (!$this->log) { $log = new AutomationRunLog($this->runId, $this->stepId, $this->stepType); $log->setRunNumber($this->runNumber); $id = $this->automationRunLogStorage->createAutomationRunLog($log); $this->log = $this->automationRunLogStorage->getAutomationRunLog($id); } if (!$this->log) { throw new InvalidStateException('Failed to create automation run log'); } $this->log->setRunNumber($this->runNumber); return $this->log; } private function triggerAfterRunHook(AutomationRunLog $log): void { try { $this->hooks->doAutomationStepAfterRun($log); } catch (Throwable $e) { if ($this->isWpDebug) { throw $e; } // ignore integration logging errors } } } Control/FilterHandler.php 0000644 00000003617 15073230056 0011430 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Control; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Filter as FilterData; use MailPoet\Automation\Engine\Data\FilterGroup; use MailPoet\Automation\Engine\Data\Filters; use MailPoet\Automation\Engine\Data\StepRunArgs; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Registry; class FilterHandler { /** @var Registry */ private $registry; public function __construct( Registry $registry ) { $this->registry = $registry; } public function matchesFilters(StepRunArgs $args): bool { $filters = $args->getStep()->getFilters(); if (!$filters) { return true; } $operator = $filters->getOperator(); foreach ($filters->getGroups() as $group) { $matches = $this->matchesGroup($group, $args); if ($operator === Filters::OPERATOR_AND && !$matches) { return false; } if ($operator === Filters::OPERATOR_OR && $matches) { return true; } } return $operator === Filters::OPERATOR_AND; } public function matchesGroup(FilterGroup $group, StepRunArgs $args): bool { $operator = $group->getOperator(); foreach ($group->getFilters() as $filter) { $value = $args->getFieldValue($filter->getFieldKey()); $matches = $this->matchesFilter($filter, $value); if ($operator === FilterGroup::OPERATOR_AND && !$matches) { return false; } if ($operator === FilterGroup::OPERATOR_OR && $matches) { return true; } } return $operator === FilterGroup::OPERATOR_AND; } /** @param mixed $value */ private function matchesFilter(FilterData $data, $value): bool { $filter = $this->registry->getFilter($data->getFieldType()); if (!$filter) { throw Exceptions::filterNotFound($data->getFieldType()); } return $filter->matches($data, $value); } } Storage/AutomationStorage.php 0000644 00000035777 15073230056 0012352 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Storage; if (!defined('ABSPATH')) exit; use DateTimeImmutable; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Data\Subject; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Integration\Trigger; use wpdb; /** * @phpstan-type VersionDate array{id: int, created_at: \DateTimeImmutable} */ class AutomationStorage { /** @var string */ private $automationsTable; /** @var string */ private $versionsTable; /** @var string */ private $triggersTable; /** @var string */ private $runsTable; /** @var string */ private $subjectsTable; /** @var wpdb */ private $wpdb; public function __construct() { global $wpdb; $this->automationsTable = $wpdb->prefix . 'mailpoet_automations'; $this->versionsTable = $wpdb->prefix . 'mailpoet_automation_versions'; $this->triggersTable = $wpdb->prefix . 'mailpoet_automation_triggers'; $this->runsTable = $wpdb->prefix . 'mailpoet_automation_runs'; $this->subjectsTable = $wpdb->prefix . 'mailpoet_automation_run_subjects'; $this->wpdb = $wpdb; } public function createAutomation(Automation $automation): int { $automationHeaderData = $this->getAutomationHeaderData($automation); unset($automationHeaderData['id']); $result = $this->wpdb->insert($this->automationsTable, $automationHeaderData); if (!$result) { throw Exceptions::databaseError($this->wpdb->last_error); } $id = $this->wpdb->insert_id; $this->insertAutomationVersion($id, $automation); $this->insertAutomationTriggers($id, $automation); return $id; } public function updateAutomation(Automation $automation): void { $oldRecord = $this->getAutomation($automation->getId()); if ($oldRecord && $oldRecord->equals($automation)) { return; } $result = $this->wpdb->update($this->automationsTable, $this->getAutomationHeaderData($automation), ['id' => $automation->getId()]); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $this->insertAutomationVersion($automation->getId(), $automation); $this->insertAutomationTriggers($automation->getId(), $automation); } /** * @param int $automationId * @return VersionDate[] * @throws \Exception */ public function getAutomationVersionDates(int $automationId): array { $versionsTable = esc_sql($this->versionsTable); /** @var literal-string $sql */ $sql = " SELECT id, created_at FROM $versionsTable WHERE automation_id = %d ORDER BY id DESC "; $query = (string)$this->wpdb->prepare($sql, $automationId); $data = $this->wpdb->get_results($query, ARRAY_A); return is_array($data) ? array_map( function($row): array { /** @var array{id: string, created_at: string} $row */ return [ 'id' => absint($row['id']), 'created_at' => new \DateTimeImmutable($row['created_at']), ]; }, $data ) : []; } /** * @param int[] $versionIds * @return Automation[] */ public function getAutomationWithDifferentVersions(array $versionIds): array { $versionIds = array_map('intval', $versionIds); if (!$versionIds) { return []; } $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); /** @var literal-string $sql */ $sql = " SELECT a.*, v.id AS version_id, v.steps FROM $automationsTable as a, $versionsTable as v WHERE v.automation_id = a.id AND v.id IN (" . implode(',', array_fill(0, count($versionIds), '%d')) . ") ORDER BY v.id DESC "; $query = (string)$this->wpdb->prepare($sql, ...$versionIds); $data = $this->wpdb->get_results($query, ARRAY_A); return is_array($data) ? array_map( function($row): Automation { return Automation::fromArray((array)$row); }, $data ) : []; } public function getAutomation(int $automationId, int $versionId = null): ?Automation { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); if ($versionId) { $automations = $this->getAutomationWithDifferentVersions([$versionId]); return $automations ? $automations[0] : null; } /** @var literal-string $sql */ $sql = " SELECT a.*, v.id AS version_id, v.steps FROM $automationsTable as a, $versionsTable as v WHERE v.automation_id = a.id AND a.id = %d ORDER BY v.id DESC LIMIT 1 "; $query = (string)$this->wpdb->prepare($sql, $automationId); $data = $this->wpdb->get_row($query, ARRAY_A); return $data ? Automation::fromArray((array)$data) : null; } /** @return Automation[] */ public function getAutomations(array $status = null): array { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $statusFilter = $status ? 'AND a.status IN(' . implode(',', array_fill(0, count($status), '%s')) . ')' : ''; /** @var literal-string $sql */ $sql = " SELECT a.*, v.id AS version_id, v.steps FROM $automationsTable AS a INNER JOIN $versionsTable as v ON (v.automation_id = a.id) WHERE v.id = ( SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id ) $statusFilter ORDER BY a.id DESC "; $query = $status ? (string)$this->wpdb->prepare($sql, ...$status) : $sql; $data = $this->wpdb->get_results($query, ARRAY_A); return array_map(function ($automationData) { /** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */ return Automation::fromArray($automationData); }, (array)$data); } /** @return Automation[] */ public function getAutomationsBySubject(Subject $subject, array $runStatus = null): array { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $runsTable = esc_sql($this->runsTable); $subjectTable = esc_sql($this->subjectsTable); $statusFilter = $runStatus ? 'AND r.status IN(' . implode(',', array_fill(0, count($runStatus), '%s')) . ')' : ''; /** @var literal-string $sql */ $sql = " SELECT DISTINCT a.*, v.id AS version_id, v.steps FROM $automationsTable a INNER JOIN $versionsTable v ON v.automation_id = a.id INNER JOIN $runsTable r ON r.automation_id = a.id INNER JOIN $subjectTable s ON s.automation_run_id = r.id WHERE v.id = ( SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id ) AND s.hash = %s $statusFilter ORDER BY a.id DESC "; $query = (string)$this->wpdb->prepare($sql, ...array_merge([$subject->getHash()], $runStatus ?? [])); $data = $this->wpdb->get_results($query, ARRAY_A); return array_map(function ($automationData) { /** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */ return Automation::fromArray($automationData); }, (array)$data); } public function getAutomationCount(): int { $automationsTable = esc_sql($this->automationsTable); return (int)$this->wpdb->get_var("SELECT COUNT(*) FROM $automationsTable"); } /** @return string[] */ public function getActiveTriggerKeys(): array { $automationsTable = esc_sql($this->automationsTable); $triggersTable = esc_sql($this->triggersTable); /** @var literal-string $sql */ $sql = " SELECT DISTINCT t.trigger_key FROM {$automationsTable} AS a JOIN $triggersTable as t WHERE a.status = %s AND a.id = t.automation_id ORDER BY trigger_key DESC "; $query = (string)$this->wpdb->prepare($sql, Automation::STATUS_ACTIVE); return $this->wpdb->get_col($query); } /** @return Automation[] */ public function getActiveAutomationsByTrigger(Trigger $trigger): array { return $this->getActiveAutomationsByTriggerKey($trigger->getKey()); } public function getActiveAutomationsByTriggerKey(string $triggerKey): array { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $triggersTable = esc_sql($this->triggersTable); /** @var literal-string $sql */ $sql = " SELECT a.*, v.id AS version_id, v.steps FROM $automationsTable AS a INNER JOIN $triggersTable as t ON (t.automation_id = a.id) INNER JOIN $versionsTable as v ON (v.automation_id = a.id) WHERE a.status = %s AND t.trigger_key = %s AND v.id = ( SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id ) "; $query = (string)$this->wpdb->prepare($sql, Automation::STATUS_ACTIVE, $triggerKey); $data = $this->wpdb->get_results($query, ARRAY_A); return array_map(function ($automationData) { /** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */ return Automation::fromArray($automationData); }, (array)$data); } public function getCountOfActiveByTriggerKeysAndAction(array $triggerKeys, string $actionKey): int { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $triggersTable = esc_sql($this->triggersTable); $triggerKeysPlaceholders = implode(',', array_fill(0, count($triggerKeys), '%s')); $queryArgs = array_merge( $triggerKeys, [ Automation::STATUS_ACTIVE, '%"' . $this->wpdb->esc_like($actionKey) . '"%', ] ); // Using the phpcs:ignore because the query arguments count is dynamic and passed via an array but the code sniffer sees only one argument /** @var literal-string $sql */ $sql = " SELECT count(*) FROM $automationsTable AS a INNER JOIN $triggersTable as t ON (t.automation_id = a.id) AND t.trigger_key IN ({$triggerKeysPlaceholders}) INNER JOIN $versionsTable as v ON v.id = (SELECT MAX(id) FROM $versionsTable WHERE automation_id = a.id) WHERE a.status = %s AND v.steps LIKE %s "; $query = (string)$this->wpdb->prepare($sql, $queryArgs); return (int)$this->wpdb->get_var($query); } public function deleteAutomation(Automation $automation): void { $automationRunsTable = esc_sql($this->runsTable); $automationRunLogsTable = esc_sql($this->wpdb->prefix . 'mailpoet_automation_run_logs'); $automationId = $automation->getId(); /** @var literal-string $sql */ $sql = " DELETE FROM $automationRunLogsTable WHERE automation_run_id IN ( SELECT id FROM $automationRunsTable WHERE automation_id = %d ) "; $runLogsQuery = (string)$this->wpdb->prepare($sql, $automationId); $logsDeleted = $this->wpdb->query($runLogsQuery); if ($logsDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $runsDeleted = $this->wpdb->delete($this->runsTable, ['automation_id' => $automationId]); if ($runsDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $versionsDeleted = $this->wpdb->delete($this->versionsTable, ['automation_id' => $automationId]); if ($versionsDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $triggersDeleted = $this->wpdb->delete($this->triggersTable, ['automation_id' => $automationId]); if ($triggersDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $automationDeleted = $this->wpdb->delete($this->automationsTable, ['id' => $automationId]); if ($automationDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function truncate(): void { $automationsTable = esc_sql($this->automationsTable); $result = $this->wpdb->query("TRUNCATE {$automationsTable}"); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $versionsTable = esc_sql($this->versionsTable); $result = $this->wpdb->query("TRUNCATE {$versionsTable}"); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $triggersTable = esc_sql($this->triggersTable); $result = $this->wpdb->query("TRUNCATE {$triggersTable}"); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function getNameColumnLength(): int { $nameColumnLengthInfo = $this->wpdb->get_col_length($this->automationsTable, 'name'); return is_array($nameColumnLengthInfo) ? $nameColumnLengthInfo['length'] ?? 255 : 255; } private function getAutomationHeaderData(Automation $automation): array { $automationHeader = $automation->toArray(); unset($automationHeader['steps']); return $automationHeader; } private function insertAutomationVersion(int $automationId, Automation $automation): void { $dateString = (new DateTimeImmutable())->format(DateTimeImmutable::W3C); $data = [ 'automation_id' => $automationId, 'steps' => $automation->toArray()['steps'], 'created_at' => $dateString, 'updated_at' => $dateString, ]; $result = $this->wpdb->insert($this->versionsTable, $data); if (!$result) { throw Exceptions::databaseError($this->wpdb->last_error); } } private function insertAutomationTriggers(int $automationId, Automation $automation): void { $triggerKeys = []; foreach ($automation->getSteps() as $step) { if ($step->getType() === Step::TYPE_TRIGGER) { $triggerKeys[] = $step->getKey(); } } $triggersTable = esc_sql($this->triggersTable); // insert/update if ($triggerKeys) { $placeholders = implode(',', array_fill(0, count($triggerKeys), '(%d, %s)')); /** @var literal-string $sql */ $sql = "INSERT IGNORE INTO {$triggersTable} (automation_id, trigger_key) VALUES {$placeholders}"; $query = (string)$this->wpdb->prepare( $sql, array_merge( ...array_map(function (string $key) use ($automationId) { return [$automationId, $key]; }, $triggerKeys) ) ); $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } // delete $placeholders = implode(',', array_fill(0, count($triggerKeys), '%s')); if ($triggerKeys) { /** @var literal-string $sql */ $sql = "DELETE FROM {$triggersTable} WHERE automation_id = %d AND trigger_key NOT IN ({$placeholders})"; $query = (string)$this->wpdb->prepare($sql, array_merge([$automationId], $triggerKeys)); } else { /** @var literal-string $sql */ $sql = "DELETE FROM {$triggersTable} WHERE automation_id = %d"; $query = (string)$this->wpdb->prepare($sql, $automationId); } $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } } Storage/index.php 0000644 00000000006 15073230056 0007765 0 ustar 00 <?php Storage/AutomationRunLogStorage.php 0000644 00000010427 15073230056 0013462 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Storage; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\AutomationRunLog; use MailPoet\Automation\Engine\Exceptions; use MailPoet\InvalidStateException; use wpdb; class AutomationRunLogStorage { /** @var string */ private $table; /** @var wpdb */ private $wpdb; public function __construct() { global $wpdb; $this->table = $wpdb->prefix . 'mailpoet_automation_run_logs'; $this->wpdb = $wpdb; } public function createAutomationRunLog(AutomationRunLog $automationRunLog): int { $result = $this->wpdb->insert($this->table, $automationRunLog->toArray()); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } return $this->wpdb->insert_id; } public function updateAutomationRunLog(AutomationRunLog $automationRunLog): void { $result = $this->wpdb->update($this->table, $automationRunLog->toArray(), ['id' => $automationRunLog->getId()]); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function getAutomationRunStatisticsForAutomationInTimeFrame(int $automationId, string $status, \DateTimeImmutable $after, \DateTimeImmutable $before, int $versionId = null): array { $logTable = esc_sql($this->table); $runTable = esc_sql($this->wpdb->prefix . 'mailpoet_automation_runs'); $whereCondition = 'run.automation_id = %d AND log.status = %s AND run.created_at BETWEEN %s AND %s'; if ($versionId !== null) { $whereCondition .= ' AND run.version_id = %d'; } /** @var literal-string $sql */ $sql = "SELECT count(log.id) as `count`, log.step_id FROM $logTable AS log JOIN $runTable AS run ON log.automation_run_id = run.id WHERE $whereCondition GROUP BY log.step_id"; $sql = $versionId ? $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s'), $versionId) : $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s')); $sql = is_string($sql) ? $sql : ""; $results = $this->wpdb->get_results($sql, ARRAY_A); return is_array($results) ? $results : []; } public function getAutomationRunLog(int $id): ?AutomationRunLog { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = "SELECT * FROM $table WHERE id = %d"; $query = $this->wpdb->prepare($sql, $id); if (!is_string($query)) { throw InvalidStateException::create(); } $result = $this->wpdb->get_row($query, ARRAY_A); if ($result) { $data = (array)$result; return AutomationRunLog::fromArray($data); } return null; } public function getAutomationRunLogByRunAndStepId(int $runId, string $stepId): ?AutomationRunLog { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = "SELECT * FROM $table WHERE automation_run_id = %d AND step_id = %s"; $query = $this->wpdb->prepare($sql, $runId, $stepId); if (!is_string($query)) { throw InvalidStateException::create(); } $result = $this->wpdb->get_row($query, ARRAY_A); return $result ? AutomationRunLog::fromArray((array)$result) : null; } /** * @param int $automationRunId * @return AutomationRunLog[] * @throws InvalidStateException */ public function getLogsForAutomationRun(int $automationRunId): array { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = " SELECT * FROM $table WHERE automation_run_id = %d ORDER BY id ASC "; $query = $this->wpdb->prepare($sql, $automationRunId); if (!is_string($query)) { throw InvalidStateException::create(); } $results = $this->wpdb->get_results($query, ARRAY_A); if (!is_array($results)) { throw InvalidStateException::create(); } if ($results) { return array_map(function($data) { /** @var array $data - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */ return AutomationRunLog::fromArray($data); }, $results); } return []; } public function truncate(): void { $table = esc_sql($this->table); $sql = "TRUNCATE $table"; $this->wpdb->query($sql); } } Storage/AutomationRunStorage.php 0000644 00000017556 15073230056 0013032 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Storage; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\AutomationRun; use MailPoet\Automation\Engine\Data\Subject; use MailPoet\Automation\Engine\Exceptions; use wpdb; class AutomationRunStorage { /** @var string */ private $table; /** @var string */ private $subjectTable; /** @var wpdb */ private $wpdb; public function __construct() { global $wpdb; $this->table = $wpdb->prefix . 'mailpoet_automation_runs'; $this->subjectTable = $wpdb->prefix . 'mailpoet_automation_run_subjects'; $this->wpdb = $wpdb; } public function createAutomationRun(AutomationRun $automationRun): int { $automationTableData = $automationRun->toArray(); $subjectTableData = $automationTableData['subjects']; unset($automationTableData['subjects']); $result = $this->wpdb->insert($this->table, $automationTableData); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $automationRunId = $this->wpdb->insert_id; if (!$subjectTableData) { //We allow for AutomationRuns with no subjects. return $automationRunId; } $sql = 'insert into ' . esc_sql($this->subjectTable) . ' (`automation_run_id`, `key`, `args`, `hash`) values %s'; $values = []; foreach ($subjectTableData as $entry) { $values[] = (string)$this->wpdb->prepare("(%d,%s,%s,%s)", $automationRunId, $entry['key'], $entry['args'], $entry['hash']); } $sql = sprintf($sql, implode(',', $values)); $result = $this->wpdb->query($sql); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } return $automationRunId; } public function getAutomationRun(int $id): ?AutomationRun { $table = esc_sql($this->table); $subjectTable = esc_sql($this->subjectTable); /** @var literal-string $sql */ $sql = "SELECT * FROM $table WHERE id = %d"; $query = (string)$this->wpdb->prepare($sql, $id); $data = $this->wpdb->get_row($query, ARRAY_A); if (!is_array($data) || !$data) { return null; } /** @var literal-string $sql */ $sql = "SELECT * FROM $subjectTable WHERE automation_run_id = %d"; $query = (string)$this->wpdb->prepare($sql, $id); $subjects = $this->wpdb->get_results($query, ARRAY_A); $data['subjects'] = is_array($subjects) ? $subjects : []; return AutomationRun::fromArray((array)$data); } /** * @param Automation $automation * @return AutomationRun[] */ public function getAutomationRunsForAutomation(Automation $automation): array { $table = esc_sql($this->table); $subjectTable = esc_sql($this->subjectTable); /** @var literal-string $sql */ $sql = "SELECT * FROM $table WHERE automation_id = %d order by id"; $query = (string)$this->wpdb->prepare($sql, $automation->getId()); $automationRuns = $this->wpdb->get_results($query, ARRAY_A); if (!is_array($automationRuns) || !$automationRuns) { return []; } $automationRunIds = array_column($automationRuns, 'id'); /** @var literal-string $sql */ $sql = sprintf( "SELECT * FROM $subjectTable WHERE automation_run_id in (%s) order by automation_run_id, id", implode( ',', array_map( function() { return '%d'; }, $automationRunIds ) ) ); $query = (string)$this->wpdb->prepare($sql, ...$automationRunIds); $subjects = $this->wpdb->get_results($query, ARRAY_A); return array_map( function($runData) use ($subjects): AutomationRun { /** @var array $runData - PHPStan expects as array_map first parameter (callable(mixed): mixed)|null */ $runData['subjects'] = array_values(array_filter( is_array($subjects) ? $subjects : [], function($subjectData) use ($runData): bool { /** @var array $subjectData - PHPStan expects as array_map first parameter (callable(mixed): mixed)|null */ return (int)$subjectData['automation_run_id'] === (int)$runData['id']; } )); return AutomationRun::fromArray($runData); }, $automationRuns ); } /** * @param Automation $automation * @return int */ public function getCountByAutomationAndSubject(Automation $automation, Subject $subject): int { $table = esc_sql($this->table); $subjectTable = esc_sql($this->subjectTable); /** @var literal-string $sql */ $sql = "SELECT count(DISTINCT runs.id) as count from $table as runs JOIN $subjectTable as subjects on runs.id = subjects.automation_run_id WHERE runs.automation_id = %d AND subjects.hash = %s"; $result = $this->wpdb->get_col( (string)$this->wpdb->prepare($sql, $automation->getId(), $subject->getHash()) ); return $result ? (int)current($result) : 0; } public function getCountForAutomation(Automation $automation, string ...$status): int { $table = esc_sql($this->table); if (!count($status)) { /** @var literal-string $sql */ $sql = " SELECT COUNT(id) as count FROM $table WHERE automation_id = %d "; $query = (string)$this->wpdb->prepare($sql, $automation->getId()); $result = $this->wpdb->get_col($query); return $result ? (int)current($result) : 0; } $statusSql = (string)$this->wpdb->prepare(implode(',', array_fill(0, count($status), '%s')), ...$status); /** @var literal-string $sql */ $sql = " SELECT COUNT(id) as count FROM $table WHERE automation_id = %d AND status IN ($statusSql) "; $query = (string)$this->wpdb->prepare($sql, $automation->getId()); $result = $this->wpdb->get_col($query); return $result ? (int)current($result) : 0; } public function updateStatus(int $id, string $status): void { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = " UPDATE $table SET status = %s, updated_at = current_timestamp() WHERE id = %d "; $query = (string)$this->wpdb->prepare($sql, $status, $id); $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function updateNextStep(int $id, ?string $nextStepId): void { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = " UPDATE $table SET next_step_id = %s, updated_at = current_timestamp() WHERE id = %d "; $query = (string)$this->wpdb->prepare($sql, $nextStepId, $id); $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function getAutomationStepStatisticForTimeFrame(int $automationId, string $status, \DateTimeImmutable $after, \DateTimeImmutable $before, int $versionId = null): array { $table = esc_sql($this->table); $where = "automation_id = %d AND `status` = %s AND created_at BETWEEN %s AND %s"; if ($versionId) { $where .= " AND version_id = %d"; } /** @var literal-string $sql */ $sql = " SELECT COUNT(id) AS `count`, next_step_id FROM $table as log WHERE $where GROUP BY next_step_id "; $sql = $versionId ? $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s'), $versionId) : $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s')); $sql = is_string($sql) ? $sql : ''; $result = $this->wpdb->get_results($sql, ARRAY_A); return is_array($result) ? $result : []; } public function truncate(): void { $table = esc_sql($this->table); $this->wpdb->query("TRUNCATE $table"); $table = esc_sql($this->subjectTable); $this->wpdb->query("TRUNCATE $table"); } } Storage/AutomationStatisticsStorage.php 0000644 00000007535 15073230056 0014414 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Storage; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\AutomationRun; use MailPoet\Automation\Engine\Data\AutomationStatistics; class AutomationStatisticsStorage { /** @var string */ private $table; /** @var \wpdb */ private $wpdb; public function __construct() { global $wpdb; $this->table = $wpdb->prefix . 'mailpoet_automation_runs'; $this->wpdb = $wpdb; } /** * @param Automation ...$automations * @return AutomationStatistics[] */ public function getAutomationStatisticsForAutomations(Automation ...$automations): array { if (empty($automations)) { return []; } $automationIds = array_map( function(Automation $automation): int { return $automation->getId(); }, $automations ); $data = $this->getStatistics($automationIds); $statistics = []; foreach ($automationIds as $id) { $statistics[$id] = new AutomationStatistics( $id, (int)($data[$id]['total'] ?? 0), (int)($data[$id]['running'] ?? 0) ); } return $statistics; } public function getAutomationStats(int $automationId, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null): AutomationStatistics { $data = $this->getStatistics([$automationId], $versionId, $after, $before); return new AutomationStatistics( $automationId, (int)($data[$automationId]['total'] ?? 0), (int)($data[$automationId]['running'] ?? 0), $versionId ); } /** * @param int[] $automationIds * @return array<int, array{id: int, total: int, running: int}> */ private function getStatistics(array $automationIds, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null): array { $totalSubquery = $this->getStatsQuery($automationIds, $versionId, $after, $before); $runningSubquery = $this->getStatsQuery($automationIds, $versionId, $after, $before, AutomationRun::STATUS_RUNNING); // The subqueries are created using $wpdb->prepare(). // phpcs:ignore WordPressDotOrg.sniffs.DirectDB.UnescapedDBParameter $results = (array)$this->wpdb->get_results(" SELECT t.id, t.count AS total, r.count AS running FROM ($totalSubquery) t LEFT JOIN ($runningSubquery) r ON t.id = r.id ", ARRAY_A); /** @var array{id: int, total: int, running: int} $results */ return array_combine(array_column($results, 'id'), $results) ?: []; } private function getStatsQuery(array $automationIds, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null, string $status = null): string { $table = esc_sql($this->table); $placeholders = implode(',', array_fill(0, count($automationIds), '%d')); /** @var string $versionCondition */ $versionCondition = $versionId ? $this->wpdb->prepare('AND version_id = %d', $versionId) : ''; $versionCondition = strval($versionCondition); /** @var string $statusCondition */ $statusCondition = $status ? $this->wpdb->prepare('AND status = %s', $status) : ''; $statusCondition = strval($statusCondition); /** @var string $dateCondition */ $dateCondition = $after !== null && $before !== null ? $this->wpdb->prepare('AND created_at BETWEEN %s AND %s', $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s')) : ''; $dateCondition = strval($dateCondition); /** @var literal-string $sql */ $sql = " SELECT automation_id AS id, COUNT(*) AS count FROM $table WHERE automation_id IN ($placeholders) $versionCondition $statusCondition $dateCondition GROUP BY automation_id "; /** @var string $query */ $query = $this->wpdb->prepare($sql, $automationIds); return strval($query); } } Registry.php 0000644 00000017107 15073230056 0007074 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Control\RootStep; use MailPoet\Automation\Engine\Data\AutomationTemplate; use MailPoet\Automation\Engine\Data\AutomationTemplateCategory; use MailPoet\Automation\Engine\Data\Field; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Integration\Action; use MailPoet\Automation\Engine\Integration\Filter; use MailPoet\Automation\Engine\Integration\Payload; use MailPoet\Automation\Engine\Integration\Step; use MailPoet\Automation\Engine\Integration\Subject; use MailPoet\Automation\Engine\Integration\SubjectTransformer; use MailPoet\Automation\Engine\Integration\Trigger; class Registry { /** @var array<string, AutomationTemplate> */ private $templates; /** @var array<string, AutomationTemplateCategory> */ private $templateCategories; /** @var array<string, Step> */ private $steps = []; /** @var array<string, Subject<Payload>> */ private $subjects = []; /** @var SubjectTransformer[] */ private $subjectTransformers = []; /** @var array<string, Field>|null */ private $fields = null; /** @var array<string, Filter> */ private $filters = []; /** @var array<string, Trigger> */ private $triggers = []; /** @var array<string, Action> */ private $actions = []; /** @var array<string, callable> */ private $contextFactories = []; /** @var WordPress */ private $wordPress; public function __construct( RootStep $rootStep, WordPress $wordPress ) { $this->wordPress = $wordPress; $this->steps[$rootStep->getKey()] = $rootStep; $this->templateCategories = [ 'welcome' => new AutomationTemplateCategory('welcome', __('Welcome', 'mailpoet')), 'abandoned-cart' => new AutomationTemplateCategory('abandoned-cart', __('Abandoned Cart', 'mailpoet')), 'reengagement' => new AutomationTemplateCategory('reengagement', __('Re-engagement', 'mailpoet')), 'woocommerce' => new AutomationTemplateCategory('woocommerce', __('WooCommerce', 'mailpoet')), ]; } public function addTemplate(AutomationTemplate $template): void { $category = $template->getCategory(); if (!isset($this->templateCategories[$category])) { throw InvalidStateException::create()->withMessage( sprintf("Category '%s' was not registered", $category) ); } $this->templates[$template->getSlug()] = $template; // keep coming soon templates at the end uasort( $this->templates, function (AutomationTemplate $a, AutomationTemplate $b): int { if ($a->getType() === AutomationTemplate::TYPE_COMING_SOON) { return 1; } if ($b->getType() === AutomationTemplate::TYPE_COMING_SOON) { return -1; } return 0; } ); } public function getTemplate(string $slug): ?AutomationTemplate { return $this->getTemplates()[$slug] ?? null; } /** @return array<string, AutomationTemplate> */ public function getTemplates(string $category = null): array { return $category ? array_filter( $this->templates, function(AutomationTemplate $template) use ($category): bool { return $template->getCategory() === $category; } ) : $this->templates; } public function removeTemplate(string $slug): void { unset($this->templates[$slug]); } /** @return array<string, AutomationTemplateCategory> */ public function getTemplateCategories(): array { return $this->templateCategories; } /** @param Subject<Payload> $subject */ public function addSubject(Subject $subject): void { $key = $subject->getKey(); if (isset($this->subjects[$key])) { throw new \Exception(); // TODO } $this->subjects[$key] = $subject; // reset fields cache $this->fields = null; } /** @return Subject<Payload>|null */ public function getSubject(string $key): ?Subject { return $this->subjects[$key] ?? null; } /** @return array<string, Subject<Payload>> */ public function getSubjects(): array { return $this->subjects; } public function addSubjectTransformer(SubjectTransformer $transformer): void { $this->subjectTransformers[] = $transformer; } public function getSubjectTransformers(): array { return $this->subjectTransformers; } public function getField(string $key): ?Field { return $this->getFields()[$key] ?? null; } /** @return array<string, Field> */ public function getFields(): array { // add fields lazily (on the first call) if ($this->fields === null) { $this->fields = []; foreach ($this->subjects as $subject) { foreach ($subject->getFields() as $field) { $this->addField($field); } } } return $this->fields ?? []; } public function addFilter(Filter $filter): void { $fieldType = $filter->getFieldType(); if (isset($this->filters[$fieldType])) { throw new \Exception(); // TODO } $this->filters[$fieldType] = $filter; } public function getFilter(string $fieldType): ?Filter { return $this->filters[$fieldType] ?? null; } /** @return array<string, Filter> */ public function getFilters(): array { return $this->filters; } public function addStep(Step $step): void { if ($step instanceof Trigger) { $this->addTrigger($step); } elseif ($step instanceof Action) { $this->addAction($step); } // TODO: allow adding any other step implementations? } public function getStep(string $key): ?Step { return $this->steps[$key] ?? null; } /** @return array<string, Step> */ public function getSteps(): array { return $this->steps; } public function addTrigger(Trigger $trigger): void { $key = $trigger->getKey(); if (isset($this->steps[$key]) || isset($this->triggers[$key])) { throw new \Exception(); // TODO } $this->steps[$key] = $trigger; $this->triggers[$key] = $trigger; } public function getTrigger(string $key): ?Trigger { return $this->triggers[$key] ?? null; } /** @return array<string, Trigger> */ public function getTriggers(): array { return $this->triggers; } public function addAction(Action $action): void { $key = $action->getKey(); if (isset($this->steps[$key]) || isset($this->actions[$key])) { throw new \Exception(); // TODO } $this->steps[$key] = $action; $this->actions[$key] = $action; } public function getAction(string $key): ?Action { return $this->actions[$key] ?? null; } /** @return array<string, Action> */ public function getActions(): array { return $this->actions; } public function addContextFactory(string $key, callable $factory): void { $this->contextFactories[$key] = $factory; } /** @return callable[] */ public function getContextFactories(): array { return $this->contextFactories; } public function onBeforeAutomationSave(callable $callback, int $priority = 10): void { $this->wordPress->addAction(Hooks::AUTOMATION_BEFORE_SAVE, $callback, $priority); } public function onBeforeAutomationStepSave(callable $callback, string $key = null, int $priority = 10): void { $keyPart = $key ? "/key=$key" : ''; $this->wordPress->addAction(Hooks::AUTOMATION_STEP_BEFORE_SAVE . $keyPart, $callback, $priority, 2); } /** * This is used only internally. Fields are added lazily from subjects. */ private function addField(Field $field): void { $key = $field->getKey(); if (isset($this->fields[$key])) { throw new \Exception(); // TODO } $this->fields[$key] = $field; } } Exceptions/index.php 0000644 00000000006 15073230056 0010502 0 ustar 00 <?php Exceptions/RuntimeException.php 0000644 00000000430 15073230056 0012676 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Exceptions; if (!defined('ABSPATH')) exit; /** * USE: Generic runtime error. When possible, use a more specific exception instead. * API: 500 Server Error */ class RuntimeException extends Exception { } Exceptions/AccessDeniedException.php 0000644 00000000503 15073230056 0013566 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Exceptions; if (!defined('ABSPATH')) exit; /** * USE: When an action is forbidden for given actor (although generally valid). * API: 403 Forbidden */ class AccessDeniedException extends UnexpectedValueException { protected $statusCode = 403; } Exceptions/NotFoundException.php 0000644 00000000461 15073230056 0013013 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Exceptions; if (!defined('ABSPATH')) exit; /** * USE: When the main resource we're interested in doesn't exist. * API: 404 Not Found */ class NotFoundException extends UnexpectedValueException { protected $statusCode = 404; } Exceptions/InvalidStateException.php 0000644 00000000466 15073230056 0013653 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Exceptions; if (!defined('ABSPATH')) exit; /** * USE: An application state that should not occur. Can be subclassed for feature-specific exceptions. * API: 500 Server Error */ class InvalidStateException extends RuntimeException { } Exceptions/ConflictException.php 0000644 00000000463 15073230056 0013022 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Exceptions; if (!defined('ABSPATH')) exit; /** * USE: When the main action produces conflict (i.e. duplicate key). * API: 409 Conflict */ class ConflictException extends UnexpectedValueException { protected $statusCode = 409; } Exceptions/UnexpectedValueException.php 0000644 00000000433 15073230056 0014357 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Exceptions; if (!defined('ABSPATH')) exit; /** * USE: When wrong data VALUE is received. * API: 400 Bad Request */ class UnexpectedValueException extends RuntimeException { protected $statusCode = 400; } Exceptions/Exception.php 0000644 00000003472 15073230056 0011343 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Exceptions; if (!defined('ABSPATH')) exit; use Exception as PhpException; use MailPoet\API\REST\Exception as RestException; use Throwable; /** * Frames all MailPoet Automation exceptions ("$e instanceof MailPoet\Automation\Exception"). */ abstract class Exception extends PhpException implements RestException { /** @var int */ protected $statusCode = 500; /** @var string */ protected $errorCode; /** @var string[] */ protected $errors = []; final public function __construct( string $message = null, string $errorCode = null, Throwable $previous = null ) { parent::__construct($message ?? __('Unknown error.', 'mailpoet'), 0, $previous); $this->errorCode = $errorCode ?? 'mailpoet_automation_unknown_error'; } /** @return static */ public static function create(Throwable $previous = null) { return new static(null, null, $previous); } /** @return static */ public function withStatusCode(int $statusCode) { $this->statusCode = $statusCode; return $this; } /** @return static */ public function withError(string $id, string $error) { $this->errors[$id] = $error; return $this; } /** @return static */ public function withErrorCode(string $errorCode) { $this->errorCode = $errorCode; return $this; } /** @return static */ public function withMessage(string $message) { $this->message = $message; return $this; } /** @return static */ public function withErrors(array $errors) { $this->errors = $errors; return $this; } public function getStatusCode(): int { return $this->statusCode; } public function getErrorCode(): string { return $this->errorCode; } public function getErrors(): array { return $this->errors; } } Integration.php 0000644 00000000272 15073230056 0007542 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine; if (!defined('ABSPATH')) exit; interface Integration { public function register(Registry $registry): void; } Engine.php 0000644 00000007154 15073230056 0006472 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\API\API; use MailPoet\Automation\Engine\Control\StepHandler; use MailPoet\Automation\Engine\Control\TriggerHandler; use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsCreateFromTemplateEndpoint; use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsDeleteEndpoint; use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsDuplicateEndpoint; use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsGetEndpoint; use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsPutEndpoint; use MailPoet\Automation\Engine\Endpoints\Automations\AutomationTemplateGetEndpoint; use MailPoet\Automation\Engine\Endpoints\Automations\AutomationTemplatesGetEndpoint; use MailPoet\Automation\Engine\Storage\AutomationStorage; use MailPoet\Automation\Integrations\Core\CoreIntegration; use MailPoet\Automation\Integrations\WordPress\WordPressIntegration; class Engine { const CAPABILITY_MANAGE_AUTOMATIONS = 'mailpoet_manage_automations'; /** @var API */ private $api; /** @var CoreIntegration */ private $coreIntegration; /** @var WordPressIntegration */ private $wordPressIntegration; /** @var Registry */ private $registry; /** @var StepHandler */ private $stepHandler; /** @var TriggerHandler */ private $triggerHandler; /** @var WordPress */ private $wordPress; /** @var AutomationStorage */ private $automationStorage; public function __construct( API $api, CoreIntegration $coreIntegration, WordPressIntegration $wordPressIntegration, Registry $registry, StepHandler $stepHandler, TriggerHandler $triggerHandler, WordPress $wordPress, AutomationStorage $automationStorage ) { $this->api = $api; $this->coreIntegration = $coreIntegration; $this->wordPressIntegration = $wordPressIntegration; $this->registry = $registry; $this->stepHandler = $stepHandler; $this->triggerHandler = $triggerHandler; $this->wordPress = $wordPress; $this->automationStorage = $automationStorage; } public function initialize(): void { $this->registerApiRoutes(); $this->api->initialize(); $this->stepHandler->initialize(); $this->triggerHandler->initialize(); $this->coreIntegration->register($this->registry); $this->wordPressIntegration->register($this->registry); $this->wordPress->doAction(Hooks::INITIALIZE, [$this->registry]); $this->registerActiveTriggerHooks(); } private function registerApiRoutes(): void { $this->wordPress->addAction(Hooks::API_INITIALIZE, function (API $api) { $api->registerGetRoute('automations', AutomationsGetEndpoint::class); $api->registerPutRoute('automations/(?P<id>\d+)', AutomationsPutEndpoint::class); $api->registerDeleteRoute('automations/(?P<id>\d+)', AutomationsDeleteEndpoint::class); $api->registerPostRoute('automations/(?P<id>\d+)/duplicate', AutomationsDuplicateEndpoint::class); $api->registerPostRoute('automations/create-from-template', AutomationsCreateFromTemplateEndpoint::class); $api->registerGetRoute('automation-templates', AutomationTemplatesGetEndpoint::class); $api->registerGetRoute('automation-templates/(?P<slug>.+)', AutomationTemplateGetEndpoint::class); }); } private function registerActiveTriggerHooks(): void { $triggerKeys = $this->automationStorage->getActiveTriggerKeys(); foreach ($triggerKeys as $triggerKey) { $instance = $this->registry->getTrigger($triggerKey); if ($instance) { $instance->registerHooks(); } } } } Validation/AutomationRules/index.php 0000644 00000000006 15073230056 0013606 0 ustar 00 <?php Validation/AutomationRules/TriggerNeedsToBeFollowedByActionRule.php 0000644 00000002723 15073230056 0021620 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class TriggerNeedsToBeFollowedByActionRule implements AutomationNodeVisitor { public const RULE_ID = 'trigger-needs-to-be-followed-by-action'; public function initialize(Automation $automation): void { } public function visitNode(Automation $automation, AutomationNode $node): void { if (!$automation->needsFullValidation()) { return; } $step = $node->getStep(); if ($step->getType() !== Step::TYPE_TRIGGER) { return; } $nextStepIds = $step->getNextStepIds(); if (!count($nextStepIds)) { throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID); } foreach ($nextStepIds as $nextStepsId) { $step = $automation->getStep($nextStepsId); if ($step && $step->getType() === Step::TYPE_ACTION) { continue; } throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID); } } public function complete(Automation $automation): void { } } Validation/AutomationRules/ValidStepRule.php 0000644 00000012024 15073230056 0015225 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; use MailPoet\Validator\ValidationException; use Throwable; class ValidStepRule implements AutomationNodeVisitor { /** @var AutomationNodeVisitor[] */ private $rules; /** @var array<string, array{step_id: string, fields: array<string,string>}> */ private $errors = []; /** @param AutomationNodeVisitor[] $rules */ public function __construct( array $rules ) { $this->rules = $rules; } public function initialize(Automation $automation): void { if (!$automation->needsFullValidation()) { return; } foreach ($this->rules as $rule) { $rule->initialize($automation); } } public function visitNode(Automation $automation, AutomationNode $node): void { if (!$automation->needsFullValidation()) { return; } foreach ($this->rules as $rule) { $stepId = $node->getStep()->getId(); try { $rule->visitNode($automation, $node); } catch (UnexpectedValueException $e) { if (!isset($this->errors[$stepId])) { $this->errors[$stepId] = ['step_id' => $stepId, 'message' => $e->getMessage(), 'fields' => [], 'filters' => []]; } $this->errors[$stepId]['fields'] = array_merge( $this->mapErrorCodesToErrorMessages($e->getErrors()), $this->errors[$stepId]['fields'] ); } catch (ValidationException $e) { if (!isset($this->errors[$stepId])) { $this->errors[$stepId] = ['step_id' => $stepId, 'message' => $e->getMessage(), 'fields' => [], 'filters' => []]; } $key = $rule instanceof ValidStepFiltersRule ? 'filters' : 'fields'; /** @phpstan-ignore-next-line - PHPStan detects inconsistency in merged array */ $this->errors[$stepId][$key] = array_merge( $this->mapErrorCodesToErrorMessages($e->getErrors()), $this->errors[$stepId][$key] ); } catch (Throwable $e) { if (!isset($this->errors[$stepId])) { $this->errors[$stepId] = ['step_id' => $stepId, 'message' => __('Unknown error.', 'mailpoet'), 'fields' => [], 'filters' => []]; } } } } private function mapErrorCodesToErrorMessages(array $errorCodes): array { return array_map( function(string $errorCode): string { switch ($errorCode) { case "rest_property_required": return __('This is a required field.', 'mailpoet'); case "rest_additional_properties_forbidden": case "rest_too_few_properties": case "rest_too_many_properties": return ""; case "rest_invalid_type": case "rest_invalid_multiple": case "rest_not_in_enum": return __('This field is not well formed.', 'mailpoet'); case "rest_too_few_items": return __('Please add more items.', 'mailpoet'); case "rest_too_many_items": return __('Please remove some items.', 'mailpoet'); case "rest_duplicate_items": return __('Please remove duplicate items.', 'mailpoet'); case "rest_out_of_bounds": return __('This value is out of bounds.', 'mailpoet'); case "rest_too_short": return __('This value is not long enough.', 'mailpoet'); case "rest_too_long": return __('This value is too long.', 'mailpoet'); case "rest_invalid_pattern": return __('This value is not well formed.', 'mailpoet'); case "rest_no_matching_schema": return __('This value does not match the expected format.', 'mailpoet'); case "rest_one_of_multiple_matches": return __('This value is not matching the correct times.', 'mailpoet'); case "rest_invalid_hex_color": return __('This value is not a hex formatted color.', 'mailpoet'); case "rest_invalid_date": return __('This value is not a date.', 'mailpoet'); case "rest_invalid_email": return __('This value is not an email address.', 'mailpoet'); case "rest_invalid_ip": return __('This value is not an IP address.', 'mailpoet'); case "rest_invalid_uuid": return __('This value is not an UUID.', 'mailpoet'); default: return $errorCode; } }, $errorCodes ); } public function complete(Automation $automation): void { if (!$automation->needsFullValidation()) { return; } foreach ($this->rules as $rule) { $rule->complete($automation); } if ($this->errors) { throw Exceptions::automationNotValid(__('Some steps are not valid', 'mailpoet'), $this->errors); } } } Validation/AutomationRules/NoJoinRule.php 0000644 00000002321 15073230056 0014525 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class NoJoinRule implements AutomationNodeVisitor { public const RULE_ID = 'no-join'; /** @var array<string|int, Step[]> */ private $directParentMap = []; public function initialize(Automation $automation): void { $this->directParentMap = []; } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); foreach ($step->getNextStepIds() as $nextStepId) { $this->directParentMap[$nextStepId] = array_merge($this->directParentMap[$nextStepId] ?? [], [$step]); } if (count($this->directParentMap[$step->getId()] ?? []) > 1) { throw Exceptions::automationStructureNotValid(__('Path join found in automation graph', 'mailpoet'), self::RULE_ID); } } public function complete(Automation $automation): void { } } Validation/AutomationRules/ValidStepArgsRule.php 0000644 00000003772 15073230056 0016054 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Registry; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; use MailPoet\Validator\ValidationException; use MailPoet\Validator\Validator; class ValidStepArgsRule implements AutomationNodeVisitor { /** @var Registry */ private $registry; /** @var Validator */ private $validator; public function __construct( Registry $registry, Validator $validator ) { $this->registry = $registry; $this->validator = $validator; } public function initialize(Automation $automation): void { } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); $registryStep = $this->registry->getStep($step->getKey()); if (!$registryStep) { return; } $schema = $registryStep->getArgsSchema(); $properties = $schema->toArray()['properties'] ?? null; if (!$properties) { $this->validator->validate($schema, $step->getArgs()); return; } $errors = []; foreach ($properties as $property => $propertySchema) { $schemaToValidate = array_merge( $schema->toArray(), ['properties' => [$property => $propertySchema]] ); try { $this->validator->validateSchemaArray( $schemaToValidate, $step->getArgs(), $property ); } catch (ValidationException $e) { $errors[$property] = $e->getWpError()->get_error_code(); } } if ($errors) { $throwable = ValidationException::create(); foreach ($errors as $errorKey => $errorMsg) { $throwable->withError((string)$errorKey, (string)$errorMsg); } throw $throwable; } } public function complete(Automation $automation): void { } } Validation/AutomationRules/ValidStepValidationRule.php 0000644 00000004342 15073230056 0017244 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Data\StepValidationArgs; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Integration\Payload; use MailPoet\Automation\Engine\Integration\Subject; use MailPoet\Automation\Engine\Registry; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class ValidStepValidationRule implements AutomationNodeVisitor { /** @var Registry */ private $registry; public function __construct( Registry $registry ) { $this->registry = $registry; } public function initialize(Automation $automation): void { } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); $registryStep = $this->registry->getStep($step->getKey()); if (!$registryStep) { return; } $subjects = $this->collectSubjects($automation, $node->getParents()); $args = new StepValidationArgs($automation, $step, $subjects); $registryStep->validate($args); } public function complete(Automation $automation): void { } /** * @param Step[] $parents * @return Subject<Payload>[] */ private function collectSubjects(Automation $automation, array $parents): array { $triggers = array_filter($parents, function (Step $step) { return $step->getType() === Step::TYPE_TRIGGER; }); $subjectKeys = []; foreach ($triggers as $trigger) { $registryTrigger = $this->registry->getTrigger($trigger->getKey()); if (!$registryTrigger) { throw Exceptions::automationTriggerNotFound($automation->getId(), $trigger->getKey()); } $subjectKeys = array_merge($subjectKeys, $registryTrigger->getSubjectKeys()); } $subjects = []; foreach (array_unique($subjectKeys) as $key) { $subject = $this->registry->getSubject($key); if (!$subject) { throw Exceptions::subjectNotFound($key); } $subjects[] = $subject; } return $subjects; } } Validation/AutomationRules/TriggersUnderRootRule.php 0000644 00000002545 15073230056 0016771 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class TriggersUnderRootRule implements AutomationNodeVisitor { public const RULE_ID = 'triggers-under-root'; /** @var array<string, Step> $triggersMap */ private $triggersMap = []; public function initialize(Automation $automation): void { $this->triggersMap = []; foreach ($automation->getSteps() as $step) { if ($step->getType() === 'trigger') { $this->triggersMap[$step->getId()] = $step; } } } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); if ($step->getType() === Step::TYPE_ROOT) { return; } foreach ($step->getNextStepIds() as $nextStepId) { if (isset($this->triggersMap[$nextStepId])) { throw Exceptions::automationStructureNotValid(__('Trigger must be a direct descendant of automation root', 'mailpoet'), self::RULE_ID); } } } public function complete(Automation $automation): void { } } Validation/AutomationRules/ValidStepOrderRule.php 0000644 00000003455 15073230056 0016231 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Control\SubjectTransformerHandler; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Registry; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class ValidStepOrderRule implements AutomationNodeVisitor { /** @var Registry */ private $registry; /** @var SubjectTransformerHandler */ private $subjectTransformerHandler; public function __construct( Registry $registry, SubjectTransformerHandler $subjectTransformerHandler ) { $this->registry = $registry; $this->subjectTransformerHandler = $subjectTransformerHandler; } public function initialize(Automation $automation): void { } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); $registryStep = $this->registry->getStep($step->getKey()); if (!$registryStep) { return; } // triggers don't require any subjects (they provide them) if ($step->getType() === Step::TYPE_TRIGGER) { return; } $requiredSubjectKeys = $registryStep->getSubjectKeys(); if (!$requiredSubjectKeys) { return; } $subjectKeys = $this->subjectTransformerHandler->getSubjectKeysForAutomation($automation); $missingSubjectKeys = array_diff($requiredSubjectKeys, $subjectKeys); if (count($missingSubjectKeys) > 0) { throw Exceptions::missingRequiredSubjects($step, $missingSubjectKeys); } } public function complete(Automation $automation): void { } } Validation/AutomationRules/NoDuplicateEdgesRule.php 0000644 00000002035 15073230056 0016512 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class NoDuplicateEdgesRule implements AutomationNodeVisitor { public const RULE_ID = 'no-duplicate-edges'; public function initialize(Automation $automation): void { } public function visitNode(Automation $automation, AutomationNode $node): void { $visitedNextStepIdsMap = []; foreach ($node->getStep()->getNextStepIds() as $nextStepId) { if (isset($visitedNextStepIdsMap[$nextStepId])) { throw Exceptions::automationStructureNotValid(__('Duplicate next step definition found', 'mailpoet'), self::RULE_ID); } $visitedNextStepIdsMap[$nextStepId] = true; } } public function complete(Automation $automation): void { } } Validation/AutomationRules/NoUnreachableStepsRule.php 0000644 00000002062 15073230056 0017060 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class NoUnreachableStepsRule implements AutomationNodeVisitor { public const RULE_ID = 'no-unreachable-steps'; /** @var AutomationNode[] */ private $visitedNodes = []; public function initialize(Automation $automation): void { $this->visitedNodes = []; } public function visitNode(Automation $automation, AutomationNode $node): void { $this->visitedNodes[$node->getStep()->getId()] = $node; } public function complete(Automation $automation): void { if (count($this->visitedNodes) !== count($automation->getSteps())) { throw Exceptions::automationStructureNotValid(__('Unreachable steps found in automation graph', 'mailpoet'), self::RULE_ID); } } } Validation/AutomationRules/AtLeastOneTriggerRule.php 0000644 00000002257 15073230056 0016664 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class AtLeastOneTriggerRule implements AutomationNodeVisitor { public const RULE_ID = 'at-least-one-trigger'; /** @var bool */ private $triggerFound = false; public function initialize(Automation $automation): void { $this->triggerFound = false; } public function visitNode(Automation $automation, AutomationNode $node): void { if ($node->getStep()->getType() === Step::TYPE_TRIGGER) { $this->triggerFound = true; } } public function complete(Automation $automation): void { if (!$automation->needsFullValidation()) { return; } if ($this->triggerFound) { return; } throw Exceptions::automationStructureNotValid(__('There must be at least one trigger in the automation.', 'mailpoet'), self::RULE_ID); } } Validation/AutomationRules/NoCycleRule.php 0000644 00000002326 15073230056 0014672 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class NoCycleRule implements AutomationNodeVisitor { public const RULE_ID = 'no-cycle'; public function initialize(Automation $automation): void { } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); $parents = $node->getParents(); $parentIdsMap = array_combine( array_map(function (Step $parent) { return $parent->getId(); }, $node->getParents()), $parents ) ?: []; foreach ($step->getNextStepIds() as $nextStepId) { if ($nextStepId === $step->getId() || isset($parentIdsMap[$nextStepId])) { throw Exceptions::automationStructureNotValid(__('Cycle found in automation graph', 'mailpoet'), self::RULE_ID); } } } public function complete(Automation $automation): void { } } Validation/AutomationRules/UnknownStepRule.php 0000644 00000004335 15073230056 0015633 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Registry; use MailPoet\Automation\Engine\Storage\AutomationStorage; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class UnknownStepRule implements AutomationNodeVisitor { /** @var Registry */ private $registry; /** @var AutomationStorage */ private $automationStorage; /** @var Automation|null|false */ private $cachedExistingAutomation = false; public function __construct( Registry $registry, AutomationStorage $automationStorage ) { $this->registry = $registry; $this->automationStorage = $automationStorage; } public function initialize(Automation $automation): void { $this->cachedExistingAutomation = false; } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); $registryStep = $this->registry->getStep($step->getKey()); // step not registered (e.g. plugin was deactivated) - allow saving it only if it hasn't changed if (!$registryStep) { $currentAutomation = $this->getCurrentAutomation($automation); $currentStep = $currentAutomation ? ($currentAutomation->getSteps()[$step->getId()] ?? null) : null; if (!$currentStep || $step->toArray() !== $currentStep->toArray()) { throw Exceptions::automationStepModifiedWhenUnknown($step); } } } public function complete(Automation $automation): void { } private function getCurrentAutomation(Automation $automation): ?Automation { try { $id = $automation->getId(); if ($this->cachedExistingAutomation === false) { $this->cachedExistingAutomation = $this->automationStorage->getAutomation($id); } } catch (InvalidStateException $e) { // for new automations, no automation ID is set $this->cachedExistingAutomation = null; } return $this->cachedExistingAutomation; } } Validation/AutomationRules/ValidStepFiltersRule.php 0000644 00000006343 15073230056 0016565 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Control\SubjectTransformerHandler; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Filter; use MailPoet\Automation\Engine\Integration\Payload; use MailPoet\Automation\Engine\Integration\Subject; use MailPoet\Automation\Engine\Registry; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; use MailPoet\Validator\ValidationException; use MailPoet\Validator\Validator; class ValidStepFiltersRule implements AutomationNodeVisitor { /** @var Registry */ private $registry; /** @var SubjectTransformerHandler */ private $subjectTransformerHandler; /** @var Validator */ private $validator; public function __construct( Registry $registry, SubjectTransformerHandler $subjectTransformerHandler, Validator $validator ) { $this->registry = $registry; $this->subjectTransformerHandler = $subjectTransformerHandler; $this->validator = $validator; } public function initialize(Automation $automation): void { } public function visitNode(Automation $automation, AutomationNode $node): void { $filters = $node->getStep()->getFilters(); $groups = $filters ? $filters->getGroups() : []; $errors = []; foreach ($groups as $group) { foreach ($group->getFilters() as $filter) { $registryFilter = $this->registry->getFilter($filter->getFieldType()); if (!$registryFilter) { continue; } try { $this->validator->validate($registryFilter->getArgsSchema($filter->getCondition()), $filter->getArgs()); } catch (ValidationException $e) { $errors[$filter->getId()] = $e->getWpError()->get_error_code(); continue; } // ensure that the field is available with the provided subjects $subjectKeys = $this->subjectTransformerHandler->getSubjectKeysForAutomation($automation); $filterSubject = $this->getFilterSubject($filter); if (!$filterSubject) { $errors[$filter->getId()] = __('Field not found', 'mailpoet'); } elseif (!in_array($filterSubject->getKey(), $subjectKeys, true)) { // translators: %s is the name of a subject (data structure) that provides the field $errors[$filter->getId()] = sprintf(__('A trigger that provides %s is required', 'mailpoet'), $filterSubject->getName()); } } } if ($errors) { $throwable = ValidationException::create()->withMessage('invalid-automation-filters'); foreach ($errors as $errorKey => $errorMsg) { $throwable->withError((string)$errorKey, (string)$errorMsg); } throw $throwable; } } public function complete(Automation $automation): void { } /** @return Subject<Payload> */ private function getFilterSubject(Filter $filter): ?Subject { foreach ($this->registry->getSubjects() as $subject) { foreach ($subject->getFields() as $field) { if ($field->getKey() === $filter->getFieldKey()) { return $subject; } } } return null; } } Validation/AutomationRules/ConsistentStepMapRule.php 0000644 00000002146 15073230056 0016761 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationRules; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor; class ConsistentStepMapRule implements AutomationNodeVisitor { public const RULE_ID = 'consistent-step-map'; public function initialize(Automation $automation): void { foreach ($automation->getSteps() as $id => $step) { if ((string)$id !== $step->getId()) { // translators: %1$s is the ID of the step, %2$s is its index in the steps object. throw Exceptions::automationStructureNotValid( sprintf(__("Step with ID '%1\$s' stored under a mismatched index '%2\$s'.", 'mailpoet'), $step->getId(), $id), self::RULE_ID ); } } } public function visitNode(Automation $automation, AutomationNode $node): void { } public function complete(Automation $automation): void { } } Validation/index.php 0000644 00000000006 15073230056 0010453 0 ustar 00 <?php Validation/AutomationSchema.php 0000644 00000005026 15073230056 0012614 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation; if (!defined('ABSPATH')) exit; use MailPoet\Validator\Builder; use MailPoet\Validator\Schema\ArraySchema; use MailPoet\Validator\Schema\ObjectSchema; class AutomationSchema { public static function getSchema(): ObjectSchema { return Builder::object([ 'id' => Builder::integer()->required(), 'name' => Builder::string()->minLength(1)->required(), 'status' => Builder::string()->required(), 'steps' => self::getStepsSchema()->required(), ]); } public static function getStepsSchema(): ObjectSchema { return Builder::object() ->properties(['root' => self::getRootStepSchema()->required()]) ->additionalProperties(self::getStepSchema()); } public static function getStepSchema(): ObjectSchema { return Builder::object([ 'id' => Builder::string()->required(), 'type' => Builder::string()->required(), 'key' => Builder::string()->required(), 'args' => Builder::object()->required(), 'next_steps' => self::getNextStepsSchema()->required(), 'filters' => self::getFiltersSchema()->nullable()->required(), ]); } public static function getRootStepSchema(): ObjectSchema { return Builder::object([ 'id' => Builder::string()->pattern('^root$'), 'type' => Builder::string()->pattern('^root$'), 'key' => Builder::string()->pattern('^core:root$'), 'args' => Builder::object()->disableAdditionalProperties(), 'next_steps' => self::getNextStepsSchema()->required(), ]); } public static function getNextStepsSchema(): ArraySchema { return Builder::array( Builder::object([ 'id' => Builder::string()->required()->nullable(), ]) ); } public static function getFiltersSchema(): ObjectSchema { $operatorSchema = Builder::string()->pattern('^and|or$')->required(); $filterSchema = Builder::object([ 'id' => Builder::string()->required(), 'field_type' => Builder::string()->required(), 'field_key' => Builder::string()->required(), 'condition' => Builder::string()->required(), 'args' => Builder::object()->required(), ]); $filterGroupSchema = Builder::object([ 'id' => Builder::string()->required(), 'operator' => $operatorSchema, 'filters' => Builder::array($filterSchema)->minItems(1)->required(), ]); return Builder::object([ 'operator' => $operatorSchema, 'groups' => Builder::array($filterGroupSchema)->minItems(1)->required(), ]); } } Validation/AutomationValidator.php 0000644 00000005675 15073230056 0013353 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationWalker; use MailPoet\Automation\Engine\Validation\AutomationRules\AtLeastOneTriggerRule; use MailPoet\Automation\Engine\Validation\AutomationRules\ConsistentStepMapRule; use MailPoet\Automation\Engine\Validation\AutomationRules\NoCycleRule; use MailPoet\Automation\Engine\Validation\AutomationRules\NoDuplicateEdgesRule; use MailPoet\Automation\Engine\Validation\AutomationRules\NoJoinRule; use MailPoet\Automation\Engine\Validation\AutomationRules\NoUnreachableStepsRule; use MailPoet\Automation\Engine\Validation\AutomationRules\TriggerNeedsToBeFollowedByActionRule; use MailPoet\Automation\Engine\Validation\AutomationRules\TriggersUnderRootRule; use MailPoet\Automation\Engine\Validation\AutomationRules\UnknownStepRule; use MailPoet\Automation\Engine\Validation\AutomationRules\ValidStepArgsRule; use MailPoet\Automation\Engine\Validation\AutomationRules\ValidStepFiltersRule; use MailPoet\Automation\Engine\Validation\AutomationRules\ValidStepOrderRule; use MailPoet\Automation\Engine\Validation\AutomationRules\ValidStepRule; use MailPoet\Automation\Engine\Validation\AutomationRules\ValidStepValidationRule; class AutomationValidator { /** @var AutomationWalker */ private $automationWalker; /** @var ValidStepArgsRule */ private $validStepArgsRule; /** @var ValidStepFiltersRule */ private $validStepFiltersRule; /** @var ValidStepOrderRule */ private $validStepOrderRule; /** @var ValidStepValidationRule */ private $validStepValidationRule; /** @var UnknownStepRule */ private $unknownStepRule; public function __construct( UnknownStepRule $unknownStepRule, ValidStepArgsRule $validStepArgsRule, ValidStepFiltersRule $validStepFiltersRule, ValidStepOrderRule $validStepOrderRule, ValidStepValidationRule $validStepValidationRule, AutomationWalker $automationWalker ) { $this->unknownStepRule = $unknownStepRule; $this->validStepArgsRule = $validStepArgsRule; $this->validStepFiltersRule = $validStepFiltersRule; $this->validStepOrderRule = $validStepOrderRule; $this->validStepValidationRule = $validStepValidationRule; $this->automationWalker = $automationWalker; } public function validate(Automation $automation): void { $this->automationWalker->walk($automation, [ new NoUnreachableStepsRule(), new ConsistentStepMapRule(), new NoDuplicateEdgesRule(), new TriggersUnderRootRule(), new NoCycleRule(), new NoJoinRule(), $this->unknownStepRule, new AtLeastOneTriggerRule(), new TriggerNeedsToBeFollowedByActionRule(), new ValidStepRule([ $this->validStepArgsRule, $this->validStepFiltersRule, $this->validStepOrderRule, $this->validStepValidationRule, ]), ]); } } Validation/AutomationGraph/index.php 0000644 00000000006 15073230056 0013555 0 ustar 00 <?php Validation/AutomationGraph/AutomationWalker.php 0000644 00000005413 15073230056 0015743 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationGraph; if (!defined('ABSPATH')) exit; use Generator; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException; class AutomationWalker { /** @param AutomationNodeVisitor[] $visitors */ public function walk(Automation $automation, array $visitors = []): void { $steps = $automation->getSteps(); $root = $steps['root'] ?? null; if (!$root) { throw Exceptions::automationStructureNotValid(__("Automation must contain a 'root' step", 'mailpoet'), 'no-root'); } foreach ($visitors as $visitor) { $visitor->initialize($automation); } foreach ($this->walkStepsDepthFirstPreOrder($steps, $root) as $record) { [$step, $parents] = $record; foreach ($visitors as $visitor) { $visitor->visitNode($automation, new AutomationNode($step, array_values($parents))); } } foreach ($visitors as $visitor) { $visitor->complete($automation); } } /** * @param array<string|int, Step> $steps * @return Generator<array{0: Step, 1: array<string|int, Step>}> */ private function walkStepsDepthFirstPreOrder(array $steps, Step $root): Generator { /** @var array{0: Step, 1: array<string|int, Step>}[] $stack */ $stack = [ [$root, []], ]; do { $record = array_pop($stack); if (!$record) { throw new InvalidStateException(); } yield $record; [$step, $parents] = $record; foreach (array_reverse($step->getNextSteps()) as $nextStepData) { $nextStepId = $nextStepData->getId(); if (!$nextStepId) { continue; // empty edge } $nextStep = $steps[$nextStepId] ?? null; if (!$nextStep) { throw $this->createStepNotFoundException($nextStepId, $step->getId()); } $nextStepParents = array_merge($parents, [$step->getId() => $step]); if (isset($nextStepParents[$nextStepId])) { continue; // cycle detected, do not enter the path again } array_push($stack, [$nextStep, $nextStepParents]); } } while (count($stack) > 0); } private function createStepNotFoundException(string $stepId, string $parentStepId): UnexpectedValueException { return Exceptions::automationStructureNotValid( // translators: %1$s is ID of the step not found, %2$s is ID of the step that references it sprintf( __("Step with ID '%1\$s' not found (referenced from '%2\$s')", 'mailpoet'), $stepId, $parentStepId ), 'step-not-found' ); } } Validation/AutomationGraph/AutomationNode.php 0000644 00000001133 15073230056 0015376 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationGraph; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Step; class AutomationNode { /** @var Step */ private $step; /** @var array */ private $parents; /* @param Step[] $parents */ public function __construct( Step $step, array $parents ) { $this->step = $step; $this->parents = $parents; } public function getStep(): Step { return $this->step; } /** @return Step[] */ public function getParents(): array { return $this->parents; } } Validation/AutomationGraph/AutomationNodeVisitor.php 0000644 00000000643 15073230056 0016763 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Validation\AutomationGraph; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; interface AutomationNodeVisitor { public function initialize(Automation $automation): void; public function visitNode(Automation $automation, AutomationNode $node): void; public function complete(Automation $automation): void; } Exceptions.php 0000644 00000034050 15073230056 0007401 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Exceptions\NotFoundException; use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException; use MailPoet\Automation\Engine\Utils\Json; class Exceptions { private const DATABASE_ERROR = 'mailpoet_automation_database_error'; private const JSON_NOT_OBJECT = 'mailpoet_automation_json_not_object'; private const AUTOMATION_NOT_FOUND = 'mailpoet_automation_not_found'; private const AUTOMATION_VERSION_NOT_FOUND = 'mailpoet_automation_version_not_found'; private const AUTOMATION_NOT_ACTIVE = 'mailpoet_automation_not_active'; private const AUTOMATION_RUN_NOT_FOUND = 'mailpoet_automation_run_not_found'; private const AUTOMATION_STEP_NOT_FOUND = 'mailpoet_automation_step_not_found'; private const AUTOMATION_TRIGGER_NOT_FOUND = 'mailpoet_automation_trigger_not_found'; private const AUTOMATION_RUN_NOT_RUNNING = 'mailpoet_automation_run_not_running'; private const SUBJECT_NOT_FOUND = 'mailpoet_automation_subject_not_found'; private const SUBJECT_LOAD_FAILED = 'mailpoet_automation_subject_load_failed'; private const SUBJECT_DATA_NOT_FOUND = 'mailpoet_automation_subject_data_not_found'; private const MULTIPLE_SUBJECTS_FOUND = 'mailpoet_automation_multiple_subjects_found'; private const PAYLOAD_NOT_FOUND = 'mailpoet_automation_payload_not_found'; private const MULTIPLE_PAYLOADS_FOUND = 'mailpoet_automation_multiple_payloads_found'; private const FIELD_NOT_FOUND = 'mailpoet_automation_field_not_found'; private const FIELD_LOAD_FAILED = 'mailpoet_automation_field_load_failed'; private const FILTER_NOT_FOUND = 'mailpoet_automation_filter_not_found'; private const NEXT_STEP_NOT_FOUND = 'mailpoet_next_step_not_found'; private const NEXT_STEP_NOT_SCHEDULED = 'mailpoet_next_step_not_scheduled'; private const AUTOMATION_STRUCTURE_MODIFICATION_NOT_SUPPORTED = 'mailpoet_automation_structure_modification_not_supported'; private const AUTOMATION_STRUCTURE_NOT_VALID = 'mailpoet_automation_structure_not_valid'; private const AUTOMATION_STEP_MODIFIED_WHEN_UNKNOWN = 'mailpoet_automation_step_modified_when_unknown'; private const AUTOMATION_NOT_VALID = 'mailpoet_automation_not_valid'; private const MISSING_REQUIRED_SUBJECTS = 'mailpoet_automation_missing_required_subjects'; private const AUTOMATION_NOT_TRASHED = 'mailpoet_automation_not_trashed'; private const AUTOMATION_TEMPLATE_NOT_FOUND = 'mailpoet_automation_template_not_found'; private const AUTOMATION_HAS_ACTIVE_RUNS = 'mailpoet_automation_has_active_runs'; public function __construct() { throw new InvalidStateException( "This is a static factory class. Use it via 'Exception::someError()' factories." ); } public static function databaseError(string $error): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::DATABASE_ERROR) // translators: %s is the error message. ->withMessage(sprintf(__('Database error: %s', 'mailpoet'), $error)); } public static function jsonNotObject(string $json): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::JSON_NOT_OBJECT) // translators: %s is the mentioned JSON string. ->withMessage(sprintf(__("JSON string '%s' doesn't encode an object.", 'mailpoet'), $json)); } public static function automationNotFound(int $id): NotFoundException { return NotFoundException::create() ->withErrorCode(self::AUTOMATION_NOT_FOUND) // translators: %d is the ID of the automation. ->withMessage(sprintf(__("Automation with ID '%d' not found.", 'mailpoet'), $id)); } public static function automationVersionNotFound(int $automation, int $version): NotFoundException { return NotFoundException::create() ->withErrorCode(self::AUTOMATION_VERSION_NOT_FOUND) // translators: %1$s is the ID of the automation, %2$s the version. ->withMessage(sprintf(__('Automation with ID "%1$s" in version "%2$s" not found.', 'mailpoet'), $automation, $version)); } public static function automationNotActive(int $automation): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::AUTOMATION_NOT_ACTIVE) // translators: %1$s is the ID of the automation. ->withMessage(sprintf(__('Automation with ID "%1$s" in no longer active.', 'mailpoet'), $automation)); } public static function automationRunNotFound(int $id): NotFoundException { return NotFoundException::create() ->withErrorCode(self::AUTOMATION_RUN_NOT_FOUND) // translators: %d is the ID of the automation run. ->withMessage(sprintf(__("Automation run with ID '%d' not found.", 'mailpoet'), $id)); } public static function automationStepNotFound(string $key): NotFoundException { return NotFoundException::create() ->withErrorCode(self::AUTOMATION_STEP_NOT_FOUND) // translators: %s is the key of the automation step. ->withMessage(sprintf(__("Automation step with key '%s' not found.", 'mailpoet'), $key)); } public static function automationTriggerNotFound(int $automationId, string $key): NotFoundException { return NotFoundException::create() ->withErrorCode(self::AUTOMATION_TRIGGER_NOT_FOUND) // translators: %1$s is the key, %2$d is the automation ID. ->withMessage(sprintf(__('Automation trigger with key "%1$s" not found in automation ID "%2$d".', 'mailpoet'), $key, $automationId)); } public static function automationRunNotRunning(int $id, string $status): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::AUTOMATION_RUN_NOT_RUNNING) // translators: %1$d is the ID of the automation run, %2$s its current status. ->withMessage(sprintf(__('Automation run with ID "%1$d" is not running. Status: %2$s', 'mailpoet'), $id, $status)); } public static function subjectNotFound(string $key): NotFoundException { return NotFoundException::create() ->withErrorCode(self::SUBJECT_NOT_FOUND) // translators: %s is the key of the subject not found. ->withMessage(sprintf(__("Subject with key '%s' not found.", 'mailpoet'), $key)); } public static function subjectClassNotFound(string $class): NotFoundException { return NotFoundException::create() ->withErrorCode(self::SUBJECT_NOT_FOUND) // translators: %s is the class name of the subject not found. ->withMessage(sprintf(__("Subject of class '%s' not found.", 'mailpoet'), $class)); } public static function subjectLoadFailed(string $key, array $args): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::SUBJECT_LOAD_FAILED) // translators: %1$s is the name of the key, %2$s the arguments. ->withMessage(sprintf(__('Subject with key "%1$s" and args "%2$s" failed to load.', 'mailpoet'), $key, Json::encode($args))); } public static function subjectDataNotFound(string $key, int $automationRunId): NotFoundException { return NotFoundException::create() ->withErrorCode(self::SUBJECT_DATA_NOT_FOUND) // translators: %1$s is the key of the subject, %2$d is automation run ID. ->withMessage( sprintf(__("Subject data for subject with key '%1\$s' not found for automation run with ID '%2\$d'.", 'mailpoet'), $key, $automationRunId) ); } public static function multipleSubjectsFound(string $key, int $automationRunId): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::MULTIPLE_SUBJECTS_FOUND) // translators: %1$s is the key of the subject, %2$d is automation run ID. ->withMessage( sprintf(__("Multiple subjects with key '%1\$s' found for automation run with ID '%2\$d', only one expected.", 'mailpoet'), $key, $automationRunId) ); } public static function payloadNotFound(string $class, int $automationRunId): NotFoundException { return NotFoundException::create() ->withErrorCode(self::PAYLOAD_NOT_FOUND) // translators: %1$s is the class of the payload, %2$d is automation run ID. ->withMessage( sprintf(__("Payload of class '%1\$s' not found for automation run with ID '%2\$d'.", 'mailpoet'), $class, $automationRunId) ); } public static function multiplePayloadsFound(string $class, int $automationRunId): NotFoundException { return NotFoundException::create() ->withErrorCode(self::MULTIPLE_PAYLOADS_FOUND) // translators: %1$s is the class of the payloads, %2$d is automation run ID. ->withMessage( sprintf(__("Multiple payloads of class '%1\$s' found for automation run with ID '%2\$d'.", 'mailpoet'), $class, $automationRunId) ); } public static function fieldNotFound(string $key): NotFoundException { return NotFoundException::create() ->withErrorCode(self::FIELD_NOT_FOUND) // translators: %s is the key of the field not found. ->withMessage(sprintf(__("Field with key '%s' not found.", 'mailpoet'), $key)); } public static function fieldLoadFailed(string $key, array $args): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::FIELD_LOAD_FAILED) // translators: %1$s is the key of the field, %2$s its arguments. ->withMessage(sprintf(__('Field with key "%1$s" and args "%2$s" failed to load.', 'mailpoet'), $key, Json::encode($args))); } public static function filterNotFound(string $fieldType): NotFoundException { return NotFoundException::create() ->withErrorCode(self::FILTER_NOT_FOUND) // translators: %s is the type of the field for which a filter was not found. ->withMessage(sprintf(__("Filter for field of type '%s' not found.", 'mailpoet'), $fieldType)); } public static function nextStepNotFound(string $stepId, int $nextStepId): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::NEXT_STEP_NOT_FOUND) // translators: %1$d is the ID of the automation step, %2$s is the ID of the next step. ->withMessage(sprintf(__("Automation step with ID '%1\$s' doesn't have a next step with index '%2\$d'.", 'mailpoet'), $stepId, $nextStepId)); } public static function nextStepNotScheduled(string $stepId): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::NEXT_STEP_NOT_SCHEDULED) // translators: %1$d is the ID of the automation step, %2$s is the ID of the next step. ->withMessage(sprintf(__("Automation step with ID '%s' did not schedule a specific next step, even though multiple next steps are possible.", 'mailpoet'), $stepId)); } public static function automationStructureModificationNotSupported(): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_STRUCTURE_MODIFICATION_NOT_SUPPORTED) ->withMessage(__('Automation structure modification not supported.', 'mailpoet')); } public static function automationStructureNotValid(string $detail, string $ruleId): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_STRUCTURE_NOT_VALID) // translators: %s is a detailed information ->withMessage(sprintf(__("Invalid automation structure: %s", 'mailpoet'), $detail)) ->withErrors(['rule_id' => $ruleId]); } public static function automationStepModifiedWhenUnknown(Step $step): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_STEP_MODIFIED_WHEN_UNKNOWN) // translators: %1$s is the key of the step, %2$s is the type of the step, %3\$s is its ID. ->withMessage( sprintf( __("Modification of step '%1\$s' of type '%2\$s' with ID '%3\$s' is not supported when the related plugin is not active.", 'mailpoet'), $step->getKey(), $step->getType(), $step->getId() ) ); } public static function automationNotValid(string $detail, array $errors): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_NOT_VALID) // translators: %s is a detailed information ->withMessage(sprintf(__("Automation validation failed: %s", 'mailpoet'), $detail)) ->withErrors($errors); } public static function missingRequiredSubjects(Step $step, array $missingSubjectKeys): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::MISSING_REQUIRED_SUBJECTS) // translators: %1$s is the key of the step, %2$s are the missing subject keys. ->withMessage( sprintf( __("Step with ID '%1\$s' is missing required subjects with keys: %2\$s", 'mailpoet'), $step->getId(), implode(', ', $missingSubjectKeys) ) ) ->withErrors( ['general' => __('This step can not be used with the selected trigger.', 'mailpoet')] ); } public static function automationNotTrashed(int $id): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_NOT_TRASHED) // translators: %d is the ID of the automation. ->withMessage(sprintf(__("Can't delete automation with ID '%d' because it was not trashed.", 'mailpoet'), $id)); } public static function automationTemplateNotFound(string $id): NotFoundException { return NotFoundException::create() ->withErrorCode(self::AUTOMATION_TEMPLATE_NOT_FOUND) // translators: %d is the ID of the automation template. ->withMessage(sprintf(__("Automation template with ID '%d' not found.", 'mailpoet'), $id)); } /** * This is a temporary block, see MAILPOET-4744 */ public static function automationHasActiveRuns(int $id): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::AUTOMATION_HAS_ACTIVE_RUNS) // translators: %d is the ID of the automation. ->withMessage(sprintf(__("Can not update automation with ID '%d' because users are currently active.", 'mailpoet'), $id)); } } Templates/index.php 0000644 00000000006 15073230056 0010317 0 ustar 00 <?php Templates/AutomationBuilder.php 0000644 00000007646 15073230056 0012660 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Templates; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Automation; use MailPoet\Automation\Engine\Data\Filter; use MailPoet\Automation\Engine\Data\FilterGroup; use MailPoet\Automation\Engine\Data\Filters; use MailPoet\Automation\Engine\Data\NextStep; use MailPoet\Automation\Engine\Data\Step; use MailPoet\Automation\Engine\Exceptions\InvalidStateException; use MailPoet\Automation\Engine\Integration\Trigger; use MailPoet\Automation\Engine\Registry; use MailPoet\Util\Security; use MailPoet\Validator\Schema\ObjectSchema; class AutomationBuilder { /** @var Registry */ private $registry; public function __construct( Registry $registry ) { $this->registry = $registry; } /** * @param string $name * @param array< * array{ * key: string, * args?: array<string, mixed>, * filters?: array{ * operator: 'and' | 'or', * groups: array{ * operator: 'and' | 'or', * filters: array{ * field: string, * condition: string, * value: mixed, * }[], * }[], * }, * } * > $sequence * @param array<string, mixed> $meta * @return Automation */ public function createFromSequence(string $name, array $sequence, array $meta = []): Automation { $steps = []; $nextSteps = []; foreach (array_reverse($sequence) as $data) { $stepKey = $data['key']; $automationStep = $this->registry->getStep($stepKey); if (!$automationStep) { continue; } $args = array_merge($this->getDefaultArgs($automationStep->getArgsSchema()), $data['args'] ?? []); $filters = isset($data['filters']) ? $this->getFilters($data['filters']) : null; $step = new Step( $this->uniqueId(), in_array(Trigger::class, (array)class_implements($automationStep)) ? Step::TYPE_TRIGGER : Step::TYPE_ACTION, $stepKey, $args, $nextSteps, $filters ); $nextSteps = [new NextStep($step->getId())]; $steps[$step->getId()] = $step; } $steps['root'] = new Step('root', 'root', 'core:root', [], $nextSteps); $steps = array_reverse($steps); $automation = new Automation( $name, $steps, wp_get_current_user() ); foreach ($meta as $key => $value) { $automation->setMeta($key, $value); } return $automation; } private function uniqueId(): string { return Security::generateRandomString(16); } private function getDefaultArgs(ObjectSchema $argsSchema): array { $args = []; foreach ($argsSchema->toArray()['properties'] ?? [] as $name => $schema) { if (array_key_exists('default', $schema)) { $args[$name] = $schema['default']; } } return $args; } /** * @param array{ * operator: 'and' | 'or', * groups: array{ * operator: 'and' | 'or', * filters: array{ * field: string, * condition: string, * value: mixed, * }[], * }[], * } $filters * @return Filters */ private function getFilters(array $filters): Filters { $groups = []; foreach ($filters['groups'] as $group) { $groups[] = new FilterGroup( $this->uniqueId(), $group['operator'], array_map( function (array $filter): Filter { $field = $this->registry->getField($filter['field']); if (!$field) { throw new InvalidStateException(sprintf("Field with key '%s' not found", $filter['field'])); } return new Filter( $this->uniqueId(), $field->getType(), $filter['field'], $filter['condition'], ['value' => $filter['value']] ); }, $group['filters'] ) ); } return new Filters($filters['operator'], $groups); } } Integration/index.php 0000644 00000000006 15073230056 0010644 0 ustar 00 <?php Integration/Trigger.php 0000644 00000000457 15073230056 0011152 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Integration; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\StepRunArgs; interface Trigger extends Step { public function registerHooks(): void; public function isTriggeredBy(StepRunArgs $args): bool; } Integration/Action.php 0000644 00000000523 15073230056 0010756 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Integration; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Control\StepRunController; use MailPoet\Automation\Engine\Data\StepRunArgs; interface Action extends Step { public function run(StepRunArgs $args, StepRunController $controller): void; } Integration/Step.php 0000644 00000000755 15073230056 0010463 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Integration; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\StepValidationArgs; use MailPoet\Validator\Schema\ObjectSchema; interface Step { public function getKey(): string; public function getName(): string; public function getArgsSchema(): ObjectSchema; /** @return string[] */ public function getSubjectKeys(): array; public function validate(StepValidationArgs $args): void; } Integration/Payload.php 0000644 00000000214 15073230056 0011127 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Integration; if (!defined('ABSPATH')) exit; interface Payload { } Integration/ValidationException.php 0000644 00000000372 15073230056 0013514 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Integration; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException; class ValidationException extends UnexpectedValueException { } Integration/Filter.php 0000644 00000001012 15073230056 0010760 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Integration; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Filter as FilterData; use MailPoet\Validator\Schema\ObjectSchema; interface Filter { public function getFieldType(): string; /** @return array<string, string> */ public function getConditions(): array; public function getArgsSchema(string $condition): ObjectSchema; /** @param mixed $value */ public function matches(FilterData $data, $value): bool; } Integration/SubjectTransformer.php 0000644 00000000507 15073230056 0013365 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Integration; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Subject; interface SubjectTransformer { public function transform(Subject $data): ?Subject; public function returns(): string; public function accepts(): string; } Integration/Subject.php 0000644 00000001135 15073230056 0011140 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\Integration; if (!defined('ABSPATH')) exit; use MailPoet\Automation\Engine\Data\Field; use MailPoet\Automation\Engine\Data\Subject as SubjectData; use MailPoet\Validator\Schema\ObjectSchema; /** * @template-covariant T of Payload */ interface Subject { public function getKey(): string; public function getName(): string; public function getArgsSchema(): ObjectSchema; /** @return Field[] */ public function getFields(): array; /** @return T */ public function getPayload(SubjectData $subjectData): Payload; } API/index.php 0000644 00000000006 15073230056 0006772 0 ustar 00 <?php API/API.php 0000644 00000002550 15073230056 0006302 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\API; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\API as MailPoetApi; use MailPoet\Automation\Engine\Hooks; use MailPoet\Automation\Engine\WordPress; class API extends MailPoetApi { /** @var MailPoetApi */ private $api; /** @var WordPress */ private $wordPress; public function __construct( MailPoetApi $api, WordPress $wordPress ) { $this->api = $api; $this->wordPress = $wordPress; } public function initialize(): void { $this->wordPress->addAction(MailPoetApi::REST_API_INIT_ACTION, function () { $this->wordPress->doAction(Hooks::API_INITIALIZE, [$this]); }); } public function registerGetRoute(string $route, string $endpoint): void { $this->api->registerGetRoute($route, $endpoint); } public function registerPostRoute(string $route, string $endpoint): void { $this->api->registerPostRoute($route, $endpoint); } public function registerPutRoute(string $route, string $endpoint): void { $this->api->registerPutRoute($route, $endpoint); } public function registerPatchRoute(string $route, string $endpoint): void { $this->api->registerPatchRoute($route, $endpoint); } public function registerDeleteRoute(string $route, string $endpoint): void { $this->api->registerDeleteRoute($route, $endpoint); } } API/Endpoint.php 0000644 00000000564 15073230056 0007454 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\Automation\Engine\API; if (!defined('ABSPATH')) exit; use MailPoet\API\REST\Endpoint as MailPoetEndpoint; use MailPoet\Automation\Engine\Engine; abstract class Endpoint extends MailPoetEndpoint { public function checkPermissions(): bool { return current_user_can(Engine::CAPABILITY_MANAGE_AUTOMATIONS); } } theme.json 0000644 00000011514 15073230072 0006542 0 ustar 00 { "$schema": "https://schemas.wp.org/trunk/theme.json", "version": 2, "settings": { "color": { "customGradient": false, "defaultGradients": false, "gradients": [] }, "layout": { "contentSize": "660px" }, "spacing": { "units": ["px"], "blockGap": true, "padding": true, "margin": false }, "border": { "radius": true, "color": true, "style": true, "width": true }, "typography": { "dropCap": false, "fontWeight": false, "fontFamilies": [ { "name": "Arial", "slug": "arial", "fontFamily": "Arial, 'Helvetica Neue', Helvetica, sans-serif" }, { "name": "Comic Sans MS", "slug": "comic-sans-ms", "fontFamily": "'Comic Sans MS', 'Marker Felt-Thin', Arial, sans-serif" }, { "name": "Courier New", "slug": "courier-new", "fontFamily": "'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace" }, { "name": "Georgia", "slug": "georgia", "fontFamily": "Georgia, Times, 'Times New Roman', serif" }, { "name": "Lucida", "slug": "lucida", "fontFamily": "'Lucida Sans Unicode', 'Lucida Grande', sans-serif" }, { "name": "Tahoma", "slug": "tahoma", "fontFamily": "'Tahoma, Verdana, Segoe, sans-serif'" }, { "name": "Times New Roman", "slug": "times-new-roman", "fontFamily": "'Times New Roman', Times, Baskerville, Georgia, serif" }, { "name": "Trebuchet MS", "slug": "trebuchet-ms", "fontFamily": "'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif" }, { "name": "Verdana", "slug": "verdana", "fontFamily": "'Verdana, Geneva, sans-serif'" }, { "name": "Arvo", "slug": "arvo", "fontFamily": "'arvo, courier, georgia, serif'" }, { "name": "Lato", "slug": "lato", "fontFamily": "lato, 'helvetica neue', helvetica, arial, sans-serif" }, { "name": "Lora", "slug": "lora", "fontFamily": "lora, georgia, 'times new roman', serif" }, { "name": "Merriweather", "slug": "merriweather", "fontFamily": "merriweather, georgia, 'times new roman', serif" }, { "name": "Merriweather Sans", "slug": "merriweather-sans", "fontFamily": "'merriweather sans', 'helvetica neue', helvetica, arial, sans-serif" }, { "name": "Noticia Text", "slug": "noticia-text", "fontFamily": "'noticia text', georgia, 'times new roman', serif" }, { "name": "Open Sans", "slug": "open-sans", "fontFamily": "'open sans', 'helvetica neue', helvetica, arial, sans-serif" }, { "name": "Playfair Display", "slug": "playfair-display", "fontFamily": "'playfair display', georgia, 'times new roman', serif" }, { "name": "Roboto", "slug": "roboto", "fontFamily": "roboto, 'helvetica neue', helvetica, arial, sans-serif" }, { "name": "Source Sans Pro", "slug": "source-sans-pro", "fontFamily": "'source sans pro', 'helvetica neue', helvetica, arial, sans-serif" }, { "name": "Oswald", "slug": "oswald", "fontFamily": "Oswald, 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif" }, { "name": "Raleway", "slug": "raleway", "fontFamily": "Raleway, 'Century Gothic', CenturyGothic, AppleGothic, sans-serif" }, { "name": "Permanent Marker", "slug": "permanent-marker", "fontFamily": "'Permanent Marker', Tahoma, Verdana, Segoe, sans-serif" }, { "name": "Pacifico", "slug": "pacifico", "fontFamily": "Pacifico, 'Arial Narrow', Arial, sans-serif" } ] }, "useRootPaddingAwareAlignments": true }, "styles": { "spacing": { "blockGap": "var(--wp--style--block-gap)", "padding": { "bottom": "var(--wp--style--block-gap)", "left": "var(--wp--style--block-gap)", "right": "var(--wp--style--block-gap)", "top": "var(--wp--style--block-gap)" } }, "color": { "background": "#ffffff", "text": "#000000" }, "typography": { "fontFamily": "Arial, 'Helvetica Neue', Helvetica, sans-serif", "fontSize": "16px" } } } Renderer/index.php 0000644 00000000006 15073230072 0010125 0 ustar 00 <?php Renderer/Preprocessors/TypographyPreprocessor.php 0000644 00000006410 15073230072 0016451 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer\Preprocessors; if (!defined('ABSPATH')) exit; use MailPoet\EmailEditor\Engine\SettingsController; class TypographyPreprocessor implements Preprocessor { /** * List of styles that should be copied from parent to children. * @var string[] */ private const TYPOGRAPHY_STYLES = [ 'color', 'font-size', 'text-decoration', ]; /** @var SettingsController */ private $settingsController; public function __construct( SettingsController $settingsController ) { $this->settingsController = $settingsController; } public function preprocess(array $parsedBlocks, array $layoutStyles): array { foreach ($parsedBlocks as $key => $block) { $block = $this->preprocessParent($block); // Set defaults from theme - this needs to be done on top level blocks only $block = $this->setDefaultsFromTheme($block); $block['innerBlocks'] = $this->copyTypographyFromParent($block['innerBlocks'], $block); $parsedBlocks[$key] = $block; } return $parsedBlocks; } private function copyTypographyFromParent(array $children, array $parent): array { foreach ($children as $key => $child) { $child = $this->preprocessParent($child); $child['email_attrs'] = array_merge($this->filterStyles($parent['email_attrs']), $child['email_attrs']); $child['innerBlocks'] = $this->copyTypographyFromParent($child['innerBlocks'] ?? [], $child); $children[$key] = $child; } return $children; } private function preprocessParent(array $block): array { // Build styles that should be copied to children $emailAttrs = []; if (isset($block['attrs']['style']['color']['text'])) { $emailAttrs['color'] = $block['attrs']['style']['color']['text']; } // In case the fontSize is set via a slug (small, medium, large, etc.) we translate it to a number // The font size slug is set in $block['attrs']['fontSize'] and value in $block['attrs']['style']['typography']['fontSize'] if (isset($block['attrs']['fontSize'])) { $block['attrs']['style']['typography']['fontSize'] = $this->settingsController->translateSlugToFontSize($block['attrs']['fontSize']); } // Pass font size to email_attrs if (isset($block['attrs']['style']['typography']['fontSize'])) { $emailAttrs['font-size'] = $block['attrs']['style']['typography']['fontSize']; } if (isset($block['attrs']['style']['typography']['textDecoration'])) { $emailAttrs['text-decoration'] = $block['attrs']['style']['typography']['textDecoration']; } $block['email_attrs'] = array_merge($emailAttrs, $block['email_attrs'] ?? []); return $block; } private function filterStyles(array $styles): array { return array_intersect_key($styles, array_flip(self::TYPOGRAPHY_STYLES)); } private function setDefaultsFromTheme(array $block): array { $themeData = $this->settingsController->getTheme()->get_data(); if (!($block['email_attrs']['color'] ?? '')) { $block['email_attrs']['color'] = $themeData['styles']['color']['text'] ?? null; } if (!($block['email_attrs']['font-size'] ?? '')) { $block['email_attrs']['font-size'] = $themeData['styles']['typography']['fontSize']; } return $block; } } Renderer/Preprocessors/Preprocessor.php 0000644 00000000354 15073230072 0014363 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer\Preprocessors; if (!defined('ABSPATH')) exit; interface Preprocessor { public function preprocess(array $parsedBlocks, array $layoutStyles): array; } Renderer/Preprocessors/index.php 0000644 00000000006 15073230072 0012776 0 ustar 00 <?php Renderer/Preprocessors/TopLevelPreprocessor.php 0000644 00000004512 15073230072 0016036 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer\Preprocessors; if (!defined('ABSPATH')) exit; class TopLevelPreprocessor implements Preprocessor { const SINGLE_COLUMN_TEMPLATE = [ 'blockName' => 'core/columns', 'attrs' => [], 'innerBlocks' => [[ 'blockName' => 'core/column', 'attrs' => [], 'innerBlocks' => [], ]], ]; /** * In the editor we allow putting content blocks directly into the root level of the email. * But for rendering purposes it is more convenient to have them wrapped in a single column. * This method walks through the first level of blocks and wraps non column blocks into a single column. */ public function preprocess(array $parsedBlocks, array $layoutStyles): array { $wrappedParsedBlocks = []; $nonColumnsBlocksBuffer = []; foreach ($parsedBlocks as $block) { $blockAlignment = $block['attrs']['align'] ?? null; // The next block is columns so we can flush the buffer and add the columns block if ($block['blockName'] === 'core/columns' || $blockAlignment === 'full') { if ($nonColumnsBlocksBuffer) { $columnsBlock = self::SINGLE_COLUMN_TEMPLATE; $columnsBlock['innerBlocks'][0]['innerBlocks'] = $nonColumnsBlocksBuffer; $nonColumnsBlocksBuffer = []; $wrappedParsedBlocks[] = $columnsBlock; } // If the block is full width and is not core/columns, we need to wrap it in a single column block, and it the columns block has to contain only the block if ($blockAlignment === 'full' && $block['blockName'] !== 'core/columns') { $columnsBlock = self::SINGLE_COLUMN_TEMPLATE; $columnsBlock['attrs']['align'] = 'full'; $columnsBlock['innerBlocks'][0]['innerBlocks'] = [$block]; $wrappedParsedBlocks[] = $columnsBlock; continue; } $wrappedParsedBlocks[] = $block; continue; } // Non columns block so we add it to the buffer $nonColumnsBlocksBuffer[] = $block; } // Flush the buffer if there are any blocks left if ($nonColumnsBlocksBuffer) { $columnsBlock = self::SINGLE_COLUMN_TEMPLATE; $columnsBlock['innerBlocks'][0]['innerBlocks'] = $nonColumnsBlocksBuffer; $wrappedParsedBlocks[] = $columnsBlock; } return $wrappedParsedBlocks; } } Renderer/Preprocessors/BlocksWidthPreprocessor.php 0000644 00000011547 15073230072 0016527 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer\Preprocessors; if (!defined('ABSPATH')) exit; /** * This class sets the width of the blocks based on the layout width or column count. * The final width in pixels is stored in the email_attrs array because we would like to avoid changing the original attributes. */ class BlocksWidthPreprocessor implements Preprocessor { public function preprocess(array $parsedBlocks, array $layoutStyles): array { foreach ($parsedBlocks as $key => $block) { // Layout width is recalculated for each block because full-width blocks don't exclude padding $layoutWidth = $this->parseNumberFromStringWithPixels($layoutStyles['width']); $alignment = $block['attrs']['align'] ?? null; // Subtract padding from the block width if it's not full-width if ($alignment !== 'full') { $layoutWidth -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['left'] ?? '0px'); $layoutWidth -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['right'] ?? '0px'); } $widthInput = $block['attrs']['width'] ?? '100%'; // Currently we support only % and px units in case only the number is provided we assume it's % // because editor saves percent values as a number. $widthInput = is_numeric($widthInput) ? "$widthInput%" : $widthInput; $width = $this->convertWidthToPixels($widthInput, $layoutWidth); if ($block['blockName'] === 'core/columns') { // Calculate width of the columns based on the layout width and padding $columnsWidth = $layoutWidth; $columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['spacing']['padding']['left'] ?? '0px'); $columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['spacing']['padding']['right'] ?? '0px'); $block['innerBlocks'] = $this->addMissingColumnWidths($block['innerBlocks'], $columnsWidth); } // Copy layout styles and update width and padding $modifiedLayoutStyles = $layoutStyles; $modifiedLayoutStyles['width'] = "{$width}px"; $modifiedLayoutStyles['padding']['left'] = $block['attrs']['style']['spacing']['padding']['left'] ?? '0px'; $modifiedLayoutStyles['padding']['right'] = $block['attrs']['style']['spacing']['padding']['right'] ?? '0px'; $block['email_attrs']['width'] = "{$width}px"; $block['innerBlocks'] = $this->preprocess($block['innerBlocks'], $modifiedLayoutStyles); $parsedBlocks[$key] = $block; } return $parsedBlocks; } // TODO: We could add support for other units like em, rem, etc. private function convertWidthToPixels(string $currentWidth, float $layoutWidth): float { $width = $layoutWidth; if (strpos($currentWidth, '%') !== false) { $width = (float)str_replace('%', '', $currentWidth); $width = round($width / 100 * $layoutWidth); } elseif (strpos($currentWidth, 'px') !== false) { $width = $this->parseNumberFromStringWithPixels($currentWidth); } return $width; } private function parseNumberFromStringWithPixels(string $string): float { return (float)str_replace('px', '', $string); } private function addMissingColumnWidths(array $columns, float $columnsWidth): array { $columnsCountWithDefinedWidth = 0; $definedColumnWidth = 0; $columnsCount = count($columns); foreach ($columns as $column) { if (isset($column['attrs']['width']) && !empty($column['attrs']['width'])) { $columnsCountWithDefinedWidth++; $definedColumnWidth += $this->convertWidthToPixels($column['attrs']['width'], $columnsWidth); } else { // When width is not set we need to add padding to the defined column width for better ratio accuracy $definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['left'] ?? '0px'); $definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['right'] ?? '0px'); } } if ($columnsCount - $columnsCountWithDefinedWidth > 0) { $defaultColumnsWidth = round(($columnsWidth - $definedColumnWidth) / ($columnsCount - $columnsCountWithDefinedWidth), 2); foreach ($columns as $key => $column) { if (!isset($column['attrs']['width']) || empty($column['attrs']['width'])) { // Add padding to the specific column width because it's not included in the default width $columnWidth = $defaultColumnsWidth; $columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['left'] ?? '0px'); $columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['right'] ?? '0px'); $columns[$key]['attrs']['width'] = "{$columnWidth}px"; } } } return $columns; } } Renderer/Preprocessors/SpacingPreprocessor.php 0000644 00000002156 15073230072 0015672 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer\Preprocessors; if (!defined('ABSPATH')) exit; use MailPoet\EmailEditor\Engine\SettingsController; /** * This preprocessor is responsible for setting default spacing values for blocks. * In the early development phase, we are setting only margin-top for blocks that are not first or last in the columns block. */ class SpacingPreprocessor implements Preprocessor { public function preprocess(array $parsedBlocks, array $layoutStyles): array { $parsedBlocks = $this->addMarginTopToBlocks($parsedBlocks); return $parsedBlocks; } private function addMarginTopToBlocks(array $parsedBlocks): array { foreach ($parsedBlocks as $key => $block) { // We don't want to add margin-top to the first block in the email or to the first block in the columns block if ($key !== 0) { $block['email_attrs']['margin-top'] = SettingsController::FLEX_GAP; } $block['innerBlocks'] = $this->addMarginTopToBlocks($block['innerBlocks'] ?? []); $parsedBlocks[$key] = $block; } return $parsedBlocks; } } Renderer/Preprocessors/CleanupPreprocessor.php 0000644 00000001312 15073230072 0015666 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer\Preprocessors; if (!defined('ABSPATH')) exit; class CleanupPreprocessor implements Preprocessor { public function preprocess(array $parsedBlocks, array $layoutStyles): array { foreach ($parsedBlocks as $key => $block) { // https://core.trac.wordpress.org/ticket/45312 // \WP_Block_Parser::parse_blocks() sometimes add a block with name null that can cause unexpected spaces in rendered content // This behavior was reported as an issue, but it was closed as won't fix if ($block['blockName'] === null) { unset($parsedBlocks[$key]); } } return array_values($parsedBlocks); } } Renderer/BlockRenderer.php 0000644 00000000471 15073230072 0011545 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer; if (!defined('ABSPATH')) exit; use MailPoet\EmailEditor\Engine\SettingsController; interface BlockRenderer { public function render(string $blockContent, array $parsedBlock, SettingsController $settingsController): string; } Renderer/template.html 0000644 00000004402 15073230072 0011012 0 ustar 00 <!DOCTYPE html> <html lang="{{newsletter_language}}" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" > <head> <title>{{newsletter_subject}}</title> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="format-detection" content="telephone=no" /> {{email_meta_robots}} <style type="text/css"> {{email_template_styles}} </style> </head> <body style="word-spacing:normal;background:{{layout_background}};"> <div class="email_layout_wrapper" style="background:{{layout_background}}"> <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:{{width}};" width="{{width}}" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> <div style="margin:0px auto;max-width:{{width}}"> <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%" > <tbody> <tr> <td class="email_preheader" style=" -webkit-text-size-adjust: none; font-size: 1px; line-height: 1px; color: #333333; " height="1" > {{email_preheader}} </td> </tr> <tr> <td class="email_content_wrapper" style=" direction: ltr; font-size: 0px; font-family: {{content_font_family}}; padding-bottom: {{padding_bottom}}; padding-top: {{padding_top}}; text-align: center; background: {{content_background}}; " > {{email_body}} </td> </tr> </tbody> </table> </div> <!--[if mso | IE]></td></tr></table><![endif]--> </div> </body> </html> Renderer/PreprocessManager.php 0000644 00000003422 15073230072 0012443 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer; if (!defined('ABSPATH')) exit; use MailPoet\EmailEditor\Engine\Renderer\Preprocessors\BlocksWidthPreprocessor; use MailPoet\EmailEditor\Engine\Renderer\Preprocessors\CleanupPreprocessor; use MailPoet\EmailEditor\Engine\Renderer\Preprocessors\Preprocessor; use MailPoet\EmailEditor\Engine\Renderer\Preprocessors\SpacingPreprocessor; use MailPoet\EmailEditor\Engine\Renderer\Preprocessors\TopLevelPreprocessor; use MailPoet\EmailEditor\Engine\Renderer\Preprocessors\TypographyPreprocessor; class PreprocessManager { /** @var Preprocessor[] */ private $preprocessors = []; public function __construct( CleanupPreprocessor $cleanupPreprocessor, TopLevelPreprocessor $topLevelPreprocessor, BlocksWidthPreprocessor $blocksWidthPreprocessor, TypographyPreprocessor $typographyPreprocessor, SpacingPreprocessor $spacingPreprocessor ) { $this->registerPreprocessor($cleanupPreprocessor); $this->registerPreprocessor($topLevelPreprocessor); $this->registerPreprocessor($blocksWidthPreprocessor); $this->registerPreprocessor($typographyPreprocessor); $this->registerPreprocessor($spacingPreprocessor); } /** * @param array $parsedBlocks * @param array{width: string, background: string, padding: array{bottom: string, left: string, right: string, top: string}} $layoutStyles * @return array */ public function preprocess(array $parsedBlocks, array $layoutStyles): array { foreach ($this->preprocessors as $preprocessor) { $parsedBlocks = $preprocessor->preprocess($parsedBlocks, $layoutStyles); } return $parsedBlocks; } public function registerPreprocessor(Preprocessor $preprocessor): void { $this->preprocessors[] = $preprocessor; } } Renderer/readme.md 0000644 00000003575 15073230072 0010102 0 ustar 00 # MailPoet Email Renderer The renderer is WIP and so is the API for adding support email rendering for new blocks. ## Adding support for a core block 1. Add block into `ALLOWED_BLOCK_TYPES` in `mailpoet/lib/EmailEditor/Engine/Renderer/SettingsController.php`. 2. Make sure the block is registered in the editor. Currently all core blocks are registered in the editor. 3. Add BlockRender class (e.g. Heading) into `mailpoet/lib/EmailEditor/Integration/Core/Renderer/Blocks` folder. <br /> ```php <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Integrations\Core\Renderer\Blocks; use MailPoet\EmailEditor\Engine\Renderer\BlockRenderer; use MailPoet\EmailEditor\Engine\SettingsController; class Heading implements BlockRenderer { public function render($blockContent, array $parsedBlock, SettingsController $settingsController): string { return 'HEADING_BLOCK'; // here comes your rendering logic; } } ``` 4. Register the renderer ```php <?php use MailPoet\EmailEditor\Engine\Renderer\BlocksRegistry; add_action('mailpoet_blocks_renderer_initialized', 'register_my_block_email_renderer'); function register_my_block_email_renderer(BlocksRegistry $blocksRegistry): void { $blocksRegistry->addBlockRenderer('core/heading', new Renderer\Blocks\Heading()); } ``` Note: For core blocks this is currently done in `MailPoet\EmailEditor\Integrations\Core\Initializer`. 5. Implement the rendering logic in the renderer class. ## Tips for adding support for block - You can take inspiration on block rendering from MJML in the https://mjml.io/try-it-live - Test the block in different clients [Litmus](https://litmus.com/) - You can take some inspirations from the HTML renderer by the old email editor ## TODO - add universal/fallback renderer for rendering blocks that are not covered by specialized renderers - add support for all core blocks - move the renderer to separate package Renderer/Renderer.php 0000644 00000012150 15073230072 0010567 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer; if (!defined('ABSPATH')) exit; use MailPoet\EmailEditor\Engine\SettingsController; use MailPoet\Util\pQuery\DomNode; use MailPoetVendor\Html2Text\Html2Text; class Renderer { /** @var \MailPoetVendor\CSS */ private $cssInliner; /** @var BlocksRegistry */ private $blocksRegistry; /** @var PreprocessManager */ private $preprocessManager; /** @var SettingsController */ private $settingsController; const TEMPLATE_FILE = 'template.html'; const TEMPLATE_STYLES_FILE = 'styles.css'; /** * @param \MailPoetVendor\CSS $cssInliner */ public function __construct( \MailPoetVendor\CSS $cssInliner, PreprocessManager $preprocessManager, BlocksRegistry $blocksRegistry, SettingsController $settingsController ) { $this->cssInliner = $cssInliner; $this->preprocessManager = $preprocessManager; $this->blocksRegistry = $blocksRegistry; $this->settingsController = $settingsController; } public function render(\WP_Post $post, string $subject, string $preHeader, string $language, $metaRobots = ''): array { $parser = new \WP_Block_Parser(); $parsedBlocks = $parser->parse($post->post_content); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps $layoutStyles = $this->settingsController->getEmailLayoutStyles(); $themeData = $this->settingsController->getTheme()->get_data(); $contentBackground = $themeData['styles']['color']['background'] ?? $layoutStyles['background']; $contentFontFamily = $themeData['styles']['typography']['fontFamily']; $parsedBlocks = $this->preprocessManager->preprocess($parsedBlocks, $layoutStyles); $renderedBody = $this->renderBlocks($parsedBlocks); $styles = (string)file_get_contents(dirname(__FILE__) . '/' . self::TEMPLATE_STYLES_FILE); $styles .= $this->settingsController->getStylesheetForRendering(); $styles = apply_filters('mailpoet_email_renderer_styles', $styles, $post); $template = (string)file_get_contents(dirname(__FILE__) . '/' . self::TEMPLATE_FILE); // Replace style settings placeholders with values $template = str_replace( ['{{width}}', '{{layout_background}}', '{{content_background}}', '{{content_font_family}}', '{{padding_top}}', '{{padding_right}}', '{{padding_bottom}}', '{{padding_left}}'], [$layoutStyles['width'], $layoutStyles['background'], $contentBackground, $contentFontFamily, $layoutStyles['padding']['top'], $layoutStyles['padding']['right'], $layoutStyles['padding']['bottom'], $layoutStyles['padding']['left']], $template ); /** * Replace template variables * {{email_language}} * {{email_subject}} * {{email_meta_robots}} * {{email_template_styles}} * {{email_preheader}} * {{email_body}} */ $templateWithContents = $this->injectContentIntoTemplate( $template, [ $language, esc_html($subject), $metaRobots, $styles, esc_html($preHeader), $renderedBody, ] ); $templateWithContentsDom = $this->inlineCSSStyles($templateWithContents); $templateWithContents = $this->postProcessTemplate($templateWithContentsDom); return [ 'html' => $templateWithContents, 'text' => $this->renderTextVersion($templateWithContents), ]; } public function renderBlocks(array $parsedBlocks): string { do_action('mailpoet_blocks_renderer_initialized', $this->blocksRegistry); $content = ''; foreach ($parsedBlocks as $parsedBlock) { $content .= render_block($parsedBlock); } /** * As we use default WordPress filters, we need to remove them after email rendering * so that we don't interfere with possible post rendering that might happen later. */ $this->blocksRegistry->removeAllBlockRendererFilters(); return $content; } private function injectContentIntoTemplate($template, array $content) { return preg_replace_callback('/{{\w+}}/', function($matches) use (&$content) { return array_shift($content); }, $template); } /** * @param string $template * @return DomNode */ private function inlineCSSStyles($template) { return $this->cssInliner->inlineCSS($template); } /** * @param string $template * @return string */ private function renderTextVersion($template) { $template = (mb_detect_encoding($template, 'UTF-8', true)) ? $template : mb_convert_encoding($template, 'UTF-8', mb_list_encodings()); return @Html2Text::convert($template); } /** * @param DomNode $templateDom * @return string */ private function postProcessTemplate(DomNode $templateDom) { // replace spaces in image tag URLs foreach ($templateDom->query('img') as $image) { $image->src = str_replace(' ', '%20', $image->src); } // because tburry/pquery contains a bug and replaces the opening non mso condition incorrectly we have to replace the opening tag with correct value $template = $templateDom->__toString(); $template = str_replace('<!--[if !mso]><![endif]-->', '<!--[if !mso]><!-- -->', $template); return $template; } } Renderer/BlocksRegistry.php 0000644 00000003300 15073230072 0011764 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer; if (!defined('ABSPATH')) exit; use MailPoet\EmailEditor\Engine\SettingsController; class BlocksRegistry { /** @var BlockRenderer[] */ private $blockRenderersMap = []; /** @var SettingsController */ private $settingsController; public function __construct( SettingsController $settingsController ) { $this->settingsController = $settingsController; } public function addBlockRenderer(string $blockName, BlockRenderer $renderer): void { $this->blockRenderersMap[$blockName] = $renderer; add_filter('render_block_' . $blockName, [$this, 'renderBlock'], 10, 2); } public function getBlockRenderer(string $blockName): ?BlockRenderer { return apply_filters('mailpoet_block_renderer_' . $blockName, $this->blockRenderersMap[$blockName] ?? null); } public function removeAllBlockRendererFilters(): void { foreach (array_keys($this->blockRenderersMap) as $blockName) { $this->removeBlockRenderer($blockName); } } public function renderBlock($blockContent, $parsedBlock): string { // Here we could add a default renderer for blocks that don't have a renderer registered if (!isset($this->blockRenderersMap[$parsedBlock['blockName']])) { throw new \InvalidArgumentException('Block renderer not found for block ' . $parsedBlock['name']); } return $this->blockRenderersMap[$parsedBlock['blockName']]->render($blockContent, $parsedBlock, $this->settingsController); } private function removeBlockRenderer(string $blockName): void { unset($this->blockRenderersMap[$blockName]); remove_filter('render_block_' . $blockName, [$this, 'renderBlock']); } } Renderer/styles.css 0000644 00000004577 15073230072 0010363 0 ustar 00 /* Base CSS rules to be applied to all emails */ /* Created based on original MailPoet template for rendering emails */ /* StyleLint is disabled because some rules contain properties that linter marks as unknown, but they are valid for email rendering */ /* stylelint-disable property-no-unknown */ body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */ -ms-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */ } .email_layout_wrapper { margin: 0 auto; padding: 20px 0; width: 100%; } table, td { border-collapse: collapse; mso-table-lspace: 0; mso-table-rspace: 0; } img { border: 0; height: auto; -ms-interpolation-mode: bicubic; line-height: 100%; max-width: 100%; outline: none; text-decoration: none; } p { display: block; margin: 0; } h1, h2, h3, h4, h5, h6 { margin-bottom: 0; margin-top: 0; } /* Wa want ensure the same design for all email clients */ ul, ol { /* When margin attribute is set to zero, Outlook doesn't render the list properly. As a possible workaround, we can reset only margin for top and bottom */ margin-bottom: 0; margin-top: 0; padding: 0 0 0 40px; } /* Outlook was adding weird spaces around lists in some versions. Resetting vertical margin for list items solved it */ li { margin-bottom: 0; margin-top: 0; } /* https://www.emailonacid.com/blog/article/email-development/tips-for-coding-email-preheaders */ .email_preheader, .email_preheader * { color: #fff; display: none; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; mso-hide: all; opacity: 0; overflow: hidden; visibility: hidden; } @media screen and (max-width: 660px) { .email_column { max-width: 100% !important; } .block { display: block; width: 100% !important; } /* Flex Layout */ .layout-flex-wrapper, .layout-flex-wrapper tbody, .layout-flex-wrapper tr { display: block !important; width: 100% !important; } .layout-flex-item { display: block !important; padding-bottom: 8px !important; /* Half of the flex gap between blocks */ padding-left: 0 !important; width: 100% !important; } .layout-flex-item table, .layout-flex-item td { display: block !important; width: 100% !important; } /* Flex Layout End */ } /* stylelint-enable property-no-unknown */ Renderer/Layout/index.php 0000644 00000000006 15073230072 0011402 0 ustar 00 <?php Renderer/Layout/FlexLayoutRenderer.php 0000644 00000011027 15073230072 0014063 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine\Renderer\Layout; if (!defined('ABSPATH')) exit; use MailPoet\EmailEditor\Engine\SettingsController; /** * This class provides functionality to render inner blocks of a block that supports reduced flex layout. */ class FlexLayoutRenderer { public function renderInnerBlocksInLayout(array $parsedBlock, SettingsController $settingsController): string { $innerBlocks = $this->computeWidthsForFlexLayout($parsedBlock, $settingsController); // MS Outlook doesn't support style attribute in divs so we conditionally wrap the buttons in a table and repeat styles $outputHtml = '<!--[if mso | IE]><table align="{align}" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tr><td class="" style="{style}" ><![endif]--> <div style="{style}"><table class="layout-flex-wrapper" style="display:inline-block"><tbody><tr>'; foreach ($innerBlocks as $key => $block) { $styles = []; if ($block['email_attrs']['layout_width'] ?? null) { $styles['width'] = $block['email_attrs']['layout_width']; } if ($key > 0) { $styles['padding-left'] = SettingsController::FLEX_GAP; } $outputHtml .= '<td class="layout-flex-item" style="' . esc_html($settingsController->convertStylesToString($styles)) . '">' . render_block($block) . '</td>'; } $outputHtml .= '</tr></table></div> <!--[if mso | IE]></td></tr></table><![endif]-->'; $wpGeneratedStyles = wp_style_engine_get_styles($parsedBlock['attrs']['style'] ?? []); $styles = $wpGeneratedStyles['css'] ?? ''; $marginTop = $parsedBlock['email_attrs']['margin-top'] ?? '0px'; $styles .= 'margin-top: ' . $marginTop . ';'; $justify = esc_attr($parsedBlock['attrs']['layout']['justifyContent'] ?? 'left'); $styles .= 'text-align: ' . $justify; $outputHtml = str_replace('{style}', $styles, $outputHtml); $outputHtml = str_replace('{align}', $justify, $outputHtml); return $outputHtml; } private function computeWidthsForFlexLayout(array $parsedBlock, SettingsController $settingsController): array { $blocksCount = count($parsedBlock['innerBlocks']); $totalUsedWidth = 0; // Total width assuming items without set width would consume proportional width $parentWidth = $settingsController->parseNumberFromStringWithPixels($parsedBlock['email_attrs']['width'] ?? SettingsController::EMAIL_WIDTH); $flexGap = $settingsController->parseNumberFromStringWithPixels(SettingsController::FLEX_GAP); $innerBlocks = $parsedBlock['innerBlocks'] ?? []; foreach ($innerBlocks as $key => $block) { $blockWidthPercent = ($block['attrs']['width'] ?? 0) ? intval($block['attrs']['width']) : 0; $blockWidth = floor($parentWidth * ($blockWidthPercent / 100)); // If width is not set, we assume it's 25% of the parent width $totalUsedWidth += $blockWidth ?: floor($parentWidth * (25 / 100)); if (!$blockWidth) { $innerBlocks[$key]['email_attrs']['layout_width'] = null; // Will be rendered as auto continue; } $innerBlocks[$key]['email_attrs']['layout_width'] = $this->getWidthWithoutGap($blockWidth, $flexGap, $blockWidthPercent) . 'px'; } // When there is only one block, or percentage is set reasonably we don't need to adjust and just render as set by user if ($blocksCount <= 1 || ($totalUsedWidth <= $parentWidth)) { return $innerBlocks; } foreach ($innerBlocks as $key => $block) { $proportionalSpaceOverflow = $parentWidth / $totalUsedWidth; $blockWidth = $block['email_attrs']['layout_width'] ? $settingsController->parseNumberFromStringWithPixels($block['email_attrs']['layout_width']) : 0; $blockProportionalWidth = $blockWidth * $proportionalSpaceOverflow; $blockProportionalPercentage = ($blockProportionalWidth / $parentWidth) * 100; $innerBlocks[$key]['email_attrs']['layout_width'] = $blockWidth ? $this->getWidthWithoutGap($blockProportionalWidth, $flexGap, $blockProportionalPercentage) . 'px' : null; } return $innerBlocks; } /** * How much of width we will strip to keep some space for the gap * This is computed based on CSS rule used in the editor: * For block with width set to X percent * width: calc(X% - (var(--wp--style--block-gap) * (100 - X)/100))); */ private function getWidthWithoutGap(float $blockWidth, float $flexGap, float $blockWidthPercent): int { $widthGapReduction = $flexGap * ((100 - $blockWidthPercent) / 100); return intval(floor($blockWidth - $widthGapReduction)); } } EmailEditor.php 0000644 00000003712 15073230072 0007455 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine; if (!defined('ABSPATH')) exit; /** * @phpstan-type EmailPostType array{name: string, args: array} * See register_post_type for details about EmailPostType args. */ class EmailEditor { /** @var EmailApiController */ private $emailApiController; public function __construct( EmailApiController $emailApiController ) { $this->emailApiController = $emailApiController; } public function initialize(): void { do_action('mailpoet_email_editor_initialized'); $this->registerEmailPostTypes(); $this->extendEmailPostApi(); } /** * Register all custom post types that should be edited via the email editor * The post types are added via mailpoet_email_editor_post_types filter. */ private function registerEmailPostTypes(): void { foreach ($this->getPostTypes() as $postType) { register_post_type( $postType['name'], array_merge($this->getDefaultEmailPostArgs(), $postType['args']) ); } } /** * @phpstan-return EmailPostType[] */ private function getPostTypes(): array { $postTypes = []; return apply_filters('mailpoet_email_editor_post_types', $postTypes); } private function getDefaultEmailPostArgs(): array { return [ 'public' => false, 'hierarchical' => false, 'show_ui' => true, 'show_in_menu' => false, 'show_in_nav_menus' => false, 'supports' => ['editor', 'title'], 'has_archive' => true, 'show_in_rest' => true, // Important to enable Gutenberg editor ]; } public function extendEmailPostApi() { $emailPostTypes = array_column($this->getPostTypes(), 'name'); register_rest_field($emailPostTypes, 'email_data', [ 'get_callback' => [$this->emailApiController, 'getEmailData'], 'update_callback' => [$this->emailApiController, 'saveEmailData'], 'schema' => $this->emailApiController->getEmailDataSchema(), ]); } } SettingsController.php 0000644 00000013736 15073230072 0011132 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine; if (!defined('ABSPATH')) exit; class SettingsController { const ALLOWED_BLOCK_TYPES = [ 'core/button', 'core/buttons', 'core/paragraph', 'core/heading', 'core/column', 'core/columns', 'core/image', 'core/list', 'core/list-item', ]; const DEFAULT_SETTINGS = [ 'enableCustomUnits' => ['px', '%'], ]; /** * Width of the email in pixels. * @var string */ const EMAIL_WIDTH = '660px'; /** * Color of email layout background. * @var string */ const EMAIL_LAYOUT_BACKGROUND = '#cccccc'; /** * Gap between blocks in flex layouts * @var string */ const FLEX_GAP = '16px'; private $availableStylesheets = ''; public function getSettings(): array { $coreDefaultSettings = get_default_block_editor_settings(); $editorTheme = $this->getTheme(); $themeSettings = $editorTheme->get_settings(); // body selector is later transformed to .editor-styles-wrapper // setting padding for bottom and top is needed because \WP_Theme_JSON::get_stylesheet() set them only for .wp-site-blocks selector $contentVariables = 'body {'; $contentVariables .= 'padding-bottom: var(--wp--style--root--padding-bottom);'; $contentVariables .= 'padding-top: var(--wp--style--root--padding-top);'; $contentVariables .= '--wp--style--block-gap:' . self::FLEX_GAP . ';'; $contentVariables .= '}'; $settings = array_merge($coreDefaultSettings, self::DEFAULT_SETTINGS); $settings['allowedBlockTypes'] = self::ALLOWED_BLOCK_TYPES; $flexEmailLayoutStyles = file_get_contents(__DIR__ . '/flex-email-layout.css'); $settings['styles'] = [ ['css' => wp_get_global_stylesheet(['base-layout-styles'])], ['css' => $editorTheme->get_stylesheet()], ['css' => $contentVariables], ['css' => $flexEmailLayoutStyles], ]; $settings['styles'] = apply_filters('mailpoet_email_editor_editor_styles', $settings['styles']); $settings['__experimentalFeatures'] = $themeSettings; // Enabling alignWide allows full width for specific blocks such as columns, heading, image, etc. $settings['alignWide'] = true; return $settings; } /** * @return array{contentSize: string, layout: string} */ public function getLayout(): array { return [ 'contentSize' => self::EMAIL_WIDTH, 'layout' => 'constrained', ]; } public function getAvailableStylesheets(): string { if ($this->availableStylesheets) return $this->availableStylesheets; $coreThemeData = \WP_Theme_JSON_Resolver::get_core_data(); $this->availableStylesheets = $coreThemeData->get_stylesheet(); return $this->availableStylesheets; } /** * @return array{width: string, background: string, padding: array{bottom: string, left: string, right: string, top: string}} */ public function getEmailLayoutStyles(): array { return [ 'width' => self::EMAIL_WIDTH, 'background' => self::EMAIL_LAYOUT_BACKGROUND, 'padding' => [ 'bottom' => self::FLEX_GAP, 'left' => self::FLEX_GAP, 'right' => self::FLEX_GAP, 'top' => self::FLEX_GAP, ], ]; } public function getLayoutWidthWithoutPadding(): string { $layoutStyles = $this->getEmailLayoutStyles(); $width = $this->parseNumberFromStringWithPixels($layoutStyles['width']); $width -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['left']); $width -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['right']); return "{$width}px"; } /** * This functions converts an array of styles to a string that can be used in HTML. */ public function convertStylesToString(array $styles): string { $cssString = ''; foreach ($styles as $property => $value) { $cssString .= $property . ':' . $value . ';'; } return trim($cssString); // Remove trailing space and return the formatted string } public function parseStylesToArray(string $styles): array { $styles = explode(';', $styles); $parsedStyles = []; foreach ($styles as $style) { $style = explode(':', $style); if (count($style) === 2) { $parsedStyles[trim($style[0])] = trim($style[1]); } } return $parsedStyles; } public function parseNumberFromStringWithPixels(string $string): float { return (float)str_replace('px', '', $string); } public function getTheme(): \WP_Theme_JSON { $coreThemeData = \WP_Theme_JSON_Resolver::get_core_data(); $themeJson = (string)file_get_contents(dirname(__FILE__) . '/theme.json'); $themeJson = json_decode($themeJson, true); /** @var array $themeJson */ $coreThemeData->merge(new \WP_Theme_JSON($themeJson, 'default')); return apply_filters('mailpoet_email_editor_theme_json', $coreThemeData); } public function getStylesheetForRendering(): string { $emailThemeSettings = $this->getTheme()->get_settings(); $cssPresets = ''; // Font family classes foreach ($emailThemeSettings['typography']['fontFamilies']['default'] as $fontFamily) { $cssPresets .= ".has-{$fontFamily['slug']}-font-family { font-family: {$fontFamily['fontFamily']}; } \n"; } // Font size classes foreach ($emailThemeSettings['typography']['fontSizes']['default'] as $fontSize) { $cssPresets .= ".has-{$fontSize['slug']}-font-size { font-size: {$fontSize['size']}; } \n"; } // Block specific styles $cssBlocks = ''; $blocks = $this->getTheme()->get_styles_block_nodes(); foreach ($blocks as $blockMetadata) { $cssBlocks .= $this->getTheme()->get_styles_for_block($blockMetadata); } return $cssPresets . $cssBlocks; } public function translateSlugToFontSize(string $fontSize): string { $settings = $this->getTheme()->get_settings(); foreach ($settings['typography']['fontSizes']['default'] as $fontSizeDefinition) { if ($fontSizeDefinition['slug'] === $fontSize) { return $fontSizeDefinition['size']; } } return $fontSize; } } theme.md 0000644 00000003117 15073230072 0006171 0 ustar 00 # Theme.json for the email editor We use theme.json to define settings and styles for the email editor and we reuse the definitions also in the rendering engine. The theme is used in combination with the [core's theme.json](https://github.com/WordPress/WordPress/blob/master/wp-includes/theme.json). We load the core's theme.json first and then we merge the email editor's theme.json on top of it. In this file we want to document settings and styles that are specific to the email editor. ## Settings - **color**: We disable gradients, because they are not supported in many email clients. We may add the support later. - **layout**: We set content width to 660px, because it's the most common width for emails. This is meant as a default value. - **spacing**: We allow only px units, because they are the most reliable in email clients. We may add the support for other units later with some sort of conversion to px. We also disable margins because they are not supported in our renderer (margin collapsing might be tricky). - **border**: We want to allow all types of borders and border styles. - **typography**: We disabled fontWeight and dropCap appearance settings, because they are not supported in our renderer. We may add the support later. We also define a set of basic font families that are safe to use with emails. The list was copied from the battle tested legacy editor. ## Styles - **spacing**: We define default padding for the emails. - **color**: We define default colors for text and background of the emails. - **typography**: We define default font family and font size for the emails. flex-email-layout.css 0000644 00000001677 15073230072 0010626 0 ustar 00 .is-layout-email-flex { flex-wrap: nowrap; } :where(body .is-layout-flex) { gap: var(--wp--style--block-gap, 16px); } .is-mobile-preview .is-layout-email-flex { display: block; } .is-mobile-preview .is-layout-email-flex .block-editor-block-list__block { padding: 5px 0; width: 100%; } .is-mobile-preview .is-layout-email-flex .wp-block-button__link { width: 100%; } /* * Email Editor specific styles for vertical gap between blocks in column. * This is needed because we disable layout for core/column and core/columns blocks, and .is-layout-flex is not applied. */ .wp-block-columns:not(.is-not-stacked-on-mobile) > .wp-block-column > .wp-block:first-child { margin-top: 0; } .wp-block-columns:not(.is-not-stacked-on-mobile) > .wp-block-column > .wp-block { margin: var(--wp--style--block-gap, 16px) 0; } .wp-block-columns:not(.is-not-stacked-on-mobile) > .wp-block-column > .wp-block:last-child { margin-bottom: 0; } EmailApiController.php 0000644 00000002350 15073230072 0011001 0 ustar 00 <?php declare(strict_types = 1); namespace MailPoet\EmailEditor\Engine; if (!defined('ABSPATH')) exit; use MailPoet\Validator\Builder; class EmailApiController { /** @var SettingsController */ private $settingsController; public function __construct( SettingsController $settingsController ) { $this->settingsController = $settingsController; } /** * @return array - Email specific data such styles. */ public function getEmailData(): array { return [ 'layout_styles' => $this->settingsController->getEmailLayoutStyles(), ]; } /** * Update Email specific data we store. */ public function saveEmailData(array $data, \WP_Post $emailPost): void { // Here comes code saving of Email specific data that will be passed on 'email_data' attribute } public function getEmailDataSchema(): array { return Builder::object([ 'layout_styles' => Builder::object([ 'width' => Builder::string(), 'background' => Builder::string(), 'padding' => Builder::object([ 'bottom' => Builder::string(), 'left' => Builder::string(), 'right' => Builder::string(), 'top' => Builder::string(), ]), ]), ])->toArray(); } }
| ver. 1.4 |
Github
|
.
| PHP 7.4.33 | Generation time: 0.01 |
proxy
|
phpinfo
|
Settings