diff --git a/administrator/components/com_admin/script.php b/administrator/components/com_admin/script.php
index eb77c91dab6b1..4b66adbc5b866 100644
--- a/administrator/components/com_admin/script.php
+++ b/administrator/components/com_admin/script.php
@@ -16,6 +16,7 @@
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Table\Asset;
+use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
@@ -148,6 +149,13 @@ public function update($installer)
$this->collectError('Further update', $e);
}
+ // Separate try/catch so a missing task cannot stop the cache being cleaned below.
+ try {
+ $this->createWorkflowAutomationTask();
+ } catch (\Throwable $e) {
+ $this->collectError('createWorkflowAutomationTask', $e);
+ }
+
// Clean cache
try {
$this->cleanJoomlaCache();
@@ -208,6 +216,72 @@ protected function clearStatsCache()
}
}
+ /**
+ * Creates the workflow automation scheduler task on a site that does not have one.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function createWorkflowAutomationTask()
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ // No state filter, so a task the administrator disabled is not recreated.
+ $existing = $db->setQuery(
+ $db->createQuery()
+ ->select($db->quoteName('id'))
+ ->from($db->quoteName('#__scheduler_tasks'))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('workflow.automation'))
+ )->loadResult();
+
+ if ($existing) {
+ return;
+ }
+
+ // Booted rather than imported directly: during an update the autoloader map can still
+ // predate the changed files.
+ /** @var \Joomla\Component\Scheduler\Administrator\Table\TaskTable $task */
+ $task = Factory::getApplication()->bootComponent('com_scheduler')
+ ->getMVCFactory()
+ ->createTable('Task', 'Administrator');
+
+ $now = Factory::getDate();
+
+ $task->bind([
+ 'title' => 'Workflow Automation',
+ 'type' => 'workflow.automation',
+
+ // Every 15 minutes, because this ships to every site, including those with no workflows.
+ // The first run waits a day so a brand new site is not doing this before anyone has
+ // built a workflow. Saving or test running the task brings it forward.
+ 'execution_rules' => json_encode([
+ 'rule-type' => 'interval-minutes',
+ 'interval-minutes' => 15,
+ 'exec-day' => $now->format('d'),
+ 'exec-time' => $now->format('H:i'),
+ ]),
+ 'cron_rules' => json_encode(['type' => 'interval', 'exp' => 'PT15M']),
+ 'state' => 1,
+ 'next_execution' => Factory::getDate('+24 hours')->toSql(),
+
+ 'params' => json_encode([
+ 'individual_log' => false,
+ 'log_file' => '',
+ 'notifications' => [
+ 'success_mail' => '0',
+ 'failure_mail' => '1',
+ 'fatal_failure_mail' => '1',
+ 'orphan_mail' => '1',
+ ],
+ ]),
+ ]);
+
+ if (!$task->check() || !$task->store()) {
+ $this->collectError(__METHOD__, new \RuntimeException('The workflow automation task could not be stored.'));
+ }
+ }
+
/**
* Method to update Database
*
diff --git a/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-09-20.sql b/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-09-20.sql
new file mode 100644
index 0000000000000..5ef266cb76f78
--- /dev/null
+++ b/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-09-20.sql
@@ -0,0 +1,77 @@
+CREATE TABLE IF NOT EXISTS `#__workflow_automation_rules` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `transition_id` int NOT NULL COMMENT 'Foreign Key to #__workflow_transitions.id',
+ `published` tinyint NOT NULL DEFAULT 0,
+ `ordering` int NOT NULL DEFAULT 0,
+ `rule_type` varchar(20) NOT NULL DEFAULT 'delay' COMMENT 'delay or cron',
+ `delay_value` int DEFAULT NULL,
+ `delay_unit` varchar(10) DEFAULT NULL COMMENT 'minutes, hours, days, months',
+ `cron_expression` varchar(100) DEFAULT NULL,
+ `item_filter` text COMMENT 'JSON filter tree: which items this rule applies to (evaluated at selection)',
+ `fire_condition` text COMMENT 'JSON expression tree: gate evaluated live at fire time',
+ `run_as_user_id` int NOT NULL DEFAULT 0 COMMENT 'User identity used to execute the transition',
+ `created` datetime NOT NULL,
+ `created_by` int NOT NULL DEFAULT 0,
+ `modified` datetime NOT NULL,
+ `modified_by` int NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_transition` (`transition_id`),
+ KEY `idx_published` (`published`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `#__workflow_item_state` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `item_id` int NOT NULL DEFAULT 0 COMMENT 'Extension table id value',
+ `extension` varchar(50) NOT NULL,
+ `stage_id` int NOT NULL COMMENT 'Foreign Key to #__workflow_stages.id',
+ `entered_at` datetime NOT NULL COMMENT 'When the item arrived in stage_id',
+ `triggered_by` varchar(20) NOT NULL DEFAULT 'manual' COMMENT 'Determine if a transition was triggered manually or by the automation',
+ `requires_intervention` tinyint NOT NULL DEFAULT 0 COMMENT 'Set when an automated transition failed; excluded from the scheduler until an admin clears it',
+ `last_checked_at` datetime DEFAULT NULL COMMENT 'When the scheduler last considered this item; null means never',
+ `last_failure_at` datetime DEFAULT NULL COMMENT 'When this item last could not be evaluated; null means it evaluated cleanly',
+ `last_failure_reason` varchar(500) DEFAULT NULL COMMENT 'Why it could not be evaluated, so the same failure is only reported once',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_item_extension` (`item_id`, `extension`),
+ KEY `idx_stage_entered` (`stage_id`, `entered_at`),
+ KEY `idx_requires_intervention` (`requires_intervention`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `#__workflow_automation_log` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `rule_id` int DEFAULT NULL COMMENT 'Foreign Key to #__workflow_automation_rules.id',
+ `item_id` int NOT NULL DEFAULT 0,
+ `extension` varchar(50) NOT NULL,
+ `transition_id` int NOT NULL,
+ `from_stage_id` int NOT NULL DEFAULT 0,
+ `to_stage_id` int NOT NULL DEFAULT 0,
+ `run_as_user_id` int NOT NULL DEFAULT 0,
+ `trigger_type` varchar(20) NOT NULL DEFAULT 'rule',
+ `exit_code` tinyint NOT NULL DEFAULT 0 COMMENT '0 ok, 1 permission denied, 2 invalid transition, 3 exception, 4 no longer applicable',
+ `note` varchar(500) DEFAULT NULL,
+ `executed_at` datetime NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_item_id` (`item_id`),
+ KEY `idx_executed_at` (`executed_at`),
+ KEY `idx_exit_code` (`exit_code`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci;
+
+INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`)
+SELECT 0, 'plg_task_workflowtransition', 'plugin', 'workflowtransition', 'task', 0, 1, 1, 0, 1, '', '{}', '', 10, 0
+WHERE NOT EXISTS (SELECT * FROM `#__extensions` e WHERE e.`type` = 'plugin' AND e.`element` = 'workflowtransition' AND e.`folder` = 'task' AND e.`client_id` = 0);
+
+INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`)
+SELECT 0, 'plg_workflow_automation', 'plugin', 'automation', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 4, 0
+WHERE NOT EXISTS (SELECT * FROM `#__extensions` e WHERE e.`type` = 'plugin' AND e.`element` = 'automation' AND e.`folder` = 'workflow' AND e.`client_id` = 0);
+
+INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`)
+SELECT 0, 'plg_workflow_remotecheck', 'plugin', 'remotecheck', 'workflow', 0, 0, 1, 0, 1, '', '{}', '', 5, 0
+WHERE NOT EXISTS (SELECT * FROM `#__extensions` e WHERE e.`type` = 'plugin' AND e.`element` = 'remotecheck' AND e.`folder` = 'workflow' AND e.`client_id` = 0);
+
+-- entered_at is stamped with the upgrade time rather than a guess. Nothing records when these
+-- items actually entered their stage, and using a date like `modified` would leave anything
+-- edited long ago instantly overdue, transitioning a whole back catalogue on the first run.
+INSERT INTO `#__workflow_item_state` (`item_id`, `extension`, `stage_id`, `entered_at`, `triggered_by`, `requires_intervention`)
+SELECT a.`item_id`, a.`extension`, a.`stage_id`, UTC_TIMESTAMP(), 'manual', 0
+FROM `#__workflow_associations` a
+LEFT JOIN `#__workflow_item_state` s ON s.`item_id` = a.`item_id` AND s.`extension` = a.`extension`
+WHERE s.`id` IS NULL;
diff --git a/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-09-20.sql b/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-09-20.sql
new file mode 100644
index 0000000000000..5879dd7180bce
--- /dev/null
+++ b/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-09-20.sql
@@ -0,0 +1,114 @@
+CREATE TABLE IF NOT EXISTS "#__workflow_automation_rules" (
+ "id" serial NOT NULL,
+ "transition_id" integer NOT NULL,
+ "published" smallint DEFAULT 0 NOT NULL,
+ "ordering" integer DEFAULT 0 NOT NULL,
+ "rule_type" varchar(20) DEFAULT 'delay' NOT NULL,
+ "delay_value" integer,
+ "delay_unit" varchar(10),
+ "cron_expression" varchar(100),
+ "item_filter" text,
+ "fire_condition" text,
+ "run_as_user_id" integer DEFAULT 0 NOT NULL,
+ "created" timestamp without time zone NOT NULL,
+ "created_by" integer DEFAULT 0 NOT NULL,
+ "modified" timestamp without time zone NOT NULL,
+ "modified_by" integer DEFAULT 0 NOT NULL,
+ PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "#__workflow_automation_rules_idx_transition" ON "#__workflow_automation_rules" ("transition_id");
+
+CREATE INDEX "#__workflow_automation_rules_idx_published" ON "#__workflow_automation_rules" ("published");
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."transition_id" IS 'Foreign Key to #__workflow_transitions.id';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."rule_type" IS 'delay or cron';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."delay_unit" IS 'minutes, hours, days, months';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."item_filter" IS 'JSON filter tree: which items this rule applies to (evaluated at selection)';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."fire_condition" IS 'JSON expression tree: gate evaluated live at fire time';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."run_as_user_id" IS 'User identity used to execute the transition';
+
+CREATE TABLE IF NOT EXISTS "#__workflow_item_state" (
+ "id" serial NOT NULL,
+ "item_id" integer DEFAULT 0 NOT NULL,
+ "extension" varchar(50) NOT NULL,
+ "stage_id" integer NOT NULL,
+ "entered_at" timestamp without time zone NOT NULL,
+ "triggered_by" varchar(20) DEFAULT 'manual' NOT NULL,
+ "requires_intervention" smallint DEFAULT 0 NOT NULL,
+ "last_checked_at" timestamp without time zone,
+ "last_failure_at" timestamp without time zone,
+ "last_failure_reason" varchar(500),
+ PRIMARY KEY ("id"),
+ CONSTRAINT "#__workflow_item_state_idx_item_extension" UNIQUE ("item_id", "extension")
+);
+
+CREATE INDEX "#__workflow_item_state_idx_stage_entered" ON "#__workflow_item_state" ("stage_id", "entered_at");
+
+CREATE INDEX "#__workflow_item_state_idx_requires_intervention" ON "#__workflow_item_state" ("requires_intervention");
+
+COMMENT ON COLUMN "#__workflow_item_state"."item_id" IS 'Extension table id value';
+
+COMMENT ON COLUMN "#__workflow_item_state"."stage_id" IS 'Foreign Key to #__workflow_stages.id';
+
+COMMENT ON COLUMN "#__workflow_item_state"."entered_at" IS 'When the item arrived in stage_id';
+
+COMMENT ON COLUMN "#__workflow_item_state"."triggered_by" IS 'Determine if a transition was triggered manually or by the automation';
+
+COMMENT ON COLUMN "#__workflow_item_state"."requires_intervention" IS 'Set when an automated transition failed; excluded from the scheduler until an admin clears it';
+
+COMMENT ON COLUMN "#__workflow_item_state"."last_checked_at" IS 'When the scheduler last considered this item; null means never';
+
+COMMENT ON COLUMN "#__workflow_item_state"."last_failure_at" IS 'When this item last could not be evaluated; null means it evaluated cleanly';
+
+COMMENT ON COLUMN "#__workflow_item_state"."last_failure_reason" IS 'Why it could not be evaluated, so the same failure is only reported once';
+
+CREATE TABLE IF NOT EXISTS "#__workflow_automation_log" (
+ "id" serial NOT NULL,
+ "rule_id" integer,
+ "item_id" integer DEFAULT 0 NOT NULL,
+ "extension" varchar(50) NOT NULL,
+ "transition_id" integer NOT NULL,
+ "from_stage_id" integer DEFAULT 0 NOT NULL,
+ "to_stage_id" integer DEFAULT 0 NOT NULL,
+ "run_as_user_id" integer DEFAULT 0 NOT NULL,
+ "trigger_type" varchar(20) DEFAULT 'rule' NOT NULL,
+ "exit_code" smallint DEFAULT 0 NOT NULL,
+ "note" varchar(500),
+ "executed_at" timestamp without time zone NOT NULL,
+ PRIMARY KEY ("id")
+);
+
+CREATE INDEX "#__workflow_automation_log_idx_item_id" ON "#__workflow_automation_log" ("item_id");
+
+CREATE INDEX "#__workflow_automation_log_idx_executed_at" ON "#__workflow_automation_log" ("executed_at");
+
+CREATE INDEX "#__workflow_automation_log_idx_exit_code" ON "#__workflow_automation_log" ("exit_code");
+
+COMMENT ON COLUMN "#__workflow_automation_log"."rule_id" IS 'Foreign Key to #__workflow_automation_rules.id';
+
+COMMENT ON COLUMN "#__workflow_automation_log"."exit_code" IS '0 ok, 1 permission denied, 2 invalid transition, 3 exception, 4 no longer applicable';
+
+INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state")
+SELECT 0, 'plg_task_workflowtransition', 'plugin', 'workflowtransition', 'task', 0, 1, 1, 0, 1, '', '{}', '', 10, 0
+WHERE NOT EXISTS (SELECT * FROM "#__extensions" e WHERE e."type" = 'plugin' AND e."element" = 'workflowtransition' AND e."folder" = 'task' AND e."client_id" = 0);
+
+INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state")
+SELECT 0, 'plg_workflow_automation', 'plugin', 'automation', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 4, 0
+WHERE NOT EXISTS (SELECT * FROM "#__extensions" e WHERE e."type" = 'plugin' AND e."element" = 'automation' AND e."folder" = 'workflow' AND e."client_id" = 0);
+
+INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state")
+SELECT 0, 'plg_workflow_remotecheck', 'plugin', 'remotecheck', 'workflow', 0, 0, 1, 0, 1, '', '{}', '', 5, 0
+WHERE NOT EXISTS (SELECT * FROM "#__extensions" e WHERE e."type" = 'plugin' AND e."element" = 'remotecheck' AND e."folder" = 'workflow' AND e."client_id" = 0);
+
+-- See the MySQL file for why entered_at is the upgrade time rather than a guess.
+INSERT INTO "#__workflow_item_state" ("item_id", "extension", "stage_id", "entered_at", "triggered_by", "requires_intervention")
+SELECT a."item_id", a."extension", a."stage_id", timezone('UTC', now()), 'manual', 0
+FROM "#__workflow_associations" a
+LEFT JOIN "#__workflow_item_state" s ON s."item_id" = a."item_id" AND s."extension" = a."extension"
+WHERE s."id" IS NULL;
diff --git a/administrator/components/com_content/tmpl/articles/default.php b/administrator/components/com_content/tmpl/articles/default.php
index 0c56baffad293..62192d62c8b6b 100644
--- a/administrator/components/com_content/tmpl/articles/default.php
+++ b/administrator/components/com_content/tmpl/articles/default.php
@@ -23,6 +23,9 @@
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\Component\Content\Administrator\Helper\ContentHelper;
+use Joomla\Component\Workflow\Administrator\Automation\RelativeTime;
+use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransitionsCalculator;
+use Joomla\Database\DatabaseInterface;
use Joomla\Utilities\ArrayHelper;
/** @var \Joomla\Component\Content\Administrator\View\Articles\HtmlView $this */
@@ -72,6 +75,19 @@
$this->filterForm->addControlField('transition_id', '');
endif;
+$upcomingByItem = [];
+
+if ($workflow_enabled && !empty($this->items)) {
+ Factory::getApplication()->getLanguage()->load('com_workflow', JPATH_ADMINISTRATOR);
+
+ $upcomingByItem = (new UpcomingTransitionsCalculator(Factory::getContainer()->get(DatabaseInterface::class)))
+ ->forItems(ArrayHelper::getColumn($this->items, 'id'), 'com_content.article');
+
+ if (!empty($upcomingByItem)) {
+ HTMLHelper::_('bootstrap.tooltip', '.hasTooltip');
+ }
+}
+
$assoc = Associations::isEnabled();
?>
@@ -227,6 +243,40 @@
stage_title); ?>
+ id])) :
+ $upcoming = $upcomingByItem[$item->id];
+ $chipClass = match ($upcoming->status) {
+ 'needs_attention' => 'bg-danger',
+ 'rule_error' => 'bg-warning text-dark',
+ 'not_scheduled' => 'bg-secondary',
+ default => 'bg-info',
+ };
+ $chipIcon = $upcoming->status === 'rule_error' ? 'icon-warning' : 'icon-clock';
+ $chipTip = match ($upcoming->status) {
+ 'needs_attention' => Text::_('COM_WORKFLOW_UPCOMING_STATUS_ATTENTION'),
+ 'rule_error' => $upcoming->failureReason !== ''
+ ? $upcoming->failureReason
+ : Text::_('COM_WORKFLOW_UPCOMING_STATUS_RULE_ERROR'),
+ 'not_scheduled' => Text::_('COM_WORKFLOW_UPCOMING_STATUS_NOT_SCHEDULED'),
+ default => $upcoming->firesAt !== null
+ ? RelativeTime::until($upcoming->firesAt) . ' (' . HTMLHelper::_('date', $upcoming->firesAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2')) . ')'
+ : '',
+ };
+
+ $chipTip .= $upcoming->status !== 'rule_error' && $upcoming->failureReason !== ''
+ ? ' - ' . $upcoming->failureReason
+ : '';
+ ?>
+
+
>
+
+ transitionTitle), ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
diff --git a/administrator/components/com_workflow/forms/automation_rule.xml b/administrator/components/com_workflow/forms/automation_rule.xml
new file mode 100644
index 0000000000000..23f78ba00d976
--- /dev/null
+++ b/administrator/components/com_workflow/forms/automation_rule.xml
@@ -0,0 +1,72 @@
+
+
diff --git a/administrator/components/com_workflow/forms/filter_logs.xml b/administrator/components/com_workflow/forms/filter_logs.xml
new file mode 100644
index 0000000000000..a40af874b756b
--- /dev/null
+++ b/administrator/components/com_workflow/forms/filter_logs.xml
@@ -0,0 +1,49 @@
+
+
diff --git a/administrator/components/com_workflow/forms/filter_upcoming.xml b/administrator/components/com_workflow/forms/filter_upcoming.xml
new file mode 100644
index 0000000000000..7e6f195f4e765
--- /dev/null
+++ b/administrator/components/com_workflow/forms/filter_upcoming.xml
@@ -0,0 +1,43 @@
+
+
diff --git a/administrator/components/com_workflow/forms/transition.xml b/administrator/components/com_workflow/forms/transition.xml
index ac82312fc6a7a..d2d0301c17b06 100644
--- a/administrator/components/com_workflow/forms/transition.xml
+++ b/administrator/components/com_workflow/forms/transition.xml
@@ -1,5 +1,8 @@
-
diff --git a/administrator/components/com_workflow/layouts/upcoming/table.php b/administrator/components/com_workflow/layouts/upcoming/table.php
new file mode 100644
index 0000000000000..325840d1a6d04
--- /dev/null
+++ b/administrator/components/com_workflow/layouts/upcoming/table.php
@@ -0,0 +1,146 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Router\Route;
+use Joomla\Component\Workflow\Administrator\Automation\RelativeTime;
+
+/**
+ * Renders a table of upcoming automated transitions.
+ *
+ * @var array $displayData
+ * @var \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[] $items
+ * @var boolean $showWorkflow Whether to show the owning workflow as a column.
+ */
+$items = $displayData['items'] ?? [];
+$showWorkflow = (bool) ($displayData['showWorkflow'] ?? false);
+$arrow = Factory::getApplication()->getLanguage()->isRtl() ? 'arrow-left' : 'arrow-right';
+
+if (empty($items)) : ?>
+
+
+
+
+ 'COM_WORKFLOW_AUTOMATION_UNIT_MINUTES',
+ 'hours' => 'COM_WORKFLOW_AUTOMATION_UNIT_HOURS',
+ 'days' => 'COM_WORKFLOW_AUTOMATION_UNIT_DAYS',
+ 'months' => 'COM_WORKFLOW_AUTOMATION_UNIT_MONTHS',
+];
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ itemTitle !== ''
+ ? $transition->itemTitle
+ : Text::sprintf('COM_WORKFLOW_UPCOMING_ITEM_FALLBACK', $transition->itemId);
+ ?>
+ editUrl !== '') : ?>
+
+
+
+
+
+
+
+
+
+ workflowTitle), ENT_QUOTES, 'UTF-8'); ?>
+ workflowId > 0) : ?>
+
+
+
+
+
+
+
+
+
+ transitionTitle), ENT_QUOTES, 'UTF-8'); ?>
+ fromStage !== $transition->toStage) : ?>
+
+ fromStage), ENT_QUOTES, 'UTF-8'); ?>
+
+ toStage), ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+ status === 'needs_attention') : ?>
+
+ status === 'rule_error') : ?>
+
+ status === 'not_scheduled') : ?>
+
+ firesAt === null) : ?>
+
+
+ firesAt); ?>
+ firesAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2')); ?>
+ hasCondition) : ?>
+
+
+
+
+ failureReason !== '') : ?>
+
+
+ failureReason, ENT_QUOTES, 'UTF-8'); ?>
+
+ failedAt !== null) : ?>
+
+ failedAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2'))
+ ); ?>
+
+
+
+
+
+ ruleType === 'cron') : ?>
+ ' . htmlspecialchars((string) $transition->cronExpression, ENT_QUOTES, 'UTF-8') . ''); ?>
+
+ delayUnit] ?? '';
+ $unit = $unitKey !== '' ? Text::_($unitKey) : (string) $transition->delayUnit;
+ echo Text::sprintf('COM_WORKFLOW_UPCOMING_TRIGGER_DELAY', (int) $transition->delayValue, $unit);
+ ?>
+
+
+
+
+
+
diff --git a/administrator/components/com_workflow/src/Automation/BuiltinConditionFields.php b/administrator/components/com_workflow/src/Automation/BuiltinConditionFields.php
new file mode 100644
index 0000000000000..77b6d370c3317
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/BuiltinConditionFields.php
@@ -0,0 +1,709 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+use Joomla\CMS\Event\Workflow\WorkflowConditionFieldsEvent;
+use Joomla\CMS\Helper\UserGroupsHelper;
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Language\Text;
+use Joomla\Database\DatabaseInterface;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * The condition checks Joomla ships with.
+ *
+ * Asked before the workflow plugins, so a plugin cannot redefine a built-in check and change
+ * what an already-saved rule means.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class BuiltinConditionFields
+{
+ /**
+ * @var DatabaseInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private DatabaseInterface $database;
+
+ /**
+ * Content type rows already looked up by extension. Null means the extension
+ * is not registered as a content type at all
+ *
+ * @var array
+ * @since __DEPLOY_VERSION__
+ */
+ private array $contentTypes = [];
+
+ /**
+ * @param DatabaseInterface $database The database driver.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(DatabaseInterface $database)
+ {
+ $this->database = $database;
+ }
+
+ /**
+ * The built-in checks available for an extension.
+ *
+ * @param string $extension The workflow extension, e.g com_content.article.
+ *
+ * @return array Check definitions keyed by name.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function declarations(string $extension): array
+ {
+ $fields = [];
+ $fields['day_of_week'] = $this->field(
+ 'day_of_week',
+ Text::_('COM_WORKFLOW_AUTOMATION_FIELD_DAY_OF_WEEK'),
+ WorkflowConditionFieldsEvent::SCOPE_MOMENT,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_IN,
+ WorkflowConditionFieldsEvent::OPERATOR_NOT_IN,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_MULTISELECT,
+ $this->getWeekdayOptions()
+ );
+
+ $fields['date'] = $this->field(
+ 'date',
+ Text::_('COM_WORKFLOW_AUTOMATION_FIELD_DATE'),
+ WorkflowConditionFieldsEvent::SCOPE_MOMENT,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_AFTER,
+ WorkflowConditionFieldsEvent::OPERATOR_BEFORE,
+ WorkflowConditionFieldsEvent::OPERATOR_ON,
+ WorkflowConditionFieldsEvent::OPERATOR_NOT_ON,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_DATE
+ );
+
+ if ($this->typeIdFor($extension) !== null) {
+ $fields['tag'] = $this->field(
+ 'tag',
+ Text::_('COM_WORKFLOW_AUTOMATION_FIELD_TAG'),
+ WorkflowConditionFieldsEvent::SCOPE_ITEM,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_ANY,
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_ALL,
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_NONE,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_MULTISELECT,
+ $this->getTagOptions()
+ );
+ }
+
+ // Each check below is offered only where the extension's own item table has its column.
+ if ($this->itemTableHasColumn($extension, 'catid')) {
+ $fields['category'] = $this->field(
+ 'category',
+ Text::_('COM_WORKFLOW_AUTOMATION_FIELD_CATEGORY'),
+ WorkflowConditionFieldsEvent::SCOPE_ITEM,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_IS,
+ WorkflowConditionFieldsEvent::OPERATOR_IS_NOT,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_SELECT,
+ $this->getCategoryOptions($extension)
+ );
+ }
+
+ if ($this->itemTableHasColumn($extension, 'created_by')) {
+ $fields['author_group'] = $this->field(
+ 'author_group',
+ Text::_('COM_WORKFLOW_AUTOMATION_FIELD_AUTHOR_GROUP'),
+ WorkflowConditionFieldsEvent::SCOPE_ITEM,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_ANY,
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_ALL,
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_NONE,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_MULTISELECT,
+ $this->getUserGroupOptions()
+ );
+ }
+
+ if ($this->itemTableHasColumn($extension, 'hits')) {
+ $fields['hits'] = $this->field(
+ 'hits',
+ Text::_('JGLOBAL_HITS'),
+ WorkflowConditionFieldsEvent::SCOPE_ITEM,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_GREATER_THAN,
+ WorkflowConditionFieldsEvent::OPERATOR_LESS_THAN,
+ WorkflowConditionFieldsEvent::OPERATOR_IS,
+ WorkflowConditionFieldsEvent::OPERATOR_IS_NOT,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_NUMBER
+ );
+ }
+
+ $fields['days_in_stage'] = $this->field(
+ 'days_in_stage',
+ Text::_('COM_WORKFLOW_AUTOMATION_FIELD_DAYS_IN_STAGE'),
+ WorkflowConditionFieldsEvent::SCOPE_ITEM,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_GREATER_THAN,
+ WorkflowConditionFieldsEvent::OPERATOR_LESS_THAN,
+ WorkflowConditionFieldsEvent::OPERATOR_IS,
+ WorkflowConditionFieldsEvent::OPERATOR_IS_NOT,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_NUMBER
+ );
+
+ if ($this->itemTableHasColumn($extension, 'modified')) {
+ $fields['days_since_modified'] = $this->field(
+ 'days_since_modified',
+ Text::_('COM_WORKFLOW_AUTOMATION_FIELD_DAYS_SINCE_MODIFIED'),
+ WorkflowConditionFieldsEvent::SCOPE_ITEM,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_GREATER_THAN,
+ WorkflowConditionFieldsEvent::OPERATOR_LESS_THAN,
+ WorkflowConditionFieldsEvent::OPERATOR_IS,
+ WorkflowConditionFieldsEvent::OPERATOR_IS_NOT,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_NUMBER
+ );
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Resolves one built-in check for a whole batch of items.
+ *
+ * @param string $fieldName The check being resolved
+ * @param int[] $itemIds The items to resolve it for
+ * @param string $extension The workflow extension, e.g com_content.article
+ * @param \DateTime|null $moment The moment clock-based checks should describe.
+ *
+ * @return array|null Values keyed by item id, or null when the name is not a built-in.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function resolve(
+ string $fieldName,
+ array $itemIds,
+ string $extension,
+ ?\DateTime $moment = null
+ ): ?array {
+ $itemIds = array_map('intval', $itemIds);
+ $when = $moment ?? new \DateTime('now', new \DateTimeZone('UTC'));
+
+ switch ($fieldName) {
+ case 'day_of_week':
+ return $this->sameForEveryItem($itemIds, (int) $when->format('w'));
+
+ case 'date':
+ return $this->sameForEveryItem($itemIds, $when->format('Y-m-d H:i:s'));
+
+ case 'tag':
+ return $this->loadTagIds($itemIds, $extension);
+
+ case 'category':
+ return $this->loadColumnPerItem($itemIds, $extension, 'catid', 0);
+
+ case 'hits':
+ return $this->loadColumnPerItem($itemIds, $extension, 'hits', 0);
+
+ case 'author_group':
+ return $this->loadAuthorGroupIds($itemIds, $extension);
+
+ case 'days_in_stage':
+ return $this->loadDaysInStage($itemIds, $extension, $when);
+
+ case 'days_since_modified':
+ return $this->loadDaysSinceModified($itemIds, $extension, $when);
+ }
+
+ return null;
+ }
+
+ /**
+ * Gives every item the same value, for checks that describe the clock rather than the item
+ *
+ * @param int[] $itemIds The items being resolved.
+ * @param mixed $value The value they all share.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function sameForEveryItem(array $itemIds, $value): array
+ {
+ return $itemIds === [] ? [] : array_fill_keys($itemIds, $value);
+ }
+
+ /**
+ * Tag ids per item, in one query.
+ *
+ * Filters by type_id through a subquery, because the tag map's primary key starts with
+ * type_id and type_alias has no index.
+ *
+ * @param int[] $itemIds The items.
+ * @param string $extension The workflow extension
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function loadTagIds(array $itemIds, string $extension): array
+ {
+ if ($itemIds === [] || $this->typeIdFor($extension) === null) {
+ return [];
+ }
+
+ $db = $this->database;
+
+ $tagTypeSubquery = '(SELECT ' . $db->quoteName('ct.type_id')
+ . ' FROM ' . $db->quoteName('#__content_types', 'ct')
+ . ' WHERE ' . $db->quoteName('ct.type_alias') . ' = :extension)';
+
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['content_item_id', 'tag_id']))
+ ->from($db->quoteName('#__contentitem_tag_map'))
+ ->where($db->quoteName('type_id') . ' = ' . $tagTypeSubquery)
+ ->whereIn($db->quoteName('content_item_id'), $itemIds)
+ ->bind(':extension', $extension);
+
+ // An item with no tags gets an empty list. A missing answer would be treated as an error.
+ $values = array_fill_keys($itemIds, []);
+
+ foreach ($db->setQuery($query)->loadObjectList() ?: [] as $row) {
+ $values[(int) $row->content_item_id][] = (int) $row->tag_id;
+ }
+
+ return $values;
+ }
+
+ /**
+ * One column of the extension's own item table, per item, in one query.
+ *
+ * @param int[] $itemIds The items.
+ * @param string $extension The workflow extension.
+ * @param string $column The column to read.
+ * @param mixed $default The value for an item the query does not return.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function loadColumnPerItem(array $itemIds, string $extension, string $column, $default): array
+ {
+ $storage = $this->itemStorageFor($extension);
+
+ if ($itemIds === [] || $storage === null || !$this->itemTableHasColumn($extension, $column)) {
+ return [];
+ }
+
+ $db = $this->database;
+ $itemValuesQuery = $db->getQuery(true)
+ ->select($db->quoteName([$storage['key'], $column]))
+ ->from($db->quoteName($storage['table']))
+ ->whereIn($db->quoteName($storage['key']), $itemIds);
+
+ $values = array_fill_keys($itemIds, $default);
+
+ foreach ($db->setQuery($itemValuesQuery)->loadAssocList() ?: [] as $row) {
+ $values[(int) $row[$storage['key']]] = $row[$column];
+ }
+
+ return $values;
+ }
+
+ /**
+ * How many whole days each item has been sitting in its current stage.
+ *
+ * An item with no state row yet counts as 0 days, so a filter never silently skips it.
+ *
+ * @param int[] $itemIds The items.
+ * @param string $extension The workflow extension.
+ * @param \DateTime $when The moment to measure against.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function loadDaysInStage(array $itemIds, string $extension, \DateTime $when): array
+ {
+ if ($itemIds === []) {
+ return [];
+ }
+
+ $db = $this->database;
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['item_id', 'entered_at']))
+ ->from($db->quoteName('#__workflow_item_state'))
+ ->where($db->quoteName('extension') . ' = :extension')
+ ->whereIn($db->quoteName('item_id'), $itemIds)
+ ->bind(':extension', $extension);
+
+ $ages = array_fill_keys($itemIds, 0);
+
+ foreach ($db->setQuery($query)->loadAssocList() ?: [] as $row) {
+ if ($this->isRealDate($row['entered_at'])) {
+ $ages[(int) $row['item_id']] = $this->wholeDaysSince($row['entered_at'], $when);
+ }
+ }
+
+ return $ages;
+ }
+
+ /**
+ * How many whole days since each item was last edited.
+ *
+ * Falls back to the creation date for an item whose modified date is empty.
+ *
+ * @param int[] $itemIds The items.
+ * @param string $extension The workflow extension.
+ * @param \DateTime $when The moment to measure against.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function loadDaysSinceModified(array $itemIds, string $extension, \DateTime $when): array
+ {
+ $modified = $this->loadColumnPerItem($itemIds, $extension, 'modified', null);
+ $created = $this->itemTableHasColumn($extension, 'created')
+ ? $this->loadColumnPerItem($itemIds, $extension, 'created', null)
+ : [];
+
+ $ages = [];
+
+ foreach ($itemIds as $itemId) {
+ $stamp = $modified[$itemId] ?? null;
+
+ if (!$this->isRealDate($stamp)) {
+ $stamp = $created[$itemId] ?? null;
+ }
+
+ $ages[$itemId] = $this->isRealDate($stamp) ? $this->wholeDaysSince($stamp, $when) : 0;
+ }
+
+ return $ages;
+ }
+
+ /**
+ * Whole days between a stored timestamp and a moment, never negative.
+ *
+ * Floored, so 7 means from seven days up to just under eight.
+ *
+ * @param string $stamp A database timestamp, in UTC.
+ * @param \DateTime $when The moment to measure against.
+ *
+ * @return integer
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function wholeDaysSince(string $stamp, \DateTime $when): int
+ {
+ $then = date_create_immutable($stamp, new \DateTimeZone('UTC'));
+
+ if ($then === false) {
+ return 0;
+ }
+
+ return (int) max(0, floor(($when->getTimestamp() - $then->getTimestamp()) / 86400));
+ }
+
+ /**
+ * Whether a stored timestamp is a real date rather than null or a zero date.
+ *
+ * @param mixed $stamp The stored value.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function isRealDate($stamp): bool
+ {
+ return \is_string($stamp) && $stamp !== '' && strpos($stamp, '0000-00-00') !== 0;
+ }
+
+ /**
+ * The author's user groups per item, in two queries: the authors, then their groups.
+ *
+ * @param int[] $itemIds The items.
+ * @param string $extension The workflow extension.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function loadAuthorGroupIds(array $itemIds, string $extension): array
+ {
+ $authorPerItem = $this->loadColumnPerItem($itemIds, $extension, 'created_by', 0);
+
+ if ($authorPerItem === []) {
+ return [];
+ }
+
+ $values = array_fill_keys(array_keys($authorPerItem), []);
+ $itemIdsByAuthor = [];
+
+ foreach ($authorPerItem as $itemId => $authorId) {
+ if ((int) $authorId > 0) {
+ $itemIdsByAuthor[(int) $authorId][] = (int) $itemId;
+ }
+ }
+
+ if ($itemIdsByAuthor === []) {
+ return $values;
+ }
+
+ $db = $this->database;
+ $groupQuery = $db->getQuery(true)
+ ->select($db->quoteName(['user_id', 'group_id']))
+ ->from($db->quoteName('#__user_usergroup_map'))
+ ->whereIn($db->quoteName('user_id'), array_keys($itemIdsByAuthor));
+
+ $groupsByAuthor = [];
+
+ foreach ($db->setQuery($groupQuery)->loadObjectList() ?: [] as $row) {
+ $groupsByAuthor[(int) $row->user_id][] = (int) $row->group_id;
+ }
+
+ foreach ($itemIdsByAuthor as $authorId => $authoredItemIds) {
+ foreach ($authoredItemIds as $authoredItemId) {
+ $values[$authoredItemId] = $groupsByAuthor[$authorId] ?? [];
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Weekday options, Sunday (0) through Saturday (6).
+ *
+ * @return array>
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getWeekdayOptions(): array
+ {
+ $weekdayKeys = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
+ $options = [];
+
+ foreach ($weekdayKeys as $dayNumber => $languageKey) {
+ $options[] = ['value' => (string) $dayNumber, 'label' => Text::_($languageKey)];
+ }
+
+ return $options;
+ }
+
+ /**
+ * Published content tags.
+ *
+ * @return array>
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getTagOptions(): array
+ {
+ $db = $this->database;
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['id', 'title']))
+ ->from($db->quoteName('#__tags'))
+ ->where($db->quoteName('published') . ' = 1')
+ ->where($db->quoteName('id') . ' > 1')
+ ->order($db->quoteName('title') . ' ASC');
+
+ $options = [];
+
+ foreach ($db->setQuery($query)->loadObjectList() ?: [] as $tag) {
+ $options[] = ['value' => (string) $tag->id, 'label' => $tag->title];
+ }
+
+ return $options;
+ }
+
+ /**
+ * The category options for an extension, e.g. com_contact's categories for a contact
+ * workflow rather than always com_content's.
+ *
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return array>
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getCategoryOptions(string $extension): array
+ {
+ // Categories belong to the component, so the section part of the extension is dropped.
+ $component = strtok($extension, '.');
+ $options = [];
+
+ foreach (HTMLHelper::_('category.options', $component) as $category) {
+ $options[] = ['value' => (string) $category->value, 'label' => $category->text];
+ }
+
+ return $options;
+ }
+
+ /**
+ * All user groups.
+ *
+ * @return array>
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getUserGroupOptions(): array
+ {
+ $options = [];
+
+ foreach (UserGroupsHelper::getInstance()->getAll() as $group) {
+ $options[] = ['value' => (string) $group->id, 'label' => $group->title];
+ }
+
+ return $options;
+ }
+
+ /**
+ * Builds one declaration.
+ *
+ * @param string $name Stored key for the check.
+ * @param string $label Translated label.
+ * @param string $scope SCOPE_ITEM or SCOPE_MOMENT.
+ * @param string[] $operators Operators the check supports.
+ * @param string $valueType How the value is entered.
+ * @param array $options Selectable values if any.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function field(
+ string $name,
+ string $label,
+ string $scope,
+ array $operators,
+ string $valueType = WorkflowConditionFieldsEvent::VALUE_SELECT,
+ array $options = []
+ ): array {
+ return [
+ 'name' => $name,
+ 'label' => $label,
+ 'scope' => $scope,
+ 'operators' => $operators,
+ 'valueType' => $valueType,
+ 'options' => $options,
+ ];
+ }
+
+ /**
+ * The content type row for an extension, or null when it is not registered as one.
+ *
+ * @param string $extension The workflow extension, e.g com_content.article.
+ *
+ * @return object|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function contentTypeFor(string $extension): ?object
+ {
+ if (!\array_key_exists($extension, $this->contentTypes)) {
+ $db = $this->database;
+ $contentTypeQuery = $db->getQuery(true)
+ ->select($db->quoteName(['type_id', 'table']))
+ ->from($db->quoteName('#__content_types'))
+ ->where($db->quoteName('type_alias') . ' = :extension')
+ ->bind(':extension', $extension);
+
+ $this->contentTypes[$extension] = $db->setQuery($contentTypeQuery)->loadObject() ?: null;
+ }
+
+ return $this->contentTypes[$extension];
+ }
+
+ /**
+ * The tag map's numeric type id of an extension, or null when it has none.
+ *
+ * @param string $extension The workflow's extension.
+ *
+ * @return integer|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function typeIdFor(string $extension): ?int
+ {
+ $contentType = $this->contentTypeFor($extension);
+ return $contentType === null ? null : (int) $contentType->type_id;
+ }
+
+ /**
+ * Where an extension keeps its own items: the table and its key column.
+ *
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return array{table: string, key: string}|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function itemStorageFor(string $extension): ?array
+ {
+ $contentType = $this->contentTypeFor($extension);
+
+ if ($contentType === null) {
+ return null;
+ }
+
+ $definition = json_decode((string) $contentType->table, true);
+
+ // Only the special table is the extension's own. The common one is always
+ // #__ucm_content, which is a different thing and is not reliably populated.
+ $table = $definition['special']['dbtable'] ?? '';
+ $key = $definition['special']['key'] ?? '';
+
+ // Used as identifiers in a query, so validated even though they come from the database.
+ if (!\is_string($table) || !preg_match('/^#__[a-zA-Z0-9_]+$/', $table)) {
+ return null;
+ }
+
+ if (!\is_string($key) || !preg_match('/^[a-zA-Z0-9_]+$/', $key)) {
+ return null;
+ }
+
+ return ['table' => $table, 'key' => $key];
+ }
+
+ /**
+ * Whether an extension's own item table carries a column.
+ *
+ * @param string $extension The workflow extension.
+ * @param string $column The column the check needs.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function itemTableHasColumn(string $extension, string $column): bool
+ {
+ $storage = $this->itemStorageFor($extension);
+
+ if ($storage === null) {
+ return false;
+ }
+
+ try {
+ return isset($this->database->getTableColumns($storage['table'], false)[$column]);
+ } catch (\Throwable) {
+ // A registration can outlive its table, for instance after a failed uninstall.
+ return false;
+ }
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/ConditionEvaluationException.php b/administrator/components/com_workflow/src/Automation/ConditionEvaluationException.php
new file mode 100644
index 0000000000000..87ecb08089f9b
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/ConditionEvaluationException.php
@@ -0,0 +1,24 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Thrown when a stored automation condition cannot be evaluated.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class ConditionEvaluationException extends \RuntimeException
+{
+}
diff --git a/administrator/components/com_workflow/src/Automation/ConditionEvaluator.php b/administrator/components/com_workflow/src/Automation/ConditionEvaluator.php
new file mode 100644
index 0000000000000..939e312b279c5
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/ConditionEvaluator.php
@@ -0,0 +1,356 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Evaluates a stored automation expression against one item's field values.
+ *
+ * Node shapes:
+ * check: { "field": string, "operator": string, "value": mixed, "not": true? }
+ * expression: { "items": [ ...nodes ], "ops": [ "and"|"or", ... ], "not": true? }
+ *
+ * Rows combine top to bottom with no AND/OR precedence, so "A or B and C" means
+ * "(A or B) and C". Nested expressions are the brackets. An empty expression is true.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class ConditionEvaluator
+{
+ /**
+ * Evaluates a stored expression in JSON form.
+ *
+ * @param string|null $expressionJson The stored JSON tree, or null/empty for "no restriction".
+ * @param callable $resolveField fn(string $fieldName): mixed - the item's value for a field.
+ *
+ * @return boolean
+ *
+ * @throws ConditionEvaluationException When the stored expression is malformed.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function evaluate(?string $expressionJson, callable $resolveField): bool
+ {
+ $decodedTree = $this->decode($expressionJson);
+
+ return $decodedTree === null || $this->evaluateNode($decodedTree, $resolveField);
+ }
+
+ /**
+ * Reads an expression into a tree, or null when there is no expression at all.
+ *
+ * @param string|null $expressionJson The stored expression.
+ *
+ * @return array|null
+ *
+ * @throws ConditionEvaluationException
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function decode(?string $expressionJson): ?array
+ {
+ if ($expressionJson === null || trim($expressionJson) === '') {
+ return null;
+ }
+
+ $decodedTree = json_decode($expressionJson, true);
+
+ if (!\is_array($decodedTree)) {
+ throw new ConditionEvaluationException('The stored condition is not valid JSON: ' . json_last_error_msg());
+ }
+
+ return $decodedTree;
+ }
+
+ /**
+ * Evaluates one already-decoded node. Public so unit tests can pass array trees directly.
+ *
+ * @param array $node A check or expression node.
+ * @param callable $resolveField The field resolver.
+ *
+ * @return boolean
+ *
+ * @throws ConditionEvaluationException
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function evaluateNode(array $node, callable $resolveField): bool
+ {
+ if (\array_key_exists('items', $node)) {
+ return $this->evaluateExpression($node, $resolveField);
+ }
+
+ if (\array_key_exists('field', $node)) {
+ return $this->evaluateCheck($node, $resolveField);
+ }
+
+ if (\array_key_exists('op', $node) || \array_key_exists('children', $node)) {
+ throw new ConditionEvaluationException(
+ 'The condition is stored in the retired group format; open the rule in the builder and save it again.'
+ );
+ }
+
+ throw new ConditionEvaluationException('A condition node is neither a check nor an expression: ' . json_encode($node));
+ }
+
+ /**
+ * Evaluates an expression: its rows combined top to bottom through the connectors.
+ *
+ * There is no short-circuiting, so a malformed row always surfaces.
+ *
+ * @param array $expressionNode The expression node.
+ * @param callable $resolveField The field resolver.
+ *
+ * @return boolean
+ *
+ * @throws ConditionEvaluationException
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function evaluateExpression(array $expressionNode, callable $resolveField): bool
+ {
+ $items = $expressionNode['items'];
+ $connectors = $expressionNode['ops'] ?? null;
+
+ if (!\is_array($items) || $items === []) {
+ throw new ConditionEvaluationException('An expression has no items; empty expressions must be pruned before saving.');
+ }
+
+ if (!\is_array($connectors) || \count($connectors) !== \count($items) - 1) {
+ throw new ConditionEvaluationException(\sprintf(
+ 'An expression has %d items but %s connectors; expected exactly %d.',
+ \count($items),
+ \is_array($connectors) ? \count($connectors) : 'no',
+ \count($items) - 1
+ ));
+ }
+
+ $result = $this->evaluateNode($items[0], $resolveField);
+
+ foreach ($connectors as $index => $connector) {
+ $nextResult = $this->evaluateNode($items[$index + 1], $resolveField);
+
+ $result = match ($connector) {
+ 'and' => $result && $nextResult,
+ 'or' => $result || $nextResult,
+ default => throw new ConditionEvaluationException(
+ 'Unknown connector "' . $connector . '" in an expression; expected "and" or "or".'
+ ),
+ };
+ }
+
+ return ($expressionNode['not'] ?? false) === true ? !$result : $result;
+ }
+
+ /**
+ * Evaluates a single check by resolving the item's value and comparing it.
+ *
+ * @param array $checkNode The check node.
+ * @param callable $resolveField The field resolver.
+ *
+ * @return boolean
+ *
+ * @throws ConditionEvaluationException
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function evaluateCheck(array $checkNode, callable $resolveField): bool
+ {
+ $fieldName = (string) ($checkNode['field'] ?? '');
+ $operatorName = strtolower(trim((string) ($checkNode['operator'] ?? '')));
+
+ if ($fieldName === '') {
+ throw new ConditionEvaluationException('A check has no field.');
+ }
+
+ if ($operatorName === '') {
+ throw new ConditionEvaluationException('The check on "' . $fieldName . '" has no operator.');
+ }
+
+ if (!\array_key_exists('value', $checkNode)) {
+ throw new ConditionEvaluationException('The check on "' . $fieldName . '" has no value.');
+ }
+
+ $result = $this->compare($resolveField($fieldName), $operatorName, $checkNode['value'], $fieldName);
+
+ return ($checkNode['not'] ?? false) === true ? !$result : $result;
+ }
+
+ /**
+ * Applies a single comparison operator.
+ *
+ * A list is never narrowed to its first element, but a single value may widen to a list.
+ *
+ * @param mixed $actualValue The item's value.
+ * @param string $operatorName The comparison operator.
+ * @param mixed $expectedValue The value stored on the rule.
+ * @param string $fieldName The field name, for error messages.
+ *
+ * @return boolean
+ *
+ * @throws ConditionEvaluationException
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function compare($actualValue, string $operatorName, $expectedValue, string $fieldName): bool
+ {
+ switch ($operatorName) {
+ case 'is':
+ return $this->asScalarString($actualValue, $fieldName) === $this->asScalarString($expectedValue, $fieldName);
+
+ case 'is not':
+ return !$this->compare($actualValue, 'is', $expectedValue, $fieldName);
+
+ case 'in':
+ return \in_array($this->asScalarString($actualValue, $fieldName), $this->asStringList($expectedValue), true);
+
+ case 'not in':
+ return !$this->compare($actualValue, 'in', $expectedValue, $fieldName);
+
+ case 'has any':
+ return array_intersect($this->asStringList($actualValue), $this->asStringList($expectedValue)) !== [];
+
+ case 'has all':
+ return array_diff($this->asStringList($expectedValue), $this->asStringList($actualValue)) === [];
+
+ case 'has none':
+ return !$this->compare($actualValue, 'has any', $expectedValue, $fieldName);
+
+ case 'before':
+ return $this->toTimestamp($actualValue, $fieldName) < $this->toTimestamp($expectedValue, $fieldName);
+
+ case 'after':
+ return $this->toTimestamp($actualValue, $fieldName) > $this->toTimestamp($expectedValue, $fieldName);
+
+ case 'on':
+ // Dates compare at calendar-day granularity: the resolver returns a
+ // full datetime while the form submits Y-m-d.
+ return gmdate('Y-m-d', $this->toTimestamp($actualValue, $fieldName))
+ === gmdate('Y-m-d', $this->toTimestamp($expectedValue, $fieldName));
+
+ case 'not on':
+ return !$this->compare($actualValue, 'on', $expectedValue, $fieldName);
+
+ case 'greater than':
+ return $this->asNumber($actualValue, $fieldName) > $this->asNumber($expectedValue, $fieldName);
+
+ case 'less than':
+ return $this->asNumber($actualValue, $fieldName) < $this->asNumber($expectedValue, $fieldName);
+
+ default:
+ throw new ConditionEvaluationException('Unknown operator "' . $operatorName . '" on the "' . $fieldName . '" check.');
+ }
+ }
+
+ /**
+ * Asserts a value is a single scalar and returns it as a string.
+ *
+ * @param mixed $value The value to check.
+ * @param string $fieldName The field name, for error messages.
+ *
+ * @return string
+ *
+ * @throws ConditionEvaluationException When the value is a list or missing.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function asScalarString($value, string $fieldName): string
+ {
+ if (\is_array($value)) {
+ throw new ConditionEvaluationException(
+ 'The "' . $fieldName . '" check expected a single value but got a list; use a membership operator instead.'
+ );
+ }
+
+ if ($value === null) {
+ throw new ConditionEvaluationException('The "' . $fieldName . '" check has no value to compare.');
+ }
+
+ return (string) $value;
+ }
+
+ /**
+ * Asserts a value is numeric and returns it as a float.
+ *
+ * @param mixed $value The value to check.
+ * @param string $fieldName The field name, for error messages.
+ *
+ * @return float
+ *
+ * @throws ConditionEvaluationException When the value is not a number.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function asNumber($value, string $fieldName): float
+ {
+ if (\is_array($value)) {
+ throw new ConditionEvaluationException(
+ 'The "' . $fieldName . '" check expected a single number but got a list.'
+ );
+ }
+
+ // Not cast, because text would read as zero and quietly pass numeric comparisons.
+ if (!is_numeric($value)) {
+ throw new ConditionEvaluationException(
+ 'The "' . $fieldName . '" check expected a number but got "' . var_export($value, true) . '".'
+ );
+ }
+
+ return (float) $value;
+ }
+
+ /**
+ * Normalises a value into a list of strings. A single value widens to a
+ * one-element list; an existing list keeps every element.
+ *
+ * @param mixed $value A scalar or a list.
+ *
+ * @return string[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function asStringList($value): array
+ {
+ return array_map('strval', array_values((array) $value));
+ }
+
+ /**
+ * Parses a datetime string into a Unix timestamp, failing loudly on garbage.
+ *
+ * @param mixed $value The datetime string.
+ * @param string $fieldName The field name, for error messages.
+ *
+ * @return integer
+ *
+ * @throws ConditionEvaluationException When the value cannot be parsed.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function toTimestamp($value, string $fieldName): int
+ {
+ if (\is_array($value)) {
+ throw new ConditionEvaluationException('The "' . $fieldName . '" check expected a date but got a list.');
+ }
+
+ $parsedTimestamp = strtotime((string) $value);
+
+ if ($parsedTimestamp === false) {
+ throw new ConditionEvaluationException(
+ 'Cannot parse "' . $value . '" as a date/time on the "' . $fieldName . '" check.'
+ );
+ }
+
+ return $parsedTimestamp;
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/ConditionWindowCalculator.php b/administrator/components/com_workflow/src/Automation/ConditionWindowCalculator.php
new file mode 100644
index 0000000000000..c4ef1f6550912
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/ConditionWindowCalculator.php
@@ -0,0 +1,125 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+use Joomla\Database\DatabaseInterface;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Works out when a rule's fire condition next opens its execution window.
+ *
+ * Steps one UTC day at a time from the deadline, because the built-in moment checks change at
+ * most once a day. Gives up after HORIZON_DAYS.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class ConditionWindowCalculator
+{
+ /**
+ * How far ahead to search before reporting that the window never opens. A year covers
+ * weekly and annual patterns while keeping the worst case bounded.
+ *
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ private const HORIZON_DAYS = 366;
+
+ /**
+ * @var ConditionEvaluator
+ * @since __DEPLOY_VERSION__
+ */
+ private ConditionEvaluator $conditionEvaluator;
+
+ /**
+ * @var ItemFieldResolver
+ * @since __DEPLOY_VERSION__
+ */
+ private ItemFieldResolver $itemFieldResolver;
+
+ /**
+ * @param DatabaseInterface $database The database driver.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(DatabaseInterface $database)
+ {
+ $this->conditionEvaluator = new ConditionEvaluator();
+ $this->itemFieldResolver = new ItemFieldResolver($database);
+ }
+
+ /**
+ * Returns the first moment at or after $earliestExecutionTime at which the condition holds.
+ *
+ * @param string|null $conditionJson The stored fire condition, or null/empty for none.
+ * @param \DateTime $earliestExecutionTime The deadline to start searching from (UTC).
+ * @param integer $itemId The content item id.
+ * @param string $extension The workflow extension.
+ *
+ * @return \DateTime|null The moment the rule may fire, or null if the window never opens
+ * within the search horizon.
+ *
+ * @throws ConditionEvaluationException When the stored condition is malformed.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function firstMatchAtOrAfter(
+ ?string $conditionJson,
+ \DateTime $earliestExecutionTime,
+ int $itemId,
+ string $extension
+ ): ?\DateTime {
+ if ($conditionJson === null || trim($conditionJson) === '') {
+ return $earliestExecutionTime;
+ }
+
+ if ($this->holdsAt($conditionJson, $earliestExecutionTime, $itemId, $extension)) {
+ return $earliestExecutionTime;
+ }
+
+ // Midnight is the earliest moment of any day that qualifies.
+ $candidate = (clone $earliestExecutionTime)->setTime(0, 0, 0)->modify('+1 day');
+
+ for ($dayOffset = 0; $dayOffset < self::HORIZON_DAYS; $dayOffset++) {
+ if ($this->holdsAt($conditionJson, $candidate, $itemId, $extension)) {
+ return clone $candidate;
+ }
+
+ $candidate->modify('+1 day');
+ }
+
+ return null;
+ }
+
+ /**
+ * Whether the condition would hold for this item at a given moment.
+ *
+ * @param string $conditionJson The stored fire condition.
+ * @param \DateTime $evaluationTime The moment to test.
+ * @param integer $itemId The content item id.
+ * @param string $extension The workflow extension.
+ *
+ * @return boolean
+ *
+ * @throws ConditionEvaluationException
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function holdsAt(string $conditionJson, \DateTime $evaluationTime, int $itemId, string $extension): bool
+ {
+ return $this->conditionEvaluator->evaluate(
+ $conditionJson,
+ $this->itemFieldResolver->forItem($itemId, $extension, $evaluationTime)
+ );
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/DeadlineCalculator.php b/administrator/components/com_workflow/src/Automation/DeadlineCalculator.php
new file mode 100644
index 0000000000000..6ed17917c5f23
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/DeadlineCalculator.php
@@ -0,0 +1,80 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+use Cron\CronExpression;
+use DateTime;
+use Joomla\CMS\Factory;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Works out when an automation rule fires for a given stage-entry time.
+ *
+ * Shared by the scheduler and the upcoming transitions views, so both compute a deadline the
+ * same way.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class DeadlineCalculator
+{
+ /**
+ * Computes the datetime a rule fires for a given entry time.
+ *
+ * @param string $enteredAt When the item entered the stage (SQL datetime, UTC).
+ * @param object $rule The rule row (rule_type, delay_value, delay_unit, cron_expression).
+ *
+ * @return \DateTime|null The fire time in UTC, or null if it cannot be computed.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function forRule(string $enteredAt, object $rule): ?\DateTime
+ {
+ // Every value parsed here comes from the database. The form validates a rule on save, so a
+ // row that fails below was written by something else: a restored backup, direct SQL, a
+ // partial import.
+ try {
+ if ($rule->rule_type === 'cron') {
+ if (empty($rule->cron_expression)) {
+ return null;
+ }
+
+ $cronExpression = new CronExpression($rule->cron_expression);
+ $deadline = $cronExpression->getNextRunDate(
+ $enteredAt,
+ 0,
+ false,
+ Factory::getApplication()->get('offset', 'UTC')
+ );
+ $deadline->setTimezone(new \DateTimeZone('UTC'));
+
+ return $deadline;
+ }
+
+ $date = new \DateTime($enteredAt, new \DateTimeZone('UTC'));
+ $delay = match ($rule->delay_unit) {
+ 'minutes' => new \DateInterval('PT' . $rule->delay_value . 'M'),
+ 'hours' => new \DateInterval('PT' . $rule->delay_value . 'H'),
+ 'days' => new \DateInterval('P' . $rule->delay_value . 'D'),
+ 'months' => new \DateInterval('P' . $rule->delay_value . 'M'),
+ default => null,
+ };
+
+ return $delay ? $date->add($delay) : null;
+ } catch (\Throwable) {
+ // Null tells the caller this rule cannot be scheduled: the scheduler records that
+ // against the item, a view leaves the row out.
+ return null;
+ }
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/FilterPreview.php b/administrator/components/com_workflow/src/Automation/FilterPreview.php
new file mode 100644
index 0000000000000..9ee62557d85d1
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/FilterPreview.php
@@ -0,0 +1,226 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+use Joomla\Database\DatabaseInterface;
+use Joomla\Database\ParameterType;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Answers which items an item filter would match, before the rule is saved.
+ *
+ * Uses the same ConditionEvaluator and ItemFieldResolver as the scheduler.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class FilterPreview
+{
+ /**
+ * How many items are examined before the answer is reported as a partial one.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public const DEFAULT_LIMIT = 200;
+
+ /**
+ * How many matched items are described back to the caller, independent of the scan cap.
+ *
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ public const LISTED_LIMIT = 200;
+
+ /**
+ * @var DatabaseInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private DatabaseInterface $database;
+
+ /**
+ * @param DatabaseInterface $database The database driver.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(DatabaseInterface $database)
+ {
+ $this->database = $database;
+ }
+
+ /**
+ * Evaluates an unsaved filter against the items a transition could act on.
+ *
+ * @param integer $transitionId The transition the rule belongs to.
+ * @param string|null $filterJson The expression as the builder currently has it.
+ * @param integer $limit How many items to examine at most.
+ *
+ * @return array{scanned:int, matched:int, items:array, extension:string, capped:bool}
+ *
+ * @throws ConditionEvaluationException When the expression cannot be read.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function forTransition(
+ int $transitionId,
+ ?string $filterJson,
+ int $limit = self::DEFAULT_LIMIT
+ ): array {
+ $empty = ['scanned' => 0, 'matched' => 0, 'titles' => [], 'capped' => false];
+ $evaluator = new ConditionEvaluator();
+
+ $filterTree = $evaluator->decode($filterJson);
+ $transition = $this->describeTransition($transitionId);
+ if ($transition === null) {
+ return $empty;
+ }
+
+ $extension = $transition->extension;
+ $candidateIds = $this->itemsInScope($transition, $limit);
+
+ if ($candidateIds === []) {
+ return $empty;
+ }
+
+ $itemStorage = new ItemStorage($this->database);
+ $scanIds = array_values(
+ array_diff($candidateIds, $itemStorage->trashedOrArchivedIds($candidateIds, $extension))
+ );
+
+ $fieldResolver = new ItemFieldResolver($this->database);
+ $fieldResolver->preload($scanIds, $extension);
+
+ $matchedIds = [];
+
+ foreach ($scanIds as $itemId) {
+ if (
+ $filterTree === null
+ || $evaluator->evaluateNode($filterTree, $fieldResolver->forItem($itemId, $extension))
+ ) {
+ $matchedIds[] = $itemId;
+ }
+ }
+
+ return [
+ 'scanned' => \count($scanIds),
+ 'matched' => \count($matchedIds),
+ 'items' => $this->describeMatches($itemStorage, $matchedIds, $extension),
+ 'extension' => $extension,
+ 'capped' => \count($candidateIds) >= $limit,
+ ];
+ }
+
+ /**
+ * Pairs each matched id with its title, so a caller can link to the item.
+ *
+ * An id with no title is labelled with the id rather than dropped.
+ *
+ * @param ItemStorage $itemStorage Resolves titles for the extension.
+ * @param int[] $matchedIds Every id that passed the filter.
+ * @param string $extension The workflow extension.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function describeMatches(ItemStorage $itemStorage, array $matchedIds, string $extension): array
+ {
+ $listedIds = \array_slice($matchedIds, 0, self::LISTED_LIMIT);
+ $titles = $itemStorage->titlesFor($listedIds, $extension);
+ $described = [];
+
+ foreach ($listedIds as $itemId) {
+ $described[] = [
+ 'id' => $itemId,
+ 'title' => $titles[$itemId] ?? (string) $itemId,
+ ];
+ }
+
+ return $described;
+ }
+
+ /**
+ * The transition's source stage and the extension its workflow belongs to.
+ *
+ * The extension comes from the workflow, not the request, because URLs may carry only
+ * "com_content" while #__workflow_associations stores "com_content.article".
+ *
+ * @param integer $transitionId The transition to describe.
+ *
+ * @return object|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function describeTransition(int $transitionId): ?object
+ {
+ $db = $this->database;
+
+ $query = $db->getQuery(true)
+ ->select(
+ [
+ $db->quoteName('t.from_stage_id'),
+ $db->quoteName('t.workflow_id'),
+ $db->quoteName('w.extension'),
+ ]
+ )
+ ->from($db->quoteName('#__workflow_transitions', 't'))
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflows', 'w'),
+ $db->quoteName('w.id') . ' = ' . $db->quoteName('t.workflow_id')
+ )
+ ->where($db->quoteName('t.id') . ' = :transitionId')
+ ->bind(':transitionId', $transitionId, ParameterType::INTEGER);
+
+ return $db->setQuery($query)->loadObject();
+ }
+
+ /**
+ * The items a transition could act on, newest first.
+ *
+ * @param object $transition A row from loadTransition().
+ * @param integer $limit How many ids to return at most.
+ *
+ * @return int[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function itemsInScope(object $transition, int $limit): array
+ {
+ $db = $this->database;
+ $fromStageId = (int) $transition->from_stage_id;
+ $workflowId = (int) $transition->workflow_id;
+ $extension = $transition->extension;
+
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('wis.item_id'))
+ ->from($db->quoteName('#__workflow_item_state', 'wis'))
+ ->where($db->quoteName('wis.extension') . ' = :extension')
+ ->bind(':extension', $extension, ParameterType::STRING)
+ ->order($db->quoteName('wis.item_id') . ' DESC');
+
+ if ($fromStageId === -1) {
+ // A wildcard transition starts from any stage in its own workflow.
+ $query->join(
+ 'INNER',
+ $db->quoteName('#__workflow_stages', 'ws'),
+ $db->quoteName('ws.id') . ' = ' . $db->quoteName('wis.stage_id')
+ )
+ ->where($db->quoteName('ws.workflow_id') . ' = :workflowId')
+ ->bind(':workflowId', $workflowId, ParameterType::INTEGER);
+ } else {
+ $query->where($db->quoteName('wis.stage_id') . ' = :stageId')
+ ->bind(':stageId', $fromStageId, ParameterType::INTEGER);
+ }
+
+ return array_map('intval', $db->setQuery($query, 0, $limit)->loadColumn());
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/ItemFieldResolver.php b/administrator/components/com_workflow/src/Automation/ItemFieldResolver.php
new file mode 100644
index 0000000000000..e78452a9f64ae
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/ItemFieldResolver.php
@@ -0,0 +1,233 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+use Joomla\CMS\Event\Workflow\WorkflowResolveFieldsEvent;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\Database\DatabaseInterface;
+use Joomla\Event\DispatcherInterface;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Resolves check values for the items a rule is being evaluated against.
+ *
+ * Batched lazily: callers announce their items with preload(), and each check is resolved for
+ * the whole set the first time it is needed.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class ItemFieldResolver
+{
+ /**
+ * The checks Joomla ships with. Held for the resolver's lifetime so its content type
+ * lookups are cached across every check a run evaluates.
+ *
+ * @var BuiltinConditionFields
+ * @since __DEPLOY_VERSION__
+ */
+ private BuiltinConditionFields $builtinFields;
+
+ /**
+ * The items a caller announced through preload(), grouped by extension, so a check can be
+ * resolved for a whole set at once the first time it is asked for.
+ *
+ * @var array
+ * @since __DEPLOY_VERSION__
+ */
+ private array $batchItemIds = [];
+
+ /**
+ * Resolved values, keyed by extension, check and moment, then by item id.
+ *
+ * @var array>
+ * @since __DEPLOY_VERSION__
+ */
+ private array $resolved = [];
+
+ /**
+ * Which items have already been asked about, keyed like $resolved. Needed because a key
+ * resolved earlier may not cover the items announced after it.
+ *
+ * @var array>
+ * @since __DEPLOY_VERSION__
+ */
+ private array $asked = [];
+
+ /**
+ * @param DatabaseInterface $database The database driver.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(DatabaseInterface $database)
+ {
+ $this->builtinFields = new BuiltinConditionFields($database);
+ }
+
+ /**
+ * Announces the items about to be evaluated, so checks can be resolved for the whole set.
+ *
+ * @param int[] $itemIds The content item ids.
+ * @param string $extension The workflow extension those ids belong to.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function preload(array $itemIds, string $extension): void
+ {
+ $this->batchItemIds[$extension] = array_values(array_unique(
+ array_merge($this->batchItemIds[$extension] ?? [], array_map('intval', $itemIds))
+ ));
+ }
+
+ /**
+ * Builds a resolver callback bound to one item, for use by the ConditionEvaluator.
+ *
+ * @param integer $itemId The content item id.
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ * @param \DateTime|null $evaluationTime The moment clock-based checks should describe,
+ * or null for now.
+ *
+ * @return callable fn(string $fieldName): mixed
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function forItem(int $itemId, string $extension, ?\DateTime $evaluationTime = null): callable
+ {
+ return function (string $fieldName) use ($itemId, $extension, $evaluationTime) {
+ return $this->valueFor($fieldName, $itemId, $extension, $evaluationTime);
+ };
+ }
+
+ /**
+ * One item's value for one check, resolving the whole batch on first use.
+ *
+ * @param string $fieldName The check.
+ * @param integer $itemId The item.
+ * @param string $extension The workflow extension.
+ * @param \DateTime|null $evaluationTime The moment to describe, or null for now.
+ *
+ * @return mixed
+ *
+ * @throws ConditionEvaluationException When nothing provides the check, or whatever does
+ * had no value for this item.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function valueFor(string $fieldName, int $itemId, string $extension, ?\DateTime $evaluationTime)
+ {
+ $cacheKey = $extension . '|' . $fieldName . '|'
+ . ($evaluationTime ? $evaluationTime->getTimestamp() : 'now');
+
+ $announced = $this->batchItemIds[$extension] ?? [];
+
+ if (!isset($this->asked[$cacheKey][$itemId])) {
+ $itemIds = \in_array($itemId, $announced, true) ? $announced : [$itemId];
+
+ // Merged, because an earlier batch under this key may hold other items' answers.
+ $this->resolved[$cacheKey] = ($this->resolved[$cacheKey] ?? [])
+ + $this->resolveBatch($fieldName, $itemIds, $extension, $evaluationTime);
+
+ foreach ($itemIds as $askedItemId) {
+ $this->asked[$cacheKey][$askedItemId] = true;
+ }
+ }
+
+ // A missing value, for example from a remote check that timed out, is not an empty one.
+ // Comparing against it would be a guess, so the item is skipped instead.
+ if (!\array_key_exists($itemId, $this->resolved[$cacheKey])) {
+ throw new ConditionEvaluationException(\sprintf(
+ 'Nothing could resolve a value for the "%s" check on item %s.%d.',
+ $fieldName,
+ $extension,
+ $itemId
+ ));
+ }
+
+ return $this->resolved[$cacheKey][$itemId];
+ }
+
+ /**
+ * Resolves one check for a set of items, from Joomla's own checks or from a plugin.
+ *
+ * @param string $fieldName The check.
+ * @param int[] $itemIds The items.
+ * @param string $extension The workflow extension.
+ * @param \DateTime|null $evaluationTime The moment to describe, or null for now
+ *
+ * @return array
+ *
+ * @throws ConditionEvaluationException When nothing provides the check.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function resolveBatch(
+ string $fieldName,
+ array $itemIds,
+ string $extension,
+ ?\DateTime $evaluationTime
+ ): array {
+ $builtinValues = $this->builtinFields->resolve($fieldName, $itemIds, $extension, $evaluationTime);
+
+ // Null means the check is not a built-in; an empty array is a built-in with no values.
+ if ($builtinValues !== null) {
+ return $builtinValues;
+ }
+
+ return $this->askPlugins($fieldName, $itemIds, $extension, $evaluationTime);
+ }
+
+ /**
+ * Asks the workflow plugins to resolve one check for a set of items.
+ *
+ * @param string $fieldName The check.
+ * @param int[] $itemIds The items.
+ * @param string $extension The workflow extension.
+ * @param \DateTime|null $evaluationTime The moment to describe, or null for now.
+ *
+ * @return array
+ *
+ * @throws ConditionEvaluationException When no plugin claims the check.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function askPlugins(string $fieldName, array $itemIds, string $extension, ?\DateTime $evaluationTime): array
+ {
+ PluginHelper::importPlugin('workflow');
+
+ $event = new WorkflowResolveFieldsEvent(
+ 'onWorkflowResolveConditionFields',
+ [
+ 'field' => $fieldName,
+ 'itemIds' => $itemIds,
+ 'extension' => $extension,
+ 'evaluationTime' => $evaluationTime,
+ ]
+ );
+
+ Factory::getContainer()->get(DispatcherInterface::class)->dispatch($event->getName(), $event);
+
+ if (!$event->isAnswered()) {
+ throw new ConditionEvaluationException(\sprintf(
+ 'No installed extension provides the "%s" check that this rule uses on %s. '
+ . 'The extension that supplied it has probably been disabled or uninstalled.',
+ $fieldName,
+ $extension
+ ));
+ }
+
+ return $event->getValues();
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/ItemStorage.php b/administrator/components/com_workflow/src/Automation/ItemStorage.php
new file mode 100644
index 0000000000000..8c306bfbf3e09
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/ItemStorage.php
@@ -0,0 +1,375 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+use Joomla\CMS\Table\Table;
+use Joomla\CMS\Workflow\Workflow;
+use Joomla\Database\DatabaseInterface;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Finds where an extension keeps its items, and which of them are trashed or archived.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class ItemStorage
+{
+ /**
+ * @var DatabaseInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private DatabaseInterface $database;
+
+ /**
+ * Content type rows already looked up, keyed by extension. Null means not registered.
+ *
+ * @var array
+ * @since __DEPLOY_VERSION__
+ */
+ private array $contentTypes = [];
+
+ /**
+ * @param DatabaseInterface $database The database driver.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(DatabaseInterface $database)
+ {
+ $this->database = $database;
+ }
+
+ /**
+ * Which of these items are trashed or archived.
+ *
+ * An extension this cannot answer for returns nothing, so automation carries on rather than
+ * silently stopping for the whole component.
+ *
+ * @param int[] $itemIds The items being considered.
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return int[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function trashedOrArchivedIds(array $itemIds, string $extension): array
+ {
+ $itemIds = array_values(array_unique(array_map('intval', $itemIds)));
+ $storage = $this->locate($extension);
+
+ if ($itemIds === [] || $storage === null) {
+ return [];
+ }
+
+ $stateColumn = $this->columnFor($extension, 'published');
+
+ if ($stateColumn === null) {
+ return [];
+ }
+
+ $db = $this->database;
+ $trashedOrArchivedQuery = $db->getQuery(true)
+ ->select($db->quoteName($storage['key']))
+ ->from($db->quoteName($storage['table']))
+ ->whereIn($db->quoteName($storage['key']), $itemIds)
+ ->whereIn($db->quoteName($stateColumn), [Workflow::CONDITION_TRASHED, Workflow::CONDITION_ARCHIVED]);
+
+ return array_map('intval', $db->setQuery($trashedOrArchivedQuery)->loadColumn() ?: []);
+ }
+
+ /**
+ * The display title of each item, keyed by id.
+ *
+ * @param int[] $itemIds The items to name.
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return array Item id to title, omitting any the query did not return.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+
+ public function titlesFor(array $itemIds, string $extension): array
+ {
+ $itemIds = array_values(array_unique(array_map('intval', $itemIds)));
+ $storage = $this->locate($extension);
+
+ if ($itemIds === [] || $storage === null) {
+ return [];
+ }
+
+ $titleColumn = $this->columnFor($extension, 'title');
+
+ if ($titleColumn === null) {
+ return [];
+ }
+
+ $db = $this->database;
+ $query = $db->getQuery(true)
+ ->select($db->quoteName([$storage['key'], $titleColumn]))
+ ->from($db->quoteName($storage['table']))
+ ->whereIn($db->quoteName($storage['key']), $itemIds);
+
+ $titles = [];
+
+ foreach ($db->setQuery($query)->loadAssocList() ?: [] as $row) {
+ $titles[(int) $row[$storage['key']]] = (string) $row[$titleColumn];
+ }
+
+ return $titles;
+ }
+
+ /**
+ * The category each item is filed in, keyed by item id.
+ *
+ * @param int[] $itemIds The items to look up.
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return array Omits items the extension does not file.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function categoriesFor(array $itemIds, string $extension): array
+ {
+ $itemIds = array_values(array_unique(array_map('intval', $itemIds)));
+ $storage = $this->locate($extension);
+
+ if ($itemIds === [] || $storage === null) {
+ return [];
+ }
+
+ $categoryColumn = $this->columnFor($extension, 'catid');
+
+ if ($categoryColumn === null) {
+ return [];
+ }
+
+ $db = $this->database;
+ $query = $db->getQuery(true)
+ ->select([
+ $db->quoteName('item.' . $storage['key'], 'item_id'),
+ $db->quoteName('category.id', 'category_id'),
+ $db->quoteName('category.title', 'category_title'),
+ $db->quoteName('category.extension', 'category_extension'),
+ ])
+ ->from($db->quoteName($storage['table'], 'item'))
+ ->join(
+ 'INNER',
+ $db->quoteName('#__categories', 'category'),
+ $db->quoteName('category.id') . ' = ' . $db->quoteName('item.' . $categoryColumn)
+ )
+ ->whereIn($db->quoteName('item.' . $storage['key']), $itemIds);
+
+ $categories = [];
+
+ foreach ($db->setQuery($query)->loadAssocList() ?: [] as $row) {
+ $categories[(int) $row['item_id']] = [
+ 'id' => (int) $row['category_id'],
+ 'title' => (string) $row['category_title'],
+ 'extension' => (string) $row['category_extension'],
+ ];
+ }
+
+ return $categories;
+ }
+
+ /**
+ * Fills in each row's item title and category, keyed by that row's own extension.
+ *
+ * Titles repeat, so the category is what tells two items of the same name apart.
+ *
+ * @param object[] $rows Rows carrying item_id and extension.
+ *
+ * @return object[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function annotateTitles(array $rows): array
+ {
+ if ($rows === []) {
+ return $rows;
+ }
+
+ $itemIdsByExtension = [];
+
+ foreach ($rows as $row) {
+ $itemIdsByExtension[$row->extension][] = (int) $row->item_id;
+ }
+
+ $titles = [];
+ $categories = [];
+
+ foreach ($itemIdsByExtension as $extension => $itemIds) {
+ foreach ($this->titlesFor($itemIds, $extension) as $itemId => $title) {
+ // An item id is only unique within its own extension.
+ $titles[$extension . '.' . $itemId] = $title;
+ }
+
+ foreach ($this->categoriesFor($itemIds, $extension) as $itemId => $category) {
+ $categories[$extension . '.' . $itemId] = $category;
+ }
+ }
+
+ foreach ($rows as $row) {
+ $key = $row->extension . '.' . $row->item_id;
+ $category = $categories[$key] ?? null;
+
+ $row->item_title = $titles[$key] ?? null;
+ $row->item_category_id = $category['id'] ?? null;
+ $row->item_category_title = $category['title'] ?? null;
+ $row->item_category_extension = $category['extension'] ?? null;
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Where an extension keeps its own items: the table and its key column.
+ *
+ * @param string $extension The workflow extension.
+ *
+ * @return array{table: string, key: string}|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function locate(string $extension): ?array
+ {
+ $contentType = $this->contentTypeFor($extension);
+
+ if ($contentType === null) {
+ return null;
+ }
+
+ $definition = json_decode((string) $contentType->table, true);
+
+ // Only the special table is the extension's own. The common one is always #__ucm_content,
+ // which is not reliably populated.
+ $table = $definition['special']['dbtable'] ?? '';
+ $key = $definition['special']['key'] ?? '';
+
+ if (!\is_string($table) || !preg_match('/^#__[a-zA-Z0-9_]+$/', $table)) {
+ return null;
+ }
+
+ if (!\is_string($key) || !preg_match('/^[a-zA-Z0-9_]+$/', $key)) {
+ return null;
+ }
+
+ return ['table' => $table, 'key' => $key];
+ }
+
+ /**
+ * Where an extension keeps its items and which column holds their titles.
+ *
+ * Every value returned has been validated, so it is safe to pass through quoteName().
+ *
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return array|null ['table' => ..., 'key' => ..., 'titleColumn' => ...], or null when
+ * the extension does not describe itself well enough to be searched.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function titleLocation(string $extension): ?array
+ {
+ $location = $this->locate($extension);
+
+ if ($location === null) {
+ return null;
+ }
+
+ $titleColumn = $this->columnFor($extension, 'title');
+
+ if ($titleColumn === null) {
+ return null;
+ }
+
+ return $location + ['titleColumn' => $titleColumn];
+ }
+
+ /**
+ * Which real column an extension uses for one of Joomla's logical field names.
+ *
+ * Asks the extension's Table class through getColumnAlias(), because guessing from the columns
+ * fails: #__contact_details has a "state" column that holds an address.
+ *
+ * @param string $extension The workflow extension.
+ * @param string $logicalName A Joomla field name, for instance published or title.
+ *
+ * @return string|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function columnFor(string $extension, string $logicalName): ?string
+ {
+ $contentType = $this->contentTypeFor($extension);
+
+ if ($contentType === null) {
+ return null;
+ }
+
+ $definition = json_decode((string) $contentType->table, true);
+ $class = ($definition['special']['prefix'] ?? '') . ($definition['special']['type'] ?? '');
+
+ // A registration can outlive its extension, for instance after a failed uninstall.
+ if (!\is_string($class) || $class === '' || !class_exists($class)) {
+ return null;
+ }
+
+ try {
+ $table = new $class($this->database);
+ } catch (\Throwable) {
+ // Some Table constructors need more than a database driver.
+ return null;
+ }
+
+ if (!$table instanceof Table) {
+ return null;
+ }
+
+ // getColumnAlias() hands back the name unchanged when there is no alias, whether or not the
+ // table has such a column, so a table without categories would otherwise reach the query.
+ if (!$table->hasField($logicalName)) {
+ return null;
+ }
+
+ $column = $table->getColumnAlias($logicalName);
+
+ // Validated because it reaches a query as an identifier.
+ return preg_match('/^[a-zA-Z0-9_]+$/', (string) $column) ? (string) $column : null;
+ }
+
+ /**
+ * The content type row for an extension, or null when it is not registered as one.
+ *
+ * @param string $extension The workflow extension
+ *
+ * @return object|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function contentTypeFor(string $extension): ?object
+ {
+ if (!\array_key_exists($extension, $this->contentTypes)) {
+ $db = $this->database;
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('table'))
+ ->from($db->quoteName('#__content_types'))
+ ->where($db->quoteName('type_alias') . ' = :extension')
+ ->bind(':extension', $extension);
+
+ $this->contentTypes[$extension] = $db->setQuery($query)->loadObject() ?: null;
+ }
+
+ return $this->contentTypes[$extension];
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/RelativeTime.php b/administrator/components/com_workflow/src/Automation/RelativeTime.php
new file mode 100644
index 0000000000000..c2495413fdaab
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/RelativeTime.php
@@ -0,0 +1,65 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+use Joomla\CMS\Language\Text;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Formats how long until an automated transition fires, as a short translated phrase.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class RelativeTime
+{
+ /**
+ * Returns a translated "In X units" phrase for a future time, or "Due now" for a past one.
+ *
+ * @param \DateTime $target The moment to count down to (UTC).
+ * @param \DateTime|null $now The reference now (UTC); defaults to the current time
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function until(\DateTime $target, ?\DateTime $now = null): string
+ {
+ $now = $now ?? new \DateTime('now', new \DateTimeZone('UTC'));
+ $seconds = $target->getTimestamp() - $now->getTimestamp();
+
+ if ($seconds <= 0) {
+ return Text::_('COM_WORKFLOW_UPCOMING_DUE_NOW');
+ }
+
+ $minutes = (int) round($seconds / 60);
+
+ if ($minutes < 60) {
+ return Text::plural('COM_WORKFLOW_UPCOMING_IN_MINUTES', max(1, $minutes));
+ }
+
+ $hours = (int) round($seconds / 3600);
+
+ if ($hours < 24) {
+ return Text::plural('COM_WORKFLOW_UPCOMING_IN_HOURS', $hours);
+ }
+
+ $days = (int) round($seconds / 86400);
+
+ if ($days < 30) {
+ return Text::plural('COM_WORKFLOW_UPCOMING_IN_DAYS', $days);
+ }
+
+ return Text::plural('COM_WORKFLOW_UPCOMING_IN_MONTHS', max(1, (int) round($seconds / 2592000)));
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/UpcomingTransition.php b/administrator/components/com_workflow/src/Automation/UpcomingTransition.php
new file mode 100644
index 0000000000000..fcd13a638355d
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/UpcomingTransition.php
@@ -0,0 +1,68 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * One item's next automated transition, as shown in the upcoming-transitions view.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+
+final class UpcomingTransition
+{
+ /**
+ * @param integer $itemId The content item id.
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ * @param string $itemTitle The item's title, or '' when it could not be resolved.
+ * @param string $editUrl Un-routed admin edit link, or '' when not linkable.
+ * @param string $fromStage Title of the stage the item is leaving.
+ * @param string $toStage Title of the stage the item moves to.
+ * @param string $transitionTitle Title of the transition that will run.
+ * @param \DateTime|null $firesAt When the move is due (UTC), or null if uncomputable.
+ * @param string $status scheduled | needs_attention | not_scheduled | rule_error.
+ * @param string $failureReason Why the rule could not be read, or '' when it could.
+ * @param \DateTime|null $failedAt When the scheduler stored the fault, or null if found now.
+ * @param string $ruleType delay | cron.
+ * @param integer|null $delayValue Delay amount for a delay rule.
+ * @param string|null $delayUnit minutes | hours | days | months.
+ * @param string|null $cronExpression Cron expression for a cron rule.
+ * @param boolean $hasCondition Whether a fire condition gates this move.
+ * @param string $workflowTitle Title of the owning workflow.
+ * @param integer $workflowId Id of the owning workflow, or 0 when unknown.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(
+ public readonly int $itemId,
+ public readonly string $extension,
+ public readonly string $itemTitle,
+ public readonly string $editUrl,
+ public readonly string $fromStage,
+ public readonly string $toStage,
+ public readonly string $transitionTitle,
+ public readonly ?\DateTime $firesAt,
+ public readonly string $status,
+ public readonly string $failureReason,
+ public readonly ?\DateTime $failedAt,
+ public readonly string $ruleType,
+ public readonly ?int $delayValue,
+ public readonly ?string $delayUnit,
+ public readonly ?string $cronExpression,
+ public readonly bool $hasCondition,
+ public readonly string $workflowTitle = '',
+ public readonly int $workflowId = 0
+ ) {
+ }
+}
diff --git a/administrator/components/com_workflow/src/Automation/UpcomingTransitionsCalculator.php b/administrator/components/com_workflow/src/Automation/UpcomingTransitionsCalculator.php
new file mode 100644
index 0000000000000..133ddb7bba1b8
--- /dev/null
+++ b/administrator/components/com_workflow/src/Automation/UpcomingTransitionsCalculator.php
@@ -0,0 +1,813 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Automation;
+
+use Joomla\Database\DatabaseInterface;
+use Joomla\Database\ParameterType;
+use Joomla\Database\QueryInterface;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Works out, on the fly, each item's next automated transition in a workflow.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class UpcomingTransitionsCalculator
+{
+ /**
+ * How many items the panel on the workflow edit screen reports on. That panel sits inside an
+ * edit form, where a pagination bar would submit the form and lose unsaved changes, so it
+ * shows the head of the list rather than all of it.
+ *
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ public const MAX_ITEMS_PER_PANEL = 20;
+
+ /**
+ * @var DatabaseInterface
+ * @since __DEPLOY_VERSION__
+ */
+ private DatabaseInterface $database;
+
+ /**
+ * @var ConditionEvaluator
+ * @since __DEPLOY_VERSION__
+ */
+ private ConditionEvaluator $conditionEvaluator;
+
+ /**
+ * @var ItemFieldResolver
+ * @since __DEPLOY_VERSION__
+ */
+ private ItemFieldResolver $itemFieldResolver;
+
+ /**
+ * @var ConditionWindowCalculator
+ * @since __DEPLOY_VERSION__
+ */
+ private ConditionWindowCalculator $conditionWindowCalculator;
+
+ /**
+ * @param DatabaseInterface $database The database driver.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(DatabaseInterface $database)
+ {
+ $this->database = $database;
+ $this->conditionEvaluator = new ConditionEvaluator();
+ $this->itemFieldResolver = new ItemFieldResolver($database);
+ $this->conditionWindowCalculator = new ConditionWindowCalculator($database);
+ }
+
+ /**
+ * Calculates the next automated transition for one page of a workflow's items.
+ *
+ * @param integer $workflowId The workflow id.
+ * @param integer $limit Page size, or 0 for every item. Defaults to the panel's cap.
+ * @param integer $start How many items to skip.
+ * @param integer $stageId Only items currently in this stage, or 0 for any stage.
+ *
+ * @return UpcomingTransition[] Items needing attention first, then longest waiting.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function forWorkflow(int $workflowId, int $limit = self::MAX_ITEMS_PER_PANEL, int $start = 0, int $stageId = 0): array
+ {
+ if ($limit <= 0) {
+ return $this->buildFromRows($this->fetchRowsForWorkflow($workflowId, [], $stageId));
+ }
+
+ $db = $this->database;
+ $scope = $this->inStage($this->inScopeItemStateQuery(), $stageId)
+ ->where($db->quoteName('wt.workflow_id') . ' = :workflowId')
+ ->bind(':workflowId', $workflowId, ParameterType::INTEGER);
+
+ $itemStateIds = $this->itemStateIds($scope, $limit, $start);
+
+ if ($itemStateIds === []) {
+ return [];
+ }
+
+ return $this->buildFromRows($this->fetchRowsForWorkflow($workflowId, $itemStateIds));
+ }
+
+ /**
+ * Counts the items in one workflow that carry a live automation rule. An upper bound on what
+ * gets listed, for the same reason as countForExtension().
+ *
+ * @param integer $workflowId The workflow id.
+ * @param integer $stageId Only items currently in this stage, or 0 for any stage.
+ *
+ * @return integer
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function countForWorkflow(int $workflowId, int $stageId = 0): int
+ {
+ $db = $this->database;
+ $query = $this->inStage($this->inScopeItemStateQuery(), $stageId)
+ ->select('COUNT(DISTINCT ' . $db->quoteName('wis.id') . ')')
+ ->where($db->quoteName('wt.workflow_id') . ' = :workflowId')
+ ->bind(':workflowId', $workflowId, ParameterType::INTEGER);
+
+ return (int) $db->setQuery($query)->loadResult();
+ }
+
+ /**
+ * Calculates the next automated transition for one page of the items under an extension,
+ * across all of its workflows.
+ *
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ * @param integer $limit Page size, or 0 for every item, which is what the user asks
+ * for by choosing All in the limit box.
+ * @param integer $start How many items to skip.
+ * @param integer $stageId Only items currently in this stage, or 0 for any stage.
+ *
+ * @return UpcomingTransition[] Items needing attention first, then longest waiting.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function forExtension(string $extension, int $limit = 0, int $start = 0, int $stageId = 0): array
+ {
+ if ($limit <= 0) {
+ return $this->buildFromRows($this->fetchRowsForExtension($extension, [], $stageId));
+ }
+
+ $db = $this->database;
+ $scope = $this->inStage($this->inScopeItemStateQuery(), $stageId)
+ ->where($db->quoteName('wis.extension') . ' = :extension')
+ ->bind(':extension', $extension);
+
+ $itemStateIds = $this->itemStateIds($scope, $limit, $start);
+
+ if ($itemStateIds === []) {
+ return [];
+ }
+
+ return $this->buildFromRows($this->fetchRowsForExtension($extension, $itemStateIds));
+ }
+
+ /**
+ * Counts the items under an extension that carry a live automation rule.
+ *
+ * This is an upper bound on what the view lists, not an exact figure: an item whose rule
+ * filters it out is counted here and not shown, because only evaluating the filter in PHP
+ * can tell them apart, which is the work pagination exists to avoid.
+ *
+ * @param string $extension The workflow extension.
+ * @param integer $stageId Only items currently in this stage, or 0 for any stage.
+ *
+ * @return integer
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function countForExtension(string $extension, int $stageId = 0): int
+ {
+ $db = $this->database;
+ $query = $this->inStage($this->inScopeItemStateQuery(), $stageId)
+ ->select('COUNT(DISTINCT ' . $db->quoteName('wis.id') . ')')
+ ->where($db->quoteName('wis.extension') . ' = :extension')
+ ->bind(':extension', $extension);
+
+ return (int) $db->setQuery($query)->loadResult();
+ }
+
+ /**
+ * Calculates the next automated transition for a single item, or null if it has none.
+ *
+ * @param integer $itemId The content item id.
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return UpcomingTransition|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function forItem(int $itemId, string $extension): ?UpcomingTransition
+ {
+ $upcoming = $this->buildFromRows($this->fetchRowsForItem($itemId, $extension));
+
+ return $upcoming[0] ?? null;
+ }
+
+ /**
+ * Turns candidate rows into upcoming transitions: soonest in-scope rule per item.
+ *
+ * @param object[] $rows Candidate (item, transition, rule) rows.
+ *
+ * @return UpcomingTransition[] Soonest first; uncomputable times last.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function buildFromRows(array $rows): array
+ {
+ $bestByItem = [];
+
+ // Items whose filter cannot be read are kept aside rather than dropped, so a broken rule
+ // still shows in the view. The first message wins, as in the scheduler.
+ $liveFailureByItem = [];
+ $fallbackRowByItem = [];
+
+ $itemIdsByExtension = [];
+ foreach ($rows as $row) {
+ $itemIdsByExtension[$row->extension][] = (int) $row->item_id;
+ }
+
+ foreach ($itemIdsByExtension as $extension => $itemIds) {
+ $this->itemFieldResolver->preload($itemIds, $extension);
+ }
+
+ foreach ($rows as $row) {
+ $itemKey = $row->extension . '.' . $row->item_id;
+ $resolveField = $this->itemFieldResolver->forItem((int) $row->item_id, $row->extension);
+
+ try {
+ if (!$this->conditionEvaluator->evaluate($row->item_filter, $resolveField)) {
+ continue;
+ }
+ } catch (ConditionEvaluationException $invalidCondition) {
+ $liveFailureByItem[$itemKey] ??= $invalidCondition->getMessage();
+ $fallbackRowByItem[$itemKey] ??= $row;
+
+ continue;
+ }
+
+ // Rules compete on their deadline, before any fire condition is applied.
+ $deadline = DeadlineCalculator::forRule($row->entered_at, $row);
+
+ if (!isset($bestByItem[$itemKey]) || $this->isSooner($deadline, $bestByItem[$itemKey]['deadline'])) {
+ $bestByItem[$itemKey] = ['row' => $row, 'deadline' => $deadline];
+ }
+ }
+
+ // An item whose every rule was unreadable is shown through the first rule that failed.
+ foreach ($fallbackRowByItem as $itemKey => $row) {
+ $bestByItem[$itemKey] ??= ['row' => $row, 'deadline' => null];
+ }
+
+ $now = new \DateTime('now', new \DateTimeZone('UTC'));
+ $upcoming = [];
+
+ foreach ($bestByItem as $itemKey => $winner) {
+ $upcoming[] = $this->buildUpcomingTransition(
+ $winner['row'],
+ $winner['deadline'],
+ $now,
+ $liveFailureByItem[$itemKey] ?? ''
+ );
+ }
+
+ return $upcoming;
+ }
+
+ /**
+ * The item state rows that could produce an upcoming transition, with no scope clause yet.
+ *
+ * Goes through joinLiveRules() like baseRowsQuery(), so a count taken here and a page of rows
+ * taken there cannot disagree about what is live.
+ *
+ * @return QueryInterface
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function inScopeItemStateQuery(): QueryInterface
+ {
+ $db = $this->database;
+
+ return $this->joinLiveRules($db->getQuery(true)->from($db->quoteName('#__workflow_item_state', 'wis')));
+ }
+
+ /**
+ * Joins item state rows to the live rules that could move them, matching the scheduler.
+ *
+ * A transition from any stage has from_stage_id -1 and applies to every item in its own workflow.
+ * A rule that has already run for an item since the item entered its current stage is left out,
+ * because the scheduler runs each rule once per stay in a stage.
+ *
+ * @param QueryInterface $query A query selecting from #__workflow_item_state as wis.
+ *
+ * @return QueryInterface
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function joinLiveRules(QueryInterface $query): QueryInterface
+ {
+ $db = $this->database;
+
+ // 0 is a successful run in #__workflow_automation_log.exit_code.
+ $alreadyRan = $db->getQuery(true)
+ ->select('1')
+ ->from($db->quoteName('#__workflow_automation_log', 'wal'))
+ ->where($db->quoteName('wal.rule_id') . ' = ' . $db->quoteName('war.id'))
+ ->where($db->quoteName('wal.item_id') . ' = ' . $db->quoteName('wis.item_id'))
+ ->where($db->quoteName('wal.extension') . ' = ' . $db->quoteName('wis.extension'))
+ ->where($db->quoteName('wal.exit_code') . ' = 0')
+ ->where($db->quoteName('wal.executed_at') . ' >= ' . $db->quoteName('wis.entered_at'));
+
+ return $query
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflow_stages', 'ws'),
+ $db->quoteName('ws.id') . ' = ' . $db->quoteName('wis.stage_id')
+ )
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflow_transitions', 'wt'),
+ $db->quoteName('wt.workflow_id') . ' = ' . $db->quoteName('ws.workflow_id')
+ . ' AND ' . $db->quoteName('wt.from_stage_id') . ' IN (' . $db->quoteName('wis.stage_id') . ', -1)'
+ )
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflow_automation_rules', 'war'),
+ $db->quoteName('war.transition_id') . ' = ' . $db->quoteName('wt.id')
+ )
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflows', 'w'),
+ $db->quoteName('w.id') . ' = ' . $db->quoteName('wt.workflow_id')
+ )
+ ->where($db->quoteName('w.published') . ' = 1')
+ ->where($db->quoteName('wt.published') . ' = 1')
+ ->where($db->quoteName('war.published') . ' = 1')
+ ->where('NOT EXISTS (' . $alreadyRan . ')');
+ }
+
+ /**
+ * Runs a scoped item state query and returns one page of ids.
+ *
+ * @param QueryInterface $scope A query from inScopeItemStateQuery(), scoped by the caller.
+ * @param integer $limit Page size.
+ * @param integer $start How many rows to skip.
+ *
+ * @return int[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function itemStateIds(QueryInterface $scope, int $limit, int $start): array
+ {
+ $db = $this->database;
+
+ // The two sort columns are selected as well as ordered by, which PostgreSQL requires of a
+ // DISTINCT query. loadColumn() reads the first column, so it still returns ids.
+ $scope->select(
+ 'DISTINCT ' . implode(
+ ', ',
+ $db->quoteName(['wis.id', 'wis.requires_intervention', 'wis.entered_at'])
+ )
+ )
+ ->order($db->quoteName('wis.requires_intervention') . ' DESC')
+ ->order($db->quoteName('wis.entered_at') . ' ASC')
+ // Unique last key: items that entered at the same second would otherwise swap places between
+ // pages, repeating one item and skipping another.
+ ->order($db->quoteName('wis.id') . ' ASC')
+ ->setLimit($limit, $start);
+
+ return array_map('intval', $db->setQuery($scope)->loadColumn());
+ }
+
+ /**
+ * Narrows a query on #__workflow_item_state as wis to items currently in one stage.
+ *
+ * @param QueryInterface $query The query to narrow.
+ * @param integer $stageId The stage, or 0 to leave the query as it is.
+ *
+ * @return QueryInterface
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function inStage(QueryInterface $query, int $stageId): QueryInterface
+ {
+ if ($stageId > 0) {
+ $query->where($this->database->quoteName('wis.stage_id') . ' = ' . $stageId);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Applies the display order: items needing attention first, then the longest waiting, and
+ * within a single item the transition order, which settles ties the way the scheduler does.
+ *
+ * @param QueryInterface $query A rows query from baseRowsQuery().
+ *
+ * @return QueryInterface
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function orderedByWait(QueryInterface $query): QueryInterface
+ {
+ $db = $this->database;
+
+ return $query->order($db->quoteName('wis.requires_intervention') . ' DESC')
+ ->order($db->quoteName('wis.entered_at') . ' ASC')
+ ->order($db->quoteName('wis.id') . ' ASC')
+ ->order($db->quoteName('wt.ordering') . ' ASC');
+ }
+
+
+ /**
+ * Loads the candidate rows for a set of item states in a workflow.
+ *
+ * @param integer $workflowId The workflow id.
+ * @param int[] $itemStateIds The item state rows to report on, or none for all of them.
+ * @param integer $stageId Only items currently in this stage, or 0 for any stage.
+ *
+ * @return object[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function fetchRowsForWorkflow(int $workflowId, array $itemStateIds = [], int $stageId = 0): array
+ {
+ $db = $this->database;
+ $query = $this->inStage($this->baseRowsQuery(), $stageId)
+ ->where($db->quoteName('wt.workflow_id') . ' = :workflowId')
+ ->bind(':workflowId', $workflowId, ParameterType::INTEGER);
+
+ if ($itemStateIds !== []) {
+ $query->whereIn($db->quoteName('wis.id'), $itemStateIds);
+ }
+
+ return $this->fetchRows($this->orderedByWait($query));
+ }
+
+ /**
+ * Loads the candidate rows for every workflow under an extension.
+ *
+ * @param string $extension The workflow extension.
+ * @param int[] $itemStateIds The item state rows to report on, or none for all of them.
+ * @param integer $stageId Only items currently in this stage, or 0 for any stage.
+ *
+ * @return object[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function fetchRowsForExtension(string $extension, array $itemStateIds = [], int $stageId = 0): array
+ {
+ $db = $this->database;
+ $query = $this->inStage($this->baseRowsQuery(), $stageId)
+ ->where($db->quoteName('wis.extension') . ' = :extension')
+ ->bind(':extension', $extension);
+
+ if ($itemStateIds !== []) {
+ $query->whereIn($db->quoteName('wis.id'), $itemStateIds);
+ }
+
+ return $this->fetchRows($this->orderedByWait($query));
+ }
+
+ /**
+ * Loads the candidate rows for a single item.
+ *
+ * @param integer $itemId The content item id.
+ * @param string $extension The workflow extension.
+ *
+ * @return object[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function fetchRowsForItem(int $itemId, string $extension): array
+ {
+ $db = $this->database;
+ $query = $this->baseRowsQuery()
+ ->where($db->quoteName('wis.item_id') . ' = :itemId')
+ ->where($db->quoteName('wis.extension') . ' = :extension')
+ ->bind(':itemId', $itemId, ParameterType::INTEGER)
+ ->bind(':extension', $extension)
+ ->order($db->quoteName('wt.ordering') . ' ASC');
+
+ return $this->fetchRows($query);
+ }
+
+ /**
+ * Builds the shared select and joins, without a scope clause.
+ *
+ * @return QueryInterface
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function baseRowsQuery(): QueryInterface
+ {
+ $db = $this->database;
+ $query = $db->getQuery(true)
+ ->select(
+ [
+ $db->quoteName('wis.item_id'),
+ $db->quoteName('wis.extension'),
+ $db->quoteName('wis.entered_at'),
+ $db->quoteName('wis.requires_intervention'),
+ $db->quoteName('wis.last_failure_at'),
+ $db->quoteName('wis.last_failure_reason'),
+ $db->quoteName('wt.id', 'transition_id'),
+ $db->quoteName('wt.title', 'transition_title'),
+ // The item's own stage, since a transition from any stage stores -1 as its from stage.
+ $db->quoteName('wis.stage_id', 'from_stage_id'),
+ $db->quoteName('wt.to_stage_id'),
+ $db->quoteName('ws.title', 'from_stage_title'),
+ $db->quoteName('sto.title', 'to_stage_title'),
+ $db->quoteName('war.rule_type'),
+ $db->quoteName('war.delay_value'),
+ $db->quoteName('war.delay_unit'),
+ $db->quoteName('war.cron_expression'),
+ $db->quoteName('war.item_filter'),
+ $db->quoteName('war.fire_condition'),
+ $db->quoteName('wt.ordering'),
+ $db->quoteName('w.id', 'workflow_id'),
+ $db->quoteName('w.title', 'workflow_title'),
+ ]
+ )
+ ->from($db->quoteName('#__workflow_item_state', 'wis'));
+
+ return $this->joinLiveRules($query)
+ ->join(
+ 'LEFT',
+ $db->quoteName('#__workflow_stages', 'sto'),
+ $db->quoteName('sto.id') . ' = ' . $db->quoteName('wt.to_stage_id')
+ );
+ }
+
+ /**
+ * Builds one upcoming transition from a winning row.
+ *
+ * @param object $row The chosen (item, transition, rule) row.
+ * @param \DateTime|null $deadline The moment the rule becomes due, or null.
+ * @param \DateTime $now Current time (UTC).
+ *
+ * @return UpcomingTransition
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function buildUpcomingTransition(
+ object $row,
+ ?\DateTime $deadline,
+ \DateTime $now,
+ string $liveFailureReason
+ ): UpcomingTransition {
+ $requiresIntervention = (int) $row->requires_intervention === 1;
+ $hasCondition = $row->fire_condition !== null && trim((string) $row->fire_condition) !== '';
+
+ [$firesAt, $status, $discoveredReason] = $this->resolveFireTime(
+ $row,
+ $deadline,
+ $now,
+ $hasCondition,
+ $requiresIntervention,
+ $liveFailureReason
+ );
+
+ $storedReason = (string) ($row->last_failure_reason ?? '');
+ $storedAt = $row->last_failure_at !== null
+ ? new \DateTime((string) $row->last_failure_at, new \DateTimeZone('UTC'))
+ : null;
+
+ // A fault found by this render wins over the one the scheduler stored, which may be out of
+ // date. A stored fault alone is shown with its time but does not change the status.
+ $failureReason = $discoveredReason !== '' ? $discoveredReason : $storedReason;
+ $failedAt = $discoveredReason !== '' ? null : $storedAt;
+
+ return new UpcomingTransition(
+ itemId: (int) $row->item_id,
+ extension: (string) $row->extension,
+ itemTitle: (string) ($row->item_title ?? ''),
+ editUrl: $this->buildEditUrl($row),
+ fromStage: (string) ($row->from_stage_title ?? ''),
+ toStage: (string) ($row->to_stage_title ?? ''),
+ transitionTitle: (string) ($row->transition_title ?? ''),
+ firesAt: $firesAt,
+ status: $status,
+ failureReason: $failureReason,
+ failedAt: $failedAt,
+ ruleType: (string) $row->rule_type,
+ delayValue: $row->delay_value !== null ? (int) $row->delay_value : null,
+ delayUnit: $row->delay_unit,
+ cronExpression: $row->cron_expression,
+ hasCondition: $hasCondition,
+ workflowTitle: (string) ($row->workflow_title ?? ''),
+ workflowId: (int) ($row->workflow_id ?? 0)
+ );
+ }
+
+ /**
+ * Works out when a rule will really fire, and the status to show for it.
+ *
+ * A gated rule fires at the first moment its condition holds, searching from the deadline,
+ * or from now when the rule is already overdue.
+ *
+ * @param object $row The rule row.
+ * @param \DateTime|null $deadline The moment the rule becomes due.
+ * @param \DateTime $now Current time (UTC).
+ * @param boolean $hasCondition Whether a fire condition exists.
+ * @param boolean $requiresIntervention Whether the item is flagged stuck.
+ * @param string $liveFailureReason A filter fault this render found, or ''.
+ *
+ * @return array{0: \DateTime|null, 1: string, 2: string} Fire time, status, and any fault found.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function resolveFireTime(
+ object $row,
+ ?\DateTime $deadline,
+ \DateTime $now,
+ bool $hasCondition,
+ bool $requiresIntervention,
+ string $liveFailureReason
+ ): array {
+ // Blocked outranks everything: the scheduler skips the item until a person clears it.
+ if ($requiresIntervention) {
+ return [$deadline, 'needs_attention', ''];
+ }
+
+ if ($liveFailureReason !== '') {
+ return [null, 'rule_error', $liveFailureReason];
+ }
+
+ if ($deadline === null) {
+ return [null, 'not_scheduled', ''];
+ }
+
+ if (!$hasCondition) {
+ return [$deadline, 'scheduled', ''];
+ }
+
+ try {
+ $firesAt = $this->conditionWindowCalculator->firstMatchAtOrAfter(
+ $row->fire_condition,
+ max($deadline, $now),
+ (int) $row->item_id,
+ (string) $row->extension
+ );
+ } catch (ConditionEvaluationException $invalidCondition) {
+ // Not needs_attention: the item is not stuck. It is retried every run and recovers once
+ // the rule is fixed.
+ return [null, 'rule_error', $invalidCondition->getMessage()];
+ }
+
+ if ($firesAt === null) {
+ return [null, 'not_scheduled', ''];
+ }
+
+ return [$firesAt, 'scheduled', ''];
+ }
+
+ /**
+ * Builds an un-routed admin edit link for the item, or '' when the extension is not linkable.
+ *
+ * @param object $row The rule row.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function buildEditUrl(object $row): string
+ {
+ $parts = explode('.', (string) $row->extension);
+ $option = $parts[0] ?? '';
+ $type = $parts[1] ?? '';
+
+ if ($option === '' || $type === '') {
+ return '';
+ }
+
+ return 'index.php?option=' . $option . '&task=' . $type . '.edit&id=' . (int) $row->item_id;
+ }
+
+ /**
+ * Whether a candidate fire time is sooner than the current best. Null (uncomputable) is
+ * treated as the latest possible, so a computable time always wins.
+ *
+ * @param \DateTime|null $candidate The candidate time.
+ * @param \DateTime|null $current The current best time.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function isSooner(?\DateTime $candidate, ?\DateTime $current): bool
+ {
+ if ($candidate === null) {
+ return false;
+ }
+
+ if ($current === null) {
+ return true;
+ }
+
+ return $candidate < $current;
+ }
+
+ /**
+ * Calculates the next automated transition for a set of items, keyed by item id.
+ *
+ * @param int[] $itemIds The content item ids.
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function forItems(array $itemIds, string $extension): array
+ {
+ $itemIds = array_values(array_unique(array_map('intval', $itemIds)));
+
+ if (empty($itemIds)) {
+ return [];
+ }
+
+ $byItem = [];
+
+ foreach ($this->buildFromRows($this->fetchRowsForItems($itemIds, $extension)) as $transition) {
+ $byItem[$transition->itemId] = $transition;
+ }
+
+ return $byItem;
+ }
+
+ /**
+ * Loads the candidate row for a set of items
+ *
+ * @param int[] $itemIds The content item ids.
+ * @param string $extension The workflow extension.
+ *
+ * @return object[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function fetchRowsForItems(array $itemIds, string $extension): array
+ {
+ $db = $this->database;
+ $scheduledRowsQuery = $this->baseRowsQuery()
+ ->whereIn($db->quoteName('wis.item_id'), $itemIds)
+ ->where($db->quoteName('wis.extension') . ' = :extension')
+ ->bind(':extension', $extension)
+ ->order($db->quoteName('wt.ordering') . ' ASC');
+
+ return $this->fetchRows($scheduledRowsQuery);
+ }
+
+ /**
+ * Runs a rows query, drops trashed and archived items, and fills in item titles.
+ *
+ * Both need the extension's own item table, which one query cannot join per row.
+ *
+ * @param QueryInterface $query The prepared rows query.
+ *
+ * @return object[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function fetchRows(QueryInterface $query): array
+ {
+ $rows = $this->database->setQuery($query)->loadObjectList() ?: [];
+
+ if ($rows === []) {
+ return [];
+ }
+
+ $itemIdsByExtension = [];
+
+ foreach ($rows as $row) {
+ $itemIdsByExtension[$row->extension][] = (int) $row->item_id;
+ }
+
+ $itemStorage = new ItemStorage($this->database);
+ $excluded = [];
+ $titles = [];
+
+ foreach ($itemIdsByExtension as $extension => $itemIds) {
+ foreach ($itemStorage->trashedOrArchivedIds($itemIds, $extension) as $itemId) {
+ // An item id is only unique within its own extension.
+ $excluded[$extension . '.' . $itemId] = true;
+ }
+
+ foreach ($itemStorage->titlesFor($itemIds, $extension) as $itemId => $title) {
+ $titles[$extension . '.' . $itemId] = $title;
+ }
+ }
+
+ $kept = [];
+
+ foreach ($rows as $row) {
+ $key = $row->extension . '.' . $row->item_id;
+
+ if (isset($excluded[$key])) {
+ continue;
+ }
+
+ $row->item_title = $titles[$key] ?? null;
+ $kept[] = $row;
+ }
+
+ return $kept;
+ }
+}
diff --git a/administrator/components/com_workflow/src/Controller/GraphController.php b/administrator/components/com_workflow/src/Controller/GraphController.php
index 6ea2d0dffcbbf..9d7f178521f70 100644
--- a/administrator/components/com_workflow/src/Controller/GraphController.php
+++ b/administrator/components/com_workflow/src/Controller/GraphController.php
@@ -317,6 +317,7 @@ public function getTransitions()
'from_stage_id' => (int) $transition->from_stage_id,
'to_stage_id' => (int) $transition->to_stage_id,
'ordering' => (int) $transition->ordering,
+ 'automated' => (bool) $transition->automated,
'workflow_id' => (int) $transition->workflow_id,
'permissions' => [
'edit' => $canEdit,
diff --git a/administrator/components/com_workflow/src/Controller/LogsController.php b/administrator/components/com_workflow/src/Controller/LogsController.php
new file mode 100644
index 0000000000000..023f07732f64f
--- /dev/null
+++ b/administrator/components/com_workflow/src/Controller/LogsController.php
@@ -0,0 +1,68 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Controller;
+
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\MVC\Controller\BaseController;
+use Joomla\CMS\Router\Route;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Controller for the automation log
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class LogsController extends BaseController
+{
+ /**
+ * Clears the requires_intervention flag on an item so it is retried.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function reset()
+ {
+ $this->checkToken('get');
+
+ $user = $this->app->getIdentity();
+ $itemId = $this->input->getInt('item_id', 0);
+ $extension = $this->input->getCmd('extension', '');
+ $redirect = 'index.php?option=com_workflow&view=logs&extension=' . $extension;
+
+ if (!$user->authorise('core.admin', 'com_workflow')) {
+ $this->setRedirect(Route::_($redirect, false), Text::_('JERROR_ALERTNOAUTHOR'), 'error');
+
+ return false;
+ }
+
+ if ($itemId > 0 && $extension !== '') {
+ /** @var \Joomla\Component\Workflow\Administrator\Model\LogsModel $model */
+ $model = $this->getModel('Logs', 'Administrator');
+
+ // The model checks the permission too, and its answer is the one that counts.
+ if (!$model->clearIntervention($itemId, $extension)) {
+ $this->setRedirect(Route::_($redirect, false), Text::_('JERROR_ALERTNOAUTHOR'), 'error');
+
+ return false;
+ }
+
+ $this->setMessage(Text::_('COM_WORKFLOW_LOGS_RESET_SUCCESS'));
+ }
+
+ $this->setRedirect(Route::_($redirect, false));
+
+ return true;
+ }
+}
diff --git a/administrator/components/com_workflow/src/Controller/TransitionController.php b/administrator/components/com_workflow/src/Controller/TransitionController.php
index c63b65f741c18..bfb7c6bfa9b94 100644
--- a/administrator/components/com_workflow/src/Controller/TransitionController.php
+++ b/administrator/components/com_workflow/src/Controller/TransitionController.php
@@ -10,11 +10,17 @@
namespace Joomla\Component\Workflow\Administrator\Controller;
+use Joomla\CMS\Access\Exception\NotAllowed;
use Joomla\CMS\Application\CMSApplication;
+use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\FormController;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
+use Joomla\CMS\Response\JsonResponse;
use Joomla\CMS\Router\Route;
+use Joomla\Component\Workflow\Administrator\Automation\ConditionEvaluationException;
+use Joomla\Component\Workflow\Administrator\Automation\FilterPreview;
+use Joomla\Database\DatabaseInterface;
use Joomla\Input\Input;
// phpcs:disable PSR1.Files.SideEffects
@@ -228,4 +234,64 @@ public function cancel($key = null)
}
return $result;
}
+
+ /**
+ * Reports which items the item filter currently in the builder would match.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function previewFilter(): void
+ {
+ try {
+ if (!$this->checkToken('post', false)) {
+ throw new \RuntimeException(Text::_('JINVALID_TOKEN_NOTICE'));
+ }
+
+ $transitionId = $this->input->post->getInt('transition_id');
+
+ if ($transitionId <= 0) {
+ throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_PREVIEW_ERROR_NO_TRANSITION'));
+ }
+
+ $preview = new FilterPreview(Factory::getContainer()->get(DatabaseInterface::class));
+ $transition = $preview->describeTransition($transitionId);
+
+ if ($transition === null) {
+ throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_PREVIEW_ERROR_NO_TRANSITION'));
+ }
+
+ // Checked against the workflow the transition belongs to, not the workflow_id in the
+ // request, which anyone could change.
+ $owningParts = explode('.', (string) $transition->extension);
+
+ if (
+ !$this->app->getIdentity()->authorise(
+ 'core.edit',
+ array_shift($owningParts) . '.workflow.' . (int) $transition->workflow_id
+ )
+ ) {
+ throw new NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403);
+ }
+
+ echo new JsonResponse(
+ $preview->forTransition(
+ $transitionId,
+ $this->input->post->getRaw('item_filter')
+ )
+ );
+ } catch (ConditionEvaluationException $invalidFilter) {
+ // An incomplete filter is normal while typing, so this is an answer, not an error.
+ echo new JsonResponse(null, $invalidFilter->getMessage(), true);
+ } catch (NotAllowed $notAllowed) {
+ $this->app->setHeader('status', 403, true);
+ echo new JsonResponse(null, $notAllowed->getMessage(), true);
+ } catch (\Exception $e) {
+ $this->app->setHeader('status', 500);
+ echo new JsonResponse(null, $e->getMessage(), true);
+ }
+
+ $this->app->close();
+ }
}
diff --git a/administrator/components/com_workflow/src/Field/ConditionbuilderField.php b/administrator/components/com_workflow/src/Field/ConditionbuilderField.php
new file mode 100644
index 0000000000000..79d2d5506f02b
--- /dev/null
+++ b/administrator/components/com_workflow/src/Field/ConditionbuilderField.php
@@ -0,0 +1,405 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Field;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+use Joomla\CMS\Event\Workflow\WorkflowConditionFieldsEvent;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Form\FormField;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\Session\Session;
+use Joomla\Component\Workflow\Administrator\Automation\BuiltinConditionFields;
+use Joomla\Database\DatabaseInterface;
+use Joomla\Database\ParameterType;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Registry\Registry;
+
+/**
+ * Renders one automation condition builder.
+ *
+ * The mode attribute picks the filter, which offers item checks, or the condition, which offers
+ * moment checks. The builder itself is drawn by condition-builder.js from the config output here.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class ConditionbuilderField extends FormField
+{
+ /**
+ * The form field type.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ protected $type = 'Conditionbuilder';
+
+ /**
+ * Builds the field markup: a hidden input for the JSON value, plus a config
+ * blob (scoped to this field's mode) that the client-side builder reads.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function getInput()
+ {
+ $document = Factory::getApplication()->getDocument();
+ $document->getWebAssetManager()
+ ->useStyle('com_workflow.condition-builder')
+ ->useScript('com_workflow.condition-builder')
+ ->usePreset('choicesjs')
+ ->useScript('webcomponent.field-fancy-select')
+ ->useScript('joomla.dialog-autocreate');
+
+ Text::script('JGLOBAL_SELECT_NO_RESULTS_MATCH');
+ Text::script('JGLOBAL_SELECT_PRESS_TO_SELECT');
+
+ $storedValue = $this->value;
+
+ // The value round trips as a JSON string; normalise anything else back to one.
+ if (\is_array($storedValue) || \is_object($storedValue)) {
+ $storedValue = json_encode($storedValue);
+ }
+
+ $availableFields = $this->getAvailableFields();
+
+ $configuration = json_encode([
+ 'fields' => $this->getFieldChoices($availableFields),
+ 'operators' => $this->getOperatorChoices($availableFields),
+ 'valueTypes' => $this->getValueTypes($availableFields),
+ 'valueOptions' => $this->getValueChoices($availableFields),
+ 'text' => $this->getInterfaceText(),
+ 'preview' => $this->getPreviewConfig(),
+ 'expert' => $this->isExpertMode(),
+ 'expertDialog' => $this->expertModeDialog(),
+ ]);
+
+ return ''
+ . ' '
+ . '
';
+ }
+
+ /**
+ * The checks that may be offered here.
+ *
+ * @return array Check definitions keyed by name.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getAvailableFields(): array
+ {
+ $app = Factory::getApplication();
+ $extension = $this->resolveExtension($app);
+ $database = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $builtInFields = (new BuiltinConditionFields($database))->declarations($extension);
+
+ PluginHelper::importPlugin('workflow');
+
+ $event = new WorkflowConditionFieldsEvent(
+ 'onWorkflowListConditionFields',
+ ['extension' => $extension]
+ );
+
+ Factory::getContainer()->get(DispatcherInterface::class)->dispatch($event->getName(), $event);
+
+ // Union, not array_merge, so a plugin cannot replace a built-in check of the same name.
+ $fields = $builtInFields + $event->getFields();
+
+ $wantedScope = (string) $this->element['mode'] === 'filter'
+ ? WorkflowConditionFieldsEvent::SCOPE_ITEM
+ : WorkflowConditionFieldsEvent::SCOPE_MOMENT;
+
+ return array_filter(
+ $fields,
+ static fn (array $field): bool => $field['scope'] === $wantedScope
+ );
+ }
+
+ /**
+ * Works out which workflow extension the builder is being drawn for.
+ *
+ * @param \Joomla\CMS\Application\CMSApplicationInterface $app The application.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function resolveExtension($app): string
+ {
+ // Prefer the workflow row: it stores com_content.article, while the request may only carry
+ // com_content.
+ $workflowId = (int) $app->getInput()->getInt('workflow_id');
+
+ if ($workflowId > 0) {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('extension'))
+ ->from($db->quoteName('#__workflows'))
+ ->where($db->quoteName('id') . ' = :workflowId')
+ ->bind(':workflowId', $workflowId, ParameterType::INTEGER);
+
+ $extension = (string) $db->setQuery($query)->loadResult();
+
+ if ($extension !== '') {
+ return $extension;
+ }
+ }
+
+ $requestedExtension = (string) $app->getInput()->getCmd('extension', '');
+
+ return $requestedExtension !== '' ? $requestedExtension : 'com_content.article';
+ }
+
+ /**
+ * The checks the builder may offer, as value and label pairs.
+ *
+ * @param array $fields The available check definitions.
+ *
+ * @return array>
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getFieldChoices(array $fields): array
+ {
+ $choices = [];
+
+ foreach ($fields as $field) {
+ $choices[] = ['value' => $field['name'], 'label' => $field['label']];
+ }
+
+ return $choices;
+ }
+
+ /**
+ * The operators each check supports.
+ *
+ * @param array $fields The available check definitions.
+ *
+ * @return array>>
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getOperatorChoices(array $fields): array
+ {
+ $operatorLabels = [
+ WorkflowConditionFieldsEvent::OPERATOR_IS => Text::_('COM_WORKFLOW_AUTOMATION_OP_IS'),
+ WorkflowConditionFieldsEvent::OPERATOR_IS_NOT => Text::_('COM_WORKFLOW_AUTOMATION_OP_IS_NOT'),
+ WorkflowConditionFieldsEvent::OPERATOR_IN => Text::_('COM_WORKFLOW_AUTOMATION_OP_IN'),
+ WorkflowConditionFieldsEvent::OPERATOR_NOT_IN => Text::_('COM_WORKFLOW_AUTOMATION_OP_NOT_IN'),
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_ANY => Text::_('COM_WORKFLOW_AUTOMATION_OP_HAS_ANY'),
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_ALL => Text::_('COM_WORKFLOW_AUTOMATION_OP_HAS_ALL'),
+ WorkflowConditionFieldsEvent::OPERATOR_HAS_NONE => Text::_('COM_WORKFLOW_AUTOMATION_OP_HAS_NONE'),
+ WorkflowConditionFieldsEvent::OPERATOR_BEFORE => Text::_('COM_WORKFLOW_AUTOMATION_OP_BEFORE'),
+ WorkflowConditionFieldsEvent::OPERATOR_AFTER => Text::_('COM_WORKFLOW_AUTOMATION_OP_AFTER'),
+ WorkflowConditionFieldsEvent::OPERATOR_ON => Text::_('COM_WORKFLOW_AUTOMATION_OP_ON'),
+ WorkflowConditionFieldsEvent::OPERATOR_NOT_ON => Text::_('COM_WORKFLOW_AUTOMATION_OP_NOT_ON'),
+ WorkflowConditionFieldsEvent::OPERATOR_GREATER_THAN => Text::_('COM_WORKFLOW_AUTOMATION_OP_GREATER_THAN'),
+ WorkflowConditionFieldsEvent::OPERATOR_LESS_THAN => Text::_('COM_WORKFLOW_AUTOMATION_OP_LESS_THAN'),
+ ];
+
+ $choices = [];
+
+ foreach ($fields as $field) {
+ foreach ($field['operators'] as $operator) {
+ // Skipped, so a check written for a newer Joomla does not break the builder.
+ if (!isset($operatorLabels[$operator])) {
+ continue;
+ }
+
+ $choices[$field['name']][] = ['value' => $operator, 'label' => $operatorLabels[$operator]];
+ }
+ }
+
+ return $choices;
+ }
+
+ /**
+ * How each check's value is entered.
+ *
+ * @param array $fields The available check definitions.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getValueTypes(array $fields): array
+ {
+ $types = [];
+
+ foreach ($fields as $field) {
+ $types[$field['name']] = $field['valueType'];
+ }
+
+ return $types;
+ }
+
+ /**
+ * The selectable values for each check, resolved once here so the builder needs no
+ * further requests.
+ *
+ * @param array $fields The available check definitions.
+ *
+ * @return array>>
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getValueChoices(array $fields): array
+ {
+ $choices = [];
+
+ foreach ($fields as $field) {
+ if ($field['options'] !== []) {
+ $choices[$field['name']] = $field['options'];
+ }
+ }
+
+ return $choices;
+ }
+
+ /**
+ * Translated strings the builder renders in its own markup.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getInterfaceText(): array
+ {
+ $emptyKey = (string) $this->element['mode'] === 'filter'
+ ? 'COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_FILTER'
+ : 'COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_CONDITION';
+ return [
+ 'addCheck' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_ADD_CHECK'),
+ 'expertHint' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_HINT'),
+ 'expertLink' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_LINK'),
+ 'expertOffHint' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_OFF_HINT'),
+ 'expertOffLink' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_OFF_LINK'),
+ 'expertHeader' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_HEADER'),
+ 'addExpression' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_ADD_EXPRESSION'),
+ 'remove' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_REMOVE'),
+ 'check' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_CHECK'),
+ 'expression' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPRESSION'),
+ 'negate' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_NEGATE'),
+ 'joinWith' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_JOIN_WITH'),
+ 'opAnd' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_OP_AND'),
+ 'opOr' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_OP_OR'),
+ 'cancel' => Text::_('JCANCEL'),
+ 'empty' => Text::_($emptyKey),
+ 'placeholder' => Text::_('JGLOBAL_TYPE_OR_SELECT_SOME_OPTIONS'),
+ 'emptyExpression' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_EXPRESSION'),
+ 'preview' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_BUTTON'),
+ 'previewRunning' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_RUNNING'),
+ 'previewResult' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_RESULT'),
+ 'previewCapped' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_CAPPED'),
+ 'previewEmpty' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_EMPTY'),
+ 'previewShowAll' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_SHOW_ALL'),
+ 'previewListHeader' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_HEADER'),
+ 'previewListTrimmed' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_TRIMMED'),
+ 'previewListRange' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_RANGE'),
+ 'field' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_FIELD'),
+ 'operator' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_OPERATOR'),
+ 'value' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_VALUE'),
+ 'removeCheck' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_REMOVE_CHECK'),
+ 'previous' => Text::_('JPREVIOUS'),
+ 'next' => Text::_('JNEXT'),
+ 'close' => Text::_('JCLOSE'),
+ ];
+ }
+
+ /**
+ * Whether the Workflow Automation plugin is set to offer OR, sub-expressions and NOT.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function isExpertMode(): bool
+ {
+ $plugin = PluginHelper::getPlugin('workflow', 'automation');
+
+ return \is_object($plugin) && (int) (new Registry($plugin->params))->get('expert_mode', 0) === 1;
+ }
+
+ /**
+ * Where Expert Mode is switched on, for the hint the simple builder shows.
+ *
+ * @return string The plugin's settings link, or '' when this user may not open it.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function expertModeDialog(): ?array
+ {
+ if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_plugins')) {
+ return null;
+ }
+
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('extension_id'))
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote('workflow'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('automation'));
+
+ $extensionId = (int) $db->setQuery($query)->loadResult();
+
+ if ($extensionId < 1) {
+ return null;
+ }
+
+ return [
+ 'src' => Route::_(
+ 'index.php?option=com_plugins&client_id=0&task=plugin.edit&extension_id=' . $extensionId
+ . '&tmpl=component&layout=modal',
+ false
+ ),
+
+ // Opening the plugin checks it out, so closing the dialog has to check it back in.
+ 'checkinUrl' => Route::_(
+ 'index.php?option=com_plugins&task=plugins.checkin&format=json&cid[]=' . $extensionId,
+ false
+ ),
+ ];
+ }
+
+ /**
+ * What the browser needs to ask the server which items this filter matches.
+ *
+ * Null for the condition field, which answers when a rule may run rather than which items match.
+ *
+ * @return array|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getPreviewConfig(): ?array
+ {
+ if ((string) $this->element['mode'] !== 'filter') {
+ return null;
+ }
+
+ $input = Factory::getApplication()->getInput();
+
+ return [
+ 'url' => 'index.php?option=com_workflow&task=transition.previewFilter',
+ 'extension' => $input->getCmd('extension'),
+ 'workflowId' => $input->getInt('workflow_id'),
+ 'transitionId' => $input->getInt('id'),
+ 'token' => Session::getFormToken(),
+ ];
+ }
+}
diff --git a/administrator/components/com_workflow/src/Field/UpcomingtransitionField.php b/administrator/components/com_workflow/src/Field/UpcomingtransitionField.php
new file mode 100644
index 0000000000000..703c928f97307
--- /dev/null
+++ b/administrator/components/com_workflow/src/Field/UpcomingtransitionField.php
@@ -0,0 +1,182 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Field;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\Form\FormField;
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Language\Text;
+use Joomla\Component\Workflow\Administrator\Automation\RelativeTime;
+use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition;
+use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransitionsCalculator;
+use Joomla\Database\DatabaseInterface;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Read-only field that shows an item's next automated transition on its edit screen.
+ *
+ * Outputs nothing for an unsaved item or one with no automated move pending.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class UpcomingtransitionField extends FormField
+{
+ /**
+ * The form field type.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ protected $type = 'Upcomingtransition';
+
+ /**
+ * Result of buildCard(), kept so renderField() and getInput() do not run the calculator twice.
+ *
+ * @var string|null
+ * @since __DEPLOY_VERSION__
+ */
+ private $card;
+
+ /**
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function getInput()
+ {
+ if ($this->card === null) {
+ $this->card = $this->buildCard();
+ }
+
+ return $this->card;
+ }
+
+ /**
+ * Drops the label and wrapper when there is nothing pending, the way SpacerField does, so the
+ * sidebar does not show an empty row on unsaved items.
+ *
+ * @param array $options Rendering options.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function renderField($options = [])
+ {
+ return $this->getInput() === '' ? '' : parent::renderField($options);
+ }
+
+ /**
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function buildCard()
+ {
+ $itemId = (int) $this->form->getValue('id');
+ $extension = (string) $this->form->getName();
+
+ if ($itemId <= 0) {
+ return '';
+ }
+
+ Factory::getApplication()->getLanguage()->load('com_workflow', JPATH_ADMINISTRATOR);
+
+ $database = Factory::getContainer()->get(DatabaseInterface::class);
+ $calculator = new UpcomingTransitionsCalculator($database);
+ $upcoming = $calculator->forItem($itemId, $extension);
+
+ if ($upcoming === null) {
+ return '';
+ }
+
+ return $this->renderCard($upcoming);
+ }
+
+ /**
+ * Renders the block for one upcoming transition
+ *
+ * @param UpcomingTransition $upcoming The item's next move.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function renderCard(UpcomingTransition $upcoming): string
+ {
+ $fires = match ($upcoming->status) {
+ 'needs_attention' => '' . Text::_('COM_WORKFLOW_UPCOMING_STATUS_ATTENTION') . ' ',
+ 'rule_error' => '' . Text::_('COM_WORKFLOW_UPCOMING_STATUS_RULE_ERROR') . ' ',
+ 'not_scheduled' => '' . Text::_('COM_WORKFLOW_UPCOMING_STATUS_NOT_SCHEDULED') . ' ',
+ // Guarded anyway, so a future status without a fire time cannot break the edit screen.
+ default => $upcoming->firesAt === null
+ ? '' . Text::_('COM_WORKFLOW_UPCOMING_STATUS_NOT_SCHEDULED') . ' '
+ : '' . RelativeTime::until($upcoming->firesAt) . '
'
+ . '' . HTMLHelper::_('date', $upcoming->firesAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2')) . '
'
+ . ($upcoming->hasCondition
+ ? '' . Text::_('COM_WORKFLOW_UPCOMING_SUBJECT_CONDITION') . ' '
+ : ''),
+ };
+
+ // Shown whatever the status: the fault may belong to another rule on this stage.
+ if ($upcoming->failureReason !== '') {
+ $fires .= ''
+ . ' '
+ . htmlspecialchars($upcoming->failureReason, ENT_QUOTES, 'UTF-8')
+ . '
';
+
+ if ($upcoming->failedAt !== null) {
+ $fires .= ''
+ . Text::sprintf(
+ 'COM_WORKFLOW_UPCOMING_LAST_FAILED',
+ HTMLHelper::_('date', $upcoming->failedAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2'))
+ )
+ . '
';
+ }
+ }
+
+ if ($upcoming->ruleType === 'cron') {
+ $trigger = Text::sprintf(
+ 'COM_WORKFLOW_UPCOMING_TRIGGER_CRON',
+ '' . htmlspecialchars((string) $upcoming->cronExpression, ENT_QUOTES, 'UTF-8') . ''
+ );
+ } else {
+ $unitKeys = [
+ 'minutes' => 'COM_WORKFLOW_AUTOMATION_UNIT_MINUTES',
+ 'hours' => 'COM_WORKFLOW_AUTOMATION_UNIT_HOURS',
+ 'days' => 'COM_WORKFLOW_AUTOMATION_UNIT_DAYS',
+ 'months' => 'COM_WORKFLOW_AUTOMATION_UNIT_MONTHS',
+ ];
+ $unitKey = $unitKeys[$upcoming->delayUnit] ?? '';
+ $unit = $unitKey !== '' ? Text::_($unitKey) : (string) $upcoming->delayUnit;
+ $trigger = Text::sprintf('COM_WORKFLOW_UPCOMING_TRIGGER_DELAY', (int) $upcoming->delayValue, $unit);
+ }
+
+ $arrow = Factory::getApplication()->getLanguage()->isRtl() ? 'arrow-left' : 'arrow-right';
+ return ''
+ . '
'
+ . '
' . Text::_('COM_WORKFLOW_UPCOMING_ARTICLE_LABEL') . ' '
+ . '
' . htmlspecialchars(Text::_($upcoming->transitionTitle), ENT_QUOTES, 'UTF-8') . '
'
+ . ($upcoming->fromStage !== $upcoming->toStage
+ ? '
'
+ . '' . htmlspecialchars(Text::_($upcoming->fromStage), ENT_QUOTES, 'UTF-8') . ' '
+ . ' '
+ . '' . htmlspecialchars(Text::_($upcoming->toStage), ENT_QUOTES, 'UTF-8') . ' '
+ . '
'
+ : '')
+ . '
' . $fires . '
'
+ . '
' . $trigger . '
'
+ . '
';
+ }
+}
diff --git a/administrator/components/com_workflow/src/Model/LogsModel.php b/administrator/components/com_workflow/src/Model/LogsModel.php
new file mode 100644
index 0000000000000..338c8f4cb5ea6
--- /dev/null
+++ b/administrator/components/com_workflow/src/Model/LogsModel.php
@@ -0,0 +1,217 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Model;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
+use Joomla\CMS\MVC\Model\ListModel;
+use Joomla\Component\Workflow\Administrator\Automation\ItemStorage;
+use Joomla\Database\ParameterType;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Model for the automation execution log (read-only list).
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class LogsModel extends ListModel
+{
+ public function __construct($config = [], ?MVCFactoryInterface $factory = null)
+ {
+ if (empty($config['filter_fields'])) {
+ $config['filter_fields'] = [
+ 'id',
+ 'l.id',
+ 'item_id',
+ 'l.item_id',
+ 'transition_id',
+ 'l.transition_id',
+ 'exit_code',
+ 'l.exit_code',
+ 'executed_at',
+ 'l.executed_at',
+ 'run_as_user_id',
+ 'l.run_as_user_id',
+ ];
+ }
+
+ parent::__construct($config, $factory);
+ }
+
+ protected function populateState($ordering = 'l.executed_at', $direction = 'DESC')
+ {
+ /** @var \Joomla\CMS\Application\AdministratorApplication $app */
+ $app = Factory::getApplication();
+ $extension = $app->getUserStateFromRequest($this->context . '.filter.extension', 'extension', null, 'cmd');
+ $this->setState('filter.extension', $extension);
+
+ // filter.search and filter.exit_code are auto-populated from filter_logs.xml.
+ parent::populateState($ordering, $direction);
+ }
+
+ protected function getStoreId($id = '')
+ {
+ $id .= ':' . $this->getState('filter.extension');
+ $id .= ':' . $this->getState('filter.search');
+ $id .= ':' . $this->getState('filter.exit_code');
+
+ return parent::getStoreId($id);
+ }
+
+ public function getListQuery()
+ {
+ $db = $this->getDatabase();
+ $automationLogQuery = $db->createQuery();
+
+ $automationLogQuery->select(
+ [
+ $db->quoteName('l.id'),
+ $db->quoteName('l.item_id'),
+ $db->quoteName('l.extension'),
+ $db->quoteName('l.transition_id'),
+ $db->quoteName('t.workflow_id'),
+ $db->quoteName('l.from_stage_id'),
+ $db->quoteName('l.to_stage_id'),
+ $db->quoteName('l.run_as_user_id'),
+ $db->quoteName('l.trigger_type'),
+ $db->quoteName('l.exit_code'),
+ $db->quoteName('l.note'),
+ $db->quoteName('l.executed_at'),
+ $db->quoteName('t.title', 'transition_title'),
+ $db->quoteName('f_stage.title', 'from_stage'),
+ $db->quoteName('t_stage.title', 'to_stage'),
+ $db->quoteName('u.name', 'run_as_name'),
+ $db->quoteName('s.requires_intervention'),
+ ]
+ )
+ ->from($db->quoteName('#__workflow_automation_log', 'l'))
+ ->join('LEFT', $db->quoteName('#__workflow_transitions', 't'), $db->quoteName('t.id') . ' = ' . $db->quoteName('l.transition_id'))
+ ->join('LEFT', $db->quoteName('#__workflow_stages', 'f_stage'), $db->quoteName('f_stage.id') . ' = ' . $db->quoteName('l.from_stage_id'))
+ ->join('LEFT', $db->quoteName('#__workflow_stages', 't_stage'), $db->quoteName('t_stage.id') . ' = ' . $db->quoteName('l.to_stage_id'))
+ ->join('LEFT', $db->quoteName('#__users', 'u'), $db->quoteName('u.id') . ' = ' . $db->quoteName('l.run_as_user_id'))
+ ->join('LEFT', $db->quoteName('#__workflow_item_state', 's'), $db->quoteName('s.item_id') . ' = ' . $db->quoteName('l.item_id') . ' AND ' . $db->quoteName('s.extension') . ' = ' . $db->quoteName('l.extension'));
+
+ if ($extension = (string) $this->getState('filter.extension')) {
+ $automationLogQuery->where($db->quoteName('l.extension') . ' = :extension')
+ ->bind(':extension', $extension);
+ }
+
+ $result = (string) $this->getState('filter.exit_code');
+
+ if ($result === 'ok') {
+ $automationLogQuery->where($db->quoteName('l.exit_code') . ' = 0');
+ } elseif ($result === 'failed') {
+ $automationLogQuery->where($db->quoteName('l.exit_code') . ' NOT IN (0, 4)');
+ } elseif ($result === 'skipped') {
+ $automationLogQuery->where($db->quoteName('l.exit_code') . ' = 4');
+ }
+
+ $search = $this->getState('filter.search');
+
+ if (!empty($search)) {
+ if (is_numeric($search)) {
+ $itemId = (int) $search;
+ $automationLogQuery->where($db->quoteName('l.item_id') . ' = :itemId')
+ ->bind(':itemId', $itemId, ParameterType::INTEGER);
+ } else {
+ $search = '%' . str_replace(' ', '%', trim($search)) . '%';
+
+ $titleLocation = $extension !== ''
+ ? (new ItemStorage($db))->titleLocation($extension)
+ : null;
+
+ if ($titleLocation !== null) {
+ $automationLogQuery->where(
+ '(' . $db->quoteName('l.note') . ' LIKE :search'
+ . ' OR ' . $db->quoteName('l.item_id') . ' IN ('
+ . 'SELECT ' . $db->quoteName($titleLocation['key'])
+ . ' FROM ' . $db->quoteName($titleLocation['table'])
+ . ' WHERE ' . $db->quoteName($titleLocation['titleColumn']) . ' LIKE :titleSearch'
+ . '))'
+ )
+ ->bind(':search', $search)
+ ->bind(':titleSearch', $search);
+ } else {
+ // Without a title table to search, only the note can match.
+ $automationLogQuery->where($db->quoteName('l.note') . ' LIKE :search')
+ ->bind(':search', $search);
+ }
+ }
+ }
+
+ $orderColumn = $this->state->get('list.ordering', 'l.executed_at');
+ $orderDirection = strtoupper($this->state->get('list.direction', 'DESC'));
+
+ if (!empty($orderColumn)) {
+ $automationLogQuery->order($db->quoteName($db->escape($orderColumn)) . ' ' . $db->escape($orderDirection));
+ }
+
+ // Ties on executed_at would otherwise sort unpredictably, which breaks LIMIT/OFFSET paging.
+ if ($orderColumn !== 'l.id') {
+ $automationLogQuery->order($db->quoteName('l.id') . ' ' . $db->escape($orderDirection));
+ }
+
+ return $automationLogQuery;
+ }
+
+ /**
+ * Adds each row's item title, which no join can supply.
+ *
+ * @return object[]|false
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getItems()
+ {
+ $items = parent::getItems();
+
+ if (empty($items)) {
+ return $items;
+ }
+
+ return (new ItemStorage($this->getDatabase()))->annotateTitles($items);
+ }
+
+ /**
+ * Clears the requires_intervention flag for an item so the scheduler retries it.
+ *
+ * Checks the permission itself, so it is safe to call from anywhere, not only the controller.
+ *
+ * @param integer $itemId The content item id.
+ * @param string $extension The workflow extension, e.g. com_content.article.
+ *
+ * @return boolean True when the flag was cleared, false when the current user may not.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function clearIntervention(int $itemId, string $extension): bool
+ {
+ if (!$this->getCurrentUser()->authorise('core.admin', 'com_workflow')) {
+ return false;
+ }
+
+ $db = $this->getDatabase();
+ $clearInterventionQuery = $db->getQuery(true)
+ ->update($db->quoteName('#__workflow_item_state'))
+ ->set($db->quoteName('requires_intervention') . ' = 0')
+ ->where($db->quoteName('item_id') . ' = :itemId')
+ ->where($db->quoteName('extension') . ' = :extension')
+ ->bind(':itemId', $itemId, ParameterType::INTEGER)
+ ->bind(':extension', $extension, ParameterType::STRING);
+
+ $db->setQuery($clearInterventionQuery)->execute();
+
+ return true;
+ }
+}
diff --git a/administrator/components/com_workflow/src/Model/TransitionModel.php b/administrator/components/com_workflow/src/Model/TransitionModel.php
index 448625d4dc658..9c74d6f02c0a7 100644
--- a/administrator/components/com_workflow/src/Model/TransitionModel.php
+++ b/administrator/components/com_workflow/src/Model/TransitionModel.php
@@ -13,8 +13,13 @@
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\User\UserFactoryInterface;
+use Joomla\Database\ParameterType;
use Joomla\Registry\Registry;
use Joomla\String\StringHelper;
@@ -118,6 +123,36 @@ public function getItem($pk = null)
$item->options = $registry->toArray();
}
+ if (!empty($item->id)) {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName([
+ 'rule_type',
+ 'delay_value',
+ 'delay_unit',
+ 'cron_expression',
+ 'run_as_user_id',
+ 'item_filter',
+ 'fire_condition',
+ ]))
+ ->from($db->quoteName('#__workflow_automation_rules'))
+ ->where($db->quoteName('transition_id') . ' = :id')
+ ->bind(':id', $item->id, ParameterType::INTEGER)
+ ->setLimit(1);
+
+ $rule = $db->setQuery($query)->loadAssoc();
+
+ if ($rule) {
+ $item->automation = [
+ 'automation_enabled' => 1,
+ 'run_as_user_id' => (int) ($rule['run_as_user_id'] ?? 0),
+ 'automation_rules' => $rule,
+ ];
+ } else {
+ $item->automation = ['automation_enabled' => 0, 'run_as_user_id' => (int) $this->getCurrentUser()->id, 'automation_rules' => []];
+ }
+ }
+
return $item;
}
@@ -132,6 +167,18 @@ public function getItem($pk = null)
*/
public function save($data)
{
+ // Switching automation off saves an empty rule, which deletes the stored one.
+ $automationData = $data['automation'] ?? [];
+ $automationEnabled = !empty($automationData['automation_enabled']);
+ $automationRule = $automationEnabled ? ($automationData['automation_rules'] ?? []) : [];
+ unset($data['automation']);
+
+ if (!empty($automationRule)) {
+ $automationRule['run_as_user_id'] = (int) ($automationData['run_as_user_id'] ?? 0);
+ }
+
+ $transitionId = (int) ($data['id'] ?? $this->getState($this->getName() . '.id'));
+
$table = $this->getTable();
$context = $this->option . '.' . $this->name;
$app = Factory::getApplication();
@@ -178,7 +225,31 @@ public function save($data)
$data['published'] = 0;
}
- return parent::save($data);
+ if (!parent::save($data)) {
+ return false;
+ }
+
+ $pk = (int) $this->getState($this->getName() . '.id');
+
+ try {
+ $this->saveAutomationRule($pk, $automationRule);
+ } catch (\Throwable $error) {
+ // The transition itself saved and the old rule survived the rollback, so this reports
+ // what failed rather than claiming the whole save did.
+ Factory::getApplication()->enqueueMessage(
+ Text::sprintf('COM_WORKFLOW_AUTOMATION_RULE_SAVE_FAILED', $error->getMessage()),
+ 'error'
+ );
+ }
+ if ($automationEnabled && !$this->canExecuteTransition((int) ($automationRule['run_as_user_id'] ?? 0), $pk)) {
+ // Only a warning, because the permission is often granted after the rule is written.
+ Factory::getApplication()->enqueueMessage(
+ Text::_('COM_WORKFLOW_AUTOMATION_WARNING_RUN_AS_CANNOT_EXECUTE'),
+ 'warning'
+ );
+ }
+
+ return true;
}
/**
@@ -325,9 +396,215 @@ protected function preprocessForm(Form $form, $data, $group = 'content')
// Set the access control rules field component value.
$form->setFieldAttribute('rules', 'component', $extension);
+ $user = $this->getCurrentUser();
+
+ // Anyone but a super user may only keep the run-as user already saved or pick themselves, so the
+ // picker becomes a list of those two. Changing the type in place keeps the field where it is.
+ if (!$user->authorise('core.admin')) {
+ $choices = array_unique([
+ $this->storedRunAsUserId((int) $this->getState($this->getName() . '.id')),
+ (int) $user->id,
+ ]);
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['id', 'name']))
+ ->from($db->quoteName('#__users'))
+ ->where($db->quoteName('id') . ' IN (' . implode(',', $choices) . ')')
+ ->order($db->quoteName('name'));
+
+ $form->setFieldAttribute('run_as_user_id', 'type', 'sql', 'automation');
+ $form->setFieldAttribute('run_as_user_id', 'key_field', 'id', 'automation');
+ $form->setFieldAttribute('run_as_user_id', 'value_field', 'name', 'automation');
+ $form->setFieldAttribute('run_as_user_id', 'query', (string) $query, 'automation');
+ }
+
+ // The Automation fieldset lives in this form rather than in the plugin that implements it,
+ // so it has to be taken out when that plugin is off. Otherwise rules can be written and
+ // saved that nothing will ever execute.
+ if (!PluginHelper::isEnabled('workflow', 'automation')) {
+ $form->removeGroup('automation');
+ } elseif (!PluginHelper::isEnabled('task', 'workflowtransition')) {
+ // Here the rules are still valid, they simply have nothing to run them, so this warns
+ // rather than hiding work the user has already done.
+ Factory::getApplication()->enqueueMessage(
+ Text::sprintf(
+ 'COM_WORKFLOW_AUTOMATION_WARNING_TASK_PLUGIN_DISABLED',
+ $this->taskPluginLink()
+ ),
+ 'warning'
+ );
+ }
+
// Import the appropriate plugin group.
PluginHelper::importPlugin('workflow');
parent::preprocessForm($form, $data, $group);
}
+
+ /**
+ * A link that opens the automation task plugin in a dialog, for the warning shown when it is off.
+ *
+ * Falls back to plain text for a user who may not edit plugins, so the sentence still reads.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function taskPluginLink(): string
+ {
+ $label = Text::_('COM_WORKFLOW_AUTOMATION_TASK_PLUGIN');
+ /** @var \Joomla\CMS\Application\CMSWebApplicationInterface $app */
+ $app = Factory::getApplication();
+
+ if (!$app->getIdentity()->authorise('core.manage', 'com_plugins')) {
+ return $label;
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('extension_id'))
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote('task'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('workflowtransition'));
+
+ $extensionId = (int) $db->setQuery($query)->loadResult();
+
+ if ($extensionId < 1) {
+ return $label;
+ }
+
+ // The dialog script is loaded by the condition builder too, but the warning can appear
+ // before any builder renders, so it is asked for here as well.
+ $app->getDocument()->getWebAssetManager()->useScript('joomla.dialog-autocreate');
+
+ $popup = [
+ 'popupType' => 'iframe',
+ 'textHeader' => $label,
+ 'src' => Route::_(
+ 'index.php?option=com_plugins&client_id=0&task=plugin.edit&extension_id=' . $extensionId
+ . '&tmpl=component&layout=modal',
+ false
+ ),
+ ];
+
+ return HTMLHelper::_('link', '#', $label, [
+ 'data-joomla-dialog' => htmlspecialchars(
+ json_encode($popup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ ENT_QUOTES,
+ 'UTF-8'
+ ),
+ 'data-checkin-url' => Route::_('index.php?option=com_plugins&task=plugins.checkin&format=json&cid[]=' . $extensionId, false),
+ 'data-close-on-message' => '',
+ 'data-reload-on-close' => '',
+ ]);
+ }
+
+ /**
+ * Persists the automation rule for a transition, replacing whatever was there.
+ *
+ * @param integer $transitionId The transition id.
+ * @param array $automationRule The submitted rule, or empty to clear.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function saveAutomationRule(int $transitionId, array $automationRule): void
+ {
+ $db = $this->getDatabase();
+ $user = Factory::getApplication()->getIdentity();
+ $now = Factory::getDate()->toSql();
+
+ try {
+ $db->transactionStart();
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__workflow_automation_rules'))
+ ->where($db->quoteName('transition_id') . ' = :id')
+ ->bind(':id', $transitionId, ParameterType::INTEGER)
+ )->execute();
+
+ if (!empty($automationRule)) {
+ $ruleType = ($automationRule['rule_type'] ?? 'delay') === 'cron' ? 'cron' : 'delay';
+
+ $ruleRow = (object) [
+ 'transition_id' => $transitionId,
+ 'published' => 1,
+ 'ordering' => 0,
+ 'rule_type' => $ruleType,
+ 'delay_value' => $ruleType === 'delay' ? (int) ($automationRule['delay_value'] ?? 0) : 0,
+ 'delay_unit' => $ruleType === 'delay' ? ($automationRule['delay_unit'] ?? 'minutes') : 'minutes',
+ 'cron_expression' => $ruleType === 'cron' ? ($automationRule['cron_expression'] ?? '') : '',
+ 'run_as_user_id' => (int) ($automationRule['run_as_user_id'] ?? 0),
+ 'item_filter' => ($automationRule['item_filter'] ?? '') !== '' ? $automationRule['item_filter'] : null,
+ 'fire_condition' => ($automationRule['fire_condition'] ?? '') !== '' ? $automationRule['fire_condition'] : null,
+ 'created' => $now,
+ 'created_by' => $user->id,
+ 'modified' => $now,
+ 'modified_by' => $user->id,
+ ];
+
+ $db->insertObject('#__workflow_automation_rules', $ruleRow);
+ }
+
+ $db->transactionCommit();
+ } catch (\Throwable $error) {
+ $db->transactionRollback();
+
+ throw $error;
+ }
+ }
+
+ /**
+ * The run-as user already stored for this transition, or 0 when there is no rule yet.
+ *
+ * @param integer $transitionId The transition being saved.
+ *
+ * @return integer
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function storedRunAsUserId(int $transitionId): int
+ {
+ if ($transitionId <= 0) {
+ return 0;
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('run_as_user_id'))
+ ->from($db->quoteName('#__workflow_automation_rules'))
+ ->where($db->quoteName('transition_id') . ' = :transitionId')
+ ->bind(':transitionId', $transitionId, ParameterType::INTEGER);
+
+ return (int) $db->setQuery($query)->loadResult();
+ }
+
+ /**
+ * Whether an account holds the permission the scheduler will need at run time.
+ *
+ * Asks the same question that Workflow::getValidTransition() asks at run time.
+ *
+ * @param integer $userId The run-as account.
+ * @param integer $transitionId The transition it would execute.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function canExecuteTransition(int $userId, int $transitionId): bool
+ {
+ $runAsUser = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
+
+ if ((int) $runAsUser->id !== $userId) {
+ return false;
+ }
+
+ $parts = explode('.', (string) Factory::getApplication()->getInput()->get('extension'));
+
+ return $runAsUser->authorise('core.execute.transition', array_shift($parts) . '.transition.' . $transitionId);
+ }
}
diff --git a/administrator/components/com_workflow/src/Model/TransitionsModel.php b/administrator/components/com_workflow/src/Model/TransitionsModel.php
index 68c8349e55c0a..c274d1550fdd2 100644
--- a/administrator/components/com_workflow/src/Model/TransitionsModel.php
+++ b/administrator/components/com_workflow/src/Model/TransitionsModel.php
@@ -156,6 +156,13 @@ public function getListQuery()
->join('LEFT', $db->quoteName('#__workflow_stages', 't_stage'), $db->quoteName('t_stage.id') . ' = ' . $db->quoteName('t.to_stage_id'))
->join('LEFT', $db->quoteName('#__users', 'uc'), $db->quoteName('uc.id') . ' = ' . $db->quoteName('t.checked_out'));
+ $query->select(
+ '(SELECT MAX(' . $db->quoteName('war.published') . ')'
+ . ' FROM ' . $db->quoteName('#__workflow_automation_rules', 'war')
+ . ' WHERE ' . $db->quoteName('war.transition_id') . ' = ' . $db->quoteName('t.id') . ')'
+ . ' AS ' . $db->quoteName('automated')
+ );
+
// Filter by extension
if ($workflowID = (int) $this->getState('filter.workflow_id')) {
$query->where($db->quoteName('t.workflow_id') . ' = :id')
diff --git a/administrator/components/com_workflow/src/Model/UpcomingModel.php b/administrator/components/com_workflow/src/Model/UpcomingModel.php
new file mode 100644
index 0000000000000..2b8ab352f94e3
--- /dev/null
+++ b/administrator/components/com_workflow/src/Model/UpcomingModel.php
@@ -0,0 +1,195 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Model;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\Form\Form;
+use Joomla\CMS\MVC\Model\ListModel;
+use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransitionsCalculator;
+use Joomla\Database\ParameterType;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Model for the extension-wide upcoming automated transitions view.
+ *
+ * A list model for the paging and the filter bar, but the rows are computed rather than queried,
+ * so getItems() and getTotal() do their own work instead of going through getListQuery().
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class UpcomingModel extends ListModel
+{
+ /**
+ * Shared by the page and the count, so both see one instance.
+ *
+ * @var UpcomingTransitionsCalculator|null
+ * @since __DEPLOY_VERSION__
+ */
+ private ?UpcomingTransitionsCalculator $calculator = null;
+
+ /**
+ * Auto-populate the model state from the request.
+ *
+ * @param string $ordering Unused: the order is fixed, attention first then longest waiting.
+ * @param string $direction Unused, for the same reason.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function populateState($ordering = null, $direction = null)
+ {
+ $extension = Factory::getApplication()->getUserStateFromRequest(
+ $this->context . '.filter.extension',
+ 'extension',
+ 'com_content.article',
+ 'cmd'
+ );
+
+ $this->setState('filter.extension', $extension);
+
+ // list.limit, list.start, list.workflow_id and list.stage_id all arrive through the parent.
+ parent::populateState($ordering, $direction);
+ }
+
+ /**
+ * The filter form, with its workflow and stage lists narrowed to what can be chosen.
+ *
+ * @param array $data Data for the form.
+ * @param boolean $loadData Whether to load the form data.
+ *
+ * @return Form|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getFilterForm($data = [], $loadData = true)
+ {
+ $form = parent::getFilterForm($data, $loadData);
+
+ if ($form instanceof Form) {
+ $db = $this->getDatabase();
+ $extension = $db->quote((string) $this->getState('filter.extension'));
+ $workflowId = (int) $this->getState('list.workflow_id');
+
+ // Narrowed here rather than in the XML, because the extension is only known at runtime.
+ $form->setFieldAttribute(
+ 'workflow_id',
+ 'sql_where',
+ $db->quoteName('published') . ' = 1 AND ' . $db->quoteName('extension') . ' = ' . $extension,
+ 'list'
+ );
+
+ // The chosen workflow's stages, or every stage in the extension while no workflow is chosen.
+ $form->setFieldAttribute(
+ 'stage_id',
+ 'sql_where',
+ $db->quoteName('s.published') . ' = 1 AND ' . $db->quoteName('w.extension') . ' = ' . $extension
+ . ($workflowId > 0 ? ' AND ' . $db->quoteName('s.workflow_id') . ' = ' . $workflowId : ''),
+ 'list'
+ );
+ }
+
+ return $form;
+ }
+
+ /**
+ * Returns one page of upcoming automated transitions.
+ *
+ * @return \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getItems(): array
+ {
+ $extension = (string) $this->getState('filter.extension');
+
+ if ($extension === '') {
+ return [];
+ }
+
+ $limit = (int) $this->getState('list.limit');
+ $start = (int) $this->getStart();
+ $workflowId = (int) $this->getState('list.workflow_id');
+ $stageId = $this->getStageFilter();
+
+ return $workflowId > 0
+ ? $this->getCalculator()->forWorkflow($workflowId, $limit, $start, $stageId)
+ : $this->getCalculator()->forExtension($extension, $limit, $start, $stageId);
+ }
+
+ /**
+ * The number of items the list can page through.
+ *
+ * @return integer
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getTotal(): int
+ {
+ $extension = (string) $this->getState('filter.extension');
+
+ if ($extension === '') {
+ return 0;
+ }
+
+ $workflowId = (int) $this->getState('list.workflow_id');
+ $stageId = $this->getStageFilter();
+
+ return $workflowId > 0
+ ? $this->getCalculator()->countForWorkflow($workflowId, $stageId)
+ : $this->getCalculator()->countForExtension($extension, $stageId);
+ }
+
+ /**
+ * The stage to filter by, or 0 for any stage.
+ *
+ * A stage that does not belong to the chosen workflow is ignored. It is left behind when the
+ * workflow filter changes, and the stage list no longer offers it, so honouring it would show an
+ * empty list with nothing on screen to explain why.
+ *
+ * @return integer
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getStageFilter(): int
+ {
+ $stageId = (int) $this->getState('list.stage_id');
+ $workflowId = (int) $this->getState('list.workflow_id');
+
+ if ($stageId <= 0 || $workflowId <= 0) {
+ return max($stageId, 0);
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select('1')
+ ->from($db->quoteName('#__workflow_stages'))
+ ->where($db->quoteName('id') . ' = :stageId')
+ ->where($db->quoteName('workflow_id') . ' = :workflowId')
+ ->bind(':stageId', $stageId, ParameterType::INTEGER)
+ ->bind(':workflowId', $workflowId, ParameterType::INTEGER);
+
+ return $db->setQuery($query)->loadResult() ? $stageId : 0;
+ }
+
+ /**
+ * @return UpcomingTransitionsCalculator
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function getCalculator(): UpcomingTransitionsCalculator
+ {
+ return $this->calculator ??= new UpcomingTransitionsCalculator($this->getDatabase());
+ }
+}
diff --git a/administrator/components/com_workflow/src/Model/WorkflowModel.php b/administrator/components/com_workflow/src/Model/WorkflowModel.php
index 9ca07c065f60f..d937b36106305 100644
--- a/administrator/components/com_workflow/src/Model/WorkflowModel.php
+++ b/administrator/components/com_workflow/src/Model/WorkflowModel.php
@@ -16,6 +16,9 @@
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Model\AdminModel;
+use Joomla\Component\Workflow\Administrator\Automation\ItemStorage;
+use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransitionsCalculator;
+use Joomla\Database\ParameterType;
use Joomla\String\StringHelper;
// phpcs:disable PSR1.Files.SideEffects
@@ -49,6 +52,89 @@ public function populateState()
$this->setState('filter.extension', $extension);
}
+ /**
+ * Returns this workflow's upcoming automated transitions, computed on the fly.
+ *
+ * @param integer $workflowId The workflow id.
+ *
+ * @return \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getUpcomingTransitions(int $workflowId): array
+ {
+ if ($workflowId <= 0) {
+ return [];
+ }
+
+ $calculator = new UpcomingTransitionsCalculator($this->getDatabase());
+
+ return $calculator->forWorkflow($workflowId);
+ }
+
+ /**
+ * Returns the most recent automation log entries for this workflow.
+ *
+ * @param integer $workflowId The workflow id.
+ * @param integer $limit How many entries to return, newest first.
+ *
+ * @return object[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getAutomationLog(int $workflowId, int $limit = 20): array
+ {
+ if ($workflowId <= 0) {
+ return [];
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select(
+ [
+ $db->quoteName('l.item_id'),
+ $db->quoteName('l.transition_id'),
+ $db->quoteName('l.extension'),
+ $db->quoteName('l.exit_code'),
+ $db->quoteName('l.note'),
+ $db->quoteName('l.executed_at'),
+ $db->quoteName('t.title', 'transition_title'),
+ $db->quoteName('t.workflow_id'),
+ $db->quoteName('f_stage.title', 'from_stage'),
+ $db->quoteName('t_stage.title', 'to_stage'),
+ $db->quoteName('u.name', 'run_as_name'),
+ ]
+ )
+ ->from($db->quoteName('#__workflow_automation_log', 'l'))
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflow_transitions', 't'),
+ $db->quoteName('t.id') . ' = ' . $db->quoteName('l.transition_id')
+ )
+ ->join(
+ 'LEFT',
+ $db->quoteName('#__workflow_stages', 'f_stage'),
+ $db->quoteName('f_stage.id') . ' = ' . $db->quoteName('l.from_stage_id')
+ )
+ ->join(
+ 'LEFT',
+ $db->quoteName('#__workflow_stages', 't_stage'),
+ $db->quoteName('t_stage.id') . ' = ' . $db->quoteName('l.to_stage_id')
+ )
+ ->join(
+ 'LEFT',
+ $db->quoteName('#__users', 'u'),
+ $db->quoteName('u.id') . ' = ' . $db->quoteName('l.run_as_user_id')
+ )
+ ->where($db->quoteName('t.workflow_id') . ' = :workflowId')
+ ->bind(':workflowId', $workflowId, ParameterType::INTEGER)
+ ->order($db->quoteName('l.executed_at') . ' DESC');
+
+ $entries = $db->setQuery($query, 0, $limit)->loadObjectList() ?: [];
+
+ return (new ItemStorage($db))->annotateTitles($entries);
+ }
+
/**
* Method to change the title
*
@@ -288,8 +374,8 @@ public function setDefault($pk, $value = 1)
if (
$table->load(
[
- 'default' => '1',
- 'extension' => $table->extension,
+ 'default' => '1',
+ 'extension' => $table->extension,
]
)
) {
diff --git a/administrator/components/com_workflow/src/Rule/AutomationRule.php b/administrator/components/com_workflow/src/Rule/AutomationRule.php
new file mode 100644
index 0000000000000..bb432676ee77c
--- /dev/null
+++ b/administrator/components/com_workflow/src/Rule/AutomationRule.php
@@ -0,0 +1,124 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Rule;
+
+use Cron\CronExpression;
+use Joomla\CMS\Form\Form;
+use Joomla\CMS\Form\FormRule;
+use Joomla\CMS\Language\Text;
+use Joomla\Registry\Registry;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Validates the whole automation rule at once.
+ *
+ * The fields live in a subform, and core's SubformRule returns only the first error its child form
+ * produced, so validating them individually can report one problem per save however many there are.
+ * Checking the row here means every problem is named in one message.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class AutomationRule extends FormRule
+{
+ /**
+ * @param \SimpleXMLElement $element The field's XML definition.
+ * @param mixed $value The submitted automation row.
+ * @param ?string $group The field's group.
+ * @param ?Registry $input Every value submitted with the form.
+ * @param ?Form $form The form being validated.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function test(\SimpleXMLElement $element, $value, $group = null, ?Registry $input = null, ?Form $form = null): bool
+ {
+ $row = (array) $value;
+
+ if ($row === []) {
+ return true;
+ }
+
+ $problems = [];
+ $ruleType = $row['rule_type'] ?? 'delay';
+
+ if ($ruleType === 'cron') {
+ $expression = trim((string) ($row['cron_expression'] ?? ''));
+
+ if ($expression === '' || !CronExpression::isValidExpression($expression)) {
+ $problems[] = Text::_('COM_WORKFLOW_AUTOMATION_ERROR_CRON');
+ }
+ } elseif ((int) ($row['delay_value'] ?? 0) < 0) {
+ $problems[] = Text::_('COM_WORKFLOW_AUTOMATION_ERROR_DELAY_VALUE');
+ }
+
+ $builders = [
+ 'item_filter' => 'COM_WORKFLOW_AUTOMATION_FILTER_LABEL',
+ 'fire_condition' => 'COM_WORKFLOW_AUTOMATION_CONDITION_LABEL',
+ ];
+
+ $incomplete = [];
+
+ foreach ($builders as $key => $label) {
+ if ($this->hasIncompleteCheck(json_decode((string) ($row[$key] ?? ''), true))) {
+ $incomplete[] = Text::_($label);
+ }
+ }
+
+ if (\count($incomplete) === 2) {
+ $problems[] = Text::sprintf('COM_WORKFLOW_AUTOMATION_ERROR_INCOMPLETE_CHECKS', $incomplete[0], $incomplete[1]);
+ } elseif ($incomplete !== []) {
+ $problems[] = Text::sprintf('COM_WORKFLOW_AUTOMATION_ERROR_INCOMPLETE_CHECK', $incomplete[0]);
+ }
+
+ if ($problems === []) {
+ return true;
+ }
+
+ throw new \RuntimeException(implode(' ', $problems));
+ }
+
+ /**
+ * Whether an expression holds a check with no field, operator or value.
+ *
+ * @param mixed $node A decoded expression or check, or null when there is none.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function hasIncompleteCheck(mixed $node): bool
+ {
+ if (!\is_array($node)) {
+ return false;
+ }
+
+ if (\array_key_exists('items', $node)) {
+ foreach ((array) $node['items'] as $child) {
+ if ($this->hasIncompleteCheck($child)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ $value = $node['value'] ?? '';
+
+ return ($node['field'] ?? '') === ''
+ || ($node['operator'] ?? '') === ''
+ || $value === ''
+ || $value === [];
+ }
+}
diff --git a/administrator/components/com_workflow/src/Rule/RunAsUserRule.php b/administrator/components/com_workflow/src/Rule/RunAsUserRule.php
new file mode 100644
index 0000000000000..49d32acaac6f1
--- /dev/null
+++ b/administrator/components/com_workflow/src/Rule/RunAsUserRule.php
@@ -0,0 +1,97 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\Rule;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\Form\Form;
+use Joomla\CMS\Form\FormRule;
+use Joomla\CMS\Language\Text;
+use Joomla\Database\DatabaseAwareInterface;
+use Joomla\Database\DatabaseAwareTrait;
+use Joomla\Database\ParameterType;
+use Joomla\Registry\Registry;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Decides whether the current user may make an automation rule run as the chosen account.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class RunAsUserRule extends FormRule implements DatabaseAwareInterface
+{
+ use DatabaseAwareTrait;
+
+ /**
+ * @param \SimpleXMLElement $element The field's XML definition.
+ * @param mixed $value The chosen user id.
+ * @param ?string $group The field's group.
+ * @param ?Registry $input Every value submitted with the form.
+ * @param ?Form $form The form being validated.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function test(\SimpleXMLElement $element, $value, $group = null, ?Registry $input = null, ?Form $form = null): bool
+ {
+ if ($input === null || (int) $input->get('automation.automation_enabled', 0) !== 1) {
+ return true;
+ }
+
+ $candidate = (int) $value;
+
+ if ($candidate === 0) {
+ throw new \RuntimeException(Text::_('COM_WORKFLOW_AUTOMATION_ERROR_NO_RUN_AS'));
+ }
+
+ $user = Factory::getApplication()->getIdentity();
+
+ // A super user may choose anyone. Anyone else may choose themselves, or keep the account
+ // the rule already runs as, so an existing rule survives being edited by a lesser user.
+ if ($user->authorise('core.admin') || $candidate === (int) $user->id) {
+ return true;
+ }
+
+ if ($candidate === $this->storedRunAsUserId((int) $input->get('id', 0))) {
+ return true;
+ }
+
+ throw new \RuntimeException(Text::_('COM_WORKFLOW_AUTOMATION_ERROR_RUN_AS_NOT_ALLOWED'));
+ }
+
+ /**
+ * The run-as user already stored for this transition, or 0 when there is no rule yet.
+ *
+ * @param integer $transitionId The transition being saved.
+ *
+ * @return integer
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function storedRunAsUserId(int $transitionId): int
+ {
+ if ($transitionId <= 0) {
+ return 0;
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('run_as_user_id'))
+ ->from($db->quoteName('#__workflow_automation_rules'))
+ ->where($db->quoteName('transition_id') . ' = :transitionId')
+ ->bind(':transitionId', $transitionId, ParameterType::INTEGER);
+
+ return (int) $db->setQuery($query)->loadResult();
+ }
+}
diff --git a/administrator/components/com_workflow/src/View/Logs/HtmlView.php b/administrator/components/com_workflow/src/View/Logs/HtmlView.php
new file mode 100644
index 0000000000000..887c8a53f7321
--- /dev/null
+++ b/administrator/components/com_workflow/src/View/Logs/HtmlView.php
@@ -0,0 +1,81 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\View\Logs;
+
+use Joomla\CMS\Access\Exception\NotAllowed;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\Toolbar\ToolbarHelper;
+use Joomla\Component\Workflow\Administrator\Model\LogsModel;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Automation log view.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class HtmlView extends BaseHtmlView
+{
+ protected $items;
+ protected $state;
+ protected $pagination;
+ public $filterForm;
+ public $activeFilters;
+ protected $extension;
+
+ public function display($tpl = null)
+ {
+ // The dropdown that links here is core.admin only, because these rows carry failure
+ // details. Without this the screen is merely hidden, not protected: the URL still works.
+ if (!$this->getCurrentUser()->authorise('core.admin', 'com_workflow')) {
+ throw new NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403);
+ }
+
+ /** @var LogsModel $model */
+ $model = $this->getModel();
+
+ $this->state = $model->getState();
+ $this->items = $model->getItems();
+ $this->pagination = $model->getPagination();
+ $this->filterForm = $model->getFilterForm();
+ $this->activeFilters = $model->getActiveFilters();
+
+ $extension = (string) $this->state->get('filter.extension');
+ $parts = explode('.', $extension);
+ $this->extension = array_shift($parts) ?: 'com_content';
+
+ $this->filterForm->addControlField('extension', $extension);
+
+ $this->addToolbar();
+
+ parent::display($tpl);
+ }
+
+ protected function addToolbar()
+ {
+ /** @var \Joomla\CMS\Document\HtmlDocument $document */
+ $document = $this->getDocument();
+ $toolbar = $document->getToolbar();
+
+ ToolbarHelper::title(Text::_('COM_WORKFLOW_LOGS_LIST'), 'list');
+
+ $arrow = $this->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left';
+
+ $toolbar->link(
+ 'JTOOLBAR_BACK',
+ Route::_('index.php?option=com_workflow&view=workflows&extension=' . $this->escape((string) $this->state->get('filter.extension')))
+ )->icon('icon-' . $arrow);
+ }
+}
diff --git a/administrator/components/com_workflow/src/View/Transition/HtmlView.php b/administrator/components/com_workflow/src/View/Transition/HtmlView.php
index 5e4c4a04ab5e8..2772a833b9e2a 100644
--- a/administrator/components/com_workflow/src/View/Transition/HtmlView.php
+++ b/administrator/components/com_workflow/src/View/Transition/HtmlView.php
@@ -214,5 +214,6 @@ function (Toolbar $childBar) use ($canCreate) {
}
$toolbar->divider();
+ $toolbar->inlinehelp();
}
}
diff --git a/administrator/components/com_workflow/src/View/Upcoming/HtmlView.php b/administrator/components/com_workflow/src/View/Upcoming/HtmlView.php
new file mode 100644
index 0000000000000..9a067dc6021c3
--- /dev/null
+++ b/administrator/components/com_workflow/src/View/Upcoming/HtmlView.php
@@ -0,0 +1,125 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Workflow\Administrator\View\Upcoming;
+
+use Joomla\CMS\Access\Exception\NotAllowed;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\Toolbar\ToolbarHelper;
+use Joomla\Component\Workflow\Administrator\Model\UpcomingModel;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Upcoming automated transitions across every workflow of an extension.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class HtmlView extends BaseHtmlView
+{
+ /**
+ * The upcoming transitions to display.
+ *
+ * @var \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[]
+ * @since __DEPLOY_VERSION__
+ */
+ protected $items = [];
+
+ /**
+ * The model state.
+ *
+ * @var \Joomla\Registry\Registry
+ * @since __DEPLOY_VERSION__
+ */
+ protected $state;
+
+ /**
+ * The pagination object.
+ *
+ * @var \Joomla\CMS\Pagination\Pagination
+ * @since __DEPLOY_VERSION__
+ */
+ protected $pagination;
+
+ /**
+ * The filter form.
+ *
+ * @var \Joomla\CMS\Form\Form
+ * @since __DEPLOY_VERSION__
+ */
+ public $filterForm;
+
+ /**
+ * The active filters.
+ *
+ * @var array
+ * @since __DEPLOY_VERSION__
+ */
+ public $activeFilters = [];
+
+ /**
+ * Display the view.
+ *
+ * @param string $tpl The name of the template file to parse.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function display($tpl = null)
+ {
+ // The dropdown that links here is core.admin only, because this lists every queued item
+ // across the site. Without this the screen is merely hidden, not protected.
+ if (!$this->getCurrentUser()->authorise('core.admin', 'com_workflow')) {
+ throw new NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403);
+ }
+
+ /** @var UpcomingModel $model */
+ $model = $this->getModel();
+
+ $this->state = $model->getState();
+ $this->items = $model->getItems();
+ $this->pagination = $model->getPagination();
+ $this->filterForm = $model->getFilterForm();
+ $this->activeFilters = $model->getActiveFilters();
+
+ // Keeps the extension on the form when the filter bar submits it.
+ $this->filterForm->addControlField('extension', (string) $this->state->get('filter.extension'));
+
+ $this->addToolbar();
+
+ parent::display($tpl);
+ }
+
+ /**
+ * Add the page title and toolbar.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function addToolbar()
+ {
+ $toolbar = $this->getDocument()->getToolbar();
+
+ ToolbarHelper::title(Text::_('COM_WORKFLOW_UPCOMING_LIST'), 'clock');
+
+ $arrow = $this->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left';
+
+ $toolbar->link(
+ 'JTOOLBAR_BACK',
+ Route::_('index.php?option=com_workflow&view=workflows&extension=' . $this->escape((string) $this->state->get('filter.extension')))
+ )->icon('icon-' . $arrow);
+ }
+}
diff --git a/administrator/components/com_workflow/src/View/Workflow/HtmlView.php b/administrator/components/com_workflow/src/View/Workflow/HtmlView.php
index 21cb043217a1b..0860740386ab5 100644
--- a/administrator/components/com_workflow/src/View/Workflow/HtmlView.php
+++ b/administrator/components/com_workflow/src/View/Workflow/HtmlView.php
@@ -75,6 +75,22 @@ class HtmlView extends BaseHtmlView
*/
protected $section;
+ /**
+ * The workflow's upcoming automated transitions.
+ *
+ * @var \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[]
+ * @since __DEPLOY_VERSION__
+ */
+ protected $upcomingTransitions = [];
+
+ /**
+ * The workflow's recent automation log entries.
+ *
+ * @var object[]
+ * @since __DEPLOY_VERSION__
+ */
+ protected $automationLog = [];
+
/**
* Display item view
*
@@ -94,7 +110,9 @@ public function display($tpl = null)
$this->form = $model->getForm();
$this->item = $model->getItem();
- $extension = $this->state->get('filter.extension');
+ $this->upcomingTransitions = $model->getUpcomingTransitions((int) $this->item->id);
+ $this->automationLog = $model->getAutomationLog((int) $this->item->id);
+ $extension = $this->state->get('filter.extension');
$parts = explode('.', $extension);
diff --git a/administrator/components/com_workflow/src/View/Workflows/HtmlView.php b/administrator/components/com_workflow/src/View/Workflows/HtmlView.php
index a132d883a8855..8f2bc7179528d 100644
--- a/administrator/components/com_workflow/src/View/Workflows/HtmlView.php
+++ b/administrator/components/com_workflow/src/View/Workflows/HtmlView.php
@@ -13,6 +13,7 @@
use Joomla\CMS\Helper\ContentHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
+use Joomla\CMS\Router\Route;
use Joomla\CMS\Toolbar\Button\DropdownButton;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Component\Workflow\Administrator\Model\WorkflowsModel;
@@ -152,6 +153,29 @@ protected function addToolbar()
$toolbar->addNew('workflow.add');
}
+ // core.admin only, because these views expose failure details.
+ if ($canDo->get('core.admin')) {
+ $extension = $this->extension . ($this->section ? '.' . $this->section : '');
+
+ /** @var DropdownButton $automations */
+ $automations = $toolbar->dropdownButton('automation-group', 'COM_WORKFLOW_AUTOMATIONS_TOOLBAR')
+ ->toggleSplit(false)
+ ->icon('icon-cogs')
+ ->buttonClass('btn btn-action');
+
+ $automationBar = $automations->getChildToolbar();
+
+ $automationBar->link(
+ 'COM_WORKFLOW_UPCOMING_LIST',
+ Route::_('index.php?option=com_workflow&view=upcoming&extension=' . $extension)
+ )->icon('icon-clock');
+
+ $automationBar->link(
+ 'COM_WORKFLOW_LOGS_LIST',
+ Route::_('index.php?option=com_workflow&view=logs&extension=' . $extension)
+ )->icon('icon-list');
+ }
+
if ($canDo->get('core.edit.state') || $user->authorise('core.admin')) {
/** @var DropdownButton $dropdown */
$dropdown = $toolbar->dropdownButton('status-group', 'JTOOLBAR_CHANGE_STATUS')
diff --git a/administrator/components/com_workflow/tmpl/graph/default_texts.php b/administrator/components/com_workflow/tmpl/graph/default_texts.php
index 6b5feef6ed435..69dc70f24fd68 100644
--- a/administrator/components/com_workflow/tmpl/graph/default_texts.php
+++ b/administrator/components/com_workflow/tmpl/graph/default_texts.php
@@ -80,6 +80,7 @@
'COM_WORKFLOW_GRAPH_TRANSITION',
'COM_WORKFLOW_GRAPH_TRANSITIONS',
'COM_WORKFLOW_GRAPH_TRANSITION_ACTIONS',
+ 'COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED',
'COM_WORKFLOW_GRAPH_TRANSITION_COUNT',
'COM_WORKFLOW_GRAPH_TRANSITION_DESCRIPTION',
'COM_WORKFLOW_GRAPH_TRANSITION_PATH',
diff --git a/administrator/components/com_workflow/tmpl/logs/default.php b/administrator/components/com_workflow/tmpl/logs/default.php
new file mode 100644
index 0000000000000..cd2c22ea634aa
--- /dev/null
+++ b/administrator/components/com_workflow/tmpl/logs/default.php
@@ -0,0 +1,141 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Layout\LayoutHelper;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\Session\Session;
+
+/** @var \Joomla\Component\Workflow\Administrator\View\Logs\HtmlView $this */
+
+$wa = $this->getDocument()->getWebAssetManager();
+$wa->useScript('table.columns');
+
+$listOrder = $this->escape($this->state->get('list.ordering'));
+$listDirn = $this->escape($this->state->get('list.direction'));
+$extension = $this->escape((string) $this->state->get('filter.extension'));
+$user = $this->getCurrentUser();
+?>
+
diff --git a/administrator/components/com_workflow/tmpl/transition/edit.php b/administrator/components/com_workflow/tmpl/transition/edit.php
index e6894ba30c9f1..d84e841e1f8ae 100644
--- a/administrator/components/com_workflow/tmpl/transition/edit.php
+++ b/administrator/components/com_workflow/tmpl/transition/edit.php
@@ -26,7 +26,7 @@
$app = Factory::getApplication();
$user = $app->getIdentity();
-$this->ignore_fieldsets = ['params', 'transition', 'permissions'];
+$this->ignore_fieldsets = ['params', 'transition', 'permissions', 'automation'];
$this->useCoreUI = true;
// In case of modal
@@ -66,6 +66,19 @@
+ form->getGroup('automation')) : ?>
+
+
+
+
+
+ form->renderFieldset('automation'); ?>
+
+
+
+
+
+
authorise('core.admin', $this->extension)) : ?>
diff --git a/administrator/components/com_workflow/tmpl/transitions/default.php b/administrator/components/com_workflow/tmpl/transitions/default.php
index c416ff7e2acfc..feaac03e9b232 100644
--- a/administrator/components/com_workflow/tmpl/transitions/default.php
+++ b/administrator/components/com_workflow/tmpl/transitions/default.php
@@ -135,6 +135,12 @@
escape(Text::_($item->title)); ?>
escape(Text::_($item->description)); ?>
+ automated) : ?>
+
+
+
+
+
from_stage_id < 0) : ?>
diff --git a/administrator/components/com_workflow/tmpl/upcoming/default.php b/administrator/components/com_workflow/tmpl/upcoming/default.php
new file mode 100644
index 0000000000000..281f006ca848b
--- /dev/null
+++ b/administrator/components/com_workflow/tmpl/upcoming/default.php
@@ -0,0 +1,39 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Layout\LayoutHelper;
+use Joomla\CMS\Router\Route;
+
+/** @var \Joomla\Component\Workflow\Administrator\View\Upcoming\HtmlView $this */
+?>
+
diff --git a/administrator/components/com_workflow/tmpl/workflow/edit.php b/administrator/components/com_workflow/tmpl/workflow/edit.php
index 8c0e2a8ae1d0a..0f70796ce869f 100644
--- a/administrator/components/com_workflow/tmpl/workflow/edit.php
+++ b/administrator/components/com_workflow/tmpl/workflow/edit.php
@@ -84,6 +84,17 @@
+
+ item->id)) : ?>
+
+ loadTemplate('upcoming'); ?>
+
+
+
+ loadTemplate('log'); ?>
+
+
+
form->getInput('extension'); ?>
diff --git a/administrator/components/com_workflow/tmpl/workflow/edit_log.php b/administrator/components/com_workflow/tmpl/workflow/edit_log.php
new file mode 100644
index 0000000000000..558363bee65db
--- /dev/null
+++ b/administrator/components/com_workflow/tmpl/workflow/edit_log.php
@@ -0,0 +1,109 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Router\Route;
+
+/** @var \Joomla\Component\Workflow\Administrator\View\Workflow\HtmlView $this */
+
+$arrow = $this->getLanguage()->isRtl() ? 'arrow-left' : 'arrow-right';
+if (empty($this->automationLog)) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ automationLog as $entry) :
+ $extensionParts = explode('.', (string) $entry->extension);
+ $itemEditLink = (!empty($extensionParts[0]) && !empty($extensionParts[1]))
+ ? Route::_('index.php?option=' . $extensionParts[0] . '&task=' . $extensionParts[1] . '.edit&id=' . (int) $entry->item_id)
+ : '';
+ $itemTitle = (string) ($entry->item_title ?? '');
+ $itemLabel = $itemTitle !== '' ? $itemTitle : (string) (int) $entry->item_id;
+ ?>
+
+ executed_at, Text::_('DATE_FORMAT_LC5')); ?>
+
+
+ escape($itemLabel); ?>
+
+ escape($itemLabel); ?>
+
+
+ item_id); ?>
+
+
+
+ item_category_id)) : ?>
+
+ escape($entry->item_category_title); ?>
+
+
+
+
+ transition_id
+ . '&workflow_id=' . (int) $entry->workflow_id
+ . '&extension=' . $this->escape((string) $entry->extension)
+ );
+ ?>
+
+ escape(Text::_((string) $entry->transition_title)); ?>
+
+
+
+ from_stage !== $entry->to_stage) : ?>
+ escape(Text::_((string) $entry->from_stage)); ?>
+
+ escape(Text::_((string) $entry->to_stage)); ?>
+
+
+ escape((string) $entry->run_as_name); ?>
+
+ exit_code === 0) : ?>
+
+
+
+
+
+ escape((string) $entry->note); ?>
+
+
+
+
+
+
+
+
+
diff --git a/administrator/components/com_workflow/tmpl/workflow/edit_upcoming.php b/administrator/components/com_workflow/tmpl/workflow/edit_upcoming.php
new file mode 100644
index 0000000000000..062ec033a7549
--- /dev/null
+++ b/administrator/components/com_workflow/tmpl/workflow/edit_upcoming.php
@@ -0,0 +1,29 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Layout\LayoutHelper;
+use Joomla\CMS\Router\Route;
+
+/** @var \Joomla\Component\Workflow\Administrator\View\Workflow\HtmlView $this */
+
+echo LayoutHelper::render(
+ 'upcoming.table',
+ ['items' => $this->upcomingTransitions, 'showWorkflow' => false],
+ JPATH_ADMINISTRATOR . '/components/com_workflow/layouts'
+);
+?>
+
+
+
+
+
diff --git a/administrator/language/en-GB/com_workflow.ini b/administrator/language/en-GB/com_workflow.ini
index 366e3fdb74369..6c56ef4595bbb 100644
--- a/administrator/language/en-GB/com_workflow.ini
+++ b/administrator/language/en-GB/com_workflow.ini
@@ -3,6 +3,94 @@
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
+COM_WORKFLOW_AUTOMATIONS_TOOLBAR="Automations"
+COM_WORKFLOW_AUTOMATION_BUILDER_ADD_CHECK="Add check"
+COM_WORKFLOW_AUTOMATION_BUILDER_ADD_EXPRESSION="Add expression"
+COM_WORKFLOW_AUTOMATION_BUILDER_CHECK="Check"
+COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_CONDITION="No conditions yet. This rule runs whenever it is due."
+COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_EXPRESSION="This expression is empty. Add a check to use it."
+COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_FILTER="No filters yet. This rule applies to all items."
+COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_HEADER="Workflow - Automation Options"
+COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_HINT="For more advanced expressions, enable %s."
+COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_LINK="Expert Mode"
+COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_OFF_HINT="Expert Mode is on for this site. %s to go back to a simpler builder."
+COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_OFF_LINK="Switch it off"
+COM_WORKFLOW_AUTOMATION_BUILDER_EXPRESSION="Expression"
+COM_WORKFLOW_AUTOMATION_BUILDER_FIELD="Field"
+COM_WORKFLOW_AUTOMATION_BUILDER_JOIN_WITH="Join with"
+COM_WORKFLOW_AUTOMATION_BUILDER_NEGATE="Not"
+COM_WORKFLOW_AUTOMATION_BUILDER_OPERATOR="Operator"
+COM_WORKFLOW_AUTOMATION_BUILDER_OP_AND="AND"
+COM_WORKFLOW_AUTOMATION_BUILDER_OP_OR="OR"
+COM_WORKFLOW_AUTOMATION_BUILDER_REMOVE="Remove"
+COM_WORKFLOW_AUTOMATION_BUILDER_REMOVE_CHECK="Remove check: %s"
+COM_WORKFLOW_AUTOMATION_BUILDER_VALUE="Value"
+COM_WORKFLOW_AUTOMATION_CONDITION_DESC="The transition only fires when these are true at run time. Leave empty to fire whenever due."
+COM_WORKFLOW_AUTOMATION_CONDITION_LABEL="Condition (When to Fire)"
+COM_WORKFLOW_AUTOMATION_CRON_DESC="Standard cron expression, for example */5 * * * * for every five minutes. The next run is computed from when the item entered the stage."
+COM_WORKFLOW_AUTOMATION_CRON_LABEL="Cron Expression"
+COM_WORKFLOW_AUTOMATION_DELAY_UNIT_DESC="The unit for the delay above, for example 3 Days."
+COM_WORKFLOW_AUTOMATION_DELAY_UNIT_LABEL="Delay Unit"
+COM_WORKFLOW_AUTOMATION_DELAY_VALUE_DESC="How long to wait after the item enters the stage before this transition becomes due. Use 0 to fire as soon as it enters."
+COM_WORKFLOW_AUTOMATION_DELAY_VALUE_LABEL="Delay"
+COM_WORKFLOW_AUTOMATION_ENABLE_TRANSITION_DESC="When off, this transition is not automated and its rules are hidden."
+COM_WORKFLOW_AUTOMATION_ENABLE_TRANSITION_LABEL="Automate This Transition"
+COM_WORKFLOW_AUTOMATION_ERROR_CRON="The cron expression is not valid."
+COM_WORKFLOW_AUTOMATION_ERROR_DELAY_UNIT="Invalid automation delay unit."
+COM_WORKFLOW_AUTOMATION_ERROR_DELAY_VALUE="The automation delay cannot be negative."
+COM_WORKFLOW_AUTOMATION_ERROR_INCOMPLETE_CHECK="A check in %s is not filled in. Complete it or remove it, then save again."
+COM_WORKFLOW_AUTOMATION_ERROR_INCOMPLETE_CHECKS="Checks in %s and %s are not filled in. Complete them or remove them, then save again."
+COM_WORKFLOW_AUTOMATION_ERROR_NO_RUN_AS="Choose a Run As user. An automated transition has to execute as somebody."
+COM_WORKFLOW_AUTOMATION_ERROR_RULE_TYPE="Invalid automation rule type."
+COM_WORKFLOW_AUTOMATION_ERROR_RUN_AS_NOT_ALLOWED="You can only run this automation as yourself or as the user it already runs as."
+COM_WORKFLOW_AUTOMATION_FIELDSET_LABEL="Automation"
+COM_WORKFLOW_AUTOMATION_FIELD_AUTHOR_GROUP="Author group"
+COM_WORKFLOW_AUTOMATION_FIELD_CATEGORY="Category"
+COM_WORKFLOW_AUTOMATION_FIELD_DATE="Date"
+COM_WORKFLOW_AUTOMATION_FIELD_DAYS_IN_STAGE="Days in current stage"
+COM_WORKFLOW_AUTOMATION_FIELD_DAYS_SINCE_MODIFIED="Days since last edited"
+COM_WORKFLOW_AUTOMATION_FIELD_DAY_OF_WEEK="Day of Week"
+COM_WORKFLOW_AUTOMATION_FIELD_TAG="Tag"
+COM_WORKFLOW_AUTOMATION_FILTER_DESC="Only items matching these conditions are affected by this rule. Leave empty to apply to all items."
+COM_WORKFLOW_AUTOMATION_FILTER_LABEL="Filter (Which Items)"
+COM_WORKFLOW_AUTOMATION_OP_AFTER="after"
+COM_WORKFLOW_AUTOMATION_OP_BEFORE="before"
+COM_WORKFLOW_AUTOMATION_OP_GREATER_THAN="greater than"
+COM_WORKFLOW_AUTOMATION_OP_HAS_ALL="has all of"
+COM_WORKFLOW_AUTOMATION_OP_HAS_ANY="has any of"
+COM_WORKFLOW_AUTOMATION_OP_HAS_NONE="has none of"
+COM_WORKFLOW_AUTOMATION_OP_IN="in"
+COM_WORKFLOW_AUTOMATION_OP_IS="is"
+COM_WORKFLOW_AUTOMATION_OP_IS_NOT="is not"
+COM_WORKFLOW_AUTOMATION_OP_LESS_THAN="less than"
+COM_WORKFLOW_AUTOMATION_OP_NOT_IN="not in"
+COM_WORKFLOW_AUTOMATION_OP_NOT_ON="not on"
+COM_WORKFLOW_AUTOMATION_OP_ON="on"
+COM_WORKFLOW_AUTOMATION_PREVIEW_BUTTON="Preview matches"
+COM_WORKFLOW_AUTOMATION_PREVIEW_CAPPED="%1$s of the first %2$s items in this stage match."
+COM_WORKFLOW_AUTOMATION_PREVIEW_EMPTY="There are no items in this stage to check."
+COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_HEADER="Items matching this filter"
+COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_RANGE="%1$s to %2$s of %3$s"
+COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_TRIMMED="Showing the first %s."
+COM_WORKFLOW_AUTOMATION_PREVIEW_RESULT="%1$s of %2$s items in this stage match."
+COM_WORKFLOW_AUTOMATION_PREVIEW_RUNNING="Checking..."
+COM_WORKFLOW_AUTOMATION_PREVIEW_SHOW_ALL="Show all %s"
+COM_WORKFLOW_AUTOMATION_RULES_DESC="Automation rules for this transition. If two automated transitions leave the same stage and fall due at the same moment, the one higher up the Transitions list runs first."
+COM_WORKFLOW_AUTOMATION_RULES_LABEL="Automation Rules"
+COM_WORKFLOW_AUTOMATION_RULE_SAVE_FAILED="The transition was saved, but its automation rule could not be updated: %s. The rule that was already stored has been left unchanged."
+COM_WORKFLOW_AUTOMATION_RULE_TYPE_CRON="Cron Expression"
+COM_WORKFLOW_AUTOMATION_RULE_TYPE_DELAY="Delay"
+COM_WORKFLOW_AUTOMATION_RULE_TYPE_DESC="Choose a simple delay or a cron expression."
+COM_WORKFLOW_AUTOMATION_RULE_TYPE_LABEL="Rule Type"
+COM_WORKFLOW_AUTOMATION_RUN_AS_DESC="The user identity the automated transition executes as. Permissions are checked against this user."
+COM_WORKFLOW_AUTOMATION_RUN_AS_LABEL="Run As User"
+COM_WORKFLOW_AUTOMATION_TASK_PLUGIN="Workflow - Automation Task plugin"
+COM_WORKFLOW_AUTOMATION_UNIT_DAYS="Days"
+COM_WORKFLOW_AUTOMATION_UNIT_HOURS="Hours"
+COM_WORKFLOW_AUTOMATION_UNIT_MINUTES="Minutes"
+COM_WORKFLOW_AUTOMATION_UNIT_MONTHS="Months"
+COM_WORKFLOW_AUTOMATION_WARNING_RUN_AS_CANNOT_EXECUTE="The Run As user cannot execute this transition, so the rule will fail until they are given permission."
+COM_WORKFLOW_AUTOMATION_WARNING_TASK_PLUGIN_DISABLED="The %s is disabled, so no automated transition will run until it is enabled."
COM_WORKFLOW_BASIC_STAGE="Basic Stage"
COM_WORKFLOW_BASIC_WORKFLOW="Basic Workflow"
COM_WORKFLOW_CHOOSE_CONTEXT_LABEL="Context"
@@ -23,11 +111,40 @@ COM_WORKFLOW_FORM_NEW="New Workflow"
COM_WORKFLOW_FROM_STAGE="Current Stage"
COM_WORKFLOW_FROM_STAGE_FILTER_ASC="Current Stage ascending"
COM_WORKFLOW_FROM_STAGE_FILTER_DESC="Current Stage descending"
+COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED="This transition runs automatically"
COM_WORKFLOW_ID="ID"
COM_WORKFLOW_INHERIT_WORKFLOW="Inherit (%s)"
COM_WORKFLOW_INHERIT_WORKFLOW_NEW="Inherit"
COM_WORKFLOW_ITEM_MUST_PUBLISHED="Item must be published to set default stage."
COM_WORKFLOW_ITEM_UNSET_DEFAULT="Default stage unset."
+COM_WORKFLOW_LOGS_EXECUTED_AT="Executed"
+COM_WORKFLOW_LOGS_EXECUTED_AT_ASC="Executed ascending"
+COM_WORKFLOW_LOGS_EXECUTED_AT_DESC="Executed descending"
+COM_WORKFLOW_LOGS_FILTER_SEARCH_HINT="Search by title, item ID or note"
+COM_WORKFLOW_LOGS_FILTER_SEARCH_LABEL="Search Log"
+COM_WORKFLOW_LOGS_ITEM="Item"
+COM_WORKFLOW_LOGS_ITEM_ASC="Item ascending"
+COM_WORKFLOW_LOGS_ITEM_DESC="Item descending"
+COM_WORKFLOW_LOGS_ITEM_ID_INLINE="ID %d"
+COM_WORKFLOW_LOGS_LIST="Automation Log"
+COM_WORKFLOW_LOGS_NOTE="Note"
+COM_WORKFLOW_LOGS_RESET="Reset"
+COM_WORKFLOW_LOGS_RESET_SUCCESS="The item was cleared and will be retried on the next scheduler run."
+COM_WORKFLOW_LOGS_RESULT="Result"
+COM_WORKFLOW_LOGS_RESULT_ASC="Result ascending"
+COM_WORKFLOW_LOGS_RESULT_DESC="Result descending"
+COM_WORKFLOW_LOGS_RESULT_FAILED="Failed"
+COM_WORKFLOW_LOGS_RESULT_OK="Success"
+COM_WORKFLOW_LOGS_RESULT_SKIPPED="Skipped"
+COM_WORKFLOW_LOGS_RUN_AS="Run as"
+COM_WORKFLOW_LOGS_RUN_AS_SYSTEM="System"
+COM_WORKFLOW_LOGS_SELECT_RESULT="- Select result -"
+COM_WORKFLOW_LOGS_STAGES="Stages"
+COM_WORKFLOW_LOGS_TABLE_CAPTION="Table of automation log entries"
+COM_WORKFLOW_LOGS_TRANSITION="Transition"
+COM_WORKFLOW_LOG_EMPTY="No automated transitions have run in this workflow yet."
+COM_WORKFLOW_LOG_TAB="Automation Log"
+COM_WORKFLOW_LOG_VIEW_ALL="View the full automation log"
COM_WORKFLOW_MODIFIED_LABEL="Date Modified"
COM_WORKFLOW_MSG_DELETE_IS_DEFAULT="You can't delete the default item."
COM_WORKFLOW_MSG_DELETE_STAGE_IS_ASSIGNED="This stage is assigned to an item of this component and has to remain in a published state."
@@ -47,6 +164,7 @@ COM_WORKFLOW_N_ITEMS_TRASHED_1="Workflow trashed."
COM_WORKFLOW_N_ITEMS_UNPUBLISHED="%d workflows disabled."
COM_WORKFLOW_N_ITEMS_UNPUBLISHED_1="Workflow disabled."
COM_WORKFLOW_PREVIEW="Preview"
+COM_WORKFLOW_PREVIEW_ERROR_NO_TRANSITION="Save the transition before previewing its filter."
COM_WORKFLOW_PUBLISHED_LABEL="Status"
COM_WORKFLOW_REDO="Redo"
COM_WORKFLOW_RULES_TAB="Permissions"
@@ -101,6 +219,35 @@ COM_WORKFLOW_TRANSITION_FORM_NEW="New Transition"
COM_WORKFLOW_TRANSITION_NOTE="Note"
COM_WORKFLOW_UNDO="Undo"
COM_WORKFLOW_UNPUBLISH_DEFAULT_ERROR="The default workflow cannot be disabled."
+COM_WORKFLOW_UPCOMING_ARTICLE_LABEL="Automated Transition"
+COM_WORKFLOW_UPCOMING_COL_FIRES="Fires"
+COM_WORKFLOW_UPCOMING_COL_ITEM="Item"
+COM_WORKFLOW_UPCOMING_COL_MOVE="Move"
+COM_WORKFLOW_UPCOMING_COL_TRIGGER="Trigger"
+COM_WORKFLOW_UPCOMING_COL_WORKFLOW="Workflow"
+COM_WORKFLOW_UPCOMING_DUE_NOW="Due now"
+COM_WORKFLOW_UPCOMING_EMPTY="No items are waiting on an automated transition."
+COM_WORKFLOW_UPCOMING_FILTER_STAGE="- Select Stage -"
+COM_WORKFLOW_UPCOMING_FILTER_WORKFLOW="- Select Workflow -"
+COM_WORKFLOW_UPCOMING_IN_DAYS="In %d days"
+COM_WORKFLOW_UPCOMING_IN_DAYS_1="In 1 day"
+COM_WORKFLOW_UPCOMING_IN_HOURS="In %d hours"
+COM_WORKFLOW_UPCOMING_IN_HOURS_1="In 1 hour"
+COM_WORKFLOW_UPCOMING_IN_MINUTES="In %d minutes"
+COM_WORKFLOW_UPCOMING_IN_MINUTES_1="In 1 minute"
+COM_WORKFLOW_UPCOMING_IN_MONTHS="In %d months"
+COM_WORKFLOW_UPCOMING_IN_MONTHS_1="In 1 month"
+COM_WORKFLOW_UPCOMING_ITEM_FALLBACK="Item #%d"
+COM_WORKFLOW_UPCOMING_LAST_FAILED="Last failed %s"
+COM_WORKFLOW_UPCOMING_LIST="Upcoming Transitions"
+COM_WORKFLOW_UPCOMING_STATUS_ATTENTION="Needs attention"
+COM_WORKFLOW_UPCOMING_STATUS_NOT_SCHEDULED="Not scheduled"
+COM_WORKFLOW_UPCOMING_STATUS_RULE_ERROR="Rule error"
+COM_WORKFLOW_UPCOMING_SUBJECT_CONDITION="Subject to condition"
+COM_WORKFLOW_UPCOMING_TAB="Upcoming Transitions"
+COM_WORKFLOW_UPCOMING_TRIGGER_CRON="Schedule: %s"
+COM_WORKFLOW_UPCOMING_TRIGGER_DELAY="%1$s %2$s after entering the stage"
+COM_WORKFLOW_UPCOMING_VIEW_ALL="View all upcoming transitions"
COM_WORKFLOW_USE_DEFAULT_WORKFLOW="Use default (%s)"
COM_WORKFLOW_WORKFLOWS_ADD="Add Workflow"
COM_WORKFLOW_WORKFLOWS_EDIT="Edit Workflow"
diff --git a/administrator/language/en-GB/plg_workflow_automation.ini b/administrator/language/en-GB/plg_workflow_automation.ini
new file mode 100644
index 0000000000000..c09babe97151e
--- /dev/null
+++ b/administrator/language/en-GB/plg_workflow_automation.ini
@@ -0,0 +1,9 @@
+; Joomla! Project
+; (C) 2026 Open Source Matters, Inc.
+; License GNU General Public License version 2 or later; see LICENSE.txt
+; Note : All ini files need to be saved as UTF-8
+
+PLG_WORKFLOW_AUTOMATION="Workflow - Automation"
+PLG_WORKFLOW_AUTOMATION_FIELD_EXPERT_MODE_DESC="Adds OR, sub-expressions and Not to the filter and condition builders. When off, checks are simply added and all must match. Rules that already use these options still open in full."
+PLG_WORKFLOW_AUTOMATION_FIELD_EXPERT_MODE_LABEL="Expert Mode"
+PLG_WORKFLOW_AUTOMATION_XML_DESCRIPTION="Enables time-based automated transitions between workflow stages"
diff --git a/administrator/language/en-GB/plg_workflow_automation.sys.ini b/administrator/language/en-GB/plg_workflow_automation.sys.ini
new file mode 100644
index 0000000000000..c1829b15b124e
--- /dev/null
+++ b/administrator/language/en-GB/plg_workflow_automation.sys.ini
@@ -0,0 +1,7 @@
+; Joomla! Project
+; (C) 2026 Open Source Matters, Inc.
+; License GNU General Public License version 2 or later; see LICENSE.txt
+; Note : All ini files need to be saved as UTF-8
+
+PLG_WORKFLOW_AUTOMATION="Workflow - Automation"
+PLG_WORKFLOW_AUTOMATION_XML_DESCRIPTION="Enables time-based automated transitions between workflow stages"
diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql
index 61180d9da653b..7e2103393fe8b 100644
--- a/installation/sql/mysql/base.sql
+++ b/installation/sql/mysql/base.sql
@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS `#__assets` (
--
INSERT INTO `#__assets` (`id`, `parent_id`, `lft`, `rgt`, `level`, `name`, `title`, `rules`) VALUES
-(1, 0, 0, 183, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'),
+(1, 0, 0, 185, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'),
(2, 1, 1, 2, 1, 'com_admin', 'com_admin', '{}'),
(3, 1, 3, 6, 1, 'com_banners', 'com_banners', '{"core.admin":{"7":1},"core.manage":{"6":1}}'),
(4, 1, 7, 8, 1, 'com_cache', 'com_cache', '{"core.admin":{"7":1},"core.manage":{"7":1}}'),
@@ -107,17 +107,17 @@ INSERT INTO `#__assets` (`id`, `parent_id`, `lft`, `rgt`, `level`, `name`, `titl
(87, 18, 124, 125, 2, 'com_modules.module.97', 'Recently Added Articles', '{}'),
(88, 18, 126, 127, 2, 'com_modules.module.98', 'Logged-in Users', '{}'),
(89, 18, 128, 129, 2, 'com_modules.module.90', 'Login Support', '{}'),
-(90, 1, 165, 172, 1, 'com_scheduler', 'com_scheduler', '{}'),
-(91, 1, 173, 174, 1, 'com_associations', 'com_associations', '{}'),
-(92, 1, 175, 176, 1, 'com_categories', 'com_categories', '{}'),
-(93, 1, 177, 178, 1, 'com_fields', 'com_fields', '{}'),
-(94, 1, 179, 180, 1, 'com_workflow', 'com_workflow', '{}'),
-(95, 1, 181, 182, 1, 'com_guidedtours', 'com_guidedtours', '{}'),
+(90, 1, 165, 174, 1, 'com_scheduler', 'com_scheduler', '{}'),
+(91, 1, 175, 176, 1, 'com_associations', 'com_associations', '{}'),
+(92, 1, 177, 178, 1, 'com_categories', 'com_categories', '{}'),
+(93, 1, 179, 180, 1, 'com_fields', 'com_fields', '{}'),
+(94, 1, 181, 182, 1, 'com_workflow', 'com_workflow', '{}'),
+(95, 1, 183, 184, 1, 'com_guidedtours', 'com_guidedtours', '{}'),
(96, 18, 130, 131, 2, 'com_modules.module.109', 'Guided Tours', '{}'),
(97, 90, 166, 167, 2, 'com_scheduler.task.1', 'Rotate Logs', '{}'),
(98, 90, 168, 169, 2, 'com_scheduler.task.2', 'Session GC', '{}'),
-(99, 90, 170, 171, 2, 'com_scheduler.task.3', 'Update Notification', '{}');
-
+(99, 90, 170, 171, 2, 'com_scheduler.task.3', 'Update Notification', '{}'),
+(100, 90, 172, 173, 2, 'com_scheduler.task.4', 'Workflow Automation', '{}');
-- --------------------------------------------------------
--
@@ -380,6 +380,7 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`,
(0, 'plg_task_sessiongc', 'plugin', 'sessiongc', 'task', 0, 1, 1, 0, 1, '', '{}', '', 7, 0),
(0, 'plg_task_sitestatus', 'plugin', 'sitestatus', 'task', 0, 1, 1, 0, 1, '', '{}', '', 8, 0),
(0, 'plg_task_updatenotification', 'plugin', 'updatenotification', 'task', 0, 1, 1, 0, 1, '', '{}', '', 9, 0),
+(0, 'plg_task_workflowtransition', 'plugin', 'workflowtransition', 'task', 0, 1, 1, 0, 1, '', '{}', '', 10, 0),
(0, 'plg_multifactorauth_totp', 'plugin', 'totp', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 1, 0),
(0, 'plg_multifactorauth_yubikey', 'plugin', 'yubikey', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 2, 0),
(0, 'plg_multifactorauth_webauthn', 'plugin', 'webauthn', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 3, 0),
@@ -411,6 +412,8 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`,
(0, 'plg_workflow_featuring', 'plugin', 'featuring', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 1, 0),
(0, 'plg_workflow_notification', 'plugin', 'notification', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 2, 0),
(0, 'plg_workflow_publishing', 'plugin', 'publishing', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 3, 0),
+(0, 'plg_workflow_automation', 'plugin', 'automation', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 4, 0),
+(0, 'plg_workflow_remotecheck', 'plugin', 'remotecheck', 'workflow', 0, 0, 1, 0, 1, '', '{}', '', 5, 0),
(0, 'plg_system_guidedtours', 'plugin', 'guidedtours', 'system', 0, 1, 1, 0, 1, '', '{}', '', 15, 0);
-- Templates
@@ -1266,3 +1269,72 @@ INSERT INTO `#__workflow_transitions` (`id`, `asset_id`, `published`, `ordering`
(5, 62, 1, 5, 1, 'FEATURE', '', -1, 1, '{"featuring":"1"}'),
(6, 63, 1, 6, 1, 'UNFEATURE', '', -1, 1, '{"featuring":"0"}'),
(7, 64, 1, 7, 1, 'PUBLISH_AND_FEATURE', '', -1, 1, '{"publishing":"1","featuring":"1"}');
+
+--
+-- Table structure for table `#__workflow_automation_rules`
+--
+
+CREATE TABLE IF NOT EXISTS `#__workflow_automation_rules` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `transition_id` int NOT NULL COMMENT 'Foreign Key to #__workflow_transitions.id',
+ `published` tinyint NOT NULL DEFAULT 0,
+ `ordering` int NOT NULL DEFAULT 0,
+ `rule_type` varchar(20) NOT NULL DEFAULT 'delay' COMMENT 'delay or cron',
+ `delay_value` int DEFAULT NULL,
+ `delay_unit` varchar(10) DEFAULT NULL COMMENT 'minutes, hours, days, months',
+ `cron_expression` varchar(100) DEFAULT NULL,
+ `item_filter` text COMMENT 'JSON filter tree: which items this rule applies to (evaluated at selection)',
+ `fire_condition` text COMMENT 'JSON expression tree: gate evaluated live at fire time',
+ `run_as_user_id` int NOT NULL DEFAULT 0 COMMENT 'User identity used to execute the transition',
+ `created` datetime NOT NULL,
+ `created_by` int NOT NULL DEFAULT 0,
+ `modified` datetime NOT NULL,
+ `modified_by` int NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_transition` (`transition_id`),
+ KEY `idx_published` (`published`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci;
+
+--
+-- Table structure for table `#__workflow_item_state`
+--
+
+CREATE TABLE IF NOT EXISTS `#__workflow_item_state` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `item_id` int NOT NULL DEFAULT 0 COMMENT 'Extension table id value',
+ `extension` varchar(50) NOT NULL,
+ `stage_id` int NOT NULL COMMENT 'Foreign Key to #__workflow_stages.id',
+ `entered_at` datetime NOT NULL COMMENT 'When the item arrived in stage_id',
+ `triggered_by` varchar(20) NOT NULL DEFAULT 'manual' COMMENT 'Determine if a transition was triggered manually or by the automation',
+ `requires_intervention` tinyint NOT NULL DEFAULT 0 COMMENT 'Set when an automated transition failed; excluded from the scheduler until an admin clears it',
+ `last_checked_at` datetime DEFAULT NULL COMMENT 'When the scheduler last considered this item; null means never',
+ `last_failure_at` datetime DEFAULT NULL COMMENT 'When this item last could not be evaluated; null means it evaluated cleanly',
+ `last_failure_reason` varchar(500) DEFAULT NULL COMMENT 'Why it could not be evaluated, so the same failure is only reported once',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_item_extension` (`item_id`, `extension`),
+ KEY `idx_stage_entered` (`stage_id`, `entered_at`),
+ KEY `idx_requires_intervention` (`requires_intervention`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci;
+
+--
+-- Table structure for table `#__workflow_automation_log`
+--
+
+CREATE TABLE IF NOT EXISTS `#__workflow_automation_log` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `rule_id` int DEFAULT NULL COMMENT 'Foreign Key to #__workflow_automation_rules.id',
+ `item_id` int NOT NULL DEFAULT 0,
+ `extension` varchar(50) NOT NULL,
+ `transition_id` int NOT NULL,
+ `from_stage_id` int NOT NULL DEFAULT 0,
+ `to_stage_id` int NOT NULL DEFAULT 0,
+ `run_as_user_id` int NOT NULL DEFAULT 0,
+ `trigger_type` varchar(20) NOT NULL DEFAULT 'rule',
+ `exit_code` tinyint NOT NULL DEFAULT 0 COMMENT '0 ok, 1 permission denied, 2 invalid transition, 3 exception, 4 no longer applicable',
+ `note` varchar(500) DEFAULT NULL,
+ `executed_at` datetime NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_item_id` (`item_id`),
+ KEY `idx_executed_at` (`executed_at`),
+ KEY `idx_exit_code` (`exit_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci;
diff --git a/installation/sql/mysql/extensions.sql b/installation/sql/mysql/extensions.sql
index 0686a3a74649b..7d5e3cdb72e90 100644
--- a/installation/sql/mysql/extensions.sql
+++ b/installation/sql/mysql/extensions.sql
@@ -934,7 +934,8 @@ CREATE TABLE IF NOT EXISTS `#__scheduler_tasks` (
INSERT INTO `#__scheduler_tasks` (`id`, `asset_id`, `title`, `type`, `execution_rules`, `cron_rules`, `state`, `last_execution`, `next_execution`, `locked`, `params`, `created`, `created_by`) VALUES
(1, 97, 'Rotate Logs', 'rotation.logs', CONCAT('{"rule-type":"interval-days","interval-days":"30","exec-day":"', DATE_FORMAT(NOW(), '%e'), '","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"P30D"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 30 DAY, '%Y-%m-%d %H:00:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"logstokeep":1}', NOW(), 42),
(2, 98, 'Session GC', 'session.gc', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 24 HOUR, '%Y-%m-%d %H:00:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"enable_session_gc":1,"enable_session_metadata_gc":1}', NOW(), 42),
-(3, 99, 'Update Notification', 'update.notification', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 24 HOUR, '%Y-%m-%d %H:00:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"email":"","language_override":""}', NOW(), 42);
+(3, 99, 'Update Notification', 'update.notification', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 24 HOUR, '%Y-%m-%d %H:00:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"email":"","language_override":""}', NOW(), 42),
+(4, 100, 'Workflow Automation', 'workflow.automation', CONCAT('{"rule-type":"interval-minutes","interval-minutes":"15","exec-day":"', DATE_FORMAT(NOW(), '%e'), '","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"PT15M"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 24 HOUR, '%Y-%m-%d %H:%i:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"}}', NOW(), 42);
-- --------------------------------------------------------
diff --git a/installation/sql/postgresql/base.sql b/installation/sql/postgresql/base.sql
index 2916c3f99c7a1..6ba5588decf36 100644
--- a/installation/sql/postgresql/base.sql
+++ b/installation/sql/postgresql/base.sql
@@ -31,7 +31,7 @@ COMMENT ON COLUMN "#__assets"."rules" IS 'JSON encoded access control.';
--
INSERT INTO "#__assets" ("id", "parent_id", "lft", "rgt", "level", "name", "title", "rules") VALUES
-(1, 0, 0, 183, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'),
+(1, 0, 0, 185, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'),
(2, 1, 1, 2, 1, 'com_admin', 'com_admin', '{}'),
(3, 1, 3, 6, 1, 'com_banners', 'com_banners', '{"core.admin":{"7":1},"core.manage":{"6":1}}'),
(4, 1, 7, 8, 1, 'com_cache', 'com_cache', '{"core.admin":{"7":1},"core.manage":{"7":1}}'),
@@ -113,19 +113,19 @@ INSERT INTO "#__assets" ("id", "parent_id", "lft", "rgt", "level", "name", "titl
(87, 18, 124, 125, 2, 'com_modules.module.97', 'Recently Added Articles', '{}'),
(88, 18, 126, 127, 2, 'com_modules.module.98', 'Logged-in Users', '{}'),
(89, 18, 128, 129, 2, 'com_modules.module.90', 'Login Support', '{}'),
-(90, 1, 165, 172, 1, 'com_scheduler', 'com_scheduler', '{}'),
-(91, 1, 173, 174, 1, 'com_associations', 'com_associations', '{}'),
-(92, 1, 175, 176, 1, 'com_categories', 'com_categories', '{}'),
-(93, 1, 177, 178, 1, 'com_fields', 'com_fields', '{}'),
-(94, 1, 179, 180, 1, 'com_workflow', 'com_workflow', '{}'),
-(95, 1, 181, 182, 1, 'com_guidedtours', 'com_guidedtours', '{}'),
+(90, 1, 165, 174, 1, 'com_scheduler', 'com_scheduler', '{}'),
+(91, 1, 175, 176, 1, 'com_associations', 'com_associations', '{}'),
+(92, 1, 177, 178, 1, 'com_categories', 'com_categories', '{}'),
+(93, 1, 179, 180, 1, 'com_fields', 'com_fields', '{}'),
+(94, 1, 181, 182, 1, 'com_workflow', 'com_workflow', '{}'),
+(95, 1, 183, 184, 1, 'com_guidedtours', 'com_guidedtours', '{}'),
(96, 18, 130, 131, 2, 'com_modules.module.109', 'Guided Tours', '{}'),
(97, 90, 166, 167, 2, 'com_scheduler.task.1', 'Rotate Logs', '{}'),
(98, 90, 168, 169, 2, 'com_scheduler.task.2', 'Session GC', '{}'),
-(99, 90, 170, 171, 2, 'com_scheduler.task.3', 'Update Notification', '{}');
-
-SELECT setval('#__assets_id_seq', 100, false);
+(99, 90, 170, 171, 2, 'com_scheduler.task.3', 'Update Notification', '{}'),
+(100, 90, 172, 173, 2, 'com_scheduler.task.4', 'Workflow Automation', '{}');
+SELECT setval ( '#__assets_id_seq', 101, false );
--
-- Table structure for table `#__extensions`
--
@@ -386,6 +386,7 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder",
(0, 'plg_task_sessiongc', 'plugin', 'sessiongc', 'task', 0, 1, 1, 0, 1, '', '{}', '', 7, 0),
(0, 'plg_task_sitestatus', 'plugin', 'sitestatus', 'task', 0, 1, 1, 0, 1, '', '{}', '', 8, 0),
(0, 'plg_task_updatenotification', 'plugin', 'updatenotification', 'task', 0, 1, 1, 0, 1, '', '{}', '', 9, 0),
+(0, 'plg_task_workflowtransition', 'plugin', 'workflowtransition', 'task', 0, 1, 1, 0, 1, '', '{}', '', 10, 0),
(0, 'plg_multifactorauth_totp', 'plugin', 'totp', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 1, 0),
(0, 'plg_multifactorauth_yubikey', 'plugin', 'yubikey', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 2, 0),
(0, 'plg_multifactorauth_webauthn', 'plugin', 'webauthn', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 3, 0),
@@ -417,6 +418,8 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder",
(0, 'plg_workflow_featuring', 'plugin', 'featuring', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 1, 0),
(0, 'plg_workflow_notification', 'plugin', 'notification', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 2, 0),
(0, 'plg_workflow_publishing', 'plugin', 'publishing', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 3, 0),
+(0, 'plg_workflow_automation', 'plugin', 'automation', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 4, 0),
+(0, 'plg_workflow_remotecheck', 'plugin', 'remotecheck', 'workflow', 0, 0, 1, 0, 1, '', '{}', '', 5, 0),
(0, 'plg_system_guidedtours', 'plugin', 'guidedtours', 'system', 0, 1, 1, 0, 1, '', '{}', '', 15, 0);
-- Templates
@@ -1289,3 +1292,100 @@ INSERT INTO "#__workflow_transitions" ("id", "asset_id", "published", "ordering"
(7, 64, 1, 7, 1, 'PUBLISH_AND_FEATURE', '', -1, 1, '{"publishing":"1","featuring":"1"}');
SELECT setval('#__workflow_transitions_id_seq', 8, false);
+
+CREATE TABLE IF NOT EXISTS "#__workflow_automation_rules" (
+ "id" serial NOT NULL,
+ "transition_id" integer NOT NULL,
+ "published" smallint DEFAULT 0 NOT NULL,
+ "ordering" integer DEFAULT 0 NOT NULL,
+ "rule_type" varchar(20) DEFAULT 'delay' NOT NULL,
+ "delay_value" integer,
+ "delay_unit" varchar(10),
+ "cron_expression" varchar(100),
+ "item_filter" text,
+ "fire_condition" text,
+ "run_as_user_id" integer DEFAULT 0 NOT NULL,
+ "created" timestamp without time zone NOT NULL,
+ "created_by" integer DEFAULT 0 NOT NULL,
+ "modified" timestamp without time zone NOT NULL,
+ "modified_by" integer DEFAULT 0 NOT NULL,
+ PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "#__workflow_automation_rules_idx_transition" ON "#__workflow_automation_rules" ("transition_id");
+
+CREATE INDEX "#__workflow_automation_rules_idx_published" ON "#__workflow_automation_rules" ("published");
+
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."transition_id" IS 'Foreign Key to #__workflow_transitions.id';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."rule_type" IS 'delay or cron';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."delay_unit" IS 'minutes, hours, days, months';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."item_filter" IS 'JSON filter tree: which items this rule applies to (evaluated at selection)';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."fire_condition" IS 'JSON expression tree: gate evaluated live at fire time';
+
+COMMENT ON COLUMN "#__workflow_automation_rules"."run_as_user_id" IS 'User identity used to execute the transition';
+
+CREATE TABLE IF NOT EXISTS "#__workflow_item_state" (
+ "id" serial NOT NULL,
+ "item_id" integer DEFAULT 0 NOT NULL,
+ "extension" varchar(50) NOT NULL,
+ "stage_id" integer NOT NULL,
+ "entered_at" timestamp without time zone NOT NULL,
+ "triggered_by" varchar(20) DEFAULT 'manual' NOT NULL,
+ "requires_intervention" smallint DEFAULT 0 NOT NULL,
+ "last_checked_at" timestamp without time zone,
+ "last_failure_at" timestamp without time zone,
+ "last_failure_reason" varchar(500),
+ PRIMARY KEY ("id"),
+ CONSTRAINT "#__workflow_item_state_idx_item_extension" UNIQUE ("item_id", "extension")
+);
+
+CREATE INDEX "#__workflow_item_state_idx_stage_entered" ON "#__workflow_item_state" ("stage_id", "entered_at");
+
+CREATE INDEX "#__workflow_item_state_idx_requires_intervention" ON "#__workflow_item_state" ("requires_intervention");
+
+COMMENT ON COLUMN "#__workflow_item_state"."item_id" IS 'Extension table id value';
+
+COMMENT ON COLUMN "#__workflow_item_state"."stage_id" IS 'Foreign Key to #__workflow_stages.id';
+
+COMMENT ON COLUMN "#__workflow_item_state"."entered_at" IS 'When the item arrived in stage_id';
+
+COMMENT ON COLUMN "#__workflow_item_state"."triggered_by" IS 'Determine if a transition was triggered manually or by the automation';
+
+COMMENT ON COLUMN "#__workflow_item_state"."requires_intervention" IS 'Set when an automated transition failed; excluded from the scheduler until an admin clears it';
+
+COMMENT ON COLUMN "#__workflow_item_state"."last_checked_at" IS 'When the scheduler last considered this item; null means never';
+
+COMMENT ON COLUMN "#__workflow_item_state"."last_failure_at" IS 'When this item last could not be evaluated; null means it evaluated cleanly';
+
+COMMENT ON COLUMN "#__workflow_item_state"."last_failure_reason" IS 'Why it could not be evaluated, so the same failure is only reported once';
+
+CREATE TABLE IF NOT EXISTS "#__workflow_automation_log" (
+ "id" serial NOT NULL,
+ "rule_id" integer,
+ "item_id" integer DEFAULT 0 NOT NULL,
+ "extension" varchar(50) NOT NULL,
+ "transition_id" integer NOT NULL,
+ "from_stage_id" integer DEFAULT 0 NOT NULL,
+ "to_stage_id" integer DEFAULT 0 NOT NULL,
+ "run_as_user_id" integer DEFAULT 0 NOT NULL,
+ "trigger_type" varchar(20) DEFAULT 'rule' NOT NULL,
+ "exit_code" smallint DEFAULT 0 NOT NULL,
+ "note" varchar(500),
+ "executed_at" timestamp without time zone NOT NULL,
+ PRIMARY KEY ("id")
+);
+
+CREATE INDEX "#__workflow_automation_log_idx_item_id" ON "#__workflow_automation_log" ("item_id");
+
+CREATE INDEX "#__workflow_automation_log_idx_executed_at" ON "#__workflow_automation_log" ("executed_at");
+
+CREATE INDEX "#__workflow_automation_log_idx_exit_code" ON "#__workflow_automation_log" ("exit_code");
+
+COMMENT ON COLUMN "#__workflow_automation_log"."rule_id" IS 'Foreign Key to #__workflow_automation_rules.id';
+
+COMMENT ON COLUMN "#__workflow_automation_log"."exit_code" IS '0 ok, 1 permission denied, 2 invalid transition, 3 exception, 4 no longer applicable';
diff --git a/installation/sql/postgresql/extensions.sql b/installation/sql/postgresql/extensions.sql
index 2aac95993f3a9..7398a459d1c95 100644
--- a/installation/sql/postgresql/extensions.sql
+++ b/installation/sql/postgresql/extensions.sql
@@ -897,9 +897,11 @@ CREATE INDEX "#__scheduler_tasks_idx_checked_out" ON "#__scheduler_tasks" ("chec
INSERT INTO "#__scheduler_tasks" ("id", "asset_id", "title", "type", "execution_rules", "cron_rules", "state", "last_execution", "next_execution", "locked", "params", "created", "created_by") VALUES
(1, 97, 'Rotate Logs', 'rotation.logs', CONCAT('{"rule-type":"interval-days","interval-days":"30","exec-day":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'FMDD'), '","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"P30D"}', 1, NULL, TO_TIMESTAMP(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '30 days', 'YYYY-MM-DD HH24:00:00'), 'YYYY-MM-DD HH24:MI:SS'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"logstokeep":1}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42),
(2, 98, 'Session GC', 'session.gc', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, TO_TIMESTAMP(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '24 hours', 'YYYY-MM-DD HH24:00:00'), 'YYYY-MM-DD HH24:MI:SS'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"enable_session_gc":1,"enable_session_metadata_gc":1}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42),
-(3, 99, 'Update Notification', 'update.notification', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, TO_TIMESTAMP(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '24 hours', 'YYYY-MM-DD HH24:00:00'), 'YYYY-MM-DD HH24:MI:SS'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"email":"","language_override":""}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42);
+(3, 99, 'Update Notification', 'update.notification', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, TO_TIMESTAMP(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '24 hours', 'YYYY-MM-DD HH24:00:00'), 'YYYY-MM-DD HH24:MI:SS'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"email":"","language_override":""}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42),
+(4, 100, 'Workflow Automation', 'workflow.automation', CONCAT('{"rule-type":"interval-minutes","interval-minutes":"15","exec-day":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'FMDD'), '","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"PT15M"}', 1, NULL, TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '24 hours', 'YYYY-MM-DD HH24:MI:00')::timestamp, NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"}}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42);
+
+SELECT setval ( '#__scheduler_tasks_id_seq', 5, false );
-SELECT setval('#__scheduler_tasks_id_seq', 4, false);
-- --------------------------------------------------------
--
diff --git a/layouts/joomla/edit/global.php b/layouts/joomla/edit/global.php
index de34bdd4699e2..9185fd060ba37 100644
--- a/layouts/joomla/edit/global.php
+++ b/layouts/joomla/edit/global.php
@@ -30,6 +30,7 @@
$fields = $displayData->get('fields') ?: [
'transition',
+ 'upcoming_transition',
['parent', 'parent_id'],
['published', 'state', 'enabled'],
['category', 'catid'],
diff --git a/layouts/joomla/form/field/groupedlist-transition.php b/layouts/joomla/form/field/groupedlist-transition.php
index 8fe9f2c1e35cd..1975c187500a5 100644
--- a/layouts/joomla/form/field/groupedlist-transition.php
+++ b/layouts/joomla/form/field/groupedlist-transition.php
@@ -91,6 +91,7 @@
'COM_WORKFLOW_GRAPH_TRANSITION',
'COM_WORKFLOW_GRAPH_TRANSITIONS',
'COM_WORKFLOW_GRAPH_TRANSITION_ACTIONS',
+ 'COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED',
'COM_WORKFLOW_GRAPH_TRANSITION_COUNT',
'COM_WORKFLOW_GRAPH_TRANSITION_DESCRIPTION',
'COM_WORKFLOW_GRAPH_TRANSITION_PATH',
diff --git a/libraries/src/Event/Workflow/WorkflowConditionFieldsEvent.php b/libraries/src/Event/Workflow/WorkflowConditionFieldsEvent.php
new file mode 100644
index 0000000000000..4d29d0d533cfc
--- /dev/null
+++ b/libraries/src/Event/Workflow/WorkflowConditionFieldsEvent.php
@@ -0,0 +1,175 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\CMS\Event\Workflow;
+
+use Joomla\Event\Event;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Collects the checks that automation filters and conditions may be built on.
+ *
+ * Plugins call addField() once per check they provide. Item checks, such as a tag, are offered in
+ * a rule's filter, and moment checks, such as the day of the week, in its fire condition.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class WorkflowConditionFieldsEvent extends Event
+{
+ /**
+ * A check that describes the item, offered in a rule's filter.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ public const SCOPE_ITEM = 'item';
+
+ /**
+ * A check that describes the current moment, offered in a rule's condition.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ public const SCOPE_MOMENT = 'moment';
+
+ /**
+ * The comparisons a check may declare support for. The values are stored inside saved
+ * rules, so they must never change.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ public const OPERATOR_IS = 'is';
+
+ public const OPERATOR_IS_NOT = 'is not';
+
+ public const OPERATOR_IN = 'in';
+
+ public const OPERATOR_NOT_IN = 'not in';
+
+ public const OPERATOR_HAS_ANY = 'has any';
+
+ public const OPERATOR_HAS_ALL = 'has all';
+
+ public const OPERATOR_HAS_NONE = 'has none';
+
+ public const OPERATOR_BEFORE = 'before';
+
+ public const OPERATOR_AFTER = 'after';
+
+ public const OPERATOR_ON = 'on';
+
+ public const OPERATOR_NOT_ON = 'not on';
+
+ public const OPERATOR_GREATER_THAN = 'greater than';
+
+ public const OPERATOR_LESS_THAN = 'less than';
+
+ /**
+ * How a check's value is entered. Select and multiselect pick from the options
+ * a check supplies.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+
+ public const VALUE_SELECT = 'select';
+
+ public const VALUE_MULTISELECT = 'multiselect';
+
+ public const VALUE_DATE = 'date';
+
+ public const VALUE_TEXT = 'text';
+
+ public const VALUE_NUMBER = 'number';
+
+ /**
+ * Constructor.
+ *
+ * @param string $name The event name.
+ * @param array $arguments Must contain 'extension', e.g. com_content.article.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct($name, array $arguments = [])
+ {
+ $arguments['fields'] = [];
+
+ parent::__construct($name, $arguments);
+ }
+
+ /**
+ * The workflow extension the builder is being drawn for, e.g. com_content.article.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getExtension(): string
+ {
+ return (string) ($this->arguments['extension'] ?? '');
+ }
+
+ /**
+ * Offers one check to the condition builder.
+ *
+ * @param string $name Unique key for the check, stored in the saved rule. Prefix
+ * it with your extension to avoid clashing, e.g. "fields.priority".
+ * @param string $label Translated label shown in the field dropdown.
+ * @param string $scope self::SCOPE_ITEM or self::SCOPE_MOMENT.
+ * @param string[] $operators Operator keys this check supports, e.g. ['is', 'is not'].
+ * Only keys com_workflow knows are offered, so a check written
+ * against a newer Joomla loses that operator rather than
+ * breaking the builder.
+ * @param string $valueType How the value is entered: select or multiselect to pick from
+ * $options, or date, text or number to type a value freely.
+ * @param array $options Selectable values as ['value' => ..., 'label' => ...] pairs.
+ * Ignored by the typed value types.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function addField(
+ string $name,
+ string $label,
+ string $scope,
+ array $operators,
+ string $valueType = 'select',
+ array $options = []
+ ): void {
+ $fields = $this->arguments['fields'];
+
+ $fields[$name] = [
+ 'name' => $name,
+ 'label' => $label,
+ 'scope' => $scope === self::SCOPE_MOMENT ? self::SCOPE_MOMENT : self::SCOPE_ITEM,
+ 'operators' => $operators,
+ 'valueType' => $valueType,
+ 'options' => $options,
+ ];
+
+ $this->arguments['fields'] = $fields;
+ }
+
+ /**
+ * Every check offered, keyed by name.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getFields(): array
+ {
+ return $this->arguments['fields'];
+ }
+}
diff --git a/libraries/src/Event/Workflow/WorkflowResolveFieldsEvent.php b/libraries/src/Event/Workflow/WorkflowResolveFieldsEvent.php
new file mode 100644
index 0000000000000..75f3840adc844
--- /dev/null
+++ b/libraries/src/Event/Workflow/WorkflowResolveFieldsEvent.php
@@ -0,0 +1,140 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\CMS\Event\Workflow;
+
+use Joomla\Event\Event;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Asks plugins for the value of one check across a set of items.
+ *
+ * Batched, so a plugin can answer for every item with one query. A moment check returns the
+ * same value for every id, computed at the evaluation time rather than now.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class WorkflowResolveFieldsEvent extends Event
+{
+ /**
+ * Constructor.
+ *
+ * @param string $name The event name.
+ * @param array $arguments Must contain 'field', 'itemIds', 'extension' and may contain
+ * 'evaluationTime'.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct($name, array $arguments = [])
+ {
+ $arguments['values'] = [];
+ $arguments['answered'] = false;
+
+ parent::__construct($name, $arguments);
+ }
+
+ /**
+ * The check being resolved, as named by addField().
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getField(): string
+ {
+ return (string) ($this->arguments['field'] ?? '');
+ }
+
+ /**
+ * The items to resolve it for.
+ *
+ * @return int[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getItemIds(): array
+ {
+ return (array) ($this->arguments['itemIds'] ?? []);
+ }
+
+ /**
+ * The workflow extension, e.g. com_content.article.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getExtension(): string
+ {
+ return (string) ($this->arguments['extension'] ?? '');
+ }
+
+ /**
+ * The moment to describe, for checks that depend on the clock. Null means now.
+ *
+ * @return \DateTime|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getEvaluationTime(): ?\DateTime
+ {
+ return $this->arguments['evaluationTime'] ?? null;
+ }
+
+ /**
+ * Supplies the resolved values, and claims the check.
+ *
+ * Call this even with an empty array when the check is yours, so it counts as claimed. Leave
+ * out any item you cannot answer for rather than guessing, and no rule will fire on it.
+ *
+ * @param array $values Item id to value. A value may be a scalar or a list, depending
+ * on the check.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function setValues(array $values): void
+ {
+ $this->arguments['values'] = $values + $this->arguments['values'];
+ $this->arguments['answered'] = true;
+
+ $this->stopPropagation();
+ }
+
+ /**
+ * Whether any plugin claimed this check, regardless of how many items it answered for.
+ *
+ * An owner that answered for no items is not the same as no owner at all. No owner means the
+ * extension that provided the check is gone.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function isAnswered(): bool
+ {
+ return $this->arguments['answered'] === true;
+ }
+
+ /**
+ * The resolved values, keyed by item id.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getValues(): array
+ {
+ return $this->arguments['values'];
+ }
+}
diff --git a/libraries/src/Event/Workflow/WorkflowTransitionEvent.php b/libraries/src/Event/Workflow/WorkflowTransitionEvent.php
index b1f37a105c88a..3c103ea0c2b00 100644
--- a/libraries/src/Event/Workflow/WorkflowTransitionEvent.php
+++ b/libraries/src/Event/Workflow/WorkflowTransitionEvent.php
@@ -54,4 +54,108 @@ public function setStopTransition($value = true)
$this->stopPropagation();
}
}
+
+ /**
+ * Pre-process the pks argument.
+ *
+ * @param mixed $value The raw argument value.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function onGetPks($value): array
+ {
+ return (array) $value;
+ }
+
+ /**
+ * Get the primary keys of the items being transitioned.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getPks(): array
+ {
+ return $this->getArgument('pks');
+ }
+
+ /**
+ * Pre-process the extension argument.
+ *
+ * @param mixed $value The raw argument value.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function onGetExtension($value): string
+ {
+ return (string) $value;
+ }
+
+ /**
+ * Get the extension context (e.g. com_content.article).
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getExtension(): string
+ {
+ return $this->getArgument('extension');
+ }
+
+ /**
+ * Pre-process the transition argument.
+ *
+ * @param mixed $value The raw argument value.
+ *
+ * @return object
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function onGetTransition($value): object
+ {
+ return (object) $value;
+ }
+
+ /**
+ * Get the transition object being executed.
+ *
+ * @return object
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getTransition(): object
+ {
+ return $this->getArgument('transition');
+ }
+
+ /**
+ * Pre-process the triggeredBy argument.
+ *
+ * @param mixed $value The raw argument value
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function onGetTriggeredBy($value): string
+ {
+ return (string) ($value ?? 'manual');
+ }
+
+ /**
+ * Get the trigger source - 'manual' or 'automation'
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function getTriggeredBy(): string
+ {
+ return $this->getArgument('triggeredBy');
+ }
}
diff --git a/libraries/src/Extension/ExtensionHelper.php b/libraries/src/Extension/ExtensionHelper.php
index 9d52b7dc900be..a86d602df86ca 100644
--- a/libraries/src/Extension/ExtensionHelper.php
+++ b/libraries/src/Extension/ExtensionHelper.php
@@ -331,6 +331,7 @@ class ExtensionHelper
['plugin', 'sessiongc', 'task', 0],
['plugin', 'sitestatus', 'task', 0],
['plugin', 'updatenotification', 'task', 0],
+ ['plugin', 'workflowtransition', 'task', 0],
// Core plugin extensions - user
['plugin', 'contactcreator', 'user', 0],
@@ -360,6 +361,7 @@ class ExtensionHelper
['plugin', 'users', 'webservices', 0],
// Core plugin extensions - workflow
+ ['plugin', 'automation', 'workflow', 0],
['plugin', 'featuring', 'workflow', 0],
['plugin', 'notification', 'workflow', 0],
['plugin', 'publishing', 'workflow', 0],
diff --git a/libraries/src/Workflow/TransitionStatus.php b/libraries/src/Workflow/TransitionStatus.php
new file mode 100644
index 0000000000000..a0cb96195d325
--- /dev/null
+++ b/libraries/src/Workflow/TransitionStatus.php
@@ -0,0 +1,58 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\CMS\Workflow;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * A namespace mapping transition outcomes to integer values.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+abstract class TransitionStatus
+{
+ /**
+ * The transition ran, or there was nothing to move.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public const SUCCESS = 0;
+
+ /**
+ * The transition does not exist, or the current user may not run it.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public const INVALID_TRANSITION = 1;
+
+ /**
+ * An item is not in the stage the transition starts from, or belongs to another workflow.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public const STAGE_MISMATCH = 2;
+
+ /**
+ * A plugin vetoed the transition through onWorkflowBeforeTransition.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public const STOPPED_BY_PLUGIN = 3;
+
+ /**
+ * The stage change itself could not be written.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public const UPDATE_FAILED = 4;
+}
diff --git a/libraries/src/Workflow/Workflow.php b/libraries/src/Workflow/Workflow.php
index 809f2c51aaaf5..b071ac53fa4e5 100644
--- a/libraries/src/Workflow/Workflow.php
+++ b/libraries/src/Workflow/Workflow.php
@@ -345,22 +345,43 @@ public function getValidTransition(array $pks, int $transitionId)
*
* @param integer[] $pks The item IDs, which should use the transition
* @param integer $transitionId The transition which should be executed
+ * @param string $triggeredBy What caused the transition to run
*
* @return boolean
*/
- public function executeTransition(array $pks, int $transitionId): bool
+ public function executeTransition(array $pks, int $transitionId, string $triggeredBy = 'manual'): bool
+ {
+ return $this->attemptTransition($pks, $transitionId, $triggeredBy) === TransitionStatus::SUCCESS;
+ }
+
+ /**
+ * Executes a transition and reports which outcome it reached.
+ *
+ * Unlike executeTransition(), which returns a boolean, this tells a stage mismatch apart from a
+ * permission failure or a plugin veto.
+ *
+ * @param integer[] $pks The item IDs, which should use the transition
+ * @param integer $transitionId The transition which should be executed
+ * @param string $triggeredBy What caused the transition to run
+ *
+ * @return integer A TransitionStatus constant
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function attemptTransition(array $pks, int $transitionId, string $triggeredBy = 'manual'): int
{
$pks = ArrayHelper::toInteger($pks);
$pks = array_filter($pks);
+ // Nothing to move is not a failure, and executeTransition() has always answered true here.
if (!\count($pks)) {
- return true;
+ return TransitionStatus::SUCCESS;
}
$transition = $this->getValidTransition($pks, $transitionId);
if (\is_null($transition)) {
- return false;
+ return TransitionStatus::INVALID_TRANSITION;
}
$transition->options = new Registry($transition->options);
@@ -376,7 +397,7 @@ public function executeTransition(array $pks, int $transitionId): bool
-1,
]) || $transition->workflow_id !== $assoc->workflow_id
) {
- return false;
+ return TransitionStatus::STAGE_MISMATCH;
}
}
@@ -398,7 +419,7 @@ public function executeTransition(array $pks, int $transitionId): bool
);
if ($eventResult->getArgument('stopTransition')) {
- return false;
+ return TransitionStatus::STOPPED_BY_PLUGIN;
}
$success = $this->updateAssociations($pks, (int) $transition->to_stage_id);
@@ -409,17 +430,18 @@ public function executeTransition(array $pks, int $transitionId): bool
AbstractEvent::create(
'onWorkflowAfterTransition',
[
- 'eventClass' => WorkflowTransitionEvent::class,
- 'subject' => $this,
- 'extension' => $this->extension,
- 'pks' => $pks,
- 'transition' => $transition,
+ 'eventClass' => WorkflowTransitionEvent::class,
+ 'subject' => $this,
+ 'extension' => $this->extension,
+ 'pks' => $pks,
+ 'transition' => $transition,
+ 'triggeredBy' => $triggeredBy,
]
)
);
}
- return $success;
+ return $success ? TransitionStatus::SUCCESS : TransitionStatus::UPDATE_FAILED;
}
/**
diff --git a/media_source/com_workflow/joomla.asset.json b/media_source/com_workflow/joomla.asset.json
index 99b29813026a9..8dc4df01fa129 100644
--- a/media_source/com_workflow/joomla.asset.json
+++ b/media_source/com_workflow/joomla.asset.json
@@ -9,13 +9,25 @@
"name": "com_workflow.admin-items-workflow-buttons",
"type": "script",
"uri": "com_workflow/admin-items-workflow-buttons.min.js",
- "dependencies": [
- "core"
- ],
+ "dependencies": ["core"],
"attributes": {
"type": "module"
}
},
+ {
+ "name": "com_workflow.condition-builder",
+ "type": "style",
+ "uri": "com_workflow/condition-builder.css"
+ },
+ {
+ "name": "com_workflow.condition-builder",
+ "type": "script",
+ "uri": "com_workflow/condition-builder.js",
+ "dependencies": ["core", "joomla.dialog"],
+ "attributes": {
+ "defer": true
+ }
+ },
{
"name": "com_workflow.workflowgraph",
"type": "style",
@@ -25,15 +37,13 @@
"name": "com_workflow.workflowgraph",
"type": "script",
"uri": "com_workflow/workflow-graph.min.js",
- "dependencies": [
- "core"
- ],
+ "dependencies": ["core"],
"attributes": {
"type": "module"
}
},
{
- "name" : "com_workflow.workflowgraphclient",
+ "name": "com_workflow.workflowgraphclient",
"type": "style",
"uri": "com_workflow/workflow-graph-client.min.css"
},
@@ -41,9 +51,7 @@
"name": "com_workflow.workflowgraphclient",
"type": "script",
"uri": "com_workflow/workflow-graph-client.min.js",
- "dependencies": [
- "core"
- ],
+ "dependencies": ["core"],
"attributes": {
"type": "module"
}
diff --git a/media_source/com_workflow/js/condition-builder.es6.js b/media_source/com_workflow/js/condition-builder.es6.js
new file mode 100644
index 0000000000000..ccb303959af31
--- /dev/null
+++ b/media_source/com_workflow/js/condition-builder.es6.js
@@ -0,0 +1,934 @@
+/**
+ * @copyright (C) 2026 Open Source Matters, Inc.
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+/**
+ * Builds the JSON expression stored by ConditionbuilderField. See ConditionEvaluator for its shape.
+ */
+((document) => {
+ 'use strict';
+
+ // Tiny DOM helper. Keys with "-" become attributes; "text" sets textContent.
+ const el = (tag, attrs, ...children) => {
+ const node = document.createElement(tag);
+ Object.entries(attrs || {}).forEach(([key, value]) => {
+ if (key === 'class') node.className = value;
+ else if (key === 'text') node.textContent = value;
+ else if (key === 'role' || key.includes('-')) node.setAttribute(key, value);
+ else {
+ try {
+ node[key] = value;
+ } catch (error) {
+ node.setAttribute(key, value);
+ }
+ }
+ });
+ children.flat().forEach((child) => {
+ if (child === null || child === undefined || child === false) return;
+ node.appendChild(
+ typeof child === 'string' ? document.createTextNode(child) : child,
+ );
+ });
+ return node;
+ };
+
+ class ConditionBuilder {
+ constructor(root) {
+ this.root = root;
+ this.input = root.querySelector('input[type="hidden"]');
+ this.config = JSON.parse(root.dataset.config || '{}');
+ this.tree = this.deserialize(this.input.value);
+
+ // Simple mode joins checks with AND and nothing else. A rule saved with OR, sub-expressions or NOT
+ // opens in the full builder anyway, so switching expert mode off never hides part of a rule.
+ this.simple = !this.config.expert && this.fitsSimpleMode(this.tree);
+
+ // { path, kind } while the AND/OR prompt for a new row is showing, otherwise null.
+ this.pendingAdd = null;
+
+ this.ui = el('div', { class: 'cb-ui' });
+ root.appendChild(this.ui);
+
+ root.addEventListener('click', (event) => this.onClick(event));
+ root.addEventListener('change', (event) => this.onChange(event));
+
+ // Without this a typed value only commits on blur, so saving from the keyboard would store
+ // the value from before the last edit.
+ root.addEventListener('input', (event) => {
+ if (
+ event.target.tagName === 'INPUT'
+ && event.target.getAttribute('data-role') === 'value'
+ ) {
+ this.onChange(event);
+ }
+ });
+
+ this.render();
+ }
+
+ deserialize(jsonString) {
+ let parsed = null;
+ try {
+ parsed = JSON.parse(jsonString);
+ } catch (error) {
+ parsed = null;
+ }
+ if (!parsed || typeof parsed !== 'object') {
+ return this.emptyChain();
+ }
+ return this.nodeFromJson(parsed);
+ }
+
+ nodeFromJson(node) {
+ if (node && typeof node.field !== 'undefined') {
+ return {
+ type: 'check',
+ field: node.field,
+ operator: node.operator || '',
+ value: node.value ?? '',
+ not: node.not === true,
+ };
+ }
+
+ const items = Array.isArray(node.items)
+ ? node.items.map((child) => this.nodeFromJson(child))
+ : [];
+ const wanted = Math.max(0, items.length - 1);
+ const ops = Array.isArray(node.ops)
+ ? node.ops.slice(0, wanted).map((op) => (op === 'or' ? 'or' : 'and'))
+ : [];
+ while (ops.length < wanted) ops.push('and');
+
+ return { type: 'chain', not: node.not === true, items, ops };
+ }
+
+ nodeToJson(node) {
+ if (node.type === 'check') {
+ // Kept even when incomplete: the server refuses to save it and the form comes back with the
+ // expression intact, instead of it being dropped without a word.
+ const json = {
+ field: node.field,
+ operator: node.operator,
+ value: node.value,
+ };
+ if (node.not) json.not = true;
+ return json;
+ }
+
+ // An empty sub-expression holds nothing the user entered, so it is dropped with its connector.
+ const items = [];
+ const ops = [];
+
+ node.items.forEach((child, index) => {
+ const childJson = this.nodeToJson(child);
+ if (childJson === null) return;
+ if (items.length > 0) {
+ ops.push(node.ops[index - 1] === 'or' ? 'or' : 'and');
+ }
+ items.push(childJson);
+ });
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ const json = { items, ops };
+ if (node.not) json.not = true;
+ return json;
+ }
+
+ sync() {
+ const serialised = this.nodeToJson(this.tree);
+ this.input.value = serialised ? JSON.stringify(serialised) : '';
+
+ // Any edit makes a previous answer describe an expression that no longer exists.
+ const output = this.root.querySelector('[data-role="preview-output"]');
+
+ if (output) {
+ output.className = 'w-100 small';
+ output.textContent = '';
+ }
+ }
+
+ fitsSimpleMode(tree) {
+ return (
+ !tree.not
+ && tree.ops.every((op) => op === 'and')
+ && tree.items.every((item) => item.type === 'check' && !item.not)
+ );
+ }
+
+ emptyChain() {
+ return { type: 'chain', not: false, items: [], ops: [] };
+ }
+
+ emptyCheck() {
+ const firstField = ((this.config.fields || [])[0] || {}).value || '';
+ return {
+ type: 'check',
+ field: firstField,
+ operator: this.firstOperator(firstField),
+ value: this.emptyValue(firstField),
+ not: false,
+ };
+ }
+
+ firstOperator(field) {
+ const operators = (this.config.operators || {})[field] || [];
+ return (operators[0] || {}).value || '';
+ }
+
+ emptyValue(field) {
+ return (this.config.valueTypes || {})[field] === 'multiselect' ? [] : '';
+ }
+
+ nodeAtPath(path) {
+ if (!path) return this.tree;
+ return path
+ .split('.')
+ .reduce((node, index) => node.items[Number(index)], this.tree);
+ }
+
+ removeAt(path) {
+ const parts = path.split('.');
+ const index = Number(parts.pop());
+ const parent = this.nodeAtPath(parts.join('.'));
+
+ parent.items.splice(index, 1);
+
+ // Removing a row also removes the operator that joined it: the one
+ // before it, or the first one when the removed row was on top.
+ if (parent.ops.length > 0) {
+ parent.ops.splice(index === 0 ? 0 : index - 1, 1);
+ }
+ }
+
+ onClick(event) {
+ const button = event.target.closest('[data-action]');
+ if (!button || !this.root.contains(button)) return;
+ event.preventDefault();
+
+ const pathEl = button.closest('[data-path]');
+ const path = pathEl ? pathEl.getAttribute('data-path') : '';
+ const action = button.getAttribute('data-action');
+ const chain = this.nodeAtPath(path);
+
+ if (action === 'add-check' && this.simple) {
+ if (chain.items.length >= 1) {
+ chain.ops.push('and');
+ }
+ chain.items.push(this.emptyCheck());
+ this.render();
+ return;
+ }
+
+ if (action === 'add-check' || action === 'add-expression') {
+ const kind = action === 'add-check' ? 'check' : 'chain';
+
+ if (chain.items.length >= 1) {
+ this.pendingAdd = { path, kind };
+ } else {
+ chain.items.push(
+ kind === 'check' ? this.emptyCheck() : this.emptyChain(),
+ );
+ this.pendingAdd = null;
+ }
+ this.render();
+ return;
+ }
+
+ if (action === 'preview') {
+ this.runPreview();
+ return;
+ }
+
+ if (action === 'choose-op') {
+ if (this.pendingAdd && this.pendingAdd.path === path) {
+ chain.ops.push(
+ button.getAttribute('data-op') === 'or' ? 'or' : 'and',
+ );
+ chain.items.push(
+ this.pendingAdd.kind === 'check'
+ ? this.emptyCheck()
+ : this.emptyChain(),
+ );
+ }
+ this.pendingAdd = null;
+ this.render();
+ return;
+ }
+
+ if (action === 'cancel-add') {
+ this.pendingAdd = null;
+ this.render();
+ return;
+ }
+
+ if (action === 'remove') {
+ this.pendingAdd = null;
+ this.removeAt(path);
+ this.render();
+ this.focusAfterRemove(path);
+ }
+ }
+
+ onChange(event) {
+ const role = event.target.getAttribute('data-role');
+ if (!role) return;
+
+ const node = this.nodeAtPath(
+ event.target.closest('[data-path]').getAttribute('data-path'),
+ );
+
+ if (role === 'op') {
+ const opIndex = Number(event.target.getAttribute('data-index'));
+ node.ops[opIndex] = event.target.value === 'or' ? 'or' : 'and';
+ this.sync();
+ return;
+ }
+
+ if (role === 'field') {
+ node.field = event.target.value;
+ node.operator = this.firstOperator(node.field);
+ node.value = this.emptyValue(node.field);
+ this.render();
+ return;
+ }
+
+ if (role === 'operator') node.operator = event.target.value;
+ else if (role === 'value')
+ node.value = this.readValue(event.target, node.field);
+ else if (role === 'not') node.not = event.target.checked;
+
+ this.sync();
+ }
+
+ readValue(target, field) {
+ if ((this.config.valueTypes || {})[field] === 'multiselect') {
+ return Array.from(target.selectedOptions).map((option) => option.value);
+ }
+ return target.value;
+ }
+
+ render() {
+ this.ui.innerHTML = '';
+ this.ui.appendChild(this.renderChain(this.tree, '', true));
+ this.sync();
+ }
+
+ focusAfterRemove(path) {
+ const replacement = this.ui.querySelector('[data-path="' + path + '"]');
+ const target = replacement
+ ? replacement.querySelector(
+ '[data-role="field"], button[data-action="remove"]',
+ )
+ : this.ui.querySelector('button[data-action="add-check"]');
+
+ if (target) target.focus();
+ }
+
+ renderChain(node, path, isRoot) {
+ const text = this.config.text;
+ const parts = [];
+
+ if (!isRoot) {
+ parts.push(
+ el(
+ 'div',
+ { class: 'cb-expression-head' },
+ el('span', {
+ class: 'cb-chip cb-chip-expression',
+ text: text.expression,
+ }),
+ this.renderNot(node),
+ el('button', {
+ type: 'button',
+ class: 'btn btn-sm btn-danger cb-remove',
+ 'data-action': 'remove',
+ 'aria-label': text.remove,
+ text: '\u00d7',
+ }),
+ ),
+ );
+ }
+
+ const list = el('div', {
+ class: isRoot ? 'cb-list cb-list-root' : 'cb-list',
+ });
+
+ node.items.forEach((child, index) => {
+ if (index > 0) {
+ list.appendChild(
+ el(
+ 'div',
+ { class: 'cb-band' },
+ this.simple
+ ? el('span', { class: 'cb-join-label', text: text.opAnd })
+ : this.renderOpSelect(node, index - 1),
+ ),
+ );
+ }
+ const childPath = path === '' ? String(index) : path + '.' + index;
+ list.appendChild(
+ child.type === 'check'
+ ? this.renderCheck(child, childPath)
+ : this.renderChain(child, childPath, false),
+ );
+ });
+
+ if (node.items.length === 0) {
+ list.appendChild(
+ el('div', {
+ class: 'cb-empty',
+ text: isRoot ? text.empty : text.emptyExpression,
+ }),
+ );
+ }
+ parts.push(list);
+
+ parts.push(this.renderAddArea(path));
+
+ const attrs = {
+ class: isRoot ? 'cb-root' : 'cb-expression',
+ 'data-path': path,
+ };
+
+ if (!isRoot) {
+ // Without this the nesting is invisible: checks inside a sub-expression are announced
+ // as siblings of the ones outside it, which inverts the meaning of the rule.
+ attrs.role = 'group';
+ attrs['aria-label'] = text.expression;
+ }
+
+ return el('div', attrs, ...parts);
+ }
+
+ renderOpSelect(chainNode, opIndex) {
+ const text = this.config.text;
+ const select = el('select', {
+ class: 'form-select cb-op',
+ 'data-role': 'op',
+ 'data-index': String(opIndex),
+ 'aria-label': text.joinWith,
+ });
+
+ [
+ ['and', text.opAnd],
+ ['or', text.opOr],
+ ].forEach(([value, label]) => {
+ const option = el('option', { value, text: label });
+ if (value === chainNode.ops[opIndex]) option.selected = true;
+ select.appendChild(option);
+ });
+
+ return select;
+ }
+
+ renderAddArea(path) {
+ const text = this.config.text;
+
+ if (this.pendingAdd && this.pendingAdd.path === path) {
+ return el(
+ 'div',
+ { class: 'cb-add cb-add-op' },
+ el('span', { class: 'cb-join-label', text: text.joinWith }),
+ el(
+ 'button',
+ {
+ type: 'button',
+ class: 'btn btn-sm btn-primary',
+ 'data-action': 'choose-op',
+ 'data-op': 'and',
+ },
+ text.opAnd,
+ ),
+ el(
+ 'button',
+ {
+ type: 'button',
+ class: 'btn btn-sm btn-primary',
+ 'data-action': 'choose-op',
+ 'data-op': 'or',
+ },
+ text.opOr,
+ ),
+ el(
+ 'button',
+ {
+ type: 'button',
+ class: 'btn btn-sm btn-link cb-cancel-add',
+ 'data-action': 'cancel-add',
+ },
+ text.cancel,
+ ),
+ );
+ }
+
+ const addArea = el(
+ 'div',
+ { class: 'cb-add flex-wrap align-items-center' },
+ el(
+ 'button',
+ {
+ type: 'button',
+ class: 'btn btn-sm btn-success',
+ 'data-action': 'add-check',
+ },
+ '+ ' + text.addCheck,
+ ),
+ );
+
+ if (!this.simple) {
+ addArea.appendChild(
+ el(
+ 'button',
+ {
+ type: 'button',
+ class: 'btn btn-sm btn-success',
+ 'data-action': 'add-expression',
+ },
+ '+ ' + text.addExpression,
+ ),
+ );
+ }
+
+ const expertHint = path === '' && (this.simple ? text.expertHint : text.expertOffHint)
+ ? this.renderExpertHint()
+ : null;
+
+ if (this.config.preview && path === '' && this.tree.items.length > 0) {
+ addArea.appendChild(
+ el(
+ 'button',
+ {
+ type: 'button',
+ class: 'btn btn-sm btn-secondary',
+ 'data-action': 'preview',
+ },
+ text.preview,
+ ),
+ );
+
+ // Before the output, which takes a line of its own, so the hint stays on the button row.
+ if (expertHint) {
+ addArea.appendChild(expertHint);
+ }
+
+ addArea.appendChild(
+ el('div', {
+ class: 'w-100 small',
+ 'data-role': 'preview-output',
+ 'aria-live': 'polite',
+ }),
+ );
+ } else if (expertHint) {
+ addArea.appendChild(expertHint);
+ }
+
+ return addArea;
+ }
+
+ renderExpertHint() {
+ const text = this.config.text;
+ const sentence = this.simple ? text.expertHint : text.expertOffHint;
+ const linkText = this.simple ? text.expertLink : text.expertOffLink;
+ // One sentence with %s where the link goes, so it stays translatable as a whole.
+ const [before, after] = sentence.split('%s');
+ const hint = el('div', { class: 'small' }, before || '');
+
+ hint.appendChild(
+ this.config.expertDialog
+ ? this.renderExpertLink(linkText || '')
+ : document.createTextNode(linkText || ''),
+ );
+ hint.appendChild(document.createTextNode(after || ''));
+
+ return hint;
+ }
+
+ renderExpertLink(label) {
+ const dialog = this.config.expertDialog;
+
+ return el('a', {
+ href: '#',
+ text: label,
+ 'data-joomla-dialog': JSON.stringify({
+ popupType: 'iframe',
+ textHeader: this.config.text.expertHeader || label,
+ src: dialog.src,
+ }),
+ 'data-checkin-url': dialog.checkinUrl,
+ 'data-close-on-message': '',
+ 'data-reload-on-close': '',
+ });
+ }
+
+ renderCheck(node, path) {
+ const text = this.config.text;
+ return el(
+ 'div',
+ { class: 'cb-leaf', 'data-path': path },
+ this.simple
+ ? null
+ : el('span', { class: 'cb-chip cb-chip-check', text: text.check }),
+ this.simple ? null : this.renderNot(node),
+ el(
+ 'div',
+ { class: 'cb-leaf-fields' },
+ this.renderFieldSelect(node),
+ this.renderOperatorSelect(node),
+ this.renderValue(node),
+ ),
+ el('button', {
+ type: 'button',
+ class: 'btn btn-sm btn-danger cb-remove',
+ 'data-action': 'remove',
+ 'aria-label': this.removeCheckLabel(node),
+ text: '\u00d7',
+ }),
+ );
+ }
+
+ removeCheckLabel(node) {
+ const text = this.config.text;
+ const field = (this.config.fields || []).find(
+ (f) => f.value === node.field,
+ );
+
+ return field && text.removeCheck
+ ? text.removeCheck.replace('%s', field.label)
+ : text.remove;
+ }
+
+ renderNot(node) {
+ const label = el('label', { class: 'cb-not' });
+ const checkbox = el('input', { type: 'checkbox', 'data-role': 'not' });
+ checkbox.checked = !!node.not;
+ label.appendChild(checkbox);
+ label.appendChild(document.createTextNode(' ' + this.config.text.negate));
+ return label;
+ }
+
+ renderFieldSelect(node) {
+ const select = el('select', {
+ class: 'form-select',
+ 'data-role': 'field',
+ 'aria-label': this.config.text.field,
+ });
+ this.config.fields.forEach((field) => {
+ const option = el('option', { value: field.value, text: field.label });
+ if (field.value === node.field) option.selected = true;
+ select.appendChild(option);
+ });
+ return select;
+ }
+
+ renderOperatorSelect(node) {
+ const select = el('select', {
+ class: 'form-select',
+ 'data-role': 'operator',
+ 'aria-label': this.config.text.operator,
+ });
+ ((this.config.operators || {})[node.field] || []).forEach((operator) => {
+ const option = el('option', {
+ value: operator.value,
+ text: operator.label,
+ });
+ if (operator.value === node.operator) option.selected = true;
+ select.appendChild(option);
+ });
+ return select;
+ }
+
+ renderValue(node) {
+ const type = (this.config.valueTypes || {})[node.field];
+
+ if (type === 'date' || type === 'text' || type === 'number') {
+ return el('input', {
+ type: type,
+ class: 'form-control',
+ 'data-role': 'value',
+ 'aria-label': this.config.text.value,
+ value:
+ node.value === null || node.value === undefined
+ ? ''
+ : String(node.value),
+ });
+ }
+
+ const options = (this.config.valueOptions || {})[node.field] || [];
+ const multiple = type === 'multiselect';
+ const selected = multiple
+ ? Array.isArray(node.value)
+ ? node.value.map(String)
+ : []
+ : [String(node.value)];
+
+ const select = el('select', {
+ class: 'form-select',
+ 'data-role': 'value',
+ 'aria-label': this.config.text.value,
+ });
+ if (multiple) select.multiple = true;
+ else select.appendChild(el('option', { value: '', text: '\u2014' }));
+
+ options.forEach((choice) => {
+ const option = el('option', {
+ value: choice.value,
+ text: choice.label,
+ });
+ if (selected.includes(String(choice.value))) {
+ option.selected = true;
+ option.setAttribute('selected', 'selected');
+ }
+ select.appendChild(option);
+ });
+
+ if (multiple) {
+ const fancy = el('joomla-field-fancy-select', {
+ placeholder: (this.config.text || {}).placeholder || '',
+ });
+ fancy.appendChild(select);
+ return fancy;
+ }
+ return select;
+ }
+
+ async runPreview() {
+ const preview = this.config.preview;
+ const text = this.config.text || {};
+ const output = this.root.querySelector('[data-role="preview-output"]');
+ const button = this.root.querySelector('[data-action="preview"]');
+
+ if (!output) return;
+
+ if (button) button.disabled = true;
+ output.className = 'w-100 small';
+ output.textContent = '';
+ output.appendChild(el('div', { text: text.previewRunning || '' }));
+
+ const body = new FormData();
+ body.append('extension', preview.extension);
+ body.append('workflow_id', preview.workflowId);
+ body.append('transition_id', preview.transitionId);
+ body.append('item_filter', this.input.value);
+ body.append(preview.token, '1');
+
+ try {
+ const response = await fetch(preview.url, {
+ method: 'POST',
+ body,
+ });
+
+ if (!response.ok) {
+ throw new Error(`${response.status} ${response.statusText}`);
+ }
+
+ const payload = await response.json();
+
+ if (!payload.success) {
+ output.className = 'w-100 small text-danger';
+ output.textContent = payload.message || '';
+ return;
+ }
+
+ this.showPreviewResult(output, payload.data);
+ } catch (error) {
+ output.className = 'w-100 small text-danger';
+ output.textContent = error.message;
+ } finally {
+ if (button) button.disabled = false;
+ }
+ }
+
+ showPreviewResult(output, data) {
+ const text = this.config.text || {};
+
+ output.textContent = '';
+
+ if (!data.scanned) {
+ output.appendChild(el('div', { text: text.previewEmpty || '' }));
+ return;
+ }
+
+ const template = data.capped ? text.previewCapped : text.previewResult;
+
+ output.appendChild(
+ el('div', {
+ text: (template || '%1$s / %2$s')
+ .replaceAll('%1$s', data.matched)
+ .replaceAll('%2$s', data.scanned),
+ }),
+ );
+
+ const items = data.items || [];
+
+ if (!items.length) return;
+
+ const inline = el('div', { class: 'mt-1' });
+ items.slice(0, 5).forEach((item, index) => {
+ if (index) inline.appendChild(document.createTextNode(', '));
+ inline.appendChild(this.previewItemLink(item, data.extension));
+ });
+
+ output.appendChild(inline);
+
+ if (data.matched > 5) {
+ const showAll = el('button', {
+ type: 'button',
+ class: 'btn btn-link btn-sm p-0 ms-1',
+ text: (text.previewShowAll || 'Show all %s').replaceAll(
+ '%s',
+ data.matched,
+ ),
+ });
+
+ showAll.addEventListener('click', () =>
+ this.openPreviewList(data, text),
+ );
+ inline.appendChild(document.createTextNode(' '));
+ inline.appendChild(showAll);
+ }
+ }
+
+ // Only link when the extension names both a component and a view, rather than guess a route.
+ previewItemLink(item, extension) {
+ const [component, view] = String(extension || '').split('.');
+
+ if (!component || !view) {
+ return el('span', { text: item.title });
+ }
+
+ return el('a', {
+ href: `index.php?option=${component}&task=${view}.edit&id=${item.id}`,
+ target: '_blank',
+ rel: 'noopener',
+ text: item.title,
+ });
+ }
+
+ openPreviewList(data, text) {
+ const items = data.items || [];
+ const pageSize = 20;
+ const lastPage = Math.max(0, Math.ceil(items.length / pageSize) - 1);
+ let page = 0;
+
+ const list = el('ul', { class: 'list-unstyled mb-0' });
+ const range = el('span', { class: 'small' });
+
+ const previous = el('button', {
+ type: 'button',
+ class: 'btn btn-sm btn-secondary',
+ text: text.previous || 'Previous',
+ });
+
+ const next = el('button', {
+ type: 'button',
+ class: 'btn btn-sm btn-secondary',
+ text: text.next || 'Next',
+ });
+
+ const renderPage = () => {
+ const from = page * pageSize;
+ const shown = items.slice(from, from + pageSize);
+
+ list.textContent = '';
+
+ shown.forEach((item) => {
+ list.appendChild(
+ el(
+ 'li',
+ { class: 'mb-1' },
+ this.previewItemLink(item, data.extension),
+ ),
+ );
+ });
+
+ range.textContent = (text.previewListRange || '%1$s to %2$s of %3$s')
+ .replaceAll('%1$s', from + 1)
+ .replaceAll('%2$s', from + shown.length)
+ .replaceAll('%3$s', items.length);
+
+ previous.disabled = page === 0;
+ next.disabled = page === lastPage;
+ };
+
+ previous.addEventListener('click', () => {
+ if (page === 0) return;
+ page -= 1;
+ renderPage();
+ });
+
+ next.addEventListener('click', () => {
+ if (page === lastPage) return;
+ page += 1;
+ renderPage();
+ });
+
+ renderPage();
+
+ const buttons = el('div', { class: 'btn-group' }, previous, next);
+
+ buttons.hidden = lastPage === 0;
+
+ const body = el(
+ 'div',
+ { class: 'p-3' },
+ list,
+ el(
+ 'div',
+ { class: 'd-flex align-items-center justify-content-between mt-3' },
+ range,
+ buttons,
+ ),
+ );
+
+ // Only reachable if the listed cap is set below the scan cap.
+ if (data.matched > items.length) {
+ body.appendChild(
+ el('p', {
+ class: 'small mt-2 mb-0',
+ text: (
+ text.previewListTrimmed || 'Showing the first %s.'
+ ).replaceAll('%s', items.length),
+ }),
+ );
+ }
+
+ const dialog = document.createElement('joomla-dialog');
+
+ dialog.popupType = 'inline';
+ dialog.textHeader = text.previewListHeader || '';
+ dialog.textClose = text.close || 'Close';
+ dialog.popupContent = body;
+ dialog.width = '600px';
+ dialog.height = 'fit-content';
+
+ document.body.appendChild(dialog);
+ dialog.show();
+ }
+ }
+
+ const initialiseWithin = (scope) => {
+ (scope || document)
+ .querySelectorAll('[data-condition-builder]')
+ .forEach((root) => {
+ if (root.dataset.cbInit) return;
+ root.dataset.cbInit = '1';
+ new ConditionBuilder(root);
+ });
+ };
+
+ document.addEventListener('joomla:updated', (event) =>
+ initialiseWithin(event.target),
+ );
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () =>
+ initialiseWithin(document),
+ );
+ } else {
+ initialiseWithin(document);
+ }
+})(document);
diff --git a/media_source/com_workflow/js/workflow-graph-client.es6.js b/media_source/com_workflow/js/workflow-graph-client.es6.js
index 0ce13da5018ac..3a8b535999089 100644
--- a/media_source/com_workflow/js/workflow-graph-client.es6.js
+++ b/media_source/com_workflow/js/workflow-graph-client.es6.js
@@ -261,6 +261,7 @@ Joomla = window.Joomla || {};
id: `transition-${tr.id}`,
pathData,
label: tr.title,
+ automated: !!tr.automated,
labelPosition: { x: labelX, y: labelY },
fromId,
toId,
@@ -340,11 +341,16 @@ Joomla = window.Joomla || {};
path.classList.toggle('highlighted', state.highlightedEdge === edge.id);
path.setAttribute('marker-end', getMarkerUrl('arrowhead'));
- let foreignObject = labelsLayer.querySelector(`foreignObject[data-edge-id="${edge.id}"]`);
+ let foreignObject = labelsLayer.querySelector(
+ `foreignObject[data-edge-id="${edge.id}"]`,
+ );
let labelDiv;
if (!foreignObject) {
- foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
+ foreignObject = document.createElementNS(
+ 'http://www.w3.org/2000/svg',
+ 'foreignObject',
+ );
foreignObject.dataset.edgeId = edge.id;
foreignObject.style.overflow = 'visible';
@@ -352,7 +358,8 @@ Joomla = window.Joomla || {};
labelDiv.className = 'transition-label-content';
labelDiv.addEventListener('click', (e) => {
e.stopPropagation();
- state.highlightedEdge = state.highlightedEdge === edge.id ? null : edge.id;
+ state.highlightedEdge
+ = state.highlightedEdge === edge.id ? null : edge.id;
renderGraph(modal);
});
foreignObject.appendChild(labelDiv);
@@ -361,8 +368,29 @@ Joomla = window.Joomla || {};
labelDiv = foreignObject.querySelector('div');
}
- labelDiv.textContent = edge.label;
- labelDiv.classList.toggle('highlighted', state.highlightedEdge === edge.id);
+ // Rebuilt each render, because setting textContent removes the icon.
+ labelDiv.textContent = '';
+
+ if (edge.automated) {
+ const clock = document.createElement('span');
+ clock.className = 'icon icon-clock me-1';
+ clock.setAttribute('aria-hidden', 'true');
+ clock.title = Joomla.Text._('COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED');
+ labelDiv.appendChild(clock);
+
+ const automatedLabel = document.createElement('span');
+ automatedLabel.className = 'visually-hidden';
+ automatedLabel.textContent = Joomla.Text._(
+ 'COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED',
+ );
+ labelDiv.appendChild(automatedLabel);
+ }
+
+ labelDiv.appendChild(document.createTextNode(edge.label));
+ labelDiv.classList.toggle(
+ 'highlighted',
+ state.highlightedEdge === edge.id,
+ );
graph.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`;
requestAnimationFrame(() => {
@@ -370,13 +398,19 @@ Joomla = window.Joomla || {};
const rect = labelDiv.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const measuredWidth = rect.width / state.scale;
- const measuredHeight = (rect.height / state.scale) || 24;
+ const measuredHeight = rect.height / state.scale || 24;
foreignObject.setAttribute('width', measuredWidth + 4);
foreignObject.setAttribute('height', measuredHeight + 4);
- foreignObject.setAttribute('x', edge.labelPosition.x - (measuredWidth / 2));
- foreignObject.setAttribute('y', edge.labelPosition.y - (measuredHeight / 2));
+ foreignObject.setAttribute(
+ 'x',
+ edge.labelPosition.x - measuredWidth / 2,
+ );
+ foreignObject.setAttribute(
+ 'y',
+ edge.labelPosition.y - measuredHeight / 2,
+ );
});
});
diff --git a/media_source/com_workflow/scss/condition-builder.scss b/media_source/com_workflow/scss/condition-builder.scss
new file mode 100644
index 0000000000000..69069cf7fad4d
--- /dev/null
+++ b/media_source/com_workflow/scss/condition-builder.scss
@@ -0,0 +1,193 @@
+.condition-builder .cb-ui {
+ margin-top: .5rem;
+}
+
+.cb-expression {
+ padding: 10px;
+ border: 1px solid rgba(128, 128, 128, .35);
+ border-radius: 8px;
+}
+
+.cb-expression .cb-expression {
+ border-inline-start: 3px solid #2a7de1;
+}
+
+.cb-expression-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-bottom: 10px;
+ gap: 10px;
+}
+
+.cb-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-inline-start: 12px;
+ border-inline-start: 2px solid rgba(128, 128, 128, .25);
+}
+
+.cb-leaf {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ border: 1px solid rgba(128, 128, 128, .35);
+ border-radius: 6px;
+ gap: 8px;
+}
+
+.cb-leaf-fields {
+ display: flex;
+ gap: 8px;
+ flex: 1;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.cb-leaf-fields .form-select,
+.cb-leaf-fields .form-control,
+.cb-leaf-fields joomla-field-fancy-select {
+ flex: 1;
+ width: auto;
+ min-width: 130px;
+}
+
+.cb-match {
+ width: auto;
+ min-width: 140px;
+}
+
+.cb-chip {
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .03em;
+ white-space: nowrap;
+ border-radius: 12px;
+}
+
+.cb-chip-check {
+ color: #1a5fb4;
+ background: rgba(42, 125, 225, .16);
+}
+
+.cb-chip-expression {
+ color: #5b3fb0;
+ background: rgba(120, 90, 220, .18);
+}
+
+.cb-band {
+ display: flex;
+ align-items: center;
+ padding-inline-start: 12px;
+}
+
+.cb-band .cb-op {
+ width: auto;
+ min-width: 90px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.cb-not {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin: 0;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.cb-add-op {
+ align-items: center;
+}
+
+.cb-join-label {
+ font-size: 13px;
+ color: rgba(128, 128, 128, .95);
+}
+
+.cb-add {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.cb-empty {
+ padding: 4px 0;
+ font-size: 13px;
+ font-style: italic;
+ color: rgba(128, 128, 128, .9);
+}
+
+.cb-remove {
+ margin-inline-start: auto;
+ line-height: 1;
+}
+
+.cb-root {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.cb-root-head {
+ margin-bottom: 0;
+}
+
+.subform-wrapper {
+ display: flex;
+ flex-wrap: wrap;
+ column-gap: 1.5%;
+}
+
+.subform-wrapper > .control-group {
+ flex-basis: 100%;
+}
+
+.control-group.cb-rule-type {
+ flex-basis: 32%;
+}
+
+.control-group.cb-delay-value {
+ flex-basis: 32%;
+}
+
+.control-group.cb-delay-unit {
+ flex-basis: 33%;
+}
+
+.control-group.cb-cron {
+ flex-basis: 66.5%;
+}
+
+.control-group.cb-rule-type,
+.control-group.cb-delay-value:not(.hidden),
+.control-group.cb-delay-unit:not(.hidden),
+.control-group.cb-cron:not(.hidden) {
+ display: block;
+}
+
+.control-group.cb-rule-type .control-label,
+.control-group.cb-delay-value .control-label,
+.control-group.cb-delay-unit .control-label,
+.control-group.cb-cron .control-label {
+ display: block;
+ float: none;
+ width: 100%;
+ padding: 0;
+ margin: 0 0 4px;
+ text-align: start;
+}
+
+.control-group.cb-rule-type .controls,
+.control-group.cb-delay-value .controls,
+.control-group.cb-delay-unit .controls,
+.control-group.cb-cron .controls {
+ display: block;
+ float: none;
+ width: 100%;
+ margin-inline-start: 0;
+}
diff --git a/media_source/com_workflow/src/components/edges/CustomEdge.vue b/media_source/com_workflow/src/components/edges/CustomEdge.vue
index 451fc23d1e3e4..a52e5563cefea 100644
--- a/media_source/com_workflow/src/components/edges/CustomEdge.vue
+++ b/media_source/com_workflow/src/components/edges/CustomEdge.vue
@@ -96,7 +96,20 @@
+
+ {{ translate('COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED') }}
+
+
{{ translate(data?.title) }}
@@ -298,7 +311,8 @@ export default {
this.$nextTick(() => {
if (this.$refs.textMeasurer) {
const measuredWidth = this.$refs.textMeasurer.offsetWidth;
- this.maxWidth = Math.min(measuredWidth + 50, 300);
+ const iconAllowance = this.data?.automated ? 20 : 0;
+ this.maxWidth = Math.min(measuredWidth + 50 + iconAllowance, 300);
}
});
},
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 418dc86a677e4..4960622eee6cf 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -13642,6 +13642,12 @@ parameters:
count: 2
path: plugins/task/updatenotification/src/Extension/UpdateNotification.php
+ -
+ message: '#^Access to an undefined property Joomla\\Plugin\\Task\\WorkflowTransition\\Extension\\WorkflowTransition\:\:\$app\.$#'
+ identifier: property.notFound
+ count: 2
+ path: plugins/task/workflowtransition/src/Extension/WorkflowTransition.php
+
-
message: '''
#^Access to deprecated static property \$language of class Joomla\\CMS\\Factory\:
diff --git a/plugins/task/workflowtransition/language/en-GB/plg_task_workflowtransition.ini b/plugins/task/workflowtransition/language/en-GB/plg_task_workflowtransition.ini
new file mode 100644
index 0000000000000..6c4b3b7d3434f
--- /dev/null
+++ b/plugins/task/workflowtransition/language/en-GB/plg_task_workflowtransition.ini
@@ -0,0 +1,8 @@
+PLG_TASK_WORKFLOWTRANSITION="Workflow - Automation Task"
+PLG_TASK_WORKFLOWTRANSITION_FAILURE_LINE="Item %1$d: %2$s"
+PLG_TASK_WORKFLOWTRANSITION_FAILURE_LINE_LINKED="Item %1$d: %2$s - %3$s"
+PLG_TASK_WORKFLOWTRANSITION_N_TRANSITIONS_FAILED="%d automated transitions could not run:"
+PLG_TASK_WORKFLOWTRANSITION_N_TRANSITIONS_FAILED_1="1 automated transition could not run:"
+PLG_TASK_WORKFLOWTRANSITION_WORKFLOWAUTOMATION_DESC="Checks for workflow items whose automation deadline has passed and fires their transitions."
+PLG_TASK_WORKFLOWTRANSITION_WORKFLOWAUTOMATION_TITLE="Workflow Automation"
+PLG_TASK_WORKFLOWTRANSITION_XML_DESCRIPTION="Fires automated workflow transitions for items whose scheduled deadline has passed."
diff --git a/plugins/task/workflowtransition/language/en-GB/plg_task_workflowtransition.sys.ini b/plugins/task/workflowtransition/language/en-GB/plg_task_workflowtransition.sys.ini
new file mode 100644
index 0000000000000..6a4284e17d7d2
--- /dev/null
+++ b/plugins/task/workflowtransition/language/en-GB/plg_task_workflowtransition.sys.ini
@@ -0,0 +1,2 @@
+PLG_TASK_WORKFLOWTRANSITION="Workflow - Automation Task"
+PLG_TASK_WORKFLOWTRANSITION_XML_DESCRIPTION="Fires automated workflow transitions for items whose scheduled deadline has passed."
diff --git a/plugins/task/workflowtransition/services/provider.php b/plugins/task/workflowtransition/services/provider.php
new file mode 100644
index 0000000000000..2e9c693adbef7
--- /dev/null
+++ b/plugins/task/workflowtransition/services/provider.php
@@ -0,0 +1,48 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\Database\DatabaseInterface;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Plugin\Task\WorkflowTransition\Extension\WorkflowTransition;
+
+\defined('_JEXEC') or die;
+
+return new class () implements ServiceProviderInterface {
+ /**
+ * Registers the service provider with a DI container.
+ *
+ * @param Container $container The DI container.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+
+ public function register(Container $container): void
+ {
+ $container->set(
+ PluginInterface::class,
+ $container->lazy(WorkflowTransition::class, function (Container $container) {
+ $plugin = new WorkflowTransition(
+ (array) PluginHelper::getPlugin('task', 'workflowtransition')
+ );
+
+ $plugin->setDatabase($container->get(DatabaseInterface::class));
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ })
+ );
+ }
+};
diff --git a/plugins/task/workflowtransition/src/Dto/DueAutomation.php b/plugins/task/workflowtransition/src/Dto/DueAutomation.php
new file mode 100644
index 0000000000000..8ee352b8f0181
--- /dev/null
+++ b/plugins/task/workflowtransition/src/Dto/DueAutomation.php
@@ -0,0 +1,75 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Task\WorkflowTransition\Dto;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * One automation rule that is due to be considered for a single item this run.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class DueAutomation
+{
+ public int $rule_id;
+ public int $transition_id;
+ public int $from_stage_id;
+ public int $to_stage_id;
+ public ?int $delay_value;
+ public ?string $delay_unit;
+ public string $rule_type;
+ public ?string $cron_expression;
+ public ?string $item_filter;
+ public ?string $fire_condition;
+ public int $ordering;
+ public int $item_id;
+ public string $extension;
+ public int $item_state_id;
+ public string $entered_at;
+ public int $run_as_user_id;
+ public ?string $last_failure_reason;
+
+ /**
+ * Builds an instance from a database row.
+ *
+ * @param object $row A joined schedule/transition/rule row.
+ *
+ * @return self
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function fromRow(object $row): self
+ {
+ $candidate = new self();
+
+ $candidate->rule_id = (int) $row->rule_id;
+ $candidate->transition_id = (int) $row->transition_id;
+ $candidate->from_stage_id = (int) $row->from_stage_id;
+ $candidate->to_stage_id = (int) $row->to_stage_id;
+ $candidate->delay_value = $row->delay_value !== null ? (int) $row->delay_value : null;
+ $candidate->delay_unit = $row->delay_unit;
+ $candidate->rule_type = (string) $row->rule_type;
+ $candidate->cron_expression = $row->cron_expression;
+ $candidate->item_filter = $row->item_filter;
+ $candidate->fire_condition = $row->fire_condition;
+ $candidate->ordering = (int) $row->ordering;
+ $candidate->item_id = (int) $row->item_id;
+ $candidate->extension = (string) $row->extension;
+ $candidate->item_state_id = (int) $row->item_state_id;
+ $candidate->entered_at = (string) $row->entered_at;
+ $candidate->run_as_user_id = (int) $row->run_as_user_id;
+ $candidate->last_failure_reason = $row->last_failure_reason;
+
+ return $candidate;
+ }
+}
diff --git a/plugins/task/workflowtransition/src/Extension/WorkflowTransition.php b/plugins/task/workflowtransition/src/Extension/WorkflowTransition.php
new file mode 100644
index 0000000000000..4f5c8b66b1b9e
--- /dev/null
+++ b/plugins/task/workflowtransition/src/Extension/WorkflowTransition.php
@@ -0,0 +1,885 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Task\WorkflowTransition\Extension;
+
+use Joomla\CMS\Application\CMSApplicationInterface;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\UserFactoryInterface;
+use Joomla\CMS\Workflow\TransitionStatus;
+use Joomla\CMS\Workflow\Workflow;
+use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
+use Joomla\Component\Scheduler\Administrator\Task\Status as TaskStatus;
+use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
+use Joomla\Component\Workflow\Administrator\Automation\ConditionEvaluationException;
+use Joomla\Component\Workflow\Administrator\Automation\ConditionEvaluator;
+use Joomla\Component\Workflow\Administrator\Automation\DeadlineCalculator;
+use Joomla\Component\Workflow\Administrator\Automation\ItemFieldResolver;
+use Joomla\Component\Workflow\Administrator\Automation\ItemStorage;
+use Joomla\Database\DatabaseAwareTrait;
+use Joomla\Database\ParameterType;
+use Joomla\Event\SubscriberInterface;
+use Joomla\Plugin\Task\WorkflowTransition\Dto\DueAutomation;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Scheduler task plugin that fires automated workflow transitions.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class WorkflowTransition extends CMSPlugin implements SubscriberInterface
+{
+ use DatabaseAwareTrait;
+ use TaskPluginTrait;
+
+ /**
+ * @var string[]
+ * @since __DEPLOY_VERSION__
+ */
+ protected const TASKS_MAP = [
+ 'workflow.automation' => [
+ 'langConstPrefix' => 'PLG_TASK_WORKFLOWTRANSITION_WORKFLOWAUTOMATION',
+ 'method' => 'fireOverdueTransitions',
+ ],
+ ];
+
+ /**
+ * How many (item, rule) rows to consider in one run.
+ *
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ private const MAX_CANDIDATES_PER_RUN = 500;
+
+ /**
+ * How much of a failure reason is kept. Matches the width of
+ * #__workflow_item_state.last_failure_reason.
+ *
+ * Cut when the message is built, not when it is written, so the next run compares against
+ * exactly what was stored instead of sending the same email again.
+ *
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ private const MAX_FAILURE_REASON_LENGTH = 500;
+
+ /**
+ * Outcomes recorded in #__workflow_automation_log.exit_code.
+ *
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ private const EXIT_OK = 0;
+ private const EXIT_REFUSED = 1;
+ private const EXIT_EXCEPTION = 3;
+
+ /**
+ * The item moved on before the transition could fire.
+ *
+ * Not a fault, so nobody is notified: someone moved the item by hand while the run was going.
+ *
+ * @var integer
+ * @since __DEPLOY_VERSION__
+ */
+ private const EXIT_NO_LONGER_APPLICABLE = 4;
+
+ /**
+ * Stands in for "never checked" when sorting. The earliest datetime MySQL accepts, so a
+ * row that has never been considered always sorts ahead of one that has.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private const NEVER_CHECKED = '1000-01-01 00:00:00';
+
+ /**
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * Returns the events the plugin listens to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[\Override]
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onTaskOptionsList' => 'advertiseRoutines',
+ 'onExecuteTask' => 'standardRoutineHandler',
+ 'onContentPrepareForm' => 'enhanceTaskItemForm',
+ ];
+ }
+
+ /**
+ * Main task routine - called by the scheduler when the task is due.
+ *
+ * No locking is done here, because the scheduler never runs the same task twice at once.
+ *
+ * @param ExecuteTaskEvent $event The scheduler event.
+ *
+ * @return integer A TaskStatus code (OK, or KNOCKOUT when a transition failed).
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function fireOverdueTransitions(ExecuteTaskEvent $event): int
+ {
+ $now = Factory::getDate()->toSql();
+ $nowDateTime = new \DateTime($now, new \DateTimeZone('UTC'));
+
+ $this->reconcileItemStates($now);
+
+ $candidates = $this->fetchCandidates();
+
+ if (empty($candidates)) {
+ return TaskStatus::OK;
+ }
+
+ // Stamped before anything is evaluated, not after, so a row whose condition throws
+ // an exception still moves to the back of the queue instead of being re-picked every run.
+ $this->markCandidatesChecked($candidates, $now);
+
+
+ // Dropped after the stamp, never before, or a trashed item would hold its place at the front
+ // of the least-recently-checked queue forever and crowd out live ones.
+ $candidates = $this->withoutTrashedOrArchived($candidates);
+
+ if ($candidates === []) {
+ return TaskStatus::OK;
+ }
+
+ $candidatesByItem = [];
+
+ foreach ($candidates as $candidate) {
+ $candidatesByItem[$candidate->extension . '.' . $candidate->item_id][] = $candidate;
+ }
+
+ $conditionEvaluator = new ConditionEvaluator();
+ $itemFieldResolver = new ItemFieldResolver($this->getDatabase());
+
+ $itemIdsByExtension = [];
+
+ foreach ($candidates as $candidate) {
+ $itemIdsByExtension[$candidate->extension][] = (int) $candidate->item_id;
+ }
+
+ foreach ($itemIdsByExtension as $extension => $itemIds) {
+ $itemFieldResolver->preload($itemIds, $extension);
+ }
+
+ $app = Factory::getApplication();
+ $failures = [];
+
+ $failureReasonsByItemState = [];
+
+ $recoveredItemStates = [];
+
+ foreach ($candidatesByItem as $itemCandidates) {
+ $evaluationFailure = null;
+
+ // Every candidate for one item shares its item state row, so any of them can speak for it.
+ $firstCandidate = $itemCandidates[0];
+
+ $winningRule = $this->selectRuleForItem(
+ $itemCandidates,
+ $nowDateTime,
+ $conditionEvaluator,
+ $itemFieldResolver,
+ $evaluationFailure
+ );
+
+ if ($evaluationFailure !== null) {
+ $failureReasonsByItemState[$firstCandidate->item_state_id] = $evaluationFailure;
+
+ // Only a reason not already stored earns an email, or a broken rule would mail the
+ // administrator on every run. A fault that returns after a fix still counts as new.
+ if ($evaluationFailure !== $firstCandidate->last_failure_reason) {
+ $failures[] = $this->notificationLine(
+ (int) $firstCandidate->item_id,
+ $evaluationFailure,
+ $this->itemEditLink($firstCandidate)
+ );
+ }
+ } elseif ($firstCandidate->last_failure_reason !== null) {
+ $recoveredItemStates[] = $firstCandidate->item_state_id;
+ }
+
+ if ($winningRule !== null) {
+ $this->fireRule($winningRule, $app, $failures, $failureReasonsByItemState);
+ }
+ }
+
+ // Clear before recording: an item can read cleanly and still fail to fire, and the other
+ // order would wipe the reason that fireRule() has just written.
+ $this->clearEvaluationFailures($recoveredItemStates);
+ $this->recordEvaluationFailures($failureReasonsByItemState, $now);
+
+ if (!empty($failures)) {
+ $summary = Text::plural('PLG_TASK_WORKFLOWTRANSITION_N_TRANSITIONS_FAILED', \count($failures))
+ . "\n" . implode("\n", $failures);
+ $this->logTask($summary, 'error');
+ $this->snapshot['output_body'] = $summary;
+
+ return TaskStatus::KNOCKOUT;
+ }
+
+ return TaskStatus::OK;
+ }
+
+ /**
+ * Brings #__workflow_item_state back in line with core's #__workflow_associations.
+ *
+ * Core records an item's stage in its association row even while workflows are switched off, but
+ * a state row is only written when the automation plugin sees the change happen. Items created
+ * while workflows were off, moved by a batch stage change, imported straight into the database or
+ * older than this feature would otherwise stay invisible to automation, or be judged by a stage
+ * they have left. As in the upgrade backfill, entered_at is the time of the repair: nothing records
+ * when the item really reached its stage, and an earlier guess would make it overdue at once.
+ *
+ * @param string $now The run time, as SQL.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function reconcileItemStates(string $now): void
+ {
+ $db = $this->getDatabase();
+
+ $untracked = $db->getQuery(true)
+ ->select([
+ $db->quoteName('wa.item_id'),
+ $db->quoteName('wa.extension'),
+ $db->quoteName('wa.stage_id'),
+ $db->quote($now),
+ $db->quote('manual'),
+ ])
+ ->from($db->quoteName('#__workflow_associations', 'wa'))
+ ->join(
+ 'LEFT',
+ $db->quoteName('#__workflow_item_state', 'wis'),
+ $db->quoteName('wis.item_id') . ' = ' . $db->quoteName('wa.item_id')
+ . ' AND ' . $db->quoteName('wis.extension') . ' = ' . $db->quoteName('wa.extension')
+ )
+ ->where($db->quoteName('wis.id') . ' IS NULL');
+
+ $columns = $db->quoteName(['item_id', 'extension', 'stage_id', 'entered_at', 'triggered_by']);
+
+ $db->setQuery(
+ 'INSERT INTO ' . $db->quoteName('#__workflow_item_state') . ' (' . implode(', ', $columns) . ') ' . $untracked
+ )->execute();
+
+ $movedRows = $db->setQuery(
+ $db->getQuery(true)
+ ->select([$db->quoteName('wis.id'), $db->quoteName('wa.stage_id')])
+ ->from($db->quoteName('#__workflow_item_state', 'wis'))
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflow_associations', 'wa'),
+ $db->quoteName('wa.item_id') . ' = ' . $db->quoteName('wis.item_id')
+ . ' AND ' . $db->quoteName('wa.extension') . ' = ' . $db->quoteName('wis.extension')
+ )
+ ->where($db->quoteName('wa.stage_id') . ' <> ' . $db->quoteName('wis.stage_id'))
+ )->loadAssocList();
+
+ $itemStateIdsByStage = [];
+
+ foreach ($movedRows as $movedRow) {
+ $itemStateIdsByStage[(int) $movedRow['stage_id']][] = (int) $movedRow['id'];
+ }
+
+ foreach ($itemStateIdsByStage as $stageId => $itemStateIds) {
+ foreach (array_chunk($itemStateIds, self::MAX_CANDIDATES_PER_RUN) as $chunk) {
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__workflow_item_state'))
+ ->set($db->quoteName('stage_id') . ' = ' . $stageId)
+ ->set($db->quoteName('entered_at') . ' = ' . $db->quote($now))
+ ->set($db->quoteName('triggered_by') . ' = ' . $db->quote('manual'))
+ // The rest describes the stage the item has left, so it is cleared as on a transition.
+ ->set($db->quoteName('requires_intervention') . ' = 0')
+ ->set($db->quoteName('last_checked_at') . ' = NULL')
+ ->set($db->quoteName('last_failure_at') . ' = NULL')
+ ->set($db->quoteName('last_failure_reason') . ' = NULL')
+ ->whereIn($db->quoteName('id'), $chunk)
+ )->execute();
+ }
+ }
+ }
+
+ /**
+ * Fetches every (item, rule) pair this run could act on.
+ *
+ * Nothing here filters by time. Whether a rule is due is worked out live from entered_at,
+ * because a stored deadline would go stale whenever a rule or an item changes.
+ *
+ * @return DueAutomation[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function fetchCandidates(): array
+ {
+ $db = $this->getDatabase();
+
+ $alreadyRan = $db->getQuery(true)
+ ->select('1')
+ ->from($db->quoteName('#__workflow_automation_log', 'wal'))
+ ->where($db->quoteName('wal.rule_id') . ' = ' . $db->quoteName('war.id'))
+ ->where($db->quoteName('wal.item_id') . ' = ' . $db->quoteName('wis.item_id'))
+ ->where($db->quoteName('wal.extension') . ' = ' . $db->quoteName('wis.extension'))
+ ->where($db->quoteName('wal.exit_code') . ' = ' . self::EXIT_OK)
+ ->where($db->quoteName('wal.executed_at') . ' >= ' . $db->quoteName('wis.entered_at'));
+
+ $overduePairsQuery = $db->getQuery(true)
+ ->select([
+ $db->quoteName('war.id', 'rule_id'),
+ $db->quoteName('war.transition_id'),
+ $db->quoteName('wis.stage_id', 'from_stage_id'),
+ $db->quoteName('wt.to_stage_id'),
+ $db->quoteName('war.delay_value'),
+ $db->quoteName('war.delay_unit'),
+ $db->quoteName('war.rule_type'),
+ $db->quoteName('war.cron_expression'),
+ $db->quoteName('war.item_filter'),
+ $db->quoteName('war.fire_condition'),
+ $db->quoteName('wt.ordering'),
+ $db->quoteName('wis.item_id'),
+ $db->quoteName('wis.extension'),
+ $db->quoteName('wis.id', 'item_state_id'),
+ $db->quoteName('wis.entered_at'),
+ $db->quoteName('war.run_as_user_id'),
+ $db->quoteName('wis.last_failure_reason'),
+ ])
+ ->from($db->quoteName('#__workflow_item_state', 'wis'))
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflow_stages', 'ws'),
+ $db->quoteName('ws.id') . ' = ' . $db->quoteName('wis.stage_id')
+ )
+ // A transition from any stage has from_stage_id -1 and applies to every item in its own
+ // workflow, the same set core offers it to by hand.
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflow_transitions', 'wt'),
+ $db->quoteName('wt.workflow_id') . ' = ' . $db->quoteName('ws.workflow_id')
+ . ' AND ' . $db->quoteName('wt.from_stage_id') . ' IN (' . $db->quoteName('wis.stage_id') . ', -1)'
+ )
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflow_automation_rules', 'war'),
+ $db->quoteName('war.transition_id') . ' = ' . $db->quoteName('wt.id')
+ )
+ ->join(
+ 'INNER',
+ $db->quoteName('#__workflows', 'w'),
+ $db->quoteName('w.id') . ' = ' . $db->quoteName('wt.workflow_id')
+ )
+
+ ->where($db->quoteName('wis.requires_intervention') . ' = 0')
+ ->where($db->quoteName('w.published') . ' = 1')
+ ->where($db->quoteName('wt.published') . ' = 1')
+ ->where($db->quoteName('war.published') . ' = 1')
+
+ // Each rule runs once per stay in a stage. Without this a transition that leaves the item where it
+ // is, as one from any stage does once it has moved the item, would fire again every delay.
+ ->where('NOT EXISTS (' . $alreadyRan . ')')
+
+ // Least-recently-checked first, so rows that a filter keeps excluding cannot hold the front
+ // of the queue forever. COALESCE because MySQL and PostgreSQL sort nulls differently.
+ ->order(
+ 'COALESCE(' . $db->quoteName('wis.last_checked_at') . ', '
+ . $db->quote(self::NEVER_CHECKED) . ') ASC'
+ )
+ ->order($db->quoteName('wis.entered_at') . ' ASC')
+ ->order($db->quoteName('wt.ordering') . ' ASC')
+ ->setLimit(self::MAX_CANDIDATES_PER_RUN);
+
+ $candidates = array_map(
+ [DueAutomation::class, 'fromRow'],
+ $db->setQuery($overduePairsQuery)->loadObjectList() ?: []
+ );
+
+ return $candidates;
+ }
+
+ /**
+ * Drops candidates whose item an editor has trashed or archived.
+ *
+ * Done after the query, because each extension keeps its items in its own table.
+ *
+ * @param DueAutomation[] $candidates Every candidate row the query returned.
+ *
+ * @return DueAutomation[]
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function withoutTrashedOrArchived(array $candidates): array
+ {
+ if ($candidates === []) {
+ return [];
+ }
+
+ $itemIdsByExtension = [];
+
+ foreach ($candidates as $candidate) {
+ $itemIdsByExtension[$candidate->extension][] = (int) $candidate->item_id;
+ }
+
+ $itemStorage = new ItemStorage($this->getDatabase());
+ $excluded = [];
+
+ foreach ($itemIdsByExtension as $extension => $itemIds) {
+ foreach ($itemStorage->trashedOrArchivedIds($itemIds, $extension) as $itemId) {
+ // An item id is only unique within its own extension.
+ $excluded[$extension . '.' . $itemId] = true;
+ }
+ }
+
+ if ($excluded === []) {
+ return $candidates;
+ }
+
+ return array_values(array_filter(
+ $candidates,
+ static fn (DueAutomation $candidate): bool
+ => !isset($excluded[$candidate->extension . '.' . $candidate->item_id])
+ ));
+ }
+
+ /**
+ * Records the outcome of one automated transition attempt.
+ *
+ * @param object $item The (item, rule) pair being processed.
+ * @param integer $exitCode One of the EXIT_* constants.
+ * @param string $note Optional reason, truncated to the column length.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function logAutomationRun(object $item, int $exitCode, string $note = ''): void
+ {
+ $db = $this->getDatabase();
+ $row = (object) [
+ 'rule_id' => (int) $item->rule_id,
+ 'item_id' => (int) $item->item_id,
+ 'extension' => $item->extension,
+ 'transition_id' => (int) $item->transition_id,
+ 'from_stage_id' => (int) ($item->from_stage_id ?? 0),
+ 'to_stage_id' => (int) ($item->to_stage_id ?? 0),
+ 'run_as_user_id' => (int) ($item->run_as_user_id ?? 0),
+ 'trigger_type' => 'rule',
+ 'exit_code' => $exitCode,
+ 'note' => $note !== '' ? substr($note, 0, 500) : null,
+ 'executed_at' => Factory::getDate()->toSql(),
+ ];
+
+ // A logging failure must not escape into fireRule()'s catch, which would report a transition
+ // that fired perfectly well as failed and block the item from further retries.
+ try {
+ $db->insertObject('#__workflow_automation_log', $row);
+ } catch (\Throwable $error) {
+ $this->logTask(
+ 'Could not write the automation log row for item ' . (int) $item->item_id . ': ' . $error->getMessage(),
+ 'warning'
+ );
+ }
+ }
+
+ /**
+ * Builds an absolute backend edit link for the item, for use in notification.
+ *
+ * @param object $item The overdue (item, rule) pair.
+ *
+ * @return string Absolute URL, or empty string if no link can be built.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function itemEditLink(object $item): string
+ {
+ $parts = explode('.', (string) $item->extension);
+ $option = $parts[0] ?? '';
+ $type = $parts[1] ?? '';
+
+ if ($option === '' || $type === '') {
+ return '';
+ }
+
+ $app = Factory::getApplication();
+ $base = (string) $app->get('live_site');
+
+ // Not called under cron: with no request, Uri::root() emits a warning before it throws, so
+ // catching the exception would still fill the log.
+ if ($base === '' && !$app->isClient('cli')) {
+ $base = Uri::root();
+ }
+
+ if ($base === '') {
+ // Under cron a link needs $live_site in configuration.php; without it the report has none.
+ return '';
+ }
+
+ return rtrim($base, '/') . '/administrator/index.php?option=' . $option
+ . '&task=' . $type . '.edit&id=' . (int) $item->item_id;
+ }
+
+ /**
+ * Builds one line of the run's failure report.
+ *
+ * The reason is not translated: it is stored and compared on the next run, so it must be the
+ * same string whatever language the site runs in.
+ *
+ * @param integer $itemId The content item id.
+ * @param string $reason Why the rule could not fire, as stored on the item.
+ * @param string $link Absolute edit link, or '' when none could be built.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function notificationLine(int $itemId, string $reason, string $link): string
+ {
+ if ($link === '') {
+ return Text::sprintf('PLG_TASK_WORKFLOWTRANSITION_FAILURE_LINE', $itemId, $reason);
+ }
+
+ return Text::sprintf('PLG_TASK_WORKFLOWTRANSITION_FAILURE_LINE_LINKED', $itemId, $reason, $link);
+ }
+
+ /**
+ * Records that this run considered these item states, in one query.
+ *
+ * @param DueAutomation[] $candidates Every candidate row this run fetched.
+ * @param string $now The run's timestamp, in SQL format.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function markCandidatesChecked(array $candidates, string $now): void
+ {
+ $itemStateIds = array_values(array_unique(
+ array_map(static fn (DueAutomation $candidate): int => $candidate->item_state_id, $candidates)
+ ));
+
+ $db = $this->getDatabase();
+ $updateQuery = $db->getQuery(true)
+ ->update($db->quoteName('#__workflow_item_state'))
+ ->set($db->quoteName('last_checked_at') . ' = :now')
+ ->whereIn($db->quoteName('id'), $itemStateIds)
+ ->bind(':now', $now, ParameterType::STRING);
+
+ $db->setQuery($updateQuery)->execute();
+ }
+
+ /**
+ * Flags an item as needing manual intervention after its transition failed.
+ *
+ * @param integer $itemStateId The #__workflow_item_state row id.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function markRequiresIntervention(int $itemStateId): void
+ {
+ $db = $this->getDatabase();
+ $updateQuery = $db->getQuery(true)
+ ->update($db->quoteName('#__workflow_item_state'))
+ ->set($db->quoteName('requires_intervention') . ' = 1')
+ ->where($db->quoteName('id') . ' = :id')
+ ->bind(':id', $itemStateId, ParameterType::INTEGER);
+
+ $db->setQuery($updateQuery)->execute();
+ }
+
+ /**
+ * Stores why these item states could not be evaluated, so the reason survives the run.
+ *
+ * Unlike requires_intervention, this does not stop retries, and it clears itself once the
+ * rule is fixed. Written with one query per distinct reason rather than one per item.
+ *
+ * @param string[] $reasonsByItemStateId Reason text, keyed by #__workflow_item_state id.
+ * @param string $now The run's timestamp, in SQL format.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function recordEvaluationFailures(array $reasonsByItemStateId, string $now): void
+ {
+ if ($reasonsByItemStateId === []) {
+ return;
+ }
+
+ $itemStateIdsByReason = [];
+
+ foreach ($reasonsByItemStateId as $itemStateId => $reason) {
+ $itemStateIdsByReason[$reason][] = (int) $itemStateId;
+ }
+
+ $db = $this->getDatabase();
+
+ foreach ($itemStateIdsByReason as $reason => $itemStateIds) {
+ // Numeric-looking reasons became integer keys above, and bind() needs a string.
+ $reasonText = (string) $reason;
+
+ $updateQuery = $db->getQuery(true)
+ ->update($db->quoteName('#__workflow_item_state'))
+ ->set($db->quoteName('last_failure_at') . ' = :now')
+ ->set($db->quoteName('last_failure_reason') . ' = :reason')
+ ->whereIn($db->quoteName('id'), $itemStateIds)
+ ->bind(':now', $now)
+ ->bind(':reason', $reasonText);
+
+ $db->setQuery($updateQuery)->execute();
+ }
+ }
+
+ /**
+ * Removes the stored failure note from item states that evaluated cleanly this run.
+ *
+ * @param integer[] $itemStateIds The #__workflow_item_state row ids to clear.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function clearEvaluationFailures(array $itemStateIds): void
+ {
+ if ($itemStateIds === []) {
+ return;
+ }
+
+ $db = $this->getDatabase();
+ $updateQuery = $db->getQuery(true)
+ ->update($db->quoteName('#__workflow_item_state'))
+ ->set($db->quoteName('last_failure_at') . ' = NULL')
+ ->set($db->quoteName('last_failure_reason') . ' = NULL')
+ ->whereIn($db->quoteName('id'), $itemStateIds);
+
+ $db->setQuery($updateQuery)->execute();
+ }
+
+ /**
+ * Chooses which single rule, if any, should fire for one item this run.
+ *
+ * The rule that came due soonest wins, with the Transitions list ordering as the tiebreak.
+ *
+ * @param object[] $itemCandidates Candidate rows for a single item.
+ * @param \DateTime $nowDateTime Current time (UTC).
+ * @param ConditionEvaluator $conditionEvaluator The expression evaluator.
+ * @param ItemFieldResolver $itemFieldResolver The field resolver factory.
+ * @param string|null $evaluationFailure Set to why the item could not be evaluated.
+ *
+ * @return object|null The winning candidate, or null if none should fire.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function selectRuleForItem(
+ array $itemCandidates,
+ \DateTime $nowDateTime,
+ ConditionEvaluator $conditionEvaluator,
+ ItemFieldResolver $itemFieldResolver,
+ ?string &$evaluationFailure
+ ): ?object {
+ $firstCandidate = $itemCandidates[0];
+ $fieldResolver = $itemFieldResolver->forItem((int) $firstCandidate->item_id, $firstCandidate->extension);
+
+ // Deadlines are kept so that sorting does not run the cron parser once per comparison.
+ $eligibleRules = [];
+
+ foreach ($itemCandidates as $candidate) {
+ $deadline = DeadlineCalculator::forRule($candidate->entered_at, $candidate);
+
+ if ($deadline === null) {
+ // A live rule with no computable deadline cannot be used: an expression the parser
+ // rejects, an empty one, or a delay unit outside the four we store.
+ $evaluationFailure ??= mb_substr(
+ 'Rule ' . $candidate->rule_id . ' has no usable schedule.',
+ 0,
+ self::MAX_FAILURE_REASON_LENGTH
+ );
+
+ continue;
+ }
+
+ if ($deadline > $nowDateTime) {
+ continue;
+ }
+
+ try {
+ if (!$conditionEvaluator->evaluate($candidate->item_filter, $fieldResolver)) {
+ continue;
+ }
+
+ if (!$conditionEvaluator->evaluate($candidate->fire_condition, $fieldResolver)) {
+ continue;
+ }
+ } catch (ConditionEvaluationException $invalidCondition) {
+ $evaluationFailure ??= mb_substr(
+ 'Rule ' . $candidate->rule_id . ' could not be evaluated: ' . $invalidCondition->getMessage(),
+ 0,
+ self::MAX_FAILURE_REASON_LENGTH
+ );
+
+ continue;
+ }
+ $eligibleRules[] = ['rule' => $candidate, 'deadline' => $deadline];
+ }
+
+ if (!empty($eligibleRules)) {
+ usort(
+ $eligibleRules,
+ static fn (array $a, array $b): int => [$a['deadline'], (int) $a['rule']->ordering, (int) $a['rule']->rule_id]
+ <=> [$b['deadline'], (int) $b['rule']->ordering, (int) $b['rule']->rule_id]
+ );
+
+ return $eligibleRules[0]['rule'];
+ }
+
+ return null;
+ }
+
+ /**
+ * Records one rule failure in the run report, the automation log and the item's state row.
+ *
+ * @param DueAutomation $rule The rule that could not fire.
+ * @param string[] $failures Collected failure messages (by reference).
+ * @param string[] $failureReasons Reasons keyed by item state row id (by reference).
+ * @param string $note What went wrong. Not translated, see notificationLine().
+ * @param integer $exitCode A #__workflow_automation_log exit code.
+ * @param boolean $blockRetries Whether to take the item out of the scheduler.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function recordRuleFailure(
+ DueAutomation $rule,
+ array &$failures,
+ array &$failureReasons,
+ string $note,
+ int $exitCode = self::EXIT_REFUSED,
+ bool $blockRetries = false
+ ): void {
+ $reason = mb_substr(
+ 'Rule ' . $rule->rule_id . ': ' . $note,
+ 0,
+ self::MAX_FAILURE_REASON_LENGTH
+ );
+
+ $failureReasons[$rule->item_state_id] = $reason;
+
+ if ($reason !== $rule->last_failure_reason) {
+ $failures[] = $this->notificationLine((int) $rule->item_id, $reason, $this->itemEditLink($rule));
+
+ $this->logAutomationRun($rule, $exitCode, $note);
+ }
+
+ if ($blockRetries) {
+ $this->markRequiresIntervention($rule->item_state_id);
+ }
+ }
+
+ /**
+ * Executes the winning transition for an item, as its configured run-as user.
+ *
+ * @param DueAutomation $rule The winning candidate row.
+ * @param CMSApplicationInterface $app The application, for the identity swap.
+ * @param string[] $failures Collected failure messages (by reference).
+ * @param string[] $failureReasons Reasons keyed by item state row id (by reference).
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function fireRule(
+ DueAutomation $rule,
+ CMSApplicationInterface $app,
+ array &$failures,
+ array &$failureReasons
+ ): void {
+ $transitionId = (int) $rule->transition_id;
+ $runAsUserId = $rule->run_as_user_id;
+ $originalUser = $app->getIdentity();
+
+ if ($runAsUserId <= 0) {
+ $this->recordRuleFailure(
+ $rule,
+ $failures,
+ $failureReasons,
+ 'This rule has no Run As user, so there is no identity to execute it with.'
+ );
+
+ return;
+ }
+
+ $runAsUser = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($runAsUserId);
+
+ if ((int) $runAsUser->id !== $runAsUserId) {
+ $this->recordRuleFailure(
+ $rule,
+ $failures,
+ $failureReasons,
+ 'The Run As user (id ' . $runAsUserId . ') no longer exists.'
+ );
+
+ return;
+ }
+
+ try {
+ $app->loadIdentity($runAsUser);
+
+ $workflow = new Workflow($rule->extension);
+ $outcome = $workflow->attemptTransition([$rule->item_id], $transitionId, 'automation');
+
+ if ($outcome === TransitionStatus::SUCCESS) {
+ $this->logAutomationRun($rule, self::EXIT_OK);
+ } elseif ($outcome === TransitionStatus::STAGE_MISMATCH) {
+ // Reported by the same read that refused the transition, so an item moved by hand
+ // mid-run cannot be mistaken for a fault and blocked from future retries.
+ $this->logAutomationRun(
+ $rule,
+ self::EXIT_NO_LONGER_APPLICABLE,
+ 'The item left this stage before the transition ran, so it no longer applies.'
+ );
+ } else {
+ $this->recordRuleFailure(
+ $rule,
+ $failures,
+ $failureReasons,
+ match ($outcome) {
+ TransitionStatus::INVALID_TRANSITION => 'The transition no longer exists, or the Run As user may not execute it.',
+ TransitionStatus::STOPPED_BY_PLUGIN => 'A plugin stopped this transition.',
+ TransitionStatus::UPDATE_FAILED => 'The stage change could not be saved.',
+ default => 'The transition was refused.',
+ },
+ self::EXIT_REFUSED,
+ true
+ );
+ }
+ } catch (\Throwable $error) {
+ $this->recordRuleFailure($rule, $failures, $failureReasons, $error->getMessage(), self::EXIT_EXCEPTION, true);
+ } finally {
+ $app->loadIdentity($originalUser);
+ }
+ }
+}
diff --git a/plugins/task/workflowtransition/workflowtransition.xml b/plugins/task/workflowtransition/workflowtransition.xml
new file mode 100644
index 0000000000000..fc8d801cf2f62
--- /dev/null
+++ b/plugins/task/workflowtransition/workflowtransition.xml
@@ -0,0 +1,21 @@
+
+
+ plg_task_workflowtransition
+ Joomla! Project
+ 2026-06
+ (C) 2026 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 1.0.0
+ PLG_TASK_WORKFLOWTRANSITION_XML_DESCRIPTION
+ Joomla\Plugin\Task\WorkflowTransition
+
+ services
+ src
+
+
+ language/en-GB/plg_task_workflowtransition.ini
+ language/en-GB/plg_task_workflowtransition.sys.ini
+
+
diff --git a/plugins/workflow/automation/automation.xml b/plugins/workflow/automation/automation.xml
new file mode 100644
index 0000000000000..fdca2e663a5a4
--- /dev/null
+++ b/plugins/workflow/automation/automation.xml
@@ -0,0 +1,40 @@
+
+
+ plg_workflow_automation
+ Joomla! Project
+ 2026-06
+ (C) 2026 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 1.0.0
+ PLG_WORKFLOW_AUTOMATION_XML_DESCRIPTION
+ Joomla\Plugin\Workflow\Automation
+
+ services
+ forms
+ src
+
+
+ language/en-GB/plg_workflow_automation.ini
+ language/en-GB/plg_workflow_automation.sys.ini
+
+
+
+
+
+ JNO
+ JYES
+
+
+
+
+
diff --git a/plugins/workflow/automation/forms/upcoming_transition.xml b/plugins/workflow/automation/forms/upcoming_transition.xml
new file mode 100644
index 0000000000000..4e7d37e7f4afc
--- /dev/null
+++ b/plugins/workflow/automation/forms/upcoming_transition.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/plugins/workflow/automation/services/provider.php b/plugins/workflow/automation/services/provider.php
new file mode 100644
index 0000000000000..ea35b1e4aedad
--- /dev/null
+++ b/plugins/workflow/automation/services/provider.php
@@ -0,0 +1,29 @@
+set(
+ PluginInterface::class,
+ $container->lazy(Automation::class, function (Container $container) {
+ $plugin = new Automation(
+ (array) PluginHelper::getPlugin('workflow', 'automation')
+ );
+ $plugin->setApplication(Factory::getApplication());
+ $plugin->setDatabase($container->get(DatabaseInterface::class));
+
+ return $plugin;
+ })
+ );
+ }
+};
diff --git a/plugins/workflow/automation/src/Extension/Automation.php b/plugins/workflow/automation/src/Extension/Automation.php
new file mode 100644
index 0000000000000..a060278b23f3d
--- /dev/null
+++ b/plugins/workflow/automation/src/Extension/Automation.php
@@ -0,0 +1,333 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Workflow\Automation\Extension;
+
+use Joomla\CMS\Component\ComponentHelper;
+use Joomla\CMS\Event\Model\AfterSaveEvent;
+use Joomla\CMS\Event\Model\PrepareFormEvent;
+use Joomla\CMS\Event\Workflow\WorkflowTransitionEvent;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Form\Form;
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Workflow\Workflow;
+use Joomla\CMS\Workflow\WorkflowServiceInterface;
+use Joomla\Database\DatabaseAwareTrait;
+use Joomla\Database\ParameterType;
+use Joomla\Database\QueryInterface;
+use Joomla\Event\SubscriberInterface;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Keeps #__workflow_item_state in step with what the rest of Joomla does to an item.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class Automation extends CMSPlugin implements SubscriberInterface
+{
+ use DatabaseAwareTrait;
+
+ /**
+ * @var boolean
+ * @since __DEPLOY_VERSION__
+ */
+ protected $autoloadLanguage = true;
+
+ /**
+ * Returns the events this plugin listens to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[\Override]
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onWorkflowAfterTransition' => 'logStageEntry',
+ 'onContentAfterSave' => 'seedNewItemState',
+ 'onContentPrepareForm' => 'injectUpcomingTransitionField',
+ ];
+ }
+
+ /**
+ * Adds the read-only "next automated transition" panel to a workflow-enabled item form.
+ *
+ * @param PrepareFormEvent $event The form event.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function injectUpcomingTransitionField(PrepareFormEvent $event): void
+ {
+ $form = $event->getForm();
+
+ if (!$this->isWorkflowContentForm((string) $form->getName())) {
+ return;
+ }
+
+ Form::addFormPath(JPATH_PLUGINS . '/workflow/automation/forms');
+
+ if (!$form->loadFile('upcoming_transition')) {
+ Log::add(
+ 'Workflow automation could not load its upcoming transition field into ' . $form->getName() . '.',
+ Log::WARNING,
+ 'workflow'
+ );
+ }
+ }
+
+ /**
+ * Returns the component behind an extension when it offers workflow and has it switched on.
+ *
+ * Unlike isWorkflowActive(), which always answers no from the CLI or the API, this works in
+ * every application.
+ *
+ * @param string $extension The extension, e.g. com_content.article.
+ *
+ * @return WorkflowServiceInterface|null The component, or null when workflow does not apply.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function workflowComponent(string $extension): ?WorkflowServiceInterface
+ {
+ $parts = explode('.', $extension);
+
+ if (\count($parts) < 2) {
+ return null;
+ }
+
+ // bootComponent() throws for a component that is not installed.
+ try {
+ $component = Factory::getApplication()->bootComponent($parts[0]);
+ } catch (\Throwable) {
+ return null;
+ }
+
+ if (!$component instanceof WorkflowServiceInterface) {
+ return null;
+ }
+
+ return ComponentHelper::getParams($parts[0])->get('workflow_enabled') ? $component : null;
+ }
+
+ /**
+ * Whether a form belongs to a content item under an active workflow.
+ *
+ * @param string $extension The form name, e.g. com_content.article.
+ *
+ * @return boolean
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function isWorkflowContentForm(string $extension): bool
+ {
+ $component = $this->workflowComponent($extension);
+
+ return $component !== null && $component->isWorkflowActive($extension);
+ }
+
+ /**
+ * Records the stage an item has just moved into.
+ *
+ * @param WorkflowTransitionEvent $event The transition event.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function logStageEntry(WorkflowTransitionEvent $event): void
+ {
+ $transition = $event->getTransition();
+
+ // A public event, so a badly dispatched one must not cause a fatal error.
+ if ($transition === null || !isset($transition->to_stage_id)) {
+ return;
+ }
+
+ $extension = $event->getExtension();
+ $toStageId = (int) $transition->to_stage_id;
+ $now = Factory::getDate()->toSql();
+ $triggeredBy = $event->getTriggeredBy();
+ $pksInt = array_map('intval', $event->getPks());
+
+ if ($pksInt === []) {
+ return;
+ }
+
+ $db = $this->getDatabase();
+
+ $findExistingLogQuery = $db->getQuery(true)
+ ->select($db->quoteName(['item_id', 'id']))
+ ->from($db->quoteName('#__workflow_item_state'))
+ ->whereIn($db->quoteName('item_id'), $pksInt)
+ ->where($db->quoteName('extension') . ' = :extension')
+ ->bind(':extension', $extension, ParameterType::STRING);
+
+ $existingLogs = $db->setQuery($findExistingLogQuery)->loadAssocList('item_id', 'id') ?: [];
+
+ $toInsert = [];
+ $stateIds = [];
+
+ foreach ($pksInt as $pk) {
+ if (isset($existingLogs[$pk])) {
+ $stateIds[] = (int) $existingLogs[$pk];
+ } else {
+ $toInsert[] = $pk;
+ }
+ }
+
+ if ($stateIds !== []) {
+ $updateStageLogQuery = $db->getQuery(true)
+ ->update($db->quoteName('#__workflow_item_state'))
+ ->set($db->quoteName('stage_id') . ' = :stageId')
+ ->set($db->quoteName('entered_at') . ' = :enteredAt')
+ ->set($db->quoteName('triggered_by') . ' = :triggeredBy')
+
+ // Everything below describes the stage the item has just left. Clearing
+ // last_checked_at also puts the item at the front of the scheduler queue.
+ ->set($db->quoteName('requires_intervention') . ' = 0')
+ ->set($db->quoteName('last_checked_at') . ' = NULL')
+ ->set($db->quoteName('last_failure_at') . ' = NULL')
+ ->set($db->quoteName('last_failure_reason') . ' = NULL')
+
+ ->whereIn($db->quoteName('id'), $stateIds)
+ ->bind(':stageId', $toStageId, ParameterType::INTEGER)
+ ->bind(':enteredAt', $now, ParameterType::STRING)
+ ->bind(':triggeredBy', $triggeredBy, ParameterType::STRING);
+
+ $this->runBookkeepingQuery(
+ $updateStageLogQuery,
+ 'the new stage for ' . \count($stateIds) . ' existing item(s) in ' . $extension
+ );
+ }
+
+ if ($toInsert !== []) {
+ $insertStageLogQuery = $db->getQuery(true)
+ ->insert($db->quoteName('#__workflow_item_state'))
+ ->columns($db->quoteName([
+ 'item_id',
+ 'extension',
+ 'stage_id',
+ 'entered_at',
+ 'triggered_by',
+ ]));
+
+ foreach ($toInsert as $pk) {
+ $insertStageLogQuery->values(
+ $db->quote($pk) . ', '
+ . $db->quote($extension) . ', '
+ . $db->quote($toStageId) . ', '
+ . $db->quote($now) . ', '
+ . $db->quote($triggeredBy)
+ );
+ }
+
+ $this->runBookkeepingQuery(
+ $insertStageLogQuery,
+ 'the first stage for ' . \count($toInsert) . ' new item(s) in ' . $extension
+ );
+ }
+ }
+
+ /**
+ * Seeds the item state row for a brand new content item.
+ *
+ * Needed because onWorkflowAfterTransition does not fire when an item is created.
+ *
+ * @param AfterSaveEvent $event The save event.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function seedNewItemState(AfterSaveEvent $event): void
+ {
+ if (!$event->getIsNew()) {
+ return;
+ }
+
+ $extension = $event->getContext();
+
+ // Not isWorkflowContentForm(): items are also created from the CLI and the web services API.
+ if ($this->workflowComponent($extension) === null) {
+ return;
+ }
+
+ $table = $event->getItem();
+ $itemId = (int) $table->id;
+
+ $workflow = new Workflow($extension, $this->getApplication());
+ $stageId = (int) $workflow->getDefaultStageByCategory($table->catid ?? 0);
+
+ if ($stageId <= 0) {
+ Log::add(
+ 'Workflow automation found no default stage for ' . $extension . ' item ' . $itemId
+ . ', so it will not be considered for automated transitions.',
+ Log::WARNING,
+ 'workflow'
+ );
+
+ return;
+ }
+
+ $db = $this->getDatabase();
+ $now = Factory::getDate()->toSql();
+ $insertQuery = $db->getQuery(true)
+ ->insert($db->quoteName('#__workflow_item_state'))
+ ->columns($db->quoteName([
+ 'item_id',
+ 'extension',
+ 'stage_id',
+ 'entered_at',
+ 'triggered_by',
+ ]))
+ ->values(
+ $db->quote($itemId) . ', '
+ . $db->quote($extension) . ', '
+ . $db->quote($stageId) . ', '
+ . $db->quote($now) . ', '
+ . $db->quote('manual')
+ );
+
+ $this->runBookkeepingQuery($insertQuery, 'the starting stage for ' . $extension . ' item ' . $itemId);
+ }
+
+ /**
+ * Runs a bookkeeping query, turning a failure into a log entry instead of an exception.
+ *
+ * These run on after-events, when the save or transition has already happened, so an exception
+ * could not undo it. A missing row is written again when the item next changes stage.
+ *
+ * @param QueryInterface $query The bookkeeping query to run.
+ * @param string $description What was being recorded, for the log line.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function runBookkeepingQuery(QueryInterface $query, string $description): void
+ {
+ try {
+ $this->getDatabase()->setQuery($query)->execute();
+ } catch (\Throwable $error) {
+ Log::add(
+ 'Workflow automation could not record ' . $description . ': ' . $error->getMessage(),
+ Log::WARNING,
+ 'workflow'
+ );
+ }
+ }
+}
diff --git a/plugins/workflow/remotecheck/language/en-GB/plg_workflow_remotecheck.ini b/plugins/workflow/remotecheck/language/en-GB/plg_workflow_remotecheck.ini
new file mode 100644
index 0000000000000..ed74664fe25e0
--- /dev/null
+++ b/plugins/workflow/remotecheck/language/en-GB/plg_workflow_remotecheck.ini
@@ -0,0 +1,19 @@
+; Joomla! Project
+; (C) 2026 Open Source Matters, Inc.
+; License GNU General Public License version 2 or later; see LICENSE.txt
+; Note : All ini files need to be saved as UTF-8
+
+PLG_WORKFLOW_REMOTECHECK="Workflow - Remote Check"
+PLG_WORKFLOW_REMOTECHECK_AUTHHEADER_DESC="The header the service expects the credential in. Common values are Authorization and X-API-Key."
+PLG_WORKFLOW_REMOTECHECK_AUTHHEADER_LABEL="Authentication Header"
+PLG_WORKFLOW_REMOTECHECK_AUTHVALUE_DESC="The credential to send in that header, for example "Bearer abc123". Leave empty for a service that needs no authentication."
+PLG_WORKFLOW_REMOTECHECK_AUTHVALUE_LABEL="Authentication Value"
+PLG_WORKFLOW_REMOTECHECK_CACHELIFETIME_DESC="How many minutes an answer is reused before the service is asked again. The check is also resolved when an administrator opens the article list, so a very short lifetime puts an external service in the way of pages loading. A long one means the workflow acts on information that old."
+PLG_WORKFLOW_REMOTECHECK_CACHELIFETIME_LABEL="Cache Lifetime (minutes)"
+PLG_WORKFLOW_REMOTECHECK_CHECKLABEL_DESC="What to call this check in the condition builder. Use the name your service uses, so the person building a rule recognises it."
+PLG_WORKFLOW_REMOTECHECK_CHECKLABEL_LABEL="Check Name"
+PLG_WORKFLOW_REMOTECHECK_ENDPOINT_DESC="The service to ask. It receives {"ids": [1, 2, 3]} as JSON and should answer with {"flags": {"1": true, "2": false}}. Leave an article out of the answer to say that its state is unknown, and no rule will act on it."
+PLG_WORKFLOW_REMOTECHECK_ENDPOINT_LABEL="Service Endpoint"
+PLG_WORKFLOW_REMOTECHECK_TIMEOUT_DESC="How many seconds to wait for the service before giving up. On a timeout no article is judged either way, so no rule fires on a guess."
+PLG_WORKFLOW_REMOTECHECK_TIMEOUT_LABEL="Timeout (seconds)"
+PLG_WORKFLOW_REMOTECHECK_XML_DESCRIPTION="Adds an automation condition check whose answer comes from an external web service rather than from the Joomla database, so transitions can be gated on a system outside the site."
diff --git a/plugins/workflow/remotecheck/language/en-GB/plg_workflow_remotecheck.sys.ini b/plugins/workflow/remotecheck/language/en-GB/plg_workflow_remotecheck.sys.ini
new file mode 100644
index 0000000000000..b71caced0aedb
--- /dev/null
+++ b/plugins/workflow/remotecheck/language/en-GB/plg_workflow_remotecheck.sys.ini
@@ -0,0 +1,7 @@
+; Joomla! Project
+; (C) 2026 Open Source Matters, Inc.
+; License GNU General Public License version 2 or later; see LICENSE.txt
+; Note : All ini files need to be saved as UTF-8
+
+PLG_WORKFLOW_REMOTECHECK="Workflow - Remote Check"
+PLG_WORKFLOW_REMOTECHECK_XML_DESCRIPTION="Adds an automation condition check whose answer comes from an external web service rather than from the Joomla database, so transitions can be gated on a system outside the site."
diff --git a/plugins/workflow/remotecheck/remotecheck.xml b/plugins/workflow/remotecheck/remotecheck.xml
new file mode 100644
index 0000000000000..c4b2a9522c98e
--- /dev/null
+++ b/plugins/workflow/remotecheck/remotecheck.xml
@@ -0,0 +1,80 @@
+
+
+ plg_workflow_remotecheck
+ Joomla! Project
+ 2026-08
+ (C) 2026 Open Source Matters, Inc.
+ GNU General Public License version 2 or later; see LICENSE.txt
+ admin@joomla.org
+ www.joomla.org
+ 1.0.0
+ PLG_WORKFLOW_REMOTECHECK_XML_DESCRIPTION
+ Joomla\Plugin\Workflow\RemoteCheck
+
+ services
+ src
+
+
+ language/en-GB/plg_workflow_remotecheck.ini
+ language/en-GB/plg_workflow_remotecheck.sys.ini
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/workflow/remotecheck/services/provider.php b/plugins/workflow/remotecheck/services/provider.php
new file mode 100644
index 0000000000000..a7efd926edfe9
--- /dev/null
+++ b/plugins/workflow/remotecheck/services/provider.php
@@ -0,0 +1,38 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+use Joomla\CMS\Cache\CacheControllerFactoryInterface;
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Plugin\Workflow\RemoteCheck\Extension\RemoteCheck;
+
+\defined('_JEXEC') or die;
+
+return new class () implements ServiceProviderInterface {
+ public function register(Container $container): void
+ {
+ $container->set(
+ PluginInterface::class,
+ $container->lazy(RemoteCheck::class, function (Container $container) {
+ $plugin = new RemoteCheck(
+ (array) PluginHelper::getPlugin('workflow', 'remotecheck')
+ );
+ $plugin->setApplication(Factory::getApplication());
+
+ $plugin->setCacheControllerFactory($container->get(CacheControllerFactoryInterface::class));
+
+ return $plugin;
+ })
+ );
+ }
+};
diff --git a/plugins/workflow/remotecheck/src/Extension/RemoteCheck.php b/plugins/workflow/remotecheck/src/Extension/RemoteCheck.php
new file mode 100644
index 0000000000000..c959eb0f84c24
--- /dev/null
+++ b/plugins/workflow/remotecheck/src/Extension/RemoteCheck.php
@@ -0,0 +1,344 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Plugin\Workflow\RemoteCheck\Extension;
+
+use Joomla\CMS\Cache\CacheControllerFactoryAwareInterface;
+use Joomla\CMS\Cache\CacheControllerFactoryAwareTrait;
+use Joomla\CMS\Event\Workflow\WorkflowConditionFieldsEvent;
+use Joomla\CMS\Event\Workflow\WorkflowResolveFieldsEvent;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\Event\SubscriberInterface;
+use Joomla\Http\HttpFactory;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Supplies a condition check whose answer lives outside Joomla.
+ *
+ * Declared SCOPE_ITEM, because a moment check is asked about many future times. Answers are
+ * cached and fetched in one request per batch. A failed request returns nothing, so the item
+ * is skipped rather than moved on a guess.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+final class RemoteCheck extends CMSPlugin implements SubscriberInterface, CacheControllerFactoryAwareInterface
+{
+ use CacheControllerFactoryAwareTrait;
+
+ /**
+ * The check's name. Prefixed, because this key is stored inside saved rules and must not
+ * collide with a check some other extension names the same.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private const FIELD = 'remotecheck.flag';
+
+ /**
+ * The only workflow extension this service knows anything about.
+ *
+ * @var string
+ * @since __DEPLOY_VERSION__
+ */
+ private const EXTENSION = 'com_content.article';
+
+ /**
+ * Answers already fetched during this request, so evaluating several rules over the same
+ * items does not repeat work the cache would only partly absorb.
+ *
+ * @var array
+ * @since __DEPLOY_VERSION__
+ */
+ private array $memo = [];
+
+ /**
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onWorkflowListConditionFields' => 'listConditionFields',
+ 'onWorkflowResolveConditionFields' => 'resolveConditionFields',
+ ];
+ }
+
+ /**
+ * Offers the check to the condition builder.
+ *
+ * @param WorkflowConditionFieldsEvent $event The collecting event.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function listConditionFields(WorkflowConditionFieldsEvent $event): void
+ {
+ if ($event->getExtension() !== self::EXTENSION) {
+ return;
+ }
+
+ // The builder can ask before this plugin's language file has been auto-loaded.
+ $this->loadLanguage();
+
+ $label = trim((string) $this->params->get('checklabel', ''));
+ $endpoint = trim((string) $this->params->get('endpoint', ''));
+
+ if ($label === '' || $endpoint === '') {
+ return;
+ }
+
+ $event->addField(
+ self::FIELD,
+ $label,
+ WorkflowConditionFieldsEvent::SCOPE_ITEM,
+ [
+ WorkflowConditionFieldsEvent::OPERATOR_IS,
+ WorkflowConditionFieldsEvent::OPERATOR_IS_NOT,
+ ],
+ WorkflowConditionFieldsEvent::VALUE_SELECT,
+ [
+ ['value' => '1', 'label' => Text::_('JYES')],
+ ['value' => '0', 'label' => Text::_('JNO')],
+ ]
+ );
+ }
+
+ /**
+ * Resolves the check for a whole batch of items.
+ *
+ * @param WorkflowResolveFieldsEvent $event The resolving event.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function resolveConditionFields(WorkflowResolveFieldsEvent $event): void
+ {
+ if ($event->getField() !== self::FIELD || $event->getExtension() !== self::EXTENSION) {
+ return;
+ }
+
+ // Always called, even with no values, so the check counts as claimed.
+ $event->setValues($this->flagsFor(array_map('intval', $event->getItemIds())));
+ }
+
+ /**
+ * The flag for each item, from memory, then the cache, then the service.
+ *
+ * @param int[] $itemIds The items being evaluated.
+ *
+ * @return array Item id to '1' or '0'. Items with no answer are absent.
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function flagsFor(array $itemIds): array
+ {
+ $flags = [];
+ $unknown = [];
+
+ foreach (array_unique($itemIds) as $itemId) {
+ if (\array_key_exists($itemId, $this->memo)) {
+ $flags[$itemId] = $this->memo[$itemId];
+
+ continue;
+ }
+
+ $cached = $this->readCache($itemId);
+
+ if ($cached !== null) {
+ $this->memo[$itemId] = $cached;
+ $flags[$itemId] = $cached;
+
+ continue;
+ }
+
+ $unknown[] = $itemId;
+ }
+
+ if ($unknown === []) {
+ return $flags;
+ }
+
+ foreach ($this->askService($unknown) as $itemId => $flag) {
+ $this->memo[$itemId] = $flag;
+ $flags[$itemId] = $flag;
+ $this->writeCache($itemId, $flag);
+ }
+
+ return $flags;
+ }
+
+ /**
+ * Asks the external service about a set of items.
+ *
+ * Sends {"ids": [1, 2, 3]} and expects {"flags": {"1": true, "2": false}} back. Any item
+ * the service does not mention is simply left out of the result.
+ *
+ * @param int[] $itemIds The items to ask about.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function askService(array $itemIds): array
+ {
+ $endpoint = trim((string) $this->params->get('endpoint', ''));
+
+ if ($endpoint === '') {
+ $this->warn('no endpoint is configured, so nothing can be checked');
+
+ return [];
+ }
+
+ try {
+ $response = (new HttpFactory())->getHttp()->post(
+ $endpoint,
+ json_encode(['ids' => array_values($itemIds)]),
+ ['Content-Type' => 'application/json'] + $this->authenticationHeader(),
+ max(1, (int) $this->params->get('timeout', 5))
+ );
+ } catch (\Throwable $failure) {
+ $this->warn('the service could not be reached: ' . $failure->getMessage());
+
+ return [];
+ }
+
+ if ($response->getStatusCode() !== 200) {
+ $this->warn('the service answered with HTTP ' . $response->getStatusCode());
+
+ return [];
+ }
+
+ $decoded = json_decode((string) $response->getBody(), true);
+
+ if (!\is_array($decoded) || !isset($decoded['flags']) || !\is_array($decoded['flags'])) {
+ $this->warn('the service answered with something other than the expected JSON');
+
+ return [];
+ }
+
+ $flags = [];
+
+ foreach ($decoded['flags'] as $itemId => $flag) {
+ // Ignore ids that were not asked about, so the service cannot answer for other items.
+ if (\in_array((int) $itemId, $itemIds, true)) {
+ $flags[(int) $itemId] = $flag ? '1' : '0';
+ }
+ }
+
+ return $flags;
+ }
+
+ /**
+ * The credential header to send, or none when the service needs no authentication.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function authenticationHeader(): array
+ {
+ $header = trim((string) $this->params->get('authheader', 'Authorization'));
+ $value = (string) $this->params->get('authvalue', '');
+
+ if ($header === '' || $value === '') {
+ return [];
+ }
+
+ return [$header => $value];
+ }
+
+ /**
+ * A previously cached flag, or null when there is none.
+ *
+ * @param integer $itemId The item.
+ *
+ * @return string|null
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function readCache(int $itemId): ?string
+ {
+ $cached = $this->cache()->get($this->cacheId($itemId));
+
+ return $cached === false ? null : (string) $cached;
+ }
+
+ /**
+ * Remembers one flag for as long as the configured lifetime.
+ *
+ * @param integer $itemId The item.
+ * @param string $flag '1' or '0'.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function writeCache(int $itemId, string $flag): void
+ {
+ $this->cache()->store($flag, $this->cacheId($itemId));
+ }
+
+ /**
+ * The cache controller, configured with this plugin's lifetime.
+ *
+ * @return \Joomla\CMS\Cache\CacheController
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function cache()
+ {
+ return $this->getCacheControllerFactory()->createCacheController('output', [
+ 'defaultgroup' => 'plg_workflow_remotecheck',
+
+ // Joomla measures this in minutes, not seconds.
+ 'lifetime' => max(1, (int) $this->params->get('cachelifetime', 15)),
+ 'caching' => true,
+ ]);
+ }
+
+ /**
+ * The cache key for one item.
+ *
+ * The endpoint is part of the key, so pointing the plugin at a different service does not
+ * hand back answers the previous one gave.
+ *
+ * @param integer $itemId The item.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function cacheId(int $itemId): string
+ {
+ return md5((string) $this->params->get('endpoint', '')) . '.' . $itemId;
+ }
+
+ /**
+ * Records why an answer is missing, in the same log the automation engine writes to.
+ *
+ * @param string $reason What went wrong.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function warn(string $reason): void
+ {
+ Log::add('Remote check: ' . $reason . '.', Log::WARNING, 'workflow');
+ }
+}